diff --git a/.eslintrc.json b/.eslintrc.json index ccfbd24f4..2f583f2c0 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -6,22 +6,20 @@ }, "extends": [ "eslint:recommended", - "plugin:@typescript-eslint/recommended" + "plugin:@typescript-eslint/recommended", + "plugin:prettier/recommended", + "prettier" ], + "plugins": ["@typescript-eslint", "prefer-arrow", "unused-imports", "prettier"], "parser": "@typescript-eslint/parser", - "plugins": [ - "@typescript-eslint" - ], "root": true, "parserOptions": { "ecmaVersion": 2018, "sourceType": "module" }, "rules": { - "linebreak-style": [ - "error", - "unix" - ], + "prettier/prettier": "error", + "linebreak-style": ["error", "unix"], "quotes": [ "error", "single", @@ -29,18 +27,7 @@ "allowTemplateLiterals": true } ], - "semi": [ - "error", - "never" - ], - "indent": [ - "error", - 2, - { - "SwitchCase": 1, - "flatTernaryExpressions": true - } - ], + "semi": ["error", "never"], // TODO: remove this rule when all the code is fully migrated to TS, atm it just produces a lot of noise "@typescript-eslint/ban-ts-comment": "off", // TODO: remove this rule when all the code is fully migrated to TS, atm it just produces a lot of noise diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 6969094d2..9b1ee3b7c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -21,9 +21,10 @@ jobs: # Store the name of the release # See https://stackoverflow.com/questions/58177786/get-the-current-pushed-tag-in-github-actions - run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV + - run: echo "RELEASE_NPM_TAG=${{ github.event.release.prerelease && 'alpha' || 'latest' }}" >> $GITHUB_ENV - run: npm ci - run: npm version $RELEASE_VERSION --no-git-tag-version - run: npm run build - - run: npm publish --access public + - run: npm publish --access public --tag $RELEASE_NPM_TAG env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 0f6c21fb9..d53b213ac 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -8,8 +8,9 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node: [ 14, 16, 18 ] - name: Node ${{ matrix.node }} + node: [14, 16, 18] + typescript: ['~4.7.4', '~4.8.3', '~4.9.5', '~5.0.4', 'latest'] + name: Node ${{ matrix.node }} / TS ${{ matrix.typescript }} steps: - name: 'Checkout latest code' uses: actions/checkout@v3 @@ -21,8 +22,12 @@ jobs: node-version: ${{ matrix.node }} - name: Install dependencies run: npm ci --legacy-peer-deps + - name: Install TS at correct version + run: npm i typescript@${{ matrix.typescript }} + - name: Run type check + run: npm run v1-check-types - name: Run tests - run: npm run test + run: npm run v1-test check_types: name: 'Check types' @@ -38,12 +43,8 @@ jobs: node-version: '16' - name: Install dependencies run: npm ci --legacy-peer-deps - - name: Build - run: npm run build - - name: Check types - run: npm run check-types - - name: Test types - run: npm run test-types + - name: Check types (v1) + run: npm run v1-check-types lint: name: 'ESLint' @@ -61,3 +62,20 @@ jobs: run: npm ci --legacy-peer-deps - name: Run ESLint run: npm run lint + + # prettier: + # name: 'Prettier' + # runs-on: ubuntu-latest + # steps: + # - name: Checkout latest code + # uses: actions/checkout@v3 + # with: + # ref: ${{ github.event.pull_request.head.sha }} + # - name: Set up node + # uses: actions/setup-node@v3 + # with: + # node-version: '16' + # - name: Install dependencies + # run: npm ci --legacy-peer-deps + # - name: Run Prettier + # run: npm run prettier diff --git a/jest.config.js b/jest.config.js index 60488ca96..825affac8 100644 --- a/jest.config.js +++ b/jest.config.js @@ -5,5 +5,9 @@ module.exports = { coveragePathIgnorePatterns: ['/__tests__/*'], transform: { '^.+\\.(ts|tsx)$': 'ts-jest' + }, + moduleNameMapper: { + '^v1/(.*)$': '/src/v1/$1', + v1: '/src/v1' } } diff --git a/package-lock.json b/package-lock.json index e3fc3a196..417c78d15 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,25 +9,46 @@ "license": "MIT", "dependencies": { "deep-copy": "^1.4.2", + "lodash.clonedeep": "^4.5.0", + "lodash.isempty": "^4.4.0", + "lodash.isequal": "^4.5.0", + "lodash.omit": "^4.5.0", + "lodash.pick": "^4.4.0", "ts-toolbelt": "^9.6.0" }, "devDependencies": { - "@aws-sdk/client-dynamodb": "^3.287.0", - "@aws-sdk/lib-dynamodb": "^3.287.0", + "@aws-sdk/client-dynamodb": "^3.145.0", + "@aws-sdk/lib-dynamodb": "^3.245.0", "@types/jest": "^29.2.1", + "@types/lodash.clonedeep": "^4.5.7", + "@types/lodash.isempty": "^4.4.0", + "@types/lodash.isequal": "^4.5.6", + "@types/lodash.omit": "^4.5.7", + "@types/lodash.pick": "^4.4.9", "@types/node": "^14.14.16", "@typescript-eslint/eslint-plugin": "^5.43.0", "@typescript-eslint/parser": "^5.43.0", "coveralls": "^3.1.0", "eslint": "^8.2.0", + "eslint-config-prettier": "^8.6.0", + "eslint-plugin-prefer-arrow": "^1.2.3", + "eslint-plugin-prettier": "^4.2.1", + "eslint-plugin-unused-imports": "^2.0.0", "jest": "^29.2.2", "prettier": "^2.2.1", "ts-jest": "^29.0.3", + "ts-node": "^10.9.1", + "tsc-alias": "^1.8.2", + "tsconfig-paths": "^4.1.1", "tsd": "^0.23.0", - "typescript": "^4.1.3" + "typescript": "^4.7.4" }, "engines": { "node": ">=14.0.0" + }, + "peerDependencies": { + "@aws-sdk/client-dynamodb": "^3.0.0", + "@aws-sdk/lib-dynamodb": "^3.0.0" } }, "node_modules/@ampproject/remapping": { @@ -52,6 +73,12 @@ "tslib": "^1.11.1" } }, + "node_modules/@aws-crypto/ie11-detection/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true + }, "node_modules/@aws-crypto/sha256-browser": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-3.0.0.tgz", @@ -68,6 +95,12 @@ "tslib": "^1.11.1" } }, + "node_modules/@aws-crypto/sha256-browser/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true + }, "node_modules/@aws-crypto/sha256-js": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-3.0.0.tgz", @@ -79,6 +112,12 @@ "tslib": "^1.11.1" } }, + "node_modules/@aws-crypto/sha256-js/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true + }, "node_modules/@aws-crypto/supports-web-crypto": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-3.0.0.tgz", @@ -88,6 +127,12 @@ "tslib": "^1.11.1" } }, + "node_modules/@aws-crypto/supports-web-crypto/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true + }, "node_modules/@aws-crypto/util": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-3.0.0.tgz", @@ -99,6 +144,12 @@ "tslib": "^1.11.1" } }, + "node_modules/@aws-crypto/util/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true + }, "node_modules/@aws-sdk/abort-controller": { "version": "3.272.0", "resolved": "https://registry.npmjs.org/@aws-sdk/abort-controller/-/abort-controller-3.272.0.tgz", @@ -2094,6 +2145,28 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "node_modules/@eslint/eslintrc": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.0.4.tgz", @@ -2570,6 +2643,30 @@ "@sinonjs/commons": "^1.7.0" } }, + "node_modules/@tsconfig/node10": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz", + "integrity": "sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==", + "dev": true + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true + }, "node_modules/@tsd/typescript": { "version": "4.8.2", "resolved": "https://registry.npmjs.org/@tsd/typescript/-/typescript-4.8.2.tgz", @@ -2682,6 +2779,57 @@ "integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==", "dev": true }, + "node_modules/@types/lodash": { + "version": "4.14.191", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.191.tgz", + "integrity": "sha512-BdZ5BCCvho3EIXw6wUCXHe7rS53AIDPLE+JzwgT+OsJk53oBfbSmZZ7CX4VaRoN78N+TJpFi9QPlfIVNmJYWxQ==", + "dev": true + }, + "node_modules/@types/lodash.clonedeep": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/@types/lodash.clonedeep/-/lodash.clonedeep-4.5.7.tgz", + "integrity": "sha512-ccNqkPptFIXrpVqUECi60/DFxjNKsfoQxSQsgcBJCX/fuX1wgyQieojkcWH/KpE3xzLoWN/2k+ZeGqIN3paSvw==", + "dev": true, + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/lodash.isempty": { + "version": "4.4.7", + "resolved": "https://registry.npmjs.org/@types/lodash.isempty/-/lodash.isempty-4.4.7.tgz", + "integrity": "sha512-YOzlpoIn9jrfHzjIukKnu9Le3tmi+0PhUdOt2rMpJW/4J6jX7s0HeBatXdh9QckLga8qt4EKBxVIEqtEq6pzLg==", + "dev": true, + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/lodash.isequal": { + "version": "4.5.6", + "resolved": "https://registry.npmjs.org/@types/lodash.isequal/-/lodash.isequal-4.5.6.tgz", + "integrity": "sha512-Ww4UGSe3DmtvLLJm2F16hDwEQSv7U0Rr8SujLUA2wHI2D2dm8kPu6Et+/y303LfjTIwSBKXB/YTUcAKpem/XEg==", + "dev": true, + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/lodash.omit": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/@types/lodash.omit/-/lodash.omit-4.5.7.tgz", + "integrity": "sha512-6q6cNg0tQ6oTWjSM+BcYMBhan54P/gLqBldG4AuXd3nKr0oeVekWNS4VrNEu3BhCSDXtGapi7zjhnna0s03KpA==", + "dev": true, + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/lodash.pick": { + "version": "4.4.9", + "resolved": "https://registry.npmjs.org/@types/lodash.pick/-/lodash.pick-4.4.9.tgz", + "integrity": "sha512-hDpr96x9xHClwy1KX4/RXRejqjDFTEGbEMT3t6wYSYeFDzxmMnSKB/xHIbktRlPj8Nii2g8L5dtFDRaNFBEzUQ==", + "dev": true, + "dependencies": { + "@types/lodash": "*" + } + }, "node_modules/@types/minimist": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.2.tgz", @@ -2972,6 +3120,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/acorn-walk": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz", + "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -3061,6 +3218,12 @@ "node": ">= 8" } }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true + }, "node_modules/argparse": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", @@ -3233,6 +3396,15 @@ "tweetnacl": "^0.14.3" } }, + "node_modules/binary-extensions": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", + "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/bowser": { "version": "2.11.0", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz", @@ -3398,6 +3570,45 @@ "node": ">=10" } }, + "node_modules/chokidar": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", + "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/ci-info": { "version": "3.5.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.5.0.tgz", @@ -3470,6 +3681,15 @@ "node": ">= 0.8" } }, + "node_modules/commander": { + "version": "9.4.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.4.1.tgz", + "integrity": "sha512-5EEkTNyHNGFPD2H+c/dXXfQZYa/scCKasxWcXJaWnNJ99pnQN9Vnmqow+p+PlFPE63Q6mThaZws1T+HxfpgtPw==", + "dev": true, + "engines": { + "node": "^12.20.0 || >=14" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -3507,6 +3727,12 @@ "node": ">=6" } }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -3628,6 +3854,15 @@ "node": ">=8" } }, + "node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true, + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/diff-sequences": { "version": "29.2.0", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.2.0.tgz", @@ -3792,6 +4027,18 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint-config-prettier": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.6.0.tgz", + "integrity": "sha512-bAF0eLpLVqP5oEVUFKpMA+NnRFICwn9X8B5jrR9FcqnYBuPbqWEjTEspPWMj5ye6czoSLDweCzSo3Ko7gGrZaA==", + "dev": true, + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, "node_modules/eslint-formatter-pretty": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/eslint-formatter-pretty/-/eslint-formatter-pretty-4.1.0.tgz", @@ -3814,6 +4061,66 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/eslint-plugin-prefer-arrow": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-prefer-arrow/-/eslint-plugin-prefer-arrow-1.2.3.tgz", + "integrity": "sha512-J9I5PKCOJretVuiZRGvPQxCbllxGAV/viI20JO3LYblAodofBxyMnZAJ+WGeClHgANnSJberTNoFWWjrWKBuXQ==", + "dev": true, + "peerDependencies": { + "eslint": ">=2.0.0" + } + }, + "node_modules/eslint-plugin-prettier": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-4.2.1.tgz", + "integrity": "sha512-f/0rXLXUt0oFYs8ra4w49wYZBG5GKZpAYsJSm6rnYL5uVDjd+zowwMwVZHnAjf4edNrKpCDYfXDgmRE/Ak7QyQ==", + "dev": true, + "dependencies": { + "prettier-linter-helpers": "^1.0.0" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "eslint": ">=7.28.0", + "prettier": ">=2.0.0" + }, + "peerDependenciesMeta": { + "eslint-config-prettier": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-unused-imports": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-2.0.0.tgz", + "integrity": "sha512-3APeS/tQlTrFa167ThtP0Zm0vctjr4M44HMpeg1P4bK6wItarumq0Ma82xorMKdFsWpphQBlRPzw/pxiVELX1A==", + "dev": true, + "dependencies": { + "eslint-rule-composer": "^0.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "peerDependencies": { + "@typescript-eslint/eslint-plugin": "^5.0.0", + "eslint": "^8.0.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/eslint-plugin": { + "optional": true + } + } + }, + "node_modules/eslint-rule-composer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/eslint-rule-composer/-/eslint-rule-composer-0.3.0.tgz", + "integrity": "sha512-bt+Sh8CtDmn2OajxvNO+BX7Wn4CIWMpTRm3MaiKPCQcnnlm0CS2mhui6QaoeQugs+3Kj2ESKEEGJUdVafwhiCg==", + "dev": true, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/eslint-rule-docs": { "version": "1.1.235", "resolved": "https://registry.npmjs.org/eslint-rule-docs/-/eslint-rule-docs-1.1.235.tgz", @@ -4025,6 +4332,12 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true }, + "node_modules/fast-diff": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.2.0.tgz", + "integrity": "sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==", + "dev": true + }, "node_modules/fast-glob": { "version": "3.2.11", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.11.tgz", @@ -4528,10 +4841,22 @@ "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", "dev": true }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/is-core-module": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.8.0.tgz", - "integrity": "sha512-vd15qHsaqrRL7dtH6QNuy0ndJmRDrS9HAM1CAiSifNUFv4x1a0CCVsj18hJ1mShxIG6T2i1sO78MkP56r0nYRw==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.11.0.tgz", + "integrity": "sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw==", "dev": true, "dependencies": { "has": "^1.0.3" @@ -5478,6 +5803,21 @@ "node": ">=8" } }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==" + }, + "node_modules/lodash.isempty": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.isempty/-/lodash.isempty-4.4.0.tgz", + "integrity": "sha512-oKMuF3xEeqDltrGMfDxAPGIVMSSRv8tbRSODbrs4KGsRRLEhrW8N8Rd4DRgB2+621hY8A8XwwrTVhXWpxFvMzg==" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -5490,6 +5830,16 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, + "node_modules/lodash.omit": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.omit/-/lodash.omit-4.5.0.tgz", + "integrity": "sha512-XeqSp49hNGmlkj2EJlfrQFIzQ6lXdNro9sddtQzcJY8QaoC2GO0DT7xaIokHeyM+mIT0mPMlPvkYzg2xCuHdZg==" + }, + "node_modules/lodash.pick": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.pick/-/lodash.pick-4.4.0.tgz", + "integrity": "sha512-hXt6Ul/5yWjfklSGvLQl8vM//l3FtyHZeuelpzK6mm99pNvN9yTDruNZPEJZD1oWrqo+izBmB7oUfWgcCX7s4Q==" + }, "node_modules/log-driver": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/log-driver/-/log-driver-1.2.7.tgz", @@ -5632,13 +5982,13 @@ } }, "node_modules/micromatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.4.tgz", - "integrity": "sha512-pRmzw/XUcwXGpD9aI9q/0XOwLNygjETJ8y0ao0wdqprrzDa4YnxLcz7fQRZr8voh8V10kGhABbNcHVk5wHgWwg==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", + "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", "dev": true, "dependencies": { - "braces": "^3.0.1", - "picomatch": "^2.2.3" + "braces": "^3.0.2", + "picomatch": "^2.3.1" }, "engines": { "node": ">=8.6" @@ -5730,6 +6080,19 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "dev": true }, + "node_modules/mylas": { + "version": "2.1.13", + "resolved": "https://registry.npmjs.org/mylas/-/mylas-2.1.13.tgz", + "integrity": "sha512-+MrqnJRtxdF+xngFfUUkIMQrUUL0KsxbADUkn23Z/4ibGg192Q+z+CQyiYwvWTsYjJygmMR8+w3ZDa98Zh6ESg==", + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/raouldeheer" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -5967,9 +6330,9 @@ "dev": true }, "node_modules/picomatch": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.0.tgz", - "integrity": "sha512-lY1Q/PiJGC2zOv/z391WOTD+Z02bCgsFfvxoXXf6h7kv9o+WmsmzYqrAwY63sNgOxE4xEdq0WyUnXfKeBrSvYw==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "dev": true, "engines": { "node": ">=8.6" @@ -5999,6 +6362,15 @@ "node": ">=8" } }, + "node_modules/plimit-lit": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/plimit-lit/-/plimit-lit-1.5.0.tgz", + "integrity": "sha512-Eb/MqCb1Iv/ok4m1FqIXqvUKPISufcjZ605hl3KM/n8GaX8zfhtgdLwZU3vKjuHGh2O9Rjog/bHTq8ofIShdng==", + "dev": true, + "dependencies": { + "queue-lit": "^1.5.0" + } + }, "node_modules/plur": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/plur/-/plur-4.0.0.tgz", @@ -6035,6 +6407,18 @@ "node": ">=10.13.0" } }, + "node_modules/prettier-linter-helpers": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", + "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", + "dev": true, + "dependencies": { + "fast-diff": "^1.1.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/pretty-format": { "version": "29.2.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.2.1.tgz", @@ -6107,6 +6491,12 @@ "node": ">=0.6" } }, + "node_modules/queue-lit": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/queue-lit/-/queue-lit-1.5.0.tgz", + "integrity": "sha512-IslToJ4eiCEE9xwMzq3viOO5nH8sUWUCwoElrhNMozzr9IIt2qqvB4I+uHu/zJTQVqc9R5DFwok4ijNK1pU3fA==", + "dev": true + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -6219,6 +6609,18 @@ "node": ">=8" } }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -6299,13 +6701,17 @@ } }, "node_modules/resolve": { - "version": "1.20.0", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.20.0.tgz", - "integrity": "sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A==", + "version": "1.22.1", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz", + "integrity": "sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==", "dev": true, "dependencies": { - "is-core-module": "^2.2.0", - "path-parse": "^1.0.6" + "is-core-module": "^2.9.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -6682,6 +7088,18 @@ "node": ">=8" } }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -6790,11 +7208,94 @@ "node": ">=12" } }, + "node_modules/ts-node": { + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz", + "integrity": "sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==", + "dev": true, + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, "node_modules/ts-toolbelt": { "version": "9.6.0", "resolved": "https://registry.npmjs.org/ts-toolbelt/-/ts-toolbelt-9.6.0.tgz", "integrity": "sha512-nsZd8ZeNUzukXPlJmTBwUAuABDe/9qtVDelJeT/qW0ow3ZS3BsQJtNkan1802aM9Uf68/Y8ljw86Hu0h5IUW3w==" }, + "node_modules/tsc-alias": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/tsc-alias/-/tsc-alias-1.8.2.tgz", + "integrity": "sha512-ukBkcNekOgwtnSWYLD5QsMX3yQWg7JviAs8zg3qJGgu4LGtY3tsV4G6vnqvOXIDkbC+XL9vbhObWSpRA5/6wbg==", + "dev": true, + "dependencies": { + "chokidar": "^3.5.3", + "commander": "^9.0.0", + "globby": "^11.0.4", + "mylas": "^2.1.9", + "normalize-path": "^3.0.0", + "plimit-lit": "^1.2.6" + }, + "bin": { + "tsc-alias": "dist/bin/index.js" + } + }, + "node_modules/tsconfig-paths": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.1.1.tgz", + "integrity": "sha512-VgPrtLKpRgEAJsMj5Q/I/mXouC6A/7eJ/X4Nuk6o0cRPwBtznYxTCU4FodbexbzH9somBPEXYi0ZkUViUpJ21Q==", + "dev": true, + "dependencies": { + "json5": "^2.2.1", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tsconfig-paths/node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/tsd": { "version": "0.23.0", "resolved": "https://registry.npmjs.org/tsd/-/tsd-0.23.0.tgz", @@ -6888,9 +7389,9 @@ } }, "node_modules/typescript": { - "version": "4.8.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.8.4.tgz", - "integrity": "sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ==", + "version": "4.7.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.7.4.tgz", + "integrity": "sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==", "dev": true, "bin": { "tsc": "bin/tsc", @@ -6951,6 +7452,12 @@ "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==", "dev": true }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true + }, "node_modules/v8-to-istanbul": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.0.1.tgz", @@ -7109,6 +7616,15 @@ "node": ">=12" } }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -7140,6 +7656,14 @@ "dev": true, "requires": { "tslib": "^1.11.1" + }, + "dependencies": { + "tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true + } } }, "@aws-crypto/sha256-browser": { @@ -7156,6 +7680,14 @@ "@aws-sdk/util-locate-window": "^3.0.0", "@aws-sdk/util-utf8-browser": "^3.0.0", "tslib": "^1.11.1" + }, + "dependencies": { + "tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true + } } }, "@aws-crypto/sha256-js": { @@ -7167,6 +7699,14 @@ "@aws-crypto/util": "^3.0.0", "@aws-sdk/types": "^3.222.0", "tslib": "^1.11.1" + }, + "dependencies": { + "tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true + } } }, "@aws-crypto/supports-web-crypto": { @@ -7176,6 +7716,14 @@ "dev": true, "requires": { "tslib": "^1.11.1" + }, + "dependencies": { + "tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true + } } }, "@aws-crypto/util": { @@ -7187,6 +7735,14 @@ "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-utf8-browser": "^3.0.0", "tslib": "^1.11.1" + }, + "dependencies": { + "tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true + } } }, "@aws-sdk/abort-controller": { @@ -8974,6 +9530,27 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, + "@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "requires": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "dependencies": { + "@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "requires": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + } + } + }, "@eslint/eslintrc": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.0.4.tgz", @@ -9360,6 +9937,30 @@ "@sinonjs/commons": "^1.7.0" } }, + "@tsconfig/node10": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz", + "integrity": "sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==", + "dev": true + }, + "@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true + }, + "@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true + }, + "@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true + }, "@tsd/typescript": { "version": "4.8.2", "resolved": "https://registry.npmjs.org/@tsd/typescript/-/typescript-4.8.2.tgz", @@ -9472,6 +10073,57 @@ "integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==", "dev": true }, + "@types/lodash": { + "version": "4.14.191", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.191.tgz", + "integrity": "sha512-BdZ5BCCvho3EIXw6wUCXHe7rS53AIDPLE+JzwgT+OsJk53oBfbSmZZ7CX4VaRoN78N+TJpFi9QPlfIVNmJYWxQ==", + "dev": true + }, + "@types/lodash.clonedeep": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/@types/lodash.clonedeep/-/lodash.clonedeep-4.5.7.tgz", + "integrity": "sha512-ccNqkPptFIXrpVqUECi60/DFxjNKsfoQxSQsgcBJCX/fuX1wgyQieojkcWH/KpE3xzLoWN/2k+ZeGqIN3paSvw==", + "dev": true, + "requires": { + "@types/lodash": "*" + } + }, + "@types/lodash.isempty": { + "version": "4.4.7", + "resolved": "https://registry.npmjs.org/@types/lodash.isempty/-/lodash.isempty-4.4.7.tgz", + "integrity": "sha512-YOzlpoIn9jrfHzjIukKnu9Le3tmi+0PhUdOt2rMpJW/4J6jX7s0HeBatXdh9QckLga8qt4EKBxVIEqtEq6pzLg==", + "dev": true, + "requires": { + "@types/lodash": "*" + } + }, + "@types/lodash.isequal": { + "version": "4.5.6", + "resolved": "https://registry.npmjs.org/@types/lodash.isequal/-/lodash.isequal-4.5.6.tgz", + "integrity": "sha512-Ww4UGSe3DmtvLLJm2F16hDwEQSv7U0Rr8SujLUA2wHI2D2dm8kPu6Et+/y303LfjTIwSBKXB/YTUcAKpem/XEg==", + "dev": true, + "requires": { + "@types/lodash": "*" + } + }, + "@types/lodash.omit": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/@types/lodash.omit/-/lodash.omit-4.5.7.tgz", + "integrity": "sha512-6q6cNg0tQ6oTWjSM+BcYMBhan54P/gLqBldG4AuXd3nKr0oeVekWNS4VrNEu3BhCSDXtGapi7zjhnna0s03KpA==", + "dev": true, + "requires": { + "@types/lodash": "*" + } + }, + "@types/lodash.pick": { + "version": "4.4.9", + "resolved": "https://registry.npmjs.org/@types/lodash.pick/-/lodash.pick-4.4.9.tgz", + "integrity": "sha512-hDpr96x9xHClwy1KX4/RXRejqjDFTEGbEMT3t6wYSYeFDzxmMnSKB/xHIbktRlPj8Nii2g8L5dtFDRaNFBEzUQ==", + "dev": true, + "requires": { + "@types/lodash": "*" + } + }, "@types/minimist": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.2.tgz", @@ -9660,6 +10312,12 @@ "dev": true, "requires": {} }, + "acorn-walk": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz", + "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", + "dev": true + }, "ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -9720,6 +10378,12 @@ "picomatch": "^2.0.4" } }, + "arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true + }, "argparse": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", @@ -9859,6 +10523,12 @@ "tweetnacl": "^0.14.3" } }, + "binary-extensions": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", + "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "dev": true + }, "bowser": { "version": "2.11.0", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz", @@ -9971,6 +10641,33 @@ "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", "dev": true }, + "chokidar": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", + "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", + "dev": true, + "requires": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "fsevents": "~2.3.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "dependencies": { + "glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "requires": { + "is-glob": "^4.0.1" + } + } + } + }, "ci-info": { "version": "3.5.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.5.0.tgz", @@ -10030,6 +10727,12 @@ "delayed-stream": "~1.0.0" } }, + "commander": { + "version": "9.4.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.4.1.tgz", + "integrity": "sha512-5EEkTNyHNGFPD2H+c/dXXfQZYa/scCKasxWcXJaWnNJ99pnQN9Vnmqow+p+PlFPE63Q6mThaZws1T+HxfpgtPw==", + "dev": true + }, "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -10061,6 +10764,12 @@ "request": "^2.88.2" } }, + "create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true + }, "cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -10149,6 +10858,12 @@ "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", "dev": true }, + "diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true + }, "diff-sequences": { "version": "29.2.0", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.2.0.tgz", @@ -10294,6 +11009,13 @@ } } }, + "eslint-config-prettier": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.6.0.tgz", + "integrity": "sha512-bAF0eLpLVqP5oEVUFKpMA+NnRFICwn9X8B5jrR9FcqnYBuPbqWEjTEspPWMj5ye6czoSLDweCzSo3Ko7gGrZaA==", + "dev": true, + "requires": {} + }, "eslint-formatter-pretty": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/eslint-formatter-pretty/-/eslint-formatter-pretty-4.1.0.tgz", @@ -10310,6 +11032,37 @@ "supports-hyperlinks": "^2.0.0" } }, + "eslint-plugin-prefer-arrow": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-prefer-arrow/-/eslint-plugin-prefer-arrow-1.2.3.tgz", + "integrity": "sha512-J9I5PKCOJretVuiZRGvPQxCbllxGAV/viI20JO3LYblAodofBxyMnZAJ+WGeClHgANnSJberTNoFWWjrWKBuXQ==", + "dev": true, + "requires": {} + }, + "eslint-plugin-prettier": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-4.2.1.tgz", + "integrity": "sha512-f/0rXLXUt0oFYs8ra4w49wYZBG5GKZpAYsJSm6rnYL5uVDjd+zowwMwVZHnAjf4edNrKpCDYfXDgmRE/Ak7QyQ==", + "dev": true, + "requires": { + "prettier-linter-helpers": "^1.0.0" + } + }, + "eslint-plugin-unused-imports": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-2.0.0.tgz", + "integrity": "sha512-3APeS/tQlTrFa167ThtP0Zm0vctjr4M44HMpeg1P4bK6wItarumq0Ma82xorMKdFsWpphQBlRPzw/pxiVELX1A==", + "dev": true, + "requires": { + "eslint-rule-composer": "^0.3.0" + } + }, + "eslint-rule-composer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/eslint-rule-composer/-/eslint-rule-composer-0.3.0.tgz", + "integrity": "sha512-bt+Sh8CtDmn2OajxvNO+BX7Wn4CIWMpTRm3MaiKPCQcnnlm0CS2mhui6QaoeQugs+3Kj2ESKEEGJUdVafwhiCg==", + "dev": true + }, "eslint-rule-docs": { "version": "1.1.235", "resolved": "https://registry.npmjs.org/eslint-rule-docs/-/eslint-rule-docs-1.1.235.tgz", @@ -10450,6 +11203,12 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true }, + "fast-diff": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.2.0.tgz", + "integrity": "sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==", + "dev": true + }, "fast-glob": { "version": "3.2.11", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.11.tgz", @@ -10827,10 +11586,19 @@ "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", "dev": true }, + "is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "requires": { + "binary-extensions": "^2.0.0" + } + }, "is-core-module": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.8.0.tgz", - "integrity": "sha512-vd15qHsaqrRL7dtH6QNuy0ndJmRDrS9HAM1CAiSifNUFv4x1a0CCVsj18hJ1mShxIG6T2i1sO78MkP56r0nYRw==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.11.0.tgz", + "integrity": "sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw==", "dev": true, "requires": { "has": "^1.0.3" @@ -11551,6 +12319,21 @@ "p-locate": "^4.1.0" } }, + "lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==" + }, + "lodash.isempty": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.isempty/-/lodash.isempty-4.4.0.tgz", + "integrity": "sha512-oKMuF3xEeqDltrGMfDxAPGIVMSSRv8tbRSODbrs4KGsRRLEhrW8N8Rd4DRgB2+621hY8A8XwwrTVhXWpxFvMzg==" + }, + "lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==" + }, "lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -11563,6 +12346,16 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, + "lodash.omit": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.omit/-/lodash.omit-4.5.0.tgz", + "integrity": "sha512-XeqSp49hNGmlkj2EJlfrQFIzQ6lXdNro9sddtQzcJY8QaoC2GO0DT7xaIokHeyM+mIT0mPMlPvkYzg2xCuHdZg==" + }, + "lodash.pick": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.pick/-/lodash.pick-4.4.0.tgz", + "integrity": "sha512-hXt6Ul/5yWjfklSGvLQl8vM//l3FtyHZeuelpzK6mm99pNvN9yTDruNZPEJZD1oWrqo+izBmB7oUfWgcCX7s4Q==" + }, "log-driver": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/log-driver/-/log-driver-1.2.7.tgz", @@ -11667,13 +12460,13 @@ "dev": true }, "micromatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.4.tgz", - "integrity": "sha512-pRmzw/XUcwXGpD9aI9q/0XOwLNygjETJ8y0ao0wdqprrzDa4YnxLcz7fQRZr8voh8V10kGhABbNcHVk5wHgWwg==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", + "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", "dev": true, "requires": { - "braces": "^3.0.1", - "picomatch": "^2.2.3" + "braces": "^3.0.2", + "picomatch": "^2.3.1" } }, "mime-db": { @@ -11744,6 +12537,12 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "dev": true }, + "mylas": { + "version": "2.1.13", + "resolved": "https://registry.npmjs.org/mylas/-/mylas-2.1.13.tgz", + "integrity": "sha512-+MrqnJRtxdF+xngFfUUkIMQrUUL0KsxbADUkn23Z/4ibGg192Q+z+CQyiYwvWTsYjJygmMR8+w3ZDa98Zh6ESg==", + "dev": true + }, "natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -11927,9 +12726,9 @@ "dev": true }, "picomatch": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.0.tgz", - "integrity": "sha512-lY1Q/PiJGC2zOv/z391WOTD+Z02bCgsFfvxoXXf6h7kv9o+WmsmzYqrAwY63sNgOxE4xEdq0WyUnXfKeBrSvYw==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "dev": true }, "pirates": { @@ -11947,6 +12746,15 @@ "find-up": "^4.0.0" } }, + "plimit-lit": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/plimit-lit/-/plimit-lit-1.5.0.tgz", + "integrity": "sha512-Eb/MqCb1Iv/ok4m1FqIXqvUKPISufcjZ605hl3KM/n8GaX8zfhtgdLwZU3vKjuHGh2O9Rjog/bHTq8ofIShdng==", + "dev": true, + "requires": { + "queue-lit": "^1.5.0" + } + }, "plur": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/plur/-/plur-4.0.0.tgz", @@ -11968,6 +12776,15 @@ "integrity": "sha512-PqyhM2yCjg/oKkFPtTGUojv7gnZAoG80ttl45O6x2Ug/rMJw4wcc9k6aaf2hibP7BGVCCM33gZoGjyvt9mm16Q==", "dev": true }, + "prettier-linter-helpers": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", + "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", + "dev": true, + "requires": { + "fast-diff": "^1.1.2" + } + }, "pretty-format": { "version": "29.2.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.2.1.tgz", @@ -12021,6 +12838,12 @@ "integrity": "sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==", "dev": true }, + "queue-lit": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/queue-lit/-/queue-lit-1.5.0.tgz", + "integrity": "sha512-IslToJ4eiCEE9xwMzq3viOO5nH8sUWUCwoElrhNMozzr9IIt2qqvB4I+uHu/zJTQVqc9R5DFwok4ijNK1pU3fA==", + "dev": true + }, "queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -12102,6 +12925,15 @@ } } }, + "readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "requires": { + "picomatch": "^2.2.1" + } + }, "redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -12165,13 +12997,14 @@ "dev": true }, "resolve": { - "version": "1.20.0", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.20.0.tgz", - "integrity": "sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A==", + "version": "1.22.1", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz", + "integrity": "sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==", "dev": true, "requires": { - "is-core-module": "^2.2.0", - "path-parse": "^1.0.6" + "is-core-module": "^2.9.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" } }, "resolve-cwd": { @@ -12451,6 +13284,12 @@ "supports-color": "^7.0.0" } }, + "supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true + }, "test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -12519,11 +13358,65 @@ } } }, + "ts-node": { + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz", + "integrity": "sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==", + "dev": true, + "requires": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + } + }, "ts-toolbelt": { "version": "9.6.0", "resolved": "https://registry.npmjs.org/ts-toolbelt/-/ts-toolbelt-9.6.0.tgz", "integrity": "sha512-nsZd8ZeNUzukXPlJmTBwUAuABDe/9qtVDelJeT/qW0ow3ZS3BsQJtNkan1802aM9Uf68/Y8ljw86Hu0h5IUW3w==" }, + "tsc-alias": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/tsc-alias/-/tsc-alias-1.8.2.tgz", + "integrity": "sha512-ukBkcNekOgwtnSWYLD5QsMX3yQWg7JviAs8zg3qJGgu4LGtY3tsV4G6vnqvOXIDkbC+XL9vbhObWSpRA5/6wbg==", + "dev": true, + "requires": { + "chokidar": "^3.5.3", + "commander": "^9.0.0", + "globby": "^11.0.4", + "mylas": "^2.1.9", + "normalize-path": "^3.0.0", + "plimit-lit": "^1.2.6" + } + }, + "tsconfig-paths": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.1.1.tgz", + "integrity": "sha512-VgPrtLKpRgEAJsMj5Q/I/mXouC6A/7eJ/X4Nuk6o0cRPwBtznYxTCU4FodbexbzH9somBPEXYi0ZkUViUpJ21Q==", + "dev": true, + "requires": { + "json5": "^2.2.1", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + }, + "dependencies": { + "strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true + } + } + }, "tsd": { "version": "0.23.0", "resolved": "https://registry.npmjs.org/tsd/-/tsd-0.23.0.tgz", @@ -12590,9 +13483,9 @@ "dev": true }, "typescript": { - "version": "4.8.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.8.4.tgz", - "integrity": "sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ==", + "version": "4.7.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.7.4.tgz", + "integrity": "sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==", "dev": true }, "update-browserslist-db": { @@ -12626,6 +13519,12 @@ "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==", "dev": true }, + "v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true + }, "v8-to-istanbul": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.0.1.tgz", @@ -12750,6 +13649,12 @@ "integrity": "sha512-FiNkvbeHzB/syOjIUxFDCnhSfzAL8R5vs40MgLFBorXACCOAEaWu0gRZl14vG8MR9AOJIZbmkjhusqBYZ3HTHw==", "dev": true }, + "yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true + }, "yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 7bce2e3a9..ad3e5ee69 100644 --- a/package.json +++ b/package.json @@ -5,16 +5,26 @@ "main": "dist/index.js", "types": "dist/index.d.ts", "scripts": { - "test": "jest unit", - "test-cov": "jest unit --coverage", + "test": "jest ", + "v1-test": "jest v1", + "test-cov": "jest --coverage", "test-ci": "eslint . && jest unit --coverage && cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js", "test-types": "tsd", "check-types": "tsc --noEmit", - "lint": "eslint .", + "v1-check-types": "tsc --noEmit -p tsconfig.v1.json", + "lint": "eslint src/v1", + "prettier": "prettier --check 'src/**/*.(js|ts)'", + "v1-build": "rm -rf dist && tsc -p tsconfig.v1-build.json && tsc-alias -p tsconfig.json", "build": "rm -rf dist && tsc -p tsconfig.build.json", - "prepublishOnly": "npm test && npm run lint && npm run test-types", + "prepare": "npm run v1-build", + "prepublishOnly": "npm run v1-test && npm run lint && npm run v1-check-types", "changelog": "git log $(git describe --tags --abbrev=0)..HEAD --oneline" }, + "lint-staged": { + "*.(ts|js)": [ + "prettier --write" + ] + }, "license": "MIT", "contributors": [ "ThomasAribart ", @@ -22,6 +32,11 @@ ], "dependencies": { "deep-copy": "^1.4.2", + "lodash.clonedeep": "^4.5.0", + "lodash.isempty": "^4.4.0", + "lodash.isequal": "^4.5.0", + "lodash.omit": "^4.5.0", + "lodash.pick": "^4.4.0", "ts-toolbelt": "^9.6.0" }, "repository": { @@ -41,30 +56,38 @@ "engines": { "node": ">=14.0.0" }, - "peerDependencies": { - "@aws-sdk/lib-dynamodb": "^3.0.0", - "@aws-sdk/client-dynamodb": "^3.0.0" - }, "devDependencies": { + "@aws-sdk/client-dynamodb": "^3.145.0", + "@aws-sdk/lib-dynamodb": "^3.245.0", "@types/jest": "^29.2.1", + "@types/lodash.clonedeep": "^4.5.7", + "@types/lodash.isempty": "^4.4.0", + "@types/lodash.isequal": "^4.5.6", + "@types/lodash.omit": "^4.5.7", + "@types/lodash.pick": "^4.4.9", "@types/node": "^14.14.16", "@typescript-eslint/eslint-plugin": "^5.43.0", "@typescript-eslint/parser": "^5.43.0", - "@aws-sdk/lib-dynamodb": "^3.287.0", - "@aws-sdk/client-dynamodb": "^3.287.0", - "@aws-sdk/util-dynamodb": "^3.287.0", "coveralls": "^3.1.0", "eslint": "^8.2.0", + "eslint-config-prettier": "^8.6.0", + "eslint-plugin-prefer-arrow": "^1.2.3", + "eslint-plugin-prettier": "^4.2.1", + "eslint-plugin-unused-imports": "^2.0.0", "jest": "^29.2.2", "prettier": "^2.2.1", "ts-jest": "^29.0.3", + "ts-node": "^10.9.1", + "tsc-alias": "^1.8.2", + "tsconfig-paths": "^4.1.1", "tsd": "^0.23.0", - "typescript": "^4.1.3" + "typescript": "^4.7.4" + }, + "peerDependencies": { + "@aws-sdk/client-dynamodb": "^3.0.0", + "@aws-sdk/lib-dynamodb": "^3.0.0" }, "files": [ - "dist/index.*", - "dist/constants.*", - "dist/classes/", - "dist/lib/" + "dist" ] } diff --git a/src/__tests__/misc-tests.unit.test.ts b/src/__tests__/misc-tests.unit.test.ts index b6426a7bf..1ebfe7e32 100644 --- a/src/__tests__/misc-tests.unit.test.ts +++ b/src/__tests__/misc-tests.unit.test.ts @@ -23,7 +23,7 @@ describe('Misc Tests (development only)', () => { table } as const) - console.log(TestEntity.schema) + // console.log(TestEntity.schema) // console.log( // await TestEntity.query(1) diff --git a/src/lib/parseEntity.ts b/src/lib/parseEntity.ts index b6c2160cf..65ac42919 100644 --- a/src/lib/parseEntity.ts +++ b/src/lib/parseEntity.ts @@ -145,7 +145,7 @@ export function parseEntity< // Add timestamps if (timestamps) { - (attributes as AttributeDefinitions)[created] = { + ;(attributes as AttributeDefinitions)[created] = { type: 'string', alias: createdAlias, default: () => new Date().toISOString() diff --git a/src/v1/entity/class.ts b/src/v1/entity/class.ts new file mode 100644 index 000000000..adb8af285 --- /dev/null +++ b/src/v1/entity/class.ts @@ -0,0 +1,100 @@ +import type { Schema } from 'v1/schema' +import type { TableV2, PrimaryKey } from 'v1/table' +import type { KeyInput } from 'v1/operations/types' +import type { EntityOperation } from 'v1/operations/class' +import type { If } from 'v1/types/if' +import { DynamoDBToolboxError } from 'v1/errors' + +import type { NeedsKeyCompute } from './generics' +import { + TimestampsOptions, + TimestampsDefaultOptions, + NarrowTimestampsOptions, + doesSchemaValidateTableSchema, + addInternalAttributes, + WithInternalAttributes +} from './utils' + +export class EntityV2< + NAME extends string = string, + TABLE extends TableV2 = TableV2, + SCHEMA extends Schema = Schema, + ENTITY_ATTRIBUTE_NAME extends string = string extends NAME ? string : 'entity', + TIMESTAMPS_OPTIONS extends TimestampsOptions = string extends NAME + ? TimestampsOptions + : TimestampsDefaultOptions +> { + public type: 'entity' + public name: NAME + public table: TABLE + public schema: WithInternalAttributes< + SCHEMA, + TABLE, + ENTITY_ATTRIBUTE_NAME, + NAME, + TIMESTAMPS_OPTIONS + > + public entityAttributeName: ENTITY_ATTRIBUTE_NAME + public timestamps: TIMESTAMPS_OPTIONS + // any is needed for contravariance + public computeKey?: ( + keyInput: Schema extends SCHEMA ? any : KeyInput + ) => PrimaryKey + public build: = EntityOperation>( + operationClass: new (entity: this) => OPERATION_CLASS + ) => OPERATION_CLASS + + /** + * Define an Entity for a given table + * + * @param name string + * @param table Table + * @param schema Schema + * @param computeKey _(optional)_ Transforms key input to primary key + * @param putDefaults _(optional)_ Computes computed defaults + * @param timestamps _(optional)_ Activates internal `created` & `modified` attributes (defaults to `true`) + * @param entityAttributeName _(optional)_ Renames internal entity name string attribute (defaults to `entity`) + */ + constructor({ + name, + table, + schema, + computeKey, + entityAttributeName = 'entity' as ENTITY_ATTRIBUTE_NAME, + timestamps = true as NarrowTimestampsOptions + }: { + name: NAME + table: TABLE + schema: SCHEMA + entityAttributeName?: ENTITY_ATTRIBUTE_NAME + timestamps?: NarrowTimestampsOptions + } & If< + NeedsKeyCompute, + { computeKey: (keyInput: KeyInput) => PrimaryKey
}, + { computeKey?: undefined } + >) { + this.type = 'entity' + this.name = name + this.table = table + this.entityAttributeName = entityAttributeName + this.timestamps = timestamps as TIMESTAMPS_OPTIONS + + if (computeKey === undefined && !doesSchemaValidateTableSchema(schema, table)) { + throw new DynamoDBToolboxError('entity.invalidSchema', { + message: `Entity ${name} schema does not follow its table primary key schema` + }) + } + + // TODO: Simplify now that '.and(...)' method is available? + this.schema = addInternalAttributes({ + schema, + table: this.table, + entityAttributeName: this.entityAttributeName, + entityName: this.name, + timestamps: this.timestamps + }) + + this.computeKey = computeKey as any + this.build = operationClass => new operationClass(this) as any + } +} diff --git a/src/v1/entity/errors.ts b/src/v1/entity/errors.ts new file mode 100644 index 000000000..2f0666132 --- /dev/null +++ b/src/v1/entity/errors.ts @@ -0,0 +1,3 @@ +import type { EntityUtilsErrorBlueprints } from './utils/errors' + +export type EntityErrorBlueprints = EntityUtilsErrorBlueprints diff --git a/src/v1/entity/generics/FormattedItem/attribute.ts b/src/v1/entity/generics/FormattedItem/attribute.ts new file mode 100644 index 000000000..b748745fb --- /dev/null +++ b/src/v1/entity/generics/FormattedItem/attribute.ts @@ -0,0 +1,46 @@ +import type { + Schema, + Attribute, + AnyAttribute, + PrimitiveAttribute, + SetAttribute, + ListAttribute, + MapAttribute, + RecordAttribute, + AnyOfAttribute, + ResolveAnyAttribute, + ResolvePrimitiveAttribute +} from 'v1/schema' +import type { FormattedListAttribute } from './list' +import type { FormattedMapAttribute } from './map' +import type { FormattedRecordAttribute } from './record' +import type { FormattedItemOptions } from './utils' + +/** + * Returned item of a fetch command (GET, QUERY ...) for a given Schema or Attribute + * + * @param Schema Schema | Attribute + * @return Object + */ +export type FormattedAttribute< + SCHEMA extends Schema | Attribute, + OPTIONS extends FormattedItemOptions = FormattedItemOptions +> = SCHEMA extends AnyAttribute + ? ResolveAnyAttribute + : SCHEMA extends PrimitiveAttribute + ? string extends OPTIONS['attributes'] + ? ResolvePrimitiveAttribute + : never + : SCHEMA extends SetAttribute + ? string extends OPTIONS['attributes'] + ? Set> + : never + : SCHEMA extends ListAttribute + ? FormattedListAttribute + : SCHEMA extends Schema | MapAttribute + ? FormattedMapAttribute + : SCHEMA extends RecordAttribute + ? FormattedRecordAttribute + : SCHEMA extends AnyOfAttribute + ? FormattedAttribute + : never diff --git a/src/v1/entity/generics/FormattedItem/entity.ts b/src/v1/entity/generics/FormattedItem/entity.ts new file mode 100644 index 000000000..cc38b7a59 --- /dev/null +++ b/src/v1/entity/generics/FormattedItem/entity.ts @@ -0,0 +1,23 @@ +import type { AnyAttributePath } from 'v1/operations/types' + +import type { EntityV2 } from '../../class' +import type { FormattedAttribute } from './attribute' + +/** + * Returned item of a fetch command (GET, QUERY ...) for a given Entity + * + * @param Entity Entity + * @return Object + */ +export type FormattedItem< + ENTITY extends EntityV2, + OPTIONS extends { attributes?: AnyAttributePath; partial?: boolean } = {} +> = FormattedAttribute< + ENTITY['schema'], + { + attributes: OPTIONS extends { attributes: string } + ? OPTIONS['attributes'] + : AnyAttributePath + partial: OPTIONS extends { partial: boolean } ? OPTIONS['partial'] : false + } +> diff --git a/src/v1/entity/generics/FormattedItem/index.ts b/src/v1/entity/generics/FormattedItem/index.ts new file mode 100644 index 000000000..71d6b2b53 --- /dev/null +++ b/src/v1/entity/generics/FormattedItem/index.ts @@ -0,0 +1,2 @@ +export type { FormattedItem } from './entity' +export type { FormattedAttribute } from './attribute' diff --git a/src/v1/entity/generics/FormattedItem/list.ts b/src/v1/entity/generics/FormattedItem/list.ts new file mode 100644 index 000000000..60d4ad1ba --- /dev/null +++ b/src/v1/entity/generics/FormattedItem/list.ts @@ -0,0 +1,27 @@ +import type { O } from 'ts-toolbelt' + +import type { ListAttribute } from 'v1/schema' + +import type { FormattedAttribute } from './attribute' +import type { FormattedItemOptions } from './utils' + +export type FormattedListAttribute< + LIST_ATTRIBUTE extends ListAttribute, + OPTIONS extends FormattedItemOptions = FormattedItemOptions, + FORMATTED_ATTRIBUTE = FormattedAttribute< + LIST_ATTRIBUTE['elements'], + O.Update< + OPTIONS, + 'attributes', + string extends OPTIONS['attributes'] + ? string + : OPTIONS['attributes'] extends `[${number}]` + ? string + : OPTIONS['attributes'] extends `[${number}]${infer CHILDREN_FILTERED_ATTRIBUTES}` + ? CHILDREN_FILTERED_ATTRIBUTES + : never + > + > +> = + // Possible in case of anyOf subSchema + [FORMATTED_ATTRIBUTE] extends [never] ? never : FORMATTED_ATTRIBUTE[] diff --git a/src/v1/entity/generics/FormattedItem/map.ts b/src/v1/entity/generics/FormattedItem/map.ts new file mode 100644 index 000000000..d8fc048b1 --- /dev/null +++ b/src/v1/entity/generics/FormattedItem/map.ts @@ -0,0 +1,55 @@ +import type { O } from 'ts-toolbelt' +import type { Schema, AtLeastOnce, Always, MapAttribute } from 'v1/schema' +import type { If } from 'v1/types/if' + +import type { FormattedAttribute } from './attribute' +import type { MatchKeys } from './utils' +import type { FormattedItemOptions } from './utils' + +export type FormattedMapAttribute< + SCHEMA extends Schema | MapAttribute, + OPTIONS extends FormattedItemOptions = FormattedItemOptions, + KEY_PREFIX extends string = SCHEMA extends Schema + ? '' + : SCHEMA extends MapAttribute + ? '.' + : never, + MATCHING_KEYS extends string = MatchKeys< + Extract, + KEY_PREFIX, + OPTIONS['attributes'] + > +> = + // Possible in case of anyOf subSchema + [MATCHING_KEYS] extends [never] + ? never + : O.Required< + O.Partial< + { + // Keep only non-hidden attributes + [KEY in O.SelectKeys< + // Pick only filtered keys + O.Pick, + { hidden: false } + >]: FormattedAttribute< + SCHEMA['attributes'][KEY], + O.Update< + OPTIONS, + 'attributes', + `${KEY_PREFIX}${KEY}` extends OPTIONS['attributes'] + ? string + : OPTIONS['attributes'] extends `${KEY_PREFIX}${KEY}${infer CHILDREN_FILTERED_ATTRIBUTES}` + ? CHILDREN_FILTERED_ATTRIBUTES + : never + > + > + } + >, + // Do not enforce any attribute if partial is true + If< + OPTIONS['partial'], + never, + // Enforce Required attributes + O.SelectKeys + > + > diff --git a/src/v1/entity/generics/FormattedItem/record.ts b/src/v1/entity/generics/FormattedItem/record.ts new file mode 100644 index 000000000..7517e629c --- /dev/null +++ b/src/v1/entity/generics/FormattedItem/record.ts @@ -0,0 +1,37 @@ +import type { O } from 'ts-toolbelt' + +import type { RecordAttribute, ResolvePrimitiveAttribute } from 'v1/schema' +import type { FormattedAttribute } from './attribute' +import type { MatchKeys } from './utils' +import type { FormattedItemOptions } from './utils' + +export type FormattedRecordAttribute< + RECORD_ATTRIBUTE extends RecordAttribute, + OPTIONS extends FormattedItemOptions = FormattedItemOptions, + MATCHING_KEYS extends string = MatchKeys< + ResolvePrimitiveAttribute, + '.', + OPTIONS['attributes'] + > +> = + // Possible in case of anyOf subSchema + [MATCHING_KEYS] extends [never] + ? never + : { + [KEY in MATCHING_KEYS]?: FormattedAttribute< + RECORD_ATTRIBUTE['elements'], + O.Update< + OPTIONS, + 'attributes', + MATCHING_KEYS extends infer FILTERED_KEY + ? FILTERED_KEY extends string + ? `.${FILTERED_KEY}` extends OPTIONS['attributes'] + ? string + : OPTIONS['attributes'] extends `.${FILTERED_KEY}${infer CHILDREN_FILTERED_ATTRIBUTES}` + ? CHILDREN_FILTERED_ATTRIBUTES + : never + : never + : never + > + > + } diff --git a/src/v1/entity/generics/FormattedItem/utils.ts b/src/v1/entity/generics/FormattedItem/utils.ts new file mode 100644 index 000000000..12fb6878f --- /dev/null +++ b/src/v1/entity/generics/FormattedItem/utils.ts @@ -0,0 +1,15 @@ +export type MatchKeys< + KEYS extends string, + KEY_PREFIX extends string, + FILTERED_ATTRIBUTES extends string +> = string extends FILTERED_ATTRIBUTES + ? KEYS + : KEYS extends infer KEY + ? KEY extends string + ? FILTERED_ATTRIBUTES extends `${KEY_PREFIX}${KEY}${string}` + ? KEY + : never + : never + : never + +export type FormattedItemOptions = { attributes: string; partial: boolean } diff --git a/src/v1/entity/generics/NeedsKeyCompute.ts b/src/v1/entity/generics/NeedsKeyCompute.ts new file mode 100644 index 000000000..0fd002d56 --- /dev/null +++ b/src/v1/entity/generics/NeedsKeyCompute.ts @@ -0,0 +1,44 @@ +import type { O } from 'ts-toolbelt' + +import type { Or } from 'v1/types/or' +import type { Schema, Always } from 'v1/schema' +import type { TableV2, IndexableKeyType, Key } from 'v1/table' + +type NeedsKeyPartCompute< + SCHEMA extends Schema, + KEY_PART_NAME extends string, + KEY_PART_TYPE extends IndexableKeyType +> = SCHEMA['attributes'] extends Record< + KEY_PART_NAME, + { type: KEY_PART_TYPE; required: Always; key: true; savedAs: undefined } +> + ? false + : O.SelectKeys< + SCHEMA['attributes'], + { type: KEY_PART_TYPE; required: Always; key: true; savedAs: KEY_PART_NAME } + > extends never + ? true + : false + +/** + * Wether the provided schema matches the primary key of a given table + * + * @param SCHEMA Schema + * @param TABLE Table + * @return Boolean + */ +export type NeedsKeyCompute< + SCHEMA extends Schema, + TABLE extends TableV2 +> = Key extends TABLE['sortKey'] + ? NeedsKeyPartCompute + : NonNullable extends Key + ? Or< + NeedsKeyPartCompute, + NeedsKeyPartCompute< + SCHEMA, + NonNullable['name'], + NonNullable['type'] + > + > + : never diff --git a/src/v1/entity/generics/SavedItem.ts b/src/v1/entity/generics/SavedItem.ts new file mode 100644 index 000000000..3d65aa160 --- /dev/null +++ b/src/v1/entity/generics/SavedItem.ts @@ -0,0 +1,78 @@ +import type { O } from 'ts-toolbelt' + +import type { + Schema, + Attribute, + AnyAttribute, + PrimitiveAttribute, + SetAttribute, + ListAttribute, + MapAttribute, + SchemaAttributes, + RecordAttribute, + AnyOfAttribute, + AtLeastOnce, + Always, + ResolveAnyAttribute, + ResolvePrimitiveAttribute, + ResolvePrimitiveAttributeType +} from 'v1/schema' +import type { PrimaryKey } from 'v1/table' + +import type { EntityV2 } from '../class' + +/** + * Swaps the key of a attributes dictionnary for their "savedAs" values if they exist + * Leave the attribute as is otherwise + * + * @param MapAttributeAttributesInput MapAttribute Attributes + * @return MapAttribute Attributes + * @example + * SwapWithSavedAs<{ keyA: { ...attribute, savedAs: "keyB" }}> + * => { keyB: { ...attribute, savedAs: "keyB" }} + */ +type SwapWithSavedAs = { + [ATTRIBUTE_NAME in keyof MAP_ATTRIBUTE_ATTRIBUTES as MAP_ATTRIBUTE_ATTRIBUTES[ATTRIBUTE_NAME]['savedAs'] extends string + ? MAP_ATTRIBUTE_ATTRIBUTES[ATTRIBUTE_NAME]['savedAs'] + : ATTRIBUTE_NAME]: MAP_ATTRIBUTE_ATTRIBUTES[ATTRIBUTE_NAME] +} + +type RecSavedItem< + SCHEMA extends Schema | MapAttribute, + SWAPPED_ATTRIBUTES extends SchemaAttributes = SwapWithSavedAs +> = O.Required< + O.Partial< + { + // Keep all attributes + [KEY in keyof SWAPPED_ATTRIBUTES]: SavedItem + } + >, + // Enforce Required attributes + O.SelectKeys +> + +/** + * Shape of saved item in DynamoDB for a given Entity, Schema or Attribute + * + * @param Schema Entity | Schema | Attribute + * @return Object + */ +export type SavedItem = SCHEMA extends AnyAttribute + ? ResolveAnyAttribute + : SCHEMA extends PrimitiveAttribute + ? SCHEMA extends { transform: undefined } + ? ResolvePrimitiveAttribute + : ResolvePrimitiveAttributeType + : SCHEMA extends SetAttribute + ? Set> + : SCHEMA extends ListAttribute + ? SavedItem[] + : SCHEMA extends MapAttribute | Schema + ? RecSavedItem + : SCHEMA extends RecordAttribute + ? { [KEY in ResolvePrimitiveAttribute]?: SavedItem } + : SCHEMA extends AnyOfAttribute + ? SavedItem + : SCHEMA extends EntityV2 + ? SavedItem & PrimaryKey + : never diff --git a/src/v1/entity/generics/index.ts b/src/v1/entity/generics/index.ts new file mode 100644 index 000000000..954a4bb4a --- /dev/null +++ b/src/v1/entity/generics/index.ts @@ -0,0 +1,3 @@ +export type { NeedsKeyCompute } from './NeedsKeyCompute' +export type { FormattedItem, FormattedAttribute } from './FormattedItem' +export type { SavedItem } from './SavedItem' diff --git a/src/v1/entity/index.ts b/src/v1/entity/index.ts new file mode 100644 index 000000000..f968d68b1 --- /dev/null +++ b/src/v1/entity/index.ts @@ -0,0 +1,2 @@ +export * from './generics' +export { EntityV2 } from './class' diff --git a/src/v1/entity/utils/addEntityAttribute.ts b/src/v1/entity/utils/addEntityAttribute.ts new file mode 100644 index 000000000..266fe618d --- /dev/null +++ b/src/v1/entity/utils/addEntityAttribute.ts @@ -0,0 +1,91 @@ +import type { Schema, AtLeastOnce, PrimitiveAttribute } from 'v1/schema' +import type { EntityAttributeSavedAs, TableV2 } from 'v1/table' +import { DynamoDBToolboxError } from 'v1/errors' +import { $get } from 'v1/operations/updateItem/utils' + +import { WithInternalAttribute, addInternalAttribute } from './addInternalAttribute' + +export type EntityAttribute
= PrimitiveAttribute< + 'string', + { + required: AtLeastOnce + hidden: true + key: false + savedAs: EntityAttributeSavedAs
+ enum: [ENTITY_NAME] + defaults: { + key: undefined + put: unknown + update: unknown + } + transform: undefined + } +> + +export type WithEntityAttribute< + SCHEMA extends Schema, + TABLE extends TableV2, + ENTITY_ATTRIBUTE_NAME extends string, + ENTITY_NAME extends string +> = string extends ENTITY_NAME + ? SCHEMA + : WithInternalAttribute> + +type EntityAttributeAdder = < + SCHEMA extends Schema, + TABLE extends TableV2, + ENTITY_ATTRIBUTE_NAME extends string, + ENTITY_NAME extends string +>(props: { + schema: SCHEMA + table: TABLE + entityAttributeName: ENTITY_ATTRIBUTE_NAME + entityName: ENTITY_NAME +}) => WithEntityAttribute + +export const addEntityAttribute: EntityAttributeAdder = < + SCHEMA extends Schema, + TABLE extends TableV2, + ENTITY_ATTRIBUTE_NAME extends string, + ENTITY_NAME extends string +>({ + schema, + table, + entityAttributeName, + entityName +}: { + schema: SCHEMA + table: TABLE + entityAttributeName: ENTITY_ATTRIBUTE_NAME + entityName: ENTITY_NAME +}) => { + if (entityAttributeName in schema.attributes) { + throw new DynamoDBToolboxError('entity.reservedAttributeName', { + message: `${entityAttributeName} is a reserved attribute name. Use a different attribute name or set a different entityAttributeName option in your Entity constructor.`, + path: entityAttributeName + }) + } + + const entityAttribute: EntityAttribute = { + path: entityAttributeName, + type: 'string', + required: 'atLeastOnce', + hidden: true, + key: false, + savedAs: table.entityAttributeSavedAs, + enum: [entityName], + defaults: { + key: undefined, + put: entityName, + update: () => $get(entityAttributeName, entityName) + }, + transform: undefined + } + + return addInternalAttribute(schema, entityAttributeName, entityAttribute) as WithEntityAttribute< + SCHEMA, + TABLE, + ENTITY_ATTRIBUTE_NAME, + ENTITY_NAME + > +} diff --git a/src/v1/entity/utils/addInternalAttribute.ts b/src/v1/entity/utils/addInternalAttribute.ts new file mode 100644 index 000000000..a96ea1504 --- /dev/null +++ b/src/v1/entity/utils/addInternalAttribute.ts @@ -0,0 +1,52 @@ +import { DynamoDBToolboxError } from 'v1/errors' +import type { Schema, Attribute } from 'v1/schema' +import { addProperty } from 'v1/utils/addProperty' + +export type WithInternalAttribute< + SCHEMA extends Schema, + ATTRIBUTE_NAME extends string, + ATTRIBUTE extends Attribute +> = Schema & Record> + +export const addInternalAttribute = < + SCHEMA extends Schema, + ATTRIBUTE_NAME extends string, + ATTRIBUTE extends Attribute +>( + schema: SCHEMA, + attributeName: ATTRIBUTE_NAME, + attribute: ATTRIBUTE +): WithInternalAttribute => { + if (attributeName in schema.attributes) { + throw new DynamoDBToolboxError('entity.reservedAttributeName', { + message: `'${attributeName}' is a reserved attribute name.`, + path: attributeName + }) + } + + if (attribute.savedAs !== undefined && attribute.savedAs in schema.savedAttributeNames) { + throw new DynamoDBToolboxError('entity.reservedAttributeSavedAs', { + message: `'${attribute.savedAs}' is a reserved attribute alias (savedAs).`, + path: attributeName + }) + } + + schema.attributes = addProperty( + schema.attributes, + attributeName, + attribute + ) + + if (attribute.savedAs !== undefined) { + schema.savedAttributeNames.add(attribute.savedAs) + } + + if (attribute.key) { + schema.keyAttributeNames.add(attributeName) + } + + schema.requiredAttributeNames[attribute.required].add(attributeName) + + // schema is actually muted but TS doesn't recognize it + return (schema as unknown) as WithInternalAttribute +} diff --git a/src/v1/entity/utils/addInternalAttributes.ts b/src/v1/entity/utils/addInternalAttributes.ts new file mode 100644 index 000000000..a40f6341f --- /dev/null +++ b/src/v1/entity/utils/addInternalAttributes.ts @@ -0,0 +1,95 @@ +import type { Schema } from 'v1/schema' +import type { TableV2 } from 'v1/table' + +import { WithEntityAttribute, addEntityAttribute } from './addEntityAttribute' +import { + WithTimestampAttributes, + addTimestampAttributes, + TimestampsOptions +} from './addTimestampAttributes' + +export type WithInternalAttributes< + SCHEMA extends Schema, + TABLE extends TableV2, + ENTITY_ATTRIBUTE_NAME extends string, + ENTITY_NAME extends string, + TIMESTAMP_OPTIONS extends TimestampsOptions +> = string extends ENTITY_NAME + ? SCHEMA + : TIMESTAMP_OPTIONS extends false + ? WithEntityAttribute + : WithTimestampAttributes< + WithEntityAttribute, + ENTITY_NAME, + TIMESTAMP_OPTIONS + > + +type InternalAttributesAdder = < + SCHEMA extends Schema, + TABLE extends TableV2, + ENTITY_ATTRIBUTE_NAME extends string, + ENTITY_NAME extends string, + TIMESTAMP_OPTIONS extends TimestampsOptions +>({ + schema, + table, + entityAttributeName, + entityName +}: { + schema: SCHEMA + table: TABLE + entityAttributeName: ENTITY_ATTRIBUTE_NAME + entityName: ENTITY_NAME + timestamps: TIMESTAMP_OPTIONS +}) => WithInternalAttributes + +export const addInternalAttributes: InternalAttributesAdder = < + SCHEMA extends Schema, + TABLE extends TableV2, + ENTITY_ATTRIBUTE_NAME extends string, + ENTITY_NAME extends string, + TIMESTAMP_OPTIONS extends TimestampsOptions +>({ + schema, + table, + entityAttributeName, + entityName, + timestamps +}: { + schema: SCHEMA + table: TABLE + entityAttributeName: ENTITY_ATTRIBUTE_NAME + entityName: ENTITY_NAME + timestamps: TIMESTAMP_OPTIONS +}) => { + const withEntityAttribute = addEntityAttribute({ + schema, + table, + entityAttributeName, + entityName + }) + + if (timestamps === false) { + return withEntityAttribute as WithInternalAttributes< + SCHEMA, + TABLE, + ENTITY_ATTRIBUTE_NAME, + ENTITY_NAME, + TIMESTAMP_OPTIONS + > + } + + const withTimestampAttributes = addTimestampAttributes({ + schema: withEntityAttribute, + entityName, + timestamps + }) + + return withTimestampAttributes as WithInternalAttributes< + SCHEMA, + TABLE, + ENTITY_ATTRIBUTE_NAME, + ENTITY_NAME, + TIMESTAMP_OPTIONS + > +} diff --git a/src/v1/entity/utils/addTimestampAttributes/addTimestampAttributes.ts b/src/v1/entity/utils/addTimestampAttributes/addTimestampAttributes.ts new file mode 100644 index 000000000..782230212 --- /dev/null +++ b/src/v1/entity/utils/addTimestampAttributes/addTimestampAttributes.ts @@ -0,0 +1,143 @@ +import type { Schema } from 'v1/schema' +import type { If } from 'v1/types/if' +import { $get } from 'v1/operations/updateItem/utils' + +import { WithInternalAttribute, addInternalAttribute } from '../addInternalAttribute' + +import type { TimestampsOptions } from './timestampOptions' +import type { TimestampAttribute } from './timestampAttribute' +import { + IsTimestampEnabled, + isTimestampEnabled, + TimestampOptionValue, + getTimestampOptionValue +} from './utils' + +export type WithTimestampAttributes< + SCHEMA extends Schema, + ENTITY_NAME extends string, + TIMESTAMP_OPTIONS extends TimestampsOptions, + IS_CREATED_ENABLED extends boolean = IsTimestampEnabled, + CREATED_NAME extends string = TimestampOptionValue, + CREATED_SAVED_AS extends string = TimestampOptionValue, + CREATED_HIDDEN extends boolean = TimestampOptionValue, + IS_MODIFIED_ENABLED extends boolean = IsTimestampEnabled, + MODIFIED_NAME extends string = TimestampOptionValue, + MODIFIED_SAVED_AS extends string = TimestampOptionValue, + MODIFIED_HIDDEN extends boolean = TimestampOptionValue +> = string extends ENTITY_NAME + ? SCHEMA + : If< + IS_CREATED_ENABLED, + If< + IS_MODIFIED_ENABLED, + WithInternalAttribute< + WithInternalAttribute< + SCHEMA, + CREATED_NAME, + TimestampAttribute + >, + MODIFIED_NAME, + TimestampAttribute + >, + WithInternalAttribute< + SCHEMA, + CREATED_NAME, + TimestampAttribute + > + >, + If< + IS_MODIFIED_ENABLED, + WithInternalAttribute< + SCHEMA, + MODIFIED_NAME, + TimestampAttribute + >, + SCHEMA + > + > + +type TimestampAttributesAdder = < + SCHEMA extends Schema, + ENTITY_NAME extends string, + TIMESTAMP_OPTIONS extends TimestampsOptions +>(props: { + schema: SCHEMA + entityName: ENTITY_NAME + timestamps: TIMESTAMP_OPTIONS +}) => WithTimestampAttributes + +export const addTimestampAttributes: TimestampAttributesAdder = < + SCHEMA extends Schema, + ENTITY_NAME extends string, + TIMESTAMP_OPTIONS extends TimestampsOptions +>({ + schema, + timestamps: $timestamps +}: { + schema: SCHEMA + entityName: ENTITY_NAME + timestamps: TIMESTAMP_OPTIONS +}) => { + let schemaWithTimestamps: Schema = schema + + const timestamps = $timestamps as TIMESTAMP_OPTIONS + + const isCreatedEnable = isTimestampEnabled(timestamps, 'created') + if (isCreatedEnable) { + const createdName = getTimestampOptionValue(timestamps, 'created', 'name') + + const createdAttribute: TimestampAttribute< + TimestampOptionValue, + TimestampOptionValue + > = { + path: createdName, + type: 'string', + required: 'atLeastOnce', + hidden: getTimestampOptionValue(timestamps, 'created', 'hidden'), + key: false, + savedAs: getTimestampOptionValue(timestamps, 'created', 'savedAs'), + enum: undefined, + defaults: { + key: undefined, + put: () => new Date().toISOString(), + update: () => $get(createdName, new Date().toISOString()) + }, + transform: undefined + } + + schemaWithTimestamps = addInternalAttribute(schemaWithTimestamps, createdName, createdAttribute) + } + + const isModifiedEnable = isTimestampEnabled(timestamps, 'modified') + if (isModifiedEnable) { + const modifiedName = getTimestampOptionValue(timestamps, 'modified', 'name') + + const modifiedAttribute: TimestampAttribute< + TimestampOptionValue, + TimestampOptionValue + > = { + path: modifiedName, + type: 'string', + required: 'atLeastOnce', + hidden: getTimestampOptionValue(timestamps, 'modified', 'hidden'), + key: false, + savedAs: getTimestampOptionValue(timestamps, 'modified', 'savedAs'), + enum: undefined, + defaults: { + key: undefined, + put: () => new Date().toISOString(), + update: () => new Date().toISOString() + }, + transform: undefined + } + + schemaWithTimestamps = addInternalAttribute( + schemaWithTimestamps, + modifiedName, + modifiedAttribute + ) + } + + return schemaWithTimestamps as WithTimestampAttributes +} diff --git a/src/v1/entity/utils/addTimestampAttributes/index.ts b/src/v1/entity/utils/addTimestampAttributes/index.ts new file mode 100644 index 000000000..6d08a932d --- /dev/null +++ b/src/v1/entity/utils/addTimestampAttributes/index.ts @@ -0,0 +1,4 @@ +export type { TimestampsOptions, NarrowTimestampsOptions } from './timestampOptions' +export type { TimestampsDefaultOptions } from './timestampDefaultOptions' +export { addTimestampAttributes } from './addTimestampAttributes' +export type { WithTimestampAttributes } from './addTimestampAttributes' diff --git a/src/v1/entity/utils/addTimestampAttributes/timestampAttribute.ts b/src/v1/entity/utils/addTimestampAttributes/timestampAttribute.ts new file mode 100644 index 000000000..5a135dd6f --- /dev/null +++ b/src/v1/entity/utils/addTimestampAttributes/timestampAttribute.ts @@ -0,0 +1,21 @@ +import type { AtLeastOnce, PrimitiveAttribute } from 'v1/schema' + +export type TimestampAttribute< + SAVED_AS extends string, + HIDDEN extends boolean +> = PrimitiveAttribute< + 'string', + { + required: AtLeastOnce + hidden: HIDDEN + key: false + savedAs: SAVED_AS + enum: undefined + defaults: { + key: undefined + put: unknown + update: unknown + } + transform: undefined + } +> diff --git a/src/v1/entity/utils/addTimestampAttributes/timestampDefaultOptions.ts b/src/v1/entity/utils/addTimestampAttributes/timestampDefaultOptions.ts new file mode 100644 index 000000000..5202b46d3 --- /dev/null +++ b/src/v1/entity/utils/addTimestampAttributes/timestampDefaultOptions.ts @@ -0,0 +1,17 @@ +export type TimestampsDefaultOptions = { + created: { + name: 'created' + savedAs: '_ct' + hidden: false + } + modified: { + name: 'modified' + savedAs: '_md' + hidden: false + } +} + +export const TIMESTAMPS_DEFAULTS_OPTIONS: TimestampsDefaultOptions = { + created: { name: 'created', savedAs: '_ct', hidden: false }, + modified: { name: 'modified', savedAs: '_md', hidden: false } +} diff --git a/src/v1/entity/utils/addTimestampAttributes/timestampOptions.ts b/src/v1/entity/utils/addTimestampAttributes/timestampOptions.ts new file mode 100644 index 000000000..0dab42c4b --- /dev/null +++ b/src/v1/entity/utils/addTimestampAttributes/timestampOptions.ts @@ -0,0 +1,18 @@ +export type TimestampObjectOptions = { + name?: string + savedAs?: string + hidden?: boolean +} + +export type TimestampsObjectOptions = { + created: boolean | TimestampObjectOptions + modified: boolean | TimestampObjectOptions +} + +export type TimestampsOptions = boolean | TimestampsObjectOptions + +export type NarrowTimestampsOptions = + | (TIMESTAMP_OPTIONS extends boolean | string ? TIMESTAMP_OPTIONS : never) + | { + [KEY in keyof TIMESTAMP_OPTIONS]: NarrowTimestampsOptions + } diff --git a/src/v1/entity/utils/addTimestampAttributes/utils/getTimestampOptionValue.ts b/src/v1/entity/utils/addTimestampAttributes/utils/getTimestampOptionValue.ts new file mode 100644 index 000000000..8d01009e4 --- /dev/null +++ b/src/v1/entity/utils/addTimestampAttributes/utils/getTimestampOptionValue.ts @@ -0,0 +1,41 @@ +import type { TimestampsOptions } from '../timestampOptions' +import { TIMESTAMPS_DEFAULTS_OPTIONS, TimestampsDefaultOptions } from '../timestampDefaultOptions' + +import { isTimestampsObjectOptions } from './isTimestampsObjectOptions' +import { isTimestampObjectOptions } from './isTimestampObjectOptions' + +export type TimestampOptionValue< + TIMESTAMP_OPTIONS extends TimestampsOptions, + TIMESTAMP_KEY extends 'created' | 'modified', + OPTION_KEY extends 'name' | 'savedAs' | 'hidden' +> = TIMESTAMP_OPTIONS extends { [KEY in TIMESTAMP_KEY]: { [KEY in OPTION_KEY]: unknown } } + ? TIMESTAMP_OPTIONS[TIMESTAMP_KEY][OPTION_KEY] + : TimestampsDefaultOptions[TIMESTAMP_KEY][OPTION_KEY] + +export const getTimestampOptionValue = < + TIMESTAMP_OPTIONS extends TimestampsOptions, + TIMESTAMP_KEY extends 'created' | 'modified', + OPTION_KEY extends 'name' | 'savedAs' | 'hidden' +>( + timestampsOptions: TIMESTAMP_OPTIONS, + timestampKey: TIMESTAMP_KEY, + optionKey: OPTION_KEY +): TimestampOptionValue => { + const defaultOptions = TIMESTAMPS_DEFAULTS_OPTIONS[timestampKey][ + optionKey + ] as TimestampOptionValue + + if (isTimestampsObjectOptions(timestampsOptions)) { + const timestampOptions = timestampsOptions[timestampKey] + + return isTimestampObjectOptions(timestampOptions) + ? (timestampOptions[optionKey] as TimestampOptionValue< + TIMESTAMP_OPTIONS, + TIMESTAMP_KEY, + OPTION_KEY + >) ?? defaultOptions + : defaultOptions + } + + return defaultOptions +} diff --git a/src/v1/entity/utils/addTimestampAttributes/utils/index.ts b/src/v1/entity/utils/addTimestampAttributes/utils/index.ts new file mode 100644 index 000000000..8fdf9e54d --- /dev/null +++ b/src/v1/entity/utils/addTimestampAttributes/utils/index.ts @@ -0,0 +1,4 @@ +export { getTimestampOptionValue } from './getTimestampOptionValue' +export type { TimestampOptionValue } from './getTimestampOptionValue' +export { isTimestampEnabled } from './isTimestampEnabled' +export type { IsTimestampEnabled } from './isTimestampEnabled' diff --git a/src/v1/entity/utils/addTimestampAttributes/utils/isTimestampEnabled.ts b/src/v1/entity/utils/addTimestampAttributes/utils/isTimestampEnabled.ts new file mode 100644 index 000000000..39c37ab6b --- /dev/null +++ b/src/v1/entity/utils/addTimestampAttributes/utils/isTimestampEnabled.ts @@ -0,0 +1,25 @@ +import type { TimestampsOptions } from '../timestampOptions' + +import { isTimestampsObjectOptions } from './isTimestampsObjectOptions' + +export type IsTimestampEnabled< + TIMESTAMP_OPTIONS extends TimestampsOptions, + TIMESTAMP extends 'created' | 'modified' +> = TIMESTAMP_OPTIONS extends true | { [KEY in TIMESTAMP]: true | Record } + ? true + : false + +export const isTimestampEnabled = < + TIMESTAMP_OPTIONS extends TimestampsOptions, + TIMESTAMP_KEY extends 'created' | 'modified' +>( + timestampOptions: TIMESTAMP_OPTIONS, + timestampKey: TIMESTAMP_KEY +): IsTimestampEnabled => + (timestampOptions === true + ? true + : isTimestampsObjectOptions(timestampOptions) && + (timestampOptions[timestampKey] === true || + typeof timestampOptions[timestampKey] === 'object') + ? true + : false) as IsTimestampEnabled diff --git a/src/v1/entity/utils/addTimestampAttributes/utils/isTimestampObjectOptions.ts b/src/v1/entity/utils/addTimestampAttributes/utils/isTimestampObjectOptions.ts new file mode 100644 index 000000000..f136c953e --- /dev/null +++ b/src/v1/entity/utils/addTimestampAttributes/utils/isTimestampObjectOptions.ts @@ -0,0 +1,9 @@ +import type { TimestampObjectOptions } from '../timestampOptions' + +export type IsTimestampObjectOptions = ( + timestampOptions: boolean | TimestampObjectOptions +) => timestampOptions is TimestampObjectOptions + +export const isTimestampObjectOptions: IsTimestampObjectOptions = ( + timestampOptions: boolean | TimestampObjectOptions +): timestampOptions is TimestampObjectOptions => typeof timestampOptions === 'object' diff --git a/src/v1/entity/utils/addTimestampAttributes/utils/isTimestampsObjectOptions.ts b/src/v1/entity/utils/addTimestampAttributes/utils/isTimestampsObjectOptions.ts new file mode 100644 index 000000000..acd1669b6 --- /dev/null +++ b/src/v1/entity/utils/addTimestampAttributes/utils/isTimestampsObjectOptions.ts @@ -0,0 +1,9 @@ +import type { TimestampsObjectOptions, TimestampsOptions } from '../timestampOptions' + +export type IsTimestampsObjectOptions = ( + timestampsOptions: TimestampsOptions +) => timestampsOptions is TimestampsObjectOptions + +export const isTimestampsObjectOptions: IsTimestampsObjectOptions = ( + timestampsOptions: TimestampsOptions +): timestampsOptions is TimestampsObjectOptions => typeof timestampsOptions === 'object' diff --git a/src/v1/entity/utils/doesSchemaValidateTableSchema.ts b/src/v1/entity/utils/doesSchemaValidateTableSchema.ts new file mode 100644 index 000000000..f1c4b2924 --- /dev/null +++ b/src/v1/entity/utils/doesSchemaValidateTableSchema.ts @@ -0,0 +1,26 @@ +import type { TableV2, Key } from 'v1/table' +import type { Schema } from 'v1/schema' + +const doesSchemaValidateTableSchemaKey = (schema: Schema, key?: Key): boolean => { + if (key === undefined) return true + + const keyAttribute = + schema.attributes[key.name] ?? + Object.values(schema.attributes).find(attribute => attribute.savedAs === key.name) + + return ( + keyAttribute !== undefined && + keyAttribute.key && + keyAttribute.type === key.type && + (keyAttribute.required === 'always' || keyAttribute.defaults.key !== undefined) + ) +} + +export const doesSchemaValidateTableSchema = (schema: Schema, table: TableV2): boolean => { + const { partitionKey, sortKey } = table + + return ( + doesSchemaValidateTableSchemaKey(schema, partitionKey) && + doesSchemaValidateTableSchemaKey(schema, sortKey) + ) +} diff --git a/src/v1/entity/utils/errors.ts b/src/v1/entity/utils/errors.ts new file mode 100644 index 000000000..79afb9c37 --- /dev/null +++ b/src/v1/entity/utils/errors.ts @@ -0,0 +1,24 @@ +import type { ErrorBlueprint } from 'v1/errors/blueprint' + +type ReservedAttributeNameErrorBlueprint = ErrorBlueprint<{ + code: 'entity.reservedAttributeName' + hasPath: true + payload: undefined +}> + +type ReservedAttributeSavedAsErrorBlueprint = ErrorBlueprint<{ + code: 'entity.reservedAttributeSavedAs' + hasPath: true + payload: undefined +}> + +type InvalidSchemaErrorBlueprint = ErrorBlueprint<{ + code: 'entity.invalidSchema' + hasPath: false + payload: undefined +}> + +export type EntityUtilsErrorBlueprints = + | ReservedAttributeNameErrorBlueprint + | ReservedAttributeSavedAsErrorBlueprint + | InvalidSchemaErrorBlueprint diff --git a/src/v1/entity/utils/index.ts b/src/v1/entity/utils/index.ts new file mode 100644 index 000000000..222926480 --- /dev/null +++ b/src/v1/entity/utils/index.ts @@ -0,0 +1,8 @@ +export { addInternalAttributes } from './addInternalAttributes' +export type { WithInternalAttributes } from './addInternalAttributes' +export type { + TimestampsOptions, + TimestampsDefaultOptions, + NarrowTimestampsOptions +} from './addTimestampAttributes' +export { doesSchemaValidateTableSchema } from './doesSchemaValidateTableSchema' diff --git a/src/v1/errors/allErrors.ts b/src/v1/errors/allErrors.ts new file mode 100644 index 000000000..ca67d7f82 --- /dev/null +++ b/src/v1/errors/allErrors.ts @@ -0,0 +1,20 @@ +import type { SchemaErrorBlueprints } from 'v1/schema/errors' +import type { EntityErrorBlueprints } from 'v1/entity/errors' +import type { OperationsErrorBlueprints } from 'v1/operations/errors' +import type { ParsingErrorBlueprints } from 'v1/validation/errors' + +import type { ErrorBlueprint } from './blueprint' + +type ErrorBlueprints = + | SchemaErrorBlueprints + | EntityErrorBlueprints + | OperationsErrorBlueprints + | ParsingErrorBlueprints + +type IndexErrors = { + [ERROR_BLUEPRINT in ERROR_BLUEPRINTS as ERROR_BLUEPRINT['code']]: ERROR_BLUEPRINT +} + +export type IndexedErrors = IndexErrors + +export type ErrorCodes = keyof IndexedErrors diff --git a/src/v1/errors/blueprint.ts b/src/v1/errors/blueprint.ts new file mode 100644 index 000000000..a326c6378 --- /dev/null +++ b/src/v1/errors/blueprint.ts @@ -0,0 +1,9 @@ +interface ErrorBlueprintConstraint { + code: string + hasPath: boolean + payload: unknown +} + +export type ErrorBlueprint< + BLUEPRINT extends ErrorBlueprintConstraint = ErrorBlueprintConstraint +> = BLUEPRINT diff --git a/src/v1/errors/dynamoDBToolboxError.ts b/src/v1/errors/dynamoDBToolboxError.ts new file mode 100644 index 000000000..f65fc16f7 --- /dev/null +++ b/src/v1/errors/dynamoDBToolboxError.ts @@ -0,0 +1,24 @@ +import type { IndexedErrors, ErrorCodes } from './allErrors' + +type ErrorArgs = (IndexedErrors[ERROR_CODE]['hasPath'] extends false + ? { path?: string } + : { path: string }) & + (IndexedErrors[ERROR_CODE]['payload'] extends undefined + ? { payload?: undefined } + : { payload: IndexedErrors[ERROR_CODE]['payload'] }) & { + message: string + } + +export class DynamoDBToolboxError extends Error { + code: ERROR_CODE + path: IndexedErrors[ERROR_CODE]['hasPath'] extends false ? undefined : string + payload: IndexedErrors[ERROR_CODE]['payload'] + + constructor(code: ERROR_CODE, { message, path, payload }: ErrorArgs) { + super(message) + + this.code = code + this.path = path as IndexedErrors[ERROR_CODE]['hasPath'] extends false ? undefined : string + this.payload = payload as IndexedErrors[ERROR_CODE]['payload'] + } +} diff --git a/src/v1/errors/index.ts b/src/v1/errors/index.ts new file mode 100644 index 000000000..320e6b291 --- /dev/null +++ b/src/v1/errors/index.ts @@ -0,0 +1 @@ +export { DynamoDBToolboxError } from './dynamoDBToolboxError' diff --git a/src/v1/index.ts b/src/v1/index.ts new file mode 100644 index 000000000..01556f8a7 --- /dev/null +++ b/src/v1/index.ts @@ -0,0 +1,7 @@ +export * from './schema' +export * from './operations' +export * from './table' +export * from './entity' +export * from './errors' +export * from './test-tools' +export * from './transformers' diff --git a/src/v1/operations/batch/BatchWriteRequestInterface.ts b/src/v1/operations/batch/BatchWriteRequestInterface.ts new file mode 100644 index 000000000..f37c7ad2a --- /dev/null +++ b/src/v1/operations/batch/BatchWriteRequestInterface.ts @@ -0,0 +1,24 @@ +import type { BatchWriteCommandInput } from '@aws-sdk/lib-dynamodb' + +import type { EntityV2 } from 'v1/entity' +import { EntityOperation } from 'v1/operations/class' + +type BatchWriteRequestInputRecord = NonNullable< + BatchWriteCommandInput['RequestItems'] +>[string][number] + +export type RequestTypes = keyof BatchWriteRequestInputRecord + +export type BatchWriteItemRequestInput = NonNullable< + BatchWriteRequestInputRecord[REQUEST_TYPE] +> + +export const $requestType = Symbol('$requestType') + +export interface BatchWriteRequestInterface< + ENTITY extends EntityV2 = EntityV2, + REQUEST_TYPE extends RequestTypes = RequestTypes +> extends EntityOperation { + params: () => BatchWriteItemRequestInput + [$requestType]: REQUEST_TYPE +} diff --git a/src/v1/operations/batch/batchGet.ts b/src/v1/operations/batch/batchGet.ts new file mode 100644 index 000000000..47b9bc8ee --- /dev/null +++ b/src/v1/operations/batch/batchGet.ts @@ -0,0 +1,39 @@ +import { BatchGetCommandInput } from '@aws-sdk/lib-dynamodb' + +import { DynamoDBToolboxError } from 'v1/errors' + +import { $entity } from '../class' +import { GetBatchItemRequest } from './getBatchItem' +import { BatchGetOptions } from './options' + +export const buildBatchGetCommandInput = ( + commands: GetBatchItemRequest[], + batchGetOptions: BatchGetOptions = {} +): BatchGetCommandInput => { + const RequestItems: NonNullable['RequestItems'] = {} + const Tables: { [key: string]: any } = {} + const TableAliases: { [key: string]: any } = {} + const EntityProjections: { [key: string]: any } = {} + const TableProjections: { [key: string]: any } = {} + + if (commands.length === 0) { + throw new DynamoDBToolboxError('operations.incompleteCommand', { + message: 'BatchGet command incomplete: No items supplied' + }) + } + + for (const command of commands) { + const tableName = command[$entity].table.getName() + + if (!RequestItems[tableName]) { + // Create a table property with an empty array + RequestItems[tableName] = { Keys: [] } + } + RequestItems[tableName].Keys?.push(command.params()) + } + + batchGetOptions + // const options = parseBatchGetOptions(batchGetOptions) + + return { RequestItems } +} diff --git a/src/v1/operations/batch/batchWrite.ts b/src/v1/operations/batch/batchWrite.ts new file mode 100644 index 000000000..671c98252 --- /dev/null +++ b/src/v1/operations/batch/batchWrite.ts @@ -0,0 +1,49 @@ +import { + BatchWriteCommand, + BatchWriteCommandInput, + BatchWriteCommandOutput +} from '@aws-sdk/lib-dynamodb' + +import { DynamoDBToolboxError } from 'v1/errors' + +import type { BatchWriteRequestInterface } from './BatchWriteRequestInterface' +import { $requestType } from './BatchWriteRequestInterface' +import type { BatchWriteOptions } from './options' +import { parseBatchWriteOptions } from './parseBatchWriteOptions' +import { $entity } from '../class' + +export const batchWrite = async ( + commands: BatchWriteRequestInterface[], + options: BatchWriteOptions = {} +): Promise => { + const commandInput = buildBatchWriteCommandInput(commands, options) + + return commands[0][$entity].table.documentClient.send(new BatchWriteCommand(commandInput)) +} + +export const buildBatchWriteCommandInput = ( + commands: BatchWriteRequestInterface[], + batchWriteOptions: BatchWriteOptions = {} +): BatchWriteCommandInput => { + const RequestItems: NonNullable['RequestItems'] = {} + + if (commands.length === 0) { + throw new DynamoDBToolboxError('operations.incompleteCommand', { + message: 'BatchWrite command incomplete: No items supplied' + }) + } + + for (const command of commands) { + const tableName = command[$entity].table.getName() + + if (RequestItems[tableName] === undefined) RequestItems[tableName] = [] + + RequestItems[tableName].push({ + [command[$requestType]]: command.params() + }) + } + + const options = parseBatchWriteOptions(batchWriteOptions) + + return { RequestItems, ...options } +} diff --git a/src/v1/operations/batch/batchWrite.unit.test.ts b/src/v1/operations/batch/batchWrite.unit.test.ts new file mode 100644 index 000000000..da2e8232e --- /dev/null +++ b/src/v1/operations/batch/batchWrite.unit.test.ts @@ -0,0 +1,274 @@ +import { DynamoDBClient } from '@aws-sdk/client-dynamodb' +import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb' + +import { DynamoDBToolboxError, EntityV2, TableV2, schema, string } from 'v1' +import { PutBatchItemRequest } from './putBatchItem/operation' +import { DeleteBatchItemRequest } from './deleteBatchItem/operation' +import { buildBatchWriteCommandInput } from './batchWrite' + +const dynamoDbClient = new DynamoDBClient({}) + +const documentClient = DynamoDBDocumentClient.from(dynamoDbClient) + +const TestTable = new TableV2({ + name: 'test-table', + partitionKey: { + type: 'string', + name: 'pk' + }, + sortKey: { + type: 'string', + name: 'sk' + }, + documentClient +}) +const TestTable2 = new TableV2({ + name: 'test-table2', + partitionKey: { + type: 'string', + name: 'pk' + }, + sortKey: { + type: 'string', + name: 'sk' + }, + indexes: { + GSI1: { + partitionKey: { name: 'GSI1pk', type: 'string' }, + sortKey: { name: 'GSIsk1', type: 'string' }, + type: 'global' + } + }, + documentClient +}) + +const TestEntity = new EntityV2({ + name: 'TestEntity', + schema: schema({ + email: string().key().savedAs('pk'), + sort: string().key().savedAs('sk'), + test: string() + }), + table: TestTable +}) +const TestEntity2 = new EntityV2({ + name: 'TestEntity2', + schema: schema({ + email: string().key().savedAs('pk'), + sort: string().key().savedAs('sk'), + test: string() + }), + table: TestTable2 +}) + +describe('buildBatchWriteCommandInput', () => { + it('fails on empty commands', () => { + const invalidCall = () => buildBatchWriteCommandInput([]) + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow(expect.objectContaining({ code: 'operations.incompleteCommand' })) + }) + + it('batchWrites data to a single table', () => { + const result = buildBatchWriteCommandInput([ + TestEntity.build(PutBatchItemRequest).item({ email: 'test', sort: 'testsk', test: 'test' }) + ]) + + expect(result).toMatchObject({ + RequestItems: { + 'test-table': [{ PutRequest: { Item: { pk: 'test', sk: 'testsk', test: 'test' } } }] + } + }) + }) + + it('fails when extra options', () => { + const invalidCall = () => + buildBatchWriteCommandInput( + [ + TestEntity.build(PutBatchItemRequest).item({ + email: 'test', + sort: 'testsk', + test: 'test' + }) + ], + // @ts-expect-error + { extra: true } + ) + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow(expect.objectContaining({ code: 'operations.unknownOption' })) + }) + + it('sets capacity options', () => { + const { ReturnConsumedCapacity } = buildBatchWriteCommandInput( + [ + TestEntity.build(PutBatchItemRequest).item({ + email: 'test', + sort: 'testsk', + test: 'test' + }) + ], + { capacity: 'TOTAL' } + ) + + expect(ReturnConsumedCapacity).toBe('TOTAL') + }) + + it('fails on invalid capacity option', () => { + const invalidCall = () => + buildBatchWriteCommandInput( + [ + TestEntity.build(PutBatchItemRequest).item({ + email: 'test', + sort: 'testsk', + test: 'test' + }) + ], + // @ts-expect-error + { capacity: 'test' } + ) + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow( + expect.objectContaining({ code: 'operations.invalidCapacityOption' }) + ) + }) + + it('sets metrics options', () => { + const { ReturnItemCollectionMetrics } = buildBatchWriteCommandInput( + [ + TestEntity.build(PutBatchItemRequest).item({ + email: 'test', + sort: 'testsk', + test: 'test' + }) + ], + { metrics: 'SIZE' } + ) + + expect(ReturnItemCollectionMetrics).toBe('SIZE') + }) + + it('fails on invalid metrics setting', () => { + const invalidCall = () => + buildBatchWriteCommandInput( + [ + TestEntity.build(PutBatchItemRequest).item({ + email: 'test', + sort: 'testsk', + test: 'test' + }) + ], + // @ts-expect-error + { metrics: 'test' } + ) + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow( + expect.objectContaining({ code: 'operations.invalidMetricsOption' }) + ) + }) + + it('batchWrites data to a single table with multiple items', () => { + const result = buildBatchWriteCommandInput([ + TestEntity.build(PutBatchItemRequest).item({ + email: 'test', + sort: 'testsk1', + test: 'test1' + }), + TestEntity.build(PutBatchItemRequest).item({ + email: 'test', + sort: 'testsk2', + test: 'test2' + }), + TestEntity.build(DeleteBatchItemRequest).key({ email: 'test', sort: 'testsk3' }) + ]) + + expect(result).toMatchObject({ + RequestItems: { + 'test-table': [ + { + PutRequest: { + Item: { + pk: 'test', + sk: 'testsk1', + test: 'test1' + } + } + }, + { + PutRequest: { + Item: { + pk: 'test', + sk: 'testsk2', + test: 'test2' + } + } + }, + { + DeleteRequest: { + Key: { + pk: 'test', + sk: 'testsk3' + } + } + } + ] + } + }) + }) + + it('batchWrites data to multiple tables', () => { + const result = buildBatchWriteCommandInput([ + TestEntity.build(PutBatchItemRequest).item({ + email: 'test', + sort: 'testsk1', + test: 'test1' + }), + TestEntity.build(PutBatchItemRequest).item({ + email: 'test', + sort: 'testsk2', + test: 'test2' + }), + TestEntity2.build(PutBatchItemRequest).item({ + email: 'test', + sort: 'testsk3', + test: 'test3' + }) + ]) + + expect(result).toMatchObject({ + RequestItems: { + 'test-table': [ + { + PutRequest: { + Item: { + pk: 'test', + sk: 'testsk1', + test: 'test1' + } + } + }, + { + PutRequest: { + Item: { + pk: 'test', + sk: 'testsk2', + test: 'test2' + } + } + } + ], + 'test-table2': [ + { + PutRequest: { + Item: { + pk: 'test', + sk: 'testsk3', + test: 'test3' + } + } + } + ] + } + }) + }) +}) diff --git a/src/v1/operations/batch/deleteBatchItem/deleteBatchItemParams.unit.test.ts b/src/v1/operations/batch/deleteBatchItem/deleteBatchItemParams.unit.test.ts new file mode 100644 index 000000000..b82985ffd --- /dev/null +++ b/src/v1/operations/batch/deleteBatchItem/deleteBatchItemParams.unit.test.ts @@ -0,0 +1,52 @@ +import { DynamoDBClient } from '@aws-sdk/client-dynamodb' +import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb' + +import { DynamoDBToolboxError, EntityV2, TableV2, schema, string } from 'v1' +import { DeleteBatchItemRequest } from './operation' + +const dynamoDbClient = new DynamoDBClient({}) + +const documentClient = DynamoDBDocumentClient.from(dynamoDbClient) + +const TestTable = new TableV2({ + name: 'test-table', + partitionKey: { + type: 'string', + name: 'pk' + }, + sortKey: { + type: 'string', + name: 'sk' + }, + documentClient +}) + +const TestEntity = new EntityV2({ + name: 'TestEntity', + schema: schema({ + email: string().key().savedAs('pk'), + sort: string().key().savedAs('sk'), + test_string: string() + }), + table: TestTable +}) + +describe('deleteBatchItem', () => { + it('returns the result in the correct format', async () => { + const { Key } = TestEntity.build(DeleteBatchItemRequest) + .key({ email: 'test-pk', sort: 'test-sk' }) + .params() + + expect(Key).toMatchObject({ + pk: 'test-pk', + sk: 'test-sk' + }) + }) + + it('fails if no key is provided', () => { + const invalidCall = () => TestEntity.build(DeleteBatchItemRequest).params() + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow(expect.objectContaining({ code: 'operations.incompleteCommand' })) + }) +}) diff --git a/src/v1/operations/batch/deleteBatchItem/index.ts b/src/v1/operations/batch/deleteBatchItem/index.ts new file mode 100644 index 000000000..6e3276877 --- /dev/null +++ b/src/v1/operations/batch/deleteBatchItem/index.ts @@ -0,0 +1 @@ +export { DeleteBatchItemRequest } from './operation' diff --git a/src/v1/operations/batch/deleteBatchItem/operation.ts b/src/v1/operations/batch/deleteBatchItem/operation.ts new file mode 100644 index 000000000..38600cafa --- /dev/null +++ b/src/v1/operations/batch/deleteBatchItem/operation.ts @@ -0,0 +1,38 @@ +import type { EntityV2 } from 'v1/entity' +import { DynamoDBToolboxError } from 'v1/errors' +import { deleteItemParams } from 'v1/operations/deleteItem/deleteItemParams' +import type { KeyInput } from 'v1/operations/types/KeyInput' + +import { $entity, EntityOperation } from '../../class' +import type { BatchWriteRequestInterface } from '../BatchWriteRequestInterface' +import { $requestType } from '../BatchWriteRequestInterface' + +export const $key = Symbol('$key') + +export class DeleteBatchItemRequest + extends EntityOperation + implements BatchWriteRequestInterface { + static operationName = 'deleteBatch' as const; + + [$key]?: KeyInput + key: (keyInput: KeyInput) => DeleteBatchItemRequest + + constructor(entity: ENTITY, key?: KeyInput) { + super(entity) + this[$key] = key + + this.key = nextKey => new DeleteBatchItemRequest(this[$entity], nextKey) + } + + params = () => { + if (!this[$key]) { + throw new DynamoDBToolboxError('operations.incompleteCommand', { + message: 'DeleteItemCommand incomplete: Missing "key" property' + }) + } + + return deleteItemParams(this[$entity], this[$key]) + }; + + [$requestType] = 'DeleteRequest' as const +} diff --git a/src/v1/operations/batch/getBatchItem/getBatchItemParams.unit.test.ts b/src/v1/operations/batch/getBatchItem/getBatchItemParams.unit.test.ts new file mode 100644 index 000000000..7fc03779c --- /dev/null +++ b/src/v1/operations/batch/getBatchItem/getBatchItemParams.unit.test.ts @@ -0,0 +1,53 @@ +import { DynamoDBClient } from '@aws-sdk/client-dynamodb' +import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb' + +import { DynamoDBToolboxError, EntityV2, TableV2, schema, string } from 'v1' +import { PutBatchItemRequest } from './operation' + +const dynamoDbClient = new DynamoDBClient({}) + +const documentClient = DynamoDBDocumentClient.from(dynamoDbClient) + +const TestTable = new TableV2({ + name: 'test-table', + partitionKey: { + type: 'string', + name: 'pk' + }, + sortKey: { + type: 'string', + name: 'sk' + }, + documentClient +}) + +const TestEntity = new EntityV2({ + name: 'TestEntity', + schema: schema({ + email: string().key().savedAs('pk'), + sort: string().key().savedAs('sk'), + test_string: string() + }), + table: TestTable +}) + +describe('putBatchItem', () => { + it('returns the result in the correct format', async () => { + const { Item } = TestEntity.build(PutBatchItemRequest) + .item({ email: 'test-pk', sort: 'test-sk', test_string: 'test string' }) + .params() + + expect(Item).toMatchObject({ + pk: 'test-pk', + sk: 'test-sk', + test_string: 'test string' + }) + }) + + it('fails if no item is provided', () => { + const invalidCall = () => TestEntity.build(PutBatchItemRequest).params() + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow(expect.objectContaining({ code: 'operations.incompleteCommand' })) + }) +}) diff --git a/src/v1/operations/batch/getBatchItem/index.ts b/src/v1/operations/batch/getBatchItem/index.ts new file mode 100644 index 000000000..be71d1594 --- /dev/null +++ b/src/v1/operations/batch/getBatchItem/index.ts @@ -0,0 +1 @@ +export { GetBatchItemRequest } from './operation' diff --git a/src/v1/operations/batch/getBatchItem/operation.ts b/src/v1/operations/batch/getBatchItem/operation.ts new file mode 100644 index 000000000..525902363 --- /dev/null +++ b/src/v1/operations/batch/getBatchItem/operation.ts @@ -0,0 +1,112 @@ +import type { BatchGetCommandInput } from '@aws-sdk/lib-dynamodb' + +import type { EntityV2 } from 'v1/entity' +import type { TableV2 } from 'v1/table' + +import { $entities, $table, TableOperation } from 'v1/operations/class' +import type { BatchGetItemOptions } from './options' +import { AnyAttributePath, KeyInput } from 'v1/operations/types' +import { DynamoDBToolboxError } from 'v1/errors' +import { parseEntityKeyInput } from 'v1/operations/utils/parseKeyInput' +import { parsePrimaryKey } from 'v1/operations/utils/parsePrimaryKey' +import { parseProjection } from 'v1/operations/expression/projection' + +const $options = Symbol('$options') +const $keys = Symbol('$keys') + +type KeysInput = EntityV2[] extends ENTITIES + ? Record[]> + : { + [ENTITY in ENTITIES[number] as ENTITY['name']]?: KeyInput[] + } + +export class GetBatchItemsCommand< + TABLE extends TableV2 = TableV2, + ENTITIES extends EntityV2[] = EntityV2[], + OPTIONS extends BatchGetItemOptions = BatchGetItemOptions +> extends TableOperation { + static operationName = 'batchGet' as const + + entities: ( + ...nextEntities: NEXT_ENTITIES + ) => GetBatchItemsCommand< + TABLE, + NEXT_ENTITIES, + OPTIONS extends BatchGetItemOptions + ? OPTIONS + : BatchGetItemOptions + >; + [$options]: OPTIONS + options: >( + nextOptions: NEXT_OPTIONS + ) => GetBatchItemsCommand; + [$keys]?: KeysInput + keys: (keys: KeysInput) => GetBatchItemsCommand + + constructor( + table: TABLE, + entities = ([] as unknown) as ENTITIES, + keys?: KeysInput, + options: OPTIONS = {} as OPTIONS + ) { + super(table, entities) + this[$options] = options + this[$keys] = keys + + this.entities = (...nextEntities: NEXT_ENTITIES) => + new GetBatchItemsCommand( + this[$table], + nextEntities, + this[$keys] as KeysInput | undefined, + this[$options] as OPTIONS extends BatchGetItemOptions + ? OPTIONS + : BatchGetItemOptions + ) + this.options = nextOptions => + new GetBatchItemsCommand(this[$table], this[$entities], this[$keys], nextOptions) + this.keys = nextKeys => + new GetBatchItemsCommand(this[$table], this[$entities], nextKeys, this[$options]) + } + + params = (): NonNullable[string] => { + if (!this[$keys]) { + throw new DynamoDBToolboxError('operations.incompleteCommand', { + message: 'GetBatchItemsCommand incomplete: Missing "keys" property' + }) + } + + for (const entity of this[$entities]) { + const entityName = entity.name + for (const input of (this[$keys] as Record[]>)[entityName]) { + const validKeyInputParser = parseEntityKeyInput(entity, input) + const validKeyInput = validKeyInputParser.next().value + const collapsedInput = validKeyInputParser.next().value + + const keyInput = entity.computeKey ? entity.computeKey(validKeyInput) : collapsedInput + const primaryKey = parsePrimaryKey(entity, keyInput) + + const attributes = ((this[$options]['attributes'] ?? {}) as Record< + string, + AnyAttributePath + >)[entityName] + + if (attributes !== undefined) { + const { ExpressionAttributeNames, ProjectionExpression } = parseProjection( + entity, + attributes + ) + + if (!isEmpty(ExpressionAttributeNames)) { + commandOptions.ExpressionAttributeNames = ExpressionAttributeNames + } + + commandOptions.ProjectionExpression = ProjectionExpression + } + + rejectExtraOptions(extraOptions) + } + } + + return {} + } +} diff --git a/src/v1/operations/batch/getBatchItem/options.ts b/src/v1/operations/batch/getBatchItem/options.ts new file mode 100644 index 000000000..a649416f1 --- /dev/null +++ b/src/v1/operations/batch/getBatchItem/options.ts @@ -0,0 +1,10 @@ +import type { EntityV2 } from 'v1/entity' +import type { CapacityOption } from 'v1/operations/constants/options/capacity' +import type { AnyAttributePath } from 'v1/operations/types' + +export type BatchGetItemOptions = { + capacity?: CapacityOption + attributes?: EntityV2[] extends ENTITIES + ? Record + : { [ENTITY in ENTITIES[number] as ENTITY['name']]?: AnyAttributePath } +} diff --git a/src/v1/operations/batch/index.ts b/src/v1/operations/batch/index.ts new file mode 100644 index 000000000..831503cb9 --- /dev/null +++ b/src/v1/operations/batch/index.ts @@ -0,0 +1,5 @@ +export type { BatchWriteRequestInterface } from './BatchWriteRequestInterface' +export type { BatchWriteOptions } from './options' +export { batchWrite } from './batchWrite' +export { DeleteBatchItemRequest } from './deleteBatchItem' +export { PutBatchItemRequest } from './putBatchItem' diff --git a/src/v1/operations/batch/options.ts b/src/v1/operations/batch/options.ts new file mode 100644 index 000000000..60fa15613 --- /dev/null +++ b/src/v1/operations/batch/options.ts @@ -0,0 +1,12 @@ +import type { CapacityOption } from 'v1/operations/constants/options/capacity' +import type { MetricsOption } from 'v1/operations/constants/options/metrics' + +export interface BatchWriteOptions { + capacity?: CapacityOption + metrics?: MetricsOption +} + +export interface BatchGetOptions { + capacity?: CapacityOption + metrics?: MetricsOption +} diff --git a/src/v1/operations/batch/parseBatchWriteOptions.ts b/src/v1/operations/batch/parseBatchWriteOptions.ts new file mode 100644 index 000000000..00e0cb8f1 --- /dev/null +++ b/src/v1/operations/batch/parseBatchWriteOptions.ts @@ -0,0 +1,26 @@ +import type { BatchWriteCommandInput } from '@aws-sdk/lib-dynamodb' + +import { parseCapacityOption } from 'v1/operations/utils/parseOptions/parseCapacityOption' +import { parseMetricsOption } from 'v1/operations/utils/parseOptions/parseMetricsOption' +import { rejectExtraOptions } from 'v1/operations/utils/parseOptions/rejectExtraOptions' + +import type { BatchWriteOptions } from './options' + +export type CommandOptions = Omit + +export const parseBatchWriteOptions = (options: BatchWriteOptions): CommandOptions => { + const commandOptions: CommandOptions = {} + const { capacity, metrics, ...extraOptions } = options + + rejectExtraOptions(extraOptions) + + if (capacity !== undefined) { + commandOptions.ReturnConsumedCapacity = parseCapacityOption(capacity) + } + + if (metrics !== undefined) { + commandOptions.ReturnItemCollectionMetrics = parseMetricsOption(metrics) + } + + return commandOptions +} diff --git a/src/v1/operations/batch/putBatchItem/index.ts b/src/v1/operations/batch/putBatchItem/index.ts new file mode 100644 index 000000000..1d2f5e0b8 --- /dev/null +++ b/src/v1/operations/batch/putBatchItem/index.ts @@ -0,0 +1 @@ +export { PutBatchItemRequest } from './operation' diff --git a/src/v1/operations/batch/putBatchItem/operation.ts b/src/v1/operations/batch/putBatchItem/operation.ts new file mode 100644 index 000000000..4a3224e59 --- /dev/null +++ b/src/v1/operations/batch/putBatchItem/operation.ts @@ -0,0 +1,38 @@ +import type { EntityV2 } from 'v1/entity' +import { DynamoDBToolboxError } from 'v1/errors' +import type { PutItemInput } from 'v1/operations/putItem' +import { putItemParams } from 'v1/operations/putItem/putItemParams' + +import { $entity, EntityOperation } from '../../class' +import type { BatchWriteRequestInterface } from '../BatchWriteRequestInterface' +import { $requestType } from '../BatchWriteRequestInterface' + +export const $item = Symbol('$item') + +export class PutBatchItemRequest + extends EntityOperation + implements BatchWriteRequestInterface { + static operationName = 'putBatch' as const; + + [$item]?: PutItemInput + item: (nextItem: PutItemInput) => PutBatchItemRequest + + constructor(entity: ENTITY, item?: PutItemInput) { + super(entity) + this[$item] = item + + this.item = nextItem => new PutBatchItemRequest(this[$entity], nextItem) + } + + params = () => { + if (!this[$item]) { + throw new DynamoDBToolboxError('operations.incompleteCommand', { + message: 'PutBatchItemCommand incomplete: Missing "item" property' + }) + } + + return putItemParams(this[$entity], this[$item]) + }; + + [$requestType] = 'PutRequest' as const +} diff --git a/src/v1/operations/batch/putBatchItem/putBatchItemParams.unit.test.ts b/src/v1/operations/batch/putBatchItem/putBatchItemParams.unit.test.ts new file mode 100644 index 000000000..7fc03779c --- /dev/null +++ b/src/v1/operations/batch/putBatchItem/putBatchItemParams.unit.test.ts @@ -0,0 +1,53 @@ +import { DynamoDBClient } from '@aws-sdk/client-dynamodb' +import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb' + +import { DynamoDBToolboxError, EntityV2, TableV2, schema, string } from 'v1' +import { PutBatchItemRequest } from './operation' + +const dynamoDbClient = new DynamoDBClient({}) + +const documentClient = DynamoDBDocumentClient.from(dynamoDbClient) + +const TestTable = new TableV2({ + name: 'test-table', + partitionKey: { + type: 'string', + name: 'pk' + }, + sortKey: { + type: 'string', + name: 'sk' + }, + documentClient +}) + +const TestEntity = new EntityV2({ + name: 'TestEntity', + schema: schema({ + email: string().key().savedAs('pk'), + sort: string().key().savedAs('sk'), + test_string: string() + }), + table: TestTable +}) + +describe('putBatchItem', () => { + it('returns the result in the correct format', async () => { + const { Item } = TestEntity.build(PutBatchItemRequest) + .item({ email: 'test-pk', sort: 'test-sk', test_string: 'test string' }) + .params() + + expect(Item).toMatchObject({ + pk: 'test-pk', + sk: 'test-sk', + test_string: 'test string' + }) + }) + + it('fails if no item is provided', () => { + const invalidCall = () => TestEntity.build(PutBatchItemRequest).params() + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow(expect.objectContaining({ code: 'operations.incompleteCommand' })) + }) +}) diff --git a/src/v1/operations/class.ts b/src/v1/operations/class.ts new file mode 100644 index 000000000..43ae9a85a --- /dev/null +++ b/src/v1/operations/class.ts @@ -0,0 +1,38 @@ +import type { EntityV2 } from '../entity/class' +import type { TableV2 } from '../table/class' + +export const $entity = Symbol('$entity') +export type $entity = typeof $entity + +export const $entities = Symbol('$entities') +export type $entities = typeof $entities + +export const $table = Symbol('$table') +export type $table = typeof $table + +export class EntityOperation { + static operationType = 'entity' + static operationName: string; + + [$entity]: ENTITY + + constructor(entity: ENTITY) { + this[$entity] = entity + } +} + +export class TableOperation< + TABLE extends TableV2 = TableV2, + ENTITIES extends EntityV2[] = EntityV2[] +> { + static operationType = 'table' + static operationName: string; + + [$table]: TABLE; + [$entities]: ENTITIES + + constructor(table: TABLE, entities = ([] as unknown) as ENTITIES) { + this[$table] = table + this[$entities] = entities + } +} diff --git a/src/v1/operations/constants/options/capacity.ts b/src/v1/operations/constants/options/capacity.ts new file mode 100644 index 000000000..9a590dedc --- /dev/null +++ b/src/v1/operations/constants/options/capacity.ts @@ -0,0 +1,7 @@ +export type NoneCapacityOption = 'NONE' +export type TotalCapacityOption = 'TOTAL' +export type IndexesCapacityOption = 'INDEXES' + +export type CapacityOption = NoneCapacityOption | TotalCapacityOption | IndexesCapacityOption + +export const capacityOptionsSet = new Set(['NONE', 'TOTAL', 'INDEXES']) diff --git a/src/v1/operations/constants/options/metrics.ts b/src/v1/operations/constants/options/metrics.ts new file mode 100644 index 000000000..4bebe3892 --- /dev/null +++ b/src/v1/operations/constants/options/metrics.ts @@ -0,0 +1,5 @@ +export type NoneMetricsOption = 'NONE' +export type SizeMetricsOption = 'SIZE' +export type MetricsOption = NoneMetricsOption | SizeMetricsOption + +export const metricsOptionsSet = new Set(['NONE', 'SIZE']) diff --git a/src/v1/operations/constants/options/returnValues.ts b/src/v1/operations/constants/options/returnValues.ts new file mode 100644 index 000000000..461daa824 --- /dev/null +++ b/src/v1/operations/constants/options/returnValues.ts @@ -0,0 +1,12 @@ +export type NoneReturnValuesOption = 'NONE' +export type AllOldReturnValuesOption = 'ALL_OLD' +export type UpdatedOldReturnValuesOption = 'UPDATED_OLD' +export type AllNewReturnValuesOption = 'ALL_NEW' +export type UpdatedNewReturnValuesOption = 'UPDATED_NEW' + +export type ReturnValuesOption = + | NoneReturnValuesOption + | AllOldReturnValuesOption + | UpdatedOldReturnValuesOption + | AllNewReturnValuesOption + | UpdatedNewReturnValuesOption diff --git a/src/v1/operations/constants/options/select.ts b/src/v1/operations/constants/options/select.ts new file mode 100644 index 000000000..e9517037b --- /dev/null +++ b/src/v1/operations/constants/options/select.ts @@ -0,0 +1,17 @@ +export type AllAttributesSelectOption = 'ALL_ATTRIBUTES' +export type AllProjectedAttributesSelectOption = 'ALL_PROJECTED_ATTRIBUTES' +export type CountSelectOption = 'COUNT' +export type SpecificAttributesSelectOption = 'SPECIFIC_ATTRIBUTES' + +export type SelectOption = + | AllAttributesSelectOption + | AllProjectedAttributesSelectOption + | CountSelectOption + | SpecificAttributesSelectOption + +export const selectOptionsSet = new Set([ + 'ALL_ATTRIBUTES', + 'ALL_PROJECTED_ATTRIBUTES', + 'COUNT', + 'SPECIFIC_ATTRIBUTES' +]) diff --git a/src/v1/operations/deleteItem/command.ts b/src/v1/operations/deleteItem/command.ts new file mode 100644 index 000000000..3d0bdb397 --- /dev/null +++ b/src/v1/operations/deleteItem/command.ts @@ -0,0 +1,96 @@ +import type { O } from 'ts-toolbelt' +import { DeleteCommandInput, DeleteCommand, DeleteCommandOutput } from '@aws-sdk/lib-dynamodb' + +import type { EntityV2, FormattedItem } from 'v1/entity' +import type { + NoneReturnValuesOption, + AllOldReturnValuesOption +} from 'v1/operations/constants/options/returnValues' +import type { KeyInput } from 'v1/operations/types' +import { DynamoDBToolboxError } from 'v1/errors' +import { formatSavedItem } from 'v1/operations/utils/formatSavedItem' + +import { EntityOperation, $entity } from '../class' +import type { DeleteItemOptions, DeleteItemCommandReturnValuesOption } from './options' +import { deleteItemParams } from './deleteItemParams' + +export const $key = Symbol('$key') +export type $key = typeof $key + +export const $options = Symbol('$options') +export type $options = typeof $options + +type ReturnedAttributes< + ENTITY extends EntityV2, + OPTIONS extends DeleteItemOptions +> = DeleteItemCommandReturnValuesOption extends OPTIONS['returnValues'] + ? undefined + : OPTIONS['returnValues'] extends NoneReturnValuesOption + ? undefined + : OPTIONS['returnValues'] extends AllOldReturnValuesOption + ? FormattedItem | undefined + : never + +export type DeleteItemResponse< + ENTITY extends EntityV2, + OPTIONS extends DeleteItemOptions = DeleteItemOptions +> = O.Merge< + Omit, + { Attributes?: ReturnedAttributes | undefined } +> + +export class DeleteItemCommand< + ENTITY extends EntityV2 = EntityV2, + OPTIONS extends DeleteItemOptions = DeleteItemOptions +> extends EntityOperation { + static operationName = 'delete' as const; + + [$key]?: KeyInput + key: (keyInput: KeyInput) => DeleteItemCommand; + [$options]?: OPTIONS + options: >( + nextOptions: NEXT_OPTIONS + ) => DeleteItemCommand + + constructor(entity: ENTITY, key?: KeyInput, options: OPTIONS = {} as OPTIONS) { + super(entity) + this[$key] = key + this[$options] = options + + this.key = nextKey => new DeleteItemCommand(this[$entity], nextKey, this[$options]) + this.options = nextOptions => new DeleteItemCommand(this[$entity], this[$key], nextOptions) + } + + params = (): DeleteCommandInput => { + if (!this[$key]) { + throw new DynamoDBToolboxError('operations.incompleteCommand', { + message: 'DeleteItemCommand incomplete: Missing "key" property' + }) + } + + return deleteItemParams(this[$entity], this[$key], this[$options]) + } + + send = async (): Promise> => { + const deleteItemParams = this.params() + + const commandOutput = await this[$entity].table.documentClient.send( + new DeleteCommand(deleteItemParams) + ) + + const { Attributes: attributes, ...restCommandOutput } = commandOutput + + if (attributes === undefined) { + return restCommandOutput + } + + const formattedItem = formatSavedItem(this[$entity], attributes) + + return { + Attributes: formattedItem as ReturnedAttributes, + ...restCommandOutput + } + } +} + +export type DeleteItemCommandClass = typeof DeleteItemCommand diff --git a/src/v1/operations/deleteItem/deleteItemParams/deleteItemParams.ts b/src/v1/operations/deleteItem/deleteItemParams/deleteItemParams.ts new file mode 100644 index 000000000..5e6135cf6 --- /dev/null +++ b/src/v1/operations/deleteItem/deleteItemParams/deleteItemParams.ts @@ -0,0 +1,34 @@ +import type { DeleteCommandInput } from '@aws-sdk/lib-dynamodb' + +import type { EntityV2 } from 'v1/entity' +import type { KeyInput } from 'v1/operations/types' +import { parseEntityKeyInput } from 'v1/operations/utils/parseKeyInput' +import { parsePrimaryKey } from 'v1/operations/utils/parsePrimaryKey' + +import type { DeleteItemOptions } from '../options' + +import { parseDeleteItemOptions } from './parseDeleteItemOptions' + +export const deleteItemParams = < + ENTITY extends EntityV2, + OPTIONS extends DeleteItemOptions +>( + entity: ENTITY, + input: KeyInput, + deleteItemOptions: OPTIONS = {} as OPTIONS +): DeleteCommandInput => { + const validKeyInputParser = parseEntityKeyInput(entity, input) + const validKeyInput = validKeyInputParser.next().value + const collapsedInput = validKeyInputParser.next().value + + const keyInput = entity.computeKey ? entity.computeKey(validKeyInput) : collapsedInput + const primaryKey = parsePrimaryKey(entity, keyInput) + + const options = parseDeleteItemOptions(entity, deleteItemOptions) + + return { + TableName: entity.table.getName(), + Key: primaryKey, + ...options + } +} diff --git a/src/v1/operations/deleteItem/deleteItemParams/deleteItemParams.unit.test.ts b/src/v1/operations/deleteItem/deleteItemParams/deleteItemParams.unit.test.ts new file mode 100644 index 000000000..7cfbd1f99 --- /dev/null +++ b/src/v1/operations/deleteItem/deleteItemParams/deleteItemParams.unit.test.ts @@ -0,0 +1,259 @@ +import { DynamoDBClient } from '@aws-sdk/client-dynamodb' +import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb' + +import { + TableV2, + EntityV2, + schema, + string, + DynamoDBToolboxError, + DeleteItemCommand, + prefix +} from 'v1' + +const dynamoDbClient = new DynamoDBClient({}) + +const documentClient = DynamoDBDocumentClient.from(dynamoDbClient) + +const TestTable = new TableV2({ + name: 'test-table', + partitionKey: { + type: 'string', + name: 'pk' + }, + sortKey: { + type: 'string', + name: 'sk' + }, + documentClient +}) + +const TestEntity = new EntityV2({ + name: 'TestEntity', + schema: schema({ + email: string().key().savedAs('pk'), + sort: string().key().savedAs('sk'), + test: string() + }), + table: TestTable +}) + +const TestEntity2 = new EntityV2({ + name: 'TestEntity', + schema: schema({ + pk: string().key(), + sk: string().key(), + test: string() + }), + table: TestTable +}) + +describe('delete', () => { + it('deletes the key from inputs', async () => { + const { TableName, Key } = TestEntity.build(DeleteItemCommand) + .key({ email: 'test-pk', sort: 'test-sk' }) + .params() + + expect(TableName).toBe('test-table') + expect(Key).toStrictEqual({ pk: 'test-pk', sk: 'test-sk' }) + }) + + it('filters out extra data', async () => { + const { Key } = TestEntity.build(DeleteItemCommand) + .key({ + email: 'test-pk', + sort: 'test-sk', + // @ts-expect-error + test: 'test' + }) + .params() + + expect(Key).not.toHaveProperty('test') + }) + + it('fails with undefined input', () => { + expect(() => + TestEntity.build(DeleteItemCommand) + .key( + // @ts-expect-error + {} + ) + .params() + ).toThrow('Attribute email is required') + }) + + it('fails when missing the sortKey', () => { + expect(() => + TestEntity.build(DeleteItemCommand) + .key( + // @ts-expect-error + { pk: 'test-pk' } + ) + .params() + ).toThrow('Attribute email is required') + }) + + it('fails when missing partitionKey (no alias)', () => { + expect(() => + TestEntity2.build(DeleteItemCommand) + .key( + // @ts-expect-error + {} + ) + .params() + ).toThrow('Attribute pk is required') + }) + + it('fails when missing the sortKey (no alias)', () => { + expect(() => + TestEntity2.build(DeleteItemCommand) + .key( + // @ts-expect-error + { pk: 'test-pk' } + ) + .params() + ).toThrow('Attribute sk is required') + }) + + it('sets capacity options', () => { + const { ReturnConsumedCapacity } = TestEntity.build(DeleteItemCommand) + .key({ email: 'x', sort: 'y' }) + .options({ capacity: 'NONE' }) + .params() + + expect(ReturnConsumedCapacity).toBe('NONE') + }) + + it('fails on invalid capacity option', () => { + const invalidCall = () => + TestEntity.build(DeleteItemCommand) + .key({ email: 'x', sort: 'y' }) + .options( + // @ts-expect-error + { capacity: 'test' } + ) + .params() + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow( + expect.objectContaining({ code: 'operations.invalidCapacityOption' }) + ) + }) + + it('sets metrics options', () => { + const { ReturnItemCollectionMetrics } = TestEntity.build(DeleteItemCommand) + .key({ email: 'x', sort: 'y' }) + .options({ metrics: 'SIZE' }) + .params() + + expect(ReturnItemCollectionMetrics).toBe('SIZE') + }) + + it('fails on invalid metrics option', () => { + const invalidCall = () => + TestEntity.build(DeleteItemCommand) + .key({ email: 'x', sort: 'y' }) + .options({ + // @ts-expect-error + metrics: 'test' + }) + .params() + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow( + expect.objectContaining({ code: 'operations.invalidMetricsOption' }) + ) + }) + + it('sets returnValues options', () => { + const { ReturnValues } = TestEntity.build(DeleteItemCommand) + .key({ email: 'x', sort: 'y' }) + .options({ returnValues: 'ALL_OLD' }) + .params() + + expect(ReturnValues).toBe('ALL_OLD') + }) + + it('fails on invalid returnValues option', () => { + const invalidCall = () => + TestEntity.build(DeleteItemCommand) + .key({ email: 'x', sort: 'y' }) + .options({ + // @ts-expect-error + returnValues: 'test' + }) + .params() + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow( + expect.objectContaining({ code: 'operations.invalidReturnValuesOption' }) + ) + }) + + it('fails on extra options', () => { + const invalidCall = () => + TestEntity.build(DeleteItemCommand) + .key({ email: 'x', sort: 'y' }) + .options({ + // @ts-expect-error + extra: true + }) + .params() + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow(expect.objectContaining({ code: 'operations.unknownOption' })) + }) + + it('sets condition', () => { + const { + ExpressionAttributeNames, + ExpressionAttributeValues, + ConditionExpression + } = TestEntity.build(DeleteItemCommand) + .key({ email: 'x', sort: 'y' }) + .options({ condition: { attr: 'email', gt: 'test' } }) + .params() + + expect(ExpressionAttributeNames).toEqual({ '#c_1': 'pk' }) + expect(ExpressionAttributeValues).toEqual({ ':c_1': 'test' }) + expect(ConditionExpression).toBe('#c_1 > :c_1') + }) + + it('missing key', () => { + const invalidCall = () => TestEntity.build(DeleteItemCommand).params() + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow(expect.objectContaining({ code: 'operations.incompleteCommand' })) + }) + + it('transformed key', () => { + const TestEntity3 = new EntityV2({ + name: 'TestEntity', + schema: schema({ + email: string().key().savedAs('pk').transform(prefix('EMAIL')), + sort: string().key().savedAs('sk') + }), + table: TestTable + }) + + const { Key, ExpressionAttributeNames, ExpressionAttributeValues } = TestEntity3.build( + DeleteItemCommand + ) + .key({ email: 'foo@bar.mail', sort: 'y' }) + .options({ condition: { attr: 'email', gt: 'test', transform: false } }) + .params() + + expect(Key).toMatchObject({ pk: 'EMAIL#foo@bar.mail' }) + expect(ExpressionAttributeNames).toEqual({ '#c_1': 'pk' }) + expect(ExpressionAttributeValues).toEqual({ ':c_1': 'test' }) + + const { ExpressionAttributeValues: ExpressionAttributeValues2 } = TestEntity3.build( + DeleteItemCommand + ) + .key({ email: 'foo@bar.mail', sort: 'y' }) + .options({ condition: { attr: 'email', gt: 'test' } }) + .params() + + expect(ExpressionAttributeValues2).toEqual({ ':c_1': 'EMAIL#test' }) + }) +}) diff --git a/src/v1/operations/deleteItem/deleteItemParams/index.ts b/src/v1/operations/deleteItem/deleteItemParams/index.ts new file mode 100644 index 000000000..b8a368b3a --- /dev/null +++ b/src/v1/operations/deleteItem/deleteItemParams/index.ts @@ -0,0 +1 @@ +export { deleteItemParams } from './deleteItemParams' diff --git a/src/v1/operations/deleteItem/deleteItemParams/parseDeleteItemOptions.ts b/src/v1/operations/deleteItem/deleteItemParams/parseDeleteItemOptions.ts new file mode 100644 index 000000000..8009c5153 --- /dev/null +++ b/src/v1/operations/deleteItem/deleteItemParams/parseDeleteItemOptions.ts @@ -0,0 +1,59 @@ +import type { DeleteCommandInput } from '@aws-sdk/lib-dynamodb' +import isEmpty from 'lodash.isempty' + +import { parseCapacityOption } from 'v1/operations/utils/parseOptions/parseCapacityOption' +import { parseMetricsOption } from 'v1/operations/utils/parseOptions/parseMetricsOption' +import { parseReturnValuesOption } from 'v1/operations/utils/parseOptions/parseReturnValuesOption' +import { rejectExtraOptions } from 'v1/operations/utils/parseOptions/rejectExtraOptions' +import { parseCondition } from 'v1/operations/expression/condition/parse' +import type { EntityV2 } from 'v1/entity' + +import { deleteItemCommandReturnValuesOptionsSet, DeleteItemOptions } from '../options' + +type CommandOptions = Omit + +export const parseDeleteItemOptions = ( + entity: ENTITY, + deleteItemOptions: DeleteItemOptions +): CommandOptions => { + const commandOptions: CommandOptions = {} + + const { capacity, metrics, returnValues, condition, ...extraOptions } = deleteItemOptions + + if (capacity !== undefined) { + commandOptions.ReturnConsumedCapacity = parseCapacityOption(capacity) + } + + if (metrics !== undefined) { + commandOptions.ReturnItemCollectionMetrics = parseMetricsOption(metrics) + } + + if (returnValues !== undefined) { + commandOptions.ReturnValues = parseReturnValuesOption( + deleteItemCommandReturnValuesOptionsSet, + returnValues + ) + } + + if (condition !== undefined) { + const { + ExpressionAttributeNames, + ExpressionAttributeValues, + ConditionExpression + } = parseCondition(entity, condition) + + if (!isEmpty(ExpressionAttributeNames)) { + commandOptions.ExpressionAttributeNames = ExpressionAttributeNames + } + + if (!isEmpty(ExpressionAttributeValues)) { + commandOptions.ExpressionAttributeValues = ExpressionAttributeValues + } + + commandOptions.ConditionExpression = ConditionExpression + } + + rejectExtraOptions(extraOptions) + + return commandOptions +} diff --git a/src/v1/operations/deleteItem/index.ts b/src/v1/operations/deleteItem/index.ts new file mode 100644 index 000000000..d0cf82430 --- /dev/null +++ b/src/v1/operations/deleteItem/index.ts @@ -0,0 +1,3 @@ +export { DeleteItemCommand } from './command' +export type { DeleteItemResponse } from './command' +export type { DeleteItemOptions } from './options' diff --git a/src/v1/operations/deleteItem/options.ts b/src/v1/operations/deleteItem/options.ts new file mode 100644 index 000000000..de80d69d4 --- /dev/null +++ b/src/v1/operations/deleteItem/options.ts @@ -0,0 +1,21 @@ +import type { CapacityOption } from 'v1/operations/constants/options/capacity' +import type { MetricsOption } from 'v1/operations/constants/options/metrics' +import type { + NoneReturnValuesOption, + AllOldReturnValuesOption +} from 'v1/operations/constants/options/returnValues' +import type { Condition } from 'v1/operations/types' +import type { EntityV2 } from 'v1/entity' + +export type DeleteItemCommandReturnValuesOption = NoneReturnValuesOption | AllOldReturnValuesOption + +export const deleteItemCommandReturnValuesOptionsSet = new Set( + ['NONE', 'ALL_OLD'] +) + +export interface DeleteItemOptions { + capacity?: CapacityOption + metrics?: MetricsOption + returnValues?: DeleteItemCommandReturnValuesOption + condition?: Condition +} diff --git a/src/v1/operations/errors.ts b/src/v1/operations/errors.ts new file mode 100644 index 000000000..0eabf8eb4 --- /dev/null +++ b/src/v1/operations/errors.ts @@ -0,0 +1,82 @@ +import type { ErrorBlueprint } from 'v1/errors/blueprint' + +import type { ScanCommandErrorBlueprints } from './scan/errors' +import type { QueryCommandErrorBlueprints } from './query/errors' +import type { OperationUtilsErrorBlueprints } from './utils/errors' +import type { ExpressionParsersErrorBlueprints } from './expression/errors' + +type IncompleteOperationErrorBlueprint = ErrorBlueprint<{ + code: 'operations.incompleteCommand' + hasPath: false + payload: undefined +}> + +type InvalidCapacityOptionErrorBlueprint = ErrorBlueprint<{ + code: 'operations.invalidCapacityOption' + hasPath: false + payload: { capacity: unknown } +}> + +type InvalidConsistentOptionErrorBlueprint = ErrorBlueprint<{ + code: 'operations.invalidConsistentOption' + hasPath: false + payload: { consistent: unknown } +}> + +type InvalidIndexOptionErrorBlueprint = ErrorBlueprint<{ + code: 'operations.invalidIndexOption' + hasPath: false + payload: { index: unknown } +}> + +type InvalidLimitOptionErrorBlueprint = ErrorBlueprint<{ + code: 'operations.invalidLimitOption' + hasPath: false + payload: { limit: unknown } +}> + +type InvalidMaxPagesOptionErrorBlueprint = ErrorBlueprint<{ + code: 'operations.invalidMaxPagesOption' + hasPath: false + payload: { maxPages: unknown } +}> + +type InvalidMetricsOptionErrorBlueprint = ErrorBlueprint<{ + code: 'operations.invalidMetricsOption' + hasPath: false + payload: { metrics: unknown } +}> + +type InvalidReturnValuesOptionErrorBlueprint = ErrorBlueprint<{ + code: 'operations.invalidReturnValuesOption' + hasPath: false + payload: { returnValues: unknown } +}> + +type InvalidSelectOptionErrorBlueprint = ErrorBlueprint<{ + code: 'operations.invalidSelectOption' + hasPath: false + payload: { select: unknown } +}> + +type UnknownOptionErrorBlueprint = ErrorBlueprint<{ + code: 'operations.unknownOption' + hasPath: false + payload: { option: unknown } +}> + +export type OperationsErrorBlueprints = + | ScanCommandErrorBlueprints + | QueryCommandErrorBlueprints + | OperationUtilsErrorBlueprints + | IncompleteOperationErrorBlueprint + | InvalidCapacityOptionErrorBlueprint + | InvalidConsistentOptionErrorBlueprint + | InvalidIndexOptionErrorBlueprint + | InvalidLimitOptionErrorBlueprint + | InvalidMaxPagesOptionErrorBlueprint + | InvalidMetricsOptionErrorBlueprint + | InvalidReturnValuesOptionErrorBlueprint + | InvalidSelectOptionErrorBlueprint + | UnknownOptionErrorBlueprint + | ExpressionParsersErrorBlueprints diff --git a/src/v1/operations/expression/condition/errors.ts b/src/v1/operations/expression/condition/errors.ts new file mode 100644 index 000000000..5ac925beb --- /dev/null +++ b/src/v1/operations/expression/condition/errors.ts @@ -0,0 +1 @@ +export type { ConditionParserErrorBlueprints } from './parser/errors' diff --git a/src/v1/operations/expression/condition/parse.ts b/src/v1/operations/expression/condition/parse.ts new file mode 100644 index 000000000..cb319b9cd --- /dev/null +++ b/src/v1/operations/expression/condition/parse.ts @@ -0,0 +1,34 @@ +import type { NativeAttributeValue } from '@aws-sdk/util-dynamodb' + +import type { Schema } from 'v1/schema' +import type { EntityV2 } from 'v1/entity' +import type { Condition, SchemaCondition } from 'v1/operations/types' + +import { ConditionParser } from './parser' + +export const parseSchemaCondition = < + SCHEMA extends Schema, + CONDITION extends SchemaCondition +>( + schema: SCHEMA, + condition: CONDITION, + id?: string +): { + ConditionExpression: string + ExpressionAttributeNames: Record + ExpressionAttributeValues: Record +} => { + const conditionParser = new ConditionParser(schema, id) + conditionParser.parseCondition(condition) + return conditionParser.toCommandOptions() +} + +export const parseCondition = >( + entity: ENTITY, + condition: CONDITION, + id?: string +): { + ConditionExpression: string + ExpressionAttributeNames: Record + ExpressionAttributeValues: Record +} => parseSchemaCondition(entity.schema, condition, id) diff --git a/src/v1/operations/expression/condition/parser/appendAttributeValue.ts b/src/v1/operations/expression/condition/parser/appendAttributeValue.ts new file mode 100644 index 000000000..2a46088b9 --- /dev/null +++ b/src/v1/operations/expression/condition/parser/appendAttributeValue.ts @@ -0,0 +1,32 @@ +import type { Attribute, AttributeValue } from 'v1/schema' +import { parseAttributeClonedInput } from 'v1/validation/parseClonedInput' + +import type { ConditionParser } from './parser' + +export type AppendAttributeValueOptions = { transform?: boolean } + +export const appendAttributeValue = ( + conditionParser: ConditionParser, + attribute: Attribute, + expressionAttributeValue: unknown, + options: AppendAttributeValueOptions = {} +): void => { + const { transform = false } = options + + const inputParser = parseAttributeClonedInput( + attribute, + expressionAttributeValue as AttributeValue, + { transform } + ) + inputParser.next() // cloned + inputParser.next() // parsed + const collapsedInput = inputParser.next().value + + const expressionAttributeValueIndex = conditionParser.expressionAttributeValues.push( + collapsedInput + ) + + conditionParser.appendToExpression( + `:${conditionParser.expressionAttributePrefix}${expressionAttributeValueIndex}` + ) +} diff --git a/src/v1/operations/expression/condition/parser/appendAttributeValueOrPath.ts b/src/v1/operations/expression/condition/parser/appendAttributeValueOrPath.ts new file mode 100644 index 000000000..0df753780 --- /dev/null +++ b/src/v1/operations/expression/condition/parser/appendAttributeValueOrPath.ts @@ -0,0 +1,18 @@ +import type { Attribute } from 'v1/schema' + +import { isAttributePath, AppendAttributePathOptions } from '../../expressionParser' +import type { ConditionParser } from './parser' +import type { AppendAttributeValueOptions } from './appendAttributeValue' + +export const appendAttributeValueOrPath = ( + conditionParser: ConditionParser, + attribute: Attribute, + expressionAttributeValueOrPath: unknown, + options: AppendAttributeValueOptions & AppendAttributePathOptions = {} +): void => { + if (isAttributePath(expressionAttributeValueOrPath)) { + conditionParser.appendAttributePath(expressionAttributeValueOrPath.attr, options) + } else { + conditionParser.appendAttributeValue(attribute, expressionAttributeValueOrPath, options) + } +} diff --git a/src/v1/operations/expression/condition/parser/errors.ts b/src/v1/operations/expression/condition/parser/errors.ts new file mode 100644 index 000000000..e99ff603f --- /dev/null +++ b/src/v1/operations/expression/condition/parser/errors.ts @@ -0,0 +1 @@ +export type { ConditionParserErrorBlueprints } from './parseCondition/errors' diff --git a/src/v1/operations/expression/condition/parser/index.ts b/src/v1/operations/expression/condition/parser/index.ts new file mode 100644 index 000000000..cbe45935e --- /dev/null +++ b/src/v1/operations/expression/condition/parser/index.ts @@ -0,0 +1 @@ +export { ConditionParser } from './parser' diff --git a/src/v1/operations/expression/condition/parser/parseCondition/between/index.ts b/src/v1/operations/expression/condition/parser/parseCondition/between/index.ts new file mode 100644 index 000000000..1ce952d9e --- /dev/null +++ b/src/v1/operations/expression/condition/parser/parseCondition/between/index.ts @@ -0,0 +1,3 @@ +export { isBetweenCondition } from './types' +export type { BetweenCondition } from './types' +export { parseBetweenCondition } from './parseCondition' diff --git a/src/v1/operations/expression/condition/parser/parseCondition/between/parseCondition.ts b/src/v1/operations/expression/condition/parser/parseCondition/between/parseCondition.ts new file mode 100644 index 000000000..7cea892a3 --- /dev/null +++ b/src/v1/operations/expression/condition/parser/parseCondition/between/parseCondition.ts @@ -0,0 +1,19 @@ +import type { ConditionParser } from '../../parser' + +import type { BetweenCondition } from './types' + +export const parseBetweenCondition = ( + conditionParser: ConditionParser, + condition: BetweenCondition +): void => { + const attributePath = condition.size ?? condition.attr + const [lowerRange, higherRange] = condition.between + const { transform = true } = condition + + conditionParser.resetExpression() + const attribute = conditionParser.appendAttributePath(attributePath, { size: !!condition.size }) + conditionParser.appendToExpression(' BETWEEN ') + conditionParser.appendAttributeValueOrPath(attribute, lowerRange, { transform }) + conditionParser.appendToExpression(' AND ') + conditionParser.appendAttributeValueOrPath(attribute, higherRange, { transform }) +} diff --git a/src/v1/operations/expression/condition/parser/parseCondition/between/parseCondition.unit.test.ts b/src/v1/operations/expression/condition/parser/parseCondition/between/parseCondition.unit.test.ts new file mode 100644 index 000000000..e6a85605e --- /dev/null +++ b/src/v1/operations/expression/condition/parser/parseCondition/between/parseCondition.unit.test.ts @@ -0,0 +1,269 @@ +import { schema, number, list, map } from 'v1/schema' + +import { parseSchemaCondition } from '../../../parse' + +describe('parseCondition - between', () => { + const simpleSchema = schema({ + num: number(), + otherNum: number(), + yetAnotherNum: number() + }) + + it('between (values)', () => { + expect(parseSchemaCondition(simpleSchema, { attr: 'num', between: [42, 43] })).toStrictEqual({ + ConditionExpression: '#c_1 BETWEEN :c_1 AND :c_2', + ExpressionAttributeNames: { '#c_1': 'num' }, + ExpressionAttributeValues: { ':c_1': 42, ':c_2': 43 } + }) + }) + + it('between (value + attribute)', () => { + expect( + parseSchemaCondition(simpleSchema, { + attr: 'num', + between: [42, { attr: 'otherNum' }] + }) + ).toStrictEqual({ + ConditionExpression: '#c_1 BETWEEN :c_1 AND #c_2', + ExpressionAttributeNames: { '#c_1': 'num', '#c_2': 'otherNum' }, + ExpressionAttributeValues: { ':c_1': 42 } + }) + }) + + it('between (attributes)', () => { + expect( + parseSchemaCondition(simpleSchema, { + attr: 'num', + between: [{ attr: 'otherNum' }, { attr: 'yetAnotherNum' }] + }) + ).toStrictEqual({ + ConditionExpression: '#c_1 BETWEEN #c_2 AND #c_3', + ExpressionAttributeNames: { '#c_1': 'num', '#c_2': 'otherNum', '#c_3': 'yetAnotherNum' }, + ExpressionAttributeValues: {} + }) + }) + + it('deep maps (values)', () => { + expect( + parseSchemaCondition( + schema({ + map: map({ + nestedA: map({ + nestedB: number() + }) + }) + }), + { + attr: 'map.nestedA.nestedB', + between: [42, 43] + } + ) + ).toStrictEqual({ + ConditionExpression: '#c_1.#c_2.#c_3 BETWEEN :c_1 AND :c_2', + ExpressionAttributeNames: { + '#c_1': 'map', + '#c_2': 'nestedA', + '#c_3': 'nestedB' + }, + ExpressionAttributeValues: { ':c_1': 42, ':c_2': 43 } + }) + }) + + const deepMapsSchema = schema({ + map: map({ + nestedA: map({ + nestedB: number() + }) + }), + nestedC: map({ + otherNum: number() + }), + nestedD: map({ + yetAnotherNum: number() + }) + }) + + it('deep maps (attribute + value)', () => { + expect( + parseSchemaCondition(deepMapsSchema, { + attr: 'map.nestedA.nestedB', + between: [{ attr: 'nestedC.otherNum' }, 43] + }) + ).toStrictEqual({ + ConditionExpression: '#c_1.#c_2.#c_3 BETWEEN #c_4.#c_5 AND :c_1', + ExpressionAttributeNames: { + '#c_1': 'map', + '#c_2': 'nestedA', + '#c_3': 'nestedB', + '#c_4': 'nestedC', + '#c_5': 'otherNum' + }, + ExpressionAttributeValues: { ':c_1': 43 } + }) + }) + + it('deep maps (attributes)', () => { + expect( + parseSchemaCondition(deepMapsSchema, { + attr: 'map.nestedA.nestedB', + between: [{ attr: 'nestedC.otherNum' }, { attr: 'nestedD.yetAnotherNum' }] + }) + ).toStrictEqual({ + ConditionExpression: '#c_1.#c_2.#c_3 BETWEEN #c_4.#c_5 AND #c_6.#c_7', + ExpressionAttributeNames: { + '#c_1': 'map', + '#c_2': 'nestedA', + '#c_3': 'nestedB', + '#c_4': 'nestedC', + '#c_5': 'otherNum', + '#c_6': 'nestedD', + '#c_7': 'yetAnotherNum' + }, + ExpressionAttributeValues: {} + }) + }) + + const deepMapsAndListsSchema = schema({ + listA: list( + map({ + nested: map({ + listB: list(map({ value: number() })) + }) + }) + ), + listC: list( + map({ + nested: map({ + listD: list(map({ value: number() })) + }) + }) + ), + listE: list( + map({ + nested: map({ + listF: list(map({ value: number() })) + }) + }) + ) + }) + + it('deep maps and lists (values)', () => { + expect( + parseSchemaCondition(deepMapsAndListsSchema, { + attr: 'listA[1].nested.listB[2].value', + between: [42, 43] + }) + ).toStrictEqual({ + ConditionExpression: '#c_1[1].#c_2.#c_3[2].#c_4 BETWEEN :c_1 AND :c_2', + ExpressionAttributeNames: { + '#c_1': 'listA', + '#c_2': 'nested', + '#c_3': 'listB', + '#c_4': 'value' + }, + ExpressionAttributeValues: { ':c_1': 42, ':c_2': 43 } + }) + }) + + it('deep maps and lists (value + attribute)', () => { + expect( + parseSchemaCondition(deepMapsAndListsSchema, { + attr: 'listA[1].nested.listB[2].value', + between: [42, { attr: 'listC[3].nested.listD[4].value' }] + }) + ).toStrictEqual({ + ConditionExpression: '#c_1[1].#c_2.#c_3[2].#c_4 BETWEEN :c_1 AND #c_5[3].#c_6.#c_7[4].#c_8', + ExpressionAttributeNames: { + '#c_1': 'listA', + '#c_2': 'nested', + '#c_3': 'listB', + '#c_4': 'value', + '#c_5': 'listC', + '#c_6': 'nested', + '#c_7': 'listD', + '#c_8': 'value' + }, + ExpressionAttributeValues: { ':c_1': 42 } + }) + }) + + it('deep maps and lists (attributes)', () => { + expect( + parseSchemaCondition(deepMapsAndListsSchema, { + attr: 'listA[1].nested.listB[2].value', + between: [ + { attr: 'listC[3].nested.listD[4].value' }, + { attr: 'listE[3].nested.listF[4].value' } + ] + }) + ).toStrictEqual({ + ConditionExpression: + '#c_1[1].#c_2.#c_3[2].#c_4 BETWEEN #c_5[3].#c_6.#c_7[4].#c_8 AND #c_9[3].#c_10.#c_11[4].#c_12', + ExpressionAttributeNames: { + '#c_1': 'listA', + '#c_2': 'nested', + '#c_3': 'listB', + '#c_4': 'value', + '#c_5': 'listC', + '#c_6': 'nested', + '#c_7': 'listD', + '#c_8': 'value', + '#c_9': 'listE', + '#c_10': 'nested', + '#c_11': 'listF', + '#c_12': 'value' + }, + ExpressionAttributeValues: {} + }) + }) + + const deepListsSchema = schema({ + list: list(list(list(number()))), + listB: list(list(list(number()))), + listC: list(list(list(number()))) + }) + + it('deep lists (values)', () => { + expect( + parseSchemaCondition(deepListsSchema, { attr: 'list[1][2][3]', between: [42, 43] }) + ).toStrictEqual({ + ConditionExpression: '#c_1[1][2][3] BETWEEN :c_1 AND :c_2', + ExpressionAttributeNames: { '#c_1': 'list' }, + ExpressionAttributeValues: { ':c_1': 42, ':c_2': 43 } + }) + }) + + it('deep lists (attribute + value)', () => { + expect( + parseSchemaCondition(deepListsSchema, { + attr: 'list[1][2][3]', + between: [{ attr: 'listB[4][5][6]' }, 42] + }) + ).toStrictEqual({ + ConditionExpression: '#c_1[1][2][3] BETWEEN #c_2[4][5][6] AND :c_1', + ExpressionAttributeNames: { '#c_1': 'list', '#c_2': 'listB' }, + ExpressionAttributeValues: { ':c_1': 42 } + }) + }) + + it('deep lists (attributes)', () => { + expect( + parseSchemaCondition(deepListsSchema, { + attr: 'list[1][2][3]', + between: [{ attr: 'listB[4][5][6]' }, { attr: 'listC[7][8][9]' }] + }) + ).toStrictEqual({ + ConditionExpression: '#c_1[1][2][3] BETWEEN #c_2[4][5][6] AND #c_3[7][8][9]', + ExpressionAttributeNames: { '#c_1': 'list', '#c_2': 'listB', '#c_3': 'listC' }, + ExpressionAttributeValues: {} + }) + }) + + it('with size', () => { + expect(parseSchemaCondition(simpleSchema, { size: 'num', between: [42, 43] })).toStrictEqual({ + ConditionExpression: 'size(#c_1) BETWEEN :c_1 AND :c_2', + ExpressionAttributeNames: { '#c_1': 'num' }, + ExpressionAttributeValues: { ':c_1': 42, ':c_2': 43 } + }) + }) +}) diff --git a/src/v1/operations/expression/condition/parser/parseCondition/between/types.ts b/src/v1/operations/expression/condition/parser/parseCondition/between/types.ts new file mode 100644 index 000000000..cf35bf34f --- /dev/null +++ b/src/v1/operations/expression/condition/parser/parseCondition/between/types.ts @@ -0,0 +1,8 @@ +import type { AnyAttributeCondition, NonLogicalCondition, Condition } from 'v1/operations/types' + +export type BetweenOperator = 'between' +export type BetweenCondition = NonLogicalCondition & + Extract, Record> + +export const isBetweenCondition = (condition: Condition): condition is BetweenCondition => + 'between' in condition diff --git a/src/v1/operations/expression/condition/parser/parseCondition/comparison/index.ts b/src/v1/operations/expression/condition/parser/parseCondition/comparison/index.ts new file mode 100644 index 000000000..30e04c760 --- /dev/null +++ b/src/v1/operations/expression/condition/parser/parseCondition/comparison/index.ts @@ -0,0 +1,3 @@ +export { isComparisonCondition } from './types' +export type { ComparisonCondition } from './types' +export { parseComparisonCondition } from './parseCondition' diff --git a/src/v1/operations/expression/condition/parser/parseCondition/comparison/parseCondition.ts b/src/v1/operations/expression/condition/parser/parseCondition/comparison/parseCondition.ts new file mode 100644 index 000000000..89dd491ea --- /dev/null +++ b/src/v1/operations/expression/condition/parser/parseCondition/comparison/parseCondition.ts @@ -0,0 +1,29 @@ +import type { ConditionParser } from '../../parser' + +import { isComparisonOperator, ComparisonCondition, ComparisonOperator } from './types' + +const comparisonOperatorExpression: Record = { + eq: '=', + ne: '<>', + gt: '>', + gte: '>=', + lt: '<', + lte: '<=' +} + +export const parseComparisonCondition = ( + conditionParser: ConditionParser, + condition: CONDITION +): void => { + const comparisonOperator = Object.keys(condition).find(isComparisonOperator) as keyof CONDITION & + ComparisonOperator + + const attributePath = condition.size ?? condition.attr + const expressionAttributeValue = condition[comparisonOperator] + const { transform = true } = condition + + conditionParser.resetExpression() + const attribute = conditionParser.appendAttributePath(attributePath, { size: !!condition.size }) + conditionParser.appendToExpression(` ${comparisonOperatorExpression[comparisonOperator]} `) + conditionParser.appendAttributeValueOrPath(attribute, expressionAttributeValue, { transform }) +} diff --git a/src/v1/operations/expression/condition/parser/parseCondition/comparison/parseCondition.unit.test.ts b/src/v1/operations/expression/condition/parser/parseCondition/comparison/parseCondition.unit.test.ts new file mode 100644 index 000000000..c5d78db99 --- /dev/null +++ b/src/v1/operations/expression/condition/parser/parseCondition/comparison/parseCondition.unit.test.ts @@ -0,0 +1,244 @@ +import { schema, map, list, number } from 'v1/schema' + +import { parseSchemaCondition } from '../../../parse' + +describe('parseCondition - comparison', () => { + const simpleSchema = schema({ + num: number(), + otherNum: number() + }) + + it('equal to (value)', () => { + expect(parseSchemaCondition(simpleSchema, { attr: 'num', eq: 42 })).toStrictEqual({ + ConditionExpression: '#c_1 = :c_1', + ExpressionAttributeNames: { '#c_1': 'num' }, + ExpressionAttributeValues: { ':c_1': 42 } + }) + }) + + it('equal to (attribute)', () => { + expect( + parseSchemaCondition(simpleSchema, { attr: 'num', eq: { attr: 'otherNum' } }) + ).toStrictEqual({ + ConditionExpression: '#c_1 = #c_2', + ExpressionAttributeNames: { '#c_1': 'num', '#c_2': 'otherNum' }, + ExpressionAttributeValues: {} + }) + }) + + it('not equal to (value)', () => { + expect(parseSchemaCondition(simpleSchema, { attr: 'num', ne: 42 })).toStrictEqual({ + ConditionExpression: '#c_1 <> :c_1', + ExpressionAttributeNames: { '#c_1': 'num' }, + ExpressionAttributeValues: { ':c_1': 42 } + }) + }) + + it('not equal to (attribute)', () => { + expect( + parseSchemaCondition(simpleSchema, { attr: 'num', ne: { attr: 'otherNum' } }) + ).toStrictEqual({ + ConditionExpression: '#c_1 <> #c_2', + ExpressionAttributeNames: { '#c_1': 'num', '#c_2': 'otherNum' }, + ExpressionAttributeValues: {} + }) + }) + + it('greater than (value)', () => { + expect(parseSchemaCondition(simpleSchema, { attr: 'num', gt: 42 })).toStrictEqual({ + ConditionExpression: '#c_1 > :c_1', + ExpressionAttributeNames: { '#c_1': 'num' }, + ExpressionAttributeValues: { ':c_1': 42 } + }) + }) + + it('greater than (attribute)', () => { + expect( + parseSchemaCondition(simpleSchema, { attr: 'num', gt: { attr: 'otherNum' } }) + ).toStrictEqual({ + ConditionExpression: '#c_1 > #c_2', + ExpressionAttributeNames: { '#c_1': 'num', '#c_2': 'otherNum' }, + ExpressionAttributeValues: {} + }) + }) + + it('greater than or equal to (value)', () => { + expect(parseSchemaCondition(simpleSchema, { attr: 'num', gte: 42 })).toStrictEqual({ + ConditionExpression: '#c_1 >= :c_1', + ExpressionAttributeNames: { '#c_1': 'num' }, + ExpressionAttributeValues: { ':c_1': 42 } + }) + }) + + it('greater than or equal to (attribute)', () => { + expect( + parseSchemaCondition(simpleSchema, { attr: 'num', gte: { attr: 'otherNum' } }) + ).toStrictEqual({ + ConditionExpression: '#c_1 >= #c_2', + ExpressionAttributeNames: { '#c_1': 'num', '#c_2': 'otherNum' }, + ExpressionAttributeValues: {} + }) + }) + + it('less than (value)', () => { + expect(parseSchemaCondition(simpleSchema, { attr: 'num', lt: 42 })).toStrictEqual({ + ConditionExpression: '#c_1 < :c_1', + ExpressionAttributeNames: { '#c_1': 'num' }, + ExpressionAttributeValues: { ':c_1': 42 } + }) + }) + + it('less than (attribute)', () => { + expect( + parseSchemaCondition(simpleSchema, { attr: 'num', lt: { attr: 'otherNum' } }) + ).toStrictEqual({ + ConditionExpression: '#c_1 < #c_2', + ExpressionAttributeNames: { '#c_1': 'num', '#c_2': 'otherNum' }, + ExpressionAttributeValues: {} + }) + }) + + it('less than or equal to (value)', () => { + expect(parseSchemaCondition(simpleSchema, { attr: 'num', lte: 42 })).toStrictEqual({ + ConditionExpression: '#c_1 <= :c_1', + ExpressionAttributeNames: { '#c_1': 'num' }, + ExpressionAttributeValues: { ':c_1': 42 } + }) + }) + + it('less than or equal to (attribute)', () => { + expect( + parseSchemaCondition(simpleSchema, { attr: 'num', lte: { attr: 'otherNum' } }) + ).toStrictEqual({ + ConditionExpression: '#c_1 <= #c_2', + ExpressionAttributeNames: { '#c_1': 'num', '#c_2': 'otherNum' }, + ExpressionAttributeValues: {} + }) + }) + + const mapSchema = schema({ + map: map({ + nestedA: map({ nestedB: number() }) + }), + other: map({ + nested: map({ value: number() }) + }) + }) + + it('deep maps (value)', () => { + expect(parseSchemaCondition(mapSchema, { attr: 'map.nestedA.nestedB', eq: 42 })).toStrictEqual({ + ConditionExpression: '#c_1.#c_2.#c_3 = :c_1', + ExpressionAttributeNames: { + '#c_1': 'map', + '#c_2': 'nestedA', + '#c_3': 'nestedB' + }, + ExpressionAttributeValues: { ':c_1': 42 } + }) + }) + + it('deep maps (attribute)', () => { + expect( + parseSchemaCondition(mapSchema, { + attr: 'map.nestedA.nestedB', + eq: { attr: 'other.nested.value' } + }) + ).toStrictEqual({ + ConditionExpression: '#c_1.#c_2.#c_3 = #c_4.#c_5.#c_6', + ExpressionAttributeNames: { + '#c_1': 'map', + '#c_2': 'nestedA', + '#c_3': 'nestedB', + '#c_4': 'other', + '#c_5': 'nested', + '#c_6': 'value' + }, + ExpressionAttributeValues: {} + }) + }) + + const mapAndListSchema = schema({ + listA: list( + map({ + nested: map({ + listB: list(map({ value: number() })) + }) + }) + ), + listC: list( + map({ + nested: map({ + listD: list(map({ value: number() })) + }) + }) + ) + }) + + it('deep maps and lists (value)', () => { + expect( + parseSchemaCondition(mapAndListSchema, { attr: 'listA[1].nested.listB[2].value', eq: 42 }) + ).toStrictEqual({ + ConditionExpression: '#c_1[1].#c_2.#c_3[2].#c_4 = :c_1', + ExpressionAttributeNames: { + '#c_1': 'listA', + '#c_2': 'nested', + '#c_3': 'listB', + '#c_4': 'value' + }, + ExpressionAttributeValues: { ':c_1': 42 } + }) + }) + + it('deep maps and lists (attribute)', () => { + expect( + parseSchemaCondition(mapAndListSchema, { + attr: 'listA[1].nested.listB[2].value', + eq: { attr: 'listC[3].nested.listD[4].value' } + }) + ).toStrictEqual({ + ConditionExpression: '#c_1[1].#c_2.#c_3[2].#c_4 = #c_5[3].#c_6.#c_7[4].#c_8', + ExpressionAttributeNames: { + '#c_1': 'listA', + '#c_2': 'nested', + '#c_3': 'listB', + '#c_4': 'value', + '#c_5': 'listC', + '#c_6': 'nested', + '#c_7': 'listD', + '#c_8': 'value' + }, + ExpressionAttributeValues: {} + }) + }) + + const listSchema = schema({ + listA: list(list(list(number()))), + listB: list(list(list(number()))) + }) + + it('deep lists (value)', () => { + expect(parseSchemaCondition(listSchema, { attr: 'listA[1][2][3]', eq: 42 })).toStrictEqual({ + ConditionExpression: '#c_1[1][2][3] = :c_1', + ExpressionAttributeNames: { '#c_1': 'listA' }, + ExpressionAttributeValues: { ':c_1': 42 } + }) + }) + + it('deep lists (attribute)', () => { + expect( + parseSchemaCondition(listSchema, { attr: 'listA[1][2][3]', eq: { attr: 'listB[4][5][6]' } }) + ).toStrictEqual({ + ConditionExpression: '#c_1[1][2][3] = #c_2[4][5][6]', + ExpressionAttributeNames: { '#c_1': 'listA', '#c_2': 'listB' }, + ExpressionAttributeValues: {} + }) + }) + + it('with size', () => { + expect(parseSchemaCondition(simpleSchema, { size: 'num', eq: 42 })).toStrictEqual({ + ConditionExpression: 'size(#c_1) = :c_1', + ExpressionAttributeNames: { '#c_1': 'num' }, + ExpressionAttributeValues: { ':c_1': 42 } + }) + }) +}) diff --git a/src/v1/operations/expression/condition/parser/parseCondition/comparison/types.ts b/src/v1/operations/expression/condition/parser/parseCondition/comparison/types.ts new file mode 100644 index 000000000..c9146ba0e --- /dev/null +++ b/src/v1/operations/expression/condition/parser/parseCondition/comparison/types.ts @@ -0,0 +1,19 @@ +import type { AnyAttributeCondition, NonLogicalCondition, Condition } from 'v1/operations/types' + +export type RangeOperator = 'gt' | 'gte' | 'lt' | 'lte' +export type ComparisonOperator = 'eq' | 'ne' | RangeOperator + +const comparisonOperatorSet = new Set(['eq', 'ne', 'gt', 'gte', 'lt', 'lte']) + +export const isComparisonOperator = (key: string): key is ComparisonOperator => + comparisonOperatorSet.has(key as ComparisonOperator) + +export type ComparisonCondition = NonLogicalCondition & + (ComparisonOperator extends infer OPERATOR + ? OPERATOR extends string + ? Extract, { [KEY in OPERATOR]: unknown }> + : never + : never) + +export const isComparisonCondition = (condition: Condition): condition is ComparisonCondition => + Object.keys(condition).some(isComparisonOperator) diff --git a/src/v1/operations/expression/condition/parser/parseCondition/errors.ts b/src/v1/operations/expression/condition/parser/parseCondition/errors.ts new file mode 100644 index 000000000..28a5b7791 --- /dev/null +++ b/src/v1/operations/expression/condition/parser/parseCondition/errors.ts @@ -0,0 +1,9 @@ +import type { ErrorBlueprint } from 'v1/errors/blueprint' + +type InvalidConditionErrorBlueprint = ErrorBlueprint<{ + code: 'operations.invalidCondition' + hasPath: false + payload: undefined +}> + +export type ConditionParserErrorBlueprints = InvalidConditionErrorBlueprint diff --git a/src/v1/operations/expression/condition/parser/parseCondition/in/index.ts b/src/v1/operations/expression/condition/parser/parseCondition/in/index.ts new file mode 100644 index 000000000..bb35a739f --- /dev/null +++ b/src/v1/operations/expression/condition/parser/parseCondition/in/index.ts @@ -0,0 +1,3 @@ +export { isInCondition } from './types' +export type { InCondition } from './types' +export { parseInCondition } from './parseCondition' diff --git a/src/v1/operations/expression/condition/parser/parseCondition/in/parseCondition.ts b/src/v1/operations/expression/condition/parser/parseCondition/in/parseCondition.ts new file mode 100644 index 000000000..d8aaf710d --- /dev/null +++ b/src/v1/operations/expression/condition/parser/parseCondition/in/parseCondition.ts @@ -0,0 +1,23 @@ +import type { ConditionParser } from '../../parser' + +import type { InCondition } from './types' + +export const parseInCondition = ( + conditionParser: ConditionParser, + condition: InCondition +): void => { + const attributePath = condition.size ?? condition.attr + const expressionAttributeValues = condition.in + const { transform = true } = condition + + conditionParser.resetExpression() + const attribute = conditionParser.appendAttributePath(attributePath, { size: !!condition.size }) + conditionParser.appendToExpression(' IN (') + expressionAttributeValues.forEach((expressionAttributeValue, index) => { + if (index > 0) { + conditionParser.appendToExpression(', ') + } + conditionParser.appendAttributeValueOrPath(attribute, expressionAttributeValue, { transform }) + }) + conditionParser.appendToExpression(')') +} diff --git a/src/v1/operations/expression/condition/parser/parseCondition/in/parseCondition.unit.test.ts b/src/v1/operations/expression/condition/parser/parseCondition/in/parseCondition.unit.test.ts new file mode 100644 index 000000000..aa36f926d --- /dev/null +++ b/src/v1/operations/expression/condition/parser/parseCondition/in/parseCondition.unit.test.ts @@ -0,0 +1,248 @@ +import { schema, list, map, number } from 'v1/schema' + +import { parseSchemaCondition } from '../../../parse' + +describe('parseCondition - in', () => { + const simpleSchema = schema({ + num: number(), + otherNum: number(), + yetAnotherNum: number() + }) + + it('in (values)', () => { + expect(parseSchemaCondition(simpleSchema, { attr: 'num', in: [42, 43] })).toStrictEqual({ + ConditionExpression: '#c_1 IN (:c_1, :c_2)', + ExpressionAttributeNames: { '#c_1': 'num' }, + ExpressionAttributeValues: { ':c_1': 42, ':c_2': 43 } + }) + }) + + it('in (value + attribute)', () => { + expect( + parseSchemaCondition(simpleSchema, { attr: 'num', in: [42, { attr: 'otherNum' }] }) + ).toStrictEqual({ + ConditionExpression: '#c_1 IN (:c_1, #c_2)', + ExpressionAttributeNames: { '#c_1': 'num', '#c_2': 'otherNum' }, + ExpressionAttributeValues: { ':c_1': 42 } + }) + }) + + it('in (attributes)', () => { + expect( + parseSchemaCondition(simpleSchema, { + attr: 'num', + in: [{ attr: 'otherNum' }, { attr: 'yetAnotherNum' }] + }) + ).toStrictEqual({ + ConditionExpression: '#c_1 IN (#c_2, #c_3)', + ExpressionAttributeNames: { '#c_1': 'num', '#c_2': 'otherNum', '#c_3': 'yetAnotherNum' }, + ExpressionAttributeValues: {} + }) + }) + + const nestedSchema = schema({ + map: map({ + nestedA: map({ + nestedB: number() + }) + }), + nestedC: map({ + otherNum: number() + }), + nestedD: map({ + yetAnotherNum: number() + }) + }) + + it('deep maps (values)', () => { + expect( + parseSchemaCondition(nestedSchema, { attr: 'map.nestedA.nestedB', in: [42, 43] }) + ).toStrictEqual({ + ConditionExpression: '#c_1.#c_2.#c_3 IN (:c_1, :c_2)', + ExpressionAttributeNames: { + '#c_1': 'map', + '#c_2': 'nestedA', + '#c_3': 'nestedB' + }, + ExpressionAttributeValues: { ':c_1': 42, ':c_2': 43 } + }) + }) + + it('deep maps (attribute + value)', () => { + expect( + parseSchemaCondition(nestedSchema, { + attr: 'map.nestedA.nestedB', + in: [{ attr: 'nestedC.otherNum' }, 43] + }) + ).toStrictEqual({ + ConditionExpression: '#c_1.#c_2.#c_3 IN (#c_4.#c_5, :c_1)', + ExpressionAttributeNames: { + '#c_1': 'map', + '#c_2': 'nestedA', + '#c_3': 'nestedB', + '#c_4': 'nestedC', + '#c_5': 'otherNum' + }, + ExpressionAttributeValues: { ':c_1': 43 } + }) + }) + + it('deep maps (attributes)', () => { + expect( + parseSchemaCondition(nestedSchema, { + attr: 'map.nestedA.nestedB', + in: [{ attr: 'nestedC.otherNum' }, { attr: 'nestedD.yetAnotherNum' }] + }) + ).toStrictEqual({ + ConditionExpression: '#c_1.#c_2.#c_3 IN (#c_4.#c_5, #c_6.#c_7)', + ExpressionAttributeNames: { + '#c_1': 'map', + '#c_2': 'nestedA', + '#c_3': 'nestedB', + '#c_4': 'nestedC', + '#c_5': 'otherNum', + '#c_6': 'nestedD', + '#c_7': 'yetAnotherNum' + }, + ExpressionAttributeValues: {} + }) + }) + + const mapAndList = schema({ + listA: list( + map({ + nested: map({ + listB: list(map({ value: number() })) + }) + }) + ), + listC: list( + map({ + nested: map({ + listD: list(map({ value: number() })) + }) + }) + ), + listE: list( + map({ + nested: map({ + listF: list(map({ value: number() })) + }) + }) + ) + }) + + it('deep maps and lists (values)', () => { + expect( + parseSchemaCondition(mapAndList, { attr: 'listA[1].nested.listB[2].value', in: [42, 43] }) + ).toStrictEqual({ + ConditionExpression: '#c_1[1].#c_2.#c_3[2].#c_4 IN (:c_1, :c_2)', + ExpressionAttributeNames: { + '#c_1': 'listA', + '#c_2': 'nested', + '#c_3': 'listB', + '#c_4': 'value' + }, + ExpressionAttributeValues: { ':c_1': 42, ':c_2': 43 } + }) + }) + + it('deep maps and lists (value + attribute)', () => { + expect( + parseSchemaCondition(mapAndList, { + attr: 'listA[1].nested.listB[2].value', + in: [42, { attr: 'listC[3].nested.listD[4].value' }] + }) + ).toStrictEqual({ + ConditionExpression: '#c_1[1].#c_2.#c_3[2].#c_4 IN (:c_1, #c_5[3].#c_6.#c_7[4].#c_8)', + ExpressionAttributeNames: { + '#c_1': 'listA', + '#c_2': 'nested', + '#c_3': 'listB', + '#c_4': 'value', + '#c_5': 'listC', + '#c_6': 'nested', + '#c_7': 'listD', + '#c_8': 'value' + }, + ExpressionAttributeValues: { ':c_1': 42 } + }) + }) + + it('deep maps and lists (attributes)', () => { + expect( + parseSchemaCondition(mapAndList, { + attr: 'listA[1].nested.listB[2].value', + in: [{ attr: 'listC[3].nested.listD[4].value' }, { attr: 'listE[3].nested.listF[4].value' }] + }) + ).toStrictEqual({ + ConditionExpression: + '#c_1[1].#c_2.#c_3[2].#c_4 IN (#c_5[3].#c_6.#c_7[4].#c_8, #c_9[3].#c_10.#c_11[4].#c_12)', + ExpressionAttributeNames: { + '#c_1': 'listA', + '#c_2': 'nested', + '#c_3': 'listB', + '#c_4': 'value', + '#c_5': 'listC', + '#c_6': 'nested', + '#c_7': 'listD', + '#c_8': 'value', + '#c_9': 'listE', + '#c_10': 'nested', + '#c_11': 'listF', + '#c_12': 'value' + }, + ExpressionAttributeValues: {} + }) + }) + + const listsSchema = schema({ + list: list(list(list(number()))), + listB: list(list(list(number()))), + listC: list(list(list(number()))) + }) + + it('deep lists (values)', () => { + expect( + parseSchemaCondition(listsSchema, { attr: 'list[1][2][3]', in: [42, 43] }) + ).toStrictEqual({ + ConditionExpression: '#c_1[1][2][3] IN (:c_1, :c_2)', + ExpressionAttributeNames: { '#c_1': 'list' }, + ExpressionAttributeValues: { ':c_1': 42, ':c_2': 43 } + }) + }) + + it('deep lists (attribute + value)', () => { + expect( + parseSchemaCondition(listsSchema, { + attr: 'list[1][2][3]', + in: [{ attr: 'listB[4][5][6]' }, 42] + }) + ).toStrictEqual({ + ConditionExpression: '#c_1[1][2][3] IN (#c_2[4][5][6], :c_1)', + ExpressionAttributeNames: { '#c_1': 'list', '#c_2': 'listB' }, + ExpressionAttributeValues: { ':c_1': 42 } + }) + }) + + it('deep lists (attributes)', () => { + expect( + parseSchemaCondition(listsSchema, { + attr: 'list[1][2][3]', + in: [{ attr: 'listB[4][5][6]' }, { attr: 'listC[7][8][9]' }] + }) + ).toStrictEqual({ + ConditionExpression: '#c_1[1][2][3] IN (#c_2[4][5][6], #c_3[7][8][9])', + ExpressionAttributeNames: { '#c_1': 'list', '#c_2': 'listB', '#c_3': 'listC' }, + ExpressionAttributeValues: {} + }) + }) + + it('with size', () => { + expect(parseSchemaCondition(simpleSchema, { size: 'num', in: [42, 43] })).toStrictEqual({ + ConditionExpression: 'size(#c_1) IN (:c_1, :c_2)', + ExpressionAttributeNames: { '#c_1': 'num' }, + ExpressionAttributeValues: { ':c_1': 42, ':c_2': 43 } + }) + }) +}) diff --git a/src/v1/operations/expression/condition/parser/parseCondition/in/types.ts b/src/v1/operations/expression/condition/parser/parseCondition/in/types.ts new file mode 100644 index 000000000..e9f5a8d81 --- /dev/null +++ b/src/v1/operations/expression/condition/parser/parseCondition/in/types.ts @@ -0,0 +1,6 @@ +import type { AnyAttributeCondition, NonLogicalCondition, Condition } from 'v1/operations/types' + +export type InCondition = NonLogicalCondition & + Extract, { in: unknown }> + +export const isInCondition = (condition: Condition): condition is InCondition => 'in' in condition diff --git a/src/v1/operations/expression/condition/parser/parseCondition/index.ts b/src/v1/operations/expression/condition/parser/parseCondition/index.ts new file mode 100644 index 000000000..416178799 --- /dev/null +++ b/src/v1/operations/expression/condition/parser/parseCondition/index.ts @@ -0,0 +1 @@ +export { parseCondition } from './parseCondition' diff --git a/src/v1/operations/expression/condition/parser/parseCondition/logicalCombination/index.ts b/src/v1/operations/expression/condition/parser/parseCondition/logicalCombination/index.ts new file mode 100644 index 000000000..b6088ba5e --- /dev/null +++ b/src/v1/operations/expression/condition/parser/parseCondition/logicalCombination/index.ts @@ -0,0 +1,3 @@ +export { isLogicalCombinationCondition } from './types' +export type { LogicalCombinationCondition } from './types' +export { parseLogicalCombinationCondition } from './parseCondition' diff --git a/src/v1/operations/expression/condition/parser/parseCondition/logicalCombination/parseCondition.ts b/src/v1/operations/expression/condition/parser/parseCondition/logicalCombination/parseCondition.ts new file mode 100644 index 000000000..bd23dda2a --- /dev/null +++ b/src/v1/operations/expression/condition/parser/parseCondition/logicalCombination/parseCondition.ts @@ -0,0 +1,43 @@ +import type { Condition } from 'v1/operations/types' + +import type { ConditionParser } from '../../parser' + +import { + isLogicalCombinationOperator, + LogicalCombinationCondition, + LogicalCombinationOperator +} from './types' + +const logicalCombinationOperatorExpression: Record = { + or: 'OR', + and: 'AND' +} + +type AppendLogicalCombinationCondition = ( + conditionParser: ConditionParser, + condition: CONDITION +) => void + +export const parseLogicalCombinationCondition: AppendLogicalCombinationCondition = < + CONDITION extends LogicalCombinationCondition +>( + conditionParser: ConditionParser, + condition: CONDITION +): void => { + const logicalCombinationOperator = Object.keys(condition).find( + isLogicalCombinationOperator + ) as keyof CONDITION & LogicalCombinationOperator + + const childrenConditions = (condition[logicalCombinationOperator] as unknown) as Condition[] + const childrenConditionExpressions: string[] = [] + conditionParser.resetExpression() + for (const childCondition of childrenConditions) { + conditionParser.parseCondition(childCondition) + childrenConditionExpressions.push(conditionParser.expression) + } + conditionParser.resetExpression( + `(${childrenConditionExpressions.join( + `) ${logicalCombinationOperatorExpression[logicalCombinationOperator]} (` + )})` + ) +} diff --git a/src/v1/operations/expression/condition/parser/parseCondition/logicalCombination/parseCondition.unit.test.ts b/src/v1/operations/expression/condition/parser/parseCondition/logicalCombination/parseCondition.unit.test.ts new file mode 100644 index 000000000..e0f9cea16 --- /dev/null +++ b/src/v1/operations/expression/condition/parser/parseCondition/logicalCombination/parseCondition.unit.test.ts @@ -0,0 +1,103 @@ +import { schema, number, string, boolean } from 'v1/schema' + +import { parseSchemaCondition } from '../../../parse' + +describe('parseCondition - Logical combination', () => { + const mySchema = schema({ + num: number(), + otherNum: number(), + str: string(), + otherStr: string(), + bool: boolean() + }) + + it('combines OR children conditions (value)', () => { + expect( + parseSchemaCondition(mySchema, { + or: [ + { attr: 'num', eq: 42 }, + { attr: 'str', eq: 'foo' } + ] + }) + ).toStrictEqual({ + ConditionExpression: '(#c_1 = :c_1) OR (#c_2 = :c_2)', + ExpressionAttributeNames: { '#c_1': 'num', '#c_2': 'str' }, + ExpressionAttributeValues: { ':c_1': 42, ':c_2': 'foo' } + }) + }) + + it('combines OR children conditions (attribute)', () => { + expect( + parseSchemaCondition(mySchema, { + or: [ + { attr: 'num', eq: { attr: 'otherNum' } }, + { attr: 'str', eq: { attr: 'otherStr' } } + ] + }) + ).toStrictEqual({ + ConditionExpression: '(#c_1 = #c_2) OR (#c_3 = #c_4)', + ExpressionAttributeNames: { + '#c_1': 'num', + '#c_2': 'otherNum', + '#c_3': 'str', + '#c_4': 'otherStr' + }, + ExpressionAttributeValues: {} + }) + }) + + it('combines AND children conditions (value)', () => { + expect( + parseSchemaCondition(mySchema, { + and: [ + { attr: 'num', eq: 42 }, + { attr: 'str', eq: 'foo' } + ] + }) + ).toStrictEqual({ + ConditionExpression: '(#c_1 = :c_1) AND (#c_2 = :c_2)', + ExpressionAttributeNames: { '#c_1': 'num', '#c_2': 'str' }, + ExpressionAttributeValues: { ':c_1': 42, ':c_2': 'foo' } + }) + }) + + it('combines AND children conditions (attribute)', () => { + expect( + parseSchemaCondition(mySchema, { + and: [ + { attr: 'num', eq: { attr: 'otherNum' } }, + { attr: 'str', eq: { attr: 'otherStr' } } + ] + }) + ).toStrictEqual({ + ConditionExpression: '(#c_1 = #c_2) AND (#c_3 = #c_4)', + ExpressionAttributeNames: { + '#c_1': 'num', + '#c_2': 'otherNum', + '#c_3': 'str', + '#c_4': 'otherStr' + }, + ExpressionAttributeValues: {} + }) + }) + + it('combines nested combinations', () => { + expect( + parseSchemaCondition(mySchema, { + and: [ + { + or: [ + { attr: 'num', eq: 42 }, + { attr: 'bool', eq: true } + ] + }, + { attr: 'str', eq: 'foo' } + ] + }) + ).toStrictEqual({ + ConditionExpression: '((#c_1 = :c_1) OR (#c_2 = :c_2)) AND (#c_3 = :c_3)', + ExpressionAttributeNames: { '#c_1': 'num', '#c_2': 'bool', '#c_3': 'str' }, + ExpressionAttributeValues: { ':c_1': 42, ':c_2': true, ':c_3': 'foo' } + }) + }) +}) diff --git a/src/v1/operations/expression/condition/parser/parseCondition/logicalCombination/types.ts b/src/v1/operations/expression/condition/parser/parseCondition/logicalCombination/types.ts new file mode 100644 index 000000000..ebb01e83b --- /dev/null +++ b/src/v1/operations/expression/condition/parser/parseCondition/logicalCombination/types.ts @@ -0,0 +1,24 @@ +import type { Condition } from 'v1/operations/types' + +export type LogicalCombinationOperator = 'and' | 'or' + +const logicalCombinationOperatorSet = new Set(['and', 'or']) + +export const isLogicalCombinationOperator = (key: string): key is LogicalCombinationOperator => + logicalCombinationOperatorSet.has(key as LogicalCombinationOperator) + +export type LogicalCombinationCondition = Condition & + (LogicalCombinationOperator extends infer OPERATOR + ? OPERATOR extends string + ? Extract + : never + : never) + +type IsLogicalCombinationCondition = ( + condition: Condition +) => condition is LogicalCombinationCondition + +export const isLogicalCombinationCondition: IsLogicalCombinationCondition = ( + condition: Condition +): condition is LogicalCombinationCondition => + Object.keys(condition).some(isLogicalCombinationOperator) diff --git a/src/v1/operations/expression/condition/parser/parseCondition/not/index.ts b/src/v1/operations/expression/condition/parser/parseCondition/not/index.ts new file mode 100644 index 000000000..8713b5123 --- /dev/null +++ b/src/v1/operations/expression/condition/parser/parseCondition/not/index.ts @@ -0,0 +1,3 @@ +export { isNotCondition } from './types' +export type { NotCondition } from './types' +export { parseNotCondition } from './parseCondition' diff --git a/src/v1/operations/expression/condition/parser/parseCondition/not/parseCondition.ts b/src/v1/operations/expression/condition/parser/parseCondition/not/parseCondition.ts new file mode 100644 index 000000000..108582162 --- /dev/null +++ b/src/v1/operations/expression/condition/parser/parseCondition/not/parseCondition.ts @@ -0,0 +1,14 @@ +import type { ConditionParser } from '../../parser' + +import type { NotCondition } from './types' + +export const parseNotCondition = ( + conditionParser: ConditionParser, + condition: NotCondition +): void => { + const { not: negatedCondition } = condition + + conditionParser.resetExpression() + conditionParser.parseCondition(negatedCondition) + conditionParser.resetExpression(`NOT (${conditionParser.expression})`) +} diff --git a/src/v1/operations/expression/condition/parser/parseCondition/not/parseCondition.unit.test.ts b/src/v1/operations/expression/condition/parser/parseCondition/not/parseCondition.unit.test.ts new file mode 100644 index 000000000..0df5d6709 --- /dev/null +++ b/src/v1/operations/expression/condition/parser/parseCondition/not/parseCondition.unit.test.ts @@ -0,0 +1,28 @@ +import { schema, number } from 'v1/schema' + +import { parseSchemaCondition } from '../../../parse' + +describe('parseCondition - Not', () => { + const mySchema = schema({ + num: number(), + otherNum: number() + }) + + it('negates child condition (value)', () => { + expect(parseSchemaCondition(mySchema, { not: { attr: 'num', eq: 42 } })).toStrictEqual({ + ConditionExpression: 'NOT (#c_1 = :c_1)', + ExpressionAttributeNames: { '#c_1': 'num' }, + ExpressionAttributeValues: { ':c_1': 42 } + }) + }) + + it('negates child condition (attribute)', () => { + expect( + parseSchemaCondition(mySchema, { not: { attr: 'num', eq: { attr: 'otherNum' } } }) + ).toStrictEqual({ + ConditionExpression: 'NOT (#c_1 = #c_2)', + ExpressionAttributeNames: { '#c_1': 'num', '#c_2': 'otherNum' }, + ExpressionAttributeValues: {} + }) + }) +}) diff --git a/src/v1/operations/expression/condition/parser/parseCondition/not/types.ts b/src/v1/operations/expression/condition/parser/parseCondition/not/types.ts new file mode 100644 index 000000000..5ada1ef87 --- /dev/null +++ b/src/v1/operations/expression/condition/parser/parseCondition/not/types.ts @@ -0,0 +1,7 @@ +import type { AnyAttributeCondition, Condition } from 'v1/operations/types' + +export type NotCondition = Condition & + Extract, { not: unknown }> + +export const isNotCondition = (condition: Condition): condition is NotCondition => + 'not' in condition diff --git a/src/v1/operations/expression/condition/parser/parseCondition/parseCondition.ts b/src/v1/operations/expression/condition/parser/parseCondition/parseCondition.ts new file mode 100644 index 000000000..560aeb44c --- /dev/null +++ b/src/v1/operations/expression/condition/parser/parseCondition/parseCondition.ts @@ -0,0 +1,48 @@ +import type { Condition } from 'v1/operations/types' +import { DynamoDBToolboxError } from 'v1/errors' + +import type { ConditionParser } from '../parser' +import { isComparisonCondition, parseComparisonCondition } from './comparison' +import { isSingleArgFnCondition, parseSingleArgFnCondition } from './singleArgFn' +import { isBetweenCondition, parseBetweenCondition } from './between' +import { isNotCondition, parseNotCondition } from './not' +import { + isLogicalCombinationCondition, + parseLogicalCombinationCondition +} from './logicalCombination' +import { isTwoArgsFnCondition, parseTwoArgsFnCondition } from './twoArgsFn' +import { isInCondition, parseInCondition } from './in' + +export const parseCondition = (conditionParser: ConditionParser, condition: Condition): void => { + if (isComparisonCondition(condition)) { + return parseComparisonCondition(conditionParser, condition) + } + + if (isSingleArgFnCondition(condition)) { + return parseSingleArgFnCondition(conditionParser, condition) + } + + if (isBetweenCondition(condition)) { + return parseBetweenCondition(conditionParser, condition) + } + + if (isNotCondition(condition)) { + return parseNotCondition(conditionParser, condition) + } + + if (isLogicalCombinationCondition(condition)) { + return parseLogicalCombinationCondition(conditionParser, condition) + } + + if (isTwoArgsFnCondition(condition)) { + return parseTwoArgsFnCondition(conditionParser, condition) + } + + if (isInCondition(condition)) { + return parseInCondition(conditionParser, condition) + } + + throw new DynamoDBToolboxError('operations.invalidCondition', { + message: 'Invalid condition: Unable to detect valid condition type.' + }) +} diff --git a/src/v1/operations/expression/condition/parser/parseCondition/parseCondition.unit.test.ts b/src/v1/operations/expression/condition/parser/parseCondition/parseCondition.unit.test.ts new file mode 100644 index 000000000..a0b4ade5d --- /dev/null +++ b/src/v1/operations/expression/condition/parser/parseCondition/parseCondition.unit.test.ts @@ -0,0 +1,104 @@ +import { schema, string, number, anyOf, map, list } from 'v1/schema' + +import { parseSchemaCondition } from '../../parse' + +/** + * @debt TODO "validate the attr value is a string" + */ + +describe('parseCondition', () => { + describe('savedAs attrs', () => { + const schemaWithSavedAs = schema({ + savedAs: string().savedAs('_s'), + nested: map({ + savedAs: string().savedAs('_s') + }).savedAs('_n'), + listed: list( + map({ + savedAs: string().savedAs('_s') + }) + ).savedAs('_l') + }) + + it('correctly parses condition (root)', () => { + expect( + parseSchemaCondition(schemaWithSavedAs, { attr: 'savedAs', beginsWith: 'foo' }) + ).toStrictEqual({ + ConditionExpression: 'begins_with(#c_1, :c_1)', + ExpressionAttributeNames: { '#c_1': '_s' }, + ExpressionAttributeValues: { ':c_1': 'foo' } + }) + }) + + it('correctly parses condition (nested)', () => { + expect( + parseSchemaCondition(schemaWithSavedAs, { attr: 'nested.savedAs', beginsWith: 'foo' }) + ).toStrictEqual({ + ConditionExpression: 'begins_with(#c_1.#c_2, :c_1)', + ExpressionAttributeNames: { '#c_1': '_n', '#c_2': '_s' }, + ExpressionAttributeValues: { ':c_1': 'foo' } + }) + }) + + it('correctly parses condition (with id)', () => { + expect( + parseSchemaCondition(schemaWithSavedAs, { attr: 'savedAs', beginsWith: 'foo' }, '1') + ).toStrictEqual({ + ConditionExpression: 'begins_with(#c1_1, :c1_1)', + ExpressionAttributeNames: { '#c1_1': '_s' }, + ExpressionAttributeValues: { ':c1_1': 'foo' } + }) + }) + + it('correctly parses condition (listed)', () => { + expect( + parseSchemaCondition(schemaWithSavedAs, { attr: 'listed[4].savedAs', beginsWith: 'foo' }) + ).toStrictEqual({ + ConditionExpression: 'begins_with(#c_1[4].#c_2, :c_1)', + ExpressionAttributeNames: { '#c_1': '_l', '#c_2': '_s' }, + ExpressionAttributeValues: { ':c_1': 'foo' } + }) + }) + }) + + describe('anyOf', () => { + const schemaWithAnyOf = schema({ + anyOf: anyOf( + number(), + map({ + strOrNum: anyOf(string(), number()) + }) + ) + }) + + it('correctly parses condition (root)', () => { + expect( + parseSchemaCondition(schemaWithAnyOf, { attr: 'anyOf', between: [42, 43] }) + ).toStrictEqual({ + ConditionExpression: '#c_1 BETWEEN :c_1 AND :c_2', + ExpressionAttributeNames: { '#c_1': 'anyOf' }, + ExpressionAttributeValues: { ':c_1': 42, ':c_2': 43 } + }) + }) + + it('correctly parses condition (nested num)', () => { + expect( + parseSchemaCondition(schemaWithAnyOf, { attr: 'anyOf.strOrNum', between: [42, 43] }) + ).toStrictEqual({ + ConditionExpression: '#c_1.#c_2 BETWEEN :c_1 AND :c_2', + ExpressionAttributeNames: { '#c_1': 'anyOf', '#c_2': 'strOrNum' }, + ExpressionAttributeValues: { ':c_1': 42, ':c_2': 43 } + }) + }) + + it('correctly parses condition (nested str)', () => { + expect( + parseSchemaCondition(schemaWithAnyOf, { attr: 'anyOf.strOrNum', beginsWith: 'foo' }) + ).toStrictEqual({ + ConditionExpression: 'begins_with(#c_1.#c_2, :c_1)', + ExpressionAttributeNames: { '#c_1': 'anyOf', '#c_2': 'strOrNum' }, + ExpressionAttributeValues: { ':c_1': 'foo' } + }) + }) + }) +}) diff --git a/src/v1/operations/expression/condition/parser/parseCondition/singleArgFn/index.ts b/src/v1/operations/expression/condition/parser/parseCondition/singleArgFn/index.ts new file mode 100644 index 000000000..c216e7d1a --- /dev/null +++ b/src/v1/operations/expression/condition/parser/parseCondition/singleArgFn/index.ts @@ -0,0 +1,3 @@ +export { isSingleArgFnCondition } from './types' +export type { SingleArgFnCondition } from './types' +export { parseSingleArgFnCondition } from './parseCondition' diff --git a/src/v1/operations/expression/condition/parser/parseCondition/singleArgFn/parseCondition.ts b/src/v1/operations/expression/condition/parser/parseCondition/singleArgFn/parseCondition.ts new file mode 100644 index 000000000..1cfa3f859 --- /dev/null +++ b/src/v1/operations/expression/condition/parser/parseCondition/singleArgFn/parseCondition.ts @@ -0,0 +1,17 @@ +import type { ConditionParser } from '../../parser' + +import type { SingleArgFnCondition } from './types' + +export const parseSingleArgFnCondition = ( + conditionParser: ConditionParser, + condition: SingleArgFnCondition +): void => { + // TOIMPROVE: It doesn't make sense to use size in single arg fns + const attributePath = condition.size ?? condition.attr + + conditionParser.resetExpression( + `${condition.exists === true ? 'attribute_exists' : 'attribute_not_exists'}(` + ) + conditionParser.appendAttributePath(attributePath, { size: !!condition.size }) + conditionParser.appendToExpression(')') +} diff --git a/src/v1/operations/expression/condition/parser/parseCondition/singleArgFn/parseCondition.unit.test.ts b/src/v1/operations/expression/condition/parser/parseCondition/singleArgFn/parseCondition.unit.test.ts new file mode 100644 index 000000000..e6011ce3f --- /dev/null +++ b/src/v1/operations/expression/condition/parser/parseCondition/singleArgFn/parseCondition.unit.test.ts @@ -0,0 +1,83 @@ +import { schema, list, map, number } from 'v1/schema' + +import { parseSchemaCondition } from '../../../parse' + +describe('parseCondition - singleArgFn', () => { + const simpleSchema = schema({ + num: number() + }) + + it('exists', () => { + expect(parseSchemaCondition(simpleSchema, { attr: 'num', exists: true })).toStrictEqual({ + ConditionExpression: 'attribute_exists(#c_1)', + ExpressionAttributeNames: { '#c_1': 'num' }, + ExpressionAttributeValues: {} + }) + }) + + it('not exists', () => { + expect(parseSchemaCondition(simpleSchema, { attr: 'num', exists: false })).toStrictEqual({ + ConditionExpression: 'attribute_not_exists(#c_1)', + ExpressionAttributeNames: { '#c_1': 'num' }, + ExpressionAttributeValues: {} + }) + }) + + const mapSchema = schema({ + map: map({ + nestedA: map({ + nestedB: number() + }) + }) + }) + + it('deep maps', () => { + expect( + parseSchemaCondition(mapSchema, { attr: 'map.nestedA.nestedB', exists: true }) + ).toStrictEqual({ + ConditionExpression: 'attribute_exists(#c_1.#c_2.#c_3)', + ExpressionAttributeNames: { + '#c_1': 'map', + '#c_2': 'nestedA', + '#c_3': 'nestedB' + }, + ExpressionAttributeValues: {} + }) + }) + + const listSchema = schema({ + listA: list( + map({ + nested: map({ + listB: list(map({ value: number() })) + }) + }) + ), + list: list(list(list(number()))) + }) + + it('deep maps and lists', () => { + expect( + parseSchemaCondition(listSchema, { attr: 'listA[1].nested.listB[2].value', exists: true }) + ).toStrictEqual({ + ConditionExpression: 'attribute_exists(#c_1[1].#c_2.#c_3[2].#c_4)', + ExpressionAttributeNames: { + '#c_1': 'listA', + '#c_2': 'nested', + '#c_3': 'listB', + '#c_4': 'value' + }, + ExpressionAttributeValues: {} + }) + }) + + it('deep lists', () => { + expect(parseSchemaCondition(listSchema, { attr: 'list[1][2][3]', exists: true })).toStrictEqual( + { + ConditionExpression: 'attribute_exists(#c_1[1][2][3])', + ExpressionAttributeNames: { '#c_1': 'list' }, + ExpressionAttributeValues: {} + } + ) + }) +}) diff --git a/src/v1/operations/expression/condition/parser/parseCondition/singleArgFn/types.ts b/src/v1/operations/expression/condition/parser/parseCondition/singleArgFn/types.ts new file mode 100644 index 000000000..d416d31e1 --- /dev/null +++ b/src/v1/operations/expression/condition/parser/parseCondition/singleArgFn/types.ts @@ -0,0 +1,7 @@ +import type { AnyAttributeCondition, NonLogicalCondition, Condition } from 'v1/operations/types' + +export type SingleArgFnCondition = NonLogicalCondition & + Extract, { exists: unknown }> + +export const isSingleArgFnCondition = (condition: Condition): condition is SingleArgFnCondition => + 'exists' in condition diff --git a/src/v1/operations/expression/condition/parser/parseCondition/twoArgsFn/index.ts b/src/v1/operations/expression/condition/parser/parseCondition/twoArgsFn/index.ts new file mode 100644 index 000000000..9313818ec --- /dev/null +++ b/src/v1/operations/expression/condition/parser/parseCondition/twoArgsFn/index.ts @@ -0,0 +1,3 @@ +export { isTwoArgsFnCondition } from './types' +export type { TwoArgsFnCondition } from './types' +export { parseTwoArgsFnCondition } from './parseCondition' diff --git a/src/v1/operations/expression/condition/parser/parseCondition/twoArgsFn/parseCondition.ts b/src/v1/operations/expression/condition/parser/parseCondition/twoArgsFn/parseCondition.ts new file mode 100644 index 000000000..c580ba834 --- /dev/null +++ b/src/v1/operations/expression/condition/parser/parseCondition/twoArgsFn/parseCondition.ts @@ -0,0 +1,65 @@ +import { Always, PrimitiveAttribute } from 'v1/schema' + +import type { ConditionParser } from '../../parser' +import { TwoArgsFnOperator, isTwoArgsFnOperator, TwoArgsFnCondition } from './types' + +const twoArgsFnOperatorExpression: Record = { + contains: 'contains', + beginsWith: 'begins_with', + type: 'attribute_type' +} + +const typeAttribute: PrimitiveAttribute< + 'string', + { + required: Always + hidden: false + key: false + savedAs: undefined + enum: ['S', 'SS', 'N', 'NS', 'B', 'BS', 'BOOL', 'NULL', 'L', 'M'] + defaults: { + key: undefined + put: undefined + update: undefined + } + transform: undefined + } +> = { + path: '', + type: 'string', + required: 'always', + hidden: false, + key: false, + savedAs: undefined, + enum: ['S', 'SS', 'N', 'NS', 'B', 'BS', 'BOOL', 'NULL', 'L', 'M'], + defaults: { + key: undefined, + put: undefined, + update: undefined + }, + transform: undefined +} + +export const parseTwoArgsFnCondition = ( + conditionParser: ConditionParser, + condition: CONDITION +): void => { + const comparisonOperator = Object.keys(condition).find(isTwoArgsFnOperator) as keyof CONDITION & + TwoArgsFnOperator + + // TOIMPROVE: It doesn't make sense to use size in two args fns + const attributePath = condition.size ?? condition.attr + const expressionAttributeValue = condition[comparisonOperator] + const { transform = true } = condition as { transform?: boolean } + + conditionParser.resetExpression(`${twoArgsFnOperatorExpression[comparisonOperator]}(`) + const attribute = conditionParser.appendAttributePath(attributePath, { size: !!condition.size }) + conditionParser.appendToExpression(', ') + comparisonOperator === 'type' + ? conditionParser.appendAttributeValue( + { ...typeAttribute, path: attributePath }, + expressionAttributeValue + ) + : conditionParser.appendAttributeValueOrPath(attribute, expressionAttributeValue, { transform }) + conditionParser.appendToExpression(')') +} diff --git a/src/v1/operations/expression/condition/parser/parseCondition/twoArgsFn/parseCondition.unit.test.ts b/src/v1/operations/expression/condition/parser/parseCondition/twoArgsFn/parseCondition.unit.test.ts new file mode 100644 index 000000000..4620a4713 --- /dev/null +++ b/src/v1/operations/expression/condition/parser/parseCondition/twoArgsFn/parseCondition.unit.test.ts @@ -0,0 +1,184 @@ +import { schema, list, map, number, string } from 'v1/schema' + +import { parseSchemaCondition } from '../../../parse' + +describe('parseCondition - singleArgFn', () => { + const simpleSchema = schema({ + str: string(), + otherStr: string(), + list: list(number()) + }) + + it('type', () => { + expect(parseSchemaCondition(simpleSchema, { attr: 'list', type: 'L' })).toStrictEqual({ + ConditionExpression: 'attribute_type(#c_1, :c_1)', + ExpressionAttributeNames: { '#c_1': 'list' }, + ExpressionAttributeValues: { ':c_1': 'L' } + }) + }) + + it('contains (value)', () => { + expect(parseSchemaCondition(simpleSchema, { attr: 'str', contains: 'foo' })).toStrictEqual({ + ConditionExpression: 'contains(#c_1, :c_1)', + ExpressionAttributeNames: { '#c_1': 'str' }, + ExpressionAttributeValues: { ':c_1': 'foo' } + }) + }) + + it('contains (attribute)', () => { + expect( + parseSchemaCondition(simpleSchema, { attr: 'str', contains: { attr: 'otherStr' } }) + ).toStrictEqual({ + ConditionExpression: 'contains(#c_1, #c_2)', + ExpressionAttributeNames: { '#c_1': 'str', '#c_2': 'otherStr' }, + ExpressionAttributeValues: {} + }) + }) + + it('beginsWith (value)', () => { + expect(parseSchemaCondition(simpleSchema, { attr: 'str', beginsWith: 'foo' })).toStrictEqual({ + ConditionExpression: 'begins_with(#c_1, :c_1)', + ExpressionAttributeNames: { '#c_1': 'str' }, + ExpressionAttributeValues: { ':c_1': 'foo' } + }) + }) + + it('beginsWith (attribute)', () => { + expect( + parseSchemaCondition(simpleSchema, { attr: 'str', beginsWith: { attr: 'otherStr' } }) + ).toStrictEqual({ + ConditionExpression: 'begins_with(#c_1, #c_2)', + ExpressionAttributeNames: { '#c_1': 'str', '#c_2': 'otherStr' }, + ExpressionAttributeValues: {} + }) + }) + + const mapSchema = schema({ + map: map({ + nestedA: map({ + nestedB: string() + }) + }), + otherMap: map({ + nestedC: map({ + nestedD: string() + }) + }) + }) + + it('deep maps (value)', () => { + expect( + parseSchemaCondition(mapSchema, { attr: 'map.nestedA.nestedB', contains: 'foo' }) + ).toStrictEqual({ + ConditionExpression: 'contains(#c_1.#c_2.#c_3, :c_1)', + ExpressionAttributeNames: { + '#c_1': 'map', + '#c_2': 'nestedA', + '#c_3': 'nestedB' + }, + ExpressionAttributeValues: { ':c_1': 'foo' } + }) + }) + + it('deep maps (attribute)', () => { + expect( + parseSchemaCondition(mapSchema, { + attr: 'map.nestedA.nestedB', + contains: { attr: 'otherMap.nestedC.nestedD' } + }) + ).toStrictEqual({ + ConditionExpression: 'contains(#c_1.#c_2.#c_3, #c_4.#c_5.#c_6)', + ExpressionAttributeNames: { + '#c_1': 'map', + '#c_2': 'nestedA', + '#c_3': 'nestedB', + '#c_4': 'otherMap', + '#c_5': 'nestedC', + '#c_6': 'nestedD' + }, + ExpressionAttributeValues: {} + }) + }) + + const mapAndList = schema({ + listA: list( + map({ + nested: map({ + listB: list(map({ value: string() })) + }) + }) + ), + listC: list( + map({ + nested: map({ + listD: list(map({ value: string() })) + }) + }) + ) + }) + + it('deep maps and lists (value)', () => { + expect( + parseSchemaCondition(mapAndList, { attr: 'listA[1].nested.listB[2].value', type: 'S' }) + ).toStrictEqual({ + ConditionExpression: 'attribute_type(#c_1[1].#c_2.#c_3[2].#c_4, :c_1)', + ExpressionAttributeNames: { + '#c_1': 'listA', + '#c_2': 'nested', + '#c_3': 'listB', + '#c_4': 'value' + }, + ExpressionAttributeValues: { ':c_1': 'S' } + }) + }) + + it('deep maps and lists (attribute)', () => { + expect( + parseSchemaCondition(mapAndList, { + attr: 'listA[1].nested.listB[2].value', + beginsWith: { attr: 'listC[3].nested.listD[4].value' } + }) + ).toStrictEqual({ + ConditionExpression: 'begins_with(#c_1[1].#c_2.#c_3[2].#c_4, #c_5[3].#c_6.#c_7[4].#c_8)', + ExpressionAttributeNames: { + '#c_1': 'listA', + '#c_2': 'nested', + '#c_3': 'listB', + '#c_4': 'value', + '#c_5': 'listC', + '#c_6': 'nested', + '#c_7': 'listD', + '#c_8': 'value' + }, + ExpressionAttributeValues: {} + }) + }) + + const listsSchema = schema({ + list: list(list(list(string()))), + listB: list(list(list(string()))) + }) + + it('deep lists (value)', () => { + expect( + parseSchemaCondition(listsSchema, { attr: 'list[1][2][3]', contains: 'foo' }) + ).toStrictEqual({ + ConditionExpression: 'contains(#c_1[1][2][3], :c_1)', + ExpressionAttributeNames: { '#c_1': 'list' }, + ExpressionAttributeValues: { ':c_1': 'foo' } + }) + }) + + it('deep lists (attribute)', () => { + expect( + parseSchemaCondition(listsSchema, { + attr: 'list[1][2][3]', + contains: { attr: 'listB[4][5][6]' } + }) + ).toStrictEqual({ + ConditionExpression: 'contains(#c_1[1][2][3], #c_2[4][5][6])', + ExpressionAttributeNames: { '#c_1': 'list', '#c_2': 'listB' }, + ExpressionAttributeValues: {} + }) + }) +}) diff --git a/src/v1/operations/expression/condition/parser/parseCondition/twoArgsFn/types.ts b/src/v1/operations/expression/condition/parser/parseCondition/twoArgsFn/types.ts new file mode 100644 index 000000000..f1032c578 --- /dev/null +++ b/src/v1/operations/expression/condition/parser/parseCondition/twoArgsFn/types.ts @@ -0,0 +1,19 @@ +import type { AnyAttributeCondition, NonLogicalCondition, Condition } from 'v1/operations/types' + +export type BeginsWithOperator = 'beginsWith' +export type TwoArgsFnOperator = 'contains' | 'type' | BeginsWithOperator + +const twoArgsFnOperatorSet = new Set(['contains', 'beginsWith', 'type']) + +export const isTwoArgsFnOperator = (key: string): key is TwoArgsFnOperator => + twoArgsFnOperatorSet.has(key as TwoArgsFnOperator) + +export type TwoArgsFnCondition = NonLogicalCondition & + (TwoArgsFnOperator extends infer OPERATOR + ? OPERATOR extends string + ? Extract, { [KEY in OPERATOR]: unknown }> + : never + : never) + +export const isTwoArgsFnCondition = (condition: Condition): condition is TwoArgsFnCondition => + Object.keys(condition).some(isTwoArgsFnOperator) diff --git a/src/v1/operations/expression/condition/parser/parser.ts b/src/v1/operations/expression/condition/parser/parser.ts new file mode 100644 index 000000000..abdabfbd8 --- /dev/null +++ b/src/v1/operations/expression/condition/parser/parser.ts @@ -0,0 +1,75 @@ +import type { NativeAttributeValue } from '@aws-sdk/util-dynamodb' + +import type { Schema, Attribute } from 'v1/schema' +import type { Condition } from 'v1/operations/types' + +import { + ExpressionParser, + appendAttributePath, + AppendAttributePathOptions +} from '../../expressionParser' +import { appendAttributeValue, AppendAttributeValueOptions } from './appendAttributeValue' +import { appendAttributeValueOrPath } from './appendAttributeValueOrPath' +import { parseCondition } from './parseCondition' +import { toCommandOptions } from './toCommandOptions' + +export class ConditionParser implements ExpressionParser { + schema: Schema | Attribute + expressionAttributePrefix: `c${string}_` + expressionAttributeNames: string[] + expressionAttributeValues: unknown[] + expression: string + id: string + + constructor(schema: Schema | Attribute, id = '') { + this.schema = schema + this.expressionAttributePrefix = `c${id}_` + this.expressionAttributeNames = [] + this.expressionAttributeValues = [] + this.expression = '' + this.id = id + } + + resetExpression = (initialStr = '') => { + this.expression = initialStr + } + + appendAttributePath = ( + attributePath: string, + options: AppendAttributePathOptions = {} + ): Attribute => appendAttributePath(this, attributePath, options) + + appendAttributeValue = ( + attribute: Attribute, + expressionAttributeValue: unknown, + options: AppendAttributeValueOptions = {} + ): void => appendAttributeValue(this, attribute, expressionAttributeValue, options) + + appendAttributeValueOrPath = ( + attribute: Attribute, + expressionAttributeValueOrPath: unknown, + options: AppendAttributePathOptions & AppendAttributeValueOptions = {} + ): void => appendAttributeValueOrPath(this, attribute, expressionAttributeValueOrPath, options) + + appendToExpression = (conditionExpressionPart: string) => { + this.expression += conditionExpressionPart + } + + parseCondition = (condition: Condition): void => parseCondition(this, condition) + + toCommandOptions = (): { + ConditionExpression: string + ExpressionAttributeNames: Record + ExpressionAttributeValues: Record + } => toCommandOptions(this) + + clone = (schema?: Schema | Attribute): ConditionParser => { + const clonedParser = new ConditionParser(schema ?? this.schema, this.id) + + clonedParser.expressionAttributeNames = [...this.expressionAttributeNames] + clonedParser.expressionAttributeValues = [...this.expressionAttributeValues] + clonedParser.expression = this.expression + + return clonedParser + } +} diff --git a/src/v1/operations/expression/condition/parser/toCommandOptions.ts b/src/v1/operations/expression/condition/parser/toCommandOptions.ts new file mode 100644 index 000000000..cb596b7dd --- /dev/null +++ b/src/v1/operations/expression/condition/parser/toCommandOptions.ts @@ -0,0 +1,37 @@ +import type { NativeAttributeValue } from '@aws-sdk/util-dynamodb' + +import type { ConditionParser } from './parser' + +/** + * @debt refactor "factorize with other expressions" + */ +export const toCommandOptions = ( + conditionParser: ConditionParser +): { + ConditionExpression: string + ExpressionAttributeNames: Record + ExpressionAttributeValues: Record +} => { + const ExpressionAttributeNames: Record = {} + + conditionParser.expressionAttributeNames.forEach((expressionAttributeName, index) => { + ExpressionAttributeNames[ + `#${conditionParser.expressionAttributePrefix}${index + 1}` + ] = expressionAttributeName + }) + + const ExpressionAttributeValues: Record = {} + conditionParser.expressionAttributeValues.forEach((expressionAttributeValue, index) => { + ExpressionAttributeValues[ + `:${conditionParser.expressionAttributePrefix}${index + 1}` + ] = expressionAttributeValue + }) + + const ConditionExpression = conditionParser.expression + + return { + ExpressionAttributeNames, + ExpressionAttributeValues, + ConditionExpression + } +} diff --git a/src/v1/operations/expression/errors.ts b/src/v1/operations/expression/errors.ts new file mode 100644 index 000000000..8853c5de9 --- /dev/null +++ b/src/v1/operations/expression/errors.ts @@ -0,0 +1,14 @@ +import type { ErrorBlueprint } from 'v1/errors/blueprint' +import type { ConditionParserErrorBlueprints } from './condition/errors' + +type InvalidExpressionAttributePathErrorBlueprint = ErrorBlueprint<{ + code: 'operations.invalidExpressionAttributePath' + hasPath: false + payload: { + attributePath: string + } +}> + +export type ExpressionParsersErrorBlueprints = + | ConditionParserErrorBlueprints + | InvalidExpressionAttributePathErrorBlueprint diff --git a/src/v1/operations/expression/expressionParser.ts b/src/v1/operations/expression/expressionParser.ts new file mode 100644 index 000000000..989338e87 --- /dev/null +++ b/src/v1/operations/expression/expressionParser.ts @@ -0,0 +1,189 @@ +import type { AnyAttribute, Attribute, PrimitiveAttribute, Schema } from 'v1/schema' +import { DynamoDBToolboxError } from 'v1/errors' +import { isObject } from 'v1/utils/validation/isObject' +import { isString } from 'v1/utils/validation/isString' +import { parseAttributeClonedInput } from 'v1/validation/parseClonedInput' + +export type AppendAttributePathOptions = { size?: boolean } + +export interface ExpressionParser { + schema: Schema | Attribute + expressionAttributePrefix: string + expressionAttributeNames: string[] + clone: (schema?: Schema | Attribute) => ExpressionParser + expression: string + resetExpression: (str?: string) => void + appendToExpression: (str: string) => void + appendAttributePath: (path: string, options?: AppendAttributePathOptions) => Attribute +} + +const defaultAnyAttribute: Omit = { + type: 'any', + required: 'never', + hidden: false, + key: false, + savedAs: undefined, + defaults: { + key: undefined, + put: undefined, + update: undefined + }, + castAs: undefined +} + +const defaultNumberAttribute: Omit, 'path'> = { + type: 'number', + required: 'never', + hidden: false, + key: false, + savedAs: undefined, + defaults: { + key: undefined, + put: undefined, + update: undefined + }, + enum: undefined, + transform: undefined +} + +class InvalidExpressionAttributePathError extends DynamoDBToolboxError<'operations.invalidExpressionAttributePath'> { + constructor(attributePath: string) { + super('operations.invalidExpressionAttributePath', { + message: `Unable to match expression attribute path with schema: ${attributePath}`, + payload: { attributePath } + }) + } +} + +const isListAccessor = (accessor: string): accessor is `[${number}]` => /\[\d+\]/g.test(accessor) + +export const isAttributePath = (candidate: unknown): candidate is { attr: string } => + isObject(candidate) && 'attr' in candidate && isString(candidate.attr) + +export const appendAttributePath = ( + parser: ExpressionParser, + attributePath: string, + options: AppendAttributePathOptions = {} +): Attribute => { + const { size = false } = options + + const expressionAttributePrefix = parser.expressionAttributePrefix + let parentAttribute: Schema | Attribute = parser.schema + let expressionPath = '' + let attributeMatches = [...attributePath.matchAll(/\[(\d+)\]|\w+(?=(\.|$|\[))/g)] + + while (attributeMatches.length > 0) { + const attributeMatch = attributeMatches.shift() as RegExpMatchArray + const childAttributeAccessor = attributeMatch[0] + + switch (parentAttribute.type) { + case 'any': { + const isChildAttributeInList = isListAccessor(childAttributeAccessor) + + if (isChildAttributeInList) { + expressionPath += childAttributeAccessor + } else { + const expressionAttributeNameIndex = parser.expressionAttributeNames.push( + childAttributeAccessor + ) + expressionPath += `.#${expressionAttributePrefix}${expressionAttributeNameIndex}` + } + + parentAttribute = { + path: [parentAttribute.path, childAttributeAccessor].join( + isChildAttributeInList ? '' : '.' + ), + ...defaultAnyAttribute + } + break + } + case 'binary': + case 'boolean': + case 'number': + case 'string': + case 'set': + throw new InvalidExpressionAttributePathError(attributePath) + + case 'record': { + const keyAttribute = parentAttribute.keys + const keyParser = parseAttributeClonedInput(keyAttribute, childAttributeAccessor) + keyParser.next() // cloned + keyParser.next() // parsed + const collapsedKey = keyParser.next().value as string + + const expressionAttributeNameIndex = parser.expressionAttributeNames.push(collapsedKey) + expressionPath += `.#${expressionAttributePrefix}${expressionAttributeNameIndex}` + + parentAttribute = parentAttribute.elements + break + } + case 'schema': + case 'map': { + const childAttribute = parentAttribute.attributes[childAttributeAccessor] + if (!childAttribute) { + throw new InvalidExpressionAttributePathError(attributePath) + } + + const expressionAttributeNameIndex = parser.expressionAttributeNames.push( + childAttribute.savedAs ?? childAttributeAccessor + ) + expressionPath += + parentAttribute.type === 'schema' + ? `#${expressionAttributePrefix}${expressionAttributeNameIndex}` + : `.#${expressionAttributePrefix}${expressionAttributeNameIndex}` + + parentAttribute = childAttribute + break + } + case 'list': { + if (!isListAccessor(childAttributeAccessor)) { + throw new InvalidExpressionAttributePathError(attributePath) + } + + expressionPath += childAttributeAccessor + + parentAttribute = parentAttribute.elements + break + } + case 'anyOf': { + let validElementExpressionParser: ExpressionParser | undefined = undefined + const subPath = attributePath.slice(attributeMatch.index) + + for (const element of parentAttribute.elements) { + try { + parentAttribute = element + const elementExpressionParser = parser.clone(element) + elementExpressionParser.resetExpression() + parentAttribute = elementExpressionParser.appendAttributePath(subPath, options) + validElementExpressionParser = elementExpressionParser + /* eslint-disable no-empty */ + } catch {} + } + + if (validElementExpressionParser === undefined) { + throw new InvalidExpressionAttributePathError(attributePath) + } + + parser.expressionAttributeNames = validElementExpressionParser.expressionAttributeNames + expressionPath += validElementExpressionParser.expression + // No need to go over the rest of the path + attributeMatches = [] + + break + } + } + } + + if (parentAttribute.type === 'schema') { + throw new InvalidExpressionAttributePathError(attributePath) + } + + parser.appendToExpression(size ? `size(${expressionPath})` : expressionPath) + + return size + ? { + path: parentAttribute.path, + ...defaultNumberAttribute + } + : parentAttribute +} diff --git a/src/v1/operations/expression/projection/index.ts b/src/v1/operations/expression/projection/index.ts new file mode 100644 index 000000000..22579e473 --- /dev/null +++ b/src/v1/operations/expression/projection/index.ts @@ -0,0 +1 @@ +export { parseProjection } from './parse' diff --git a/src/v1/operations/expression/projection/parse.ts b/src/v1/operations/expression/projection/parse.ts new file mode 100644 index 000000000..998dc600b --- /dev/null +++ b/src/v1/operations/expression/projection/parse.ts @@ -0,0 +1,30 @@ +import type { EntityV2 } from 'v1/entity' +import type { Schema } from 'v1/schema' +import type { SchemaAttributePath, AnyAttributePath } from 'v1/operations/types' + +import { ProjectionParser } from './parser' + +export const parseSchemaProjection = < + SCHEMA extends Schema, + ATTRIBUTE_PATHS extends SchemaAttributePath[] +>( + schema: SCHEMA, + attributes: ATTRIBUTE_PATHS, + id?: string +): { + ProjectionExpression: string + ExpressionAttributeNames: Record +} => { + const projectionExpression = new ProjectionParser(schema, id) + projectionExpression.parseProjection(attributes) + return projectionExpression.toCommandOptions() +} + +export const parseProjection = < + ENTITY extends EntityV2, + ATTRIBUTE_PATHS extends AnyAttributePath[] +>( + entity: ENTITY, + attributes: ATTRIBUTE_PATHS, + id?: string +) => parseSchemaProjection(entity.schema, attributes, id) diff --git a/src/v1/operations/expression/projection/parse.unit.test.ts b/src/v1/operations/expression/projection/parse.unit.test.ts new file mode 100644 index 000000000..60ada91ad --- /dev/null +++ b/src/v1/operations/expression/projection/parse.unit.test.ts @@ -0,0 +1,82 @@ +import { schema, string, number, anyOf, map, list } from 'v1/schema' + +import { parseSchemaProjection } from './parse' + +/** + * @debt TODO "validate the attr value is a string" + */ + +describe('parseProjection', () => { + describe('savedAs attrs', () => { + const schemaWithSavedAs = schema({ + savedAs: string().savedAs('_s'), + nested: map({ + savedAs: string().savedAs('_s') + }).savedAs('_n'), + listed: list( + map({ + savedAs: string().savedAs('_s') + }) + ).savedAs('_l') + }) + + it('correctly parses projection (root)', () => { + expect(parseSchemaProjection(schemaWithSavedAs, ['savedAs'])).toStrictEqual({ + ProjectionExpression: '#p_1', + ExpressionAttributeNames: { '#p_1': '_s' } + }) + }) + + it('correctly parses projection (nested)', () => { + expect(parseSchemaProjection(schemaWithSavedAs, ['savedAs', 'nested.savedAs'])).toStrictEqual( + { + ProjectionExpression: '#p_1, #p_2.#p_3', + ExpressionAttributeNames: { '#p_1': '_s', '#p_2': '_n', '#p_3': '_s' } + } + ) + }) + + it('correctly parses projection (with id)', () => { + expect(parseSchemaProjection(schemaWithSavedAs, ['savedAs'], '1')).toStrictEqual({ + ProjectionExpression: '#p1_1', + ExpressionAttributeNames: { '#p1_1': '_s' } + }) + }) + + it('correctly parses condition (listed)', () => { + expect( + parseSchemaProjection(schemaWithSavedAs, ['savedAs', 'listed[4].savedAs']) + ).toStrictEqual({ + ProjectionExpression: '#p_1, #p_2[4].#p_3', + ExpressionAttributeNames: { '#p_1': '_s', '#p_2': '_l', '#p_3': '_s' } + }) + }) + }) + + describe('anyOf', () => { + const schemaWithAnyOf = schema({ + anyOf: anyOf(number(), map({ str: string() }), map({ num: number().savedAs('_n') })) + }) + + it('correctly parses projection (root)', () => { + expect(parseSchemaProjection(schemaWithAnyOf, ['anyOf'])).toStrictEqual({ + ProjectionExpression: '#p_1', + ExpressionAttributeNames: { '#p_1': 'anyOf' } + }) + }) + + it('correctly parses projection (nested str)', () => { + expect(parseSchemaProjection(schemaWithAnyOf, ['anyOf.str'])).toStrictEqual({ + ProjectionExpression: '#p_1.#p_2', + ExpressionAttributeNames: { '#p_1': 'anyOf', '#p_2': 'str' } + }) + }) + + it('correctly parses projection (nested num)', () => { + expect(parseSchemaProjection(schemaWithAnyOf, ['anyOf.num'])).toStrictEqual({ + ProjectionExpression: '#p_1.#p_2', + ExpressionAttributeNames: { '#p_1': 'anyOf', '#p_2': '_n' } + }) + }) + }) +}) diff --git a/src/v1/operations/expression/projection/parser.ts b/src/v1/operations/expression/projection/parser.ts new file mode 100644 index 000000000..f1e4b0437 --- /dev/null +++ b/src/v1/operations/expression/projection/parser.ts @@ -0,0 +1,75 @@ +import type { Schema, Attribute } from 'v1/schema' +import { appendAttributePath, ExpressionParser } from 'v1/operations/expression/expressionParser' + +import type { AppendAttributePathOptions } from '../expressionParser' + +export class ProjectionParser implements ExpressionParser { + schema: Schema | Attribute + expressionAttributePrefix: `p${string}_` + expressionAttributeNames: string[] + expression: string + id: string + + constructor(schema: Schema | Attribute, id = '') { + this.schema = schema + this.expressionAttributePrefix = `p${id}_` + this.expressionAttributeNames = [] + this.expression = '' + this.id = id + } + + resetExpression = (initialStr = '') => { + this.expression = initialStr + } + + appendAttributePath = ( + attributePath: string, + options: AppendAttributePathOptions = {} + ): Attribute => appendAttributePath(this, attributePath, options) + + appendToExpression = (projectionExpressionPart: string) => { + this.expression += projectionExpressionPart + } + + parseProjection = (attributes: string[]): void => { + const [firstAttribute, ...restAttributes] = attributes + this.appendAttributePath(firstAttribute) + + for (const attribute of restAttributes) { + this.appendToExpression(', ') + this.appendAttributePath(attribute) + } + } + + /** + * @debt refactor "factorize with other expressions" + */ + toCommandOptions = (): { + ProjectionExpression: string + ExpressionAttributeNames: Record + } => { + const ExpressionAttributeNames: Record = {} + + this.expressionAttributeNames.forEach((expressionAttributeName, index) => { + ExpressionAttributeNames[ + `#${this.expressionAttributePrefix}${index + 1}` + ] = expressionAttributeName + }) + + const ProjectionExpression = this.expression + + return { + ExpressionAttributeNames, + ProjectionExpression + } + } + + clone = (schema?: Schema | Attribute): ProjectionParser => { + const clonedParser = new ProjectionParser(schema ?? this.schema, this.id) + + clonedParser.expressionAttributeNames = [...this.expressionAttributeNames] + clonedParser.expression = this.expression + + return clonedParser + } +} diff --git a/src/v1/operations/getItem/command.ts b/src/v1/operations/getItem/command.ts new file mode 100644 index 000000000..e500faf90 --- /dev/null +++ b/src/v1/operations/getItem/command.ts @@ -0,0 +1,88 @@ +import type { O } from 'ts-toolbelt' +import { GetCommandInput, GetCommand, GetCommandOutput } from '@aws-sdk/lib-dynamodb' + +import type { EntityV2, FormattedItem } from 'v1/entity' +import type { AnyAttributePath, KeyInput } from 'v1/operations/types' +import { DynamoDBToolboxError } from 'v1/errors' +import { formatSavedItem } from 'v1/operations/utils/formatSavedItem' + +import { EntityOperation, $entity } from '../class' +import type { GetItemOptions } from './options' +import { getItemParams } from './getItemParams' + +export const $key = Symbol('$key') +export type $key = typeof $key + +export const $options = Symbol('$options') +export type $options = typeof $options + +export type GetItemResponse< + ENTITY extends EntityV2, + OPTIONS extends GetItemOptions = GetItemOptions +> = O.Merge< + Omit, + { + Item?: + | (OPTIONS['attributes'] extends AnyAttributePath[] + ? FormattedItem + : FormattedItem) + | undefined + } +> + +export class GetItemCommand< + ENTITY extends EntityV2 = EntityV2, + OPTIONS extends GetItemOptions = GetItemOptions +> extends EntityOperation { + static operationName = 'get' as const; + + [$key]?: KeyInput + key: (key: KeyInput) => GetItemCommand; + [$options]: OPTIONS + options: >( + nextOptions: NEXT_OPTIONS + ) => GetItemCommand + + constructor(entity: ENTITY, key?: KeyInput, options: OPTIONS = {} as OPTIONS) { + super(entity) + this[$key] = key + this[$options] = options + + this.key = nextKey => new GetItemCommand(this[$entity], nextKey, this[$options]) + this.options = nextOptions => new GetItemCommand(this[$entity], this[$key], nextOptions) + } + + params = (): GetCommandInput => { + if (!this[$key]) { + throw new DynamoDBToolboxError('operations.incompleteCommand', { + message: 'GetItemCommand incomplete: Missing "key" property' + }) + } + + return getItemParams(this[$entity], this[$key], this[$options]) + } + + send = async (): Promise> => { + const getItemParams = this.params() + + const commandOutput = await this[$entity].table.documentClient.send( + new GetCommand(getItemParams) + ) + + const { Item: item, ...restCommandOutput } = commandOutput + + if (item === undefined) { + return restCommandOutput + } + + const { attributes } = this[$options] + const formattedItem = formatSavedItem(this[$entity], item, { attributes }) + + return { + Item: formattedItem, + ...restCommandOutput + } + } +} + +export type GetItemCommandClass = typeof GetItemCommand diff --git a/src/v1/operations/getItem/getItemParams/getItemParams.ts b/src/v1/operations/getItem/getItemParams/getItemParams.ts new file mode 100644 index 000000000..1deefbf67 --- /dev/null +++ b/src/v1/operations/getItem/getItemParams/getItemParams.ts @@ -0,0 +1,31 @@ +import type { GetCommandInput } from '@aws-sdk/lib-dynamodb' + +import type { EntityV2 } from 'v1/entity/class' +import type { KeyInput } from 'v1/operations/types' +import { parseEntityKeyInput } from 'v1/operations/utils/parseKeyInput' +import { parsePrimaryKey } from 'v1/operations/utils/parsePrimaryKey' + +import type { GetItemOptions } from '../options' + +import { parseGetItemOptions } from './parseGetItemOptions' + +export const getItemParams = >( + entity: ENTITY, + input: KeyInput, + getItemOptions: OPTIONS = {} as OPTIONS +): GetCommandInput => { + const validKeyInputParser = parseEntityKeyInput(entity, input) + const validKeyInput = validKeyInputParser.next().value + const collapsedInput = validKeyInputParser.next().value + + const keyInput = entity.computeKey ? entity.computeKey(validKeyInput) : collapsedInput + const primaryKey = parsePrimaryKey(entity, keyInput) + + const options = parseGetItemOptions(entity, getItemOptions) + + return { + TableName: entity.table.getName(), + Key: primaryKey, + ...options + } +} diff --git a/src/v1/operations/getItem/getItemParams/getItemParams.unit.test.ts b/src/v1/operations/getItem/getItemParams/getItemParams.unit.test.ts new file mode 100644 index 000000000..263b58f92 --- /dev/null +++ b/src/v1/operations/getItem/getItemParams/getItemParams.unit.test.ts @@ -0,0 +1,230 @@ +import { DynamoDBClient } from '@aws-sdk/client-dynamodb' +import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb' + +import { TableV2, EntityV2, schema, string, DynamoDBToolboxError, GetItemCommand, prefix } from 'v1' + +const dynamoDbClient = new DynamoDBClient({}) + +const documentClient = DynamoDBDocumentClient.from(dynamoDbClient) + +const TestTable = new TableV2({ + name: 'test-table', + partitionKey: { + type: 'string', + name: 'pk' + }, + sortKey: { + type: 'string', + name: 'sk' + }, + documentClient +}) + +const TestEntity = new EntityV2({ + name: 'TestEntity', + schema: schema({ + email: string().key().savedAs('pk'), + sort: string().key().savedAs('sk'), + test: string() + }), + table: TestTable +}) + +const TestTable2 = new TableV2({ + name: 'test-table', + partitionKey: { type: 'string', name: 'pk' }, + sortKey: { type: 'string', name: 'sk' }, + documentClient +}) + +const TestEntity2 = new EntityV2({ + name: 'TestEntity', + schema: schema({ + pk: string().key(), + sk: string().key(), + test: string() + }), + table: TestTable2 +}) + +describe('get', () => { + it('gets the key from inputs', async () => { + const { TableName, Key } = TestEntity.build(GetItemCommand) + .key({ + email: 'test-pk', + sort: 'test-sk' + }) + .params() + + expect(TableName).toBe('test-table') + expect(Key).toStrictEqual({ pk: 'test-pk', sk: 'test-sk' }) + }) + + it('filters out extra data', async () => { + const { Key } = TestEntity.build(GetItemCommand) + .key({ + email: 'test-pk', + sort: 'test-sk', + // @ts-expect-error + test: 'test' + }) + .params() + + expect(Key).not.toHaveProperty('test') + }) + + it('fails with undefined input', () => { + expect(() => + TestEntity.build(GetItemCommand) + .key( + // @ts-expect-error + {} + ) + .params() + ).toThrow('Attribute email is required') + }) + + it('fails when missing the sortKey', () => { + expect(() => + TestEntity.build(GetItemCommand) + .key( + // @ts-expect-error + { pk: 'test-pk' } + ) + .params() + ).toThrow('Attribute email is required') + }) + + it('fails when missing partitionKey (no alias)', () => { + expect(() => + TestEntity2.build(GetItemCommand) + .key( + // @ts-expect-error + {} + ) + .params() + ).toThrow('Attribute pk is required') + }) + + it('fails when missing the sortKey (no alias)', () => { + expect(() => + TestEntity2.build(GetItemCommand) + .key( + // @ts-expect-error + { pk: 'test-pk' } + ) + .params() + ).toThrow('Attribute sk is required') + }) + + // Options + it('sets capacity options', () => { + const { ReturnConsumedCapacity } = TestEntity.build(GetItemCommand) + .key({ email: 'x', sort: 'y' }) + .options({ capacity: 'NONE' }) + .params() + + expect(ReturnConsumedCapacity).toBe('NONE') + }) + + it('fails on invalid capacity option', () => { + const invalidCall = () => + TestEntity.build(GetItemCommand) + .key({ email: 'x', sort: 'y' }) + .options({ + // @ts-expect-error + capacity: 'test' + }) + .params() + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow( + expect.objectContaining({ code: 'operations.invalidCapacityOption' }) + ) + }) + + it('sets consistent option', () => { + const { ConsistentRead } = TestEntity.build(GetItemCommand) + .key({ email: 'x', sort: 'y' }) + .options({ consistent: true }) + .params() + + expect(ConsistentRead).toBe(true) + }) + + it('fails on invalid consistent option', () => { + const invalidCall = () => + TestEntity.build(GetItemCommand) + .key({ email: 'x', sort: 'y' }) + .options({ + // @ts-expect-error + consistent: 'true' + }) + .params() + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow( + expect.objectContaining({ code: 'operations.invalidConsistentOption' }) + ) + }) + + it('fails on extra options', () => { + const invalidCall = () => + TestEntity.build(GetItemCommand) + .key({ email: 'x', sort: 'y' }) + .options({ + // @ts-expect-error + extra: true + }) + .params() + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow(expect.objectContaining({ code: 'operations.unknownOption' })) + }) + + it('parses attribute projections', () => { + const { ExpressionAttributeNames, ProjectionExpression } = TestEntity.build(GetItemCommand) + .key({ email: 'x', sort: 'y' }) + .options({ attributes: ['email'] }) + .params() + + expect(ExpressionAttributeNames).toEqual({ '#p_1': 'pk' }) + expect(ProjectionExpression).toBe('#p_1') + }) + + it('missing key', () => { + const invalidCall = () => TestEntity.build(GetItemCommand).params() + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow(expect.objectContaining({ code: 'operations.incompleteCommand' })) + }) + + it('transformed key', () => { + const TestEntity3 = new EntityV2({ + name: 'TestEntity', + schema: schema({ + email: string().key().savedAs('pk').transform(prefix('EMAIL')), + sort: string().key().savedAs('sk') + }), + table: TestTable + }) + + const { Key } = TestEntity3.build(GetItemCommand) + .key({ email: 'foo@bar.mail', sort: 'y' }) + .params() + + expect(Key).toMatchObject({ pk: 'EMAIL#foo@bar.mail' }) + }) + + // TODO Create getBatch method and move tests there + // it('formats a batch get response', async () => { + // let { Table, Key } = TestEntity.getBatch({ email: 'a', sort: 'b' }) + // expect(Table.name).toBe('test-table') + // expect(Key).toEqual({ pk: 'a', sk: 'b' }) + // }) + + // it('fails if no value is provided to the getBatch method', () => { + // // @ts-expect-error + // expect(() => TestEntity.getBatch()).toThrow(`'pk' or 'email' is required`) + // }) +}) diff --git a/src/v1/operations/getItem/getItemParams/index.ts b/src/v1/operations/getItem/getItemParams/index.ts new file mode 100644 index 000000000..15abf4cc3 --- /dev/null +++ b/src/v1/operations/getItem/getItemParams/index.ts @@ -0,0 +1 @@ +export { getItemParams } from './getItemParams' diff --git a/src/v1/operations/getItem/getItemParams/parseGetItemOptions.ts b/src/v1/operations/getItem/getItemParams/parseGetItemOptions.ts new file mode 100644 index 000000000..7e04afb3e --- /dev/null +++ b/src/v1/operations/getItem/getItemParams/parseGetItemOptions.ts @@ -0,0 +1,43 @@ +import type { GetCommandInput } from '@aws-sdk/lib-dynamodb' +import isEmpty from 'lodash.isempty' + +import { parseCapacityOption } from 'v1/operations/utils/parseOptions/parseCapacityOption' +import { rejectExtraOptions } from 'v1/operations/utils/parseOptions/rejectExtraOptions' +import { parseConsistentOption } from 'v1/operations/utils/parseOptions/parseConsistentOption' +import { parseProjection } from 'v1/operations/expression/projection/parse' +import { EntityV2 } from 'v1/entity' + +import type { GetItemOptions } from '../options' + +type CommandOptions = Omit + +export const parseGetItemOptions = ( + entity: ENTITY, + getItemOptions: GetItemOptions +): CommandOptions => { + const commandOptions: CommandOptions = {} + + const { capacity, consistent, attributes, ...extraOptions } = getItemOptions + + if (capacity !== undefined) { + commandOptions.ReturnConsumedCapacity = parseCapacityOption(capacity) + } + + if (consistent !== undefined) { + commandOptions.ConsistentRead = parseConsistentOption(consistent) + } + + if (attributes !== undefined) { + const { ExpressionAttributeNames, ProjectionExpression } = parseProjection(entity, attributes) + + if (!isEmpty(ExpressionAttributeNames)) { + commandOptions.ExpressionAttributeNames = ExpressionAttributeNames + } + + commandOptions.ProjectionExpression = ProjectionExpression + } + + rejectExtraOptions(extraOptions) + + return commandOptions +} diff --git a/src/v1/operations/getItem/index.ts b/src/v1/operations/getItem/index.ts new file mode 100644 index 000000000..3b1db08f2 --- /dev/null +++ b/src/v1/operations/getItem/index.ts @@ -0,0 +1,3 @@ +export { GetItemCommand } from './command' +export type { GetItemResponse } from './command' +export type { GetItemOptions } from './options' diff --git a/src/v1/operations/getItem/options.ts b/src/v1/operations/getItem/options.ts new file mode 100644 index 000000000..cc16d38ef --- /dev/null +++ b/src/v1/operations/getItem/options.ts @@ -0,0 +1,9 @@ +import type { CapacityOption } from 'v1/operations/constants/options/capacity' +import type { EntityV2 } from 'v1/entity' +import type { AnyAttributePath } from 'v1/operations/types' + +export interface GetItemOptions { + capacity?: CapacityOption + consistent?: boolean + attributes?: AnyAttributePath[] +} diff --git a/src/v1/operations/index.ts b/src/v1/operations/index.ts new file mode 100644 index 000000000..0f9bd82c9 --- /dev/null +++ b/src/v1/operations/index.ts @@ -0,0 +1,29 @@ +export { GetItemCommand } from './getItem' +export type { GetItemOptions, GetItemResponse } from './getItem' +export { PutItemCommand } from './putItem' +export type { PutItemInput, PutItemOptions, PutItemResponse } from './putItem' +export { DeleteItemCommand } from './deleteItem' +export type { DeleteItemOptions, DeleteItemResponse } from './deleteItem' +export { + UpdateItemCommand, + $set, + $get, + $remove, + $sum, + $subtract, + $add, + $delete, + $append, + $prepend +} from './updateItem' +export type { UpdateItemInput, UpdateItemOptions, UpdateItemResponse } from './updateItem' +export { ScanCommand } from './scan' +export type { ScanOptions, ScanResponse } from './scan' +export { QueryCommand } from './query' +export type { QueryOptions, QueryResponse } from './query' +export { batchWrite, DeleteBatchItemRequest, PutBatchItemRequest } from './batch' +export type { BatchWriteOptions, BatchWriteRequestInterface } from './batch' +export { formatSavedItem } from './utils/formatSavedItem' +export { parseCondition } from './expression/condition/parse' +export { parseProjection } from './expression/projection/parse' +export * from './types' diff --git a/src/v1/operations/putItem/command.ts b/src/v1/operations/putItem/command.ts new file mode 100644 index 000000000..39377ed0e --- /dev/null +++ b/src/v1/operations/putItem/command.ts @@ -0,0 +1,116 @@ +import type { O } from 'ts-toolbelt' +import { PutCommandInput, PutCommand, PutCommandOutput } from '@aws-sdk/lib-dynamodb' + +import type { EntityV2, FormattedItem } from 'v1/entity' +import type { + NoneReturnValuesOption, + UpdatedOldReturnValuesOption, + UpdatedNewReturnValuesOption, + AllOldReturnValuesOption, + AllNewReturnValuesOption +} from 'v1/operations/constants/options/returnValues' +import { DynamoDBToolboxError } from 'v1/errors' +import { formatSavedItem } from 'v1/operations/utils/formatSavedItem' + +import { EntityOperation, $entity } from '../class' +import type { PutItemInput } from './types' +import type { PutItemOptions, PutItemCommandReturnValuesOption } from './options' +import { putItemParams } from './putItemParams' + +export const $item = Symbol('$item') +export type $item = typeof $item + +export const $options = Symbol('$options') +export type $options = typeof $options + +type ReturnedAttributes< + ENTITY extends EntityV2, + OPTIONS extends PutItemOptions +> = PutItemCommandReturnValuesOption extends OPTIONS['returnValues'] + ? undefined + : OPTIONS['returnValues'] extends NoneReturnValuesOption + ? undefined + : OPTIONS['returnValues'] extends UpdatedOldReturnValuesOption | UpdatedNewReturnValuesOption + ? + | FormattedItem< + ENTITY, + { + partial: OPTIONS['returnValues'] extends + | UpdatedOldReturnValuesOption + | UpdatedNewReturnValuesOption + ? true + : false + } + > + | undefined + : OPTIONS['returnValues'] extends AllNewReturnValuesOption | AllOldReturnValuesOption + ? FormattedItem | undefined + : never + +export type PutItemResponse< + ENTITY extends EntityV2, + OPTIONS extends PutItemOptions = PutItemOptions +> = O.Merge< + Omit, + { Attributes?: ReturnedAttributes } +> + +export class PutItemCommand< + ENTITY extends EntityV2 = EntityV2, + OPTIONS extends PutItemOptions = PutItemOptions +> extends EntityOperation { + static operationName = 'put' as const; + + [$item]?: PutItemInput + item: (nextItem: PutItemInput) => PutItemCommand; + [$options]: OPTIONS + options: >( + nextOptions: NEXT_OPTIONS + ) => PutItemCommand + + constructor(entity: ENTITY, item?: PutItemInput, options: OPTIONS = {} as OPTIONS) { + super(entity) + this[$item] = item + this[$options] = options + + this.item = nextItem => new PutItemCommand(this[$entity], nextItem, this[$options]) + this.options = nextOptions => new PutItemCommand(this[$entity], this[$item], nextOptions) + } + + params = (): PutCommandInput => { + if (!this[$item]) { + throw new DynamoDBToolboxError('operations.incompleteCommand', { + message: 'PutItemCommand incomplete: Missing "item" property' + }) + } + + return putItemParams(this[$entity], this[$item], this[$options]) + } + + send = async (): Promise> => { + const putItemParams = this.params() + + const commandOutput = await this[$entity].table.documentClient.send( + new PutCommand(putItemParams) + ) + + const { Attributes: attributes, ...restCommandOutput } = commandOutput + + if (attributes === undefined) { + return restCommandOutput + } + + const { returnValues } = this[$options] + + const formattedItem = (formatSavedItem(this[$entity], attributes, { + partial: returnValues === 'UPDATED_NEW' || returnValues === 'UPDATED_OLD' + }) as unknown) as ReturnedAttributes + + return { + Attributes: formattedItem, + ...restCommandOutput + } + } +} + +export type PutItemCommandClass = typeof PutItemCommand diff --git a/src/v1/operations/putItem/index.ts b/src/v1/operations/putItem/index.ts new file mode 100644 index 000000000..c41d14abb --- /dev/null +++ b/src/v1/operations/putItem/index.ts @@ -0,0 +1,4 @@ +export { PutItemCommand } from './command' +export type { PutItemResponse } from './command' +export type { PutItemOptions } from './options' +export type { PutItemInput } from './types' diff --git a/src/v1/operations/putItem/options.ts b/src/v1/operations/putItem/options.ts new file mode 100644 index 000000000..8167206a4 --- /dev/null +++ b/src/v1/operations/putItem/options.ts @@ -0,0 +1,22 @@ +import type { CapacityOption } from 'v1/operations/constants/options/capacity' +import type { MetricsOption } from 'v1/operations/constants/options/metrics' +import type { ReturnValuesOption } from 'v1/operations/constants/options/returnValues' +import type { Condition } from 'v1/operations/types' +import type { EntityV2 } from 'v1/entity' + +export type PutItemCommandReturnValuesOption = ReturnValuesOption + +export const putItemCommandReturnValuesOptionsSet = new Set([ + 'NONE', + 'ALL_OLD', + 'UPDATED_OLD', + 'ALL_NEW', + 'UPDATED_NEW' +]) + +export interface PutItemOptions { + capacity?: CapacityOption + metrics?: MetricsOption + returnValues?: PutItemCommandReturnValuesOption + condition?: Condition +} diff --git a/src/v1/operations/putItem/putItemParams/index.ts b/src/v1/operations/putItem/putItemParams/index.ts new file mode 100644 index 000000000..e20017f11 --- /dev/null +++ b/src/v1/operations/putItem/putItemParams/index.ts @@ -0,0 +1 @@ +export { putItemParams } from './putItemParams' diff --git a/src/v1/operations/putItem/putItemParams/parsePutCommandInput.ts b/src/v1/operations/putItem/putItemParams/parsePutCommandInput.ts new file mode 100644 index 000000000..442f508cd --- /dev/null +++ b/src/v1/operations/putItem/putItemParams/parsePutCommandInput.ts @@ -0,0 +1,18 @@ +import type { EntityV2 } from 'v1/entity' +import type { Item, RequiredOption } from 'v1/schema' +import { parseSchemaClonedInput } from 'v1/validation/parseClonedInput' + +type EntityPutCommandInputParser = (entity: EntityV2, input: Item) => Generator + +const requiringOptions = new Set(['always', 'atLeastOnce']) + +export const parseEntityPutCommandInput: EntityPutCommandInputParser = (entity, input) => { + const parser = parseSchemaClonedInput(entity.schema, input, { + requiringOptions, + operationName: 'put' + }) + + parser.next() // cloned + + return parser +} diff --git a/src/v1/operations/putItem/putItemParams/parsePutItemOptions.ts b/src/v1/operations/putItem/putItemParams/parsePutItemOptions.ts new file mode 100644 index 000000000..3df21eb34 --- /dev/null +++ b/src/v1/operations/putItem/putItemParams/parsePutItemOptions.ts @@ -0,0 +1,58 @@ +import type { PutCommandInput } from '@aws-sdk/lib-dynamodb' +import isEmpty from 'lodash.isempty' + +import type { EntityV2 } from 'v1/entity' +import { parseCondition } from 'v1/operations/expression/condition/parse' +import { parseCapacityOption } from 'v1/operations/utils/parseOptions/parseCapacityOption' +import { parseMetricsOption } from 'v1/operations/utils/parseOptions/parseMetricsOption' +import { parseReturnValuesOption } from 'v1/operations/utils/parseOptions/parseReturnValuesOption' +import { rejectExtraOptions } from 'v1/operations/utils/parseOptions/rejectExtraOptions' + +import { putItemCommandReturnValuesOptionsSet, PutItemOptions } from '../options' + +type CommandOptions = Omit + +export const parsePutItemOptions = ( + entity: ENTITY, + putItemOptions: PutItemOptions +): CommandOptions => { + const commandOptions: CommandOptions = {} + + const { capacity, metrics, returnValues, condition, ...extraOptions } = putItemOptions + rejectExtraOptions(extraOptions) + + if (capacity !== undefined) { + commandOptions.ReturnConsumedCapacity = parseCapacityOption(capacity) + } + + if (metrics !== undefined) { + commandOptions.ReturnItemCollectionMetrics = parseMetricsOption(metrics) + } + + if (returnValues !== undefined) { + commandOptions.ReturnValues = parseReturnValuesOption( + putItemCommandReturnValuesOptionsSet, + returnValues + ) + } + + if (condition !== undefined) { + const { + ExpressionAttributeNames, + ExpressionAttributeValues, + ConditionExpression + } = parseCondition(entity, condition) + + if (!isEmpty(ExpressionAttributeNames)) { + commandOptions.ExpressionAttributeNames = ExpressionAttributeNames + } + + if (!isEmpty(ExpressionAttributeValues)) { + commandOptions.ExpressionAttributeValues = ExpressionAttributeValues + } + + commandOptions.ConditionExpression = ConditionExpression + } + + return commandOptions +} diff --git a/src/v1/operations/putItem/putItemParams/putItemParams.ts b/src/v1/operations/putItem/putItemParams/putItemParams.ts new file mode 100644 index 000000000..f1892ee38 --- /dev/null +++ b/src/v1/operations/putItem/putItemParams/putItemParams.ts @@ -0,0 +1,31 @@ +import type { PutCommandInput } from '@aws-sdk/lib-dynamodb' + +import type { EntityV2 } from 'v1/entity' +import { parsePrimaryKey } from 'v1/operations/utils/parsePrimaryKey' + +import type { PutItemInput } from '../types' +import type { PutItemOptions } from '../options' + +import { parseEntityPutCommandInput } from './parsePutCommandInput' +import { parsePutItemOptions } from './parsePutItemOptions' + +export const putItemParams = >( + entity: ENTITY, + input: PutItemInput, + putItemOptions: OPTIONS = {} as OPTIONS +): PutCommandInput => { + const validInputParser = parseEntityPutCommandInput(entity, input) + const validInput = validInputParser.next().value + const collapsedInput = validInputParser.next().value + + const keyInput = entity.computeKey ? entity.computeKey(validInput) : collapsedInput + const primaryKey = parsePrimaryKey(entity, keyInput) + + const options = parsePutItemOptions(entity, putItemOptions) + + return { + TableName: entity.table.getName(), + Item: { ...collapsedInput, ...primaryKey }, + ...options + } +} diff --git a/src/v1/operations/putItem/putItemParams/putItemParams.unit.test.ts b/src/v1/operations/putItem/putItemParams/putItemParams.unit.test.ts new file mode 100644 index 000000000..716a81653 --- /dev/null +++ b/src/v1/operations/putItem/putItemParams/putItemParams.unit.test.ts @@ -0,0 +1,596 @@ +import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb' +import { DynamoDBClient } from '@aws-sdk/client-dynamodb' + +import { + TableV2, + EntityV2, + schema, + any, + binary, + string, + number, + boolean, + set, + list, + map, + record, + DynamoDBToolboxError, + PutItemCommand, + prefix +} from 'v1' + +const dynamoDbClient = new DynamoDBClient({}) + +const documentClient = DynamoDBDocumentClient.from(dynamoDbClient) + +const TestTable = new TableV2({ + name: 'test-table', + partitionKey: { + type: 'string', + name: 'pk' + }, + sortKey: { + type: 'string', + name: 'sk' + }, + documentClient +}) + +const TestEntity = new EntityV2({ + name: 'TestEntity', + schema: schema({ + email: string().key().savedAs('pk'), + sort: string().key().savedAs('sk'), + test_any: any().optional(), + test_binary: binary().optional(), + test_string: string().putDefault('test string'), + count: number().optional().savedAs('test_number'), + test_number_defaulted: number().putDefault(0), + test_boolean: boolean().optional(), + test_list: list(string()).optional(), + test_map: map({ + str: string() + }).optional(), + test_string_set: set(string()).optional(), + test_number_set: set(number()).optional(), + test_binary_set: set(binary()).optional() + }), + table: TestTable +}) + +const TestTable2 = new TableV2({ + name: 'test-table', + partitionKey: { type: 'string', name: 'pk' }, + documentClient +}) + +const TestEntity2 = new EntityV2({ + name: 'TestEntity2', + schema: schema({ + email: string().key().savedAs('pk'), + test_composite: string().optional(), + test_composite2: string().optional() + }).and(schema => ({ + sort: string() + .optional() + .savedAs('sk') + .putLink( + ({ test_composite, test_composite2 }) => + test_composite && test_composite2 && [test_composite, test_composite2].join('#') + ) + })), + table: TestTable2 +}) + +const TestEntity3 = new EntityV2({ + name: 'TestEntity3', + schema: schema({ + email: string().key().savedAs('pk'), + test: any(), + test2: string().optional() + }), + table: TestTable2 +}) + +const TestTable3 = new TableV2({ + name: 'TestTable3', + partitionKey: { type: 'string', name: 'pk' }, + sortKey: { type: 'string', name: 'sk' }, + documentClient +}) + +const TestEntity4 = new EntityV2({ + name: 'TestEntity4', + schema: schema({ + id: number().key().savedAs('pk'), + // sk: { hidden: true, sortKey: true, default: (data: any) => data.id }, + xyz: any().optional().savedAs('test') + }), + computeKey: ({ id }) => ({ pk: String(id), sk: String(id) }), + table: TestTable3 +}) + +const TestEntity5 = new EntityV2({ + name: 'TestEntity5', + schema: schema({ + pk: string().key(), + test_required_boolean: boolean(), + test_required_number: number() + }), + table: TestTable2 +}) + +describe('put', () => { + it('creates basic item', () => { + const { Item } = TestEntity.build(PutItemCommand) + .item({ email: 'test-pk', sort: 'test-sk' }) + .params() + + expect(Item).toMatchObject({ + _et: TestEntity.name, + _ct: expect.any(String), + _md: expect.any(String), + pk: 'test-pk', + sk: 'test-sk', + test_string: 'test string', + test_number_defaulted: 0 + }) + }) + + it('creates item with aliases', () => { + const { Item } = TestEntity.build(PutItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + count: 0 + }) + .params() + + expect(Item).toMatchObject({ test_number: 0 }) + }) + + it('creates item with overridden default override', () => { + const overrideValue = 'different value' + + const { Item } = TestEntity.build(PutItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + test_string: overrideValue + }) + .params() + + expect(Item).toMatchObject({ test_string: overrideValue }) + }) + + it('creates item with composite field', () => { + const { Item } = TestEntity2.build(PutItemCommand) + .item({ + email: 'test-pk', + test_composite: 'test' + }) + .params() + + expect(Item).not.toHaveProperty('sort') + }) + + it('creates item with filled composite key', () => { + const { Item } = TestEntity2.build(PutItemCommand) + .item({ + email: 'test-pk', + test_composite: 'test', + test_composite2: 'test2' + }) + .params() + + expect(Item).toMatchObject({ sk: 'test#test2' }) + }) + + it('creates item with overriden composite key', () => { + const { Item } = TestEntity2.build(PutItemCommand) + .item({ + email: 'test-pk', + sort: 'override', + test_composite: 'test', + test_composite2: 'test2' + }) + .params() + + expect(Item).toMatchObject({ sk: 'override' }) + }) + + it('fails if required attribute misses', () => { + expect(() => + TestEntity3.build(PutItemCommand) + .item( + // @ts-expect-error + { email: 'test-pk' } + ) + .params() + ).toThrow('Attribute test is required') + }) + + it('ignores additional attribute', () => { + const { Item } = TestEntity.build(PutItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + // @ts-expect-error + unknown: '?' + }) + .params() + + expect(Item).not.toHaveProperty('unknown') + }) + + it('fails when invalid string provided with no coercion', () => { + expect(() => + TestEntity.build(PutItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + // @ts-expect-error + test_string: 1 + }) + .params() + ).toThrow('Attribute test_string should be a string') + }) + + it('fails when invalid boolean provided with no coercion', () => { + expect(() => + TestEntity.build(PutItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + // @ts-expect-error + test_boolean: 'x' + }) + .params() + ).toThrow('Attribute test_boolean should be a boolean') + }) + + it('fails when invalid number provided with no coercion', () => { + expect(() => + TestEntity.build(PutItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + // @ts-expect-error + count: 'x' + }) + .params() + ).toThrow('Attribute count should be a number') + }) + + it('with valid array', () => { + const { Item } = TestEntity.build(PutItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + test_list: ['a', 'b'] + }) + .params() + + expect(Item).toMatchObject({ + test_list: ['a', 'b'] + }) + }) + + it('fails when invalid array provided', () => { + expect(() => + TestEntity.build(PutItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + // @ts-expect-error + test_list: ['a', 2] + }) + .params() + ).toThrow('Attribute test_list[n] should be a string') + }) + + it('with valid map', () => { + const { Item } = TestEntity.build(PutItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + test_map: { + str: 'x' + } + }) + .params() + + expect(Item).toMatchObject({ + test_map: { str: 'x' } + }) + }) + + it('fails when invalid map provided', () => { + expect(() => + TestEntity.build(PutItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + // @ts-expect-error + test_map: { str: 2 } + }) + .params() + ).toThrow('Attribute test_map.str should be a string') + }) + + it('with valid set', () => { + const { Item } = TestEntity.build(PutItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + test_string_set: new Set(['a', 'b', 'c']) + }) + .params() + + expect(Item).toMatchObject({ + test_string_set: new Set(['a', 'b', 'c']) + }) + }) + + it('fails when set contains different types', () => { + expect(() => + TestEntity.build(PutItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + // @ts-expect-error + test_string_set: new Set(['a', 'b', 3]) + }) + .params() + ).toThrow('Attribute test_string_set[x] should be a string') + }) + + it('fails when missing a required field', () => { + expect(() => + TestEntity3.build(PutItemCommand) + .item( + // @ts-expect-error + { email: 'test-pk', test2: 'test' } + ) + .params() + ).toThrow('Attribute test is required') + }) + + it('puts 0 and false to required fields', () => { + const { Item } = TestEntity5.build(PutItemCommand) + .item({ + pk: 'test-pk', + test_required_boolean: false, + test_required_number: 0 + }) + .params() + + expect(Item).toMatchObject({ + test_required_boolean: false, + test_required_number: 0 + }) + }) + + it('correctly aliases pks', () => { + const { Item } = TestEntity4.build(PutItemCommand).item({ id: 3, xyz: '123' }).params() + expect(Item).toMatchObject({ pk: '3', sk: '3' }) + }) + + // Options + it('sets capacity options', () => { + const { ReturnConsumedCapacity } = TestEntity.build(PutItemCommand) + .item({ email: 'x', sort: 'y' }) + .options({ capacity: 'NONE' }) + .params() + + expect(ReturnConsumedCapacity).toBe('NONE') + }) + + it('fails on invalid capacity option', () => { + const invalidCall = () => + TestEntity.build(PutItemCommand) + .item({ email: 'x', sort: 'y' }) + .options({ + // @ts-expect-error + capacity: 'test' + }) + .params() + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow( + expect.objectContaining({ code: 'operations.invalidCapacityOption' }) + ) + }) + + it('sets metrics options', () => { + const { ReturnItemCollectionMetrics } = TestEntity.build(PutItemCommand) + .item({ email: 'x', sort: 'y' }) + .options({ metrics: 'SIZE' }) + .params() + + expect(ReturnItemCollectionMetrics).toBe('SIZE') + }) + + it('fails on invalid metrics option', () => { + const invalidCall = () => + TestEntity.build(PutItemCommand) + .item({ email: 'x', sort: 'y' }) + .options({ + // @ts-expect-error + metrics: 'test' + }) + .params() + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow( + expect.objectContaining({ code: 'operations.invalidMetricsOption' }) + ) + }) + + it('sets returnValues options', () => { + const { ReturnValues } = TestEntity.build(PutItemCommand) + .item({ email: 'x', sort: 'y' }) + .options({ returnValues: 'ALL_OLD' }) + .params() + + expect(ReturnValues).toBe('ALL_OLD') + }) + + it('fails on invalid returnValues option', () => { + const invalidCall = () => + TestEntity.build(PutItemCommand) + .item({ email: 'x', sort: 'y' }) + .options({ + // @ts-expect-error + returnValues: 'test' + }) + .params() + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow( + expect.objectContaining({ code: 'operations.invalidReturnValuesOption' }) + ) + }) + + it('fails on extra options', () => { + const invalidCall = () => + TestEntity.build(PutItemCommand) + .item({ email: 'x', sort: 'y' }) + .options({ + // @ts-expect-error + extra: true + }) + .params() + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow(expect.objectContaining({ code: 'operations.unknownOption' })) + }) + + it('sets condition', () => { + const { + ExpressionAttributeNames, + ExpressionAttributeValues, + ConditionExpression + } = TestEntity.build(PutItemCommand) + .item({ email: 'x', sort: 'y' }) + .options({ condition: { attr: 'email', gt: 'test' } }) + .params() + + expect(ExpressionAttributeNames).toEqual({ '#c_1': 'pk' }) + expect(ExpressionAttributeValues).toEqual({ ':c_1': 'test' }) + expect(ConditionExpression).toBe('#c_1 > :c_1') + }) + + it('missing item', () => { + const invalidCall = () => TestEntity.build(PutItemCommand).params() + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow(expect.objectContaining({ code: 'operations.incompleteCommand' })) + }) + + it('transformed key/attribute', () => { + const TestEntity3 = new EntityV2({ + name: 'TestEntity', + schema: schema({ + email: string().key().savedAs('pk').transform(prefix('EMAIL')), + sort: string().key().savedAs('sk'), + transformedStr: string().transform(prefix('STR')), + transformedSet: set(string().transform(prefix('SET'))), + transformedList: list(string().transform(prefix('LIST'))), + transformedMap: map({ str: string().transform(prefix('MAP')) }), + transformedRecord: record( + string().transform(prefix('RECORD_KEY')), + string().transform(prefix('RECORD_VALUE')) + ) + }), + table: TestTable + }) + + const { + Item, + ConditionExpression, + ExpressionAttributeNames, + ExpressionAttributeValues + } = TestEntity3.build(PutItemCommand) + .item({ + email: 'foo@bar.mail', + sort: 'y', + transformedStr: 'str', + transformedSet: new Set(['set']), + transformedList: ['list'], + transformedMap: { str: 'map' }, + transformedRecord: { recordKey: 'recordValue' } + }) + .options({ + condition: { + and: [ + { attr: 'email', eq: 'test', transform: false }, + { attr: 'transformedStr', eq: 'str', transform: false }, + /** + * @debt feature "Can you apply Contains clauses to Set attributes?" + */ + // { attr: 'transformedSet', contains: 'SET' } + { attr: 'transformedMap.str', eq: 'map', transform: false }, + { attr: 'transformedRecord.key', eq: 'value', transform: false } + ] + } + }) + .params() + + expect(Item).toMatchObject({ + pk: 'EMAIL#foo@bar.mail', + transformedStr: 'STR#str', + transformedSet: new Set(['SET#set']), + transformedList: ['LIST#list'], + transformedMap: { str: 'MAP#map' }, + transformedRecord: { 'RECORD_KEY#recordKey': 'RECORD_VALUE#recordValue' } + }) + expect(ConditionExpression).toContain('#c_5.#c_6 = :c_4') + expect(ExpressionAttributeNames).toMatchObject({ + '#c_5': 'transformedRecord', + // transform is only applied to values, not to paths + '#c_6': 'RECORD_KEY#key' + }) + expect(ExpressionAttributeValues).toMatchObject({ + ':c_1': 'test', + ':c_2': 'str', + ':c_3': 'map', + ':c_4': 'value' + }) + + const { ExpressionAttributeValues: ExpressionAttributeValues2 } = TestEntity3.build( + PutItemCommand + ) + .item({ + email: 'foo@bar.mail', + sort: 'y', + transformedStr: 'str', + transformedSet: new Set(['set']), + transformedList: ['list'], + transformedMap: { str: 'map' }, + transformedRecord: { recordKey: 'recordValue' } + }) + .options({ + condition: { + and: [ + { attr: 'email', eq: 'test' }, + { attr: 'transformedStr', eq: 'str' }, + /** + * @debt feature "Can you apply Contains clauses to Set attributes?" + */ + // { attr: 'transformedSet', contains: 'SET' } + { attr: 'transformedMap.str', eq: 'map' }, + { attr: 'transformedRecord.key', eq: 'value' } + ] + } + }) + .params() + + expect(ExpressionAttributeValues2).toMatchObject({ + ':c_1': 'EMAIL#test', + ':c_2': 'STR#str', + ':c_3': 'MAP#map', + ':c_4': 'RECORD_VALUE#value' + }) + }) +}) diff --git a/src/v1/operations/putItem/types.ts b/src/v1/operations/putItem/types.ts new file mode 100644 index 000000000..7b24bc2d7 --- /dev/null +++ b/src/v1/operations/putItem/types.ts @@ -0,0 +1,110 @@ +import type { O } from 'ts-toolbelt' + +import type { + Schema, + Attribute, + Item, + AttributeValue, + ResolveAnyAttribute, + ResolvePrimitiveAttribute, + AnyAttribute, + PrimitiveAttribute, + SetAttribute, + ListAttribute, + MapAttribute, + RecordAttribute, + AnyOfAttribute, + AtLeastOnce, + Always, + Never +} from 'v1/schema' +import type { OptionalizeUndefinableProperties } from 'v1/types/optionalizeUndefinableProperties' +import type { EntityV2 } from 'v1/entity/class' +import type { If } from 'v1/types/if' + +export type MustBeDefined< + ATTRIBUTE extends Attribute, + REQUIRED_DEFAULTS extends boolean = false +> = REQUIRED_DEFAULTS extends false + ? ATTRIBUTE extends { required: AtLeastOnce | Always } & ( + | { key: true; defaults: { key: undefined } } + | { key: false; defaults: { put: undefined } } + ) + ? true + : false + : ATTRIBUTE extends { required: AtLeastOnce | Always } + ? true + : false + +/** + * User input of a PUT command for a given Entity or Schema + * + * @param Schema Entity | Schema + * @param RequireIndependentDefaults Boolean + * @return Object + */ +export type PutItemInput< + SCHEMA extends EntityV2 | Schema, + REQUIRED_DEFAULTS extends boolean = false +> = EntityV2 extends SCHEMA + ? Item + : Schema extends SCHEMA + ? Item + : SCHEMA extends Schema + ? OptionalizeUndefinableProperties< + { + [KEY in keyof SCHEMA['attributes']]: AttributePutItemInput< + SCHEMA['attributes'][KEY], + REQUIRED_DEFAULTS + > + }, + // Sadly we override optional AnyAttributes as 'unknown | undefined' => 'unknown' (undefined lost in the process) + O.SelectKeys + > + : SCHEMA extends EntityV2 + ? PutItemInput + : never + +/** + * User input of a PUT command for a given Attribute + * + * @param Attribute Attribute + * @param RequireIndependentDefaults Boolean + * @return Any + */ +export type AttributePutItemInput< + ATTRIBUTE extends Attribute, + REQUIRED_DEFAULTS extends boolean = false +> = Attribute extends ATTRIBUTE + ? AttributeValue | undefined + : + | If, never, undefined> + | (ATTRIBUTE extends AnyAttribute + ? ResolveAnyAttribute + : ATTRIBUTE extends PrimitiveAttribute + ? ResolvePrimitiveAttribute + : ATTRIBUTE extends SetAttribute + ? Set> + : ATTRIBUTE extends ListAttribute + ? AttributePutItemInput[] + : ATTRIBUTE extends MapAttribute + ? OptionalizeUndefinableProperties< + { + [KEY in keyof ATTRIBUTE['attributes']]: AttributePutItemInput< + ATTRIBUTE['attributes'][KEY], + REQUIRED_DEFAULTS + > + }, + // Sadly we override optional AnyAttributes as 'unknown | undefined' => 'unknown' (undefined lost in the process) + O.SelectKeys + > + : ATTRIBUTE extends RecordAttribute + ? { + [KEY in ResolvePrimitiveAttribute]?: AttributePutItemInput< + ATTRIBUTE['elements'], + REQUIRED_DEFAULTS + > + } + : ATTRIBUTE extends AnyOfAttribute + ? AttributePutItemInput + : never) diff --git a/src/v1/operations/query/command.ts b/src/v1/operations/query/command.ts new file mode 100644 index 000000000..3b8336768 --- /dev/null +++ b/src/v1/operations/query/command.ts @@ -0,0 +1,220 @@ +import type { O } from 'ts-toolbelt' +import { + QueryCommandInput, + QueryCommand as _QueryCommand, + QueryCommandOutput +} from '@aws-sdk/lib-dynamodb' +import type { ConsumedCapacity } from '@aws-sdk/client-dynamodb' +import type { NativeAttributeValue } from '@aws-sdk/util-dynamodb' + +import type { TableV2 } from 'v1/table' +import type { EntityV2, FormattedItem } from 'v1/entity' +import type { Item } from 'v1/schema' +import type { CountSelectOption } from 'v1/operations/constants/options/select' +import type { AnyAttributePath, Query } from 'v1/operations/types' +import { formatSavedItem } from 'v1/operations/utils/formatSavedItem' +import { DynamoDBToolboxError } from 'v1/errors' +import { isString } from 'v1/utils/validation' + +import { $entities, $table, TableOperation } from '../class' +import type { QueryOptions } from './options' +import { queryParams } from './queryParams' + +const $query = Symbol('$query') +type $query = typeof $query + +const $options = Symbol('$options') +type $options = typeof $options + +type ReturnedItems< + TABLE extends TableV2, + ENTITIES extends EntityV2[], + QUERY extends Query
, + OPTIONS extends QueryOptions +> = OPTIONS['select'] extends CountSelectOption + ? undefined + : (EntityV2[] extends ENTITIES + ? Item + : ENTITIES[number] extends infer ENTITY + ? ENTITY extends EntityV2 + ? FormattedItem< + ENTITY, + { + attributes: OPTIONS['attributes'] extends AnyAttributePath[] + ? OPTIONS['attributes'][number] + : undefined + } + > + : never + : never)[] + +export type QueryResponse< + TABLE extends TableV2, + ENTITIES extends EntityV2[], + QUERY extends Query
, + OPTIONS extends QueryOptions +> = O.Merge< + Omit, + { + Items?: ReturnedItems + // $metadata is not returned on multiple page queries + $metadata?: QueryCommandOutput['$metadata'] + } +> + +export class QueryCommand< + TABLE extends TableV2 = TableV2, + ENTITIES extends EntityV2[] = EntityV2[], + QUERY extends Query
= Query
, + OPTIONS extends QueryOptions = QueryOptions +> extends TableOperation { + static operationName = 'query' as const + + entities: ( + ...nextEntities: NEXT_ENTITIES + ) => QueryCommand< + TABLE, + NEXT_ENTITIES, + QUERY, + OPTIONS extends QueryOptions + ? OPTIONS + : QueryOptions + >; + + [$query]?: QUERY + query: >( + query: NEXT_QUERY + ) => QueryCommand; + [$options]: OPTIONS + options: >( + nextOptions: NEXT_OPTIONS + ) => QueryCommand + + constructor( + table: TABLE, + entities = ([] as unknown) as ENTITIES, + query?: QUERY, + options: OPTIONS = {} as OPTIONS + ) { + super(table, entities) + this[$query] = query + this[$options] = options + + this.entities = (...nextEntities: NEXT_ENTITIES) => + new QueryCommand< + TABLE, + NEXT_ENTITIES, + QUERY, + OPTIONS extends QueryOptions + ? OPTIONS + : QueryOptions + >( + this[$table], + nextEntities, + this[$query], + this[$options] as OPTIONS extends QueryOptions + ? OPTIONS + : QueryOptions + ) + this.query = nextQuery => + new QueryCommand(this[$table], this[$entities], nextQuery, this[$options]) + this.options = nextOptions => + new QueryCommand(this[$table], this[$entities], this[$query], nextOptions) + } + + params = (): QueryCommandInput => { + if (!this[$query]) { + throw new DynamoDBToolboxError('operations.incompleteCommand', { + message: 'QueryCommand incomplete: Missing "query" property' + }) + } + + return queryParams(this[$table], this[$entities], this[$query], this[$options]) + } + + send = async (): Promise> => { + const queryParams = this.params() + + const entities = this[$entities] ?? [] + const entitiesByName: Record = {} + entities.forEach(entity => { + entitiesByName[entity.name] = entity + }) + + const formattedItems: Item[] = [] + let lastEvaluatedKey: Record | undefined = undefined + let count: number | undefined = 0 + let scannedCount: number | undefined = 0 + let consumedCapacity: ConsumedCapacity | undefined = undefined + let responseMetadata: QueryCommandOutput['$metadata'] | undefined = undefined + + // NOTE: maxPages has been validated by this.params() + const { attributes, maxPages = 1 } = this[$options] + let pageIndex = 0 + do { + pageIndex += 1 + + const pageQueryParams: QueryCommandInput = { + ...queryParams, + // NOTE: Important NOT to override initial exclusiveStartKey on first page + ...(lastEvaluatedKey !== undefined ? { ExclusiveStartKey: lastEvaluatedKey } : {}) + } + + const { + Items: items = [], + LastEvaluatedKey: pageLastEvaluatedKey, + Count: pageCount, + ScannedCount: pageScannedCount, + ConsumedCapacity: pageConsumedCapacity, + $metadata: pageMetadata + } = await this[$table].documentClient.send(new _QueryCommand(pageQueryParams)) + + for (const item of items) { + const itemEntityName = item[this[$table].entityAttributeSavedAs] + + if (!isString(itemEntityName)) { + continue + } + + const itemEntity = entitiesByName[itemEntityName] + + if (itemEntity === undefined) { + continue + } + + formattedItems.push( + formatSavedItem(itemEntity, item, { attributes }) + ) + } + + lastEvaluatedKey = pageLastEvaluatedKey + + if (count !== undefined) { + count = pageCount !== undefined ? count + pageCount : undefined + } + + if (scannedCount !== undefined) { + scannedCount = pageScannedCount !== undefined ? scannedCount + pageScannedCount : undefined + } + + consumedCapacity = pageConsumedCapacity + responseMetadata = pageMetadata + } while (pageIndex < maxPages && lastEvaluatedKey !== undefined) + + return { + Items: formattedItems as QueryResponse['Items'], + ...(lastEvaluatedKey !== undefined ? { LastEvaluatedKey: lastEvaluatedKey } : {}), + ...(count !== undefined ? { Count: count } : {}), + ...(scannedCount !== undefined ? { ScannedCount: scannedCount } : {}), + // return ConsumedCapacity & $metadata only if one page has been fetched + ...(pageIndex === 1 + ? { + ...(consumedCapacity !== undefined ? { ConsumedCapacity: consumedCapacity } : {}), + ...(responseMetadata !== undefined ? { $metadata: responseMetadata } : {}) + } + : {}) + } + } +} + +export type QueryCommandClass = typeof QueryCommand diff --git a/src/v1/operations/query/errors.ts b/src/v1/operations/query/errors.ts new file mode 100644 index 000000000..ad38b428f --- /dev/null +++ b/src/v1/operations/query/errors.ts @@ -0,0 +1 @@ +export type { QueryCommandErrorBlueprints } from './queryParams/errors' diff --git a/src/v1/operations/query/index.ts b/src/v1/operations/query/index.ts new file mode 100644 index 000000000..e6342f32b --- /dev/null +++ b/src/v1/operations/query/index.ts @@ -0,0 +1,3 @@ +export { QueryCommand } from './command' +export type { QueryResponse } from './command' +export type { QueryOptions } from './options' diff --git a/src/v1/operations/query/options.ts b/src/v1/operations/query/options.ts new file mode 100644 index 000000000..2ece8f460 --- /dev/null +++ b/src/v1/operations/query/options.ts @@ -0,0 +1,42 @@ +import type { CapacityOption } from 'v1/operations/constants/options/capacity' +import type { + SelectOption, + AllProjectedAttributesSelectOption, + SpecificAttributesSelectOption +} from 'v1/operations/constants/options/select' +import type { Condition, Query, AnyCommonAttributePath } from 'v1/operations/types' +import type { TableV2 } from 'v1/table' +import type { EntityV2 } from 'v1/entity' + +export type QueryOptions< + TABLE extends TableV2 = TableV2, + ENTITIES extends EntityV2[] = EntityV2[], + QUERY extends Query
= Query
+> = { + capacity?: CapacityOption + exclusiveStartKey?: Record + limit?: number + maxPages?: number + reverse?: boolean + filters?: EntityV2[] extends ENTITIES + ? Record + : { [ENTITY in ENTITIES[number] as ENTITY['name']]?: Condition } +} & (QUERY['index'] extends string + ? { + // consistent must be false if a secondary index is queried + consistent?: false + select?: SelectOption + } + : { + consistent?: boolean + // "ALL_PROJECTED_ATTRIBUTES" is only available if a secondary index is queried + select?: Exclude + }) & + ( + | { attributes?: undefined; select?: SelectOption } + | { + attributes: AnyCommonAttributePath[] + // "SPECIFIC_ATTRIBUTES" is the only valid option if projectionExpression is present + select?: SpecificAttributesSelectOption + } + ) diff --git a/src/v1/operations/query/queryParams/errors.ts b/src/v1/operations/query/queryParams/errors.ts new file mode 100644 index 000000000..f79e0bbd5 --- /dev/null +++ b/src/v1/operations/query/queryParams/errors.ts @@ -0,0 +1,17 @@ +import type { ErrorBlueprint } from 'v1/errors/blueprint' + +type InvalidReverseOptionErrorBlueprint = ErrorBlueprint<{ + code: 'queryCommand.invalidReverseOption' + hasPath: false + payload: { reverse?: unknown } +}> + +type InvalidPartitionErrorBlueprint = ErrorBlueprint<{ + code: 'queryCommand.invalidPartition' + hasPath: true + payload: { partition?: unknown } +}> + +export type QueryCommandErrorBlueprints = + | InvalidReverseOptionErrorBlueprint + | InvalidPartitionErrorBlueprint diff --git a/src/v1/operations/query/queryParams/index.ts b/src/v1/operations/query/queryParams/index.ts new file mode 100644 index 000000000..22d2ba8c4 --- /dev/null +++ b/src/v1/operations/query/queryParams/index.ts @@ -0,0 +1 @@ +export { queryParams } from './queryParams' diff --git a/src/v1/operations/query/queryParams/parseQuery.ts b/src/v1/operations/query/queryParams/parseQuery.ts new file mode 100644 index 000000000..607838afe --- /dev/null +++ b/src/v1/operations/query/queryParams/parseQuery.ts @@ -0,0 +1,116 @@ +import type { O } from 'ts-toolbelt' +import type { QueryCommandInput } from '@aws-sdk/lib-dynamodb' +import { ConditionParser } from 'v1/operations/expression/condition/parser' +import _pick from 'lodash.pick' + +import type { Condition, Query } from 'v1/operations/types' +import type { Attribute, PrimitiveAttribute, ResolvedPrimitiveAttribute, Schema } from 'v1/schema' +import type { TableV2 } from 'v1/table' +import type { PrimitiveAttributeExtraCondition } from 'v1/operations/types/condition' +import { DynamoDBToolboxError } from 'v1/errors' +import { queryOperatorSet } from 'v1/operations/types/query' + +const defaultSchema: Schema = { + type: 'schema', + attributes: {}, + savedAttributeNames: new Set(), + keyAttributeNames: new Set(), + requiredAttributeNames: { + always: new Set(), + atLeastOnce: new Set(), + never: new Set() + }, + and: undefined as any +} + +const defaultAttribute: Omit = { + required: 'never', + key: false, + hidden: false, + savedAs: undefined, + defaults: { + key: undefined, + put: undefined, + update: undefined + } +} + +const pick = _pick as ( + object: OBJECT, + keys: KEYS +) => O.Pick + +export const parseQuery =
>( + table: TABLE, + query: QUERY +): Pick< + QueryCommandInput, + 'KeyConditionExpression' | 'ExpressionAttributeNames' | 'ExpressionAttributeValues' +> => { + const { index, partition, range } = query + const { partitionKey = table.partitionKey, sortKey } = + index !== undefined ? table.indexes[index] : table + + if (partition === undefined) { + throw new DynamoDBToolboxError('queryCommand.invalidPartition', { + message: `Missing query partition. Expected: ${partitionKey.type}.`, + path: partitionKey.name, + payload: { partition } + }) + } + + const indexSchema: Schema = defaultSchema + indexSchema.attributes[partitionKey.name] = { + ...defaultAttribute, + path: partitionKey.name, + type: partitionKey.type, + enum: undefined, + transform: undefined + } + + let condition: Condition = { + attr: partitionKey.name, + eq: partition + } + + if (sortKey !== undefined && range !== undefined) { + indexSchema.attributes[sortKey.name] = { + ...defaultAttribute, + path: sortKey.name, + type: sortKey.type, + enum: undefined, + transform: undefined + } + + const sortKeyCondition = ({ + attr: sortKey.name, + ...pick(range, [...queryOperatorSet]) + /** + * @debt type "TODO: Remove this cast" + */ + } as unknown) as PrimitiveAttributeExtraCondition< + string, + PrimitiveAttribute, + never, + ResolvedPrimitiveAttribute + > + + condition = { + and: [condition, sortKeyCondition] + } + } + + const conditionParser = new ConditionParser(indexSchema, '0') + conditionParser.parseCondition(condition) + const { + ConditionExpression, + ExpressionAttributeNames, + ExpressionAttributeValues + } = conditionParser.toCommandOptions() + + return { + KeyConditionExpression: ConditionExpression, + ExpressionAttributeNames, + ExpressionAttributeValues + } +} diff --git a/src/v1/operations/query/queryParams/queryParams.ts b/src/v1/operations/query/queryParams/queryParams.ts new file mode 100644 index 000000000..13748258f --- /dev/null +++ b/src/v1/operations/query/queryParams/queryParams.ts @@ -0,0 +1,169 @@ +import type { QueryCommandInput } from '@aws-sdk/lib-dynamodb' +import isEmpty from 'lodash.isempty' + +import type { TableV2 } from 'v1/table' +import type { AnyAttributePath, Condition, Query } from 'v1/operations/types' +import type { EntityV2 } from 'v1/entity' +import { DynamoDBToolboxError } from 'v1/errors' +import { parseCapacityOption } from 'v1/operations/utils/parseOptions/parseCapacityOption' +import { parseIndexOption } from 'v1/operations/utils/parseOptions/parseIndexOption' +import { parseConsistentOption } from 'v1/operations/utils/parseOptions/parseConsistentOption' +import { parseLimitOption } from 'v1/operations/utils/parseOptions/parseLimitOption' +import { parseMaxPagesOption } from 'v1/operations/utils/parseOptions/parseMaxPagesOption' +import { parseSelectOption } from 'v1/operations/utils/parseOptions/parseSelectOption' +import { rejectExtraOptions } from 'v1/operations/utils/parseOptions/rejectExtraOptions' +import { parseCondition } from 'v1/operations/expression/condition/parse' +import { parseProjection } from 'v1/operations/expression/projection/parse' + +import type { QueryOptions } from '../options' +import { isBoolean } from 'v1/utils/validation' +import { parseQuery } from './parseQuery' + +export const queryParams = < + TABLE extends TableV2, + ENTITIES extends EntityV2[], + QUERY extends Query
, + OPTIONS extends QueryOptions +>( + table: TABLE, + entities = ([] as unknown) as ENTITIES, + query: QUERY, + scanOptions: OPTIONS = {} as OPTIONS +): QueryCommandInput => { + const { index } = query + const { + capacity, + consistent, + exclusiveStartKey, + limit, + maxPages, + reverse, + select, + filters: _filters, + attributes: _attributes, + ...extraOptions + } = scanOptions + + const filters = (_filters ?? {}) as Record + const attributes = _attributes as AnyAttributePath[] | undefined + + const commandOptions: QueryCommandInput = { + TableName: table.getName() + } + + if (capacity !== undefined) { + commandOptions.ReturnConsumedCapacity = parseCapacityOption(capacity) + } + + if (consistent !== undefined) { + commandOptions.ConsistentRead = parseConsistentOption(consistent, index) + } + + if (exclusiveStartKey !== undefined) { + commandOptions.ExclusiveStartKey = exclusiveStartKey + } + + if (index !== undefined) { + commandOptions.IndexName = parseIndexOption(table, index) + } + + if (limit !== undefined) { + commandOptions.Limit = parseLimitOption(limit) + } + + if (maxPages !== undefined) { + // maxPages is a meta-option, validated but not used here + parseMaxPagesOption(maxPages) + } + + if (reverse !== undefined) { + if (!isBoolean(reverse)) { + throw new DynamoDBToolboxError('queryCommand.invalidReverseOption', { + message: 'Invalid "reverse" options: Must be a boolean', + payload: { reverse } + }) + } + + commandOptions.ScanIndexForward = !reverse + } + + if (select !== undefined) { + commandOptions.Select = parseSelectOption(select, { index, attributes }) + } + + const expressionAttributeNames: Record = {} + const expressionAttributeValues: Record = {} + + const { + KeyConditionExpression, + ExpressionAttributeNames: keyConditionExpressionAttributeNames, + ExpressionAttributeValues: keyConditionExpressionAttributeValues + } = parseQuery(table, query) + + commandOptions.KeyConditionExpression = KeyConditionExpression + Object.assign(expressionAttributeNames, keyConditionExpressionAttributeNames) + Object.assign(expressionAttributeValues, keyConditionExpressionAttributeValues) + + if (entities.length > 0) { + const filterExpressions: string[] = [] + let projectionExpression: string | undefined = undefined + + entities.forEach((entity, index) => { + const entityNameFilter = { attr: entity.entityAttributeName, eq: entity.name } + const entityFilter = filters[entity.name] + + const { + ExpressionAttributeNames: filterExpressionAttributeNames, + ExpressionAttributeValues: filterExpressionAttributeValues, + ConditionExpression: filterExpression + } = parseCondition>( + entity, + entityFilter !== undefined ? { and: [entityNameFilter, entityFilter] } : entityNameFilter, + // Need to add +1 to take KeyConditionExpression into account + (index + 1).toString() + ) + + Object.assign(expressionAttributeNames, filterExpressionAttributeNames) + Object.assign(expressionAttributeValues, filterExpressionAttributeValues) + filterExpressions.push(filterExpression) + + // TODO: For now, we compute the projectionExpression using the first entity. Will probably use Table schemas once they exist. + if (projectionExpression === undefined && attributes !== undefined) { + const { + ExpressionAttributeNames: projectionExpressionAttributeNames, + ProjectionExpression + } = parseProjection(entity, [ + // entityAttributeName is required at all times for formatting + ...(attributes.includes(entity.entityAttributeName) ? [] : [entity.entityAttributeName]), + ...attributes + ]) + + Object.assign(expressionAttributeNames, projectionExpressionAttributeNames) + projectionExpression = ProjectionExpression + } + }) + + if (filterExpressions.length > 0) { + commandOptions.FilterExpression = + filterExpressions.length === 1 + ? filterExpressions[0] + : `(${filterExpressions.filter(Boolean).join(') OR (')})` + } + + if (projectionExpression !== undefined) { + commandOptions.ProjectionExpression = projectionExpression + } + } + + if (!isEmpty(expressionAttributeNames)) { + commandOptions.ExpressionAttributeNames = expressionAttributeNames + } + + if (!isEmpty(expressionAttributeValues)) { + commandOptions.ExpressionAttributeValues = expressionAttributeValues + } + + rejectExtraOptions(extraOptions) + + return commandOptions +} diff --git a/src/v1/operations/query/queryParams/queryParams.unit.test.ts b/src/v1/operations/query/queryParams/queryParams.unit.test.ts new file mode 100644 index 000000000..4ba2bec3e --- /dev/null +++ b/src/v1/operations/query/queryParams/queryParams.unit.test.ts @@ -0,0 +1,831 @@ +import { DynamoDBClient } from '@aws-sdk/client-dynamodb' +import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb' +import type { A } from 'ts-toolbelt' + +import { + TableV2, + DynamoDBToolboxError, + QueryCommand, + EntityV2, + schema, + string, + number, + Item, + FormattedItem, + prefix +} from 'v1' + +const dynamoDbClient = new DynamoDBClient({}) + +const documentClient = DynamoDBDocumentClient.from(dynamoDbClient) + +const TestTable = new TableV2({ + name: 'test-table', + partitionKey: { + type: 'string', + name: 'pk' + }, + sortKey: { + type: 'string', + name: 'sk' + }, + indexes: { + lsi: { + type: 'local', + sortKey: { + name: 'lsi_sk', + type: 'number' + } + }, + gsi: { + type: 'global', + partitionKey: { + name: 'gsi_pk', + type: 'string' + }, + sortKey: { + name: 'gsi_sk', + type: 'string' + } + } + }, + documentClient +}) + +const Entity1 = new EntityV2({ + name: 'entity1', + schema: schema({ + userPoolId: string().key().savedAs('pk'), + userId: string().key().savedAs('sk'), + name: string(), + age: number() + }), + table: TestTable +}) + +const Entity2 = new EntityV2({ + name: 'entity2', + schema: schema({ + productGroupId: string().key().savedAs('pk'), + productId: string().key().savedAs('sk'), + launchDate: string(), + price: number() + }), + table: TestTable +}) + +describe('query', () => { + it('gets the tableName', async () => { + const command = TestTable.build(QueryCommand).query({ partition: 'foo' }) + const { TableName } = command.params() + + expect(TableName).toBe('test-table') + + const assertReturnedItems: A.Equals< + Awaited>['Items'], + Item[] | undefined + > = 1 + assertReturnedItems + }) + + it('creates simple query', () => { + const { + KeyConditionExpression: KeyConditionExpressionA, + ExpressionAttributeNames: ExpressionAttributeNamesA, + ExpressionAttributeValues: ExpressionAttributeValuesA + } = TestTable.build(QueryCommand).query({ partition: 'foo' }).params() + + expect(KeyConditionExpressionA).toBe('#c0_1 = :c0_1') + expect(ExpressionAttributeNamesA).toMatchObject({ '#c0_1': TestTable.partitionKey.name }) + expect(ExpressionAttributeValuesA).toMatchObject({ ':c0_1': 'foo' }) + + const { + KeyConditionExpression: KeyConditionExpressionB, + ExpressionAttributeNames: ExpressionAttributeNamesB, + ExpressionAttributeValues: ExpressionAttributeValuesB + } = TestTable.build(QueryCommand) + .query({ partition: 'foo', range: { gte: 'bar' } }) + .params() + + expect(KeyConditionExpressionB).toBe('(#c0_1 = :c0_1) AND (#c0_2 >= :c0_2)') + expect(ExpressionAttributeNamesB).toMatchObject({ + '#c0_1': TestTable.partitionKey.name, + '#c0_2': TestTable.sortKey?.name + }) + expect(ExpressionAttributeValuesB).toMatchObject({ + ':c0_1': 'foo', + ':c0_2': 'bar' + }) + }) + + it('throws on invalid simple query', () => { + const invalidCallA = () => + TestTable.build(QueryCommand) + .query({ + // @ts-expect-error + partition: 42 + }) + .params() + + expect(invalidCallA).toThrow(DynamoDBToolboxError) + expect(invalidCallA).toThrow(expect.objectContaining({ code: 'parsing.invalidAttributeInput' })) + + const invalidCallB = () => + TestTable.build(QueryCommand) + // @ts-expect-error + .query({ + partition: 'foo', + range: { + gt: 42 + } + }) + .params() + + expect(invalidCallB).toThrow(DynamoDBToolboxError) + expect(invalidCallB).toThrow(expect.objectContaining({ code: 'parsing.invalidAttributeInput' })) + + const invalidCallC = () => + TestTable.build(QueryCommand) + .query({ + partition: 'foo', + range: { + // @ts-expect-error + gt: { foo: 'bar' } + } + }) + .params() + + expect(invalidCallC).toThrow(DynamoDBToolboxError) + expect(invalidCallC).toThrow(expect.objectContaining({ code: 'parsing.invalidAttributeInput' })) + + const invalidCallD = () => + TestTable.build(QueryCommand) + .query({ + partition: 'foo', + range: { + // @ts-expect-error + eq: 'bar' + } + }) + .params() + + expect(invalidCallD).toThrow(DynamoDBToolboxError) + expect(invalidCallD).toThrow(expect.objectContaining({ code: 'operations.invalidCondition' })) + }) + + it('creates query on LSI', () => { + const { + IndexName, + KeyConditionExpression: KeyConditionExpressionA, + ExpressionAttributeNames: ExpressionAttributeNamesA, + ExpressionAttributeValues: ExpressionAttributeValuesA + } = TestTable.build(QueryCommand).query({ index: 'lsi', partition: 'foo' }).params() + + expect(IndexName).toBe('lsi') + expect(KeyConditionExpressionA).toBe('#c0_1 = :c0_1') + expect(ExpressionAttributeNamesA).toMatchObject({ '#c0_1': TestTable.partitionKey.name }) + expect(ExpressionAttributeValuesA).toMatchObject({ ':c0_1': 'foo' }) + + const { + KeyConditionExpression: KeyConditionExpressionB, + ExpressionAttributeNames: ExpressionAttributeNamesB, + ExpressionAttributeValues: ExpressionAttributeValuesB + } = TestTable.build(QueryCommand) + .query({ index: 'lsi', partition: 'foo', range: { gte: 42 } }) + .params() + + expect(KeyConditionExpressionB).toBe('(#c0_1 = :c0_1) AND (#c0_2 >= :c0_2)') + expect(ExpressionAttributeNamesB).toMatchObject({ + '#c0_1': TestTable.partitionKey.name, + '#c0_2': TestTable.indexes.lsi.sortKey.name + }) + expect(ExpressionAttributeValuesB).toMatchObject({ + ':c0_1': 'foo', + ':c0_2': 42 + }) + }) + + it('throws on invalid LSI query', () => { + const invalidCallA = () => + TestTable.build(QueryCommand) + .query({ + index: 'lsi', + // @ts-expect-error + partition: 42 + }) + .params() + + expect(invalidCallA).toThrow(DynamoDBToolboxError) + expect(invalidCallA).toThrow(expect.objectContaining({ code: 'parsing.invalidAttributeInput' })) + + const invalidCallB = () => + TestTable.build(QueryCommand) + // @ts-expect-error + .query({ + index: 'lsi', + partition: 'foo', + range: { gt: 'bar' } + }) + .params() + + expect(invalidCallB).toThrow(DynamoDBToolboxError) + expect(invalidCallB).toThrow(expect.objectContaining({ code: 'parsing.invalidAttributeInput' })) + + const invalidCallC = () => + TestTable.build(QueryCommand) + .query({ + index: 'lsi', + partition: 'foo', + range: { + // @ts-expect-error + gt: { foo: 'bar' } + } + }) + .params() + + expect(invalidCallC).toThrow(DynamoDBToolboxError) + expect(invalidCallC).toThrow(expect.objectContaining({ code: 'parsing.invalidAttributeInput' })) + + const invalidCallD = () => + TestTable.build(QueryCommand) + .query({ + index: 'lsi', + partition: 'foo', + range: { + // @ts-expect-error + eq: 42 + } + }) + .params() + + expect(invalidCallD).toThrow(DynamoDBToolboxError) + expect(invalidCallD).toThrow(expect.objectContaining({ code: 'operations.invalidCondition' })) + + const invalidCallE = () => + TestTable.build(QueryCommand) + .query({ + index: 'lsi', + partition: 'foo', + range: { gt: 42 } + }) + .options({ + // @ts-expect-error + consistent: true + }) + .params() + + expect(invalidCallE).toThrow(DynamoDBToolboxError) + expect(invalidCallE).toThrow( + expect.objectContaining({ code: 'operations.invalidConsistentOption' }) + ) + }) + + it('creates query on GSI', () => { + const { + IndexName, + KeyConditionExpression: KeyConditionExpressionA, + ExpressionAttributeNames: ExpressionAttributeNamesA, + ExpressionAttributeValues: ExpressionAttributeValuesA + } = TestTable.build(QueryCommand).query({ index: 'gsi', partition: 'foo' }).params() + + expect(IndexName).toBe('gsi') + expect(KeyConditionExpressionA).toBe('#c0_1 = :c0_1') + expect(ExpressionAttributeNamesA).toMatchObject({ '#c0_1': 'gsi_pk' }) + expect(ExpressionAttributeValuesA).toMatchObject({ ':c0_1': 'foo' }) + + const { + KeyConditionExpression: KeyConditionExpressionB, + ExpressionAttributeNames: ExpressionAttributeNamesB, + ExpressionAttributeValues: ExpressionAttributeValuesB + } = TestTable.build(QueryCommand) + .query({ index: 'gsi', partition: 'foo', range: { beginsWith: 'bar' } }) + .params() + + expect(KeyConditionExpressionB).toBe('(#c0_1 = :c0_1) AND (begins_with(#c0_2, :c0_2))') + expect(ExpressionAttributeNamesB).toMatchObject({ + '#c0_1': TestTable.indexes.gsi.partitionKey.name, + '#c0_2': TestTable.indexes.gsi.sortKey.name + }) + expect(ExpressionAttributeValuesB).toMatchObject({ + ':c0_1': 'foo', + ':c0_2': 'bar' + }) + }) + + it('throws on invalid GSI query', () => { + const invalidCallA = () => + TestTable.build(QueryCommand) + .query({ + index: 'gsi', + // @ts-expect-error + partition: 42 + }) + .params() + + expect(invalidCallA).toThrow(DynamoDBToolboxError) + expect(invalidCallA).toThrow(expect.objectContaining({ code: 'parsing.invalidAttributeInput' })) + + const invalidCallB = () => + TestTable.build(QueryCommand) + // @ts-expect-error + .query({ + index: 'gsi', + partition: 'foo', + range: { gt: 42 } + }) + .params() + + expect(invalidCallB).toThrow(DynamoDBToolboxError) + expect(invalidCallB).toThrow(expect.objectContaining({ code: 'parsing.invalidAttributeInput' })) + + const invalidCallC = () => + TestTable.build(QueryCommand) + .query({ + index: 'gsi', + partition: 'foo', + range: { + // @ts-expect-error + gt: { foo: 'bar' } + } + }) + .params() + + expect(invalidCallC).toThrow(DynamoDBToolboxError) + expect(invalidCallC).toThrow(expect.objectContaining({ code: 'parsing.invalidAttributeInput' })) + + const invalidCallD = () => + TestTable.build(QueryCommand) + .query({ + index: 'gsi', + partition: 'foo', + range: { + // @ts-expect-error + eq: 'foo' + } + }) + .params() + + expect(invalidCallD).toThrow(DynamoDBToolboxError) + expect(invalidCallD).toThrow(expect.objectContaining({ code: 'operations.invalidCondition' })) + + const invalidCallE = () => + TestTable.build(QueryCommand) + .query({ + index: 'gsi', + partition: 'foo', + range: { gt: 'bar' } + }) + .options({ + // @ts-expect-error + consistent: true + }) + .params() + + expect(invalidCallE).toThrow(DynamoDBToolboxError) + expect(invalidCallE).toThrow( + expect.objectContaining({ code: 'operations.invalidConsistentOption' }) + ) + }) + + // Options + it('sets capacity options', () => { + const { ReturnConsumedCapacity } = TestTable.build(QueryCommand) + .query({ partition: 'foo' }) + .options({ capacity: 'NONE' }) + .params() + + expect(ReturnConsumedCapacity).toBe('NONE') + }) + + it('fails on invalid capacity option', () => { + const invalidCall = () => + TestTable.build(QueryCommand) + .query({ partition: 'foo' }) + .options({ + // @ts-expect-error + capacity: 'test' + }) + .params() + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow( + expect.objectContaining({ code: 'operations.invalidCapacityOption' }) + ) + }) + + it('sets consistent option', () => { + const { ConsistentRead } = TestTable.build(QueryCommand) + .query({ partition: 'foo' }) + .options({ consistent: true }) + .params() + + expect(ConsistentRead).toBe(true) + }) + + it('sets exclusiveStartKey option', () => { + const { ExclusiveStartKey } = TestTable.build(QueryCommand) + .query({ partition: 'foo' }) + .options({ exclusiveStartKey: { foo: 'bar' } }) + .params() + + expect(ExclusiveStartKey).toStrictEqual({ foo: 'bar' }) + }) + + // TO MOVE IN QUERY TEST? + it('sets index in query', () => { + const { IndexName } = TestTable.build(QueryCommand) + .query({ index: 'gsi', partition: 'foo' }) + .params() + + expect(IndexName).toBe('gsi') + }) + + // TO MOVE IN QUERY TEST? + it('fails on invalid index', () => { + const invalidCallA = () => + TestTable.build(QueryCommand) + .query({ + // @ts-expect-error + index: { foo: 'bar' }, + partition: 'baz' + }) + .params() + + expect(invalidCallA).toThrow(DynamoDBToolboxError) + expect(invalidCallA).toThrow(expect.objectContaining({ code: 'operations.invalidIndexOption' })) + + const invalidCallB = () => + TestTable.build(QueryCommand) + .query({ + // @ts-expect-error + index: 'foo', + partition: 'bar' + }) + .params() + + expect(invalidCallB).toThrow(DynamoDBToolboxError) + expect(invalidCallB).toThrow(expect.objectContaining({ code: 'operations.invalidIndexOption' })) + }) + + it('sets select option', () => { + const { Select } = TestTable.build(QueryCommand) + .query({ partition: 'foo' }) + .options({ select: 'COUNT' }) + .params() + + expect(Select).toBe('COUNT') + }) + + it('fails on invalid select option', () => { + const invalidCall = () => + TestTable.build(QueryCommand) + .query({ partition: 'foo' }) + .options({ + // @ts-expect-error + select: 'foobar' + }) + .params() + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow(expect.objectContaining({ code: 'operations.invalidSelectOption' })) + }) + + it('sets "ALL_PROJECTED_ATTRIBUTES" select option if an index is provided', () => { + const { Select } = TestTable.build(QueryCommand) + .query({ index: 'gsi', partition: 'foo' }) + .options({ select: 'ALL_PROJECTED_ATTRIBUTES' }) + .params() + + expect(Select).toBe('ALL_PROJECTED_ATTRIBUTES') + }) + + it('fails if select option is "ALL_PROJECTED_ATTRIBUTES" but no index is provided', () => { + const invalidCall = () => + TestTable.build(QueryCommand) + .query({ partition: 'foo' }) + // @ts-expect-error + .options({ select: 'ALL_PROJECTED_ATTRIBUTES' }) + .params() + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow(expect.objectContaining({ code: 'operations.invalidSelectOption' })) + }) + + it('accepts "SPECIFIC_ATTRIBUTES" select option if a projection expression has been provided', () => { + const { Select } = TestTable.build(QueryCommand) + .query({ partition: 'foo' }) + .entities(Entity1) + .options({ attributes: ['age'], select: 'SPECIFIC_ATTRIBUTES' }) + .params() + + expect(Select).toBe('SPECIFIC_ATTRIBUTES') + }) + + it('fails if a projection expression has been provided but select option is NOT "SPECIFIC_ATTRIBUTES"', () => { + const invalidCall = () => + TestTable.build(QueryCommand) + .query({ partition: 'foo' }) + .entities(Entity1) + // @ts-expect-error + .options({ attributes: { entity1: ['age'] }, select: 'ALL_ATTRIBUTES' }) + .params() + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow(expect.objectContaining({ code: 'operations.invalidSelectOption' })) + }) + + it('sets limit option', () => { + const { Limit } = TestTable.build(QueryCommand) + .query({ partition: 'foo' }) + .options({ limit: 3 }) + .params() + + expect(Limit).toBe(3) + }) + + it('fails on invalid limit option', () => { + const invalidCall = () => + TestTable.build(QueryCommand) + .query({ partition: 'foo' }) + .options({ + // @ts-expect-error + limit: '3' + }) + .params() + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow(expect.objectContaining({ code: 'operations.invalidLimitOption' })) + }) + + it('ignores valid maxPages option', () => { + const validCallA = () => + TestTable.build(QueryCommand).query({ partition: 'foo' }).options({ maxPages: 3 }).params() + expect(validCallA).not.toThrow() + + const validCallB = () => + TestTable.build(QueryCommand) + .query({ partition: 'foo' }) + .options({ maxPages: Infinity }) + .params() + expect(validCallB).not.toThrow() + }) + + it('fails on invalid maxPages option', () => { + const invalidCallA = () => + TestTable.build(QueryCommand) + .query({ partition: 'foo' }) + .options({ + // @ts-expect-error + maxPages: '3' + }) + .params() + + expect(invalidCallA).toThrow(DynamoDBToolboxError) + expect(invalidCallA).toThrow( + expect.objectContaining({ code: 'operations.invalidMaxPagesOption' }) + ) + + // Unable to ts-expect-error here + const invalidCallB = () => + TestTable.build(QueryCommand).query({ partition: 'foo' }).options({ maxPages: 0 }).params() + + expect(invalidCallB).toThrow(DynamoDBToolboxError) + expect(invalidCallB).toThrow( + expect.objectContaining({ code: 'operations.invalidMaxPagesOption' }) + ) + }) + + it('sets reverse option', () => { + const { ScanIndexForward } = TestTable.build(QueryCommand) + .query({ partition: 'foo' }) + .options({ reverse: true }) + .params() + expect(ScanIndexForward).toBe(false) + }) + + it('fails on invalid reverse option', () => { + // segment without totalSegment option + const invalidCall = () => + TestTable.build(QueryCommand) + .query({ partition: 'foo' }) + // @ts-expect-error + .options({ reverse: 'true' }) + .params() + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow( + expect.objectContaining({ code: 'queryCommand.invalidReverseOption' }) + ) + }) + + it('applies entity _et filter', () => { + const command = TestTable.build(QueryCommand).query({ partition: 'foo' }).entities(Entity1) + const { + FilterExpression, + ExpressionAttributeNames, + ExpressionAttributeValues + } = command.params() + + expect(FilterExpression).toBe('#c1_1 = :c1_1') + expect(ExpressionAttributeNames).toMatchObject({ '#c1_1': TestTable.entityAttributeSavedAs }) + expect(ExpressionAttributeValues).toMatchObject({ ':c1_1': Entity1.name }) + + const assertReturnedItems: A.Equals< + Awaited>['Items'], + FormattedItem[] | undefined + > = 1 + assertReturnedItems + }) + + it('applies entity _et AND additional filter', () => { + const { + FilterExpression, + ExpressionAttributeNames, + ExpressionAttributeValues + } = TestTable.build(QueryCommand) + .query({ partition: 'foo' }) + .entities(Entity1) + .options({ + filters: { + entity1: { attr: 'age', gte: 40 } + } + }) + .params() + + expect(FilterExpression).toBe('(#c1_1 = :c1_1) AND (#c1_2 >= :c1_2)') + expect(ExpressionAttributeNames).toMatchObject({ + '#c1_1': TestTable.entityAttributeSavedAs, + '#c1_2': 'age' + }) + expect(ExpressionAttributeValues).toMatchObject({ + ':c1_1': Entity1.name, + ':c1_2': 40 + }) + }) + + it('applies two entity filters', () => { + const command = TestTable.build(QueryCommand) + .query({ partition: 'foo' }) + .entities(Entity1, Entity2) + const { + FilterExpression, + ExpressionAttributeNames, + ExpressionAttributeValues + } = command.params() + + expect(FilterExpression).toBe('(#c1_1 = :c1_1) OR (#c2_1 = :c2_1)') + expect(ExpressionAttributeNames).toMatchObject({ + '#c1_1': TestTable.entityAttributeSavedAs, + '#c2_1': TestTable.entityAttributeSavedAs + }) + expect(ExpressionAttributeValues).toMatchObject({ + ':c1_1': Entity1.name, + ':c2_1': Entity2.name + }) + + const assertReturnedItems: A.Equals< + Awaited>['Items'], + (FormattedItem | FormattedItem)[] | undefined + > = 1 + assertReturnedItems + }) + + it('applies two entity filters AND additional filters', () => { + const { + FilterExpression, + ExpressionAttributeNames, + ExpressionAttributeValues + } = TestTable.build(QueryCommand) + .query({ partition: 'foo' }) + .entities(Entity1, Entity2) + .options({ + filters: { + entity1: { attr: 'age', gte: 40 }, + entity2: { attr: 'price', gte: 100 } + } + }) + .params() + + expect(FilterExpression).toBe( + '((#c1_1 = :c1_1) AND (#c1_2 >= :c1_2)) OR ((#c2_1 = :c2_1) AND (#c2_2 >= :c2_2))' + ) + expect(ExpressionAttributeNames).toMatchObject({ + '#c1_1': TestTable.entityAttributeSavedAs, + '#c1_2': 'age', + '#c2_1': TestTable.entityAttributeSavedAs, + '#c2_2': 'price' + }) + expect(ExpressionAttributeValues).toMatchObject({ + ':c1_1': Entity1.name, + ':c1_2': 40, + ':c2_1': Entity2.name, + ':c2_2': 100 + }) + }) + + it('transforms attributes when applying filters', () => { + const TestEntity3 = new EntityV2({ + name: 'entity3', + schema: schema({ + email: string().key().savedAs('pk'), + sort: string().key().savedAs('sk'), + transformedStr: string().transform(prefix('foo')) + }), + table: TestTable + }) + + const { + FilterExpression, + ExpressionAttributeNames, + ExpressionAttributeValues + } = TestTable.build(QueryCommand) + .query({ partition: 'foo' }) + .entities(TestEntity3) + .options({ + filters: { + entity3: { attr: 'transformedStr', gte: 'bar', transform: false } + } + }) + .params() + + expect(FilterExpression).toContain('#c1_2 >= :c1_2') + expect(ExpressionAttributeNames).toMatchObject({ '#c1_2': 'transformedStr' }) + expect(ExpressionAttributeValues).toMatchObject({ ':c1_2': 'bar' }) + + const { ExpressionAttributeValues: ExpressionAttributeValues2 } = TestTable.build(QueryCommand) + .query({ partition: 'foo' }) + .entities(TestEntity3) + .options({ + filters: { + entity3: { attr: 'transformedStr', gte: 'bar' } + } + }) + .params() + + expect(ExpressionAttributeValues2).toMatchObject({ ':c1_2': 'foo#bar' }) + }) + + it('applies entity projection expression', () => { + const command = TestTable.build(QueryCommand) + .query({ partition: 'foo' }) + .entities(Entity1) + .options({ attributes: ['age', 'name'] }) + + const { ProjectionExpression, ExpressionAttributeNames } = command.params() + + const assertReturnedItems: A.Equals< + Awaited>['Items'], + FormattedItem[] | undefined + > = 1 + assertReturnedItems + + expect(ProjectionExpression).toBe('#p_1, #p_2, #p_3') + expect(ExpressionAttributeNames).toMatchObject({ + '#p_1': '_et', + '#p_2': 'age', + '#p_3': 'name' + }) + }) + + it('applies two entity projection expressions', () => { + const command = TestTable.build(QueryCommand) + .query({ partition: 'foo' }) + .entities(Entity1, Entity2) + .options({ + attributes: ['created', 'modified'] + }) + + const { ProjectionExpression, ExpressionAttributeNames } = command.params() + + const assertReturnedItems: A.Equals< + Awaited>['Items'], + | ( + | FormattedItem + | FormattedItem + )[] + | undefined + > = 1 + assertReturnedItems + + expect(ProjectionExpression).toBe('#p_1, #p_2, #p_3') + expect(ExpressionAttributeNames).toMatchObject({ + '#p_1': '_et', + '#p_2': '_ct', + '#p_3': '_md' + }) + }) + + it('fails on extra options', () => { + const invalidCall = () => + TestTable.build(QueryCommand) + .query({ partition: 'foo' }) + .options({ + // @ts-expect-error + extra: true + }) + .params() + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow(expect.objectContaining({ code: 'operations.unknownOption' })) + }) +}) diff --git a/src/v1/operations/scan/command.ts b/src/v1/operations/scan/command.ts new file mode 100644 index 000000000..28b2ed54b --- /dev/null +++ b/src/v1/operations/scan/command.ts @@ -0,0 +1,180 @@ +import type { O } from 'ts-toolbelt' +import { + ScanCommandInput, + ScanCommand as _ScanCommand, + ScanCommandOutput +} from '@aws-sdk/lib-dynamodb' +import type { ConsumedCapacity } from '@aws-sdk/client-dynamodb' +import type { NativeAttributeValue } from '@aws-sdk/util-dynamodb' + +import type { TableV2 } from 'v1/table' +import type { EntityV2, FormattedItem } from 'v1/entity' +import type { Item } from 'v1/schema' +import type { CountSelectOption } from 'v1/operations/constants/options/select' +import type { AnyAttributePath } from 'v1/operations/types' +import { formatSavedItem } from 'v1/operations/utils/formatSavedItem' +import { isString } from 'v1/utils/validation' + +import { TableOperation, $table, $entities } from '../class' +import type { ScanOptions } from './options' +import { scanParams } from './scanParams' + +const $options = Symbol('$options') +type $options = typeof $options + +type ReturnedItems< + TABLE extends TableV2, + ENTITIES extends EntityV2[], + OPTIONS extends ScanOptions +> = OPTIONS['select'] extends CountSelectOption + ? undefined + : (EntityV2[] extends ENTITIES + ? Item + : ENTITIES[number] extends infer ENTITY + ? ENTITY extends EntityV2 + ? FormattedItem< + ENTITY, + { + attributes: OPTIONS['attributes'] extends AnyAttributePath[] + ? OPTIONS['attributes'][number] + : undefined + } + > + : never + : never)[] + +export type ScanResponse< + TABLE extends TableV2, + ENTITIES extends EntityV2[], + OPTIONS extends ScanOptions +> = O.Merge< + Omit, + { + Items?: ReturnedItems + // $metadata is not returned on multiple page queries + $metadata?: ScanCommandOutput['$metadata'] + } +> + +export class ScanCommand< + TABLE extends TableV2 = TableV2, + ENTITIES extends EntityV2[] = EntityV2[], + OPTIONS extends ScanOptions = ScanOptions +> extends TableOperation { + static operationName = 'scan' as const + + entities: ( + ...nextEntities: NEXT_ENTITIES + ) => ScanCommand>; + + [$options]: OPTIONS + options: >( + nextOptions: NEXT_OPTIONS + ) => ScanCommand + + constructor( + table: TABLE, + entities = ([] as unknown) as ENTITIES, + options: OPTIONS = {} as OPTIONS + ) { + super(table, entities) + this[$options] = options + + this.entities = (...nextEntities: NEXT_ENTITIES) => + new ScanCommand>( + this[$table], + nextEntities, + // For some reason we can't do the same as Query (cast OPTIONS) as it triggers an infinite type compute + this[$options] as ScanOptions + ) + this.options = nextOptions => new ScanCommand(this[$table], this[$entities], nextOptions) + } + + params = (): ScanCommandInput => scanParams(this[$table], this[$entities], this[$options]) + + send = async (): Promise> => { + const scanParams = this.params() + + const entities = this[$entities] ?? [] + const entitiesByName: Record = {} + entities.forEach(entity => { + entitiesByName[entity.name] = entity + }) + + const formattedItems: Item[] = [] + let lastEvaluatedKey: Record | undefined = undefined + let count: number | undefined = 0 + let scannedCount: number | undefined = 0 + let consumedCapacity: ConsumedCapacity | undefined = undefined + let responseMetadata: ScanCommandOutput['$metadata'] | undefined = undefined + + // NOTE: maxPages has been validated by this.params() + const { attributes, maxPages = 1 } = this[$options] + let pageIndex = 0 + do { + pageIndex += 1 + + const pageScanParams: ScanCommandInput = { + ...scanParams, + // NOTE: Important NOT to override initial exclusiveStartKey on first page + ...(lastEvaluatedKey !== undefined ? { ExclusiveStartKey: lastEvaluatedKey } : {}) + } + + const { + Items: items = [], + LastEvaluatedKey: pageLastEvaluatedKey, + Count: pageCount, + ScannedCount: pageScannedCount, + ConsumedCapacity: pageConsumedCapacity, + $metadata: pageMetadata + } = await this[$table].documentClient.send(new _ScanCommand(pageScanParams)) + + for (const item of items) { + const itemEntityName = item[this[$table].entityAttributeSavedAs] + + if (!isString(itemEntityName)) { + continue + } + + const itemEntity = entitiesByName[itemEntityName] + + if (itemEntity === undefined) { + continue + } + + formattedItems.push( + formatSavedItem(itemEntity, item, { attributes }) + ) + } + + lastEvaluatedKey = pageLastEvaluatedKey + + if (count !== undefined) { + count = pageCount !== undefined ? count + pageCount : undefined + } + + if (scannedCount !== undefined) { + scannedCount = pageScannedCount !== undefined ? scannedCount + pageScannedCount : undefined + } + + consumedCapacity = pageConsumedCapacity + responseMetadata = pageMetadata + } while (pageIndex < maxPages && lastEvaluatedKey !== undefined) + + return { + Items: formattedItems as ScanResponse['Items'], + ...(lastEvaluatedKey !== undefined ? { LastEvaluatedKey: lastEvaluatedKey } : {}), + ...(count !== undefined ? { Count: count } : {}), + ...(scannedCount !== undefined ? { ScannedCount: scannedCount } : {}), + // return ConsumedCapacity & $metadata only if one page has been fetched + ...(pageIndex === 1 + ? { + ...(consumedCapacity !== undefined ? { ConsumedCapacity: consumedCapacity } : {}), + ...(responseMetadata !== undefined ? { $metadata: responseMetadata } : {}) + } + : {}) + } + } +} + +export type ScanCommandClass = typeof ScanCommand diff --git a/src/v1/operations/scan/errors.ts b/src/v1/operations/scan/errors.ts new file mode 100644 index 000000000..4f07f9a06 --- /dev/null +++ b/src/v1/operations/scan/errors.ts @@ -0,0 +1 @@ +export type { ScanCommandErrorBlueprints } from './scanParams/errors' diff --git a/src/v1/operations/scan/index.ts b/src/v1/operations/scan/index.ts new file mode 100644 index 000000000..1039f25b0 --- /dev/null +++ b/src/v1/operations/scan/index.ts @@ -0,0 +1,3 @@ +export { ScanCommand } from './command' +export type { ScanResponse } from './command' +export type { ScanOptions } from './options' diff --git a/src/v1/operations/scan/options.ts b/src/v1/operations/scan/options.ts new file mode 100644 index 000000000..494173914 --- /dev/null +++ b/src/v1/operations/scan/options.ts @@ -0,0 +1,48 @@ +import type { CapacityOption } from 'v1/operations/constants/options/capacity' +import type { + SelectOption, + AllProjectedAttributesSelectOption, + SpecificAttributesSelectOption +} from 'v1/operations/constants/options/select' +import type { Condition, AnyCommonAttributePath } from 'v1/operations/types' +import type { TableV2, IndexNames } from 'v1/table' +import type { EntityV2 } from 'v1/entity' + +export type ScanOptions< + TABLE extends TableV2 = TableV2, + ENTITIES extends EntityV2[] = EntityV2[] +> = { + capacity?: CapacityOption + exclusiveStartKey?: Record + limit?: number + maxPages?: number + filters?: EntityV2[] extends ENTITIES + ? Record + : { [ENTITY in ENTITIES[number] as ENTITY['name']]?: Condition } +} & ( + | { segment?: never; totalSegments?: never } + // Either both segment & totalSegments are set, either none + | { segment: number; totalSegments: number } +) & + ( + | { + consistent?: boolean + // "ALL_PROJECTED_ATTRIBUTES" is only available if index is present + select?: Exclude + index?: undefined + } + | { + // consistent must be false if an index is present + consistent?: false + select?: SelectOption + index: IndexNames
+ } + ) & + ( + | { attributes?: undefined; select?: SelectOption } + | { + attributes: AnyCommonAttributePath[] + // "SPECIFIC_ATTRIBUTES" is the only valid option if projectionExpression is present + select?: SpecificAttributesSelectOption + } + ) diff --git a/src/v1/operations/scan/scanParams/errors.ts b/src/v1/operations/scan/scanParams/errors.ts new file mode 100644 index 000000000..b8983255e --- /dev/null +++ b/src/v1/operations/scan/scanParams/errors.ts @@ -0,0 +1,9 @@ +import type { ErrorBlueprint } from 'v1/errors/blueprint' + +type InvalidSegmentOptionErrorBlueprint = ErrorBlueprint<{ + code: 'scanCommand.invalidSegmentOption' + hasPath: false + payload: { segment?: unknown; totalSegments?: unknown } +}> + +export type ScanCommandErrorBlueprints = InvalidSegmentOptionErrorBlueprint diff --git a/src/v1/operations/scan/scanParams/index.ts b/src/v1/operations/scan/scanParams/index.ts new file mode 100644 index 000000000..98fa98f51 --- /dev/null +++ b/src/v1/operations/scan/scanParams/index.ts @@ -0,0 +1 @@ +export { scanParams } from './scanParams' diff --git a/src/v1/operations/scan/scanParams/scanParams.ts b/src/v1/operations/scan/scanParams/scanParams.ts new file mode 100644 index 000000000..cba26c1b3 --- /dev/null +++ b/src/v1/operations/scan/scanParams/scanParams.ts @@ -0,0 +1,174 @@ +import type { ScanCommandInput } from '@aws-sdk/lib-dynamodb' +import isEmpty from 'lodash.isempty' + +import type { TableV2 } from 'v1/table' +import type { AnyAttributePath, Condition } from 'v1/operations/types' +import type { EntityV2 } from 'v1/entity' +import { DynamoDBToolboxError } from 'v1/errors' +import { parseCapacityOption } from 'v1/operations/utils/parseOptions/parseCapacityOption' +import { parseIndexOption } from 'v1/operations/utils/parseOptions/parseIndexOption' +import { parseConsistentOption } from 'v1/operations/utils/parseOptions/parseConsistentOption' +import { parseLimitOption } from 'v1/operations/utils/parseOptions/parseLimitOption' +import { parseMaxPagesOption } from 'v1/operations/utils/parseOptions/parseMaxPagesOption' +import { parseSelectOption } from 'v1/operations/utils/parseOptions/parseSelectOption' +import { rejectExtraOptions } from 'v1/operations/utils/parseOptions/rejectExtraOptions' +import { isInteger } from 'v1/utils/validation/isInteger' +import { parseCondition } from 'v1/operations/expression/condition/parse' +import { parseProjection } from 'v1/operations/expression/projection/parse' + +import type { ScanOptions } from '../options' + +export const scanParams = < + TABLE extends TableV2, + ENTITIES extends EntityV2[], + OPTIONS extends ScanOptions +>( + table: TABLE, + entities = ([] as unknown) as ENTITIES, + scanOptions: OPTIONS = {} as OPTIONS +): ScanCommandInput => { + const { + capacity, + consistent, + exclusiveStartKey, + index, + limit, + maxPages, + select, + totalSegments, + segment, + filters: _filters, + attributes: _attributes, + ...extraOptions + } = scanOptions + + const filters = (_filters ?? {}) as Record + const attributes = _attributes as AnyAttributePath[] | undefined + + const commandOptions: ScanCommandInput = { + TableName: table.getName() + } + + if (capacity !== undefined) { + commandOptions.ReturnConsumedCapacity = parseCapacityOption(capacity) + } + + if (consistent !== undefined) { + commandOptions.ConsistentRead = parseConsistentOption(consistent, index) + } + + if (exclusiveStartKey !== undefined) { + commandOptions.ExclusiveStartKey = exclusiveStartKey + } + + if (index !== undefined) { + commandOptions.IndexName = parseIndexOption(table, index) + } + + if (limit !== undefined) { + commandOptions.Limit = parseLimitOption(limit) + } + + if (maxPages !== undefined) { + // maxPages is a meta-option, validated but not used here + parseMaxPagesOption(maxPages) + } + + if (select !== undefined) { + commandOptions.Select = parseSelectOption(select, { index, attributes }) + } + + if (segment !== undefined) { + if (totalSegments === undefined) { + throw new DynamoDBToolboxError('scanCommand.invalidSegmentOption', { + message: 'If a segment option has been provided, totalSegments must also be defined', + payload: {} + }) + } + + if (!isInteger(totalSegments) || totalSegments < 1) { + throw new DynamoDBToolboxError('scanCommand.invalidSegmentOption', { + message: `Invalid totalSegments option: '${String( + totalSegments + )}'. 'totalSegments' must be a strictly positive integer.`, + payload: { totalSegments } + }) + } + + if (!isInteger(segment) || segment < 0 || segment >= totalSegments) { + throw new DynamoDBToolboxError('scanCommand.invalidSegmentOption', { + message: `Invalid segment option: '${String( + segment + )}'. 'segment' must be a positive integer strictly lower than 'totalSegments'.`, + payload: { segment, totalSegments } + }) + } + + commandOptions.TotalSegments = totalSegments + commandOptions.Segment = segment + } + + if (entities.length > 0) { + const expressionAttributeNames: Record = {} + const expressionAttributeValues: Record = {} + const filterExpressions: string[] = [] + let projectionExpression: string | undefined = undefined + + entities.forEach((entity, index) => { + const entityNameFilter = { attr: entity.entityAttributeName, eq: entity.name } + const entityFilter = filters[entity.name] + + const { + ExpressionAttributeNames: filterExpressionAttributeNames, + ExpressionAttributeValues: filterExpressionAttributeValues, + ConditionExpression: filterExpression + } = parseCondition>( + entity, + entityFilter !== undefined ? { and: [entityNameFilter, entityFilter] } : entityNameFilter, + index.toString() + ) + + Object.assign(expressionAttributeNames, filterExpressionAttributeNames) + Object.assign(expressionAttributeValues, filterExpressionAttributeValues) + filterExpressions.push(filterExpression) + + // TODO: For now, we compute the projectionExpression using the first entity. Will probably use Table schemas once they exist. + if (projectionExpression === undefined && attributes !== undefined) { + const { + ExpressionAttributeNames: projectionExpressionAttributeNames, + ProjectionExpression + } = parseProjection(entity, [ + // entityAttributeName is required at all times for formatting + ...(attributes.includes(entity.entityAttributeName) ? [] : [entity.entityAttributeName]), + ...attributes + ]) + + Object.assign(expressionAttributeNames, projectionExpressionAttributeNames) + projectionExpression = ProjectionExpression + } + }) + + if (!isEmpty(expressionAttributeNames)) { + commandOptions.ExpressionAttributeNames = expressionAttributeNames + } + + if (!isEmpty(expressionAttributeValues)) { + commandOptions.ExpressionAttributeValues = expressionAttributeValues + } + + if (filterExpressions.length > 0) { + commandOptions.FilterExpression = + filterExpressions.length === 1 + ? filterExpressions[0] + : `(${filterExpressions.filter(Boolean).join(') OR (')})` + } + + if (projectionExpression !== undefined) { + commandOptions.ProjectionExpression = projectionExpression + } + } + + rejectExtraOptions(extraOptions) + + return commandOptions +} diff --git a/src/v1/operations/scan/scanParams/scanParams.unit.test.ts b/src/v1/operations/scan/scanParams/scanParams.unit.test.ts new file mode 100644 index 000000000..e4fcc7a52 --- /dev/null +++ b/src/v1/operations/scan/scanParams/scanParams.unit.test.ts @@ -0,0 +1,604 @@ +import { DynamoDBClient } from '@aws-sdk/client-dynamodb' +import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb' +import type { A } from 'ts-toolbelt' + +import { + TableV2, + DynamoDBToolboxError, + ScanCommand, + EntityV2, + schema, + string, + number, + Item, + FormattedItem, + prefix +} from 'v1' + +const dynamoDbClient = new DynamoDBClient({}) + +const documentClient = DynamoDBDocumentClient.from(dynamoDbClient) + +const TestTable = new TableV2({ + name: 'test-table', + partitionKey: { + type: 'string', + name: 'pk' + }, + sortKey: { + type: 'string', + name: 'sk' + }, + indexes: { + gsi: { + type: 'global', + partitionKey: { + name: 'gsi_pk', + type: 'string' + }, + sortKey: { + name: 'gsi_sk', + type: 'string' + } + } + }, + documentClient +}) + +const Entity1 = new EntityV2({ + name: 'entity1', + schema: schema({ + userPoolId: string().key().savedAs('pk'), + userId: string().key().savedAs('sk'), + name: string(), + age: number() + }), + table: TestTable +}) + +const Entity2 = new EntityV2({ + name: 'entity2', + schema: schema({ + productGroupId: string().key().savedAs('pk'), + productId: string().key().savedAs('sk'), + launchDate: string(), + price: number() + }), + table: TestTable +}) + +describe('scan', () => { + it('gets the tableName', async () => { + const command = TestTable.build(ScanCommand) + const { TableName } = command.params() + + expect(TableName).toBe('test-table') + + const assertReturnedItems: A.Equals< + Awaited>['Items'], + Item[] | undefined + > = 1 + assertReturnedItems + }) + + // Options + it('sets capacity options', () => { + const { ReturnConsumedCapacity } = TestTable.build(ScanCommand) + .options({ capacity: 'NONE' }) + .params() + + expect(ReturnConsumedCapacity).toBe('NONE') + }) + + it('fails on invalid capacity option', () => { + const invalidCall = () => + TestTable.build(ScanCommand) + .options({ + // @ts-expect-error + capacity: 'test' + }) + .params() + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow( + expect.objectContaining({ code: 'operations.invalidCapacityOption' }) + ) + }) + + it('sets consistent option', () => { + const { ConsistentRead } = TestTable.build(ScanCommand).options({ consistent: true }).params() + + expect(ConsistentRead).toBe(true) + }) + + it('fails on invalid consistent option', () => { + const invalidCallA = () => + TestTable.build(ScanCommand) + .options({ + // @ts-expect-error + consistent: 'true' + }) + .params() + + expect(invalidCallA).toThrow(DynamoDBToolboxError) + expect(invalidCallA).toThrow( + expect.objectContaining({ code: 'operations.invalidConsistentOption' }) + ) + + const invalidCallB = () => + TestTable.build(ScanCommand) + // @ts-expect-error + .options({ + index: 'gsi', + consistent: true + }) + .params() + + expect(invalidCallB).toThrow(DynamoDBToolboxError) + expect(invalidCallB).toThrow( + expect.objectContaining({ code: 'operations.invalidConsistentOption' }) + ) + }) + + it('sets exclusiveStartKey option', () => { + const { ExclusiveStartKey } = TestTable.build(ScanCommand) + .options({ exclusiveStartKey: { foo: 'bar' } }) + .params() + + expect(ExclusiveStartKey).toStrictEqual({ foo: 'bar' }) + }) + + it('sets index option', () => { + const { IndexName } = TestTable.build(ScanCommand).options({ index: 'gsi' }).params() + + expect(IndexName).toBe('gsi') + }) + + it('fails on invalid index option', () => { + const invalidCallA = () => + TestTable.build(ScanCommand) + .options({ + // @ts-expect-error + index: { foo: 'bar' } + }) + .params() + + expect(invalidCallA).toThrow(DynamoDBToolboxError) + expect(invalidCallA).toThrow(expect.objectContaining({ code: 'operations.invalidIndexOption' })) + + const invalidCallB = () => + TestTable.build(ScanCommand) + .options({ + // @ts-expect-error + index: 'unexisting-index' + }) + .params() + + expect(invalidCallB).toThrow(DynamoDBToolboxError) + expect(invalidCallB).toThrow(expect.objectContaining({ code: 'operations.invalidIndexOption' })) + }) + + it('sets select option', () => { + const { Select } = TestTable.build(ScanCommand).options({ select: 'COUNT' }).params() + + expect(Select).toBe('COUNT') + }) + + it('fails on invalid select option', () => { + const invalidCall = () => + TestTable.build(ScanCommand) + .options({ + // @ts-expect-error + select: 'foobar' + }) + .params() + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow(expect.objectContaining({ code: 'operations.invalidSelectOption' })) + }) + + it('sets "ALL_PROJECTED_ATTRIBUTES" select option if an index is provided', () => { + const { Select } = TestTable.build(ScanCommand) + .options({ select: 'ALL_PROJECTED_ATTRIBUTES', index: 'gsi' }) + .params() + + expect(Select).toBe('ALL_PROJECTED_ATTRIBUTES') + }) + + it('fails if select option is "ALL_PROJECTED_ATTRIBUTES" but no index is provided', () => { + const invalidCall = () => + TestTable.build(ScanCommand) + // @ts-expect-error + .options({ select: 'ALL_PROJECTED_ATTRIBUTES' }) + .params() + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow(expect.objectContaining({ code: 'operations.invalidSelectOption' })) + }) + + it('accepts "SPECIFIC_ATTRIBUTES" select option if a projection expression has been provided', () => { + const { Select } = TestTable.build(ScanCommand) + .entities(Entity1) + .options({ attributes: ['age'], select: 'SPECIFIC_ATTRIBUTES' }) + .params() + + expect(Select).toBe('SPECIFIC_ATTRIBUTES') + }) + + it('fails if a projection expression has been provided but select option is NOT "SPECIFIC_ATTRIBUTES"', () => { + const invalidCall = () => + TestTable.build(ScanCommand) + .entities(Entity1) + // @ts-expect-error + .options({ attributes: { entity1: ['age'] }, select: 'ALL_ATTRIBUTES' }) + .params() + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow(expect.objectContaining({ code: 'operations.invalidSelectOption' })) + }) + + it('sets limit option', () => { + const { Limit } = TestTable.build(ScanCommand).options({ limit: 3 }).params() + + expect(Limit).toBe(3) + }) + + it('fails on invalid limit option', () => { + const invalidCall = () => + TestTable.build(ScanCommand) + .options({ + // @ts-expect-error + limit: '3' + }) + .params() + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow(expect.objectContaining({ code: 'operations.invalidLimitOption' })) + }) + + it('ignores valid maxPages option', () => { + const validCallA = () => TestTable.build(ScanCommand).options({ maxPages: 3 }).params() + expect(validCallA).not.toThrow() + + const validCallB = () => TestTable.build(ScanCommand).options({ maxPages: Infinity }).params() + expect(validCallB).not.toThrow() + }) + + it('fails on invalid maxPages option', () => { + const invalidCallA = () => + TestTable.build(ScanCommand) + .options({ + // @ts-expect-error + maxPages: '3' + }) + .params() + + expect(invalidCallA).toThrow(DynamoDBToolboxError) + expect(invalidCallA).toThrow( + expect.objectContaining({ code: 'operations.invalidMaxPagesOption' }) + ) + + // Unable to ts-expect-error here + const invalidCallB = () => TestTable.build(ScanCommand).options({ maxPages: 0 }).params() + + expect(invalidCallB).toThrow(DynamoDBToolboxError) + expect(invalidCallB).toThrow( + expect.objectContaining({ code: 'operations.invalidMaxPagesOption' }) + ) + }) + + it('sets segment and totalSegments options', () => { + const { Segment, TotalSegments } = TestTable.build(ScanCommand) + .options({ segment: 3, totalSegments: 4 }) + .params() + + expect(Segment).toBe(3) + expect(TotalSegments).toBe(4) + }) + + it('fails on invalid segment and/or totalSegments options', () => { + // segment without totalSegment option + const invalidCallA = () => + TestTable.build(ScanCommand) + // @ts-expect-error + .options({ segment: 3 }) + .params() + + expect(invalidCallA).toThrow(DynamoDBToolboxError) + expect(invalidCallA).toThrow( + expect.objectContaining({ code: 'scanCommand.invalidSegmentOption' }) + ) + + // invalid totalSegments (non number) + const invalidCallB = () => + TestTable.build(ScanCommand) + .options({ + segment: 3, + // @ts-expect-error + totalSegments: 'foo' + }) + .params() + + expect(invalidCallB).toThrow(DynamoDBToolboxError) + expect(invalidCallB).toThrow( + expect.objectContaining({ code: 'scanCommand.invalidSegmentOption' }) + ) + + // invalid totalSegments (non-integer) + const invalidCallC = () => + TestTable.build(ScanCommand) + // Impossible to raise type error here + .options({ segment: 3, totalSegments: 3.5 }) + .params() + + expect(invalidCallC).toThrow(DynamoDBToolboxError) + expect(invalidCallC).toThrow( + expect.objectContaining({ code: 'scanCommand.invalidSegmentOption' }) + ) + + // invalid totalSegments (negative integer) + const invalidCallD = () => + TestTable.build(ScanCommand) + // Impossible to raise type error here + .options({ segment: 3, totalSegments: -1 }) + .params() + + expect(invalidCallD).toThrow(DynamoDBToolboxError) + expect(invalidCallD).toThrow( + expect.objectContaining({ code: 'scanCommand.invalidSegmentOption' }) + ) + + // invalid segment (non-number) + const invalidCallE = () => + TestTable.build(ScanCommand) + .options({ + // @ts-expect-error + segment: 'foo', + totalSegments: 4 + }) + .params() + + expect(invalidCallE).toThrow(DynamoDBToolboxError) + expect(invalidCallE).toThrow( + expect.objectContaining({ code: 'scanCommand.invalidSegmentOption' }) + ) + + // invalid segment (non-integer) + const invalidCallF = () => + TestTable.build(ScanCommand) + // Impossible to raise type error here + .options({ segment: 2.5, totalSegments: 4 }) + .params() + + expect(invalidCallF).toThrow(DynamoDBToolboxError) + expect(invalidCallF).toThrow( + expect.objectContaining({ code: 'scanCommand.invalidSegmentOption' }) + ) + + // invalid segment (negative integer) + const invalidCallG = () => + TestTable.build(ScanCommand) + // Impossible to raise type error here + .options({ segment: -1, totalSegments: 4 }) + .params() + + expect(invalidCallG).toThrow(DynamoDBToolboxError) + expect(invalidCallG).toThrow( + expect.objectContaining({ code: 'scanCommand.invalidSegmentOption' }) + ) + + // invalid segment (above totalSegments) + const invalidCallH = () => + TestTable.build(ScanCommand) + // Impossible to raise type error here + .options({ segment: 3, totalSegments: 3 }) + .params() + + expect(invalidCallH).toThrow(DynamoDBToolboxError) + expect(invalidCallH).toThrow( + expect.objectContaining({ code: 'scanCommand.invalidSegmentOption' }) + ) + }) + + it('fails on extra options', () => { + const invalidCall = () => + TestTable.build(ScanCommand) + .options({ + // @ts-expect-error + extra: true + }) + .params() + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow(expect.objectContaining({ code: 'operations.unknownOption' })) + }) + + it('applies entity _et filter', () => { + const command = TestTable.build(ScanCommand).entities(Entity1) + const { + FilterExpression, + ExpressionAttributeNames, + ExpressionAttributeValues + } = command.params() + + expect(FilterExpression).toBe('#c0_1 = :c0_1') + expect(ExpressionAttributeNames).toMatchObject({ '#c0_1': TestTable.entityAttributeSavedAs }) + expect(ExpressionAttributeValues).toMatchObject({ ':c0_1': Entity1.name }) + + const assertReturnedItems: A.Equals< + Awaited>['Items'], + FormattedItem[] | undefined + > = 1 + assertReturnedItems + }) + + it('applies entity _et AND additional filter', () => { + const { + FilterExpression, + ExpressionAttributeNames, + ExpressionAttributeValues + } = TestTable.build(ScanCommand) + .entities(Entity1) + .options({ + filters: { + entity1: { attr: 'age', gte: 40 } + } + }) + .params() + + expect(FilterExpression).toBe('(#c0_1 = :c0_1) AND (#c0_2 >= :c0_2)') + expect(ExpressionAttributeNames).toMatchObject({ + '#c0_1': TestTable.entityAttributeSavedAs, + '#c0_2': 'age' + }) + expect(ExpressionAttributeValues).toMatchObject({ + ':c0_1': Entity1.name, + ':c0_2': 40 + }) + }) + + it('applies two entity filters', () => { + const command = TestTable.build(ScanCommand).entities(Entity1, Entity2) + const { + FilterExpression, + ExpressionAttributeNames, + ExpressionAttributeValues + } = command.params() + + expect(FilterExpression).toBe('(#c0_1 = :c0_1) OR (#c1_1 = :c1_1)') + expect(ExpressionAttributeNames).toMatchObject({ + '#c0_1': TestTable.entityAttributeSavedAs, + '#c1_1': TestTable.entityAttributeSavedAs + }) + expect(ExpressionAttributeValues).toMatchObject({ + ':c0_1': Entity1.name, + ':c1_1': Entity2.name + }) + + const assertReturnedItems: A.Equals< + Awaited>['Items'], + (FormattedItem | FormattedItem)[] | undefined + > = 1 + assertReturnedItems + }) + + it('applies two entity filters AND additional filters', () => { + const { + FilterExpression, + ExpressionAttributeNames, + ExpressionAttributeValues + } = TestTable.build(ScanCommand) + .entities(Entity1, Entity2) + .options({ + filters: { + entity1: { attr: 'age', gte: 40 }, + entity2: { attr: 'price', gte: 100 } + } + }) + .params() + + expect(FilterExpression).toBe( + '((#c0_1 = :c0_1) AND (#c0_2 >= :c0_2)) OR ((#c1_1 = :c1_1) AND (#c1_2 >= :c1_2))' + ) + expect(ExpressionAttributeNames).toMatchObject({ + '#c0_1': TestTable.entityAttributeSavedAs, + '#c0_2': 'age', + '#c1_1': TestTable.entityAttributeSavedAs, + '#c1_2': 'price' + }) + expect(ExpressionAttributeValues).toMatchObject({ + ':c0_1': Entity1.name, + ':c0_2': 40, + ':c1_1': Entity2.name, + ':c1_2': 100 + }) + }) + + it('transforms attributes when applying filters', () => { + const TestEntity3 = new EntityV2({ + name: 'entity3', + schema: schema({ + email: string().key().savedAs('pk'), + sort: string().key().savedAs('sk'), + transformedStr: string().transform(prefix('foo')) + }), + table: TestTable + }) + + const { + FilterExpression, + ExpressionAttributeNames, + ExpressionAttributeValues + } = TestTable.build(ScanCommand) + .entities(TestEntity3) + .options({ + filters: { + entity3: { attr: 'transformedStr', gte: 'bar', transform: false } + } + }) + .params() + + expect(FilterExpression).toContain('#c0_2 >= :c0_2') + expect(ExpressionAttributeNames).toMatchObject({ '#c0_2': 'transformedStr' }) + expect(ExpressionAttributeValues).toMatchObject({ ':c0_2': 'bar' }) + + const { ExpressionAttributeValues: ExpressionAttributeValues2 } = TestTable.build(ScanCommand) + .entities(TestEntity3) + .options({ + filters: { + entity3: { attr: 'transformedStr', gte: 'bar' } + } + }) + .params() + + expect(ExpressionAttributeValues2).toMatchObject({ ':c0_2': 'foo#bar' }) + }) + + it('applies entity projection expression', () => { + const command = TestTable.build(ScanCommand) + .entities(Entity1) + .options({ attributes: ['age', 'name'] }) + + const { ProjectionExpression, ExpressionAttributeNames } = command.params() + + const assertReturnedItems: A.Equals< + Awaited>['Items'], + FormattedItem[] | undefined + > = 1 + assertReturnedItems + + expect(ProjectionExpression).toBe('#p_1, #p_2, #p_3') + expect(ExpressionAttributeNames).toMatchObject({ + '#p_1': '_et', + '#p_2': 'age', + '#p_3': 'name' + }) + }) + + it('applies two entity projection expressions', () => { + const command = TestTable.build(ScanCommand) + .entities(Entity1, Entity2) + .options({ + attributes: ['created', 'modified'] + }) + + const { ProjectionExpression, ExpressionAttributeNames } = command.params() + + const assertReturnedItems: A.Equals< + Awaited>['Items'], + | ( + | FormattedItem + | FormattedItem + )[] + | undefined + > = 1 + assertReturnedItems + + expect(ProjectionExpression).toBe('#p_1, #p_2, #p_3') + expect(ExpressionAttributeNames).toMatchObject({ + '#p_1': '_et', + '#p_2': '_ct', + '#p_3': '_md' + }) + }) +}) diff --git a/src/v1/operations/types/KeyInput.ts b/src/v1/operations/types/KeyInput.ts new file mode 100644 index 000000000..8c860d9d2 --- /dev/null +++ b/src/v1/operations/types/KeyInput.ts @@ -0,0 +1,106 @@ +import type { O } from 'ts-toolbelt' + +import type { + Schema, + Attribute, + AttributeValue, + ResolveAnyAttribute, + ResolvePrimitiveAttribute, + MapAttributeValue, + AnyAttribute, + PrimitiveAttribute, + SetAttribute, + ListAttribute, + MapAttribute, + RecordAttribute, + AnyOfAttribute, + Always, + Never +} from 'v1/schema' +import type { OptionalizeUndefinableProperties } from 'v1/types/optionalizeUndefinableProperties' +import type { EntityV2 } from 'v1/entity' +import type { If } from 'v1/types/if' + +type MustBeDefined< + ATTRIBUTE extends Attribute, + REQUIRED_DEFAULTS extends boolean = false +> = REQUIRED_DEFAULTS extends false + ? ATTRIBUTE extends { required: Always; defaults: { key: undefined } } + ? true + : false + : ATTRIBUTE extends { required: Always } + ? true + : false + +/** + * Key input of a single item command (GET, DELETE ...) for an Entity or Schema + * + * @param Schema Entity | Schema + * @return Object + */ +export type KeyInput< + SCHEMA extends EntityV2 | Schema, + REQUIRED_DEFAULTS extends boolean = false +> = EntityV2 extends SCHEMA + ? MapAttributeValue + : Schema extends SCHEMA + ? MapAttributeValue + : SCHEMA extends Schema + ? OptionalizeUndefinableProperties< + { + // Keep only key attributes + [KEY in O.SelectKeys]: AttributeKeyInput< + SCHEMA['attributes'][KEY], + REQUIRED_DEFAULTS + > + }, + // Sadly we override optional AnyAttributes as 'unknown | undefined' => 'unknown' (undefined lost in the process) + O.SelectKeys + > + : SCHEMA extends EntityV2 + ? KeyInput + : never + +/** + * Key input of a single item command (GET, DELETE ...) for an Attribute + * + * @param Attribute Attribute + * @return Any + */ +export type AttributeKeyInput< + ATTRIBUTE extends Attribute, + REQUIRED_DEFAULTS extends boolean = false +> = Attribute extends ATTRIBUTE + ? AttributeValue | undefined + : + | If, never, undefined> + | (ATTRIBUTE extends AnyAttribute + ? ResolveAnyAttribute + : ATTRIBUTE extends PrimitiveAttribute + ? ResolvePrimitiveAttribute + : ATTRIBUTE extends SetAttribute + ? Set> + : ATTRIBUTE extends ListAttribute + ? AttributeKeyInput[] + : ATTRIBUTE extends MapAttribute + ? OptionalizeUndefinableProperties< + { + // Keep only key attributes + [KEY in O.SelectKeys]: AttributeKeyInput< + ATTRIBUTE['attributes'][KEY], + REQUIRED_DEFAULTS + > + }, + // Sadly we override optional AnyAttributes as 'unknown | undefined' => 'unknown' (undefined lost in the process) + O.SelectKeys + > + : ATTRIBUTE extends RecordAttribute + ? { + [KEY in ResolvePrimitiveAttribute]?: AttributeKeyInput< + ATTRIBUTE['elements'], + REQUIRED_DEFAULTS + > + } + : ATTRIBUTE extends AnyOfAttribute + ? AttributeKeyInput + : never) diff --git a/src/v1/operations/types/condition.ts b/src/v1/operations/types/condition.ts new file mode 100644 index 000000000..9b98bd3db --- /dev/null +++ b/src/v1/operations/types/condition.ts @@ -0,0 +1,168 @@ +import type { EntityV2 } from 'v1/entity' +import type { + Schema, + AnyAttribute, + ListAttribute, + MapAttribute, + RecordAttribute, + AnyOfAttribute, + Attribute, + ResolvePrimitiveAttribute, + ResolvedPrimitiveAttribute, + PrimitiveAttribute +} from 'v1/schema' +import type { SchemaAttributePath } from './paths' + +export type AnyAttributeCondition< + ATTRIBUTE_PATH extends string, + COMPARED_ATTRIBUTE_PATH extends string +> = + | AttributeCondition + | PrimitiveAttributeExtraCondition< + ATTRIBUTE_PATH, + | PrimitiveAttribute<'boolean'> + | PrimitiveAttribute<'number'> + | PrimitiveAttribute<'string'> + | PrimitiveAttribute<'binary'>, + COMPARED_ATTRIBUTE_PATH + > + +export type TypeCondition = 'S' | 'SS' | 'N' | 'NS' | 'B' | 'BS' | 'BOOL' | 'NULL' | 'L' | 'M' + +export type AttrOrSize = + | { attr: ATTRIBUTE_PATH; size?: undefined } + | { attr?: undefined; size: ATTRIBUTE_PATH } + +export type SharedAttributeCondition = AttrOrSize & + ( + | // TO VERIFY: Is EXIST applyable to all types of Attributes? + { exists: boolean } + | { type: TypeCondition } + // TO VERIFY: Is SIZE applyable to all types of Attributes? + | { size: string } + ) + +export type AttributeCondition< + ATTRIBUTE_PATH extends string, + ATTRIBUTE extends Attribute, + COMPARED_ATTRIBUTE_PATH extends string +> = + | SharedAttributeCondition + | (ATTRIBUTE extends AnyAttribute + ? AnyAttributeCondition<`${ATTRIBUTE_PATH}${string}`, COMPARED_ATTRIBUTE_PATH> + : never) + | (ATTRIBUTE extends PrimitiveAttribute + ? PrimitiveAttributeExtraCondition + : never) + /** + * @debt feature "Can you apply Contains clauses to Set attributes?" + */ + | (ATTRIBUTE extends ListAttribute + ? AttributeCondition< + `${ATTRIBUTE_PATH}[${number}]`, + ATTRIBUTE['elements'], + COMPARED_ATTRIBUTE_PATH + > + : never) + | (ATTRIBUTE extends MapAttribute + ? { + [KEY in keyof ATTRIBUTE['attributes']]: AttributeCondition< + `${ATTRIBUTE_PATH}.${Extract}`, + ATTRIBUTE['attributes'][KEY], + COMPARED_ATTRIBUTE_PATH + > + }[keyof ATTRIBUTE['attributes']] + : never) + | (ATTRIBUTE extends RecordAttribute + ? AttributeCondition< + `${ATTRIBUTE_PATH}.${ResolvePrimitiveAttribute}`, + ATTRIBUTE['elements'], + COMPARED_ATTRIBUTE_PATH + > + : never) + | (ATTRIBUTE extends AnyOfAttribute + ? ATTRIBUTE['elements'][number] extends infer ELEMENT + ? ELEMENT extends Attribute + ? AttributeCondition + : never + : never + : never) + +type NumberStringOrBinaryAttributeExtraCondition< + ATTRIBUTE_PATH extends string, + ATTRIBUTE extends + | PrimitiveAttribute<'number'> + | PrimitiveAttribute<'string'> + | PrimitiveAttribute<'binary'>, + COMPARED_ATTRIBUTE_PATH extends string, + ATTRIBUTE_VALUE extends ResolvedPrimitiveAttribute = ResolvePrimitiveAttribute +> = AttrOrSize & + ( + | { lt: ATTRIBUTE_VALUE | { attr: COMPARED_ATTRIBUTE_PATH } } + | { lte: ATTRIBUTE_VALUE | { attr: COMPARED_ATTRIBUTE_PATH } } + | { gt: ATTRIBUTE_VALUE | { attr: COMPARED_ATTRIBUTE_PATH } } + | { gte: ATTRIBUTE_VALUE | { attr: COMPARED_ATTRIBUTE_PATH } } + | { + between: [ + ATTRIBUTE_VALUE | { attr: COMPARED_ATTRIBUTE_PATH }, + ATTRIBUTE_VALUE | { attr: COMPARED_ATTRIBUTE_PATH } + ] + } + ) + +type StringOrBinaryAttributeExtraCondition< + ATTRIBUTE_PATH extends string, + ATTRIBUTE extends PrimitiveAttribute<'string'> | PrimitiveAttribute<'binary'>, + COMPARED_ATTRIBUTE_PATH extends string, + ATTRIBUTE_VALUE extends ResolvedPrimitiveAttribute = ResolvePrimitiveAttribute +> = AttrOrSize & + ( + | { contains: ATTRIBUTE_VALUE | { attr: COMPARED_ATTRIBUTE_PATH } } + | { notContains: ATTRIBUTE_VALUE | { attr: COMPARED_ATTRIBUTE_PATH } } + | { beginsWith: ATTRIBUTE_VALUE | { attr: COMPARED_ATTRIBUTE_PATH } } + ) + +export type PrimitiveAttributeExtraCondition< + ATTRIBUTE_PATH extends string, + ATTRIBUTE extends PrimitiveAttribute, + COMPARED_ATTRIBUTE_PATH extends string, + ATTRIBUTE_VALUE extends ResolvedPrimitiveAttribute = ResolvePrimitiveAttribute +> = AttrOrSize & { transform?: boolean } & ( + | // TO VERIFY: Are EQ | NE | IN applyable to other types than Primitives? + { eq: ATTRIBUTE_VALUE | { attr: COMPARED_ATTRIBUTE_PATH } } + | { ne: ATTRIBUTE_VALUE | { attr: COMPARED_ATTRIBUTE_PATH } } + | { in: (ATTRIBUTE_VALUE | { attr: COMPARED_ATTRIBUTE_PATH })[] } + | (ATTRIBUTE extends + | PrimitiveAttribute<'string'> + | PrimitiveAttribute<'number'> + | PrimitiveAttribute<'binary'> + ? NumberStringOrBinaryAttributeExtraCondition< + ATTRIBUTE_PATH, + ATTRIBUTE, + COMPARED_ATTRIBUTE_PATH + > + : never) + | (ATTRIBUTE extends PrimitiveAttribute<'string'> | PrimitiveAttribute<'binary'> + ? StringOrBinaryAttributeExtraCondition + : never) + ) + +export type NonLogicalCondition = Schema extends SCHEMA + ? AnyAttributeCondition + : keyof SCHEMA['attributes'] extends infer ATTRIBUTE_PATH + ? ATTRIBUTE_PATH extends string + ? AttributeCondition< + ATTRIBUTE_PATH, + SCHEMA['attributes'][ATTRIBUTE_PATH], + SchemaAttributePath + > + : never + : never + +export type SchemaCondition = + | NonLogicalCondition + | { and: SchemaCondition[] } + | { or: SchemaCondition[] } + | { not: SchemaCondition } + +export type Condition = SchemaCondition diff --git a/src/v1/operations/types/condition.type.test.ts b/src/v1/operations/types/condition.type.test.ts new file mode 100644 index 000000000..6fd3a30d5 --- /dev/null +++ b/src/v1/operations/types/condition.type.test.ts @@ -0,0 +1,241 @@ +import type { A } from 'ts-toolbelt' + +import type { Attribute } from 'v1/schema' + +import type { + SchemaCondition, + NonLogicalCondition, + AttributeCondition, + SharedAttributeCondition, + TypeCondition, + AttrOrSize +} from './condition' +import type { ATTRIBUTE_PATHS } from './paths.type.test' +import { mySchema } from './fixtures.test' + +type ATTRIBUTES = typeof mySchema['attributes'] + +type PARENT_ID_CONDITION = AttributeCondition<'parentId', ATTRIBUTES['parentId'], ATTRIBUTE_PATHS> +const assertParentIdCondition: A.Equals< + AttrOrSize<'parentId'> & + ( + | SharedAttributeCondition<'parentId'> + | ({ transform?: boolean } & ( + | { eq: string | { attr: ATTRIBUTE_PATHS } } + | { ne: string | { attr: ATTRIBUTE_PATHS } } + | { in: (string | { attr: ATTRIBUTE_PATHS })[] } + | { lt: string | { attr: ATTRIBUTE_PATHS } } + | { lte: string | { attr: ATTRIBUTE_PATHS } } + | { gt: string | { attr: ATTRIBUTE_PATHS } } + | { gte: string | { attr: ATTRIBUTE_PATHS } } + | { between: [string | { attr: ATTRIBUTE_PATHS }, string | { attr: ATTRIBUTE_PATHS }] } + | { contains: string | { attr: ATTRIBUTE_PATHS } } + | { notContains: string | { attr: ATTRIBUTE_PATHS } } + | { beginsWith: string | { attr: ATTRIBUTE_PATHS } } + )) + ), + PARENT_ID_CONDITION +> = 1 +assertParentIdCondition + +type CHILD_ID_CONDITION = AttributeCondition<'childId', ATTRIBUTES['childId'], ATTRIBUTE_PATHS> + +type ANY_CONDITION = AttributeCondition<'any', ATTRIBUTES['any'], ATTRIBUTE_PATHS> + +const anyCondition: A.Contains< + AttrOrSize<`any${string}`> & + ( + | { exists: boolean } + | { type: TypeCondition } + | { size: string } + | { eq: boolean | string | number | Buffer | { attr: ATTRIBUTE_PATHS } } + | { ne: boolean | string | number | Buffer | { attr: ATTRIBUTE_PATHS } } + | { in: (boolean | string | number | Buffer | { attr: ATTRIBUTE_PATHS })[] } + | { lt: string | { attr: ATTRIBUTE_PATHS } } + | { lt: number | { attr: ATTRIBUTE_PATHS } } + | { lt: Buffer | { attr: ATTRIBUTE_PATHS } } + | { lte: string | { attr: ATTRIBUTE_PATHS } } + | { lte: number | { attr: ATTRIBUTE_PATHS } } + | { lte: Buffer | { attr: ATTRIBUTE_PATHS } } + | { gt: string | { attr: ATTRIBUTE_PATHS } } + | { gt: number | { attr: ATTRIBUTE_PATHS } } + | { gt: Buffer | { attr: ATTRIBUTE_PATHS } } + | { gte: string | { attr: ATTRIBUTE_PATHS } } + | { gte: number | { attr: ATTRIBUTE_PATHS } } + | { gte: Buffer | { attr: ATTRIBUTE_PATHS } } + | { between: [string | { attr: ATTRIBUTE_PATHS }, string | { attr: ATTRIBUTE_PATHS }] } + | { between: [number | { attr: ATTRIBUTE_PATHS }, number | { attr: ATTRIBUTE_PATHS }] } + | { between: [Buffer | { attr: ATTRIBUTE_PATHS }, Buffer | { attr: ATTRIBUTE_PATHS }] } + | { contains: string | { attr: ATTRIBUTE_PATHS } } + | { contains: Buffer | { attr: ATTRIBUTE_PATHS } } + | { notContains: string | { attr: ATTRIBUTE_PATHS } } + | { notContains: Buffer | { attr: ATTRIBUTE_PATHS } } + | { beginsWith: string | { attr: ATTRIBUTE_PATHS } } + | { beginsWith: Buffer | { attr: ATTRIBUTE_PATHS } } + ), + ANY_CONDITION +> = 1 +anyCondition + +type NUM_CONDITION = AttributeCondition<'num', ATTRIBUTES['num'], ATTRIBUTE_PATHS> +const assertNumCondition: A.Equals< + AttrOrSize<'num'> & + ( + | SharedAttributeCondition<'num'> + | ({ transform?: boolean } & ( + | { eq: number | { attr: ATTRIBUTE_PATHS } } + | { ne: number | { attr: ATTRIBUTE_PATHS } } + | { in: (number | { attr: ATTRIBUTE_PATHS })[] } + | { lt: number | { attr: ATTRIBUTE_PATHS } } + | { lte: number | { attr: ATTRIBUTE_PATHS } } + | { gt: number | { attr: ATTRIBUTE_PATHS } } + | { gte: number | { attr: ATTRIBUTE_PATHS } } + | { between: [number | { attr: ATTRIBUTE_PATHS }, number | { attr: ATTRIBUTE_PATHS }] } + )) + ), + NUM_CONDITION +> = 1 +assertNumCondition + +type BOOL_CONDITION = AttributeCondition<'bool', ATTRIBUTES['bool'], ATTRIBUTE_PATHS> +const assertBoolCondition: A.Equals< + AttrOrSize<'bool'> & + ( + | SharedAttributeCondition<'bool'> + | ({ transform?: boolean } & ( + | { eq: boolean | { attr: ATTRIBUTE_PATHS } } + | { ne: boolean | { attr: ATTRIBUTE_PATHS } } + | { in: (boolean | { attr: ATTRIBUTE_PATHS })[] } + )) + ), + BOOL_CONDITION +> = 1 +assertBoolCondition + +type BIN_CONDITION = AttributeCondition<'bin', ATTRIBUTES['bin'], ATTRIBUTE_PATHS> +const assertBinCondition: A.Equals< + AttrOrSize<'bin'> & + ( + | SharedAttributeCondition<'bin'> + | ({ transform?: boolean } & ( + | { eq: Buffer | { attr: ATTRIBUTE_PATHS } } + | { ne: Buffer | { attr: ATTRIBUTE_PATHS } } + | { in: (Buffer | { attr: ATTRIBUTE_PATHS })[] } + | { lt: Buffer | { attr: ATTRIBUTE_PATHS } } + | { lte: Buffer | { attr: ATTRIBUTE_PATHS } } + | { gt: Buffer | { attr: ATTRIBUTE_PATHS } } + | { gte: Buffer | { attr: ATTRIBUTE_PATHS } } + | { between: [Buffer | { attr: ATTRIBUTE_PATHS }, Buffer | { attr: ATTRIBUTE_PATHS }] } + | { contains: Buffer | { attr: ATTRIBUTE_PATHS } } + | { notContains: Buffer | { attr: ATTRIBUTE_PATHS } } + | { beginsWith: Buffer | { attr: ATTRIBUTE_PATHS } } + )) + ), + BIN_CONDITION +> = 1 +assertBinCondition + +type STRING_SET_CONDITION = AttributeCondition< + 'stringSet', + ATTRIBUTES['stringSet'], + ATTRIBUTE_PATHS +> +const assertStringSetCondition: A.Equals< + SharedAttributeCondition<'stringSet'>, + STRING_SET_CONDITION +> = 1 +assertStringSetCondition + +type STRING_LIST_CONDITION = AttributeCondition< + 'stringList', + ATTRIBUTES['stringList'], + ATTRIBUTE_PATHS +> +const assertStringListCondition: A.Equals< + | SharedAttributeCondition<'stringList'> + | AttributeCondition< + `stringList[${number}]`, + ATTRIBUTES['stringList']['elements'], + ATTRIBUTE_PATHS + >, + STRING_LIST_CONDITION +> = 1 +assertStringListCondition + +type MAP_LIST_CONDITION = AttributeCondition<'mapList', ATTRIBUTES['mapList'], ATTRIBUTE_PATHS> +const assertMapListCondition: A.Equals< + | SharedAttributeCondition<'mapList'> + | AttributeCondition<`mapList[${number}]`, ATTRIBUTES['mapList']['elements'], ATTRIBUTE_PATHS> + | AttributeCondition< + `mapList[${number}].num`, + ATTRIBUTES['mapList']['elements']['attributes']['num'], + ATTRIBUTE_PATHS + >, + MAP_LIST_CONDITION +> = 1 +assertMapListCondition + +type MAP_CONDITION = AttributeCondition<'map', ATTRIBUTES['map'], ATTRIBUTE_PATHS> +const assertMapCondition: A.Equals< + | SharedAttributeCondition<'map'> + | AttributeCondition<`map.num`, ATTRIBUTES['map']['attributes']['num'], ATTRIBUTE_PATHS> + | AttributeCondition< + `map.stringList`, + ATTRIBUTES['map']['attributes']['stringList'], + ATTRIBUTE_PATHS + > + | AttributeCondition<`map.map`, ATTRIBUTES['map']['attributes']['map'], ATTRIBUTE_PATHS>, + MAP_CONDITION +> = 1 +assertMapCondition + +type RECORD_CONDITION = AttributeCondition<'record', ATTRIBUTES['record'], ATTRIBUTE_PATHS> +const assertRecordCondition: A.Equals< + | SharedAttributeCondition<'record'> + | AttributeCondition< + 'record.foo' | 'record.bar', + ATTRIBUTES['record']['elements'], + ATTRIBUTE_PATHS + >, + RECORD_CONDITION +> = 1 +assertRecordCondition + +type UNION_CONDITION = AttributeCondition<'union', ATTRIBUTES['union'], ATTRIBUTE_PATHS> +const assertUnionCondition: A.Equals< + | SharedAttributeCondition<'union'> + | (ATTRIBUTES['union']['elements'][number] extends infer ELEMENT + ? ELEMENT extends Attribute + ? AttributeCondition<'union', ELEMENT, ATTRIBUTE_PATHS> + : never + : never), + UNION_CONDITION +> = 1 +assertUnionCondition + +type NON_LOGICAL_CONDITION = NonLogicalCondition +const assertNonLogicalCondition: A.Contains< + | PARENT_ID_CONDITION + | CHILD_ID_CONDITION + | ANY_CONDITION + | NUM_CONDITION + | BOOL_CONDITION + | BIN_CONDITION + | STRING_SET_CONDITION + | STRING_LIST_CONDITION + | MAP_LIST_CONDITION + | MAP_CONDITION + | RECORD_CONDITION + | UNION_CONDITION, + NON_LOGICAL_CONDITION +> = 1 +assertNonLogicalCondition + +const assertEntityCondition: A.Contains< + | NON_LOGICAL_CONDITION + | { or: NON_LOGICAL_CONDITION[] } + | { and: NON_LOGICAL_CONDITION[] } + | { not: NON_LOGICAL_CONDITION }, + SchemaCondition +> = 1 +assertEntityCondition diff --git a/src/v1/operations/types/fixtures.test.ts b/src/v1/operations/types/fixtures.test.ts new file mode 100644 index 000000000..0f4048111 --- /dev/null +++ b/src/v1/operations/types/fixtures.test.ts @@ -0,0 +1,35 @@ +import { + schema, + any, + string, + number, + boolean, + binary, + set, + list, + map, + record, + anyOf +} from 'v1/schema' + +export const mySchema = schema({ + parentId: string().key().savedAs('pk'), + childId: string().key().savedAs('sk'), + any: any(), + const: string().const('const'), + num: number(), + bool: boolean(), + bin: binary(), + stringSet: set(string()), + stringList: list(string()), + mapList: list(map({ num: number() })), + map: map({ + num: number(), + stringList: list(string()), + map: map({ + num: number() + }) + }), + record: record(string().enum('foo', 'bar'), map({ num: number() })), + union: anyOf(map({ str: string() }), map({ num: number() })) +}) diff --git a/src/v1/operations/types/index.ts b/src/v1/operations/types/index.ts new file mode 100644 index 000000000..37428d41a --- /dev/null +++ b/src/v1/operations/types/index.ts @@ -0,0 +1,11 @@ +export * from '../putItem/types' +export * from '../updateItem/types' +export type { + Condition, + SchemaCondition, + AnyAttributeCondition, + NonLogicalCondition +} from './condition' +export type { SchemaAttributePath, AnyAttributePath, AnyCommonAttributePath } from './paths' +export type { KeyInput, AttributeKeyInput } from './KeyInput' +export type { Query } from './query' diff --git a/src/v1/operations/types/paths.ts b/src/v1/operations/types/paths.ts new file mode 100644 index 000000000..e7fefdee1 --- /dev/null +++ b/src/v1/operations/types/paths.ts @@ -0,0 +1,63 @@ +import type { EntityV2 } from 'v1/entity' +import type { + Schema, + AnyAttribute, + ListAttribute, + MapAttribute, + RecordAttribute, + AnyOfAttribute, + Attribute, + ResolvePrimitiveAttribute +} from 'v1/schema' + +type AttributePath = + | ATTRIBUTE_PATH + | (ATTRIBUTE extends AnyAttribute ? `${ATTRIBUTE_PATH}${string}` : never) + // TO VERIFY: Can you apply clauses to Set attributes like Contains ? + | (ATTRIBUTE extends ListAttribute + ? AttributePath<`${ATTRIBUTE_PATH}[${number}]`, ATTRIBUTE['elements']> + : never) + | (ATTRIBUTE extends MapAttribute + ? { + [KEY in keyof ATTRIBUTE['attributes']]: AttributePath< + `${ATTRIBUTE_PATH}.${Extract}`, + ATTRIBUTE['attributes'][KEY] + > + }[keyof ATTRIBUTE['attributes']] + : never) + | (ATTRIBUTE extends RecordAttribute + ? AttributePath< + `${ATTRIBUTE_PATH}.${ResolvePrimitiveAttribute}`, + ATTRIBUTE['elements'] + > + : never) + | (ATTRIBUTE extends AnyOfAttribute + ? ATTRIBUTE['elements'][number] extends infer ELEMENT + ? ELEMENT extends Attribute + ? AttributePath + : never + : never + : never) + +export type SchemaAttributePath = Schema extends SCHEMA + ? string + : keyof SCHEMA['attributes'] extends infer ATTRIBUTE_PATH + ? ATTRIBUTE_PATH extends string + ? AttributePath + : never + : never + +export type AnyAttributePath = SchemaAttributePath< + ENTITY['schema'] +> + +export type AnyCommonAttributePath< + ENTITIES extends EntityV2[] = EntityV2[], + RESULTS extends string = string +> = ENTITIES extends [infer ENTITIES_HEAD, ...infer ENTITIES_TAIL] + ? ENTITIES_HEAD extends EntityV2 + ? ENTITIES_TAIL extends EntityV2[] + ? AnyCommonAttributePath> + : never + : never + : RESULTS diff --git a/src/v1/operations/types/paths.type.test.ts b/src/v1/operations/types/paths.type.test.ts new file mode 100644 index 000000000..fe6487d49 --- /dev/null +++ b/src/v1/operations/types/paths.type.test.ts @@ -0,0 +1,36 @@ +import type { A } from 'ts-toolbelt' + +import type { mySchema } from './fixtures.test' +import type { SchemaAttributePath } from './paths' + +export type ATTRIBUTE_PATHS = SchemaAttributePath +const assertAttributePaths: A.Equals< + | 'parentId' + | 'childId' + | 'any' + | `any${string}` + | 'const' + | 'num' + | 'bool' + | 'bin' + | 'stringSet' + | 'stringList' + | `stringList[${number}]` + | 'mapList' + | `mapList[${number}]` + | `mapList[${number}].num` + | 'map' + | `map.num` + | `map.stringList` + | `map.stringList[${number}]` + | `map.map` + | `map.map.num` + | 'record' + | `record.${'foo' | 'bar'}` + | `record.${'foo' | 'bar'}.num` + | 'union' + | 'union.str' + | 'union.num', + ATTRIBUTE_PATHS +> = 1 +assertAttributePaths diff --git a/src/v1/operations/types/query.ts b/src/v1/operations/types/query.ts new file mode 100644 index 000000000..fcb749df0 --- /dev/null +++ b/src/v1/operations/types/query.ts @@ -0,0 +1,86 @@ +import type { A } from 'ts-toolbelt' + +import type { PrimitiveAttribute, ResolvePrimitiveAttribute } from 'v1/schema' +import type { + IndexableKeyType, + TableV2, + LocalIndex, + GlobalIndex, + IndexNames, + IndexSchema, + Key +} from 'v1/table' +import type { RangeOperator } from 'v1/operations/expression/condition/parser/parseCondition/comparison/types' +import type { BeginsWithOperator } from 'v1/operations/expression/condition/parser/parseCondition/twoArgsFn/types' +import type { BetweenOperator } from 'v1/operations/expression/condition/parser/parseCondition/between/types' + +type QueryOperator = RangeOperator | BeginsWithOperator | BetweenOperator +export const queryOperatorSet = new Set([ + 'gt', + 'gte', + 'lt', + 'lte', + 'between', + 'beginsWith' +]) + +/** + * @debt refactor "Factorize with Condition types" + */ +type QueryRange< + KEY_TYPE extends IndexableKeyType, + ATTRIBUTE_VALUE extends ResolvePrimitiveAttribute< + PrimitiveAttribute + > = ResolvePrimitiveAttribute> +> = + | (RangeOperator extends infer COMPARISON_OPERATOR + ? COMPARISON_OPERATOR extends RangeOperator + ? Record + : never + : never) + | Record + | (KEY_TYPE extends 'string' ? Record : never) + +type SecondaryIndexQuery< + TABLE extends TableV2, + INDEX_NAME extends IndexNames
, + INDEX_SCHEMA extends IndexSchema
= IndexSchema +> = A.Compute< + { index: INDEX_NAME } & (INDEX_SCHEMA extends GlobalIndex + ? { + partition: ResolvePrimitiveAttribute< + PrimitiveAttribute + > + range?: QueryRange + } + : INDEX_SCHEMA extends LocalIndex + ? { + partition: ResolvePrimitiveAttribute> + range?: QueryRange + } + : never) +> + +type SecondaryIndexQueries
= IndexNames
extends infer INDEX_NAME + ? INDEX_NAME extends IndexNames
+ ? SecondaryIndexQuery + : never + : never + +type PrimaryIndexQuery
= A.Compute< + { + index?: never + } & (Key extends TABLE['sortKey'] + ? { + partition: ResolvePrimitiveAttribute> + range?: never + } + : NonNullable extends Key + ? { + partition: ResolvePrimitiveAttribute> + range?: QueryRange['type']> + } + : never) +> + +export type Query
= PrimaryIndexQuery
| SecondaryIndexQueries
diff --git a/src/v1/operations/updateItem/command.ts b/src/v1/operations/updateItem/command.ts new file mode 100644 index 000000000..4a0952a7b --- /dev/null +++ b/src/v1/operations/updateItem/command.ts @@ -0,0 +1,116 @@ +import type { O } from 'ts-toolbelt' +import { UpdateCommandInput, UpdateCommand, UpdateCommandOutput } from '@aws-sdk/lib-dynamodb' + +import type { EntityV2, FormattedItem } from 'v1/entity' +import type { + NoneReturnValuesOption, + UpdatedOldReturnValuesOption, + UpdatedNewReturnValuesOption, + AllOldReturnValuesOption, + AllNewReturnValuesOption +} from 'v1/operations/constants/options/returnValues' +import { DynamoDBToolboxError } from 'v1/errors' +import { formatSavedItem } from 'v1/operations/utils/formatSavedItem' + +import { EntityOperation, $entity } from '../class' +import type { UpdateItemInput } from './types' +import type { UpdateItemOptions, UpdateItemCommandReturnValuesOption } from './options' +import { updateItemParams } from './updateItemParams' + +export const $item = Symbol('$item') +export type $item = typeof $item + +export const $options = Symbol('$options') +export type $options = typeof $options + +type ReturnedAttributes< + ENTITY extends EntityV2, + OPTIONS extends UpdateItemOptions +> = UpdateItemCommandReturnValuesOption extends OPTIONS['returnValues'] + ? undefined + : OPTIONS['returnValues'] extends NoneReturnValuesOption + ? undefined + : OPTIONS['returnValues'] extends UpdatedOldReturnValuesOption | UpdatedNewReturnValuesOption + ? + | FormattedItem< + ENTITY, + { + partial: OPTIONS['returnValues'] extends + | UpdatedOldReturnValuesOption + | UpdatedNewReturnValuesOption + ? true + : false + } + > + | undefined + : OPTIONS['returnValues'] extends AllNewReturnValuesOption | AllOldReturnValuesOption + ? FormattedItem | undefined + : never + +export type UpdateItemResponse< + ENTITY extends EntityV2, + OPTIONS extends UpdateItemOptions = UpdateItemOptions +> = O.Merge< + Omit, + { Attributes?: ReturnedAttributes } +> + +export class UpdateItemCommand< + ENTITY extends EntityV2 = EntityV2, + OPTIONS extends UpdateItemOptions = UpdateItemOptions +> extends EntityOperation { + static operationName = 'update' as const; + + [$item]?: UpdateItemInput + item: (nextItem: UpdateItemInput) => UpdateItemCommand; + [$options]: OPTIONS + options: >( + nextOptions: NEXT_OPTIONS + ) => UpdateItemCommand + + constructor(entity: ENTITY, item?: UpdateItemInput, options: OPTIONS = {} as OPTIONS) { + super(entity) + this[$item] = item + this[$options] = options + + this.item = nextItem => new UpdateItemCommand(this[$entity], nextItem, this[$options]) + this.options = nextOptions => new UpdateItemCommand(this[$entity], this[$item], nextOptions) + } + + params = (): UpdateCommandInput => { + if (!this[$item]) { + throw new DynamoDBToolboxError('operations.incompleteCommand', { + message: 'UpdateItemCommand incomplete: Missing "item" property' + }) + } + + return updateItemParams(this[$entity], this[$item], this[$options]) + } + + send = async (): Promise> => { + const getItemParams = this.params() + + const commandOutput = await this[$entity].table.documentClient.send( + new UpdateCommand(getItemParams) + ) + + const { Attributes: attributes, ...restCommandOutput } = commandOutput + + if (attributes === undefined) { + return restCommandOutput + } + + const { returnValues } = this[$options] + + const formattedItem = (formatSavedItem(this[$entity], attributes, { + partial: returnValues === 'UPDATED_NEW' || returnValues === 'UPDATED_OLD' + }) as unknown) as ReturnedAttributes + + return { + Attributes: formattedItem, + ...restCommandOutput + } + } +} + +export type UpdateItemCommandClass = typeof UpdateItemCommand diff --git a/src/v1/operations/updateItem/constants.ts b/src/v1/operations/updateItem/constants.ts new file mode 100644 index 000000000..7923eb44c --- /dev/null +++ b/src/v1/operations/updateItem/constants.ts @@ -0,0 +1,29 @@ +export const $HAS_VERB = Symbol('$HAS_VERB') +export type $HAS_VERB = typeof $HAS_VERB + +export const $SET = Symbol('$SET') +export type $SET = typeof $SET + +export const $GET = Symbol('$GET') +export type $GET = typeof $GET + +export const $REMOVE = Symbol('$REMOVE') +export type $REMOVE = typeof $REMOVE + +export const $SUM = Symbol('$SUM') +export type $SUM = typeof $SUM + +export const $SUBTRACT = Symbol('$SUBTRACT') +export type $SUBTRACT = typeof $SUBTRACT + +export const $ADD = Symbol('$ADD') +export type $ADD = typeof $ADD + +export const $DELETE = Symbol('$DELETE') +export type $DELETE = typeof $DELETE + +export const $APPEND = Symbol('$APPEND') +export type $APPEND = typeof $APPEND + +export const $PREPEND = Symbol('$PREPEND') +export type $PREPEND = typeof $PREPEND diff --git a/src/v1/operations/updateItem/index.ts b/src/v1/operations/updateItem/index.ts new file mode 100644 index 000000000..67d58dff5 --- /dev/null +++ b/src/v1/operations/updateItem/index.ts @@ -0,0 +1,5 @@ +export { UpdateItemCommand } from './command' +export { $set, $get, $remove, $sum, $subtract, $add, $delete, $append, $prepend } from './utils' +export type { UpdateItemResponse } from './command' +export type { UpdateItemOptions } from './options' +export type { UpdateItemInput } from './types' diff --git a/src/v1/operations/updateItem/options.ts b/src/v1/operations/updateItem/options.ts new file mode 100644 index 000000000..de399c254 --- /dev/null +++ b/src/v1/operations/updateItem/options.ts @@ -0,0 +1,18 @@ +import type { CapacityOption } from 'v1/operations/constants/options/capacity' +import type { MetricsOption } from 'v1/operations/constants/options/metrics' +import type { ReturnValuesOption } from 'v1/operations/constants/options/returnValues' +import type { Condition } from 'v1/operations/types/condition' +import type { EntityV2 } from 'v1/entity' + +export type UpdateItemCommandReturnValuesOption = ReturnValuesOption + +export const updateItemCommandReturnValuesOptionsSet = new Set( + ['NONE', 'ALL_OLD', 'UPDATED_OLD', 'ALL_NEW', 'UPDATED_NEW'] +) + +export interface UpdateItemOptions { + capacity?: CapacityOption + metrics?: MetricsOption + returnValues?: UpdateItemCommandReturnValuesOption + condition?: Condition +} diff --git a/src/v1/operations/updateItem/types.ts b/src/v1/operations/updateItem/types.ts new file mode 100644 index 000000000..d3b8a6bca --- /dev/null +++ b/src/v1/operations/updateItem/types.ts @@ -0,0 +1,350 @@ +import type { O } from 'ts-toolbelt' + +import type { + Schema, + Attribute, + AttributeValue, + ResolveAnyAttribute, + ResolvePrimitiveAttribute, + Item, + AnyAttribute, + PrimitiveAttribute, + PrimitiveAttributeValue, + SetAttribute, + SetAttributeValue, + ListAttribute, + ListAttributeValue, + MapAttribute, + MapAttributeValue, + RecordAttribute, + RecordAttributeValue, + AnyOfAttribute, + AtLeastOnce, + Always, + Never +} from 'v1/schema' +import type { OptionalizeUndefinableProperties } from 'v1/types/optionalizeUndefinableProperties' +import type { EntityV2 } from 'v1/entity/class' +import type { If } from 'v1/types/if' +import type { SchemaAttributePath } from 'v1/operations/types' + +import { + $HAS_VERB, + $SET, + $GET, + $REMOVE, + $SUM, + $SUBTRACT, + $ADD, + $DELETE, + $APPEND, + $PREPEND +} from './constants' + +// Distinguishing verbal syntax vs non-verbal for type inference & parsing +export type Verbal = { [$HAS_VERB]: true } & VALUE + +export type ADD = Verbal<{ [$ADD]: VALUE }> +export type SET = Verbal<{ [$SET]: VALUE }> +export type GET = Verbal<{ [$GET]: VALUE }> +export type SUM = Verbal<{ [$SUM]: [A, B] }> +export type SUBTRACT = Verbal<{ [$SUBTRACT]: [A, B] }> +export type DELETE = Verbal<{ [$DELETE]: VALUE }> +export type APPEND = Verbal<{ [$APPEND]: VALUE }> +export type PREPEND = Verbal<{ [$PREPEND]: VALUE }> + +export type NonVerbal = { [$HAS_VERB]?: false } & VALUE + +export type ReferenceExtension = { + type: '*' + value: Verbal<{ [$GET]: [ref: string, fallback?: AttributeValue] }> +} + +export type UpdateItemInputExtension = + | ReferenceExtension + | { type: '*'; value: $REMOVE } + | { + type: 'number' + value: + | Verbal<{ [$ADD]: number }> + | Verbal<{ + [$SUM]: [ + PrimitiveAttributeValue, + PrimitiveAttributeValue + ] + }> + | Verbal<{ + [$SUBTRACT]: [ + PrimitiveAttributeValue, + PrimitiveAttributeValue + ] + }> + } + | { + type: 'set' + value: Verbal<{ [$ADD]: SetAttributeValue } | { [$DELETE]: SetAttributeValue }> + } + | { + type: 'list' + value: + | NonVerbal<{ [INDEX in number]: AttributeValue | undefined }> + | Verbal<{ [$SET]: ListAttributeValue }> + | Verbal< + | { [$APPEND]: AttributeValue | AttributeValue[] } + | { [$PREPEND]: AttributeValue | AttributeValue[] } + // TODO: CONCAT to join two unrelated lists + > + } + | { + type: 'map' + value: Verbal<{ [$SET]: MapAttributeValue }> + } + | { + type: 'record' + value: Verbal<{ [$SET]: RecordAttributeValue }> + } + +type MustBeDefined< + ATTRIBUTE extends Attribute, + REQUIRED_DEFAULTS extends boolean = false +> = REQUIRED_DEFAULTS extends false + ? ATTRIBUTE extends { required: Always } & ( + | { key: true; defaults: { key: undefined } } + | { key: false; defaults: { update: undefined } } + ) + ? true + : false + : ATTRIBUTE extends { required: Always } + ? true + : false + +type CanBeRemoved = ATTRIBUTE extends { required: Never } + ? true + : false + +/** + * User input of an UPDATE command for a given Entity or Schema + * + * @param Schema Entity | Schema + * @param RequireIndependentDefaults Boolean + * @return Object + */ +export type UpdateItemInput< + SCHEMA extends EntityV2 | Schema = EntityV2, + REQUIRED_DEFAULTS extends boolean = false +> = EntityV2 extends SCHEMA + ? Item + : Schema extends SCHEMA + ? Item + : SCHEMA extends Schema + ? OptionalizeUndefinableProperties< + { + [KEY in keyof SCHEMA['attributes']]: AttributeUpdateItemInput< + SCHEMA['attributes'][KEY], + REQUIRED_DEFAULTS, + SchemaAttributePath + > + }, + // Sadly we override optional AnyAttributes as 'unknown | undefined' => 'unknown' (undefined lost in the process) + O.SelectKeys + > + : SCHEMA extends EntityV2 + ? UpdateItemInput + : never + +export type Reference< + ATTRIBUTE extends Attribute, + SCHEMA_ATTRIBUTE_PATHS extends string = string +> = GET< + [ + ref: SCHEMA_ATTRIBUTE_PATHS, + fallback?: + | AttributeUpdateItemCompleteInput + | Reference + ] +> + +type AttributeUpdateItemCompleteInput = Attribute extends ATTRIBUTE + ? AttributeValue | undefined + : + | (ATTRIBUTE extends { required: Never } ? undefined : never) + | (ATTRIBUTE extends AnyAttribute + ? ResolveAnyAttribute | unknown + : ATTRIBUTE extends PrimitiveAttribute + ? ResolvePrimitiveAttribute + : ATTRIBUTE extends SetAttribute + ? Set> + : ATTRIBUTE extends ListAttribute + ? AttributeUpdateItemCompleteInput[] + : ATTRIBUTE extends MapAttribute + ? OptionalizeUndefinableProperties< + { + [KEY in keyof ATTRIBUTE['attributes']]: AttributeUpdateItemCompleteInput< + ATTRIBUTE['attributes'][KEY] + > + }, + // Sadly we override optional AnyAttributes as 'unknown | undefined' => 'unknown' (undefined lost in the process) + O.SelectKeys + > + : ATTRIBUTE extends RecordAttribute + ? { + [KEY in ResolvePrimitiveAttribute< + ATTRIBUTE['keys'] + >]?: AttributeUpdateItemCompleteInput + } + : ATTRIBUTE extends AnyOfAttribute + ? AttributeUpdateItemCompleteInput + : never) + +/** + * User input of an UPDATE command for a given Attribute + * + * @param Attribute Attribute + * @param RequireIndependentDefaults Boolean + * @return Any + */ +export type AttributeUpdateItemInput< + ATTRIBUTE extends Attribute = Attribute, + REQUIRED_DEFAULTS extends boolean = false, + SCHEMA_ATTRIBUTE_PATHS extends string = string +> = Attribute extends ATTRIBUTE + ? AttributeValue | undefined + : + | If, never, undefined> + | If, $REMOVE, never> + // Not using Reference<...> for improved type display + | GET< + [ + ref: SCHEMA_ATTRIBUTE_PATHS, + fallback?: + | AttributeUpdateItemCompleteInput + | Reference + ] + > + | (ATTRIBUTE extends AnyAttribute + ? ResolveAnyAttribute | unknown + : ATTRIBUTE extends PrimitiveAttribute + ? + | ResolvePrimitiveAttribute + | (ATTRIBUTE extends PrimitiveAttribute<'number'> + ? + | ADD + | SUM< + // Not using Reference<...> for improved type display + | number + | GET< + [ + ref: SCHEMA_ATTRIBUTE_PATHS, + fallback?: number | Reference + ] + >, + // Not using Reference<...> for improved type display + | number + | GET< + [ + ref: SCHEMA_ATTRIBUTE_PATHS, + fallback?: number | Reference + ] + > + > + | SUBTRACT< + // Not using Reference<...> for improved type display + | number + | GET< + [ + ref: SCHEMA_ATTRIBUTE_PATHS, + fallback?: number | Reference + ] + >, + // Not using Reference<...> for improved type display + | number + | GET< + [ + ref: SCHEMA_ATTRIBUTE_PATHS, + fallback?: number | Reference + ] + > + > + : never) + : ATTRIBUTE extends SetAttribute + ? + | Set> + | ADD>> + | DELETE>> + : ATTRIBUTE extends ListAttribute + ? + | NonVerbal< + { + [INDEX in number]?: + | AttributeUpdateItemInput< + ATTRIBUTE['elements'], + REQUIRED_DEFAULTS, + SCHEMA_ATTRIBUTE_PATHS + > + | $REMOVE + } + > + | SET[]> + | APPEND< + // Not using Reference<...> for improved type display + | GET< + [ + ref: SCHEMA_ATTRIBUTE_PATHS, + fallback?: + | AttributeUpdateItemCompleteInput[] + | Reference + ] + > + | AttributeUpdateItemCompleteInput[] + > + | PREPEND< + | GET< + [ + ref: SCHEMA_ATTRIBUTE_PATHS, + fallback?: + | AttributeUpdateItemCompleteInput[] + | Reference + ] + > + | AttributeUpdateItemCompleteInput[] + > + : ATTRIBUTE extends MapAttribute + ? + | NonVerbal< + OptionalizeUndefinableProperties< + { + [KEY in keyof ATTRIBUTE['attributes']]: AttributeUpdateItemInput< + ATTRIBUTE['attributes'][KEY], + REQUIRED_DEFAULTS, + SCHEMA_ATTRIBUTE_PATHS + > + }, + // Sadly we override optional AnyAttributes as 'unknown | undefined' => 'unknown' (undefined lost in the process) + O.SelectKeys< + ATTRIBUTE['attributes'], + AnyAttribute & { required: AtLeastOnce | Never } + > + > + > + | SET> + : ATTRIBUTE extends RecordAttribute + ? + | NonVerbal< + { + [KEY in ResolvePrimitiveAttribute]?: + | AttributeUpdateItemInput< + ATTRIBUTE['elements'], + REQUIRED_DEFAULTS, + SCHEMA_ATTRIBUTE_PATHS + > + | $REMOVE + } + > + | SET> + : ATTRIBUTE extends AnyOfAttribute + ? AttributeUpdateItemInput< + ATTRIBUTE['elements'][number], + REQUIRED_DEFAULTS, + SCHEMA_ATTRIBUTE_PATHS + > + : never) diff --git a/src/v1/operations/updateItem/updateExpression/index.ts b/src/v1/operations/updateItem/updateExpression/index.ts new file mode 100644 index 000000000..17f341167 --- /dev/null +++ b/src/v1/operations/updateItem/updateExpression/index.ts @@ -0,0 +1,2 @@ +export type { ParsedUpdate } from './type' +export { parseUpdate } from './parse' diff --git a/src/v1/operations/updateItem/updateExpression/parse.ts b/src/v1/operations/updateItem/updateExpression/parse.ts new file mode 100644 index 000000000..f0511821e --- /dev/null +++ b/src/v1/operations/updateItem/updateExpression/parse.ts @@ -0,0 +1,20 @@ +import type { EntityV2 } from 'v1/entity' +import type { Item, Schema } from 'v1/schema' + +import type { UpdateItemInputExtension } from '../types' +import type { ParsedUpdate } from './type' +import { UpdateExpressionParser } from './parser' + +export const parseSchemaUpdate = ( + schema: SCHEMA, + input: Item +): ParsedUpdate => { + const updateParser = new UpdateExpressionParser(schema) + updateParser.parseUpdate(input) + return updateParser.toCommandOptions() +} + +export const parseUpdate = ( + entity: ENTITY, + input: Item +): ParsedUpdate => parseSchemaUpdate(entity.schema, input) diff --git a/src/v1/operations/updateItem/updateExpression/parser.ts b/src/v1/operations/updateItem/updateExpression/parser.ts new file mode 100644 index 000000000..7aa2feb7e --- /dev/null +++ b/src/v1/operations/updateItem/updateExpression/parser.ts @@ -0,0 +1,187 @@ +import type { NativeAttributeValue } from '@aws-sdk/util-dynamodb' + +import type { Schema, Attribute } from 'v1/schema' +import { isObject } from 'v1/utils/validation/isObject' +import { isArray } from 'v1/utils/validation/isArray' + +import type { UpdateItemInput, AttributeUpdateItemInput } from '../types' +import { $SET, $REMOVE, $SUM, $SUBTRACT, $ADD, $DELETE, $APPEND, $PREPEND } from '../constants' +import { + hasSetOperation, + hasGetOperation, + hasSumOperation, + hasSubtractOperation, + hasAddOperation, + hasDeleteOperation, + hasAppendOperation, + hasPrependOperation +} from '../utils' + +import type { ParsedUpdate } from './type' +import { UpdateExpressionVerbParser } from './verbParser' + +export class UpdateExpressionParser { + schema: Schema | Attribute + set: UpdateExpressionVerbParser + remove: UpdateExpressionVerbParser + add: UpdateExpressionVerbParser + delete: UpdateExpressionVerbParser + + constructor(schema: Schema | Attribute) { + this.schema = schema + this.set = new UpdateExpressionVerbParser(schema, 's') + this.remove = new UpdateExpressionVerbParser(schema, 'r') + this.add = new UpdateExpressionVerbParser(schema, 'a') + this.delete = new UpdateExpressionVerbParser(schema, 'd') + } + + parseUpdate = ( + input: UpdateItemInput | AttributeUpdateItemInput, + currentPath: (string | number)[] = [] + ): void => { + if (input === undefined) { + return + } + + if (hasSetOperation(input)) { + this.set.beginNewInstruction() + this.set.appendValidAttributePath(currentPath) + this.set.appendToExpression(' = ') + this.set.appendValidAttributeValue(input[$SET]) + return + } + + if (hasGetOperation(input)) { + this.set.beginNewInstruction() + this.set.appendValidAttributePath(currentPath) + this.set.appendToExpression(' = ') + this.set.appendValidAttributeValue(input) + return + } + + if (input === $REMOVE) { + this.remove.beginNewInstruction() + this.remove.appendValidAttributePath(currentPath) + return + } + + if (hasSumOperation(input)) { + const [a, b] = input[$SUM] + this.set.beginNewInstruction() + this.set.appendValidAttributePath(currentPath) + this.set.appendToExpression(' = ') + this.set.appendValidAttributeValue(a) + this.set.appendToExpression(' + ') + this.set.appendValidAttributeValue(b) + return + } + + if (hasSubtractOperation(input)) { + const [a, b] = input[$SUBTRACT] + this.set.beginNewInstruction() + this.set.appendValidAttributePath(currentPath) + this.set.appendToExpression(' = ') + this.set.appendValidAttributeValue(a) + this.set.appendToExpression(' - ') + this.set.appendValidAttributeValue(b) + return + } + + if (hasAddOperation(input)) { + this.add.beginNewInstruction() + this.add.appendValidAttributePath(currentPath) + this.add.appendToExpression(' ') + this.add.appendValidAttributeValue(input[$ADD]) + return + } + + if (hasDeleteOperation(input)) { + this.delete.beginNewInstruction() + this.delete.appendValidAttributePath(currentPath) + this.delete.appendToExpression(' ') + this.delete.appendValidAttributeValue(input[$DELETE]) + return + } + + if (hasAppendOperation(input)) { + this.set.beginNewInstruction() + this.set.appendValidAttributePath(currentPath) + this.set.appendToExpression(' = list_append(') + this.set.appendValidAttributePath(currentPath) + this.set.appendToExpression(', ') + this.set.appendValidAttributeValue(input[$APPEND]) + this.set.appendToExpression(')') + return + } + + if (hasPrependOperation(input)) { + this.set.beginNewInstruction() + this.set.appendValidAttributePath(currentPath) + this.set.appendToExpression(' = list_append(') + this.set.appendValidAttributeValue(input[$PREPEND]) + this.set.appendToExpression(', ') + this.set.appendValidAttributePath(currentPath) + this.set.appendToExpression(')') + return + } + + if (isObject(input)) { + for (const [key, value] of Object.entries(input)) { + this.parseUpdate(value, [...currentPath, key]) + } + return + } + + if (isArray(input)) { + input.forEach((element, index) => { + if (element === undefined) { + return + } + + this.parseUpdate(element, [...currentPath, index]) + }) + + return + } + + this.set.beginNewInstruction() + this.set.appendValidAttributePath(currentPath) + this.set.appendToExpression(' = ') + this.set.appendValidAttributeValue(input) + } + + toCommandOptions = (): ParsedUpdate => { + let UpdateExpression = '' + const ExpressionAttributeNames: Record = {} + const ExpressionAttributeValues: Record = {} + + for (const [verb, parser] of [ + ['SET', this.set], + ['REMOVE', this.remove], + ['ADD', this.add], + ['DELETE', this.delete] + ] as const) { + const verbCommandOptions = parser.toCommandOptions() + + if (verbCommandOptions.UpdateExpression === '') { + continue + } + + if (UpdateExpression !== '') { + UpdateExpression += ' ' + } + UpdateExpression += verb + UpdateExpression += ' ' + UpdateExpression += verbCommandOptions.UpdateExpression + + Object.assign(ExpressionAttributeNames, verbCommandOptions.ExpressionAttributeNames) + Object.assign(ExpressionAttributeValues, verbCommandOptions.ExpressionAttributeValues) + } + + return { + UpdateExpression, + ExpressionAttributeNames, + ExpressionAttributeValues + } + } +} diff --git a/src/v1/operations/updateItem/updateExpression/type.ts b/src/v1/operations/updateItem/updateExpression/type.ts new file mode 100644 index 000000000..5f72a6d52 --- /dev/null +++ b/src/v1/operations/updateItem/updateExpression/type.ts @@ -0,0 +1,6 @@ +import type { UpdateCommandInput } from '@aws-sdk/lib-dynamodb' + +export type ParsedUpdate = Pick< + UpdateCommandInput, + 'UpdateExpression' | 'ExpressionAttributeNames' | 'ExpressionAttributeValues' +> diff --git a/src/v1/operations/updateItem/updateExpression/verbParser.ts b/src/v1/operations/updateItem/updateExpression/verbParser.ts new file mode 100644 index 000000000..e2eccf924 --- /dev/null +++ b/src/v1/operations/updateItem/updateExpression/verbParser.ts @@ -0,0 +1,150 @@ +import type { Schema, Attribute, AttributeValue } from 'v1/schema' +import { isNumber, isString } from 'v1/utils/validation' + +import { + ExpressionParser, + appendAttributePath, + AppendAttributePathOptions +} from 'v1/operations/expression/expressionParser' + +import type { UpdateItemInputExtension } from '../types' +import { hasGetOperation } from '../utils' +import { $GET } from '../constants' +import type { ParsedUpdate } from './type' + +export class UpdateExpressionVerbParser implements ExpressionParser { + schema: Schema | Attribute + verbPrefix: 's' | 'r' | 'a' | 'd' + expressionAttributePrefix: `${'s' | 'r' | 'a' | 'd'}${string}_` + expressionAttributeNames: string[] + expressionAttributeValues: unknown[] + expression: string + id: string + + constructor(schema: Schema | Attribute, verbPrefix: 's' | 'r' | 'a' | 'd', id = '') { + this.schema = schema + this.verbPrefix = verbPrefix + this.expressionAttributePrefix = `${verbPrefix}${id}_` + this.expressionAttributeNames = [] + this.expressionAttributeValues = [] + this.expression = '' + this.id = id + } + + resetExpression = (initialStr = '') => { + this.expression = initialStr + } + + appendAttributePath = ( + attributePath: string, + options: AppendAttributePathOptions = {} + ): Attribute => appendAttributePath(this, attributePath, options) + + appendAttributeValue = (_: Attribute, attributeValue: unknown): void => { + const expressionAttributeValueIndex = this.expressionAttributeValues.push(attributeValue) + + this.appendToExpression(`:${this.expressionAttributePrefix}${expressionAttributeValueIndex}`) + } + + beginNewInstruction = () => { + if (this.expression !== '') { + this.appendToExpression(', ') + } + } + + appendValidAttributePath = (validAttributePath: (string | number)[]): void => { + validAttributePath.forEach((pathPart, index) => { + if (isString(pathPart)) { + let pathPartIndex = this.expressionAttributeNames.findIndex(value => value === pathPart) + + if (pathPartIndex !== -1) { + pathPartIndex += 1 + } else { + pathPartIndex = this.expressionAttributeNames.push(pathPart) + } + + if (index > 0) { + this.appendToExpression('.') + } + + this.appendToExpression(`#${this.expressionAttributePrefix}${pathPartIndex}`) + } + + if (isNumber(pathPart)) { + this.appendToExpression(`[${pathPart}]`) + } + }) + } + + appendValidAttributeValue = ( + validAttributeValue: AttributeValue + ): void => { + if (hasGetOperation(validAttributeValue)) { + const [expression, fallback] = validAttributeValue[$GET] + + if (fallback === undefined) { + this.appendAttributePath(expression) + return + } + + if (fallback !== undefined) { + this.appendToExpression('if_not_exists(') + this.appendAttributePath(expression) + this.appendToExpression(', ') + this.appendValidAttributeValue(fallback) + this.appendToExpression(')') + return + } + } + + const expressionAttributeValueIndex = this.expressionAttributeValues.push(validAttributeValue) + + this.appendToExpression(`:${this.expressionAttributePrefix}${expressionAttributeValueIndex}`) + } + + appendToExpression = (conditionExpressionPart: string) => { + this.expression += conditionExpressionPart + } + + /** + * @debt refactor "factorize with other expressions" + */ + toCommandOptions = (): ParsedUpdate => { + const ExpressionAttributeNames: ParsedUpdate['ExpressionAttributeNames'] = {} + + this.expressionAttributeNames.forEach((expressionAttributeName, index) => { + ExpressionAttributeNames[ + `#${this.expressionAttributePrefix}${index + 1}` + ] = expressionAttributeName + }) + + const ExpressionAttributeValues: ParsedUpdate['ExpressionAttributeValues'] = {} + this.expressionAttributeValues.forEach((expressionAttributeValue, index) => { + ExpressionAttributeValues[ + `:${this.expressionAttributePrefix}${index + 1}` + ] = expressionAttributeValue + }) + + const UpdateExpression = this.expression + + return { + ExpressionAttributeNames, + ExpressionAttributeValues, + UpdateExpression + } + } + + clone = (schema?: Schema | Attribute): UpdateExpressionVerbParser => { + const clonedParser = new UpdateExpressionVerbParser( + schema ?? this.schema, + this.verbPrefix, + this.id + ) + + clonedParser.expressionAttributeNames = [...this.expressionAttributeNames] + clonedParser.expressionAttributeValues = [...this.expressionAttributeValues] + clonedParser.expression = this.expression + + return clonedParser + } +} diff --git a/src/v1/operations/updateItem/updateItemParams/extension/parseExtension/attribute.ts b/src/v1/operations/updateItem/updateItemParams/extension/parseExtension/attribute.ts new file mode 100644 index 000000000..4648fd729 --- /dev/null +++ b/src/v1/operations/updateItem/updateItemParams/extension/parseExtension/attribute.ts @@ -0,0 +1,72 @@ +import type { ExtensionParser } from 'v1/validation/parseClonedInput/types' +import type { PrimitiveAttribute } from 'v1/schema' +import { DynamoDBToolboxError } from 'v1/errors' + +import type { UpdateItemInputExtension } from 'v1/operations/updateItem/types' +import { $REMOVE } from 'v1/operations/updateItem/constants' +import { hasGetOperation } from 'v1/operations/updateItem/utils' + +import { parseNumberExtension } from './number' +import { parseSetExtension } from './set' +import { parseListExtension } from './list' +import { parseMapExtension } from './map' +import { parseRecordExtension } from './record' +import { parseReferenceExtension } from './reference' + +export const parseUpdateExtension: ExtensionParser = ( + attribute, + input, + options +) => { + if (input === $REMOVE) { + return { + isExtension: true, + *extensionParser() { + const clonedValue: typeof $REMOVE = input + yield clonedValue + + if (attribute.required !== 'never') { + throw new DynamoDBToolboxError('parsing.attributeRequired', { + message: `Attribute ${attribute.path} is required and cannot be removed`, + path: attribute.path + }) + } + + const parsedValue: typeof $REMOVE = clonedValue + yield parsedValue + + const collapsedValue: typeof $REMOVE = parsedValue + return collapsedValue + } + } + } + + /** + * @debt refactor "Maybe we can simply parse a super-extension here, and continue if is(Super)Extension is false. Would be neat." + */ + if (hasGetOperation(input)) { + return parseReferenceExtension(attribute, input, { + ...options, + // Can be a reference + parseExtension: parseReferenceExtension + }) + } + + switch (attribute.type) { + case 'number': + /** + * @debt type "fix this cast" + */ + return parseNumberExtension(attribute as PrimitiveAttribute<'number'>, input, options) + case 'set': + return parseSetExtension(attribute, input, options) + case 'list': + return parseListExtension(attribute, input, options) + case 'map': + return parseMapExtension(attribute, input, options) + case 'record': + return parseRecordExtension(attribute, input, options) + default: + return { isExtension: false, basicInput: input } + } +} diff --git a/src/v1/operations/updateItem/updateItemParams/extension/parseExtension/index.ts b/src/v1/operations/updateItem/updateItemParams/extension/parseExtension/index.ts new file mode 100644 index 000000000..4e0e73779 --- /dev/null +++ b/src/v1/operations/updateItem/updateItemParams/extension/parseExtension/index.ts @@ -0,0 +1 @@ +export { parseUpdateExtension } from './attribute' diff --git a/src/v1/operations/updateItem/updateItemParams/extension/parseExtension/list.ts b/src/v1/operations/updateItem/updateItemParams/extension/parseExtension/list.ts new file mode 100644 index 000000000..e11e58771 --- /dev/null +++ b/src/v1/operations/updateItem/updateItemParams/extension/parseExtension/list.ts @@ -0,0 +1,249 @@ +import type { AttributeBasicValue, AttributeValue, ListAttribute } from 'v1/schema' +import type { ExtensionParser, ParsingOptions } from 'v1/validation/parseClonedInput/types' +import { parseAttributeClonedInput } from 'v1/validation/parseClonedInput/attribute' +import { DynamoDBToolboxError } from 'v1/errors' +import { isObject } from 'v1/utils/validation/isObject' +import { isInteger } from 'v1/utils/validation/isInteger' +import { isArray } from 'v1/utils/validation/isArray' + +import type { ReferenceExtension, UpdateItemInputExtension } from 'v1/operations/updateItem/types' +import { $SET, $REMOVE, $APPEND, $PREPEND } from 'v1/operations/updateItem/constants' +import { + hasSetOperation, + hasAppendOperation, + hasPrependOperation +} from 'v1/operations/updateItem/utils' + +import { parseReferenceExtension } from './reference' + +function* parseListElementClonedInput( + attribute: ListAttribute, + inputValue: AttributeValue | undefined, + options: ParsingOptions +): Generator< + AttributeValue | undefined, + AttributeValue | undefined +> { + // $REMOVE is allowed (we need this as elements 'required' prop is defaulted to "atLeastOnce") + if (inputValue === $REMOVE) { + const clonedValue: typeof $REMOVE = $REMOVE + yield clonedValue + + const parsedValue: typeof $REMOVE = clonedValue + yield parsedValue + + const collapsedValue: typeof $REMOVE = parsedValue + return collapsedValue + } + + if (inputValue === undefined) { + const clonedValue = undefined + yield clonedValue + + const parsedValue = clonedValue + yield parsedValue + + const collapsedValue = parsedValue + return collapsedValue + } + + return yield* parseAttributeClonedInput(attribute.elements, inputValue, options) +} + +export const parseListExtension = ( + attribute: ListAttribute, + input: AttributeValue | undefined, + options: ParsingOptions +): ReturnType> => { + if (hasSetOperation(input)) { + return { + isExtension: true, + *extensionParser() { + const parser = parseAttributeClonedInput(attribute, input[$SET], { + ...options, + // Should a simple list of valid elements (not extended) + parseExtension: undefined + }) + + const clonedValue = { [$SET]: parser.next().value } + yield clonedValue + + const parsedValue = { [$SET]: parser.next().value } + yield parsedValue + + const collapsedValue = { [$SET]: parser.next().value } + return collapsedValue + } + } + } + + if (isObject(input) || isArray(input)) { + if (hasAppendOperation(input)) { + const appendedValue = input[$APPEND] + + if (isArray(appendedValue)) { + return { + isExtension: true, + *extensionParser() { + /** + * @debt type "TODO: fix this cast" + */ + const parsers = (appendedValue as AttributeValue[]).map(element => + parseAttributeClonedInput( + attribute.elements, + element, + // Should a simple list of valid elements (not extended) + { ...options, parseExtension: undefined } + ) + ) + + const clonedValue = { [$APPEND]: parsers.map(parser => parser.next().value) } + yield clonedValue + + const parsedValue = { [$APPEND]: parsers.map(parser => parser.next().value) } + yield parsedValue + + const collapsedValue = { [$APPEND]: parsers.map(parser => parser.next().value) } + return collapsedValue + } + } + } + + return { + isExtension: true, + *extensionParser() { + const parser = parseAttributeClonedInput( + attribute, + appendedValue, + // Can be a reference + { ...options, parseExtension: parseReferenceExtension } + ) + + const clonedValue = { [$APPEND]: parser.next().value } + yield clonedValue + + const parsedValue = { [$APPEND]: parser.next().value } + yield parsedValue + + const collapsedValue = { [$APPEND]: parser.next().value } + return collapsedValue + } + } + } + + if (hasPrependOperation(input)) { + const prependedValue = input[$PREPEND] + + if (isArray(prependedValue)) { + return { + isExtension: true, + *extensionParser() { + /** + * @debt type "TODO: fix this cast" + */ + const parsers = (prependedValue as AttributeValue[]).map(element => + parseAttributeClonedInput( + attribute.elements, + element, + // Should a simple list of valid elements (not extended) + { ...options, parseExtension: undefined } + ) + ) + + const clonedValue = { [$PREPEND]: parsers.map(parser => parser.next().value) } + yield clonedValue + + const parsedValue = { [$PREPEND]: parsers.map(parser => parser.next().value) } + yield parsedValue + + const collapsedValue = { [$PREPEND]: parsers.map(parser => parser.next().value) } + return collapsedValue + } + } + } + + return { + isExtension: true, + *extensionParser() { + const parser = parseAttributeClonedInput( + attribute, + prependedValue, + // Can be a reference + { ...options, parseExtension: parseReferenceExtension } + ) + + const clonedValue = { [$PREPEND]: parser.next().value } + yield clonedValue + + const parsedValue = { [$PREPEND]: parser.next().value } + yield parsedValue + + const collapsedValue = { [$PREPEND]: parser.next().value } + return collapsedValue + } + } + } + + return { + isExtension: true, + *extensionParser() { + let maxUpdatedIndex = 0 + const parsers: { + [KEY in number]: Generator< + AttributeValue, + AttributeValue + > + } = Object.fromEntries( + Object.entries(input) + .map(([index, element]) => [ + index, + parseListElementClonedInput(attribute, element, options) + ]) + .filter(([, element]) => element !== undefined) + ) + + const clonedValue = Object.fromEntries( + Object.entries(parsers) + .map(([index, parser]) => [index, parser.next().value]) + .filter(([, element]) => element !== undefined) + ) + yield clonedValue + + for (const inputKey of Object.keys(parsers)) { + const parsedInputKey = parseFloat(inputKey) + + if (!isInteger(parsedInputKey)) { + throw new DynamoDBToolboxError('parsing.invalidAttributeInput', { + message: `Index of array attribute ${attribute.path} is not a valid integer`, + path: attribute.path, + payload: { + received: inputKey + } + }) + } + + maxUpdatedIndex = Math.max(maxUpdatedIndex, parsedInputKey) + } + + const parsedValue = Object.fromEntries( + Object.entries(parsers) + .map(([index, parser]) => [index, parser.next().value]) + .filter(([, element]) => element !== undefined) + ) + yield parsedValue + + const collapsedValue = [...Array(maxUpdatedIndex + 1).keys()].map(index => { + const parser = parsers[index] + + return parser === undefined ? undefined : parser.next().value + }) + return collapsedValue + } + } + } + + return { + isExtension: false, + basicInput: input as AttributeBasicValue | undefined + } +} diff --git a/src/v1/operations/updateItem/updateItemParams/extension/parseExtension/map.ts b/src/v1/operations/updateItem/updateItemParams/extension/parseExtension/map.ts new file mode 100644 index 000000000..0347a01f6 --- /dev/null +++ b/src/v1/operations/updateItem/updateItemParams/extension/parseExtension/map.ts @@ -0,0 +1,41 @@ +import type { AttributeValue, AttributeBasicValue, MapAttribute } from 'v1/schema' +import type { ExtensionParser, ParsingOptions } from 'v1/validation/parseClonedInput/types' +import { parseAttributeClonedInput } from 'v1/validation/parseClonedInput' + +import type { UpdateItemInputExtension } from 'v1/operations/updateItem/types' +import { $SET } from 'v1/operations/updateItem/constants' +import { hasSetOperation } from 'v1/operations/updateItem/utils' + +export const parseMapExtension = ( + attribute: MapAttribute, + input: AttributeValue | undefined, + options: ParsingOptions +): ReturnType> => { + if (hasSetOperation(input)) { + return { + isExtension: true, + *extensionParser() { + const parser = parseAttributeClonedInput( + attribute, + input[$SET], + // Should a simple map of valid elements (not extended) + { ...options, parseExtension: undefined } + ) + + const clonedValue = { [$SET]: parser.next().value } + yield clonedValue + + const parsedValue = { [$SET]: parser.next().value } + yield parsedValue + + const collapsedValue = { [$SET]: parser.next().value } + return collapsedValue + } + } + } + + return { + isExtension: false, + basicInput: input as AttributeBasicValue | undefined + } +} diff --git a/src/v1/operations/updateItem/updateItemParams/extension/parseExtension/number.ts b/src/v1/operations/updateItem/updateItemParams/extension/parseExtension/number.ts new file mode 100644 index 000000000..69cfbe8bf --- /dev/null +++ b/src/v1/operations/updateItem/updateItemParams/extension/parseExtension/number.ts @@ -0,0 +1,151 @@ +import cloneDeep from 'lodash.clonedeep' + +import type { AttributeBasicValue, AttributeValue, PrimitiveAttribute } from 'v1/schema' +import type { ExtensionParser, ParsingOptions } from 'v1/validation/parseClonedInput/types' +import { parseAttributeClonedInput } from 'v1/validation/parseClonedInput/attribute' +import { isArray } from 'v1/utils/validation/isArray' +import { DynamoDBToolboxError } from 'v1/errors' + +import type { ReferenceExtension, UpdateItemInputExtension } from 'v1/operations/updateItem/types' +import { $SUM, $SUBTRACT, $ADD } from 'v1/operations/updateItem/constants' +import { + hasSumOperation, + hasSubtractOperation, + hasAddOperation +} from 'v1/operations/updateItem/utils' + +import { parseReferenceExtension } from './reference' + +const ACCEPTABLE_LENGTH_SET = new Set([1, 2]) + +export const parseNumberExtension = ( + attribute: PrimitiveAttribute<'number'>, + inputValue: AttributeValue | undefined, + options: ParsingOptions +): ReturnType> => { + if (hasSumOperation(inputValue)) { + return { + isExtension: true, + *extensionParser() { + const parsers: Generator< + AttributeValue, + AttributeValue + >[] = [] + + const isInputValueArray = isArray(inputValue[$SUM]) + if (isInputValueArray) { + for (const sumElement of inputValue[$SUM]) { + parsers.push( + parseAttributeClonedInput( + attribute, + sumElement, + // References are allowed in sums + { ...options, parseExtension: parseReferenceExtension } + ) + ) + } + } + + const clonedValue = { + [$SUM]: isInputValueArray + ? parsers.map(parser => parser.next().value) + : cloneDeep(inputValue[$SUM]) + } + yield clonedValue + + if (!isInputValueArray || !ACCEPTABLE_LENGTH_SET.has(inputValue[$SUM].length)) { + throw new DynamoDBToolboxError('parsing.invalidAttributeInput', { + message: `Sum for number attribute ${attribute.path} should be a tuple of length 1 or 2`, + path: attribute.path, + payload: { + received: inputValue[$SUM] + } + }) + } + + const parsedValue = { [$SUM]: parsers.map(parser => parser.next().value) } + yield parsedValue + + const collapsedValue = { [$SUM]: parsers.map(parser => parser.next().value) } + return collapsedValue + } + } + } + + if (hasSubtractOperation(inputValue)) { + return { + isExtension: true, + *extensionParser() { + const parsers: Generator< + AttributeValue, + AttributeValue + >[] = [] + + const isInputValueArray = isArray(inputValue[$SUBTRACT]) + if (isInputValueArray) { + for (const sumElement of inputValue[$SUBTRACT]) { + parsers.push( + parseAttributeClonedInput( + attribute, + sumElement, + // References are allowed in sums + { ...options, parseExtension: parseReferenceExtension } + ) + ) + } + } + + const clonedValue = { + [$SUBTRACT]: isInputValueArray + ? parsers.map(parser => parser.next().value) + : cloneDeep(inputValue[$SUBTRACT]) + } + yield clonedValue + + if (!isInputValueArray || !ACCEPTABLE_LENGTH_SET.has(inputValue[$SUBTRACT].length)) { + throw new DynamoDBToolboxError('parsing.invalidAttributeInput', { + message: `Subtraction for number attribute ${attribute.path} should be a tuple of length 1 or 2`, + path: attribute.path, + payload: { + received: inputValue[$SUBTRACT] + } + }) + } + + const parsedValue = { [$SUBTRACT]: parsers.map(parser => parser.next().value) } + yield parsedValue + + const collapsedValue = { [$SUBTRACT]: parsers.map(parser => parser.next().value) } + return collapsedValue + } + } + } + + if (hasAddOperation(inputValue)) { + const parser = parseAttributeClonedInput( + attribute, + inputValue[$ADD], + // References are allowed in additions + { ...options, parseExtension: parseReferenceExtension } + ) + + return { + isExtension: true, + *extensionParser() { + const clonedValue = { [$ADD]: parser.next().value } + yield clonedValue + + const parsedValue = { [$ADD]: parser.next().value } + yield parsedValue + + const collapsedValue = { [$ADD]: parser.next().value } + return collapsedValue + } + } + } + + return { + isExtension: false, + basicInput: inputValue as AttributeBasicValue | undefined + } +} diff --git a/src/v1/operations/updateItem/updateItemParams/extension/parseExtension/record.ts b/src/v1/operations/updateItem/updateItemParams/extension/parseExtension/record.ts new file mode 100644 index 000000000..d12b6425c --- /dev/null +++ b/src/v1/operations/updateItem/updateItemParams/extension/parseExtension/record.ts @@ -0,0 +1,125 @@ +import type { AttributeBasicValue, AttributeValue, RecordAttribute } from 'v1/schema' +import type { ExtensionParser, ParsingOptions } from 'v1/validation/parseClonedInput/types' +import { parseAttributeClonedInput } from 'v1/validation/parseClonedInput' +import { isObject } from 'v1/utils/validation/isObject' + +import type { UpdateItemInputExtension } from 'v1/operations/updateItem/types' +import { $SET, $REMOVE } from 'v1/operations/updateItem/constants' +import { hasSetOperation } from 'v1/operations/updateItem/utils' + +function* parseRecordElementClonedInput( + attribute: RecordAttribute, + inputValue: AttributeValue, + options: ParsingOptions +): Generator, AttributeValue> { + // $REMOVE is allowed (we need this as elements 'required' prop is defaulted to "atLeastOnce") + if (inputValue === $REMOVE) { + const clonedValue: typeof $REMOVE = $REMOVE + yield clonedValue + + const parsedValue: typeof $REMOVE = clonedValue + yield parsedValue + + const collapsedValue: typeof $REMOVE = parsedValue + return collapsedValue + } + + return yield* parseAttributeClonedInput(attribute.elements, inputValue, options) +} + +export const parseRecordExtension = ( + attribute: RecordAttribute, + input: AttributeValue | undefined, + options: ParsingOptions +): ReturnType> => { + if (hasSetOperation(input)) { + return { + isExtension: true, + *extensionParser() { + const parser = parseAttributeClonedInput(attribute, input[$SET], { + ...options, + // Should a simple record of valid elements (not extended) + parseExtension: undefined + }) + + const clonedValue = { [$SET]: parser.next().value } + yield clonedValue + + const parsedValue = { [$SET]: parser.next().value } + yield parsedValue + + const collapsedValue = { [$SET]: parser.next().value } + return collapsedValue + } + } + } + + if (isObject(input)) { + return { + isExtension: true, + *extensionParser() { + const parsers: [ + Generator< + AttributeValue, + AttributeValue + >, + Generator< + AttributeValue, + AttributeValue + > + ][] = Object.entries(input) + .filter(([, inputValue]) => inputValue !== undefined) + .map(([inputKey, inputValue]) => [ + parseAttributeClonedInput(attribute.keys, inputKey, { + ...options, + // Should a simple string (not extended) + parseExtension: undefined + }), + parseRecordElementClonedInput( + attribute, + /** + * @debt type "TODO: Fix this cast" + */ + inputValue as AttributeValue, + options + ) + ]) + + const clonedValue = Object.fromEntries( + parsers + .map(([keyParser, elementParser]) => [ + keyParser.next().value, + elementParser.next().value + ]) + .filter(([, element]) => element !== undefined) + ) + yield clonedValue + + const parsedValue = Object.fromEntries( + parsers + .map(([keyParser, elementParser]) => [ + keyParser.next().value, + elementParser.next().value + ]) + .filter(([, element]) => element !== undefined) + ) + yield parsedValue + + const collapsedValue = Object.fromEntries( + parsers + .map(([keyParser, elementParser]) => [ + keyParser.next().value, + elementParser.next().value + ]) + .filter(([, element]) => element !== undefined) + ) + return collapsedValue + } + } + } + + return { + isExtension: false, + basicInput: input as AttributeBasicValue | undefined + } +} diff --git a/src/v1/operations/updateItem/updateItemParams/extension/parseExtension/reference.ts b/src/v1/operations/updateItem/updateItemParams/extension/parseExtension/reference.ts new file mode 100644 index 000000000..2962af940 --- /dev/null +++ b/src/v1/operations/updateItem/updateItemParams/extension/parseExtension/reference.ts @@ -0,0 +1,100 @@ +import type { Attribute, AttributeValue } from 'v1/schema' +import type { ReferenceExtension } from 'v1/operations/types' +import type { ExtensionParser } from 'v1/validation/parseClonedInput/types' +import { isArray } from 'v1/utils/validation/isArray' +import { parseAttributeClonedInput } from 'v1/validation/parseClonedInput/attribute' +import { DynamoDBToolboxError } from 'v1/errors' + +import type { UpdateItemInputExtension } from 'v1/operations/updateItem/types' +import { $GET } from 'v1/operations/updateItem/constants' +import { hasGetOperation } from 'v1/operations/updateItem/utils' +import cloneDeep from 'lodash.clonedeep' +import { isString } from 'v1/utils/validation' + +export const parseReferenceExtension: ExtensionParser< + ReferenceExtension, + UpdateItemInputExtension +> = (attribute, inputValue, options) => { + if (hasGetOperation(inputValue)) { + return { + isExtension: true, + *extensionParser() { + const isInputValueArray = isArray(inputValue[$GET]) + let reference: string | undefined = undefined + let fallbackParser: + | Generator, AttributeValue> + | undefined = undefined + + if (isInputValueArray) { + const [_reference, fallback, ...rest] = inputValue[$GET] + reference = _reference + + if (fallback !== undefined) { + fallbackParser = parseAttributeClonedInput(attribute, fallback, options) + } + + const clonedValue = { + [$GET]: [ + cloneDeep(reference), + ...[ + fallbackParser !== undefined + ? [fallbackParser.next().value] + : rest.length === 0 + ? [] + : [undefined] + ], + ...cloneDeep(rest) + ] + } + yield clonedValue + } else { + const clonedValue = { [$GET]: cloneDeep(inputValue[$GET]) } + yield clonedValue + } + + if (!isInputValueArray) { + throw new DynamoDBToolboxError('parsing.invalidAttributeInput', { + message: `Reference for attribute ${attribute.path} should be a tuple of one or two elements`, + path: attribute.path, + payload: { + received: inputValue[$GET] + } + }) + } + + if (!isString(reference)) { + throw new DynamoDBToolboxError('parsing.invalidAttributeInput', { + message: `First element of a reference for attribute ${attribute.path} should be a string`, + path: attribute.path, + payload: { + received: inputValue[$GET][0] + } + }) + } + + const parsedValue = { + [$GET]: [ + // NOTE: Reference validation will be done in UpdateExpressionParser + reference, + ...(fallbackParser !== undefined ? [fallbackParser.next().value] : []) + ] + } + yield parsedValue + + const collapsedValue = { + [$GET]: [ + // NOTE: Reference validation will be done in UpdateExpressionParser + reference, + ...(fallbackParser !== undefined ? [fallbackParser.next().value] : []) + ] + } + return collapsedValue + } + } + } + + return { + isExtension: false, + basicInput: inputValue + } +} diff --git a/src/v1/operations/updateItem/updateItemParams/extension/parseExtension/set.ts b/src/v1/operations/updateItem/updateItemParams/extension/parseExtension/set.ts new file mode 100644 index 000000000..4f0938e76 --- /dev/null +++ b/src/v1/operations/updateItem/updateItemParams/extension/parseExtension/set.ts @@ -0,0 +1,62 @@ +import type { AttributeBasicValue, AttributeValue, SetAttribute } from 'v1/schema' +import type { ExtensionParser, ParsingOptions } from 'v1/validation/parseClonedInput/types' +import { parseAttributeClonedInput } from 'v1/validation/parseClonedInput/attribute' + +import type { UpdateItemInputExtension } from 'v1/operations/updateItem/types' +import { $ADD, $DELETE } from 'v1/operations/updateItem/constants' +import { hasAddOperation, hasDeleteOperation } from 'v1/operations/updateItem/utils' + +export const parseSetExtension = ( + attribute: SetAttribute, + input: AttributeValue | undefined, + options: ParsingOptions +): ReturnType> => { + if (hasAddOperation(input)) { + return { + isExtension: true, + *extensionParser() { + const parser = parseAttributeClonedInput(attribute, input[$ADD], { + ...options, + // Should a simple set of valid elements (not extended) + parseExtension: undefined + }) + + const clonedValue = { [$ADD]: parser.next().value } + yield clonedValue + + const parsedValue = { [$ADD]: parser.next().value } + yield parsedValue + + const collapsedValue = { [$ADD]: parser.next().value } + return collapsedValue + } + } + } + + if (hasDeleteOperation(input)) { + return { + isExtension: true, + *extensionParser() { + const parser = parseAttributeClonedInput(attribute, input[$DELETE], { + ...options, + // Should a simple set of valid elements (not extended) + parseExtension: undefined + }) + + const clonedValue = { [$DELETE]: parser.next().value } + yield clonedValue + + const parsedValue = { [$DELETE]: parser.next().value } + yield parsedValue + + const collapsedValue = { [$DELETE]: parser.next().value } + return collapsedValue + } + } + } + + return { + isExtension: false, + basicInput: input as AttributeBasicValue | undefined + } +} diff --git a/src/v1/operations/updateItem/updateItemParams/index.ts b/src/v1/operations/updateItem/updateItemParams/index.ts new file mode 100644 index 000000000..674488b3c --- /dev/null +++ b/src/v1/operations/updateItem/updateItemParams/index.ts @@ -0,0 +1 @@ +export { updateItemParams } from './updateItemParams' diff --git a/src/v1/operations/updateItem/updateItemParams/parseUpdateCommandInput.ts b/src/v1/operations/updateItem/updateItemParams/parseUpdateCommandInput.ts new file mode 100644 index 000000000..cb53fb6af --- /dev/null +++ b/src/v1/operations/updateItem/updateItemParams/parseUpdateCommandInput.ts @@ -0,0 +1,25 @@ +import type { EntityV2 } from 'v1/entity' +import type { Item, RequiredOption } from 'v1/schema' +import { parseSchemaClonedInput } from 'v1/validation/parseClonedInput' + +import type { UpdateItemInputExtension } from '../types' +import { parseUpdateExtension } from './extension/parseExtension' + +type EntityUpdateCommandInputParser = ( + entity: EntityV2, + input: Item +) => Generator, Item> + +const requiringOptions = new Set(['always']) + +export const parseEntityUpdateCommandInput: EntityUpdateCommandInputParser = (entity, input) => { + const parser = parseSchemaClonedInput(entity.schema, input, { + operationName: 'update', + requiringOptions, + parseExtension: parseUpdateExtension + }) + + parser.next() // cloned + + return parser +} diff --git a/src/v1/operations/updateItem/updateItemParams/parseUpdateItemOptions.ts b/src/v1/operations/updateItem/updateItemParams/parseUpdateItemOptions.ts new file mode 100644 index 000000000..a9cc46ad9 --- /dev/null +++ b/src/v1/operations/updateItem/updateItemParams/parseUpdateItemOptions.ts @@ -0,0 +1,51 @@ +import type { UpdateCommandInput } from '@aws-sdk/lib-dynamodb' + +import type { EntityV2 } from 'v1/entity' +import { parseCapacityOption } from 'v1/operations/utils/parseOptions/parseCapacityOption' +import { parseMetricsOption } from 'v1/operations/utils/parseOptions/parseMetricsOption' +import { parseReturnValuesOption } from 'v1/operations/utils/parseOptions/parseReturnValuesOption' +import { rejectExtraOptions } from 'v1/operations/utils/parseOptions/rejectExtraOptions' +import { parseCondition } from 'v1/operations/expression/condition/parse' + +import { updateItemCommandReturnValuesOptionsSet, UpdateItemOptions } from '../options' + +type CommandOptions = Omit + +export const parseUpdateItemOptions = ( + entity: ENTITY, + updateItemOptions: UpdateItemOptions +): CommandOptions => { + const commandOptions: CommandOptions = {} + + const { capacity, metrics, returnValues, condition, ...extraOptions } = updateItemOptions + rejectExtraOptions(extraOptions) + + if (capacity !== undefined) { + commandOptions.ReturnConsumedCapacity = parseCapacityOption(capacity) + } + + if (metrics !== undefined) { + commandOptions.ReturnItemCollectionMetrics = parseMetricsOption(metrics) + } + + if (returnValues !== undefined) { + commandOptions.ReturnValues = parseReturnValuesOption( + updateItemCommandReturnValuesOptionsSet, + returnValues + ) + } + + if (condition !== undefined) { + const { + ExpressionAttributeNames, + ExpressionAttributeValues, + ConditionExpression + } = parseCondition(entity, condition) + + commandOptions.ExpressionAttributeNames = ExpressionAttributeNames + commandOptions.ExpressionAttributeValues = ExpressionAttributeValues + commandOptions.ConditionExpression = ConditionExpression + } + + return commandOptions +} diff --git a/src/v1/operations/updateItem/updateItemParams/updateItemParams.ts b/src/v1/operations/updateItem/updateItemParams/updateItemParams.ts new file mode 100644 index 000000000..76c5aadd5 --- /dev/null +++ b/src/v1/operations/updateItem/updateItemParams/updateItemParams.ts @@ -0,0 +1,60 @@ +import type { UpdateCommandInput } from '@aws-sdk/lib-dynamodb' +import isEmpty from 'lodash.isempty' +import omit from 'lodash.omit' + +import type { EntityV2 } from 'v1/entity' +import { parsePrimaryKey } from 'v1/operations/utils/parsePrimaryKey' + +import type { UpdateItemInput } from '../types' +import type { UpdateItemOptions } from '../options' +import { parseUpdate } from '../updateExpression' + +import { parseEntityUpdateCommandInput } from './parseUpdateCommandInput' +import { parseUpdateItemOptions } from './parseUpdateItemOptions' + +export const updateItemParams = < + ENTITY extends EntityV2, + OPTIONS extends UpdateItemOptions +>( + entity: ENTITY, + input: UpdateItemInput, + updateItemOptions: OPTIONS = {} as OPTIONS +): UpdateCommandInput => { + const validInputParser = parseEntityUpdateCommandInput(entity, input) + const validInput = validInputParser.next().value + const collapsedInput = validInputParser.next().value + + const keyInput = entity.computeKey ? entity.computeKey(validInput) : collapsedInput + const primaryKey = parsePrimaryKey(entity, keyInput) + + const { + ExpressionAttributeNames: updateExpressionAttributeNames, + ExpressionAttributeValues: updateExpressionAttributeValues, + ...update + } = parseUpdate(entity, omit(collapsedInput, Object.keys(primaryKey))) + + const { + ExpressionAttributeNames: optionsExpressionAttributeNames, + ExpressionAttributeValues: optionsExpressionAttributeValues, + ...options + } = parseUpdateItemOptions(entity, updateItemOptions) + + const ExpressionAttributeNames = { + ...optionsExpressionAttributeNames, + ...updateExpressionAttributeNames + } + + const ExpressionAttributeValues = { + ...optionsExpressionAttributeValues, + ...updateExpressionAttributeValues + } + + return { + TableName: entity.table.getName(), + Key: primaryKey, + ...update, + ...options, + ...(!isEmpty(ExpressionAttributeNames) ? { ExpressionAttributeNames } : {}), + ...(!isEmpty(ExpressionAttributeValues) ? { ExpressionAttributeValues } : {}) + } +} diff --git a/src/v1/operations/updateItem/updateItemParams/updateItemParams.unit.test.ts b/src/v1/operations/updateItem/updateItemParams/updateItemParams.unit.test.ts new file mode 100644 index 000000000..ef809d759 --- /dev/null +++ b/src/v1/operations/updateItem/updateItemParams/updateItemParams.unit.test.ts @@ -0,0 +1,1940 @@ +import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb' +import { DynamoDBClient } from '@aws-sdk/client-dynamodb' + +import { + TableV2, + EntityV2, + schema, + any, + binary, + string, + number, + boolean, + set, + list, + map, + record, + DynamoDBToolboxError, + UpdateItemCommand, + prefix +} from 'v1' +import { $set, $get, $remove, $sum, $subtract, $add, $delete, $append, $prepend } from '../utils' + +const dynamoDbClient = new DynamoDBClient({}) + +const documentClient = DynamoDBDocumentClient.from(dynamoDbClient) + +const TestTable = new TableV2({ + name: 'test-table', + partitionKey: { + type: 'string', + name: 'pk' + }, + sortKey: { + type: 'string', + name: 'sk' + }, + documentClient +}) + +const TestEntity = new EntityV2({ + name: 'TestEntity', + schema: schema({ + email: string().key().savedAs('pk'), + sort: string().key().savedAs('sk'), + test_string_coerce: string().optional(), + count: number().optional().savedAs('test_number'), + test_boolean: boolean().optional(), + test_boolean_coerce: boolean().optional(), + test_list: list(string()).optional(), + test_list_nested: list(map({ value: string().enum('foo', 'bar') })).optional(), + test_list_coerce: list(any()).optional(), + test_list_required: list(any()), + contents: map({ test: string() }).savedAs('_c'), + test_map: map({ optional: number().enum(1, 2).optional() }), + test_string_set: set(string()).optional(), + test_number_set: set(number()).optional(), + test_binary_set: set(binary()).optional(), + test_binary: binary(), + simple_string: string().optional(), + test_record: record(string(), number()).optional(), + // Put updateDefaulted attributes last to have simpler, ordered assertions + test_string: string().optional().updateDefault('default string'), + test_number_default: number().optional().updateDefault(0), + test_boolean_default: boolean().optional().updateDefault(false), + operationsCount: number() + .putDefault(1) + .updateDefault(() => $add(1)) + }).and(schema => ({ + simple_string_copy: string() + .optional() + .updateLink(({ simple_string }) => simple_string ?? 'NOTHING_TO_COPY') + })), + table: TestTable +}) + +const TestTable2 = new TableV2({ + name: 'test-table2', + partitionKey: { + type: 'string', + name: 'pk' + }, + documentClient +}) + +const TestEntity2 = new EntityV2({ + name: 'TestEntity2', + schema: schema({ + email: string().key().savedAs('pk'), + test: string().optional(), // TODO: prefix with test--- + test_composite: string().optional(), + test_composite2: string().optional(), + test_undefined: any() + .optional() + // TODO: use unknown + .putDefault(() => '') + }).and(schema => ({ + sort: string() + .savedAs('sk') + .optional() + .link( + ({ test_composite, test_composite2 }) => + test_composite && test_composite2 && [test_composite, test_composite2].join('#') + ) + })), + timestamps: false, + table: TestTable2 +}) + +const TestTable3 = new TableV2({ + name: 'test-table3', + partitionKey: { + type: 'string', + name: 'pk' + }, + documentClient +}) + +const TestEntity3 = new EntityV2({ + name: 'TestEntity3', + schema: schema({ + email: string().key().savedAs('pk'), + test: string(), + test2: string().required('always'), + test3: number() + }), + timestamps: false, + table: TestTable3 +}) + +const TestTable4 = new TableV2({ + name: 'test-table4', + partitionKey: { + type: 'string', + name: 'pk' + }, + documentClient +}) + +const TestEntity4 = new EntityV2({ + name: 'TestEntity4', + schema: schema({ + email: string().key().savedAs('pk'), + test_number_default_with_map: number().savedAs('test_mapped_number').default(0) + }), + timestamps: false, + table: TestTable4 +}) + +const TestEntity5 = new EntityV2({ + name: 'TestEntity', + schema: schema({ + email: string().key().savedAs('pk').transform(prefix('EMAIL')), + sort: string().key().savedAs('sk'), + transformedStr: string().transform(prefix('STR')), + transformedSet: set(string().transform(prefix('SET'))), + transformedList: list(string().transform(prefix('LIST'))), + transformedMap: map({ str: string().transform(prefix('MAP')) }), + transformedRecord: record( + string().transform(prefix('RECORD_KEY')), + string().transform(prefix('RECORD_VALUE')) + ) + }), + table: TestTable +}) + +describe('update', () => { + it('creates default update', () => { + const { + TableName, + Key, + UpdateExpression, + ExpressionAttributeNames, + ExpressionAttributeValues + } = TestEntity.build(UpdateItemCommand).item({ email: 'test-pk', sort: 'test-sk' }).params() + + expect(TableName).toBe('test-table') + expect(Key).toStrictEqual({ pk: 'test-pk', sk: 'test-sk' }) + + expect(UpdateExpression).toStrictEqual( + 'SET #s_1 = :s_1, #s_2 = :s_2, #s_3 = :s_3, #s_4 = :s_4, #s_5 = if_not_exists(#s_6, :s_5), #s_7 = if_not_exists(#s_8, :s_6), #s_9 = :s_7 ADD #a_1 :a_1' + ) + expect(ExpressionAttributeNames).toStrictEqual({ + '#s_1': 'test_string', + '#s_2': 'test_number_default', + '#s_3': 'test_boolean_default', + '#s_4': 'simple_string_copy', + // TODO: Re-use s5 + '#s_5': '_et', + '#s_6': '_et', + // TODO: Re-use s7 + '#s_7': '_ct', + '#s_8': '_ct', + '#s_9': '_md', + '#a_1': 'operationsCount' + }) + expect(ExpressionAttributeValues).toStrictEqual({ + ':s_1': 'default string', + ':s_2': 0, + ':s_3': false, + ':s_4': 'NOTHING_TO_COPY', + ':s_5': TestEntity.name, + ':s_6': expect.any(String), + ':s_7': expect.any(String), + ':a_1': 1 + }) + }) + + it('allows overriding default field values', () => { + const { + UpdateExpression, + ExpressionAttributeNames, + ExpressionAttributeValues + } = TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + test_string: 'test string' + }) + .params() + + expect(UpdateExpression).toContain('SET #s_1 = :s_1') + expect(ExpressionAttributeNames).toMatchObject({ '#s_1': 'test_string' }) + expect(ExpressionAttributeValues).toMatchObject({ ':s_1': 'test string' }) + }) + + it('overrides default field values that use mapping', () => { + const { UpdateExpression, ExpressionAttributeNames } = TestEntity4.build(UpdateItemCommand) + .item({ + email: 'test-pk', + test_number_default_with_map: 111 + }) + .params() + + expect(UpdateExpression).toContain('SET #s_1 = :s_1') + expect(ExpressionAttributeNames).toMatchObject({ '#s_1': 'test_mapped_number' }) + }) + + it('removes fields', () => { + const { UpdateExpression, ExpressionAttributeNames } = TestEntity2.build(UpdateItemCommand) + .item({ + email: 'test-pk', + test: $remove(), + test_composite: $remove() + }) + .params() + + expect(UpdateExpression).toContain('REMOVE #r_1, #r_2') + expect(ExpressionAttributeNames).toMatchObject({ + '#r_1': 'test', + '#r_2': 'test_composite' + }) + }) + + it('ignores removing an invalid attribute', () => { + const { ExpressionAttributeNames = {} } = TestEntity.build(UpdateItemCommand) + .item({ + email: 'x', + sort: 'y', + // @ts-expect-error + missing: $remove() + }) + .params() + + expect(Object.values(ExpressionAttributeNames)).not.toContain('missing') + }) + + it('fails when trying to remove the partitionKey', () => { + const invalidCall = () => + TestEntity.build(UpdateItemCommand) + .item({ + // @ts-expect-error + email: $remove(), + sort: 'y' + }) + .params() + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow(expect.objectContaining({ code: 'parsing.attributeRequired' })) + }) + + it('fails when trying to remove the sortKey', () => { + const invalidCall = () => + TestEntity.build(UpdateItemCommand) + .item({ + email: 'test', + // @ts-expect-error + sort: $remove() + }) + .params() + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow(expect.objectContaining({ code: 'parsing.attributeRequired' })) + }) + + it('ignores fields with no value', () => { + const { ExpressionAttributeValues = {} } = TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-pk', + test_string: undefined + }) + .params() + + const attributeValues = Object.values(ExpressionAttributeValues) + + expect(attributeValues).not.toContain('test_string') + }) + + it('accepts references', () => { + const { + UpdateExpression: UpdateExpressionA, + ExpressionAttributeNames: ExpressionAttributeNamesA + } = TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-pk', + test_string_coerce: $get('test_string') + }) + .params() + + expect(UpdateExpressionA).toContain('SET #s_1 = #s_2') + expect(ExpressionAttributeNamesA).toMatchObject({ + '#s_1': 'test_string_coerce', + '#s_2': 'test_string' + }) + + const { + UpdateExpression: UpdateExpressionB, + ExpressionAttributeNames: ExpressionAttributeNamesB, + ExpressionAttributeValues: ExpressionAttributeValuesB + } = TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-pk', + test_string_coerce: $get('test_string', 'foo') + }) + .params() + + expect(UpdateExpressionB).toContain('SET #s_1 = if_not_exists(#s_2, :s_1)') + expect(ExpressionAttributeNamesB).toMatchObject({ + '#s_1': 'test_string_coerce', + '#s_2': 'test_string' + }) + expect(ExpressionAttributeValuesB).toMatchObject({ ':s_1': 'foo' }) + + const { + UpdateExpression: UpdateExpressionC, + ExpressionAttributeNames: ExpressionAttributeNamesC + } = TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-pk', + test_string_coerce: $get('test_string', $get('simple_string')) + }) + .params() + + expect(UpdateExpressionC).toContain('SET #s_1 = if_not_exists(#s_2, #s_3)') + expect(ExpressionAttributeNamesC).toMatchObject({ + '#s_1': 'test_string_coerce', + '#s_2': 'test_string', + '#s_3': 'simple_string' + }) + + const { + UpdateExpression: UpdateExpressionD, + ExpressionAttributeNames: ExpressionAttributeNamesD, + ExpressionAttributeValues: ExpressionAttributeValuesD + } = TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-pk', + test_string_coerce: $get('test_string', $get('simple_string', 'bar')) + }) + .params() + + expect(UpdateExpressionD).toContain('SET #s_1 = if_not_exists(#s_2, if_not_exists(#s_3, :s_1))') + expect(ExpressionAttributeNamesD).toMatchObject({ + '#s_1': 'test_string_coerce', + '#s_2': 'test_string', + '#s_3': 'simple_string' + }) + expect(ExpressionAttributeValuesD).toMatchObject({ ':s_1': 'bar' }) + }) + + it('rejects invalid references', () => { + const invalidCallA = () => + TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-pk', + // @ts-expect-error invalid_attribute_name is not an existing attribute name + test_string_coerce: $get('invalid_attribute_name') + }) + .params() + + expect(invalidCallA).toThrow(DynamoDBToolboxError) + expect(invalidCallA).toThrow( + expect.objectContaining({ code: 'operations.invalidExpressionAttributePath' }) + ) + + const invalidCallB = () => + TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-pk', + // @ts-expect-error 42 is not assignable to test_string_coerce + test_string_coerce: $get('test_string', 42) + }) + .params() + + expect(invalidCallB).toThrow(DynamoDBToolboxError) + expect(invalidCallB).toThrow(expect.objectContaining({ code: 'parsing.invalidAttributeInput' })) + + const invalidCallC = () => + TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-pk', + // @ts-expect-error $get is only available in SET operations + test_number_default: $get('test_string', $add(42)) + }) + .params() + + expect(invalidCallC).toThrow(DynamoDBToolboxError) + expect(invalidCallC).toThrow(expect.objectContaining({ code: 'parsing.invalidAttributeInput' })) + + const invalidCallD = () => + TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-pk', + // @ts-expect-error invalid_attribute_name is not an existing attribute name + test_string_coerce: $get('test_string', $get('invalid_attribute_name')) + }) + .params() + + expect(invalidCallD).toThrow(DynamoDBToolboxError) + expect(invalidCallD).toThrow( + expect.objectContaining({ code: 'operations.invalidExpressionAttributePath' }) + ) + + const invalidCallE = () => + TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-pk', + // @ts-expect-error 42 is not assignable to test_string_coerce + test_string_coerce: $get('test_string', $get('simple_string', 42)) + }) + .params() + + expect(invalidCallE).toThrow(DynamoDBToolboxError) + expect(invalidCallE).toThrow(expect.objectContaining({ code: 'parsing.invalidAttributeInput' })) + }) + + it('performs sum operation', () => { + const { + UpdateExpression: UpdateExpressionA, + ExpressionAttributeNames: ExpressionAttributeNamesA, + ExpressionAttributeValues: ExpressionAttributeValuesA + } = TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + test_number_default: $sum(10, 10) + }) + .params() + + /** + * @debt test "We get some noise due to update defaults. Use case specific Entity." + */ + expect(UpdateExpressionA).toContain('SET #s_1 = :s_1, #s_2 = :s_2 + :s_3') + expect(ExpressionAttributeNamesA).toMatchObject({ '#s_2': 'test_number_default' }) + expect(ExpressionAttributeValuesA).toMatchObject({ ':s_2': 10, ':s_3': 10 }) + + const { + UpdateExpression: UpdateExpressionB, + ExpressionAttributeNames: ExpressionAttributeNamesB, + ExpressionAttributeValues: ExpressionAttributeValuesB + } = TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + test_number_default: $sum($get('count'), 10) + }) + .params() + + expect(UpdateExpressionB).toContain('SET #s_1 = :s_1, #s_2 = #s_3 + :s_2') + expect(ExpressionAttributeNamesB).toMatchObject({ + '#s_2': 'test_number_default', + // TODO: Use a non re-mapped property + '#s_3': 'test_number' + }) + expect(ExpressionAttributeValuesB).toMatchObject({ ':s_2': 10 }) + + const { + UpdateExpression: UpdateExpressionC, + ExpressionAttributeNames: ExpressionAttributeNamesC, + ExpressionAttributeValues: ExpressionAttributeValuesC + } = TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + test_number_default: $sum(10, $get('count', 10)) + }) + .params() + + expect(UpdateExpressionC).toContain('SET #s_1 = :s_1, #s_2 = :s_2 + if_not_exists(#s_3, :s_3)') + expect(ExpressionAttributeNamesC).toMatchObject({ + '#s_2': 'test_number_default', + // TODO: Use a non re-mapped property + '#s_3': 'test_number' + }) + expect(ExpressionAttributeValuesC).toMatchObject({ ':s_2': 10, ':s_3': 10 }) + + const { + UpdateExpression: UpdateExpressionD, + ExpressionAttributeNames: ExpressionAttributeNamesD, + ExpressionAttributeValues: ExpressionAttributeValuesD + } = TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + test_number_default: $sum($get('count', 5), $get('count', 10)) + }) + .params() + + expect(UpdateExpressionD).toContain( + 'SET #s_1 = :s_1, #s_2 = if_not_exists(#s_3, :s_2) + if_not_exists(#s_4, :s_3)' + ) + expect(ExpressionAttributeNamesD).toMatchObject({ + '#s_2': 'test_number_default', + // TODO: Use a non re-mapped property + '#s_3': 'test_number' + }) + expect(ExpressionAttributeValuesD).toMatchObject({ ':s_2': 5, ':s_3': 10 }) + }) + + it('rejects invalid sum operation', () => { + const invalidCallA = () => + TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + // @ts-expect-error + test_number_default: $sum('a', 10) + }) + .params() + + expect(invalidCallA).toThrow(DynamoDBToolboxError) + expect(invalidCallA).toThrow(expect.objectContaining({ code: 'parsing.invalidAttributeInput' })) + + const invalidCallB = () => + TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + // @ts-expect-error + test_number_default: $sum(10, '10') + }) + .params() + + expect(invalidCallB).toThrow(DynamoDBToolboxError) + expect(invalidCallB).toThrow(expect.objectContaining({ code: 'parsing.invalidAttributeInput' })) + + const invalidCallC = () => + TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + // @ts-expect-error + test_number_default: $sum($get('invalid_prop'), 10) + }) + .params() + + expect(invalidCallC).toThrow(DynamoDBToolboxError) + expect(invalidCallC).toThrow( + expect.objectContaining({ code: 'operations.invalidExpressionAttributePath' }) + ) + + const invalidCallD = () => + TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + // @ts-expect-error + test_number_default: $sum(10, $get('count', '10')) + }) + .params() + + expect(invalidCallD).toThrow(DynamoDBToolboxError) + expect(invalidCallD).toThrow(expect.objectContaining({ code: 'parsing.invalidAttributeInput' })) + }) + + it('performs subtract operation', () => { + const { + UpdateExpression: UpdateExpressionA, + ExpressionAttributeNames: ExpressionAttributeNamesA, + ExpressionAttributeValues: ExpressionAttributeValuesA + } = TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + test_number_default: $subtract(10, 10) + }) + .params() + + /** + * @debt test "We get some noise due to update defaults. Use case specific Entity." + */ + expect(UpdateExpressionA).toContain('SET #s_1 = :s_1, #s_2 = :s_2 - :s_3') + expect(ExpressionAttributeNamesA).toMatchObject({ '#s_2': 'test_number_default' }) + expect(ExpressionAttributeValuesA).toMatchObject({ ':s_2': 10, ':s_3': 10 }) + + const { + UpdateExpression: UpdateExpressionB, + ExpressionAttributeNames: ExpressionAttributeNamesB, + ExpressionAttributeValues: ExpressionAttributeValuesB + } = TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + test_number_default: $subtract($get('count'), 10) + }) + .params() + + expect(UpdateExpressionB).toContain('SET #s_1 = :s_1, #s_2 = #s_3 - :s_2') + expect(ExpressionAttributeNamesB).toMatchObject({ + '#s_2': 'test_number_default', + // TODO: Use a non re-mapped property + '#s_3': 'test_number' + }) + expect(ExpressionAttributeValuesB).toMatchObject({ ':s_2': 10 }) + + const { + UpdateExpression: UpdateExpressionC, + ExpressionAttributeNames: ExpressionAttributeNamesC, + ExpressionAttributeValues: ExpressionAttributeValuesC + } = TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + test_number_default: $subtract(10, $get('count', 10)) + }) + .params() + + expect(UpdateExpressionC).toContain('SET #s_1 = :s_1, #s_2 = :s_2 - if_not_exists(#s_3, :s_3)') + expect(ExpressionAttributeNamesC).toMatchObject({ + '#s_2': 'test_number_default', + // TODO: Use a non re-mapped property + '#s_3': 'test_number' + }) + expect(ExpressionAttributeValuesC).toMatchObject({ ':s_3': 10 }) + + const { + UpdateExpression: UpdateExpressionD, + ExpressionAttributeNames: ExpressionAttributeNamesD, + ExpressionAttributeValues: ExpressionAttributeValuesD + } = TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + test_number_default: $subtract($get('count', 5), $get('count', 10)) + }) + .params() + + expect(UpdateExpressionD).toContain( + 'SET #s_1 = :s_1, #s_2 = if_not_exists(#s_3, :s_2) - if_not_exists(#s_4, :s_3)' + ) + expect(ExpressionAttributeNamesD).toMatchObject({ + '#s_2': 'test_number_default', + // TODO: Use a non re-mapped property + '#s_3': 'test_number' + }) + expect(ExpressionAttributeValuesD).toMatchObject({ ':s_2': 5, ':s_3': 10 }) + }) + + it('rejects invalid subtract operation', () => { + const invalidCallA = () => + TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + // @ts-expect-error + test_number_default: $subtract('a', 10) + }) + .params() + + expect(invalidCallA).toThrow(DynamoDBToolboxError) + expect(invalidCallA).toThrow(expect.objectContaining({ code: 'parsing.invalidAttributeInput' })) + + const invalidCallB = () => + TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + // @ts-expect-error + test_number_default: $subtract(10, '10') + }) + .params() + + expect(invalidCallB).toThrow(DynamoDBToolboxError) + expect(invalidCallB).toThrow(expect.objectContaining({ code: 'parsing.invalidAttributeInput' })) + + const invalidCallC = () => + TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + // @ts-expect-error + test_number_default: $subtract($get('invalid_prop'), 10) + }) + .params() + + expect(invalidCallC).toThrow(DynamoDBToolboxError) + expect(invalidCallC).toThrow( + expect.objectContaining({ code: 'operations.invalidExpressionAttributePath' }) + ) + + const invalidCallD = () => + TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + // @ts-expect-error + test_number_default: $subtract(10, $get('count', '10')) + }) + .params() + + expect(invalidCallD).toThrow(DynamoDBToolboxError) + expect(invalidCallD).toThrow(expect.objectContaining({ code: 'parsing.invalidAttributeInput' })) + }) + + it('performs number and set add operations', () => { + const { + UpdateExpression, + ExpressionAttributeNames, + ExpressionAttributeValues + } = TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + test_number_default: $add(10), + test_number_set: $add(new Set([1, 2, 3])) + }) + .params() + + expect(UpdateExpression).toContain('ADD #a_1 :a_1, #a_2 :a_2') + expect(ExpressionAttributeNames).toMatchObject({ + '#a_1': 'test_number_set', + '#a_2': 'test_number_default' + }) + expect(ExpressionAttributeValues).toMatchObject({ + ':a_1': new Set([1, 2, 3]), + ':a_2': 10 + }) + }) + + it('rejects an invalid number add operation', () => { + const invalidCallA = () => + TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + // @ts-expect-error + test_string: $add(10) + }) + .params() + + expect(invalidCallA).toThrow(DynamoDBToolboxError) + expect(invalidCallA).toThrow(expect.objectContaining({ code: 'parsing.invalidAttributeInput' })) + + const invalidCallB = () => + TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + // @ts-expect-error + test_number_default: $delete(10) + }) + .params() + + expect(invalidCallB).toThrow(DynamoDBToolboxError) + expect(invalidCallB).toThrow(expect.objectContaining({ code: 'parsing.invalidAttributeInput' })) + }) + + it('creates sets', () => { + const { + UpdateExpression, + ExpressionAttributeNames, + ExpressionAttributeValues + } = TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + test_string_set: new Set(['1', '2', '3']), + test_number_set: new Set([1, 2, 3]), + test_binary_set: new Set([Buffer.from('1'), Buffer.from('2'), Buffer.from('3')]) + }) + .params() + + expect(UpdateExpression).toContain('SET #s_1 = :s_1, #s_2 = :s_2, #s_3 = :s_3') + expect(ExpressionAttributeNames).toMatchObject({ + '#s_1': 'test_string_set', + '#s_2': 'test_number_set', + '#s_3': 'test_binary_set' + }) + expect(ExpressionAttributeValues).toMatchObject({ + ':s_1': new Set(['1', '2', '3']), + ':s_2': new Set([1, 2, 3]), + ':s_3': new Set([Buffer.from('1'), Buffer.from('2'), Buffer.from('3')]) + }) + }) + + it('performs a delete operation on set', () => { + const { + UpdateExpression, + ExpressionAttributeNames, + ExpressionAttributeValues + } = TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + test_string_set: $delete(new Set(['1', '2', '3'])), + test_number_set: $delete(new Set([1, 2, 3])) + }) + .params() + + expect(UpdateExpression).toContain('DELETE #d_1 :d_1, #d_2 :d_2') + expect(ExpressionAttributeNames).toMatchObject({ + '#d_1': 'test_string_set', + '#d_2': 'test_number_set' + }) + expect(ExpressionAttributeValues).toMatchObject({ + ':d_1': new Set(['1', '2', '3']), + ':d_2': new Set([1, 2, 3]) + }) + }) + + it('rejects an invalid delete operation', () => { + const invalidCall = () => + TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + // @ts-expect-error + test_string: $delete(10) + }) + .params() + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow(expect.objectContaining({ code: 'parsing.invalidAttributeInput' })) + }) + + it('overrides existing list', () => { + const { + UpdateExpression, + ExpressionAttributeNames, + ExpressionAttributeValues + } = TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + test_list: $set(['test1', 'test2']) + }) + .params() + + expect(UpdateExpression).toContain('SET #s_1 = :s_1') + expect(ExpressionAttributeNames).toMatchObject({ '#s_1': 'test_list' }) + expect(ExpressionAttributeValues).toMatchObject({ ':s_1': ['test1', 'test2'] }) + }) + + it('rejects references when setting whole list', () => { + const invalidCall = () => + TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + // @ts-expect-error + test_list: $set([$get('test_string'), 'test2']) + }) + .params() + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow(expect.objectContaining({ code: 'parsing.invalidAttributeInput' })) + }) + + it('updates specific items in a list', () => { + const { + UpdateExpression, + ExpressionAttributeNames, + ExpressionAttributeValues + } = TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + test_list: { 2: 'Test2' }, + test_list_nested: { 1: { value: 'foo' } } + }) + .params() + + expect(UpdateExpression).toContain('SET #s_1[2] = :s_1, #s_2[1].#s_3 = :s_2') + expect(ExpressionAttributeNames).toMatchObject({ + '#s_1': 'test_list', + '#s_2': 'test_list_nested', + '#s_3': 'value' + }) + expect(ExpressionAttributeValues).toMatchObject({ + ':s_1': 'Test2', + ':s_2': 'foo' + }) + }) + + it('accepts references when updating list element', () => { + const { UpdateExpression, ExpressionAttributeNames } = TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + test_list: { 2: $get('test_string') } + }) + .params() + + expect(UpdateExpression).toContain('SET #s_1[2] = #s_2') + expect(ExpressionAttributeNames).toMatchObject({ + '#s_1': 'test_list', + '#s_2': 'test_string' + }) + }) + + it('rejects invalid reference when updating list element', () => { + const invalidCallA = () => + TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + test_list: { + // @ts-expect-error invalid_ref is not a valid attribute + 2: $get('invalid_ref') + } + }) + .params() + + expect(invalidCallA).toThrow(DynamoDBToolboxError) + expect(invalidCallA).toThrow( + expect.objectContaining({ code: 'operations.invalidExpressionAttributePath' }) + ) + + const invalidCallB = () => + TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-pk', + test_list: { + // @ts-expect-error 42 is not assignable to string + 2: $get('test_string', 42) + } + }) + .params() + + expect(invalidCallB).toThrow(DynamoDBToolboxError) + expect(invalidCallB).toThrow(expect.objectContaining({ code: 'parsing.invalidAttributeInput' })) + + const invalidCallC = () => + TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-pk', + test_list: { + // @ts-expect-error $get is only available in SET operations + 2: $get('test_string', $add(42)) + } + }) + .params() + + expect(invalidCallC).toThrow(DynamoDBToolboxError) + expect(invalidCallC).toThrow(expect.objectContaining({ code: 'parsing.invalidAttributeInput' })) + + const invalidCallD = () => + TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-pk', + test_list: { + // @ts-expect-error invalid_attribute_name is not an existing attribute name + 2: $get('test_string', $get('invalid_attribute_name')) + } + }) + .params() + + expect(invalidCallD).toThrow(DynamoDBToolboxError) + expect(invalidCallD).toThrow( + expect.objectContaining({ code: 'operations.invalidExpressionAttributePath' }) + ) + + const invalidCallE = () => + TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-pk', + test_list: { + // @ts-expect-error 42 is not assignable to string + 2: $get('test_string', $get('simple_string', 42)) + } + }) + .params() + + expect(invalidCallE).toThrow(DynamoDBToolboxError) + expect(invalidCallE).toThrow(expect.objectContaining({ code: 'parsing.invalidAttributeInput' })) + }) + + it('rejects invalid key while updating list element', () => { + const invalidCallA = () => + TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + test_list: { + // @ts-expect-error + foo: 'Test2' + } + }) + .params() + + expect(invalidCallA).toThrow(DynamoDBToolboxError) + expect(invalidCallA).toThrow(expect.objectContaining({ code: 'parsing.invalidAttributeInput' })) + + const invalidCallB = () => + TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + test_list: { + // TS unable to detect integers + 1.5: 'Test2' + } + }) + .params() + + expect(invalidCallB).toThrow(DynamoDBToolboxError) + expect(invalidCallB).toThrow(expect.objectContaining({ code: 'parsing.invalidAttributeInput' })) + }) + + it('removes items from a list', () => { + const { UpdateExpression, ExpressionAttributeNames } = TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + test_list: { + 2: $remove() + } + }) + .params() + + expect(UpdateExpression).toContain('REMOVE #r_1[2]') + expect(ExpressionAttributeNames).toMatchObject({ '#r_1': 'test_list' }) + }) + + it('updates elements within a list', () => { + const { + UpdateExpression, + ExpressionAttributeNames, + ExpressionAttributeValues + } = TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + test_list: [undefined, $remove(), 'test'] + }) + .params() + + expect(UpdateExpression).toContain('SET #s_1[2] = :s_1') + expect(ExpressionAttributeNames).toMatchObject({ '#s_1': 'test_list' }) + expect(ExpressionAttributeValues).toMatchObject({ ':s_1': 'test' }) + + expect(UpdateExpression).toContain('REMOVE #r_1[1]') + expect(ExpressionAttributeNames).toMatchObject({ '#r_1': 'test_list' }) + }) + + it('appends data to a list', () => { + const { + UpdateExpression: UpdateExpressionA, + ExpressionAttributeNames: ExpressionAttributeNamesA, + ExpressionAttributeValues: ExpressionAttributeValuesA + } = TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + test_list: $append(['1', '2', '3']) + }) + .params() + + expect(UpdateExpressionA).toContain('SET #s_1 = list_append(#s_1, :s_1)') + expect(ExpressionAttributeNamesA).toMatchObject({ '#s_1': 'test_list' }) + expect(ExpressionAttributeValuesA).toMatchObject({ ':s_1': ['1', '2', '3'] }) + + const { + UpdateExpression: UpdateExpressionB, + ExpressionAttributeNames: ExpressionAttributeNamesB + } = TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + test_list: $append($get('test_string')) + }) + .params() + + expect(UpdateExpressionB).toContain('SET #s_1 = list_append(#s_1, #s_2)') + expect(ExpressionAttributeNamesB).toMatchObject({ '#s_1': 'test_list', '#s_2': 'test_string' }) + + const { + UpdateExpression: UpdateExpressionC, + ExpressionAttributeNames: ExpressionAttributeNamesC, + ExpressionAttributeValues: ExpressionAttributeValuesC + } = TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + test_list: $append($get('test_string', ['1', '2', '3'])) + }) + .params() + + expect(UpdateExpressionC).toContain('SET #s_1 = list_append(#s_1, if_not_exists(#s_2, :s_1))') + expect(ExpressionAttributeNamesC).toMatchObject({ '#s_1': 'test_list', '#s_2': 'test_string' }) + expect(ExpressionAttributeValuesC).toMatchObject({ ':s_1': ['1', '2', '3'] }) + }) + + it('rejects invalid appended values', () => { + const invalidCallA = () => + TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + // @ts-expect-error + test_list_nested: $append([{ value: 'foo' }, { value: 'baz' }]) + }) + .params() + + expect(invalidCallA).toThrow(DynamoDBToolboxError) + expect(invalidCallA).toThrow(expect.objectContaining({ code: 'parsing.invalidAttributeInput' })) + + const invalidCallB = () => + TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + // @ts-expect-error + test_list_nested: $append($get('invalid_ref')) + }) + .params() + + expect(invalidCallB).toThrow(DynamoDBToolboxError) + expect(invalidCallB).toThrow( + expect.objectContaining({ code: 'operations.invalidExpressionAttributePath' }) + ) + }) + + it('prepends data to a list', () => { + const { + UpdateExpression: UpdateExpressionA, + ExpressionAttributeNames: ExpressionAttributeNamesA, + ExpressionAttributeValues: ExpressionAttributeValuesA + } = TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + test_list: $prepend(['a', 'b', 'c']) + }) + .params() + + expect(UpdateExpressionA).toContain('SET #s_1 = list_append(:s_1, #s_1)') + expect(ExpressionAttributeNamesA).toMatchObject({ '#s_1': 'test_list' }) + expect(ExpressionAttributeValuesA).toMatchObject({ ':s_1': ['a', 'b', 'c'] }) + + const { + UpdateExpression: UpdateExpressionB, + ExpressionAttributeNames: ExpressionAttributeNamesB + } = TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + test_list: $prepend($get('test_string')) + }) + .params() + + expect(UpdateExpressionB).toContain('SET #s_1 = list_append(#s_2, #s_1)') + expect(ExpressionAttributeNamesB).toMatchObject({ '#s_1': 'test_list', '#s_2': 'test_string' }) + + const { + UpdateExpression: UpdateExpressionC, + ExpressionAttributeNames: ExpressionAttributeNamesC, + ExpressionAttributeValues: ExpressionAttributeValuesC + } = TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + test_list: $prepend($get('test_string', ['1', '2', '3'])) + }) + .params() + + expect(UpdateExpressionC).toContain('SET #s_1 = list_append(if_not_exists(#s_2, :s_1), #s_1)') + expect(ExpressionAttributeNamesC).toMatchObject({ '#s_1': 'test_list', '#s_2': 'test_string' }) + expect(ExpressionAttributeValuesC).toMatchObject({ ':s_1': ['1', '2', '3'] }) + }) + + it('rejects invalid prepended values', () => { + const invalidCallA = () => + TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + // @ts-expect-error + test_list_nested: $prepend([{ value: 'foo' }, { value: 'baz' }]) + }) + .params() + + expect(invalidCallA).toThrow(DynamoDBToolboxError) + expect(invalidCallA).toThrow(expect.objectContaining({ code: 'parsing.invalidAttributeInput' })) + + const invalidCallB = () => + TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + // @ts-expect-error + test_list_nested: $prepend($get('invalid_ref')) + }) + .params() + + expect(invalidCallB).toThrow(DynamoDBToolboxError) + expect(invalidCallB).toThrow( + expect.objectContaining({ code: 'operations.invalidExpressionAttributePath' }) + ) + }) + + it('updates nested data in a map', () => { + const { + UpdateExpression, + ExpressionAttributeNames, + ExpressionAttributeValues + } = TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + test_map: { optional: 1 } + }) + .params() + + expect(UpdateExpression).toContain('SET #s_1.#s_2 = :s_1') + expect(ExpressionAttributeNames).toMatchObject({ + '#s_1': 'test_map', + '#s_2': 'optional' + }) + expect(ExpressionAttributeValues).toMatchObject({ ':s_1': 1 }) + }) + + it('removes nested data in a map', () => { + const { UpdateExpression, ExpressionAttributeNames } = TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + test_map: { optional: $remove() } + }) + .params() + + expect(UpdateExpression).toContain('REMOVE #r_1.#r_2') + expect(ExpressionAttributeNames).toMatchObject({ + '#r_1': 'test_map', + '#r_2': 'optional' + }) + }) + + it('ignores undefined values', () => { + const { ExpressionAttributeNames = {} } = TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + test_map: { optional: undefined } + }) + .params() + + expect(Object.values(ExpressionAttributeNames)).not.toContain('test_map') + }) + + it('accepts references', () => { + const { UpdateExpression, ExpressionAttributeNames } = TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + test_map: { optional: $get('test_number_default') } + }) + .params() + + expect(UpdateExpression).toContain('SET #s_1.#s_2 = #s_3') + expect(ExpressionAttributeNames).toMatchObject({ + '#s_1': 'test_map', + '#s_2': 'optional', + '#s_3': 'test_number_default' + }) + }) + + it('rejects invalid reference', () => { + const invalidCallA = () => + TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + test_map: { + // @ts-expect-error invalid_attribute_name is not an existing attribute name + optional: $get('invalid_attribute_name') + } + }) + .params() + + expect(invalidCallA).toThrow(DynamoDBToolboxError) + expect(invalidCallA).toThrow( + expect.objectContaining({ code: 'operations.invalidExpressionAttributePath' }) + ) + + const invalidCallB = () => + TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-pk', + test_map: { + // @ts-expect-error 42 is not assignable to 1 | 2 + optional: $get('test_number_default', 42) + } + }) + .params() + + expect(invalidCallB).toThrow(DynamoDBToolboxError) + expect(invalidCallB).toThrow(expect.objectContaining({ code: 'parsing.invalidAttributeInput' })) + + const invalidCallC = () => + TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-pk', + test_map: { + // @ts-expect-error $get is only available in SET operations + optional: $get('test_number_default', $add(42)) + } + }) + .params() + + expect(invalidCallC).toThrow(DynamoDBToolboxError) + expect(invalidCallC).toThrow(expect.objectContaining({ code: 'parsing.invalidAttributeInput' })) + + const invalidCallD = () => + TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-pk', + test_map: { + // @ts-expect-error invalid_attribute_name is not an existing attribute name + optional: $get('test_number_default', $get('invalid_attribute_name')) + } + }) + .params() + + expect(invalidCallD).toThrow(DynamoDBToolboxError) + expect(invalidCallD).toThrow( + expect.objectContaining({ code: 'operations.invalidExpressionAttributePath' }) + ) + + const invalidCallE = () => + TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-pk', + test_map: { + // @ts-expect-error 42 is not assignable to 1 | 2 + optional: $get('test_number_default', $get('test_number_default', 42)) + } + }) + .params() + + expect(invalidCallE).toThrow(DynamoDBToolboxError) + expect(invalidCallE).toThrow(expect.objectContaining({ code: 'parsing.invalidAttributeInput' })) + }) + + it('override whole map if set is used', () => { + const { + UpdateExpression, + ExpressionAttributeNames, + ExpressionAttributeValues + } = TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + test_map: $set({ optional: 1 }) + }) + .params() + + expect(UpdateExpression).toContain('SET #s_1 = :s_1') + expect(ExpressionAttributeNames).toMatchObject({ '#s_1': 'test_map' }) + expect(ExpressionAttributeValues).toMatchObject({ ':s_1': { optional: 1 } }) + }) + + it('rejects references when setting whole map', () => { + const invalidCall = () => + TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + // @ts-expect-error + test_map: $set({ + optional: $get('test_string') + }) + }) + .params() + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow(expect.objectContaining({ code: 'parsing.invalidAttributeInput' })) + }) + + it('rejects invalid set map', () => { + const invalidCall = () => + TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + // @ts-expect-error + test_map: $set({ + optional: $add(1) + }) + }) + .params() + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow(expect.objectContaining({ code: 'parsing.invalidAttributeInput' })) + }) + + it('updates nested data in a record', () => { + const { + UpdateExpression, + ExpressionAttributeNames, + ExpressionAttributeValues + } = TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + test_record: { foo: 1 } + }) + .params() + + expect(UpdateExpression).toContain('SET #s_1.#s_2 = :s_1') + expect(ExpressionAttributeNames).toMatchObject({ + '#s_1': 'test_record', + '#s_2': 'foo' + }) + expect(ExpressionAttributeValues).toMatchObject({ ':s_1': 1 }) + }) + + it('removes nested data in a record', () => { + const { UpdateExpression, ExpressionAttributeNames } = TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + test_record: { foo: $remove() } + }) + .params() + + expect(UpdateExpression).toContain('REMOVE #r_1.#r_2') + expect(ExpressionAttributeNames).toMatchObject({ + '#r_1': 'test_record', + '#r_2': 'foo' + }) + }) + + it('ignores undefined values', () => { + const { ExpressionAttributeNames = {} } = TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + test_record: { foo: undefined } + }) + .params() + + expect(Object.values(ExpressionAttributeNames)).not.toContain('test_record') + }) + + it('accepts references', () => { + const { UpdateExpression, ExpressionAttributeNames } = TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + test_record: { foo: $get('test_number_default') } + }) + .params() + + expect(UpdateExpression).toContain('SET #s_1.#s_2 = #s_3') + expect(ExpressionAttributeNames).toMatchObject({ + '#s_1': 'test_record', + '#s_2': 'foo', + '#s_3': 'test_number_default' + }) + }) + + it('rejects invalid reference', () => { + const invalidCallA = () => + TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + test_record: { + // @ts-expect-error invalid_attribute_name is not an existing attribute name + foo: $get('invalid_attribute_name') + } + }) + .params() + + expect(invalidCallA).toThrow(DynamoDBToolboxError) + expect(invalidCallA).toThrow( + expect.objectContaining({ code: 'operations.invalidExpressionAttributePath' }) + ) + + const invalidCallB = () => + TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-pk', + test_record: { + // @ts-expect-error 'foo' is not assignable to number + foo: $get('test_number_default', 'foo') + } + }) + .params() + + expect(invalidCallB).toThrow(DynamoDBToolboxError) + expect(invalidCallB).toThrow(expect.objectContaining({ code: 'parsing.invalidAttributeInput' })) + + const invalidCallC = () => + TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-pk', + test_record: { + // @ts-expect-error $get is only available in SET operations + foo: $get('test_number_default', $add(42)) + } + }) + .params() + + expect(invalidCallC).toThrow(DynamoDBToolboxError) + expect(invalidCallC).toThrow(expect.objectContaining({ code: 'parsing.invalidAttributeInput' })) + + const invalidCallD = () => + TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-pk', + test_record: { + // @ts-expect-error invalid_attribute_name is not an existing attribute name + foo: $get('test_number_default', $get('invalid_attribute_name')) + } + }) + .params() + + expect(invalidCallD).toThrow(DynamoDBToolboxError) + expect(invalidCallD).toThrow( + expect.objectContaining({ code: 'operations.invalidExpressionAttributePath' }) + ) + + const invalidCallE = () => + TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-pk', + test_record: { + // @ts-expect-error 'foo" is not assignable to number + foo: $get('test_number_default', $get('test_number_default', 'foo')) + } + }) + .params() + + expect(invalidCallE).toThrow(DynamoDBToolboxError) + expect(invalidCallE).toThrow(expect.objectContaining({ code: 'parsing.invalidAttributeInput' })) + }) + + it('override whole record if set is used', () => { + const { + UpdateExpression, + ExpressionAttributeNames, + ExpressionAttributeValues + } = TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + test_record: $set({ foo: 1 }) + }) + .params() + + expect(UpdateExpression).toContain('SET #s_1 = :s_1') + expect(ExpressionAttributeNames).toMatchObject({ '#s_1': 'test_record' }) + expect(ExpressionAttributeValues).toMatchObject({ ':s_1': { foo: 1 } }) + }) + + it('rejects references when setting whole record', () => { + const invalidCall = () => + TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + // @ts-expect-error + test_record: $set({ + foo: $get('test_string') + }) + }) + .params() + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow(expect.objectContaining({ code: 'parsing.invalidAttributeInput' })) + }) + + it('rejects invalid set record', () => { + const invalidCall = () => + TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + // @ts-expect-error + test_record: $set({ + foo: $add(1) + }) + }) + .params() + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow(expect.objectContaining({ code: 'parsing.invalidAttributeInput' })) + }) + + it('rejects set on non-map or non-record attributes', () => { + const invalidCall = () => + TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + // @ts-expect-error + test_string: $set('test') + }) + .params() + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow(expect.objectContaining({ code: 'parsing.invalidAttributeInput' })) + }) + + /** + * @debt TODO "Test anyOf attribute" + */ + + it('uses an alias', async () => { + const { UpdateExpression, ExpressionAttributeNames } = TestEntity.build(UpdateItemCommand) + .item({ + email: 'test@test.com', + sort: 'test-sk', + count: $add(10), + contents: { test: 'test' } + }) + .params() + + expect(UpdateExpression).toContain('SET #s_1.#s_2 = :s_1') + expect(ExpressionAttributeNames).toMatchObject({ '#s_1': '_c' }) + + expect(UpdateExpression).toContain('ADD #a_1 :a_1') + expect(ExpressionAttributeNames).toMatchObject({ '#a_1': 'test_number' }) + }) + + it('ignores additional attribute', () => { + const { ExpressionAttributeNames = {} } = TestEntity.build(UpdateItemCommand) + .item({ + email: 'test-pk', + sort: 'test-sk', + // @ts-expect-error + fooBar: '?' + }) + .params() + + expect(Object.values(ExpressionAttributeNames)).not.toContain('fooBar') + }) + + it('fails when missing an "always" required field', () => { + const invalidCall = () => + TestEntity3.build(UpdateItemCommand) + .item( + // @ts-expect-error + { email: 'test-pk' } + ) + .params() + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow(expect.objectContaining({ code: 'parsing.attributeRequired' })) + }) + + it('sets capacity options', () => { + const { ReturnConsumedCapacity } = TestEntity.build(UpdateItemCommand) + .item({ email: 'x', sort: 'y' }) + .options({ capacity: 'NONE' }) + .params() + + expect(ReturnConsumedCapacity).toBe('NONE') + }) + + it('sets metrics options', () => { + const { ReturnItemCollectionMetrics } = TestEntity.build(UpdateItemCommand) + .item({ email: 'x', sort: 'y' }) + .options({ metrics: 'SIZE' }) + .params() + + expect(ReturnItemCollectionMetrics).toBe('SIZE') + }) + + it('sets returnValues options', () => { + const { ReturnValues } = TestEntity.build(UpdateItemCommand) + .item({ email: 'x', sort: 'y' }) + .options({ returnValues: 'ALL_OLD' }) + .params() + + expect(ReturnValues).toBe('ALL_OLD') + }) + + it('fails on invalid capacity option', () => { + const invalidCall = () => + TestEntity.build(UpdateItemCommand) + .item({ email: 'x', sort: 'y' }) + .options({ + // @ts-expect-error + capacity: 'test' + }) + .params() + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow( + expect.objectContaining({ code: 'operations.invalidCapacityOption' }) + ) + }) + + it('fails on invalid metrics option', () => { + const invalidCall = () => + TestEntity.build(UpdateItemCommand) + .item({ email: 'x', sort: 'y' }) + .options({ + // @ts-expect-error + metrics: 'test' + }) + .params() + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow( + expect.objectContaining({ code: 'operations.invalidMetricsOption' }) + ) + }) + + it('fails on invalid returnValues option', () => { + const invalidCall = () => + TestEntity.build(UpdateItemCommand) + .item({ email: 'x', sort: 'y' }) + .options({ + // @ts-expect-error + returnValues: 'test' + }) + .params() + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow( + expect.objectContaining({ code: 'operations.invalidReturnValuesOption' }) + ) + }) + + it('fails on extra options', () => { + const invalidCall = () => + TestEntity.build(UpdateItemCommand) + .item({ email: 'x', sort: 'y' }) + .options({ + // @ts-expect-error + extra: true + }) + .params() + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow(expect.objectContaining({ code: 'operations.unknownOption' })) + }) + + it('sets conditions', () => { + const { + ExpressionAttributeNames, + ExpressionAttributeValues, + ConditionExpression + } = TestEntity.build(UpdateItemCommand) + .item({ email: 'x', sort: 'y' }) + .options({ condition: { attr: 'email', gt: 'test' } }) + .params() + + expect(ConditionExpression).toBe('#c_1 > :c_1') + expect(ExpressionAttributeNames).toMatchObject({ '#c_1': 'pk' }) + expect(ExpressionAttributeValues).toMatchObject({ ':c_1': 'test' }) + }) + + it('missing item', () => { + const invalidCall = () => TestEntity.build(UpdateItemCommand).params() + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow(expect.objectContaining({ code: 'operations.incompleteCommand' })) + }) + + it('transformed key/attribute (partial - 1)', () => { + const { + Key, + UpdateExpression, + ConditionExpression, + ExpressionAttributeNames, + ExpressionAttributeValues + } = TestEntity5.build(UpdateItemCommand) + .item({ + email: 'foo@bar.mail', + sort: 'y', + transformedSet: $add(new Set(['set'])), + transformedList: ['list'], + transformedMap: { str: 'map' }, + transformedRecord: { recordKey: 'recordValue' } + }) + .options({ + condition: { + and: [ + { attr: 'email', eq: 'test' }, + { attr: 'transformedStr', eq: 'str' }, + /** + * @debt feature "Can you apply Contains clauses to Set attributes?" + */ + // { attr: 'transformedSet', contains: 'SET' } + { attr: 'transformedMap.str', eq: 'map' }, + { attr: 'transformedRecord.key', eq: 'value' } + ] + } + }) + .params() + + expect(Key).toMatchObject({ pk: 'EMAIL#foo@bar.mail' }) + expect(UpdateExpression).toContain('SET #s_1[0] = :s_1, #s_2.#s_3 = :s_2, #s_4.#s_5 = :s_3') + expect(UpdateExpression).toContain('ADD #a_1 :a_1') + expect(ExpressionAttributeNames).toMatchObject({ + '#s_1': 'transformedList', + '#s_2': 'transformedMap', + '#s_3': 'str', + '#s_4': 'transformedRecord', + '#s_5': 'RECORD_KEY#recordKey', + '#a_1': 'transformedSet' + }) + expect(ExpressionAttributeValues).toMatchObject({ + ':s_1': 'LIST#list', + ':s_2': 'MAP#map', + ':s_3': 'RECORD_VALUE#recordValue', + ':a_1': new Set(['SET#set']) + }) + + expect(ConditionExpression).toBe( + '(#c_1 = :c_1) AND (#c_2 = :c_2) AND (#c_3.#c_4 = :c_3) AND (#c_5.#c_6 = :c_4)' + ) + expect(ExpressionAttributeNames).toMatchObject({ + '#c_1': 'pk', + '#c_2': 'transformedStr', + '#c_3': 'transformedMap', + '#c_4': 'str', + '#c_5': 'transformedRecord', + '#c_6': 'RECORD_KEY#key' + }) + expect(ExpressionAttributeValues).toMatchObject({ + ':a_1': new Set(['SET#set']), + ':c_1': 'EMAIL#test', + ':c_2': 'STR#str', + ':c_3': 'MAP#map', + ':c_4': 'RECORD_VALUE#value' + }) + }) + + it('transformed key/attribute (partial - 2)', () => { + const { + Key, + UpdateExpression, + ExpressionAttributeNames, + ExpressionAttributeValues + } = TestEntity5.build(UpdateItemCommand) + .item({ + email: 'foo@bar.mail', + sort: 'y', + transformedSet: $delete(new Set(['set'])), + transformedList: $append(['list']) + }) + .params() + + expect(Key).toMatchObject({ pk: 'EMAIL#foo@bar.mail' }) + expect(UpdateExpression).toContain('SET #s_1 = list_append(#s_1, :s_1)') + expect(UpdateExpression).toContain('DELETE #d_1 :d_1') + expect(ExpressionAttributeNames).toMatchObject({ + '#d_1': 'transformedSet', + '#s_1': 'transformedList' + }) + expect(ExpressionAttributeValues).toMatchObject({ + ':d_1': new Set(['SET#set']), + ':s_1': ['LIST#list'] + }) + }) + + it('transformed key/attribute (complete)', () => { + const { + Key, + UpdateExpression, + ExpressionAttributeNames, + ExpressionAttributeValues + } = TestEntity5.build(UpdateItemCommand) + .item({ + email: 'foo@bar.mail', + sort: 'y', + transformedSet: new Set(['set']), + transformedList: $set(['list']), + transformedMap: $set({ str: 'map' }), + transformedRecord: $set({ recordKey: 'recordValue' }) + }) + .params() + + expect(Key).toMatchObject({ pk: 'EMAIL#foo@bar.mail' }) + expect(UpdateExpression).toContain('SET #s_1 = :s_1, #s_2 = :s_2, #s_3 = :s_3, #s_4 = :s_4') + expect(ExpressionAttributeNames).toMatchObject({ + '#s_1': 'transformedSet', + '#s_2': 'transformedList', + '#s_3': 'transformedMap', + '#s_4': 'transformedRecord' + }) + expect(ExpressionAttributeValues).toMatchObject({ + ':s_1': new Set(['SET#set']), + ':s_2': ['LIST#list'], + ':s_3': { + str: 'MAP#map' + }, + ':s_4': { + 'RECORD_KEY#recordKey': 'RECORD_VALUE#recordValue' + } + }) + }) +}) diff --git a/src/v1/operations/updateItem/utils.ts b/src/v1/operations/updateItem/utils.ts new file mode 100644 index 000000000..1e4358e99 --- /dev/null +++ b/src/v1/operations/updateItem/utils.ts @@ -0,0 +1,117 @@ +import type { Attribute, AttributeValue } from 'v1/schema' +import { isObject } from 'v1/utils/validation/isObject' + +import type { + SET, + GET, + SUM, + SUBTRACT, + ADD, + DELETE, + APPEND, + PREPEND, + Reference, + UpdateItemInputExtension +} from './types' +import { + $HAS_VERB, + $SET, + $GET, + $REMOVE, + $SUM, + $SUBTRACT, + $ADD, + $DELETE, + $APPEND, + $PREPEND +} from './constants' + +export const $set = (value: VALUE): SET => ({ [$HAS_VERB]: true, [$SET]: value }) + +export const hasSetOperation = ( + input: AttributeValue | undefined +): input is { + [$SET]: Extract, { [$SET]: unknown }>[$SET] +} => isObject(input) && $SET in input + +export const $get = < + REFERENCE extends string, + FALLBACK extends undefined | AttributeValue | Reference = undefined +>( + reference: REFERENCE, + fallback?: FALLBACK +): GET => ({ + [$HAS_VERB]: true, + [$GET]: (fallback === undefined ? [reference] : [reference, fallback]) as any +}) + +export const hasGetOperation = ( + input: AttributeValue | undefined +): input is { + [$GET]: Extract, { [$GET]: unknown }>[$GET] +} => isObject(input) && $GET in input + +export const $remove = (): $REMOVE => $REMOVE + +export const $sum = (a: A, b: B): SUM => ({ [$HAS_VERB]: true, [$SUM]: [a, b] }) + +export const hasSumOperation = ( + input: AttributeValue | undefined +): input is { + [$SUM]: Extract, { [$SUM]: unknown }>[$SUM] +} => isObject(input) && $SUM in input + +export const $subtract = (a: A, b: B): SUBTRACT => ({ + [$HAS_VERB]: true, + [$SUBTRACT]: [a, b] +}) + +export const hasSubtractOperation = ( + input: AttributeValue | undefined +): input is { + [$SUBTRACT]: Extract< + AttributeValue, + { [$SUBTRACT]: unknown } + >[$SUBTRACT] +} => isObject(input) && $SUBTRACT in input + +export const $add = (value: VALUE): ADD => ({ [$HAS_VERB]: true, [$ADD]: value }) + +export const hasAddOperation = ( + input: AttributeValue | undefined +): input is { + [$ADD]: Extract, { [$ADD]: unknown }>[$ADD] +} => isObject(input) && $ADD in input + +export const $delete = (value: VALUE): DELETE => ({ + [$HAS_VERB]: true, + [$DELETE]: value +}) + +export const hasDeleteOperation = ( + input: AttributeValue | undefined +): input is { + [$DELETE]: Extract, { [$DELETE]: unknown }>[$DELETE] +} => isObject(input) && $DELETE in input + +export const $append = (value: VALUE): APPEND => ({ + [$HAS_VERB]: true, + [$APPEND]: value +}) + +export const hasAppendOperation = ( + input: AttributeValue | undefined +): input is { + [$APPEND]: Extract, { [$APPEND]: unknown }>[$APPEND] +} => isObject(input) && $APPEND in input + +export const $prepend = (value: VALUE): PREPEND => ({ + [$HAS_VERB]: true, + [$PREPEND]: value +}) + +export const hasPrependOperation = ( + input: AttributeValue | undefined +): input is { + [$PREPEND]: Extract, { [$PREPEND]: unknown }>[$PREPEND] +} => isObject(input) && $PREPEND in input diff --git a/src/v1/operations/utils/errors.ts b/src/v1/operations/utils/errors.ts new file mode 100644 index 000000000..7f63bc962 --- /dev/null +++ b/src/v1/operations/utils/errors.ts @@ -0,0 +1,6 @@ +import type { FormatSavedItemErrorBlueprints } from './formatSavedItem/errors' +import type { ParsePrimaryKeyErrorBlueprints } from './parsePrimaryKey/errors' + +export type OperationUtilsErrorBlueprints = + | FormatSavedItemErrorBlueprints + | ParsePrimaryKeyErrorBlueprints diff --git a/src/v1/operations/utils/formatSavedItem/anyOf.ts b/src/v1/operations/utils/formatSavedItem/anyOf.ts new file mode 100644 index 000000000..db5e2c76b --- /dev/null +++ b/src/v1/operations/utils/formatSavedItem/anyOf.ts @@ -0,0 +1,44 @@ +import type { AttributeValue, AnyOfAttribute } from 'v1/schema' +import { DynamoDBToolboxError } from 'v1/errors' + +import type { FormatSavedAttributeOptions } from './types' +import { formatSavedAttribute } from './attribute' +import { getItemKey } from './utils' + +export const formatSavedAnyOfAttribute = ( + anyOfAttribute: AnyOfAttribute, + savedValue: AttributeValue, + options: FormatSavedAttributeOptions = {} +): AttributeValue => { + let parsedAttribute: AttributeValue | undefined = undefined + + for (const element of anyOfAttribute.elements) { + try { + parsedAttribute = formatSavedAttribute(element, savedValue, options) + break + } catch (error) { + continue + } + } + + if (parsedAttribute === undefined) { + const { partitionKey, sortKey } = options + + throw new DynamoDBToolboxError('operations.formatSavedItem.invalidSavedAttribute', { + message: [ + `Saved item attribute does not match any of the possible sub-types: ${anyOfAttribute.path}.`, + getItemKey({ partitionKey, sortKey }) + ] + .filter(Boolean) + .join(' '), + path: anyOfAttribute.path, + payload: { + received: savedValue, + partitionKey, + sortKey + } + }) + } + + return parsedAttribute +} diff --git a/src/v1/operations/utils/formatSavedItem/attribute.ts b/src/v1/operations/utils/formatSavedItem/attribute.ts new file mode 100644 index 000000000..4c7f8732e --- /dev/null +++ b/src/v1/operations/utils/formatSavedItem/attribute.ts @@ -0,0 +1,66 @@ +import cloneDeep from 'lodash.clonedeep' + +import type { Attribute, RequiredOption, AttributeValue } from 'v1/schema' +import { DynamoDBToolboxError } from 'v1/errors' + +import type { FormatSavedAttributeOptions } from './types' +import { formatSavedPrimitiveAttribute } from './primitive' +import { formatSavedSetAttribute } from './set' +import { formatSavedListAttribute } from './list' +import { formatSavedMapAttribute } from './map' +import { formatSavedAnyOfAttribute } from './anyOf' +import { formatSavedRecordAttribute } from './record' +import { getItemKey } from './utils' + +export const requiringOptions = new Set(['always', 'atLeastOnce']) + +export const isRequired = (attribute: Attribute): boolean => + requiringOptions.has(attribute.required) + +export const formatSavedAttribute = ( + attribute: Attribute, + savedValue: AttributeValue | undefined, + options: FormatSavedAttributeOptions = {} +): AttributeValue | undefined => { + if (savedValue === undefined) { + if (isRequired(attribute) && options.partial !== true) { + const { partitionKey, sortKey } = options + + throw new DynamoDBToolboxError('operations.formatSavedItem.savedAttributeRequired', { + message: [ + `Missing required attribute in saved item: ${attribute.path}.`, + getItemKey({ partitionKey, sortKey }) + ] + .filter(Boolean) + .join(' '), + path: attribute.path, + payload: { + partitionKey, + sortKey + } + }) + } else { + return undefined + } + } + + switch (attribute.type) { + case 'any': + return cloneDeep(savedValue) as AttributeValue + case 'string': + case 'binary': + case 'boolean': + case 'number': + return formatSavedPrimitiveAttribute(attribute, savedValue, options) + case 'set': + return formatSavedSetAttribute(attribute, savedValue, options) + case 'list': + return formatSavedListAttribute(attribute, savedValue, options) + case 'map': + return formatSavedMapAttribute(attribute, savedValue, options) + case 'record': + return formatSavedRecordAttribute(attribute, savedValue, options) + case 'anyOf': + return formatSavedAnyOfAttribute(attribute, savedValue, options) + } +} diff --git a/src/v1/operations/utils/formatSavedItem/entity.ts b/src/v1/operations/utils/formatSavedItem/entity.ts new file mode 100644 index 000000000..d32aae542 --- /dev/null +++ b/src/v1/operations/utils/formatSavedItem/entity.ts @@ -0,0 +1,59 @@ +import type { EntityV2, FormattedItem } from 'v1/entity' +import type { Item } from 'v1/schema' +import type { AnyAttributePath } from 'v1/operations/types' + +import { formatSavedAttribute } from './attribute' +import { matchProjection } from './utils' + +type FormatSavedItemOptions = { + attributes?: AnyAttributePath[] + partial?: boolean +} + +export const formatSavedItem = < + ENTITY extends EntityV2, + OPTIONS extends FormatSavedItemOptions +>( + entity: ENTITY, + savedItem: Item, + { attributes, partial = false }: OPTIONS = {} as OPTIONS +): OPTIONS['attributes'] extends AnyAttributePath[] + ? FormattedItem + : FormattedItem => { + const formattedItem: Item = {} + + const schema = entity.schema + + const partitionKey = savedItem[entity.table.partitionKey.name] + const sortKey = entity.table.sortKey && savedItem[entity.table.sortKey.name] + + Object.entries(schema.attributes).forEach(([attributeName, attribute]) => { + if (attribute.hidden) { + return + } + + const { isProjected, childrenAttributes } = matchProjection( + new RegExp('^' + attributeName), + attributes + ) + + if (!isProjected) { + return + } + + const attributeSavedAs = attribute.savedAs ?? attributeName + + const formattedAttribute = formatSavedAttribute(attribute, savedItem[attributeSavedAs], { + projectedAttributes: childrenAttributes, + partial, + ...(partitionKey !== undefined ? { partitionKey } : {}), + ...(sortKey !== undefined ? { sortKey } : {}) + }) + + if (formattedAttribute !== undefined) { + formattedItem[attributeName] = formattedAttribute + } + }) + + return formattedItem as any +} diff --git a/src/v1/operations/utils/formatSavedItem/errors.ts b/src/v1/operations/utils/formatSavedItem/errors.ts new file mode 100644 index 000000000..809172b95 --- /dev/null +++ b/src/v1/operations/utils/formatSavedItem/errors.ts @@ -0,0 +1,25 @@ +import type { ErrorBlueprint } from 'v1/errors/blueprint' + +type SavedAttributeRequiredErrorBlueprint = ErrorBlueprint<{ + code: 'operations.formatSavedItem.savedAttributeRequired' + hasPath: true + payload: { + partitionKey?: unknown + sortKey?: unknown + } +}> + +type InvalidSavedAttributeErrorBlueprint = ErrorBlueprint<{ + code: 'operations.formatSavedItem.invalidSavedAttribute' + hasPath: true + payload: { + received: unknown + expected?: unknown + partitionKey?: unknown + sortKey?: unknown + } +}> + +export type FormatSavedItemErrorBlueprints = + | SavedAttributeRequiredErrorBlueprint + | InvalidSavedAttributeErrorBlueprint diff --git a/src/v1/operations/utils/formatSavedItem/index.ts b/src/v1/operations/utils/formatSavedItem/index.ts new file mode 100644 index 000000000..1beabe979 --- /dev/null +++ b/src/v1/operations/utils/formatSavedItem/index.ts @@ -0,0 +1 @@ +export { formatSavedItem } from './entity' diff --git a/src/v1/operations/utils/formatSavedItem/list.ts b/src/v1/operations/utils/formatSavedItem/list.ts new file mode 100644 index 000000000..3f33fe9a6 --- /dev/null +++ b/src/v1/operations/utils/formatSavedItem/list.ts @@ -0,0 +1,53 @@ +import type { ListAttribute, AttributeValue, ListAttributeValue } from 'v1/schema' +import { isArray } from 'v1/utils/validation' +import { DynamoDBToolboxError } from 'v1/errors' + +import type { FormatSavedAttributeOptions } from './types' +import { formatSavedAttribute } from './attribute' +import { matchProjection, getItemKey } from './utils' + +export const formatSavedListAttribute = ( + listAttribute: ListAttribute, + savedValue: AttributeValue, + { projectedAttributes, ...restOptions }: FormatSavedAttributeOptions = {} +): ListAttributeValue => { + if (!isArray(savedValue)) { + const { partitionKey, sortKey } = restOptions + + throw new DynamoDBToolboxError('operations.formatSavedItem.invalidSavedAttribute', { + message: [ + `Invalid attribute in saved item: ${listAttribute.path}. Should be a ${listAttribute.type}.`, + getItemKey({ partitionKey, sortKey }) + ] + .filter(Boolean) + .join(' '), + path: listAttribute.path, + payload: { + received: savedValue, + expected: listAttribute.type, + partitionKey, + sortKey + } + }) + } + + // We don't need isProjected: + // - Either whole list is projected and we already know => projectedAttributes undefined + // - Either some elements are projected => childrenAttributes undefined + // - Either projection is nested => childrenAttributes defined + const { childrenAttributes } = matchProjection(/\[\d+\]/, projectedAttributes) + + const parsedValues: ListAttributeValue = [] + for (const savedElement of savedValue) { + const parsedElement = formatSavedAttribute(listAttribute.elements, savedElement, { + projectedAttributes: childrenAttributes, + ...restOptions + }) + + if (parsedElement !== undefined) { + parsedValues.push(parsedElement) + } + } + + return parsedValues +} diff --git a/src/v1/operations/utils/formatSavedItem/map.ts b/src/v1/operations/utils/formatSavedItem/map.ts new file mode 100644 index 000000000..6f34f6351 --- /dev/null +++ b/src/v1/operations/utils/formatSavedItem/map.ts @@ -0,0 +1,63 @@ +import type { MapAttribute, AttributeValue, MapAttributeValue } from 'v1/schema' +import { isObject } from 'v1/utils/validation' +import { DynamoDBToolboxError } from 'v1/errors' + +import type { FormatSavedAttributeOptions } from './types' +import { formatSavedAttribute } from './attribute' +import { matchProjection, getItemKey } from './utils' + +export const formatSavedMapAttribute = ( + mapAttribute: MapAttribute, + savedValue: AttributeValue, + { projectedAttributes, ...restOptions }: FormatSavedAttributeOptions = {} +): MapAttributeValue => { + if (!isObject(savedValue)) { + const { partitionKey, sortKey } = restOptions + + throw new DynamoDBToolboxError('operations.formatSavedItem.invalidSavedAttribute', { + message: [ + `Invalid attribute in saved item: ${mapAttribute.path}. Should be a ${mapAttribute.type}.`, + getItemKey({ partitionKey, sortKey }) + ] + .filter(Boolean) + .join(' '), + path: mapAttribute.path, + payload: { + received: savedValue, + expected: mapAttribute.type, + partitionKey, + sortKey + } + }) + } + + const formattedMap: MapAttributeValue = {} + + Object.entries(mapAttribute.attributes).forEach(([attributeName, attribute]) => { + if (attribute.hidden) { + return + } + + const { isProjected, childrenAttributes } = matchProjection( + new RegExp('^\\.' + attributeName), + projectedAttributes + ) + + if (!isProjected) { + return + } + + const attributeSavedAs = attribute.savedAs ?? attributeName + + const formattedAttribute = formatSavedAttribute(attribute, savedValue[attributeSavedAs], { + projectedAttributes: childrenAttributes, + ...restOptions + }) + + if (formattedAttribute !== undefined) { + formattedMap[attributeName] = formattedAttribute + } + }) + + return formattedMap +} diff --git a/src/v1/operations/utils/formatSavedItem/primitive.ts b/src/v1/operations/utils/formatSavedItem/primitive.ts new file mode 100644 index 000000000..5d08be62e --- /dev/null +++ b/src/v1/operations/utils/formatSavedItem/primitive.ts @@ -0,0 +1,69 @@ +import type { + PrimitiveAttribute, + AttributeValue, + PrimitiveAttributeValue, + ResolvedPrimitiveAttribute, + Transformer +} from 'v1/schema' +import { validatorsByPrimitiveType } from 'v1/utils/validation' +import { DynamoDBToolboxError } from 'v1/errors' + +import type { FormatSavedAttributeOptions } from './types' +import { getItemKey } from './utils' + +export const formatSavedPrimitiveAttribute = ( + primitiveAttribute: PrimitiveAttribute, + savedValue: AttributeValue, + options: FormatSavedAttributeOptions = {} +): PrimitiveAttributeValue => { + const { partitionKey, sortKey } = options + + const validator = validatorsByPrimitiveType[primitiveAttribute.type] + if (!validator(savedValue)) { + throw new DynamoDBToolboxError('operations.formatSavedItem.invalidSavedAttribute', { + message: [ + `Invalid attribute in saved item: ${primitiveAttribute.path}. Should be a ${primitiveAttribute.type}.`, + getItemKey({ partitionKey, sortKey }) + ] + .filter(Boolean) + .join(' '), + path: primitiveAttribute.path, + payload: { + received: savedValue, + expected: primitiveAttribute.type, + partitionKey, + sortKey + } + }) + } + + /** + * @debt type "validator should act as typeguard" + */ + const savedPrimitive = savedValue as ResolvedPrimitiveAttribute + const transformer = primitiveAttribute.transform as Transformer + const formattedValue = + transformer !== undefined ? transformer.format(savedPrimitive) : savedPrimitive + + if (primitiveAttribute.enum !== undefined && !primitiveAttribute.enum.includes(formattedValue)) { + throw new DynamoDBToolboxError('operations.formatSavedItem.invalidSavedAttribute', { + message: [ + `Invalid attribute in saved item: ${ + primitiveAttribute.path + }. Should be one of: ${primitiveAttribute.enum.map(String).join(', ')}.`, + getItemKey({ partitionKey, sortKey }) + ] + .filter(Boolean) + .join(' '), + path: primitiveAttribute.path, + payload: { + received: formattedValue, + expected: primitiveAttribute.enum, + partitionKey, + sortKey + } + }) + } + + return formattedValue +} diff --git a/src/v1/operations/utils/formatSavedItem/primitive.unit.test.ts b/src/v1/operations/utils/formatSavedItem/primitive.unit.test.ts new file mode 100644 index 000000000..22120286b --- /dev/null +++ b/src/v1/operations/utils/formatSavedItem/primitive.unit.test.ts @@ -0,0 +1,40 @@ +import { DynamoDBToolboxError } from 'v1/errors' +import { string } from 'v1/schema' +import { prefix } from 'v1/transformers' + +import { formatSavedPrimitiveAttribute } from './primitive' + +describe('parseSavedPrimitiveAttribute', () => { + it('throws an error if saved value type does not match', () => { + const str = string().freeze('path') + + const invalidCall = () => formatSavedPrimitiveAttribute(str, 42) + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow( + expect.objectContaining({ code: 'operations.formatSavedItem.invalidSavedAttribute' }) + ) + }) + + it('uses formatter if transformer has been provided', () => { + const str = string().transform(prefix('TEST')).freeze('path') + + const parsedValue = formatSavedPrimitiveAttribute(str, 'TEST#bar') + + expect(parsedValue).toBe('bar') + }) + + it('throws if value is not part of enum', () => { + const str = string().enum('foo', 'bar').transform(prefix('TEST')).freeze('path') + + const parsedValue = formatSavedPrimitiveAttribute(str, 'TEST#bar') + expect(parsedValue).toBe('bar') + + const invalidCall = () => formatSavedPrimitiveAttribute(str, 'TEST#baz') + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow( + expect.objectContaining({ code: 'operations.formatSavedItem.invalidSavedAttribute' }) + ) + }) +}) diff --git a/src/v1/operations/utils/formatSavedItem/record.ts b/src/v1/operations/utils/formatSavedItem/record.ts new file mode 100644 index 000000000..8b1368add --- /dev/null +++ b/src/v1/operations/utils/formatSavedItem/record.ts @@ -0,0 +1,61 @@ +import type { RecordAttribute, AttributeValue, RecordAttributeValue } from 'v1/schema' +import { isObject } from 'v1/utils/validation' +import { DynamoDBToolboxError } from 'v1/errors' + +import type { FormatSavedAttributeOptions } from './types' +import { formatSavedPrimitiveAttribute } from './primitive' +import { formatSavedAttribute } from './attribute' +import { matchProjection, getItemKey } from './utils' + +export const formatSavedRecordAttribute = ( + recordAttribute: RecordAttribute, + savedValue: AttributeValue, + { projectedAttributes, ...restOptions }: FormatSavedAttributeOptions +): RecordAttributeValue => { + if (!isObject(savedValue)) { + const { partitionKey, sortKey } = restOptions + + throw new DynamoDBToolboxError('operations.formatSavedItem.invalidSavedAttribute', { + message: [ + `Invalid attribute in saved item: ${recordAttribute.path}. Should be a ${recordAttribute.type}.`, + getItemKey({ partitionKey, sortKey }) + ] + .filter(Boolean) + .join(' '), + path: recordAttribute.path, + payload: { + received: savedValue, + expected: recordAttribute.type, + partitionKey, + sortKey + } + }) + } + + const formattedRecord: RecordAttributeValue = {} + + Object.entries(savedValue).forEach(([key, element]) => { + const parsedKey = formatSavedPrimitiveAttribute( + recordAttribute.keys, + key, + restOptions + ) as string + + // We don't need isProjected: We used the saved value key so we know it is + const { childrenAttributes } = matchProjection( + new RegExp('^\\.' + parsedKey), + projectedAttributes + ) + + const formattedAttribute = formatSavedAttribute(recordAttribute.elements, element, { + projectedAttributes: childrenAttributes, + ...restOptions + }) + + if (formattedAttribute !== undefined) { + formattedRecord[parsedKey] = formattedAttribute + } + }) + + return formattedRecord +} diff --git a/src/v1/operations/utils/formatSavedItem/set.ts b/src/v1/operations/utils/formatSavedItem/set.ts new file mode 100644 index 000000000..0a4694b40 --- /dev/null +++ b/src/v1/operations/utils/formatSavedItem/set.ts @@ -0,0 +1,45 @@ +import type { SetAttribute, AttributeValue, SetAttributeValue } from 'v1/schema' +import { isSet } from 'v1/utils/validation' +import { DynamoDBToolboxError } from 'v1/errors' + +import type { FormatSavedAttributeOptions } from './types' +import { formatSavedAttribute } from './attribute' +import { getItemKey } from './utils' + +export const formatSavedSetAttribute = ( + setAttribute: SetAttribute, + savedValue: AttributeValue, + options: FormatSavedAttributeOptions = {} +): SetAttributeValue => { + if (!isSet(savedValue)) { + const { partitionKey, sortKey } = options + + throw new DynamoDBToolboxError('operations.formatSavedItem.invalidSavedAttribute', { + message: [ + `Invalid attribute in saved item: ${setAttribute.path}. Should be a ${setAttribute.type}.`, + getItemKey({ partitionKey, sortKey }) + ] + .filter(Boolean) + .join(' '), + path: setAttribute.path, + payload: { + received: savedValue, + expected: setAttribute.type, + partitionKey, + sortKey + } + }) + } + + const parsedPutItemInput: SetAttributeValue = new Set() + + for (const savedElement of savedValue) { + const parsedElement = formatSavedAttribute(setAttribute.elements, savedElement, options) + + if (parsedElement !== undefined) { + parsedPutItemInput.add(parsedElement) + } + } + + return parsedPutItemInput +} diff --git a/src/v1/operations/utils/formatSavedItem/types.ts b/src/v1/operations/utils/formatSavedItem/types.ts new file mode 100644 index 000000000..89902f17f --- /dev/null +++ b/src/v1/operations/utils/formatSavedItem/types.ts @@ -0,0 +1,6 @@ +export interface FormatSavedAttributeOptions { + projectedAttributes?: string[] + partial?: boolean + partitionKey?: unknown + sortKey?: unknown +} diff --git a/src/v1/operations/utils/formatSavedItem/utils.ts b/src/v1/operations/utils/formatSavedItem/utils.ts new file mode 100644 index 000000000..f0e2b4a58 --- /dev/null +++ b/src/v1/operations/utils/formatSavedItem/utils.ts @@ -0,0 +1,46 @@ +export const matchProjection = ( + attributeNameRegex: RegExp, + projectedAttributes?: string[] +): + | { isProjected: false; childrenAttributes?: never } + | { isProjected: true; childrenAttributes?: string[] } => { + if (projectedAttributes === undefined) { + return { isProjected: true } + } + + const childrenAttributes: string[] = [] + for (const attributePath of projectedAttributes) { + const attributeMatch = attributePath.match(attributeNameRegex) + + if (attributeMatch !== null) { + const [firstMatch] = attributeMatch + childrenAttributes.push(attributePath.slice(firstMatch.length)) + } + } + + if (childrenAttributes.length === 0) { + return { isProjected: false } + } + + if (childrenAttributes.some(attribute => attribute === '')) { + // We do not add childrenAttributes as we want all of them + return { isProjected: true } + } + + return { isProjected: true, childrenAttributes } +} + +export const getItemKey = ({ + partitionKey, + sortKey +}: { + partitionKey?: unknown + sortKey?: unknown +}) => + partitionKey && + [ + partitionKey && `Partition key: ${String(partitionKey)}`, + sortKey && `Sort key: ${String(sortKey)}` + ] + .filter(Boolean) + .join(' / ') diff --git a/src/v1/operations/utils/parseKeyInput.ts b/src/v1/operations/utils/parseKeyInput.ts new file mode 100644 index 000000000..c510f1c37 --- /dev/null +++ b/src/v1/operations/utils/parseKeyInput.ts @@ -0,0 +1,19 @@ +import type { EntityV2 } from 'v1/entity' +import type { Item, RequiredOption } from 'v1/schema' +import { parseSchemaClonedInput } from 'v1/validation/parseClonedInput' + +type EntityKeyInputParser = (entity: EntityV2, input: Item) => Generator + +const requiringOptions = new Set(['always']) + +export const parseEntityKeyInput: EntityKeyInputParser = (entity, input) => { + const parser = parseSchemaClonedInput(entity.schema, input, { + operationName: 'key', + requiringOptions, + filters: { key: true } + }) + + parser.next() // cloned + + return parser +} diff --git a/src/v1/operations/utils/parseOptions/parseCapacityOption.ts b/src/v1/operations/utils/parseOptions/parseCapacityOption.ts new file mode 100644 index 000000000..f8e1943cb --- /dev/null +++ b/src/v1/operations/utils/parseOptions/parseCapacityOption.ts @@ -0,0 +1,15 @@ +import { DynamoDBToolboxError } from 'v1/errors/dynamoDBToolboxError' +import { CapacityOption, capacityOptionsSet } from 'v1/operations/constants/options/capacity' + +export const parseCapacityOption = (capacity: CapacityOption): CapacityOption => { + if (!capacityOptionsSet.has(capacity)) { + throw new DynamoDBToolboxError('operations.invalidCapacityOption', { + message: `Invalid capacity option: '${String(capacity)}'. 'capacity' must be one of: ${[ + ...capacityOptionsSet + ].join(', ')}.`, + payload: { capacity } + }) + } + + return capacity +} diff --git a/src/v1/operations/utils/parseOptions/parseConsistentOption.ts b/src/v1/operations/utils/parseOptions/parseConsistentOption.ts new file mode 100644 index 000000000..c8a6fe098 --- /dev/null +++ b/src/v1/operations/utils/parseOptions/parseConsistentOption.ts @@ -0,0 +1,22 @@ +import { DynamoDBToolboxError } from 'v1/errors/dynamoDBToolboxError' +import { isBoolean } from 'v1/utils/validation/isBoolean' + +export const parseConsistentOption = (consistent: boolean, index?: string): boolean => { + if (!isBoolean(consistent)) { + throw new DynamoDBToolboxError('operations.invalidConsistentOption', { + message: `Invalid consistent option: '${String(consistent)}'. 'consistent' must be boolean.`, + payload: { consistent } + }) + } + + if (consistent && index !== undefined) { + throw new DynamoDBToolboxError('operations.invalidConsistentOption', { + message: `Invalid consistent option: '${String( + consistent + )}'. Queries on secondary indexes cannot be consistent.`, + payload: { consistent } + }) + } + + return consistent +} diff --git a/src/v1/operations/utils/parseOptions/parseIndexOption.ts b/src/v1/operations/utils/parseOptions/parseIndexOption.ts new file mode 100644 index 000000000..549f4e349 --- /dev/null +++ b/src/v1/operations/utils/parseOptions/parseIndexOption.ts @@ -0,0 +1,24 @@ +import type { TableV2 } from 'v1/table' + +import { DynamoDBToolboxError } from 'v1/errors/dynamoDBToolboxError' +import { isString } from 'v1/utils/validation/isString' + +export const parseIndexOption = (table: TableV2, index: string): string => { + if (!isString(index)) { + throw new DynamoDBToolboxError('operations.invalidIndexOption', { + message: `Invalid index option: '${String(index)}'. 'index' must be a string.`, + payload: { index } + }) + } + + if (table.indexes[index] === undefined) { + throw new DynamoDBToolboxError('operations.invalidIndexOption', { + message: `Invalid index option: '${String( + index + )}'. Index is not defined on Table, please provide an 'indexes' option to its constructor.`, + payload: { index } + }) + } + + return index +} diff --git a/src/v1/operations/utils/parseOptions/parseLimitOption.ts b/src/v1/operations/utils/parseOptions/parseLimitOption.ts new file mode 100644 index 000000000..0f98a3d08 --- /dev/null +++ b/src/v1/operations/utils/parseOptions/parseLimitOption.ts @@ -0,0 +1,13 @@ +import { DynamoDBToolboxError } from 'v1/errors/dynamoDBToolboxError' +import { isInteger } from 'v1/utils/validation/isInteger' + +export const parseLimitOption = (limit: number): number => { + if (!isInteger(limit) || limit <= 0) { + throw new DynamoDBToolboxError('operations.invalidLimitOption', { + message: `Invalid limit option: '${String(limit)}'. 'limit' must be an integer > 0.`, + payload: { limit } + }) + } + + return limit +} diff --git a/src/v1/operations/utils/parseOptions/parseMaxPagesOption.ts b/src/v1/operations/utils/parseOptions/parseMaxPagesOption.ts new file mode 100644 index 000000000..71319647a --- /dev/null +++ b/src/v1/operations/utils/parseOptions/parseMaxPagesOption.ts @@ -0,0 +1,19 @@ +import { DynamoDBToolboxError } from 'v1/errors/dynamoDBToolboxError' +import { isInteger } from 'v1/utils/validation/isInteger' + +export const parseMaxPagesOption = (maxPages: number): number => { + if (maxPages === Infinity) { + return maxPages + } + + if (!isInteger(maxPages) || maxPages <= 0) { + throw new DynamoDBToolboxError('operations.invalidMaxPagesOption', { + message: `Invalid limit option: '${String( + maxPages + )}'. 'limit' must be Infinity or an integer > 0.`, + payload: { maxPages } + }) + } + + return maxPages +} diff --git a/src/v1/operations/utils/parseOptions/parseMetricsOption.ts b/src/v1/operations/utils/parseOptions/parseMetricsOption.ts new file mode 100644 index 000000000..32266c6ea --- /dev/null +++ b/src/v1/operations/utils/parseOptions/parseMetricsOption.ts @@ -0,0 +1,15 @@ +import { DynamoDBToolboxError } from 'v1/errors/dynamoDBToolboxError' +import { metricsOptionsSet, MetricsOption } from 'v1/operations/constants/options/metrics' + +export const parseMetricsOption = (metrics: MetricsOption): MetricsOption => { + if (!metricsOptionsSet.has(metrics)) { + throw new DynamoDBToolboxError('operations.invalidMetricsOption', { + message: `Invalid metrics option: '${String(metrics)}'. 'metrics' must be one of: ${[ + ...metricsOptionsSet + ].join(', ')}.`, + payload: { metrics } + }) + } + + return metrics +} diff --git a/src/v1/operations/utils/parseOptions/parseReturnValuesOption.ts b/src/v1/operations/utils/parseOptions/parseReturnValuesOption.ts new file mode 100644 index 000000000..0bc0a6e53 --- /dev/null +++ b/src/v1/operations/utils/parseOptions/parseReturnValuesOption.ts @@ -0,0 +1,18 @@ +import { DynamoDBToolboxError } from 'v1/errors/dynamoDBToolboxError' +import { ReturnValuesOption } from 'v1/operations/constants/options/returnValues' + +export const parseReturnValuesOption = ( + allowedReturnValuesOptions: Set, + returnValues: ALLOWED_RETURN_VALUES_OPTION +): ALLOWED_RETURN_VALUES_OPTION => { + if (!allowedReturnValuesOptions.has(returnValues)) { + throw new DynamoDBToolboxError('operations.invalidReturnValuesOption', { + message: `Invalid returnValues option: '${String( + returnValues + )}'. 'returnValues' must be one of: ${[...allowedReturnValuesOptions].join(', ')}.`, + payload: { returnValues } + }) + } + + return returnValues +} diff --git a/src/v1/operations/utils/parseOptions/parseSelectOption.ts b/src/v1/operations/utils/parseOptions/parseSelectOption.ts new file mode 100644 index 000000000..d5148bd76 --- /dev/null +++ b/src/v1/operations/utils/parseOptions/parseSelectOption.ts @@ -0,0 +1,36 @@ +import isEmpty from 'lodash.isempty' + +import { DynamoDBToolboxError } from 'v1/errors' +import { SelectOption, selectOptionsSet } from 'v1/operations/constants/options/select' + +export const parseSelectOption = ( + select: SelectOption, + { index, attributes }: { index?: string; attributes?: string[] | undefined } = {} +): SelectOption => { + if (!selectOptionsSet.has(select)) { + throw new DynamoDBToolboxError('operations.invalidSelectOption', { + message: `Invalid select option: '${String(select)}'. 'select' must be one of: ${[ + ...selectOptionsSet + ].join(', ')}.`, + payload: { select } + }) + } + + if (select === 'ALL_PROJECTED_ATTRIBUTES' && index === undefined) { + throw new DynamoDBToolboxError('operations.invalidSelectOption', { + message: `Invalid select option: '${String(select)}'. Please provide an 'index' option.`, + payload: { select } + }) + } + + if (!isEmpty(attributes) && select !== 'SPECIFIC_ATTRIBUTES') { + throw new DynamoDBToolboxError('operations.invalidSelectOption', { + message: `Invalid select option: '${String( + select + )}'. Select must be 'SPECIFIC_ATTRIBUTES' if a filter expression has been provided.`, + payload: { select } + }) + } + + return select +} diff --git a/src/v1/operations/utils/parseOptions/rejectExtraOptions.ts b/src/v1/operations/utils/parseOptions/rejectExtraOptions.ts new file mode 100644 index 000000000..8ae441db1 --- /dev/null +++ b/src/v1/operations/utils/parseOptions/rejectExtraOptions.ts @@ -0,0 +1,12 @@ +import { DynamoDBToolboxError } from 'v1/errors/dynamoDBToolboxError' + +export const rejectExtraOptions = (extraOptions: {}): void => { + const [extraOption] = Object.keys(extraOptions) + + if (extraOption !== undefined) { + throw new DynamoDBToolboxError('operations.unknownOption', { + message: `Unkown option: ${extraOption}.`, + payload: { option: extraOption } + }) + } +} diff --git a/src/v1/operations/utils/parsePrimaryKey/errors.ts b/src/v1/operations/utils/parsePrimaryKey/errors.ts new file mode 100644 index 000000000..e74e98dd2 --- /dev/null +++ b/src/v1/operations/utils/parsePrimaryKey/errors.ts @@ -0,0 +1,13 @@ +import type { ErrorBlueprint } from 'v1/errors/blueprint' + +type InvalidKeyPartErrorBlueprint = ErrorBlueprint<{ + code: 'operations.parsePrimaryKey.invalidKeyPart' + hasPath: true + payload: { + expected: string + received: unknown + keyPart: string + } +}> + +export type ParsePrimaryKeyErrorBlueprints = InvalidKeyPartErrorBlueprint diff --git a/src/v1/operations/utils/parsePrimaryKey/index.ts b/src/v1/operations/utils/parsePrimaryKey/index.ts new file mode 100644 index 000000000..422a67766 --- /dev/null +++ b/src/v1/operations/utils/parsePrimaryKey/index.ts @@ -0,0 +1 @@ +export { parsePrimaryKey } from './parsePrimaryKey' diff --git a/src/v1/operations/utils/parsePrimaryKey/parsePrimaryKey.ts b/src/v1/operations/utils/parsePrimaryKey/parsePrimaryKey.ts new file mode 100644 index 000000000..37e7fc954 --- /dev/null +++ b/src/v1/operations/utils/parsePrimaryKey/parsePrimaryKey.ts @@ -0,0 +1,59 @@ +import type { EntityV2 } from 'v1/entity' +import type { Item, AttributeValue, Extension } from 'v1/schema' +import type { PrimaryKey } from 'v1/table' +import { validatorsByPrimitiveType } from 'v1/utils/validation' +import { DynamoDBToolboxError } from 'v1/errors/dynamoDBToolboxError' + +export const parsePrimaryKey = ( + entity: ENTITY, + keyInput: Item +): PrimaryKey => { + const { table } = entity + const { partitionKey, sortKey } = table + + const primaryKey: Record = {} + + const partitionKeyValidator = validatorsByPrimitiveType[partitionKey.type] + const partitionKeyValue = keyInput[partitionKey.name] + + if (partitionKeyValidator(partitionKeyValue)) { + /** + * @debt type "TODO: Make validator act as primitive typeguard" + */ + primaryKey[partitionKey.name] = partitionKeyValue as number | string | Buffer + } else { + throw new DynamoDBToolboxError('operations.parsePrimaryKey.invalidKeyPart', { + message: `Invalid partition key: ${partitionKey.name}`, + path: partitionKey.name, + payload: { + expected: partitionKey.type, + received: partitionKeyValue, + keyPart: 'partitionKey' + } + }) + } + + if (sortKey !== undefined) { + const sortKeyValidator = validatorsByPrimitiveType[sortKey.type] + const sortKeyValue = keyInput[sortKey.name] + + if (sortKeyValidator(sortKeyValue)) { + /** + * @debt type "TODO: Make validator act as primitive typeguard" + */ + primaryKey[sortKey.name] = sortKeyValue as number | string | Buffer + } else { + throw new DynamoDBToolboxError('operations.parsePrimaryKey.invalidKeyPart', { + message: `Invalid sort key: ${sortKey.name}`, + path: sortKey.name, + payload: { + expected: sortKey.type, + received: sortKeyValue, + keyPart: 'sortKey' + } + }) + } + } + + return primaryKey as PrimaryKey +} diff --git a/src/v1/playground/commands/getItem.ts b/src/v1/playground/commands/getItem.ts new file mode 100644 index 000000000..139b5a416 --- /dev/null +++ b/src/v1/playground/commands/getItem.ts @@ -0,0 +1,45 @@ +import { GetItemCommand } from 'v1/operations' +import { mockEntity } from 'v1/test-tools' + +import { UserEntity } from '../entity' + +const mockedEntity = mockEntity(UserEntity) + +mockedEntity.on(GetItemCommand).resolve({ + Item: { + created: '2020-01-01T00:00:00.000Z', + modified: '2021-01-01T00:00:00.000Z', + userId: 'foo', + age: 42, + constant: 'toto', + firstName: 'Thomus', + lastName: 'Arbeit', + completeName: 'Thomus Arbeit', + parents: { + father: 'yo', + mother: 'ya' + }, + castedStr: 'bar' + } +}) + +const test = async () => { + const test = await UserEntity.build(GetItemCommand).key({ userId: 'foo', age: 41 }).send() + console.log('TEST', test) +} + +const run = async () => { + console.log(mockedEntity.received(GetItemCommand).count()) + console.log(mockedEntity.received(GetItemCommand).allArgs()) + console.log(mockedEntity.received(GetItemCommand).args(0) ?? '-') + await test() + console.log(mockedEntity.received(GetItemCommand).count()) + console.log(mockedEntity.received(GetItemCommand).allArgs()) + console.log(mockedEntity.received(GetItemCommand).args(0) ?? '-') + mockedEntity.reset() + console.log(mockedEntity.received(GetItemCommand).count()) + console.log(mockedEntity.received(GetItemCommand).allArgs()) + console.log(mockedEntity.received(GetItemCommand).args(0) ?? '-') +} + +void run() diff --git a/src/v1/playground/commands/putItem.ts b/src/v1/playground/commands/putItem.ts new file mode 100644 index 000000000..38aa7275d --- /dev/null +++ b/src/v1/playground/commands/putItem.ts @@ -0,0 +1,22 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { PutItemCommand } from 'v1/operations' + +import { UserEntity } from '../entity' + +const test = async () => { + const commandB = UserEntity.build(PutItemCommand) + .item({ + userId: 'some-user-id', + age: 42, + firstName: 'john', + lastName: 'dow', + parents: { + father: 'dark vador', + mother: 'toto' + }, + someSet: new Set(['foo', 'bar']), + castedStr: 'foo' + }) + .options({ returnValues: 'ALL_OLD' }) + .send() +} diff --git a/src/v1/playground/entity.ts b/src/v1/playground/entity.ts new file mode 100644 index 000000000..ef39abe9b --- /dev/null +++ b/src/v1/playground/entity.ts @@ -0,0 +1,47 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { + number, + string, + map, + set, + any, + schema, + EntityV2, + PutItemInput, + SavedItem, + FormattedItem, + KeyInput, + UpdateItemInput, + prefix +} from 'v1' + +import { MyTable } from './table' + +export const UserEntity = new EntityV2({ + name: 'User', + table: MyTable, + schema: schema({ + userId: string().key(), + age: number().key().enum(41, 42).putDefault(42).savedAs('sk'), + constant: string().const('toto').optional(), + firstName: string().savedAs('fn'), + lastName: string().savedAs('ln'), + parents: map({ + father: string(), + mother: string() + }), + someSet: set(string().enum('foo', 'bar')).optional(), + castedStr: any().castAs<'foo' | 'bar'>(), + transformedStr: string().optional().enum('foo', 'bar').transform(prefix('toto')) + }).and(prevSchema => ({ + completeName: string().putLink( + ({ firstName, lastName }) => firstName + ' ' + lastName + ) + })) +}) + +type UserPutItemInput = PutItemInput +type SavedUser = SavedItem +type UserOutput = FormattedItem +type UserInputKeys = KeyInput +type UserUpdateItemInput = UpdateItemInput diff --git a/src/v1/playground/schema.ts b/src/v1/playground/schema.ts new file mode 100644 index 000000000..c14f3a8f9 --- /dev/null +++ b/src/v1/playground/schema.ts @@ -0,0 +1,82 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { + binary, + boolean, + number, + string, + map, + list, + any, + schema, + PutItemInput, + FormattedAttribute, + SavedItem, + KeyInput +} from 'v1' + +const playgroundSchema1 = schema({ + reqStr: string(), + reqStrWithDef: string().putDefault('string'), + hiddenStr: string().optional().hidden(), + num: number().optional(), + bool: boolean().optional(), + bin: binary().optional(), + map: map({ + nestedMap: map({ + str: string().optional() + }).optional() + }).optional(), + reqMap: map({ + str: string().optional() + }), + hiddenMap: map({ + str: string().optional() + }) + .optional() + .hidden(), + reqList: list( + map({ + str: string().optional() + }) + ), + hiddenList: list(string()).optional().hidden() +}) + +type PlaygroundSchema1PutItemInput = PutItemInput +type PlaygroundSchema1FormattedItem = FormattedAttribute + +const allCasesOfProps = { + optProp: string().optional(), + optPropWithIndepDef: string().optional().putDefault('foo'), + reqProp: string(), + reqPropWithIndepDef: string().putDefault('baz') +} + +const playgroundSchema2 = schema({ + ...allCasesOfProps, + map: map(allCasesOfProps), + list: list(map(allCasesOfProps)) +}).and(schema => ({ + optLink: string() + .optional() + .putLink(({ optPropWithIndepDef }) => optPropWithIndepDef), + reqLink: string().putLink(({ reqPropWithIndepDef }) => reqPropWithIndepDef) +})) + +type PlaygroundSchema2FormattedItem = FormattedAttribute +type PlaygroundSchema2PutItemInput = PutItemInput +type PlaygroundSchema2PutItemInputWithDefaults = PutItemInput + +const playgroundSchema3 = schema({ + keyEl: string().key(), + nonKeyEl: string().optional(), + coucou: map({ + renamed: string().savedAs('bar').key() + }) + .savedAs('baz') + .key(), + anyvalue: any() +}) + +type PlaygroundSchema3SavedItem = SavedItem +type PlaygroundSchema3KeyInput = KeyInput diff --git a/src/v1/playground/table.ts b/src/v1/playground/table.ts new file mode 100644 index 000000000..5c6f776d2 --- /dev/null +++ b/src/v1/playground/table.ts @@ -0,0 +1,50 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { DynamoDBClient } from '@aws-sdk/client-dynamodb' +import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb' +import type { Query } from 'v1/operations' + +import { PrimaryKey, TableV2, EntityAttributeSavedAs, IndexNames, IndexSchema } from 'v1/table' + +const dynamoDbClient = new DynamoDBClient({}) + +const documentClient = DynamoDBDocumentClient.from(dynamoDbClient) + +export const MyTable = new TableV2({ + name: 'MySuperTable', + partitionKey: { + name: 'userId', + type: 'string' + }, + sortKey: { + name: 'sk', + type: 'number' + }, + documentClient, + indexes: { + lsi: { + type: 'local', + sortKey: { + name: 'lsi_sk', + type: 'string' + } + }, + gsi: { + type: 'global', + partitionKey: { + name: 'GSI1_PK', + type: 'string' + }, + sortKey: { + name: 'GSI1_SK', + type: 'binary' + } + } + } +}) + +type PK = PrimaryKey +type EntityAttrSavedAs = EntityAttributeSavedAs +type AllIndexes = IndexNames +type LSI = IndexSchema +type GSI = IndexSchema +type AnyQuery = Query diff --git a/src/v1/schema/attributes/any/freeze.ts b/src/v1/schema/attributes/any/freeze.ts new file mode 100644 index 000000000..ed52d5899 --- /dev/null +++ b/src/v1/schema/attributes/any/freeze.ts @@ -0,0 +1,50 @@ +import type { O } from 'ts-toolbelt' + +import { validateAttributeProperties } from '../shared/validate' +import { + $required, + $hidden, + $key, + $savedAs, + $defaults, + $castAs +} from '../constants/attributeOptions' + +import type { $AnyAttributeState, AnyAttribute } from './interface' +import type { AnyAttributeState } from './types' + +export type FreezeAnyAttribute<$ANY_ATTRIBUTE extends $AnyAttributeState> = + // Applying void O.Update improves type display + O.Update< + AnyAttribute<{ + required: $ANY_ATTRIBUTE[$required] + hidden: $ANY_ATTRIBUTE[$hidden] + key: $ANY_ATTRIBUTE[$key] + savedAs: $ANY_ATTRIBUTE[$savedAs] + defaults: $ANY_ATTRIBUTE[$defaults] + castAs: $ANY_ATTRIBUTE[$castAs] + }>, + never, + never + > + +type AnyAttributeFreezer = ( + anyAttribute: STATE, + path: string +) => FreezeAnyAttribute<$AnyAttributeState> + +/** + * Validates a warm `any` attribute + * + * @param state Attribute options + * @param path Path of the instance in the related schema (string) + * @return void + */ +export const freezeAnyAttribute: AnyAttributeFreezer = ( + state: STATE, + path: string +) => { + validateAttributeProperties(state, path) + + return { path, type: 'any', ...state } +} diff --git a/src/v1/schema/attributes/any/index.ts b/src/v1/schema/attributes/any/index.ts new file mode 100644 index 000000000..8287e7786 --- /dev/null +++ b/src/v1/schema/attributes/any/index.ts @@ -0,0 +1,9 @@ +export { any } from './typer' +export type { + $AnyAttributeState, + $AnyAttributeNestedState, + $AnyAttribute, + AnyAttribute +} from './interface' +export type { FreezeAnyAttribute } from './freeze' +export type { ResolveAnyAttribute } from './resolve' diff --git a/src/v1/schema/attributes/any/interface.ts b/src/v1/schema/attributes/any/interface.ts new file mode 100644 index 000000000..a47d454f1 --- /dev/null +++ b/src/v1/schema/attributes/any/interface.ts @@ -0,0 +1,275 @@ +import type { O } from 'ts-toolbelt' + +import type { If, ValueOrGetter } from 'v1/types' +import type { + AttributeKeyInput, + AttributePutItemInput, + AttributeUpdateItemInput, + KeyInput, + PutItemInput, + UpdateItemInput +} from 'v1/operations' + +import type { Schema } from '../../interface' +import type { RequiredOption, AtLeastOnce, Never, Always } from '../constants/requiredOptions' +import type { $type, $castAs } from '../constants/attributeOptions' +import type { $SharedAttributeState, SharedAttributeState } from '../shared/interface' + +import type { FreezeAnyAttribute } from './freeze' +import type { AnyAttributeState } from './types' + +export interface $AnyAttributeState + extends $SharedAttributeState { + [$type]: 'any' + [$castAs]: STATE['castAs'] +} + +export interface $AnyAttributeNestedState + extends $AnyAttributeState { + freeze: (path: string) => FreezeAnyAttribute<$AnyAttributeState> +} + +/** + * Any attribute interface + */ +export interface $AnyAttribute + extends $AnyAttributeNestedState { + /** + * Tag attribute as required. Possible values are: + * - `"atLeastOnce"` _(default)_: Required in PUTs, optional in UPDATEs + * - `"never"`: Optional in PUTs and UPDATEs + * - `"always"`: Required in PUTs and UPDATEs + * + * @param nextRequired RequiredOption + */ + required: ( + nextRequired?: NEXT_IS_REQUIRED + ) => $AnyAttribute> + /** + * Shorthand for `required('never')` + */ + optional: () => $AnyAttribute> + /** + * Hide attribute after fetch commands and formatting + */ + hidden: () => $AnyAttribute> + /** + * Tag attribute as needed for Primary Key computing + */ + key: () => $AnyAttribute> + /** + * Rename attribute before save commands + */ + savedAs: ( + nextSavedAs: NEXT_SAVED_AS + ) => $AnyAttribute> + /** + * Cast attribute TS type + */ + castAs: ( + nextCastAs?: NEXT_CAST_AS + ) => $AnyAttribute> + /** + * Provide a default value for attribute in Primary Key computing + * + * @param nextKeyDefault `keyAttributeInput | (() => keyAttributeInput)` + */ + keyDefault: ( + nextKeyDefault: ValueOrGetter< + AttributeKeyInput>, true> + > + ) => $AnyAttribute< + O.Overwrite< + STATE, + { + defaults: { + key: unknown + put: STATE['defaults']['put'] + update: STATE['defaults']['update'] + } + } + > + > + /** + * Provide a default value for attribute in PUT commands + * + * @param nextPutDefault `putAttributeInput | (() => putAttributeInput)` + */ + putDefault: ( + nextPutDefault: ValueOrGetter< + AttributePutItemInput>, true> + > + ) => $AnyAttribute< + O.Overwrite< + STATE, + { + defaults: { + key: STATE['defaults']['key'] + put: unknown + update: STATE['defaults']['update'] + } + } + > + > + /** + * Provide a default value for attribute in UPDATE commands + * + * @param nextUpdateDefault `updateAttributeInput | (() => updateAttributeInput)` + */ + updateDefault: ( + nextUpdateDefault: ValueOrGetter< + AttributeUpdateItemInput>, true> + > + ) => $AnyAttribute< + O.Overwrite< + STATE, + { + defaults: { + key: STATE['defaults']['key'] + put: STATE['defaults']['put'] + update: unknown + } + } + > + > + /** + * Provide a default value for attribute in PUT commands OR Primary Key computing if attribute is tagged as key + * + * @param nextDefault `key/putAttributeInput | (() => key/putAttributeInput)` + */ + default: ( + nextDefault: ValueOrGetter< + If< + STATE['key'], + AttributeKeyInput>, true>, + AttributePutItemInput>, true> + > + > + ) => $AnyAttribute< + O.Overwrite< + STATE, + { + defaults: If< + STATE['key'], + { + key: unknown + put: STATE['defaults']['put'] + update: STATE['defaults']['update'] + }, + { + key: STATE['defaults']['key'] + put: unknown + update: STATE['defaults']['update'] + } + > + } + > + > + /** + * Provide a **linked** default value for attribute in Primary Key computing + * + * @param nextKeyDefault `keyAttributeInput | ((keyInput) => keyAttributeInput)` + */ + keyLink: ( + nextKeyDefault: ValueOrGetter< + AttributeKeyInput>, true>, + [KeyInput] + > + ) => $AnyAttribute< + O.Overwrite< + STATE, + { + defaults: { + key: unknown + put: STATE['defaults']['put'] + update: STATE['defaults']['update'] + } + } + > + > + /** + * Provide a **linked** default value for attribute in PUT commands + * + * @param nextPutDefault `putAttributeInput | ((putItemInput) => putAttributeInput)` + */ + putLink: ( + nextPutDefault: ValueOrGetter< + AttributePutItemInput>, true>, + [PutItemInput] + > + ) => $AnyAttribute< + O.Overwrite< + STATE, + { + defaults: { + key: STATE['defaults']['key'] + put: unknown + update: STATE['defaults']['update'] + } + } + > + > + /** + * Provide a **linked** default value for attribute in UPDATE commands + * + * @param nextUpdateDefault `unknown | ((updateItemInput) => updateAttributeInput)` + */ + updateLink: ( + nextUpdateDefault: ValueOrGetter< + AttributeUpdateItemInput>, true>, + [UpdateItemInput] + > + ) => $AnyAttribute< + O.Overwrite< + STATE, + { + defaults: { + key: STATE['defaults']['key'] + put: STATE['defaults']['put'] + update: unknown + } + } + > + > + /** + * Provide a **linked** default value for attribute in PUT commands OR Primary Key computing if attribute is tagged as key + * + * @param nextDefault `key/putAttributeInput | (() => key/putAttributeInput)` + */ + link: ( + nextDefault: ValueOrGetter< + If< + STATE['key'], + AttributeKeyInput>, true>, + AttributePutItemInput>, true> + >, + [If, PutItemInput>] + > + ) => $AnyAttribute< + O.Overwrite< + STATE, + { + defaults: If< + STATE['key'], + { + key: unknown + put: STATE['defaults']['put'] + update: STATE['defaults']['update'] + }, + { + key: STATE['defaults']['key'] + put: unknown + update: STATE['defaults']['update'] + } + > + } + > + > +} + +export interface AnyAttribute + extends SharedAttributeState { + path: string + type: 'any' + castAs: STATE['castAs'] +} diff --git a/src/v1/schema/attributes/any/options.ts b/src/v1/schema/attributes/any/options.ts new file mode 100644 index 000000000..bc7a7f619 --- /dev/null +++ b/src/v1/schema/attributes/any/options.ts @@ -0,0 +1,57 @@ +import { RequiredOption, AtLeastOnce } from '../constants/requiredOptions' + +// Note: May look like a duplicate of AnyAttributeState but actually adds JSDocs + +export interface AnyAttributeOptions { + /** + * Tag attribute as required. Possible values are: + * - `"atLeastOnce"` _(default)_: Required in PUTs, optional in UPDATEs + * - `"never"`: Optional in PUTs and UPDATEs + * - `"always"`: Required in PUTs and UPDATEs + */ + required: RequiredOption + /** + * Hide attribute after fetch commands and formatting + */ + hidden: boolean + /** + * Tag attribute as needed for Primary Key computing + */ + key: boolean + /** + * Rename attribute before save commands + */ + savedAs: string | undefined + /** + * Provide default values for attribute + */ + defaults: { + key: undefined | unknown + put: undefined | unknown + update: undefined | unknown + } +} + +export type AnyAttributeDefaultOptions = { + required: AtLeastOnce + hidden: false + key: false + savedAs: undefined + defaults: { + key: undefined + put: undefined + update: undefined + } +} + +export const ANY_DEFAULT_OPTIONS: AnyAttributeDefaultOptions = { + required: 'atLeastOnce', + hidden: false, + key: false, + savedAs: undefined, + defaults: { + key: undefined, + put: undefined, + update: undefined + } +} diff --git a/src/v1/schema/attributes/any/resolve.ts b/src/v1/schema/attributes/any/resolve.ts new file mode 100644 index 000000000..2d34dfda9 --- /dev/null +++ b/src/v1/schema/attributes/any/resolve.ts @@ -0,0 +1,3 @@ +import type { AnyAttribute } from './interface' + +export type ResolveAnyAttribute = ATTRIBUTE['castAs'] diff --git a/src/v1/schema/attributes/any/typer.ts b/src/v1/schema/attributes/any/typer.ts new file mode 100644 index 000000000..dad70aa34 --- /dev/null +++ b/src/v1/schema/attributes/any/typer.ts @@ -0,0 +1,177 @@ +import type { NarrowObject } from 'v1/types/narrowObject' +import { overwrite } from 'v1/utils/overwrite' + +import type { RequiredOption, AtLeastOnce } from '../constants/requiredOptions' +import { + $type, + $required, + $hidden, + $key, + $savedAs, + $defaults, + $castAs +} from '../constants/attributeOptions' +import type { InferStateFromOptions } from '../shared/inferStateFromOptions' + +import type { $AnyAttribute } from './interface' +import type { AnyAttributeState } from './types' +import { AnyAttributeOptions, AnyAttributeDefaultOptions, ANY_DEFAULT_OPTIONS } from './options' +import { freezeAnyAttribute } from './freeze' + +type $AnyAttributeTyper = ( + state: STATE +) => $AnyAttribute + +const $any: $AnyAttributeTyper = ( + state: STATE +) => { + const $anyAttribute: $AnyAttribute = { + [$type]: 'any', + [$required]: state.required, + [$hidden]: state.hidden, + [$key]: state.key, + [$savedAs]: state.savedAs, + [$defaults]: state.defaults, + [$castAs]: state.castAs, + required: ( + nextRequired: NEXT_IS_REQUIRED = 'atLeastOnce' as NEXT_IS_REQUIRED + ) => $any(overwrite(state, { required: nextRequired })), + optional: () => $any(overwrite(state, { required: 'never' })), + hidden: () => $any(overwrite(state, { hidden: true })), + key: () => $any(overwrite(state, { key: true, required: 'always' })), + savedAs: nextSavedAs => $any(overwrite(state, { savedAs: nextSavedAs })), + castAs: (nextCastAs = (undefined as unknown) as NEXT_CAST_AS) => + $any(overwrite(state, { castAs: nextCastAs })), + keyDefault: nextKeyDefault => + $any( + overwrite(state, { + defaults: { + key: nextKeyDefault as unknown, + put: state.defaults.put, + update: state.defaults.update + } + }) + ), + putDefault: nextPutDefault => + $any( + overwrite(state, { + defaults: { + key: state.defaults.key, + put: nextPutDefault as unknown, + update: state.defaults.update + } + }) + ), + updateDefault: nextUpdateDefault => + $any( + overwrite(state, { + defaults: { + key: state.defaults.key, + put: state.defaults.put, + update: nextUpdateDefault as unknown + } + }) + ), + default: nextDefault => + $any( + overwrite(state, { + defaults: state.key + ? { + key: nextDefault as unknown, + put: state.defaults.put, + update: state.defaults.update + } + : { + key: state.defaults.key, + put: nextDefault as unknown, + update: state.defaults.update + } + }) + ), + keyLink: nextKeyDefault => + $any( + overwrite(state, { + defaults: { + key: nextKeyDefault as unknown, + put: state.defaults.put, + update: state.defaults.update + } + }) + ), + putLink: nextPutDefault => + $any( + overwrite(state, { + defaults: { + key: state.defaults.key, + put: nextPutDefault as unknown, + update: state.defaults.update + } + }) + ), + updateLink: nextUpdateDefault => + $any( + overwrite(state, { + defaults: { + key: state.defaults.key, + put: state.defaults.put, + update: nextUpdateDefault as unknown + } + }) + ), + link: nextDefault => + $any( + overwrite(state, { + defaults: state.key + ? { + key: nextDefault as unknown, + put: state.defaults.put, + update: state.defaults.update + } + : { + key: state.defaults.key, + put: nextDefault as unknown, + update: state.defaults.update + } + }) + ), + freeze: path => freezeAnyAttribute(state, path) + } + + return $anyAttribute +} + +type AnyAttributeTyper = = AnyAttributeOptions>( + options?: NarrowObject +) => $AnyAttribute< + InferStateFromOptions< + AnyAttributeOptions, + AnyAttributeDefaultOptions, + OPTIONS, + { castAs: unknown } + > +> + +/** + * Define a new attribute of any type + * + * @param options _(optional)_ Attribute Options + */ +export const any: AnyAttributeTyper = < + OPTIONS extends Partial = AnyAttributeOptions +>( + options?: NarrowObject +) => { + const state = { + ...ANY_DEFAULT_OPTIONS, + ...options, + castAs: undefined, + defaults: { ...ANY_DEFAULT_OPTIONS.defaults, ...options?.defaults } + } as InferStateFromOptions< + AnyAttributeOptions, + AnyAttributeDefaultOptions, + OPTIONS, + { castAs: unknown } + > + + return $any(state) +} diff --git a/src/v1/schema/attributes/any/typer.unit.test.ts b/src/v1/schema/attributes/any/typer.unit.test.ts new file mode 100644 index 000000000..c4840bfee --- /dev/null +++ b/src/v1/schema/attributes/any/typer.unit.test.ts @@ -0,0 +1,255 @@ +import type { A } from 'ts-toolbelt' + +import { Never, AtLeastOnce, Always } from '../constants' +import { + $type, + $required, + $hidden, + $key, + $savedAs, + $defaults, + $castAs +} from '../constants/attributeOptions' + +import type { $AnyAttributeState, AnyAttribute } from './interface' + +import { any } from './typer' + +describe('anyAttribute', () => { + it('returns default string', () => { + const anyInstance = any() + + const assertAny: A.Contains< + typeof anyInstance, + { + [$type]: 'any' + [$required]: AtLeastOnce + [$hidden]: false + [$savedAs]: undefined + [$key]: false + [$defaults]: { + key: undefined + put: undefined + update: undefined + } + [$castAs]: unknown + } + > = 1 + assertAny + + const assertExtends: A.Extends = 1 + assertExtends + + const frozenAny = anyInstance.freeze('some.path') + const assertFrozenExtends: A.Extends = 1 + assertFrozenExtends + + expect(anyInstance).toMatchObject({ + [$type]: 'any', + [$required]: 'atLeastOnce', + [$hidden]: false, + [$savedAs]: undefined, + [$key]: false, + [$defaults]: { + key: undefined, + put: undefined, + update: undefined + } + }) + }) + + it('returns required any (option)', () => { + const anyAtLeastOnce = any({ required: 'atLeastOnce' }) + const anyAlways = any({ required: 'always' }) + const anyNever = any({ required: 'never' }) + + const assertAtLeastOnce: A.Contains = 1 + assertAtLeastOnce + const assertAlways: A.Contains = 1 + assertAlways + const assertNever: A.Contains = 1 + assertNever + + expect(anyAtLeastOnce).toMatchObject({ [$required]: 'atLeastOnce' }) + expect(anyAlways).toMatchObject({ [$required]: 'always' }) + expect(anyNever).toMatchObject({ [$required]: 'never' }) + }) + + it('returns required any (method)', () => { + const anyAtLeastOnce = any().required() + const anyAlways = any().required('always') + const anyNever = any().required('never') + const anyOpt = any().optional() + + const assertAtLeastOnce: A.Contains = 1 + assertAtLeastOnce + const assertAlways: A.Contains = 1 + assertAlways + const assertNever: A.Contains = 1 + assertNever + const assertOpt: A.Contains = 1 + assertOpt + + expect(anyAtLeastOnce).toMatchObject({ [$required]: 'atLeastOnce' }) + expect(anyAlways).toMatchObject({ [$required]: 'always' }) + expect(anyNever).toMatchObject({ [$required]: 'never' }) + expect(anyOpt).toMatchObject({ [$required]: 'never' }) + }) + + it('returns hidden any (option)', () => { + const anyInstance = any({ hidden: true }) + + const assertAny: A.Contains = 1 + assertAny + + expect(anyInstance).toMatchObject({ [$hidden]: true }) + }) + + it('returns hidden any (method)', () => { + const anyInstance = any().hidden() + + const assertAny: A.Contains = 1 + assertAny + + expect(anyInstance).toMatchObject({ [$hidden]: true }) + }) + + it('returns key any (option)', () => { + const anyInstance = any({ key: true }) + + const assertAny: A.Contains = 1 + assertAny + + expect(anyInstance).toMatchObject({ [$key]: true, [$required]: 'atLeastOnce' }) + }) + + it('returns key any (method)', () => { + const anyInstance = any().key() + + const assertAny: A.Contains = 1 + assertAny + + expect(anyInstance).toMatchObject({ [$key]: true, [$required]: 'always' }) + }) + + it('returns savedAs any (option)', () => { + const anyInstance = any({ savedAs: 'foo' }) + + const assertAny: A.Contains = 1 + assertAny + + expect(anyInstance).toMatchObject({ [$savedAs]: 'foo' }) + }) + + it('returns savedAs any (method)', () => { + const anyInstance = any().savedAs('foo') + + const assertAny: A.Contains = 1 + assertAny + + expect(anyInstance).toMatchObject({ [$savedAs]: 'foo' }) + }) + + it('returns castAs any (method)', () => { + const anyInstance = any().castAs<'foo' | 'bar'>() + + const assertAny: A.Contains = 1 + assertAny + + // Keeps cast type at type level only + expect(anyInstance[$castAs]).toBeUndefined() + }) + + it('returns any with default value (option)', () => { + // TOIMPROVE: Add type constraints here + const strA = any({ defaults: { key: 'hello', put: undefined, update: undefined } }) + const strB = any({ defaults: { key: undefined, put: 'world', update: undefined } }) + const sayHello = () => 'hello' + const strC = any({ defaults: { key: undefined, put: undefined, update: sayHello } }) + + const assertAnyA: A.Contains< + typeof strA, + { [$defaults]: { key: unknown; put: undefined; update: undefined } } + > = 1 + assertAnyA + + expect(strA).toMatchObject({ [$defaults]: { key: 'hello', put: undefined, update: undefined } }) + + const assertAnyB: A.Contains< + typeof strB, + { [$defaults]: { key: undefined; put: unknown; update: undefined } } + > = 1 + assertAnyB + + expect(strB).toMatchObject({ [$defaults]: { key: undefined, put: 'world', update: undefined } }) + + const assertAnyC: A.Contains< + typeof strC, + { [$defaults]: { key: undefined; put: undefined; update: unknown } } + > = 1 + assertAnyC + + expect(strC).toMatchObject({ + [$defaults]: { key: undefined, put: undefined, update: sayHello } + }) + }) + + it('returns any with default value (method)', () => { + const strA = any().keyDefault('hello') + const strB = any().putDefault('world') + const sayHello = () => 'hello' + const strC = any().updateDefault(sayHello) + + const assertAnyA: A.Contains< + typeof strA, + { [$defaults]: { key: unknown; put: undefined; update: undefined } } + > = 1 + assertAnyA + + expect(strA).toMatchObject({ [$defaults]: { key: 'hello', put: undefined, update: undefined } }) + + const assertAnyB: A.Contains< + typeof strB, + { [$defaults]: { key: undefined; put: unknown; update: undefined } } + > = 1 + assertAnyB + + expect(strB).toMatchObject({ [$defaults]: { put: 'world', update: undefined } }) + + const assertAnyC: A.Contains< + typeof strC, + { [$defaults]: { key: undefined; put: undefined; update: unknown } } + > = 1 + assertAnyC + + expect(strC).toMatchObject({ [$defaults]: { put: undefined, update: sayHello } }) + }) + + it('returns any with PUT default value if it is not key (default shorthand)', () => { + const str = any().default('hello') + + const assertAny: A.Contains< + typeof str, + { [$defaults]: { key: undefined; put: unknown; update: undefined } } + > = 1 + assertAny + + expect(str).toMatchObject({ + [$defaults]: { key: undefined, put: 'hello', update: undefined } + }) + }) + + it('returns any with KEY default value if it is key (default shorthand)', () => { + const str = any().key().default('hello') + + const assertAny: A.Contains< + typeof str, + { [$defaults]: { key: unknown; put: undefined; update: undefined } } + > = 1 + assertAny + + expect(str).toMatchObject({ + [$defaults]: { key: 'hello', put: undefined, update: undefined } + }) + }) +}) diff --git a/src/v1/schema/attributes/any/types.ts b/src/v1/schema/attributes/any/types.ts new file mode 100644 index 000000000..dc89cdb2a --- /dev/null +++ b/src/v1/schema/attributes/any/types.ts @@ -0,0 +1,5 @@ +import type { SharedAttributeState } from '../shared/interface' + +export interface AnyAttributeState extends SharedAttributeState { + castAs: unknown +} diff --git a/src/v1/schema/attributes/anyOf/errors.ts b/src/v1/schema/attributes/anyOf/errors.ts new file mode 100644 index 000000000..6e5580076 --- /dev/null +++ b/src/v1/schema/attributes/anyOf/errors.ts @@ -0,0 +1,44 @@ +import type { ErrorBlueprint } from 'v1/errors/blueprint' + +type InvalidElementsErrorBlueprint = ErrorBlueprint<{ + code: 'schema.anyOfAttribute.invalidElements' + hasPath: true + payload: undefined +}> +type MissingElementsErrorBlueprint = ErrorBlueprint<{ + code: 'schema.anyOfAttribute.missingElements' + hasPath: true + payload: undefined +}> + +type OptionalElementsErrorBlueprint = ErrorBlueprint<{ + code: 'schema.anyOfAttribute.optionalElements' + hasPath: true + payload: undefined +}> + +type HiddenElementsErrorBlueprint = ErrorBlueprint<{ + code: 'schema.anyOfAttribute.hiddenElements' + hasPath: true + payload: undefined +}> + +type SavedAsElementsErrorBlueprint = ErrorBlueprint<{ + code: 'schema.anyOfAttribute.savedAsElements' + hasPath: true + payload: undefined +}> + +type DefaultedElementsErrorBlueprint = ErrorBlueprint<{ + code: 'schema.anyOfAttribute.defaultedElements' + hasPath: true + payload: undefined +}> + +export type AnyOfAttributeErrorBlueprints = + | InvalidElementsErrorBlueprint + | MissingElementsErrorBlueprint + | OptionalElementsErrorBlueprint + | HiddenElementsErrorBlueprint + | SavedAsElementsErrorBlueprint + | DefaultedElementsErrorBlueprint diff --git a/src/v1/schema/attributes/anyOf/freeze.ts b/src/v1/schema/attributes/anyOf/freeze.ts new file mode 100644 index 000000000..fc908360f --- /dev/null +++ b/src/v1/schema/attributes/anyOf/freeze.ts @@ -0,0 +1,134 @@ +import type { O } from 'ts-toolbelt' + +import { DynamoDBToolboxError } from 'v1/errors' +import { isArray } from 'v1/utils/validation' + +import type { FreezeAttribute } from '../freeze' +import { validateAttributeProperties } from '../shared/validate' +import { hasDefinedDefault } from '../shared/hasDefinedDefault' +import { + $elements, + $required, + $hidden, + $key, + $savedAs, + $defaults +} from '../constants/attributeOptions' + +import type { SharedAttributeState } from '../shared/interface' +import type { $AttributeState } from '../types' +import type { $AnyOfAttributeState, AnyOfAttribute } from './interface' +import type { $AnyOfAttributeElements, AnyOfAttributeElements } from './types' + +type FreezeElements< + $ELEMENTS extends $AnyOfAttributeElements[], + RESULTS extends AnyOfAttributeElements[] = [] +> = $AnyOfAttributeElements[] extends $ELEMENTS + ? AnyOfAttributeElements[] + : $ELEMENTS extends [infer $ELEMENTS_HEAD, ...infer $ELEMENTS_TAIL] + ? $ELEMENTS_TAIL extends $AnyOfAttributeElements[] + ? $ELEMENTS_HEAD extends $AttributeState + ? FreezeAttribute<$ELEMENTS_HEAD> extends AnyOfAttributeElements + ? FreezeElements<$ELEMENTS_TAIL, [...RESULTS, FreezeAttribute<$ELEMENTS_HEAD>]> + : FreezeElements<$ELEMENTS_TAIL, RESULTS> + : FreezeElements<$ELEMENTS_TAIL, RESULTS> + : never + : RESULTS + +export type FreezeAnyOfAttribute<$ANY_OF_ATTRIBUTE extends $AnyOfAttributeState> = + // Applying void O.Update improves type display + O.Update< + AnyOfAttribute< + FreezeElements<$ANY_OF_ATTRIBUTE[$elements]>, + { + required: $ANY_OF_ATTRIBUTE[$required] + hidden: $ANY_OF_ATTRIBUTE[$hidden] + key: $ANY_OF_ATTRIBUTE[$key] + savedAs: $ANY_OF_ATTRIBUTE[$savedAs] + defaults: $ANY_OF_ATTRIBUTE[$defaults] + } + >, + never, + never + > + +type AnyOfAttributeFreezer = < + $ELEMENTS extends $AnyOfAttributeElements[], + STATE extends SharedAttributeState +>( + elements: $ELEMENTS, + state: STATE, + path: string +) => FreezeAnyOfAttribute<$AnyOfAttributeState<$ELEMENTS, STATE>> + +/** + * Freezes a warm `anyOf` attribute + * + * @param elements Attribute elements + * @param state Attribute options + * @param path Path of the instance in the related schema (string) + * @return void + */ +export const freezeAnyOfAttribute: AnyOfAttributeFreezer = < + $ELEMENTS extends $AnyOfAttributeElements[], + STATE extends SharedAttributeState +>( + elements: $ELEMENTS, + state: STATE, + path: string +): FreezeAnyOfAttribute<$AnyOfAttributeState<$ELEMENTS, STATE>> => { + validateAttributeProperties(state, path) + + if (!isArray(elements)) { + throw new DynamoDBToolboxError('schema.anyOfAttribute.invalidElements', { + message: `Invalid anyOf elements at path ${path}: AnyOf elements must be an array`, + path + }) + } + + if (elements.length === 0) { + throw new DynamoDBToolboxError('schema.anyOfAttribute.missingElements', { + message: `Invalid anyOf elements at path ${path}: AnyOf attributes must have at least one element`, + path + }) + } + + elements.forEach(element => { + if (element[$required] !== 'atLeastOnce' && element[$required] !== 'always') { + throw new DynamoDBToolboxError('schema.anyOfAttribute.optionalElements', { + message: `Invalid anyOf elements at path ${path}: AnyOf elements must be required`, + path + }) + } + + if (element[$hidden] !== false) { + throw new DynamoDBToolboxError('schema.anyOfAttribute.hiddenElements', { + message: `Invalid anyOf elements at path ${path}: AnyOf elements cannot be hidden`, + path + }) + } + + if (element[$savedAs] !== undefined) { + throw new DynamoDBToolboxError('schema.anyOfAttribute.savedAsElements', { + message: `Invalid anyOf elements at path ${path}: AnyOf elements cannot be renamed (have savedAs option)`, + path + }) + } + + if (hasDefinedDefault(element)) { + throw new DynamoDBToolboxError('schema.anyOfAttribute.defaultedElements', { + message: `Invalid anyOf elements at path ${path}: AnyOf elements cannot have default values`, + path + }) + } + }) + + const frozenElements = elements.map(element => element.freeze(path)) as FreezeElements<$ELEMENTS> + + return { + path, + type: 'anyOf', + elements: frozenElements, + ...state + } +} diff --git a/src/v1/schema/attributes/anyOf/index.ts b/src/v1/schema/attributes/anyOf/index.ts new file mode 100644 index 000000000..9b84e69ea --- /dev/null +++ b/src/v1/schema/attributes/anyOf/index.ts @@ -0,0 +1,8 @@ +export { anyOf } from './typer' +export type { + $AnyOfAttributeState, + $AnyOfAttributeNestedState, + $AnyOfAttribute, + AnyOfAttribute +} from './interface' +export type { FreezeAnyOfAttribute } from './freeze' diff --git a/src/v1/schema/attributes/anyOf/interface.ts b/src/v1/schema/attributes/anyOf/interface.ts new file mode 100644 index 000000000..eaaeb10b5 --- /dev/null +++ b/src/v1/schema/attributes/anyOf/interface.ts @@ -0,0 +1,286 @@ +import type { O } from 'ts-toolbelt' + +import type { If, ValueOrGetter } from 'v1/types' +import type { + AttributeKeyInput, + AttributePutItemInput, + AttributeUpdateItemInput, + KeyInput, + PutItemInput, + UpdateItemInput +} from 'v1/operations' + +import type { Schema } from '../../interface' +import type { RequiredOption, AtLeastOnce, Never, Always } from '../constants' +import type { $type, $elements } from '../constants/attributeOptions' +import type { $SharedAttributeState, SharedAttributeState } from '../shared/interface' +import type { Attribute } from '../types' + +import type { FreezeAnyOfAttribute } from './freeze' +import type { $AnyOfAttributeElements } from './types' + +export interface $AnyOfAttributeState< + $ELEMENTS extends $AnyOfAttributeElements[] = $AnyOfAttributeElements[], + STATE extends SharedAttributeState = SharedAttributeState +> extends $SharedAttributeState { + [$type]: 'anyOf' + [$elements]: $ELEMENTS +} + +export interface $AnyOfAttributeNestedState< + $ELEMENTS extends $AnyOfAttributeElements[] = $AnyOfAttributeElements[], + STATE extends SharedAttributeState = SharedAttributeState +> extends $AnyOfAttributeState<$ELEMENTS, STATE> { + freeze: (path: string) => FreezeAnyOfAttribute<$AnyOfAttributeState<$ELEMENTS, STATE>> +} + +/** + * AnyOf attribute interface + */ +export interface $AnyOfAttribute< + $ELEMENTS extends $AnyOfAttributeElements[] = $AnyOfAttributeElements[], + STATE extends SharedAttributeState = SharedAttributeState +> extends $AnyOfAttributeNestedState<$ELEMENTS, STATE> { + /** + * Tag attribute as required. Possible values are: + * - `"atLeastOnce"` _(default)_: Required in PUTs, optional in UPDATEs + * - `"never"`: Optional in PUTs and UPDATEs + * - `"always"`: Required in PUTs and UPDATEs + * + * @param nextRequired RequiredOption + */ + required: ( + nextRequired?: NEXT_IS_REQUIRED + ) => $AnyOfAttribute<$ELEMENTS, O.Overwrite> + /** + * Shorthand for `required('never')` + */ + optional: () => $AnyOfAttribute<$ELEMENTS, O.Overwrite> + /** + * Hide attribute after fetch commands and formatting + */ + hidden: () => $AnyOfAttribute<$ELEMENTS, O.Overwrite> + /** + * Tag attribute as needed for Primary Key computing + */ + key: () => $AnyOfAttribute<$ELEMENTS, O.Overwrite> + /** + * Rename attribute before save commands + */ + savedAs: ( + nextSavedAs: NEXT_SAVED_AS + ) => $AnyOfAttribute<$ELEMENTS, O.Overwrite> + /** + * Provide a default value for attribute in Primary Key computing + * + * @param nextKeyDefault `keyAttributeInput | (() => keyAttributeInput)` + */ + keyDefault: ( + nextKeyDefault: ValueOrGetter< + AttributeKeyInput>, true> + > + ) => $AnyOfAttribute< + $ELEMENTS, + O.Overwrite< + STATE, + { + defaults: { + key: unknown + put: STATE['defaults']['put'] + update: STATE['defaults']['update'] + } + } + > + > + /** + * Provide a default value for attribute in PUT commands + * + * @param nextPutDefault `putAttributeInput | (() => putAttributeInput)` + */ + putDefault: ( + nextPutDefault: ValueOrGetter< + AttributePutItemInput>, true> + > + ) => $AnyOfAttribute< + $ELEMENTS, + O.Overwrite< + STATE, + { + defaults: { + key: STATE['defaults']['key'] + put: unknown + update: STATE['defaults']['update'] + } + } + > + > + /** + * Provide a default value for attribute in UPDATE commands + * + * @param nextUpdateDefault `updateAttributeInput | (() => updateAttributeInput)` + */ + updateDefault: ( + nextUpdateDefault: ValueOrGetter< + AttributeUpdateItemInput>, true> + > + ) => $AnyOfAttribute< + $ELEMENTS, + O.Overwrite< + STATE, + { + defaults: { + key: STATE['defaults']['key'] + put: STATE['defaults']['put'] + update: unknown + } + } + > + > + /** + * Provide a default value for attribute in PUT commands OR Primary Key computing if attribute is tagged as key + * + * @param nextDefault `key/putAttributeInput | (() => key/putAttributeInput)` + */ + default: ( + nextDefault: ValueOrGetter< + If< + STATE['key'], + AttributeKeyInput>, true>, + AttributePutItemInput>, true> + > + > + ) => $AnyOfAttribute< + $ELEMENTS, + O.Overwrite< + STATE, + { + defaults: If< + STATE['key'], + { + key: unknown + put: STATE['defaults']['put'] + update: STATE['defaults']['update'] + }, + { + key: STATE['defaults']['key'] + put: unknown + update: STATE['defaults']['update'] + } + > + } + > + > + /** + * Provide a **linked** default value for attribute in Primary Key computing + * + * @param nextKeyDefault `keyAttributeInput | ((keyInput) => keyAttributeInput)` + */ + keyLink: ( + nextKeyDefault: ValueOrGetter< + AttributeKeyInput>, true>, + [KeyInput] + > + ) => $AnyOfAttribute< + $ELEMENTS, + O.Overwrite< + STATE, + { + defaults: { + key: unknown + put: STATE['defaults']['put'] + update: STATE['defaults']['update'] + } + } + > + > + /** + * Provide a **linked** default value for attribute in PUT commands + * + * @param nextPutDefault `putAttributeInput | ((putItemInput) => putAttributeInput)` + */ + putLink: ( + nextPutDefault: ValueOrGetter< + AttributePutItemInput>, true>, + [PutItemInput] + > + ) => $AnyOfAttribute< + $ELEMENTS, + O.Overwrite< + STATE, + { + defaults: { + key: STATE['defaults']['key'] + put: unknown + update: STATE['defaults']['update'] + } + } + > + > + /** + * Provide a **linked** default value for attribute in UPDATE commands + * + * @param nextUpdateDefault `unknown | ((updateItemInput) => updateAttributeInput)` + */ + updateLink: ( + nextUpdateDefault: ValueOrGetter< + AttributeUpdateItemInput>, true>, + [UpdateItemInput] + > + ) => $AnyOfAttribute< + $ELEMENTS, + O.Overwrite< + STATE, + { + defaults: { + key: STATE['defaults']['key'] + put: STATE['defaults']['put'] + update: unknown + } + } + > + > + /** + * Provide a **linked** default value for attribute in PUT commands OR Primary Key computing if attribute is tagged as key + * + * @param nextDefault `key/putAttributeInput | (() => key/putAttributeInput)` + */ + link: ( + nextDefault: ValueOrGetter< + If< + STATE['key'], + AttributeKeyInput>, true>, + AttributePutItemInput>, true> + >, + [If, PutItemInput>] + > + ) => $AnyOfAttribute< + $ELEMENTS, + O.Overwrite< + STATE, + { + defaults: If< + STATE['key'], + { + key: unknown + put: STATE['defaults']['put'] + update: STATE['defaults']['update'] + }, + { + key: STATE['defaults']['key'] + put: unknown + update: STATE['defaults']['update'] + } + > + } + > + > +} + +export interface AnyOfAttribute< + ELEMENTS extends Attribute[] = Attribute[], + STATE extends SharedAttributeState = SharedAttributeState +> extends SharedAttributeState { + path: string + type: 'anyOf' + elements: ELEMENTS +} diff --git a/src/v1/schema/attributes/anyOf/options.ts b/src/v1/schema/attributes/anyOf/options.ts new file mode 100644 index 000000000..e9a3f29cb --- /dev/null +++ b/src/v1/schema/attributes/anyOf/options.ts @@ -0,0 +1,60 @@ +import type { RequiredOption, AtLeastOnce } from '../constants' + +// Note: May look like a duplicate of AnyAttributeState but actually adds JSDocs + +/** + * Input options of AnyOf Attribute + */ +export interface AnyOfAttributeOptions { + /** + * Tag attribute as required. Possible values are: + * - `"atLeastOnce"` _(default)_: Required in PUTs, optional in UPDATEs + * - `"never"`: Optional in PUTs and UPDATEs + * - `"always"`: Required in PUTs and UPDATEs + */ + required: RequiredOption + /** + * Hide attribute after fetch commands and formatting + */ + hidden: boolean + /** + * Tag attribute as needed for Primary Key computing + */ + key: boolean + /** + * Rename attribute before save commands + */ + savedAs: string | undefined + /** + * Provide default values for attribute + */ + defaults: { + key: undefined | unknown + put: undefined | unknown + update: undefined | unknown + } +} + +export type AnyOfAttributeDefaultOptions = { + required: AtLeastOnce + hidden: false + key: false + savedAs: undefined + defaults: { + key: undefined + put: undefined + update: undefined + } +} + +export const ANY_OF_DEFAULT_OPTIONS: AnyOfAttributeDefaultOptions = { + required: 'atLeastOnce', + hidden: false, + key: false, + savedAs: undefined, + defaults: { + key: undefined, + put: undefined, + update: undefined + } +} diff --git a/src/v1/schema/attributes/anyOf/typer.ts b/src/v1/schema/attributes/anyOf/typer.ts new file mode 100644 index 000000000..635344717 --- /dev/null +++ b/src/v1/schema/attributes/anyOf/typer.ts @@ -0,0 +1,151 @@ +import { overwrite } from 'v1/utils/overwrite' + +import type { RequiredOption, AtLeastOnce } from '../constants' +import { + $type, + $elements, + $required, + $hidden, + $key, + $savedAs, + $defaults +} from '../constants/attributeOptions' +import type { SharedAttributeState } from '../shared/interface' + +import type { $AnyOfAttribute } from './interface' +import type { $AnyOfAttributeElements } from './types' +import { AnyOfAttributeDefaultOptions, ANY_OF_DEFAULT_OPTIONS } from './options' +import { freezeAnyOfAttribute } from './freeze' + +type $AnyOfAttributeTyper = < + $ELEMENTS extends $AnyOfAttributeElements[], + STATE extends SharedAttributeState = SharedAttributeState +>( + state: STATE, + ...elements: $ELEMENTS +) => $AnyOfAttribute<$ELEMENTS, STATE> + +const $anyOf: $AnyOfAttributeTyper = < + $ELEMENTS extends $AnyOfAttributeElements[], + STATE extends SharedAttributeState = SharedAttributeState +>( + state: STATE, + ...elements: $ELEMENTS +) => { + const $anyOfAttribute: $AnyOfAttribute<$ELEMENTS, STATE> = { + [$type]: 'anyOf', + [$elements]: elements, + [$required]: state.required, + [$hidden]: state.hidden, + [$key]: state.key, + [$savedAs]: state.savedAs, + [$defaults]: state.defaults, + required: ( + nextRequired: NEXT_IS_REQUIRED = 'atLeastOnce' as NEXT_IS_REQUIRED + ) => $anyOf(overwrite(state, { required: nextRequired }), ...elements), + optional: () => $anyOf(overwrite(state, { required: 'never' }), ...elements), + hidden: () => $anyOf(overwrite(state, { hidden: true }), ...elements), + key: () => $anyOf(overwrite(state, { key: true, required: 'always' }), ...elements), + savedAs: nextSavedAs => $anyOf(overwrite(state, { savedAs: nextSavedAs }), ...elements), + keyDefault: nextKeyDefault => + $anyOf( + overwrite(state, { + defaults: { + key: nextKeyDefault, + put: state.defaults.put, + update: state.defaults.update + } + }), + ...elements + ), + putDefault: nextPutDefault => + $anyOf( + overwrite(state, { + defaults: { + key: state.defaults.key, + put: nextPutDefault, + update: state.defaults.update + } + }), + ...elements + ), + updateDefault: nextUpdateDefault => + $anyOf( + overwrite(state, { + defaults: { + key: state.defaults.key, + put: state.defaults.put, + update: nextUpdateDefault + } + }), + ...elements + ), + default: nextDefault => + $anyOf( + overwrite(state, { + defaults: state.key + ? { key: nextDefault, put: state.defaults.put, update: state.defaults.update } + : { key: state.defaults.key, put: nextDefault, update: state.defaults.update } + }), + ...elements + ), + keyLink: nextKeyDefault => + $anyOf( + overwrite(state, { + defaults: { + key: nextKeyDefault, + put: state.defaults.put, + update: state.defaults.update + } + }), + ...elements + ), + putLink: nextPutDefault => + $anyOf( + overwrite(state, { + defaults: { + key: state.defaults.key, + put: nextPutDefault, + update: state.defaults.update + } + }), + ...elements + ), + updateLink: nextUpdateDefault => + $anyOf( + overwrite(state, { + defaults: { + key: state.defaults.key, + put: state.defaults.put, + update: nextUpdateDefault + } + }), + ...elements + ), + link: nextDefault => + $anyOf( + overwrite(state, { + defaults: state.key + ? { key: nextDefault, put: state.defaults.put, update: state.defaults.update } + : { key: state.defaults.key, put: nextDefault, update: state.defaults.update } + }), + ...elements + ), + freeze: path => freezeAnyOfAttribute(elements, state, path) + } + + return $anyOfAttribute +} + +type AnyOfAttributeTyper = ( + ...elements: ELEMENTS +) => $AnyOfAttribute + +/** + * Define a new anyOf attribute + * @param elements Attribute[] + * @param options _(optional)_ AnyOf Options + */ +export const anyOf: AnyOfAttributeTyper = <$ELEMENTS extends $AnyOfAttributeElements[]>( + ...elements: $ELEMENTS +) => $anyOf(ANY_OF_DEFAULT_OPTIONS, ...elements) diff --git a/src/v1/schema/attributes/anyOf/typer.unit.test.ts b/src/v1/schema/attributes/anyOf/typer.unit.test.ts new file mode 100644 index 000000000..f4f7e3155 --- /dev/null +++ b/src/v1/schema/attributes/anyOf/typer.unit.test.ts @@ -0,0 +1,330 @@ +import type { A } from 'ts-toolbelt' + +import { DynamoDBToolboxError } from 'v1/errors' + +import { string } from '../primitive' +import { Never, AtLeastOnce, Always } from '../constants' +import { + $type, + $elements, + $required, + $hidden, + $key, + $savedAs, + $defaults +} from '../constants/attributeOptions' + +import type { AnyOfAttribute, $AnyOfAttributeState } from './interface' +import { anyOf } from './typer' + +describe('anyOf', () => { + const path = 'some.path' + const str = string() + + it('rejects missing elements', () => { + const invalidAnyOf = anyOf() + + const invalidCall = () => invalidAnyOf.freeze(path) + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow( + expect.objectContaining({ code: 'schema.anyOfAttribute.missingElements', path }) + ) + }) + + it('rejects non-required elements', () => { + const invalidAnyOf = anyOf( + str, + // @ts-expect-error + str.optional() + ) + + const invalidCall = () => invalidAnyOf.freeze(path) + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow( + expect.objectContaining({ code: 'schema.anyOfAttribute.optionalElements', path }) + ) + }) + + it('rejects hidden elements', () => { + const invalidAnyOf = anyOf( + str, + // @ts-expect-error + str.hidden() + ) + + const invalidCall = () => invalidAnyOf.freeze(path) + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow( + expect.objectContaining({ code: 'schema.anyOfAttribute.hiddenElements', path }) + ) + }) + + it('rejects elements with savedAs values', () => { + const invalidAnyOf = anyOf( + str, + // @ts-expect-error + str.savedAs('foo') + ) + + const invalidCall = () => invalidAnyOf.freeze(path) + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow( + expect.objectContaining({ code: 'schema.anyOfAttribute.savedAsElements', path }) + ) + }) + + it('rejects elements with default values', () => { + const invalidAnyOf = anyOf( + str, + // @ts-expect-error + str.putDefault('foo') + ) + + const invalidCall = () => invalidAnyOf.freeze(path) + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow( + expect.objectContaining({ code: 'schema.anyOfAttribute.defaultedElements', path }) + ) + }) + + it('returns default anyOf', () => { + const anyOfAttr = anyOf(str) + + const assertAnyOf: A.Contains< + typeof anyOfAttr, + { + [$type]: 'anyOf' + [$elements]: [typeof str] + [$required]: AtLeastOnce + [$hidden]: false + [$key]: false + [$savedAs]: undefined + [$defaults]: { + key: undefined + put: undefined + update: undefined + } + } + > = 1 + assertAnyOf + + const assertExtends: A.Extends = 1 + assertExtends + + const frozenList = anyOfAttr.freeze(path) + const assertFrozen: A.Extends = 1 + assertFrozen + + expect(anyOfAttr).toMatchObject({ + [$type]: 'anyOf', + [$elements]: [str], + [$required]: 'atLeastOnce', + [$key]: false, + [$savedAs]: undefined, + [$hidden]: false, + [$defaults]: { + key: undefined, + put: undefined, + update: undefined + } + }) + }) + + // TODO: Reimplement options as potential first argument + // it('returns required anyOf (option)', () => { + // const anyOfAtLeastOnce = anyOf({ required: 'atLeastOnce' }, str) + // const anyOfAlways = anyOf({ required: 'always' }, str) + // const anyOfNever = anyOf({ required: 'never' }, str) + + // const assertAtLeastOnce: A.Contains = 1 + // assertAtLeastOnce + // const assertAlways: A.Contains = 1 + // assertAlways + // const assertNever: A.Contains = 1 + // assertNever + + // expect(anyOfAtLeastOnce).toMatchObject({ [$required]: 'atLeastOnce' }) + // expect(anyOfAlways).toMatchObject({ [$required]: 'always' }) + // expect(anyOfNever).toMatchObject({ [$required]: 'never' }) + // }) + + it('returns required anyOf (method)', () => { + const anyOfAtLeastOnce = anyOf(str).required() + const anyOfAlways = anyOf(str).required('always') + const anyOfNever = anyOf(str).required('never') + const anyOfOpt = anyOf(str).optional() + + const assertAtLeastOnce: A.Contains = 1 + assertAtLeastOnce + const assertAlways: A.Contains = 1 + assertAlways + const assertNever: A.Contains = 1 + assertNever + const assertOpt: A.Contains = 1 + assertOpt + + expect(anyOfAtLeastOnce).toMatchObject({ [$required]: 'atLeastOnce' }) + expect(anyOfAlways).toMatchObject({ [$required]: 'always' }) + expect(anyOfNever).toMatchObject({ [$required]: 'never' }) + }) + + // TODO: Reimplement options as potential first argument + // it('returns hidden anyOf (option)', () => { + // const anyOfAttr = anyOf({ hidden: true }, str) + + // const assertAnyOf: A.Contains = 1 + // assertAnyOf + + // expect(anyOfAttr).toMatchObject({ [$hidden]: true }) + // }) + + it('returns hidden anyOf (method)', () => { + const anyOfAttr = anyOf(str).hidden() + + const assertAnyOf: A.Contains = 1 + assertAnyOf + + expect(anyOfAttr).toMatchObject({ [$hidden]: true }) + }) + + // TODO: Reimplement options as potential first argument + // it('returns key anyOf (option)', () => { + // const anyOfAttr = anyOf({ key: true }, str) + + // const assertAnyOf: A.Contains = 1 + // assertAnyOf + + // expect(anyOfAttr).toMatchObject({ [$key]: true, [$required]: 'atLeastOnce' }) + // }) + + it('returns key anyOf (method)', () => { + const anyOfAttr = anyOf(str).key() + + const assertAnyOf: A.Contains = 1 + assertAnyOf + + expect(anyOfAttr).toMatchObject({ [$key]: true, [$required]: 'always' }) + }) + + // TODO: Reimplement options as potential first argument + // it('returns savedAs anyOf (option)', () => { + // const anyOfAttr = anyOf({ savedAs: 'foo' }, str) + + // const assertAnyOf: A.Contains = 1 + // assertAnyOf + + // expect(anyOfAttr).toMatchObject({ [$savedAs]: 'foo' }) + // }) + + it('returns savedAs anyOf (method)', () => { + const anyOfAttr = anyOf(str).savedAs('foo') + + const assertAnyOf: A.Contains = 1 + assertAnyOf + + expect(anyOfAttr).toMatchObject({ [$savedAs]: 'foo' }) + }) + + // TODO: Reimplement options as potential first argument + // it('returns defaulted anyOf (option)', () => { + // const anyOfAttr = anyOf( + // // TOIMPROVE: Add type constraints here + // { defaults: { key: undefined, put: 'foo', update: undefined } }, + // str + // ) + + // const assertAnyOf: A.Contains< + // typeof anyOfAttr, + // { [$defaults]: { key: undefined; put: unknown; update: undefined } } + // > = 1 + // assertAnyOf + + // expect(anyOfAttr).toMatchObject({ + // [$defaults]: { key: undefined, put: 'foo', update: undefined } + // }) + // }) + + it('returns defaulted anyOf (method)', () => { + const anyOfAttr = anyOf(str).updateDefault('bar') + + const assertAnyOf: A.Contains< + typeof anyOfAttr, + { [$defaults]: { key: undefined; put: undefined; update: unknown } } + > = 1 + assertAnyOf + + expect(anyOfAttr).toMatchObject({ + [$defaults]: { key: undefined, put: undefined, update: 'bar' } + }) + }) + + it('returns anyOf with PUT default value if it is not key (default shorthand)', () => { + const anyOfAttr = anyOf(str).default('foo') + + const assertAnyOf: A.Contains< + typeof anyOfAttr, + { [$defaults]: { key: undefined; put: unknown; update: undefined } } + > = 1 + assertAnyOf + + expect(anyOfAttr).toMatchObject({ + [$defaults]: { key: undefined, put: 'foo', update: undefined } + }) + }) + + it('returns anyOf with KEY default value if it is key (default shorthand)', () => { + const anyOfAttr = anyOf(str).key().default('foo') + + const assertAnyOf: A.Contains< + typeof anyOfAttr, + { [$defaults]: { key: unknown; put: undefined; update: undefined } } + > = 1 + assertAnyOf + + expect(anyOfAttr).toMatchObject({ + [$defaults]: { key: 'foo', put: undefined, update: undefined } + }) + }) + + it('anyOf of anyOfs', () => { + const nestedAnyOff = anyOf(str) + const anyOfAttr = anyOf(nestedAnyOff) + + const assertAnyOf: A.Contains< + typeof anyOfAttr, + { + [$type]: 'anyOf' + [$elements]: [typeof nestedAnyOff] + [$required]: AtLeastOnce + [$hidden]: false + [$key]: false + [$savedAs]: undefined + [$defaults]: { + key: undefined + put: undefined + update: undefined + } + } + > = 1 + assertAnyOf + + expect(anyOfAttr).toMatchObject({ + [$type]: 'anyOf', + [$elements]: [nestedAnyOff], + [$required]: 'atLeastOnce', + [$hidden]: false, + [$key]: false, + [$savedAs]: undefined, + [$defaults]: { + key: undefined, + put: undefined, + update: undefined + } + }) + }) +}) diff --git a/src/v1/schema/attributes/anyOf/types.ts b/src/v1/schema/attributes/anyOf/types.ts new file mode 100644 index 000000000..68bcf23c2 --- /dev/null +++ b/src/v1/schema/attributes/anyOf/types.ts @@ -0,0 +1,25 @@ +import type { AtLeastOnce } from '../constants' +import type { $required, $hidden, $savedAs, $defaults } from '../constants/attributeOptions' +import type { $AttributeNestedState, Attribute } from '../types' + +export type $AnyOfAttributeElements = $AttributeNestedState & { + [$required]: AtLeastOnce + [$hidden]: false + [$savedAs]: undefined + [$defaults]: { + key: undefined + put: undefined + update: undefined + } +} + +export type AnyOfAttributeElements = Attribute & { + required: AtLeastOnce + hidden: false + savedAs: undefined + defaults: { + key: undefined + put: undefined + update: undefined + } +} diff --git a/src/v1/schema/attributes/constants/attributeOptions.ts b/src/v1/schema/attributes/constants/attributeOptions.ts new file mode 100644 index 000000000..df4d05329 --- /dev/null +++ b/src/v1/schema/attributes/constants/attributeOptions.ts @@ -0,0 +1,88 @@ +import { ResolvedPrimitiveAttribute } from '../primitive' + +import { RequiredOption } from './requiredOptions' + +export const $type = Symbol('$type') +export type $type = typeof $type + +export const $elements = Symbol('$elements') +export type $elements = typeof $elements + +export const $attributes = Symbol('$attributes') +export type $attributes = typeof $attributes + +export const $value = Symbol('$value') +export type $value = typeof $value + +export const $required = Symbol('$required') +export type $required = typeof $required + +export const $hidden = Symbol('$hidden') +export type $hidden = typeof $hidden + +export const $keys = Symbol('$keys') +export type $keys = typeof $keys + +export const $key = Symbol('$key') +export type $key = typeof $key + +export const $defaults = Symbol('$defaults') +export type $defaults = typeof $defaults + +export const $enum = Symbol('$enum') +export type $enum = typeof $enum + +export const $savedAs = Symbol('$savedAs') +export type $savedAs = typeof $savedAs + +export const $castAs = Symbol('$castAs') +export type $castAs = typeof $castAs + +export const $transform = Symbol('$transform') +export type $transform = typeof $transform + +export type $AttributeOptionSymbol = + | $type + | $keys + | $elements + | $attributes + | $value + | $required + | $hidden + | $key + | $defaults + | $enum + | $savedAs + | $castAs + | $transform + +export type AttributeOptionSymbolName = { + [$type]: 'type' + [$keys]: 'keys' + [$elements]: 'elements' + [$attributes]: 'attributes' + [$value]: 'value' + [$required]: 'required' + [$hidden]: 'hidden' + [$key]: 'key' + [$defaults]: 'defaults' + [$enum]: 'enum' + [$savedAs]: 'savedAs' + [$castAs]: 'castAs' + [$transform]: 'transform' +} + +export type AttributeOptionName = AttributeOptionSymbolName[$AttributeOptionSymbol] + +export type AttributeOptions = { + required: RequiredOption + hidden: boolean + key: boolean + savedAs: string | undefined + defaults: { + key: undefined | unknown + put: undefined | unknown + update: undefined | unknown + } + enum: ResolvedPrimitiveAttribute[] | undefined +} diff --git a/src/v1/schema/attributes/constants/index.ts b/src/v1/schema/attributes/constants/index.ts new file mode 100644 index 000000000..6723a4254 --- /dev/null +++ b/src/v1/schema/attributes/constants/index.ts @@ -0,0 +1,2 @@ +export { RequiredOption, Never, AtLeastOnce, Always } from './requiredOptions' +export * from './attributeOptions' diff --git a/src/v1/schema/attributes/constants/requiredOptions.ts b/src/v1/schema/attributes/constants/requiredOptions.ts new file mode 100644 index 000000000..659e968d1 --- /dev/null +++ b/src/v1/schema/attributes/constants/requiredOptions.ts @@ -0,0 +1,30 @@ +/** + * Tag for optional attributes + */ +export type Never = 'never' + +/** + * Tag for required at least once attributes + */ +export type AtLeastOnce = 'atLeastOnce' + +// To re-introduce once updates are supported +// /** +// * Tag for required only once attributes +// */ +// export type OnlyOnce = 'onlyOnce' + +/** + * Tag for always required attributes + */ +export type Always = 'always' + +/** + * Available options for attributes required option + */ +export type RequiredOption = Never | AtLeastOnce | Always + +/** + * Available options for attributes required options as Set + */ +export const requiredOptionsSet = new Set(['never', 'atLeastOnce', 'always']) diff --git a/src/v1/schema/attributes/errors.ts b/src/v1/schema/attributes/errors.ts new file mode 100644 index 000000000..2b759a842 --- /dev/null +++ b/src/v1/schema/attributes/errors.ts @@ -0,0 +1,16 @@ +import type { PrimitiveAttributeErrorBlueprints } from './primitive/errors' +import type { SetAttributeErrorBlueprints } from './set/errors' +import type { ListAttributeErrorBlueprints } from './list/errors' +import type { MapAttributeErrorBlueprints } from './map/errors' +import type { RecordAttributeErrorBlueprints } from './record/errors' +import type { AnyOfAttributeErrorBlueprints } from './anyOf/errors' +import type { SharedAttributeErrorBlueprints } from './shared/errors' + +export type AttributeErrorBlueprints = + | PrimitiveAttributeErrorBlueprints + | SetAttributeErrorBlueprints + | ListAttributeErrorBlueprints + | MapAttributeErrorBlueprints + | RecordAttributeErrorBlueprints + | AnyOfAttributeErrorBlueprints + | SharedAttributeErrorBlueprints diff --git a/src/v1/schema/attributes/freeze.ts b/src/v1/schema/attributes/freeze.ts new file mode 100644 index 000000000..5f693a0cc --- /dev/null +++ b/src/v1/schema/attributes/freeze.ts @@ -0,0 +1,26 @@ +import type { $AnyAttributeState, FreezeAnyAttribute } from './any' +import type { $PrimitiveAttributeState, FreezePrimitiveAttribute } from './primitive' +import type { $SetAttributeState, FreezeSetAttribute } from './set' +import type { $ListAttributeState, FreezeListAttribute } from './list' +import type { $MapAttributeState, FreezeMapAttribute } from './map' +import type { $RecordAttributeState, FreezeRecordAttribute } from './record' +import type { $AnyOfAttributeState, FreezeAnyOfAttribute } from './anyOf' +import type { $AttributeState } from './types' + +export type FreezeAttribute< + $ATTRIBUTE extends $AttributeState +> = $ATTRIBUTE extends $AnyAttributeState + ? FreezeAnyAttribute<$ATTRIBUTE> + : $ATTRIBUTE extends $PrimitiveAttributeState + ? FreezePrimitiveAttribute<$ATTRIBUTE> + : $ATTRIBUTE extends $SetAttributeState + ? FreezeSetAttribute<$ATTRIBUTE> + : $ATTRIBUTE extends $ListAttributeState + ? FreezeListAttribute<$ATTRIBUTE> + : $ATTRIBUTE extends $MapAttributeState + ? FreezeMapAttribute<$ATTRIBUTE> + : $ATTRIBUTE extends $RecordAttributeState + ? FreezeRecordAttribute<$ATTRIBUTE> + : $ATTRIBUTE extends $AnyOfAttributeState + ? FreezeAnyOfAttribute<$ATTRIBUTE> + : never diff --git a/src/v1/schema/attributes/index.ts b/src/v1/schema/attributes/index.ts new file mode 100644 index 000000000..1adee9bb9 --- /dev/null +++ b/src/v1/schema/attributes/index.ts @@ -0,0 +1,43 @@ +export * from './any' +export * from './primitive' +export * from './set' +export * from './list' +export * from './map' +export * from './record' +export * from './anyOf' + +export * from './constants' +export * from './types' + +import { any } from './any' +import { binary, boolean, number, string } from './primitive' +import { set } from './set' +import { list } from './list' +import { map } from './map' +import { record } from './record' +import { anyOf } from './anyOf' + +export const attribute: { + any: typeof any + binary: typeof binary + boolean: typeof boolean + number: typeof number + string: typeof string + set: typeof set + list: typeof list + map: typeof map + record: typeof record + anyOf: typeof anyOf +} = { + any, + binary, + boolean, + number, + string, + set, + list, + map, + record, + anyOf +} +export const attr = attribute diff --git a/src/v1/schema/attributes/list/errors.ts b/src/v1/schema/attributes/list/errors.ts new file mode 100644 index 000000000..87bb01f17 --- /dev/null +++ b/src/v1/schema/attributes/list/errors.ts @@ -0,0 +1,31 @@ +import type { ErrorBlueprint } from 'v1/errors/blueprint' + +type OptionalElementsErrorBlueprint = ErrorBlueprint<{ + code: 'schema.listAttribute.optionalElements' + hasPath: true + payload: undefined +}> + +type HiddenElementsErrorBlueprint = ErrorBlueprint<{ + code: 'schema.listAttribute.hiddenElements' + hasPath: true + payload: undefined +}> + +type SavedAsElementsErrorBlueprint = ErrorBlueprint<{ + code: 'schema.listAttribute.savedAsElements' + hasPath: true + payload: undefined +}> + +type DefaultedElementsErrorBlueprint = ErrorBlueprint<{ + code: 'schema.listAttribute.defaultedElements' + hasPath: true + payload: undefined +}> + +export type ListAttributeErrorBlueprints = + | OptionalElementsErrorBlueprint + | HiddenElementsErrorBlueprint + | SavedAsElementsErrorBlueprint + | DefaultedElementsErrorBlueprint diff --git a/src/v1/schema/attributes/list/freeze.ts b/src/v1/schema/attributes/list/freeze.ts new file mode 100644 index 000000000..31f56023f --- /dev/null +++ b/src/v1/schema/attributes/list/freeze.ts @@ -0,0 +1,101 @@ +import type { O } from 'ts-toolbelt' + +import { DynamoDBToolboxError } from 'v1/errors' + +import type { FreezeAttribute } from '../freeze' +import { validateAttributeProperties } from '../shared/validate' +import { hasDefinedDefault } from '../shared/hasDefinedDefault' +import { + $elements, + $required, + $hidden, + $key, + $savedAs, + $defaults +} from '../constants/attributeOptions' + +import type { SharedAttributeState } from '../shared/interface' +import type { $ListAttributeState, ListAttribute } from './interface' +import type { $ListAttributeElements } from './types' + +export type FreezeListAttribute<$LIST_ATTRIBUTE extends $ListAttributeState> = + // Applying void O.Update improves type display + O.Update< + ListAttribute< + FreezeAttribute<$LIST_ATTRIBUTE[$elements]>, + { + required: $LIST_ATTRIBUTE[$required] + hidden: $LIST_ATTRIBUTE[$hidden] + key: $LIST_ATTRIBUTE[$key] + savedAs: $LIST_ATTRIBUTE[$savedAs] + defaults: $LIST_ATTRIBUTE[$defaults] + } + >, + never, + never + > + +type ListAttributeFreezer = < + $ELEMENTS extends $ListAttributeElements, + STATE extends SharedAttributeState +>( + $elements: $ELEMENTS, + state: STATE, + path: string +) => FreezeListAttribute<$ListAttributeState<$ELEMENTS, STATE>> + +/** + * Freezes a warm `list` attribute + * + * @param elements Attribute elements + * @param state Attribute options + * @param path Path of the instance in the related schema (string) + * @return void + */ +export const freezeListAttribute: ListAttributeFreezer = < + $ELEMENTS extends $ListAttributeElements, + STATE extends SharedAttributeState +>( + elements: $ELEMENTS, + state: STATE, + path: string +): FreezeListAttribute<$ListAttributeState<$ELEMENTS, STATE>> => { + validateAttributeProperties(state, path) + + if (elements[$required] !== 'atLeastOnce' && elements[$required] !== 'always') { + throw new DynamoDBToolboxError('schema.listAttribute.optionalElements', { + message: `Invalid list elements at path ${path}: List elements must be required`, + path + }) + } + + if (elements[$hidden] !== false) { + throw new DynamoDBToolboxError('schema.listAttribute.hiddenElements', { + message: `Invalid list elements at path ${path}: List elements cannot be hidden`, + path + }) + } + + if (elements[$savedAs] !== undefined) { + throw new DynamoDBToolboxError('schema.listAttribute.savedAsElements', { + message: `Invalid list elements at path ${path}: List elements cannot be renamed (have savedAs option)`, + path + }) + } + + if (hasDefinedDefault(elements)) { + throw new DynamoDBToolboxError('schema.listAttribute.defaultedElements', { + message: `Invalid list elements at path ${path}: List elements cannot have default values`, + path + }) + } + + const frozenElements = elements.freeze(`${path}[n]`) as FreezeAttribute<$ELEMENTS> + + return { + path, + type: 'list', + elements: frozenElements, + ...state + } +} diff --git a/src/v1/schema/attributes/list/index.ts b/src/v1/schema/attributes/list/index.ts new file mode 100644 index 000000000..ce0d3341f --- /dev/null +++ b/src/v1/schema/attributes/list/index.ts @@ -0,0 +1,8 @@ +export { list } from './typer' +export type { + $ListAttributeState, + $ListAttributeNestedState, + $ListAttribute, + ListAttribute +} from './interface' +export type { FreezeListAttribute } from './freeze' diff --git a/src/v1/schema/attributes/list/interface.ts b/src/v1/schema/attributes/list/interface.ts new file mode 100644 index 000000000..2d4624d65 --- /dev/null +++ b/src/v1/schema/attributes/list/interface.ts @@ -0,0 +1,285 @@ +import type { O } from 'ts-toolbelt' + +import type { If, ValueOrGetter } from 'v1/types' +import type { + AttributeKeyInput, + AttributePutItemInput, + AttributeUpdateItemInput, + KeyInput, + PutItemInput, + UpdateItemInput +} from 'v1/operations' + +import type { Schema } from '../../interface' +import type { RequiredOption, AtLeastOnce, Never, Always } from '../constants' +import type { $type, $elements } from '../constants/attributeOptions' +import type { $SharedAttributeState, SharedAttributeState } from '../shared/interface' + +import type { FreezeListAttribute } from './freeze' +import type { $ListAttributeElements, ListAttributeElements } from './types' + +export interface $ListAttributeState< + $ELEMENTS extends $ListAttributeElements = $ListAttributeElements, + STATE extends SharedAttributeState = SharedAttributeState +> extends $SharedAttributeState { + [$type]: 'list' + [$elements]: $ELEMENTS +} + +export interface $ListAttributeNestedState< + $ELEMENTS extends $ListAttributeElements = $ListAttributeElements, + STATE extends SharedAttributeState = SharedAttributeState +> extends $ListAttributeState<$ELEMENTS, STATE> { + freeze: (path: string) => FreezeListAttribute<$ListAttributeState<$ELEMENTS, STATE>> +} + +/** + * List attribute interface + */ +export interface $ListAttribute< + $ELEMENTS extends $ListAttributeElements = $ListAttributeElements, + STATE extends SharedAttributeState = SharedAttributeState +> extends $ListAttributeNestedState<$ELEMENTS, STATE> { + /** + * Tag attribute as required. Possible values are: + * - `"atLeastOnce"` _(default)_: Required in PUTs, optional in UPDATEs + * - `"never"`: Optional in PUTs and UPDATEs + * - `"always"`: Required in PUTs and UPDATEs + * + * @param nextRequired RequiredOption + */ + required: ( + nextRequired?: NEXT_IS_REQUIRED + ) => $ListAttribute<$ELEMENTS, O.Overwrite> + /** + * Shorthand for `required('never')` + */ + optional: () => $ListAttribute<$ELEMENTS, O.Overwrite> + /** + * Hide attribute after fetch commands and formatting + */ + hidden: () => $ListAttribute<$ELEMENTS, O.Overwrite> + /** + * Tag attribute as needed for Primary Key computing + */ + key: () => $ListAttribute<$ELEMENTS, O.Overwrite> + /** + * Rename attribute before save commands + */ + savedAs: ( + nextSavedAs: NEXT_SAVED_AS + ) => $ListAttribute<$ELEMENTS, O.Overwrite> + /** + * Provide a default value for attribute in Primary Key computing + * + * @param nextKeyDefault `keyAttributeInput | (() => keyAttributeInput)` + */ + keyDefault: ( + nextKeyDefault: ValueOrGetter< + AttributeKeyInput>, true> + > + ) => $ListAttribute< + $ELEMENTS, + O.Overwrite< + STATE, + { + defaults: { + key: unknown + put: STATE['defaults']['put'] + update: STATE['defaults']['update'] + } + } + > + > + /** + * Provide a default value for attribute in PUT commands + * + * @param nextPutDefault `putAttributeInput | (() => putAttributeInput)` + */ + putDefault: ( + nextPutDefault: ValueOrGetter< + AttributePutItemInput>, true> + > + ) => $ListAttribute< + $ELEMENTS, + O.Overwrite< + STATE, + { + defaults: { + key: STATE['defaults']['key'] + put: unknown + update: STATE['defaults']['update'] + } + } + > + > + /** + * Provide a default value for attribute in UPDATE commands + * + * @param nextUpdateDefault `updateAttributeInput | (() => updateAttributeInput)` + */ + updateDefault: ( + nextUpdateDefault: ValueOrGetter< + AttributeUpdateItemInput>, true> + > + ) => $ListAttribute< + $ELEMENTS, + O.Overwrite< + STATE, + { + defaults: { + key: STATE['defaults']['key'] + put: STATE['defaults']['put'] + update: unknown + } + } + > + > + /** + * Provide a default value for attribute in PUT commands OR Primary Key computing if attribute is tagged as key + * + * @param nextDefault `key/putAttributeInput | (() => key/putAttributeInput)` + */ + default: ( + nextDefault: ValueOrGetter< + If< + STATE['key'], + AttributeKeyInput>, true>, + AttributePutItemInput>, true> + > + > + ) => $ListAttribute< + $ELEMENTS, + O.Overwrite< + STATE, + { + defaults: If< + STATE['key'], + { + key: unknown + put: STATE['defaults']['put'] + update: STATE['defaults']['update'] + }, + { + key: STATE['defaults']['key'] + put: unknown + update: STATE['defaults']['update'] + } + > + } + > + > + /** + * Provide a **linked** default value for attribute in Primary Key computing + * + * @param nextKeyDefault `keyAttributeInput | ((keyInput) => keyAttributeInput)` + */ + keyLink: ( + nextKeyDefault: ValueOrGetter< + AttributeKeyInput>, true>, + [KeyInput] + > + ) => $ListAttribute< + $ELEMENTS, + O.Overwrite< + STATE, + { + defaults: { + key: unknown + put: STATE['defaults']['put'] + update: STATE['defaults']['update'] + } + } + > + > + /** + * Provide a **linked** default value for attribute in PUT commands + * + * @param nextPutDefault `putAttributeInput | ((putItemInput) => putAttributeInput)` + */ + putLink: ( + nextPutDefault: ValueOrGetter< + AttributePutItemInput>, true>, + [PutItemInput] + > + ) => $ListAttribute< + $ELEMENTS, + O.Overwrite< + STATE, + { + defaults: { + key: STATE['defaults']['key'] + put: unknown + update: STATE['defaults']['update'] + } + } + > + > + /** + * Provide a **linked** default value for attribute in UPDATE commands + * + * @param nextUpdateDefault `unknown | ((updateItemInput) => updateAttributeInput)` + */ + updateLink: ( + nextUpdateDefault: ValueOrGetter< + AttributeUpdateItemInput>, true>, + [UpdateItemInput] + > + ) => $ListAttribute< + $ELEMENTS, + O.Overwrite< + STATE, + { + defaults: { + key: STATE['defaults']['key'] + put: STATE['defaults']['put'] + update: unknown + } + } + > + > + /** + * Provide a **linked** default value for attribute in PUT commands OR Primary Key computing if attribute is tagged as key + * + * @param nextDefault `key/putAttributeInput | (() => key/putAttributeInput)` + */ + link: ( + nextDefault: ValueOrGetter< + If< + STATE['key'], + AttributeKeyInput>, true>, + AttributePutItemInput>, true> + >, + [If, PutItemInput>] + > + ) => $ListAttribute< + $ELEMENTS, + O.Overwrite< + STATE, + { + defaults: If< + STATE['key'], + { + key: unknown + put: STATE['defaults']['put'] + update: STATE['defaults']['update'] + }, + { + key: STATE['defaults']['key'] + put: unknown + update: STATE['defaults']['update'] + } + > + } + > + > +} + +export interface ListAttribute< + ELEMENTS extends ListAttributeElements = ListAttributeElements, + STATE extends SharedAttributeState = SharedAttributeState +> extends SharedAttributeState { + path: string + type: 'list' + elements: ELEMENTS +} diff --git a/src/v1/schema/attributes/list/options.ts b/src/v1/schema/attributes/list/options.ts new file mode 100644 index 000000000..f5c4bf309 --- /dev/null +++ b/src/v1/schema/attributes/list/options.ts @@ -0,0 +1,60 @@ +import type { RequiredOption, AtLeastOnce } from '../constants' + +// Note: May look like a duplicate of AnyAttributeState but actually adds JSDocs + +/** + * Input options of List Attribute + */ +export interface ListAttributeOptions { + /** + * Tag attribute as required. Possible values are: + * - `"atLeastOnce"` _(default)_: Required in PUTs, optional in UPDATEs + * - `"never"`: Optional in PUTs and UPDATEs + * - `"always"`: Required in PUTs and UPDATEs + */ + required: RequiredOption + /** + * Hide attribute after fetch commands and formatting + */ + hidden: boolean + /** + * Tag attribute as needed for Primary Key computing + */ + key: boolean + /** + * Rename attribute before save commands + */ + savedAs: string | undefined + /** + * Provide default values for attribute + */ + defaults: { + key: undefined | unknown + put: undefined | unknown + update: undefined | unknown + } +} + +export type ListAttributeDefaultOptions = { + required: AtLeastOnce + hidden: false + key: false + savedAs: undefined + defaults: { + key: undefined + put: undefined + update: undefined + } +} + +export const LIST_DEFAULT_OPTIONS: ListAttributeDefaultOptions = { + required: 'atLeastOnce', + hidden: false, + key: false, + savedAs: undefined, + defaults: { + key: undefined, + put: undefined, + update: undefined + } +} diff --git a/src/v1/schema/attributes/list/typer.ts b/src/v1/schema/attributes/list/typer.ts new file mode 100644 index 000000000..4d4efc0b5 --- /dev/null +++ b/src/v1/schema/attributes/list/typer.ts @@ -0,0 +1,184 @@ +import type { NarrowObject } from 'v1/types/narrowObject' +import { overwrite } from 'v1/utils/overwrite' + +import type { RequiredOption, AtLeastOnce } from '../constants' +import { + $type, + $elements, + $required, + $hidden, + $key, + $savedAs, + $defaults +} from '../constants/attributeOptions' +import type { InferStateFromOptions } from '../shared/inferStateFromOptions' +import type { SharedAttributeState } from '../shared/interface' + +import type { $ListAttributeElements } from './types' +import type { $ListAttribute } from './interface' +import { ListAttributeOptions, ListAttributeDefaultOptions, LIST_DEFAULT_OPTIONS } from './options' +import { freezeListAttribute } from './freeze' + +type $ListAttributeTyper = < + $ELEMENTS extends $ListAttributeElements, + STATE extends SharedAttributeState = SharedAttributeState +>( + elements: $ELEMENTS, + state: STATE +) => $ListAttribute<$ELEMENTS, STATE> + +const $list: $ListAttributeTyper = < + $ELEMENTS extends $ListAttributeElements, + STATE extends SharedAttributeState = SharedAttributeState +>( + elements: $ELEMENTS, + state: STATE +) => { + const $listAttribute: $ListAttribute<$ELEMENTS, STATE> = { + [$type]: 'list', + [$elements]: elements, + [$required]: state.required, + [$hidden]: state.hidden, + [$key]: state.key, + [$savedAs]: state.savedAs, + [$defaults]: state.defaults, + required: ( + nextRequired: NEXT_IS_REQUIRED = 'atLeastOnce' as NEXT_IS_REQUIRED + ) => $list(elements, overwrite(state, { required: nextRequired })), + optional: () => $list(elements, overwrite(state, { required: 'never' })), + hidden: () => $list(elements, overwrite(state, { hidden: true })), + key: () => $list(elements, overwrite(state, { key: true, required: 'always' })), + savedAs: nextSavedAs => $list(elements, overwrite(state, { savedAs: nextSavedAs })), + keyDefault: nextKeyDefault => + $list( + elements, + overwrite(state, { + defaults: { + key: nextKeyDefault, + put: state.defaults.put, + update: state.defaults.update + } + }) + ), + putDefault: nextPutDefault => + $list( + elements, + overwrite(state, { + defaults: { + key: state.defaults.key, + put: nextPutDefault, + update: state.defaults.update + } + }) + ), + updateDefault: nextUpdateDefault => + $list( + elements, + overwrite(state, { + defaults: { + key: state.defaults.key, + put: state.defaults.put, + update: nextUpdateDefault + } + }) + ), + default: nextDefault => + $list( + elements, + overwrite(state, { + defaults: state.key + ? { key: nextDefault, put: state.defaults.put, update: state.defaults.update } + : { key: state.defaults.key, put: nextDefault, update: state.defaults.update } + }) + ), + keyLink: nextKeyDefault => + $list( + elements, + overwrite(state, { + defaults: { + key: nextKeyDefault, + put: state.defaults.put, + update: state.defaults.update + } + }) + ), + putLink: nextPutDefault => + $list( + elements, + overwrite(state, { + defaults: { + key: state.defaults.key, + put: nextPutDefault, + update: state.defaults.update + } + }) + ), + updateLink: nextUpdateDefault => + $list( + elements, + overwrite(state, { + defaults: { + key: state.defaults.key, + put: state.defaults.put, + update: nextUpdateDefault + } + }) + ), + link: nextDefault => + $list( + elements, + overwrite(state, { + defaults: state.key + ? { key: nextDefault, put: state.defaults.put, update: state.defaults.update } + : { key: state.defaults.key, put: nextDefault, update: state.defaults.update } + }) + ), + freeze: path => freezeListAttribute(elements, state, path) + } + + return $listAttribute +} + +type ListAttributeTyper = < + $ELEMENTS extends $ListAttributeElements, + OPTIONS extends Partial = ListAttributeOptions +>( + elements: $ELEMENTS, + options?: NarrowObject +) => $ListAttribute< + $ELEMENTS, + InferStateFromOptions +> + +/** + * Define a new list attribute + * Not that list elements have constraints. They must be: + * - Required (required: AtLeastOnce) + * - Displayed (hidden: false) + * - Not renamed (savedAs: undefined) + * - Not defaulted (defaults: undefined) + * + * @param elements Attribute (With constraints) + * @param options _(optional)_ List Options + */ +export const list: ListAttributeTyper = < + $ELEMENTS extends $ListAttributeElements, + OPTIONS extends Partial = ListAttributeOptions +>( + elements: $ELEMENTS, + options?: NarrowObject +): $ListAttribute< + $ELEMENTS, + InferStateFromOptions +> => { + const state = { + ...LIST_DEFAULT_OPTIONS, + ...options, + defaults: { + ...LIST_DEFAULT_OPTIONS.defaults, + ...options?.defaults + } + } as InferStateFromOptions + + return $list(elements, state) +} diff --git a/src/v1/schema/attributes/list/typer.unit.test.ts b/src/v1/schema/attributes/list/typer.unit.test.ts new file mode 100644 index 000000000..1d5e950ea --- /dev/null +++ b/src/v1/schema/attributes/list/typer.unit.test.ts @@ -0,0 +1,386 @@ +import type { A } from 'ts-toolbelt' + +import { DynamoDBToolboxError } from 'v1/errors' + +import { Never, AtLeastOnce, Always } from '../constants' +import { string } from '../primitive' +import { + $type, + $elements, + $required, + $hidden, + $key, + $savedAs, + $defaults +} from '../constants/attributeOptions' + +import type { ListAttribute, $ListAttributeState } from './interface' +import { list } from './typer' + +describe('list', () => { + const path = 'some.path' + const strElement = string() + + it('rejects non-required elements', () => { + const invalidList = list( + // @ts-expect-error + string().optional() + ) + + const invalidCall = () => invalidList.freeze(path) + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow( + expect.objectContaining({ code: 'schema.listAttribute.optionalElements', path }) + ) + }) + + it('rejects hidden elements', () => { + const invalidList = list( + // @ts-expect-error + strElement.hidden() + ) + + const invalidCall = () => invalidList.freeze(path) + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow( + expect.objectContaining({ code: 'schema.listAttribute.hiddenElements', path }) + ) + }) + + it('rejects elements with savedAs values', () => { + const invalidList = list( + // @ts-expect-error + strElement.savedAs('foo') + ) + + const invalidCall = () => invalidList.freeze(path) + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow( + expect.objectContaining({ code: 'schema.listAttribute.savedAsElements', path }) + ) + }) + + it('rejects elements with default values', () => { + const invalidList = list( + // @ts-expect-error + strElement.putDefault('foo') + ) + + const invalidCall = () => invalidList.freeze(path) + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow( + expect.objectContaining({ code: 'schema.listAttribute.defaultedElements', path }) + ) + }) + + it('returns default list', () => { + const lst = list(strElement) + + const assertList: A.Contains< + typeof lst, + { + [$type]: 'list' + [$elements]: typeof strElement + [$required]: AtLeastOnce + [$hidden]: false + [$key]: false + [$savedAs]: undefined + [$defaults]: { + key: undefined + put: undefined + update: undefined + } + } + > = 1 + assertList + + const assertExtends: A.Extends = 1 + assertExtends + + const frozenList = lst.freeze(path) + const assertFrozen: A.Extends = 1 + assertFrozen + + expect(lst).toMatchObject({ + [$type]: 'list', + [$elements]: strElement, + [$required]: 'atLeastOnce', + [$key]: false, + [$savedAs]: undefined, + [$hidden]: false, + [$defaults]: { + key: undefined, + put: undefined, + update: undefined + } + }) + }) + + it('returns required list (option)', () => { + const lstAtLeastOnce = list(strElement, { required: 'atLeastOnce' }) + const lstAlways = list(strElement, { required: 'always' }) + const lstNever = list(strElement, { required: 'never' }) + + const assertAtLeastOnce: A.Contains = 1 + assertAtLeastOnce + const assertAlways: A.Contains = 1 + assertAlways + const assertNever: A.Contains = 1 + assertNever + + expect(lstAtLeastOnce).toMatchObject({ [$required]: 'atLeastOnce' }) + expect(lstAlways).toMatchObject({ [$required]: 'always' }) + expect(lstNever).toMatchObject({ [$required]: 'never' }) + }) + + it('returns required list (method)', () => { + const lstAtLeastOnce = list(strElement).required() + const lstAlways = list(strElement).required('always') + const lstNever = list(strElement).required('never') + const lstOpt = list(strElement).optional() + + const assertAtLeastOnce: A.Contains = 1 + assertAtLeastOnce + const assertAlways: A.Contains = 1 + assertAlways + const assertNever: A.Contains = 1 + assertNever + const assertOpt: A.Contains = 1 + assertOpt + + expect(lstAtLeastOnce).toMatchObject({ [$required]: 'atLeastOnce' }) + expect(lstAlways).toMatchObject({ [$required]: 'always' }) + expect(lstNever).toMatchObject({ [$required]: 'never' }) + }) + + it('returns hidden list (option)', () => { + const lst = list(strElement, { hidden: true }) + + const assertList: A.Contains = 1 + assertList + + expect(lst).toMatchObject({ [$hidden]: true }) + }) + + it('returns hidden list (method)', () => { + const lst = list(strElement).hidden() + + const assertList: A.Contains = 1 + assertList + + expect(lst).toMatchObject({ [$hidden]: true }) + }) + + it('returns key list (option)', () => { + const lst = list(strElement, { key: true }) + + const assertList: A.Contains = 1 + assertList + + expect(lst).toMatchObject({ [$key]: true, [$required]: 'atLeastOnce' }) + }) + + it('returns key list (method)', () => { + const lst = list(strElement).key() + + const assertList: A.Contains = 1 + assertList + + expect(lst).toMatchObject({ [$key]: true, [$required]: 'always' }) + }) + + it('returns savedAs list (option)', () => { + const lst = list(strElement, { savedAs: 'foo' }) + + const assertList: A.Contains = 1 + assertList + + expect(lst).toMatchObject({ [$savedAs]: 'foo' }) + }) + + it('returns savedAs list (method)', () => { + const lst = list(strElement).savedAs('foo') + + const assertList: A.Contains = 1 + assertList + + expect(lst).toMatchObject({ [$savedAs]: 'foo' }) + }) + + it('returns defaulted list (option)', () => { + const lstA = list(strElement, { + // TOIMPROVE: Add type constraints here + defaults: { key: ['foo'], put: undefined, update: undefined } + }) + + const assertListA: A.Contains< + typeof lstA, + { [$defaults]: { key: unknown; put: undefined; update: undefined } } + > = 1 + assertListA + + expect(lstA).toMatchObject({ + [$defaults]: { key: ['foo'], put: undefined, update: undefined } + }) + + const lstB = list(strElement, { + // TOIMPROVE: Add type constraints here + defaults: { key: undefined, put: ['bar'], update: undefined } + }) + + const assertListB: A.Contains< + typeof lstB, + { [$defaults]: { key: undefined; put: unknown; update: undefined } } + > = 1 + assertListB + + expect(lstB).toMatchObject({ + [$defaults]: { key: undefined, put: ['bar'], update: undefined } + }) + + const lstC = list(strElement, { + // TOIMPROVE: Add type constraints here + defaults: { key: undefined, put: undefined, update: ['baz'] } + }) + + const assertListC: A.Contains< + typeof lstC, + { [$defaults]: { key: undefined; put: undefined; update: unknown } } + > = 1 + assertListC + + expect(lstC).toMatchObject({ + [$defaults]: { key: undefined, put: undefined, update: ['baz'] } + }) + }) + + it('returns defaulted list (method)', () => { + const lstA = list(strElement).keyDefault(['foo']) + + const assertListA: A.Contains< + typeof lstA, + { [$defaults]: { key: unknown; put: undefined; update: undefined } } + > = 1 + assertListA + + expect(lstA).toMatchObject({ + [$defaults]: { key: ['foo'], put: undefined, update: undefined } + }) + + const lstB = list(strElement).putDefault(['bar']) + + const assertListB: A.Contains< + typeof lstB, + { [$defaults]: { key: undefined; put: unknown; update: undefined } } + > = 1 + assertListB + + expect(lstB).toMatchObject({ + [$defaults]: { key: undefined, put: ['bar'], update: undefined } + }) + + const lstC = list(strElement).updateDefault(['baz']) + + const assertListC: A.Contains< + typeof lstC, + { [$defaults]: { key: undefined; put: undefined; update: unknown } } + > = 1 + assertListC + + expect(lstC).toMatchObject({ + [$defaults]: { key: undefined, put: undefined, update: ['baz'] } + }) + }) + + it('returns list with PUT default value if it is not key (default shorthand)', () => { + const listAttr = list(strElement).default(['foo']) + + const assertList: A.Contains< + typeof listAttr, + { [$defaults]: { key: undefined; put: unknown; update: undefined } } + > = 1 + assertList + + expect(listAttr).toMatchObject({ + [$defaults]: { key: undefined, put: ['foo'], update: undefined } + }) + }) + + it('returns list with KEY default value if it is key (default shorthand)', () => { + const listAttr = list(strElement).key().default(['bar']) + + const assertList: A.Contains< + typeof listAttr, + { [$defaults]: { key: unknown; put: undefined; update: undefined } } + > = 1 + assertList + + expect(listAttr).toMatchObject({ + [$defaults]: { key: ['bar'], put: undefined, update: undefined } + }) + }) + + it('list of lists', () => { + const lst = list(list(strElement)) + + const assertList: A.Contains< + typeof lst, + { + [$type]: 'list' + [$elements]: { + [$type]: 'list' + [$elements]: typeof strElement + [$required]: AtLeastOnce + [$hidden]: false + [$key]: false + [$savedAs]: undefined + [$defaults]: { + key: undefined + put: undefined + update: undefined + } + } + [$required]: AtLeastOnce + [$hidden]: false + [$key]: false + [$savedAs]: undefined + [$defaults]: { + key: undefined + put: undefined + update: undefined + } + } + > = 1 + assertList + + expect(lst).toMatchObject({ + [$type]: 'list', + [$elements]: { + [$type]: 'list', + [$elements]: strElement, + [$required]: 'atLeastOnce', + [$hidden]: false, + [$key]: false, + [$savedAs]: undefined, + [$defaults]: { + key: undefined, + put: undefined, + update: undefined + } + }, + [$required]: 'atLeastOnce', + [$hidden]: false, + [$key]: false, + [$savedAs]: undefined, + [$defaults]: { + key: undefined, + put: undefined, + update: undefined + } + }) + }) +}) diff --git a/src/v1/schema/attributes/list/types.ts b/src/v1/schema/attributes/list/types.ts new file mode 100644 index 000000000..9ddd2b8ff --- /dev/null +++ b/src/v1/schema/attributes/list/types.ts @@ -0,0 +1,25 @@ +import type { AtLeastOnce } from '../constants' +import type { $required, $hidden, $savedAs, $defaults } from '../constants/attributeOptions' +import type { $AttributeNestedState, Attribute } from '../types' + +export type $ListAttributeElements = $AttributeNestedState & { + [$required]: AtLeastOnce + [$hidden]: false + [$savedAs]: undefined + [$defaults]: { + key: undefined + put: undefined + update: undefined + } +} + +export type ListAttributeElements = Attribute & { + required: AtLeastOnce + hidden: false + savedAs: undefined + defaults: { + key: undefined + put: undefined + update: undefined + } +} diff --git a/src/v1/schema/attributes/map/errors.ts b/src/v1/schema/attributes/map/errors.ts new file mode 100644 index 000000000..88bb87ae1 --- /dev/null +++ b/src/v1/schema/attributes/map/errors.ts @@ -0,0 +1,9 @@ +import type { ErrorBlueprint } from 'v1/errors/blueprint' + +type DuplicateSavedAsErrorBlueprint = ErrorBlueprint<{ + code: 'schema.mapAttribute.duplicateSavedAs' + hasPath: true + payload: { savedAs: string } +}> + +export type MapAttributeErrorBlueprints = DuplicateSavedAsErrorBlueprint diff --git a/src/v1/schema/attributes/map/freeze.ts b/src/v1/schema/attributes/map/freeze.ts new file mode 100644 index 000000000..2ce36b639 --- /dev/null +++ b/src/v1/schema/attributes/map/freeze.ts @@ -0,0 +1,115 @@ +import type { O } from 'ts-toolbelt' + +import { DynamoDBToolboxError } from 'v1/errors' + +import type { RequiredOption } from '../constants/requiredOptions' +import type { FreezeAttribute } from '../freeze' +import { validateAttributeProperties } from '../shared/validate' +import { + $attributes, + $required, + $hidden, + $key, + $savedAs, + $defaults +} from '../constants/attributeOptions' + +import type { SharedAttributeState } from '../shared/interface' +import type { $MapAttributeState, MapAttribute } from './interface' +import type { $MapAttributeAttributeStates } from './types' + +export type FreezeMapAttribute<$MAP_ATTRIBUTE extends $MapAttributeState> = + // Applying void O.Update improves type display + O.Update< + MapAttribute< + { + [KEY in keyof $MAP_ATTRIBUTE[$attributes]]: FreezeAttribute< + $MAP_ATTRIBUTE[$attributes][KEY] + > + }, + { + required: $MAP_ATTRIBUTE[$required] + hidden: $MAP_ATTRIBUTE[$hidden] + key: $MAP_ATTRIBUTE[$key] + savedAs: $MAP_ATTRIBUTE[$savedAs] + defaults: $MAP_ATTRIBUTE[$defaults] + } + >, + never, + never + > + +type MapAttributeFreezer = < + $ATTRIBUTES extends $MapAttributeAttributeStates, + STATE extends SharedAttributeState +>( + attribute: $ATTRIBUTES, + state: STATE, + path: string +) => FreezeMapAttribute<$MapAttributeState<$ATTRIBUTES, STATE>> + +/** + * Freezes a warm `map` attribute + * + * @param attributes Attribute elements + * @param state Attribute options + * @param path Path of the instance in the related schema (string) + * @return void + */ +export const freezeMapAttribute: MapAttributeFreezer = < + $ATTRIBUTES extends $MapAttributeAttributeStates, + STATE extends SharedAttributeState +>( + attributes: $ATTRIBUTES, + state: STATE, + path: string +): FreezeMapAttribute<$MapAttributeState<$ATTRIBUTES, STATE>> => { + validateAttributeProperties(state, path) + + const attributesSavedAs = new Set() + + const keyAttributeNames = new Set() + + const requiredAttributeNames: Record> = { + always: new Set(), + atLeastOnce: new Set(), + never: new Set() + } + + const frozenAttributes: { + [KEY in keyof $ATTRIBUTES]: FreezeAttribute<$ATTRIBUTES[KEY]> + } = {} as any + + for (const attributeName in attributes) { + const attribute = attributes[attributeName] + + const attributeSavedAs = attribute[$savedAs] ?? attributeName + if (attributesSavedAs.has(attributeSavedAs)) { + throw new DynamoDBToolboxError('schema.mapAttribute.duplicateSavedAs', { + message: `Invalid map attributes at path ${path}: More than two attributes are saved as '${attributeSavedAs}'`, + path, + payload: { savedAs: attributeSavedAs } + }) + } + attributesSavedAs.add(attributeSavedAs) + + if (attribute[$key]) { + keyAttributeNames.add(attributeName) + } + + requiredAttributeNames[attribute[$required]].add(attributeName) + + frozenAttributes[attributeName] = attribute.freeze( + [path, attributeName].join('.') + ) as FreezeAttribute<$ATTRIBUTES[Extract]> + } + + return { + path, + type: 'map', + attributes: frozenAttributes, + keyAttributeNames, + requiredAttributeNames, + ...state + } +} diff --git a/src/v1/schema/attributes/map/freeze.unit.test.ts b/src/v1/schema/attributes/map/freeze.unit.test.ts new file mode 100644 index 000000000..5a5ff7433 --- /dev/null +++ b/src/v1/schema/attributes/map/freeze.unit.test.ts @@ -0,0 +1,70 @@ +import { DynamoDBToolboxError } from 'v1/errors' + +import { string } from '../primitive' +import { validateAttributeProperties } from '../shared/validate' + +import { map } from './typer' +import { $attributes } from '../constants' + +jest.mock('../shared/validate', () => ({ + ...jest.requireActual>('../shared/validate'), + validateAttributeProperties: jest.fn() +})) + +const validateAttributePropertiesMock = validateAttributeProperties as jest.MockedFunction< + typeof validateAttributeProperties +> + +describe('map properties freeze', () => { + const pathMock = 'some.path' + + const stringAttr = string() + const string1Name = 'string1' + const string2Name = 'string2' + const mapInstance = map({ [string1Name]: stringAttr, [string2Name]: stringAttr }) + + beforeEach(() => { + validateAttributePropertiesMock.mockClear() + }) + + it('applies validateAttributeProperties on mapInstance', () => { + mapInstance.freeze(pathMock) + + // Once + 2 attributes + expect(validateAttributePropertiesMock).toHaveBeenCalledTimes(3) + }) + + it('applies freezeAttribute on attributes', () => { + mapInstance[$attributes][string1Name].freeze = jest.fn( + mapInstance[$attributes][string1Name].freeze + ) + mapInstance[$attributes][string2Name].freeze = jest.fn( + mapInstance[$attributes][string2Name].freeze + ) + mapInstance.freeze(pathMock) + + expect(mapInstance[$attributes][string1Name].freeze).toHaveBeenCalledWith( + [pathMock, string1Name].join('.') + ) + expect(mapInstance[$attributes][string2Name].freeze).toHaveBeenCalledWith( + [pathMock, string2Name].join('.') + ) + }) + + it('throws if map attribute has duplicate savedAs', () => { + const invalidCallA = () => map({ a: stringAttr, b: stringAttr.savedAs('a') }).freeze(pathMock) + + expect(invalidCallA).toThrow(DynamoDBToolboxError) + expect(invalidCallA).toThrow( + expect.objectContaining({ code: 'schema.mapAttribute.duplicateSavedAs', path: pathMock }) + ) + + const invalidCallB = () => + map({ a: stringAttr.savedAs('c'), b: stringAttr.savedAs('c') }).freeze(pathMock) + + expect(invalidCallB).toThrow(DynamoDBToolboxError) + expect(invalidCallB).toThrow( + expect.objectContaining({ code: 'schema.mapAttribute.duplicateSavedAs', path: pathMock }) + ) + }) +}) diff --git a/src/v1/schema/attributes/map/index.ts b/src/v1/schema/attributes/map/index.ts new file mode 100644 index 000000000..0349b9c54 --- /dev/null +++ b/src/v1/schema/attributes/map/index.ts @@ -0,0 +1,8 @@ +export { map } from './typer' +export type { + $MapAttributeState, + $MapAttributeNestedState, + $MapAttribute, + MapAttribute +} from './interface' +export type { FreezeMapAttribute } from './freeze' diff --git a/src/v1/schema/attributes/map/interface.ts b/src/v1/schema/attributes/map/interface.ts new file mode 100644 index 000000000..628f5d0d4 --- /dev/null +++ b/src/v1/schema/attributes/map/interface.ts @@ -0,0 +1,287 @@ +import type { O } from 'ts-toolbelt' + +import type { If, ValueOrGetter } from 'v1/types' +import type { + AttributeKeyInput, + AttributePutItemInput, + AttributeUpdateItemInput, + KeyInput, + PutItemInput, + UpdateItemInput +} from 'v1/operations' + +import type { Schema } from '../../interface' +import type { RequiredOption, AtLeastOnce, Never, Always } from '../constants' +import type { $type, $attributes } from '../constants/attributeOptions' +import type { SharedAttributeState, $SharedAttributeState } from '../shared/interface' + +import type { FreezeMapAttribute } from './freeze' +import type { $MapAttributeAttributeStates, MapAttributeAttributes } from './types' + +export interface $MapAttributeState< + $ATTRIBUTES extends $MapAttributeAttributeStates = $MapAttributeAttributeStates, + STATE extends SharedAttributeState = SharedAttributeState +> extends $SharedAttributeState { + [$type]: 'map' + [$attributes]: $ATTRIBUTES +} + +export interface $MapAttributeNestedState< + $ATTRIBUTES extends $MapAttributeAttributeStates = $MapAttributeAttributeStates, + STATE extends SharedAttributeState = SharedAttributeState +> extends $MapAttributeState<$ATTRIBUTES, STATE> { + freeze: (path: string) => FreezeMapAttribute<$MapAttributeState<$ATTRIBUTES, STATE>> +} + +/** + * MapAttribute attribute interface + */ +export interface $MapAttribute< + $ATTRIBUTES extends $MapAttributeAttributeStates = $MapAttributeAttributeStates, + STATE extends SharedAttributeState = SharedAttributeState +> extends $MapAttributeNestedState<$ATTRIBUTES, STATE> { + /** + * Tag attribute as required. Possible values are: + * - `"atLeastOnce"` _(default)_: Required in PUTs, optional in UPDATEs + * - `"never"`: Optional in PUTs and UPDATEs + * - `"always"`: Required in PUTs and UPDATEs + * + * @param nextRequired RequiredOption + */ + required: ( + nextRequired?: NEXT_REQUIRED + ) => $MapAttribute<$ATTRIBUTES, O.Overwrite> + /** + * Shorthand for `required('never')` + */ + optional: () => $MapAttribute<$ATTRIBUTES, O.Overwrite> + /** + * Hide attribute after fetch commands and formatting + */ + hidden: () => $MapAttribute<$ATTRIBUTES, O.Overwrite> + /** + * Tag attribute as needed for Primary Key computing + */ + key: () => $MapAttribute<$ATTRIBUTES, O.Overwrite> + /** + * Rename attribute before save commands + */ + savedAs: ( + nextSavedAs: NEXT_SAVED_AS + ) => $MapAttribute<$ATTRIBUTES, O.Overwrite> + /** + * Provide a default value for attribute in Primary Key computing + * + * @param nextKeyDefault `keyAttributeInput | (() => keyAttributeInput)` + */ + keyDefault: ( + nextKeyDefault: ValueOrGetter< + AttributeKeyInput>, true> + > + ) => $MapAttribute< + $ATTRIBUTES, + O.Overwrite< + STATE, + { + defaults: { + key: unknown + put: STATE['defaults']['put'] + update: STATE['defaults']['update'] + } + } + > + > + /** + * Provide a default value for attribute in PUT commands + * + * @param nextPutDefault `putAttributeInput | (() => putAttributeInput)` + */ + putDefault: ( + nextPutDefault: ValueOrGetter< + AttributePutItemInput>, true> + > + ) => $MapAttribute< + $ATTRIBUTES, + O.Overwrite< + STATE, + { + defaults: { + key: STATE['defaults']['key'] + put: unknown + update: STATE['defaults']['update'] + } + } + > + > + /** + * Provide a default value for attribute in UPDATE commands + * + * @param nextUpdateDefault `updateAttributeInput | (() => updateAttributeInput)` + */ + updateDefault: ( + nextUpdateDefault: ValueOrGetter< + AttributeUpdateItemInput>, true> + > + ) => $MapAttribute< + $ATTRIBUTES, + O.Overwrite< + STATE, + { + defaults: { + key: STATE['defaults']['key'] + put: STATE['defaults']['put'] + update: unknown + } + } + > + > + /** + * Provide a default value for attribute in PUT commands OR Primary Key computing if attribute is tagged as key + * + * @param nextDefault `key/putAttributeInput | (() => key/putAttributeInput)` + */ + default: ( + nextDefault: ValueOrGetter< + If< + STATE['key'], + AttributeKeyInput>, true>, + AttributePutItemInput>, true> + > + > + ) => $MapAttribute< + $ATTRIBUTES, + O.Overwrite< + STATE, + { + defaults: If< + STATE['key'], + { + key: unknown + put: STATE['defaults']['put'] + update: STATE['defaults']['update'] + }, + { + key: STATE['defaults']['key'] + put: unknown + update: STATE['defaults']['update'] + } + > + } + > + > + /** + * Provide a **linked** default value for attribute in Primary Key computing + * + * @param nextKeyDefault `keyAttributeInput | ((keyInput) => keyAttributeInput)` + */ + keyLink: ( + nextKeyDefault: ValueOrGetter< + AttributeKeyInput>, true>, + [KeyInput] + > + ) => $MapAttribute< + $ATTRIBUTES, + O.Overwrite< + STATE, + { + defaults: { + key: unknown + put: STATE['defaults']['put'] + update: STATE['defaults']['update'] + } + } + > + > + /** + * Provide a **linked** default value for attribute in PUT commands + * + * @param nextPutDefault `putAttributeInput | ((putItemInput) => putAttributeInput)` + */ + putLink: ( + nextPutDefault: ValueOrGetter< + AttributePutItemInput>, true>, + [PutItemInput] + > + ) => $MapAttribute< + $ATTRIBUTES, + O.Overwrite< + STATE, + { + defaults: { + key: STATE['defaults']['key'] + put: unknown + update: STATE['defaults']['update'] + } + } + > + > + /** + * Provide a **linked** default value for attribute in UPDATE commands + * + * @param nextUpdateDefault `unknown | ((updateItemInput) => updateAttributeInput)` + */ + updateLink: ( + nextUpdateDefault: ValueOrGetter< + AttributeUpdateItemInput>, true>, + [UpdateItemInput] + > + ) => $MapAttribute< + $ATTRIBUTES, + O.Overwrite< + STATE, + { + defaults: { + key: STATE['defaults']['key'] + put: STATE['defaults']['put'] + update: unknown + } + } + > + > + /** + * Provide a **linked** default value for attribute in PUT commands OR Primary Key computing if attribute is tagged as key + * + * @param nextDefault `key/putAttributeInput | (() => key/putAttributeInput)` + */ + link: ( + nextDefault: ValueOrGetter< + If< + STATE['key'], + AttributeKeyInput>, true>, + AttributePutItemInput>, true> + >, + [If, PutItemInput>] + > + ) => $MapAttribute< + $ATTRIBUTES, + O.Overwrite< + STATE, + { + defaults: If< + STATE['key'], + { + key: unknown + put: STATE['defaults']['put'] + update: STATE['defaults']['update'] + }, + { + key: STATE['defaults']['key'] + put: unknown + update: STATE['defaults']['update'] + } + > + } + > + > +} + +export interface MapAttribute< + ATTRIBUTES extends MapAttributeAttributes = MapAttributeAttributes, + STATE extends SharedAttributeState = SharedAttributeState +> extends SharedAttributeState { + path: string + type: 'map' + attributes: ATTRIBUTES + keyAttributeNames: Set + requiredAttributeNames: Record> +} diff --git a/src/v1/schema/attributes/map/options.ts b/src/v1/schema/attributes/map/options.ts new file mode 100644 index 000000000..7bb4fbaad --- /dev/null +++ b/src/v1/schema/attributes/map/options.ts @@ -0,0 +1,60 @@ +import type { RequiredOption, AtLeastOnce } from '../constants' + +// Note: May look like a duplicate of AnyAttributeState but actually adds JSDocs + +/** + * Input options of MapAttribute Attribute + */ +export interface MapAttributeOptions { + /** + * Tag attribute as required. Possible values are: + * - `"atLeastOnce"` _(default)_: Required in PUTs, optional in UPDATEs + * - `"never"`: Optional in PUTs and UPDATEs + * - `"always"`: Required in PUTs and UPDATEs + */ + required: RequiredOption + /** + * Hide attribute after fetch commands and formatting + */ + hidden: boolean + /** + * Tag attribute as needed for Primary Key computing + */ + key: boolean + /** + * Rename attribute before save commands + */ + savedAs: string | undefined + /** + * Provide default values for attribute + */ + defaults: { + key: undefined | unknown + put: undefined | unknown + update: undefined | unknown + } +} + +export type MapAttributeDefaultOptions = { + required: AtLeastOnce + hidden: false + key: false + savedAs: undefined + defaults: { + key: undefined + put: undefined + update: undefined + } +} + +export const MAP_DEFAULT_OPTIONS: MapAttributeDefaultOptions = { + required: 'atLeastOnce', + hidden: false, + key: false, + savedAs: undefined, + defaults: { + key: undefined, + put: undefined, + update: undefined + } +} diff --git a/src/v1/schema/attributes/map/typer.ts b/src/v1/schema/attributes/map/typer.ts new file mode 100644 index 000000000..7de6ef9c8 --- /dev/null +++ b/src/v1/schema/attributes/map/typer.ts @@ -0,0 +1,153 @@ +import type { NarrowObject } from 'v1/types/narrowObject' +import { overwrite } from 'v1/utils/overwrite' + +import type { RequiredOption, AtLeastOnce } from '../constants' +import { + $type, + $attributes, + $required, + $hidden, + $key, + $savedAs, + $defaults +} from '../constants/attributeOptions' +import type { InferStateFromOptions } from '../shared/inferStateFromOptions' +import type { SharedAttributeState } from '../shared/interface' + +import type { $MapAttribute } from './interface' +import type { $MapAttributeAttributeStates } from './types' +import { MapAttributeOptions, MapAttributeDefaultOptions, MAP_DEFAULT_OPTIONS } from './options' +import { freezeMapAttribute } from './freeze' + +type $MapAttributeTyper = < + $ATTRIBUTES extends $MapAttributeAttributeStates, + STATE extends SharedAttributeState = SharedAttributeState +>( + attributes: NarrowObject<$ATTRIBUTES>, + state: STATE +) => $MapAttribute<$ATTRIBUTES, STATE> + +const $map: $MapAttributeTyper = < + $ATTRIBUTES extends $MapAttributeAttributeStates, + STATE extends SharedAttributeState = SharedAttributeState +>( + attributes: NarrowObject<$ATTRIBUTES>, + state: STATE +) => { + const $mapAttribute: $MapAttribute<$ATTRIBUTES, STATE> = { + [$type]: 'map', + [$attributes]: attributes, + [$required]: state.required, + [$hidden]: state.hidden, + [$key]: state.key, + [$savedAs]: state.savedAs, + [$defaults]: state.defaults, + required: ( + nextRequired: NEXT_REQUIRED = ('atLeastOnce' as unknown) as NEXT_REQUIRED + ) => $map(attributes, overwrite(state, { required: nextRequired })), + optional: () => $map(attributes, overwrite(state, { required: 'never' as const })), + hidden: () => $map(attributes, overwrite(state, { hidden: true as const })), + key: () => + $map(attributes, overwrite(state, { required: 'always' as const, key: true as const })), + savedAs: nextSavedAs => $map(attributes, overwrite(state, { savedAs: nextSavedAs })), + keyDefault: nextKeyDefault => + $map( + attributes, + overwrite(state, { + defaults: { key: nextKeyDefault, put: state.defaults.put, update: state.defaults.update } + }) + ), + putDefault: nextPutDefault => + $map( + attributes, + overwrite(state, { + defaults: { key: state.defaults.key, put: nextPutDefault, update: state.defaults.update } + }) + ), + updateDefault: nextUpdateDefault => + $map( + attributes, + overwrite(state, { + defaults: { key: state.defaults.key, put: state.defaults.put, update: nextUpdateDefault } + }) + ), + default: nextDefault => + $map( + attributes, + overwrite(state, { + defaults: state.key + ? { key: nextDefault, put: state.defaults.put, update: state.defaults.update } + : { key: state.defaults.key, put: nextDefault, update: state.defaults.update } + }) + ), + keyLink: nextKeyDefault => + $map( + attributes, + overwrite(state, { + defaults: { key: nextKeyDefault, put: state.defaults.put, update: state.defaults.update } + }) + ), + putLink: nextPutDefault => + $map( + attributes, + overwrite(state, { + defaults: { key: state.defaults.key, put: nextPutDefault, update: state.defaults.update } + }) + ), + updateLink: nextUpdateDefault => + $map( + attributes, + overwrite(state, { + defaults: { key: state.defaults.key, put: state.defaults.put, update: nextUpdateDefault } + }) + ), + link: nextDefault => + $map( + attributes, + overwrite(state, { + defaults: state.key + ? { key: nextDefault, put: state.defaults.put, update: state.defaults.update } + : { key: state.defaults.key, put: nextDefault, update: state.defaults.update } + }) + ), + freeze: path => freezeMapAttribute(attributes, state, path) + } + + return $mapAttribute +} + +type MapAttributeTyper = < + ATTRIBUTES extends $MapAttributeAttributeStates, + OPTIONS extends Partial = MapAttributeDefaultOptions +>( + attributes: NarrowObject, + options?: NarrowObject +) => $MapAttribute< + ATTRIBUTES, + InferStateFromOptions +> + +/** + * Define a new map attribute + * + * @param attributes Dictionary of attributes + * @param options _(optional)_ Map Options + */ +export const map: MapAttributeTyper = < + ATTRIBUTES extends $MapAttributeAttributeStates, + OPTIONS extends Partial = MapAttributeDefaultOptions +>( + attributes: NarrowObject, + options?: OPTIONS +): $MapAttribute< + ATTRIBUTES, + InferStateFromOptions +> => { + const state = { + ...MAP_DEFAULT_OPTIONS, + ...options, + defaults: { ...MAP_DEFAULT_OPTIONS.defaults, ...options?.defaults } + } as InferStateFromOptions + + return $map(attributes, state) +} diff --git a/src/v1/schema/attributes/map/typer.unit.test.ts b/src/v1/schema/attributes/map/typer.unit.test.ts new file mode 100644 index 000000000..2fedb0a2d --- /dev/null +++ b/src/v1/schema/attributes/map/typer.unit.test.ts @@ -0,0 +1,383 @@ +import type { A } from 'ts-toolbelt' + +import { Never, AtLeastOnce, Always } from '../constants' +import { string } from '../primitive' +import { + $type, + $attributes, + $required, + $hidden, + $key, + $savedAs, + $defaults +} from '../constants/attributeOptions' + +import { map } from './typer' +import type { MapAttribute, $MapAttributeState } from './interface' + +describe('map', () => { + const str = string() + + it('returns default map', () => { + const mapped = map({ str }) + + const assertMapAttribute: A.Contains< + typeof mapped, + { + [$type]: 'map' + [$attributes]: { + str: typeof str + } + [$required]: AtLeastOnce + [$hidden]: false + [$key]: false + [$savedAs]: undefined + [$defaults]: { + key: undefined + put: undefined + update: undefined + } + } + > = 1 + assertMapAttribute + + const assertExtends: A.Extends = 1 + assertExtends + + const frozenMap = mapped.freeze('some.path') + const assertFrozenExtends: A.Extends = 1 + assertFrozenExtends + + expect(mapped).toMatchObject({ + [$type]: 'map', + [$attributes]: { str }, + [$required]: 'atLeastOnce', + [$key]: false, + [$savedAs]: undefined, + [$hidden]: false, + [$defaults]: { + key: undefined, + put: undefined, + update: undefined + } + }) + }) + + it('returns required map (option)', () => { + const mappedAtLeastOnce = map({ str }, { required: 'atLeastOnce' }) + const mappedAlways = map({ str }, { required: 'always' }) + const mappedNever = map({ str }, { required: 'never' }) + + const assertMapAttributeAtLeastOnce: A.Contains< + typeof mappedAtLeastOnce, + { [$required]: AtLeastOnce } + > = 1 + assertMapAttributeAtLeastOnce + const assertMapAttributeAlways: A.Contains = 1 + assertMapAttributeAlways + const assertMapAttributeNever: A.Contains = 1 + assertMapAttributeNever + + expect(mappedAtLeastOnce).toMatchObject({ [$required]: 'atLeastOnce' }) + expect(mappedAlways).toMatchObject({ [$required]: 'always' }) + expect(mappedNever).toMatchObject({ [$required]: 'never' }) + }) + + it('returns required map (method)', () => { + const mappedAtLeastOnce = map({ str }).required() + const mappedAlways = map({ str }).required('always') + const mappedNever = map({ str }).required('never') + const mappedOpt = map({ str }).optional() + + const assertMapAttributeAtLeastOnce: A.Contains< + typeof mappedAtLeastOnce, + { [$required]: AtLeastOnce } + > = 1 + assertMapAttributeAtLeastOnce + const assertMapAttributeAlways: A.Contains = 1 + assertMapAttributeAlways + const assertMapAttributeNever: A.Contains = 1 + assertMapAttributeNever + const assertMapAttributeOpt: A.Contains = 1 + assertMapAttributeOpt + + expect(mappedAtLeastOnce).toMatchObject({ [$required]: 'atLeastOnce' }) + expect(mappedAlways).toMatchObject({ [$required]: 'always' }) + expect(mappedNever).toMatchObject({ [$required]: 'never' }) + expect(mappedOpt).toMatchObject({ [$required]: 'never' }) + }) + + it('returns hidden map (option)', () => { + const mapped = map({ str }, { hidden: true }) + + const assertMapAttribute: A.Contains = 1 + assertMapAttribute + + expect(mapped).toMatchObject({ [$hidden]: true }) + }) + + it('returns hidden map (method)', () => { + const mapped = map({ str }).hidden() + + const assertMapAttribute: A.Contains = 1 + assertMapAttribute + + expect(mapped).toMatchObject({ [$hidden]: true }) + }) + + it('returns key map (option)', () => { + const mapped = map({ str }, { key: true }) + + const assertMapAttribute: A.Contains< + typeof mapped, + { [$key]: true; [$required]: AtLeastOnce } + > = 1 + assertMapAttribute + + expect(mapped).toMatchObject({ [$key]: true, [$required]: 'atLeastOnce' }) + }) + + it('returns key map (method)', () => { + const mapped = map({ str }).key() + + const assertMapAttribute: A.Contains = 1 + assertMapAttribute + + expect(mapped).toMatchObject({ [$key]: true, [$required]: 'always' }) + }) + + it('returns savedAs map (option)', () => { + const mapped = map({ str }, { savedAs: 'foo' }) + + const assertMapAttribute: A.Contains = 1 + assertMapAttribute + + expect(mapped).toMatchObject({ [$savedAs]: 'foo' }) + }) + + it('returns savedAs map (method)', () => { + const mapped = map({ str }).savedAs('foo') + + const assertMapAttribute: A.Contains = 1 + assertMapAttribute + + expect(mapped).toMatchObject({ [$savedAs]: 'foo' }) + }) + + it('returns defaulted map (option)', () => { + const mapA = map( + { str }, + // TOIMPROVE: Try to add type constraints here + { defaults: { key: { str: 'foo' }, put: undefined, update: undefined } } + ) + + const assertMapAttribute: A.Contains< + typeof mapA, + { [$defaults]: { key: unknown; put: undefined; update: undefined } } + > = 1 + assertMapAttribute + + expect(mapA).toMatchObject({ + [$defaults]: { key: { str: 'foo' }, put: undefined, update: undefined } + }) + + const mapB = map( + { str }, + // TOIMPROVE: Try to add type constraints here + { defaults: { key: undefined, put: { str: 'bar' }, update: undefined } } + ) + + const assertMapB: A.Contains< + typeof mapB, + { [$defaults]: { key: undefined; put: unknown; update: undefined } } + > = 1 + assertMapB + + expect(mapB).toMatchObject({ + [$defaults]: { key: undefined, put: { str: 'bar' }, update: undefined } + }) + + const mapC = map( + { str }, + { defaults: { key: undefined, put: undefined, update: { str: 'baz' } } } + ) + + const assertMapC: A.Contains< + typeof mapC, + { [$defaults]: { key: undefined; put: undefined; update: unknown } } + > = 1 + assertMapC + + expect(mapC).toMatchObject({ + [$defaults]: { key: undefined, put: undefined, update: { str: 'baz' } } + }) + }) + + it('returns defaulted map (method)', () => { + const mapA = map({ str }).key().keyDefault({ str: 'foo' }) + + const assertMapAttribute: A.Contains< + typeof mapA, + { [$defaults]: { key: unknown; put: undefined; update: undefined } } + > = 1 + assertMapAttribute + + expect(mapA).toMatchObject({ + [$defaults]: { key: { str: 'bar' }, put: undefined, update: undefined } + }) + + const mapB = map({ str }).putDefault({ str: 'bar' }) + + const assertMapB: A.Contains< + typeof mapB, + { [$defaults]: { key: undefined; put: unknown; update: undefined } } + > = 1 + assertMapB + + expect(mapB).toMatchObject({ + [$defaults]: { key: undefined, put: { str: 'bar' }, update: undefined } + }) + + const mapC = map({ str }).updateDefault({ str: 'baz' }) + + const assertMapC: A.Contains< + typeof mapC, + { [$defaults]: { key: undefined; put: undefined; update: unknown } } + > = 1 + assertMapC + + expect(mapC).toMatchObject({ + [$defaults]: { key: undefined, put: undefined, update: { str: 'baz' } } + }) + }) + + it('returns map with PUT default value if it is not key (default shorthand)', () => { + const mapAttr = map({ str }).default({ str: 'foo' }) + + const assertMap: A.Contains< + typeof mapAttr, + { [$defaults]: { key: undefined; put: unknown; update: undefined } } + > = 1 + assertMap + + expect(mapAttr).toMatchObject({ + [$defaults]: { key: undefined, put: { str: 'foo' }, update: undefined } + }) + }) + + it('returns map with KEY default value if it is key (default shorthand)', () => { + const mapAttr = map({ str }).key().default({ str: 'bar' }) + + const assertMap: A.Contains< + typeof mapAttr, + { [$defaults]: { key: unknown; put: undefined; update: undefined } } + > = 1 + assertMap + + expect(mapAttr).toMatchObject({ + [$defaults]: { key: { str: 'bar' }, put: undefined, update: undefined } + }) + }) + + it('nested map', () => { + const mapped = map({ + nested: map({ + nestedAgain: map({ + str + }).hidden() + }) + }) + + const assertMapAttribute: A.Contains< + typeof mapped, + { + [$type]: 'map' + [$attributes]: { + nested: { + [$type]: 'map' + [$attributes]: { + nestedAgain: { + [$type]: 'map' + [$attributes]: { + str: typeof str + } + [$required]: AtLeastOnce + [$hidden]: true + [$key]: false + [$savedAs]: undefined + [$defaults]: { + key: undefined + put: undefined + update: undefined + } + } + } + [$required]: AtLeastOnce + [$hidden]: false + [$key]: false + [$savedAs]: undefined + [$defaults]: { + key: undefined + put: undefined + update: undefined + } + } + } + [$required]: AtLeastOnce + [$hidden]: false + [$key]: false + [$savedAs]: undefined + [$defaults]: { + key: undefined + put: undefined + update: undefined + } + } + > = 1 + assertMapAttribute + + expect(mapped).toMatchObject({ + [$type]: 'map', + [$attributes]: { + nested: { + [$type]: 'map', + [$attributes]: { + nestedAgain: { + [$type]: 'map', + [$attributes]: { + str + }, + [$required]: 'atLeastOnce', + [$hidden]: true, + [$key]: false, + [$savedAs]: undefined, + [$defaults]: { + key: undefined, + put: undefined, + update: undefined + } + } + }, + [$required]: 'atLeastOnce', + [$hidden]: false, + [$key]: false, + [$savedAs]: undefined, + [$defaults]: { + key: undefined, + put: undefined, + update: undefined + } + } + }, + [$required]: 'atLeastOnce', + [$hidden]: false, + [$key]: false, + [$savedAs]: undefined, + [$defaults]: { + key: undefined, + put: undefined, + update: undefined + } + }) + }) +}) diff --git a/src/v1/schema/attributes/map/types.ts b/src/v1/schema/attributes/map/types.ts new file mode 100644 index 000000000..1f6516c2d --- /dev/null +++ b/src/v1/schema/attributes/map/types.ts @@ -0,0 +1,9 @@ +import type { $AttributeNestedState, Attribute } from '../types' + +export interface $MapAttributeAttributeStates { + [key: string]: $AttributeNestedState +} + +export interface MapAttributeAttributes { + [key: string]: Attribute +} diff --git a/src/v1/schema/attributes/primitive/errors.ts b/src/v1/schema/attributes/primitive/errors.ts new file mode 100644 index 000000000..22f2eecd9 --- /dev/null +++ b/src/v1/schema/attributes/primitive/errors.ts @@ -0,0 +1,39 @@ +import type { ErrorBlueprint } from 'v1/errors/blueprint' + +import type { + PrimitiveAttributeType, + ResolvePrimitiveAttributeType, + PrimitiveAttributeEnumValues +} from './types' + +type InvalidEnumValueTypeErrorBlueprint = ErrorBlueprint<{ + code: 'schema.primitiveAttribute.invalidEnumValueType' + hasPath: true + payload: { + expectedType: PrimitiveAttributeType + enumValue: ResolvePrimitiveAttributeType + } +}> + +type InvalidDefaultValueTypeErrorBlueprint = ErrorBlueprint<{ + code: 'schema.primitiveAttribute.invalidDefaultValueType' + hasPath: true + payload: { + expectedType: PrimitiveAttributeType + defaultValue: unknown + } +}> + +type InvalidDefaultValueRangeErrorBlueprint = ErrorBlueprint<{ + code: 'schema.primitiveAttribute.invalidDefaultValueRange' + hasPath: true + payload: { + enumValues: NonNullable> + defaultValue: unknown + } +}> + +export type PrimitiveAttributeErrorBlueprints = + | InvalidEnumValueTypeErrorBlueprint + | InvalidDefaultValueTypeErrorBlueprint + | InvalidDefaultValueRangeErrorBlueprint diff --git a/src/v1/schema/attributes/primitive/freeze.ts b/src/v1/schema/attributes/primitive/freeze.ts new file mode 100644 index 000000000..ecb6b308b --- /dev/null +++ b/src/v1/schema/attributes/primitive/freeze.ts @@ -0,0 +1,121 @@ +import type { O } from 'ts-toolbelt' + +import { DynamoDBToolboxError } from 'v1/errors' +import { isStaticDefault } from 'v1/schema/utils/isStaticDefault' +import { validatorsByPrimitiveType } from 'v1/utils/validation' + +import { validateAttributeProperties } from '../shared/validate' +import { + $type, + $required, + $hidden, + $key, + $savedAs, + $enum, + $defaults, + $transform +} from '../constants/attributeOptions' + +import type { $PrimitiveAttributeState, PrimitiveAttribute } from './interface' +import type { + PrimitiveAttributeEnumValues, + PrimitiveAttributeState, + PrimitiveAttributeType +} from './types' + +export type FreezePrimitiveAttribute<$PRIMITIVE_ATTRIBUTE extends $PrimitiveAttributeState> = + // Applying void O.Update improves type display + O.Update< + PrimitiveAttribute< + $PRIMITIVE_ATTRIBUTE[$type], + { + required: $PRIMITIVE_ATTRIBUTE[$required] + hidden: $PRIMITIVE_ATTRIBUTE[$hidden] + key: $PRIMITIVE_ATTRIBUTE[$key] + savedAs: $PRIMITIVE_ATTRIBUTE[$savedAs] + enum: Extract< + $PRIMITIVE_ATTRIBUTE[$enum], + PrimitiveAttributeEnumValues<$PRIMITIVE_ATTRIBUTE[$type]> + > + defaults: $PRIMITIVE_ATTRIBUTE[$defaults] + transform: $PRIMITIVE_ATTRIBUTE[$transform] + } + >, + never, + never + > + +type PrimitiveAttributeFreezer = < + TYPE extends PrimitiveAttributeType, + STATE extends PrimitiveAttributeState +>( + type: TYPE, + primitiveAttribute: STATE, + path: string +) => FreezePrimitiveAttribute<$PrimitiveAttributeState> + +/** + * Freezes a warm `boolean`, `number`, `string` or `binary` attribute + * + * @param type Attribute type + * @param state Attribute options + * @param path Path of the instance in the related schema (string) + * @return void + */ +export const freezePrimitiveAttribute: PrimitiveAttributeFreezer = < + TYPE extends PrimitiveAttributeType, + STATE extends PrimitiveAttributeState +>( + type: TYPE, + state: STATE, + path: string +): FreezePrimitiveAttribute<$PrimitiveAttributeState> => { + validateAttributeProperties(state, path) + + const typeValidator = validatorsByPrimitiveType[type] + + const { enum: enumValues, ...restState } = state + enumValues?.forEach(enumValue => { + const isEnumValueValid = typeValidator(enumValue) + if (!isEnumValueValid) { + throw new DynamoDBToolboxError('schema.primitiveAttribute.invalidEnumValueType', { + message: `Invalid enum value type at path ${path}. Expected: ${type}. Received: ${String( + enumValue + )}.`, + path, + payload: { expectedType: type, enumValue } + }) + } + }) + + for (const defaultValue of Object.values(state.defaults)) { + if (defaultValue !== undefined && isStaticDefault(defaultValue)) { + if (!typeValidator(defaultValue)) { + throw new DynamoDBToolboxError('schema.primitiveAttribute.invalidDefaultValueType', { + message: `Invalid default value type at path ${path}: Expected: ${type}. Received: ${String( + defaultValue + )}.`, + path, + payload: { expectedType: type, defaultValue } + }) + } + + if (enumValues !== undefined && !enumValues.some(enumValue => enumValue === defaultValue)) { + throw new DynamoDBToolboxError('schema.primitiveAttribute.invalidDefaultValueRange', { + message: `Invalid default value at path ${path}: Expected one of: ${enumValues.join( + ', ' + )}. Received: ${String(defaultValue)}.`, + path, + payload: { enumValues, defaultValue } + }) + } + } + } + + return { + path, + type, + enum: state.enum as Extract>, + ...restState + } +} diff --git a/src/v1/schema/attributes/primitive/index.ts b/src/v1/schema/attributes/primitive/index.ts new file mode 100644 index 000000000..cab89a22c --- /dev/null +++ b/src/v1/schema/attributes/primitive/index.ts @@ -0,0 +1,15 @@ +export { string, boolean, binary, number } from './typer' +export type { + PrimitiveAttributeType, + ResolvePrimitiveAttributeType, + ResolvedPrimitiveAttribute, + Transformer +} from './types' +export type { + $PrimitiveAttributeState, + $PrimitiveAttributeNestedState, + $PrimitiveAttribute, + PrimitiveAttribute +} from './interface' +export type { FreezePrimitiveAttribute } from './freeze' +export type { ResolvePrimitiveAttribute } from './resolve' diff --git a/src/v1/schema/attributes/primitive/interface.ts b/src/v1/schema/attributes/primitive/interface.ts new file mode 100644 index 000000000..2c4b44bdf --- /dev/null +++ b/src/v1/schema/attributes/primitive/interface.ts @@ -0,0 +1,364 @@ +import type { O } from 'ts-toolbelt' + +import type { If, ValueOrGetter } from 'v1/types' +import type { + AttributeKeyInput, + AttributePutItemInput, + AttributeUpdateItemInput, + KeyInput, + PutItemInput, + UpdateItemInput +} from 'v1/operations' + +import type { Schema } from '../../interface' +import type { RequiredOption, AtLeastOnce, Never, Always } from '../constants/requiredOptions' +import type { $type, $enum, $transform } from '../constants/attributeOptions' +import type { $SharedAttributeState, SharedAttributeState } from '../shared/interface' + +import type { + PrimitiveAttributeType, + ResolvePrimitiveAttributeType, + PrimitiveAttributeState, + Transformer +} from './types' +import type { FreezePrimitiveAttribute } from './freeze' + +export interface $PrimitiveAttributeState< + TYPE extends PrimitiveAttributeType = PrimitiveAttributeType, + STATE extends PrimitiveAttributeState = PrimitiveAttributeState +> extends $SharedAttributeState { + [$type]: TYPE + [$enum]: STATE['enum'] + [$transform]: STATE['transform'] +} + +export interface $PrimitiveAttributeNestedState< + TYPE extends PrimitiveAttributeType = PrimitiveAttributeType, + STATE extends PrimitiveAttributeState = PrimitiveAttributeState +> extends $PrimitiveAttributeState { + freeze: (path: string) => FreezePrimitiveAttribute<$PrimitiveAttributeState> +} + +/** + * Primitive attribute interface + */ +export interface $PrimitiveAttribute< + TYPE extends PrimitiveAttributeType = PrimitiveAttributeType, + STATE extends PrimitiveAttributeState = PrimitiveAttributeState +> extends $PrimitiveAttributeNestedState { + /** + * Tag attribute as required. Possible values are: + * - `"atLeastOnce"` _(default)_: Required in PUTs, optional in UPDATEs + * - `"never"`: Optional in PUTs and UPDATEs + * - `"always"`: Required in PUTs and UPDATEs + * + * @param nextRequired RequiredOption + */ + required: ( + nextRequired?: NEXT_IS_REQUIRED + ) => $PrimitiveAttribute> + /** + * Shorthand for `required('never')` + */ + optional: () => $PrimitiveAttribute> + /** + * Hide attribute after fetch commands and formatting + */ + hidden: () => $PrimitiveAttribute> + /** + * Tag attribute as needed for Primary Key computing + */ + key: () => $PrimitiveAttribute> + /** + * Rename attribute before save commands + */ + savedAs: ( + nextSavedAs: NEXT_SAVED_AS + ) => $PrimitiveAttribute> + /** + * Provide a finite list of possible values for attribute + * (For typing reasons, enums are only available as attribute methods, not as input options) + * + * @param enum Possible values + * @example + * string().enum('foo', 'bar') + */ + enum: []>( + ...nextEnum: NEXT_ENUM + ) => /** + * @debt type "O.Overwrite widens NEXT_ENUM type to its type constraint for some reason" + */ + $PrimitiveAttribute> + /** + * Shorthand for `enum(constantValue).default(constantValue)` + * + * @param constantValue Constant value + * @example + * string().const('foo') + */ + const: >( + constant: CONSTANT + ) => $PrimitiveAttribute< + TYPE, + O.Overwrite< + STATE, + { + enum: [CONSTANT] + defaults: If< + STATE['key'], + { + key: unknown + put: STATE['defaults']['put'] + update: STATE['defaults']['update'] + }, + { + key: STATE['defaults']['key'] + put: unknown + update: STATE['defaults']['update'] + } + > + } + > + > + /** + * Provide a default value for attribute in Primary Key computing + * + * @param nextKeyDefault `keyAttributeInput | (() => keyAttributeInput)` + */ + keyDefault: ( + nextKeyDefault: ValueOrGetter< + AttributeKeyInput>, true> + > + ) => $PrimitiveAttribute< + TYPE, + O.Overwrite< + STATE, + { + defaults: { + key: unknown + put: STATE['defaults']['put'] + update: STATE['defaults']['update'] + } + } + > + > + /** + * Provide a default value for attribute in PUT commands + * + * @param nextPutDefault `putAttributeInput | (() => putAttributeInput)` + */ + putDefault: ( + nextPutDefault: ValueOrGetter< + AttributePutItemInput>, true> + > + ) => $PrimitiveAttribute< + TYPE, + O.Overwrite< + STATE, + { + defaults: { + key: STATE['defaults']['key'] + put: unknown + update: STATE['defaults']['update'] + } + } + > + > + /** + * Provide a default value for attribute in UPDATE commands + * + * @param nextUpdateDefault `updateAttributeInput | (() => updateAttributeInput)` + */ + updateDefault: ( + nextUpdateDefault: ValueOrGetter< + AttributeUpdateItemInput< + FreezePrimitiveAttribute<$PrimitiveAttributeState>, + true + > + > + ) => $PrimitiveAttribute< + TYPE, + O.Overwrite< + STATE, + { + defaults: { + key: STATE['defaults']['key'] + put: STATE['defaults']['put'] + update: unknown + } + } + > + > + /** + * Provide a default value for attribute in PUT commands OR Primary Key computing if attribute is tagged as key + * + * @param nextDefault `key/putAttributeInput | (() => key/putAttributeInput)` + */ + default: ( + nextDefault: ValueOrGetter< + If< + STATE['key'], + AttributeKeyInput>, true>, + AttributePutItemInput>, true> + > + > + ) => $PrimitiveAttribute< + TYPE, + O.Overwrite< + STATE, + { + defaults: If< + STATE['key'], + { + key: unknown + put: STATE['defaults']['put'] + update: STATE['defaults']['update'] + }, + { + key: STATE['defaults']['key'] + put: unknown + update: STATE['defaults']['update'] + } + > + } + > + > + /** + * Transform the attribute value in PUT commands OR Primary Key computing if attribute is tagged as key + * + * @param nextDefault `key/putAttributeInput | (() => key/putAttributeInput)` + */ + transform: ( + transformer: Transformer< + Exclude< + If< + STATE['key'], + AttributeKeyInput>, true>, + AttributePutItemInput< + FreezePrimitiveAttribute<$PrimitiveAttributeState>, + true + > + >, + undefined + >, + ResolvePrimitiveAttributeType + > + ) => $PrimitiveAttribute> + /** + * Provide a **linked** default value for attribute in Primary Key computing + * + * @param nextKeyDefault `keyAttributeInput | ((keyInput) => keyAttributeInput)` + */ + keyLink: ( + nextKeyDefault: ValueOrGetter< + AttributeKeyInput>, true>, + [KeyInput] + > + ) => $PrimitiveAttribute< + TYPE, + O.Overwrite< + STATE, + { + defaults: { + key: unknown + put: STATE['defaults']['put'] + update: STATE['defaults']['update'] + } + } + > + > + /** + * Provide a **linked** default value for attribute in PUT commands + * + * @param nextPutDefault `putAttributeInput | ((putItemInput) => putAttributeInput)` + */ + putLink: ( + nextPutDefault: ValueOrGetter< + AttributePutItemInput>, true>, + [PutItemInput] + > + ) => $PrimitiveAttribute< + TYPE, + O.Overwrite< + STATE, + { + defaults: { + key: STATE['defaults']['key'] + put: unknown + update: STATE['defaults']['update'] + } + } + > + > + /** + * Provide a **linked** default value for attribute in UPDATE commands + * + * @param nextUpdateDefault `unknown | ((updateItemInput) => updateAttributeInput)` + */ + updateLink: ( + nextUpdateDefault: ValueOrGetter< + AttributeUpdateItemInput< + FreezePrimitiveAttribute<$PrimitiveAttributeState>, + true + >, + [UpdateItemInput] + > + ) => $PrimitiveAttribute< + TYPE, + O.Overwrite< + STATE, + { + defaults: { + key: STATE['defaults']['key'] + put: STATE['defaults']['put'] + update: unknown + } + } + > + > + /** + * Provide a **linked** default value for attribute in PUT commands OR Primary Key computing if attribute is tagged as key + * + * @param nextDefault `key/putAttributeInput | (() => key/putAttributeInput)` + */ + link: ( + nextDefault: ValueOrGetter< + If< + STATE['key'], + AttributeKeyInput>, true>, + AttributePutItemInput>, true> + >, + [If, PutItemInput>] + > + ) => $PrimitiveAttribute< + TYPE, + O.Overwrite< + STATE, + { + defaults: If< + STATE['key'], + { + key: unknown + put: STATE['defaults']['put'] + update: STATE['defaults']['update'] + }, + { + key: STATE['defaults']['key'] + put: unknown + update: STATE['defaults']['update'] + } + > + } + > + > +} + +export interface PrimitiveAttribute< + TYPE extends PrimitiveAttributeType = PrimitiveAttributeType, + STATE extends PrimitiveAttributeState = PrimitiveAttributeState +> extends SharedAttributeState { + path: string + type: TYPE + enum: STATE['enum'] + transform: STATE['transform'] +} diff --git a/src/v1/schema/attributes/primitive/options.ts b/src/v1/schema/attributes/primitive/options.ts new file mode 100644 index 000000000..acb30d21c --- /dev/null +++ b/src/v1/schema/attributes/primitive/options.ts @@ -0,0 +1,63 @@ +import type { RequiredOption, AtLeastOnce } from '../constants/requiredOptions' + +// Note: May look like a duplicate of AnyAttributeState but actually adds JSDocs + +/** + * Input options of Primitive Attribute + */ +export type PrimitiveAttributeOptions = { + /** + * Tag attribute as required. Possible values are: + * - `"atLeastOnce"` _(default)_: Required in PUTs, optional in UPDATEs + * - `"never"`: Optional in PUTs and UPDATEs + * - `"always"`: Required in PUTs and UPDATEs + */ + required: RequiredOption + /** + * Hide attribute after fetch commands and formatting + */ + hidden: boolean + /** + * Tag attribute as needed for Primary Key computing + */ + key: boolean + /** + * Rename attribute before save commands + */ + savedAs: string | undefined + /** + * Provide default values for attribute + */ + defaults: { + key: undefined | unknown + put: undefined | unknown + update: undefined | unknown + } + transform: undefined | unknown +} + +export type PrimitiveAttributeDefaultOptions = { + required: AtLeastOnce + hidden: false + key: false + savedAs: undefined + defaults: { + key: undefined + put: undefined + update: undefined + } + transform: undefined +} + +export const PRIMITIVE_DEFAULT_OPTIONS: PrimitiveAttributeDefaultOptions = { + required: 'atLeastOnce', + hidden: false, + key: false, + savedAs: undefined, + defaults: { + key: undefined, + put: undefined, + update: undefined + }, + transform: undefined +} diff --git a/src/v1/schema/attributes/primitive/resolve.ts b/src/v1/schema/attributes/primitive/resolve.ts new file mode 100644 index 000000000..0fd3e2c0b --- /dev/null +++ b/src/v1/schema/attributes/primitive/resolve.ts @@ -0,0 +1,8 @@ +import type { PrimitiveAttribute } from './interface' +import type { ResolvePrimitiveAttributeType } from './types' + +export type ResolvePrimitiveAttribute< + ATTRIBUTE extends PrimitiveAttribute +> = ATTRIBUTE['enum'] extends ResolvePrimitiveAttributeType[] + ? ATTRIBUTE['enum'][number] + : ResolvePrimitiveAttributeType diff --git a/src/v1/schema/attributes/primitive/typer.ts b/src/v1/schema/attributes/primitive/typer.ts new file mode 100644 index 000000000..e3cb698a1 --- /dev/null +++ b/src/v1/schema/attributes/primitive/typer.ts @@ -0,0 +1,234 @@ +import type { NarrowObject } from 'v1/types/narrowObject' +import { overwrite } from 'v1/utils/overwrite' +import { update } from 'v1/utils/update' + +import type { RequiredOption, AtLeastOnce } from '../constants/requiredOptions' +import type { InferStateFromOptions } from '../shared/inferStateFromOptions' +import { + $type, + $required, + $hidden, + $key, + $savedAs, + $enum, + $defaults, + $transform +} from '../constants/attributeOptions' + +import type { $PrimitiveAttribute } from './interface' +import type { PrimitiveAttributeState, PrimitiveAttributeType } from './types' +import { + PrimitiveAttributeOptions, + PrimitiveAttributeDefaultOptions, + PRIMITIVE_DEFAULT_OPTIONS +} from './options' +import { freezePrimitiveAttribute } from './freeze' + +type $PrimitiveAttributeTyper = < + $TYPE extends PrimitiveAttributeType, + STATE extends PrimitiveAttributeState<$TYPE> = PrimitiveAttributeState<$TYPE> +>( + type: $TYPE, + state: STATE +) => $PrimitiveAttribute<$TYPE, STATE> + +/** + * Define a new "primitive" attribute, i.e. string, number, binary or boolean + * + * @param options _(optional)_ Primitive Options + */ +const $primitive: $PrimitiveAttributeTyper = < + $TYPE extends PrimitiveAttributeType, + STATE extends PrimitiveAttributeState<$TYPE> = PrimitiveAttributeState<$TYPE> +>( + type: $TYPE, + state: STATE +) => { + const $primitiveAttribute: $PrimitiveAttribute<$TYPE, STATE> = { + [$type]: type, + [$required]: state.required, + [$hidden]: state.hidden, + [$key]: state.key, + [$savedAs]: state.savedAs, + [$enum]: state.enum, + [$defaults]: state.defaults, + [$transform]: state.transform, + required: ( + nextRequired: NEXT_IS_REQUIRED = 'atLeastOnce' as NEXT_IS_REQUIRED + ) => $primitive(type, overwrite(state, { required: nextRequired })), + optional: () => $primitive(type, overwrite(state, { required: 'never' })), + hidden: () => $primitive(type, overwrite(state, { hidden: true })), + key: () => $primitive(type, overwrite(state, { key: true, required: 'always' })), + savedAs: nextSavedAs => $primitive(type, overwrite(state, { savedAs: nextSavedAs })), + enum: (...nextEnum) => $primitive(type, update(state, 'enum', nextEnum)), + const: constant => + $primitive( + type, + overwrite(state, { + enum: [constant], + defaults: state.key + ? { key: constant, put: state.defaults.put, update: state.defaults.update } + : { key: state.defaults.key, put: constant, update: state.defaults.update } + }) + ), + transform: transformer => $primitive(type, overwrite(state, { transform: transformer })), + keyDefault: nextKeyDefault => + $primitive( + type, + overwrite(state, { + defaults: { + key: nextKeyDefault, + put: state.defaults.put, + update: state.defaults.update + } + }) + ), + putDefault: nextPutDefault => + $primitive( + type, + overwrite(state, { + defaults: { + key: state.defaults.key, + put: nextPutDefault, + update: state.defaults.update + } + }) + ), + updateDefault: nextUpdateDefault => + $primitive( + type, + overwrite(state, { + defaults: { + key: state.defaults.key, + put: state.defaults.put, + update: nextUpdateDefault + } + }) + ), + default: nextDefault => + $primitive( + type, + overwrite(state, { + defaults: state.key + ? { key: nextDefault, put: state.defaults.put, update: state.defaults.update } + : { key: state.defaults.key, put: nextDefault, update: state.defaults.update } + }) + ), + keyLink: nextKeyDefault => + $primitive( + type, + overwrite(state, { + defaults: { + key: nextKeyDefault, + put: state.defaults.put, + update: state.defaults.update + } + }) + ), + putLink: nextPutDefault => + $primitive( + type, + overwrite(state, { + defaults: { + key: state.defaults.key, + put: nextPutDefault, + update: state.defaults.update + } + }) + ), + updateLink: nextUpdateDefault => + $primitive( + type, + overwrite(state, { + defaults: { + key: state.defaults.key, + put: state.defaults.put, + update: nextUpdateDefault + } + }) + ), + link: nextDefault => + $primitive( + type, + overwrite(state, { + defaults: state.key + ? { key: nextDefault, put: state.defaults.put, update: state.defaults.update } + : { key: state.defaults.key, put: nextDefault, update: state.defaults.update } + }) + ), + freeze: path => freezePrimitiveAttribute(type, state, path) + } + + return $primitiveAttribute +} + +type PrimitiveAttributeTyper = < + OPTIONS extends Partial = PrimitiveAttributeOptions +>( + primitiveOptions?: NarrowObject +) => $PrimitiveAttribute< + TYPE, + InferStateFromOptions< + PrimitiveAttributeOptions, + PrimitiveAttributeDefaultOptions, + OPTIONS, + { enum: undefined } + > +> + +type PrimitiveAttributeTyperFactory = ( + type: TYPE +) => PrimitiveAttributeTyper + +const primitiveAttributeTyperFactory: PrimitiveAttributeTyperFactory = < + TYPE extends PrimitiveAttributeType +>( + type: TYPE +) => = PrimitiveAttributeOptions>( + primitiveOptions = {} as NarrowObject +) => { + const state = { + ...PRIMITIVE_DEFAULT_OPTIONS, + ...primitiveOptions, + defaults: { + ...PRIMITIVE_DEFAULT_OPTIONS.defaults, + ...primitiveOptions.defaults + }, + enum: undefined + } as InferStateFromOptions< + PrimitiveAttributeOptions, + PrimitiveAttributeDefaultOptions, + OPTIONS, + { enum: undefined } + > + + return $primitive(type, state) +} + +/** + * Define a new string attribute + * + * @param options _(optional)_ String Options + */ +export const string = primitiveAttributeTyperFactory('string') + +/** + * Define a new number attribute + * + * @param options _(optional)_ Number Options + */ +export const number = primitiveAttributeTyperFactory('number') + +/** + * Define a new binary attribute + * + * @param options _(optional)_ Binary Options + */ +export const binary = primitiveAttributeTyperFactory('binary') + +/** + * Define a new boolean attribute + * + * @param options _(optional)_ Boolean Options + */ +export const boolean = primitiveAttributeTyperFactory('boolean') diff --git a/src/v1/schema/attributes/primitive/typer.unit.test.ts b/src/v1/schema/attributes/primitive/typer.unit.test.ts new file mode 100644 index 000000000..18becca50 --- /dev/null +++ b/src/v1/schema/attributes/primitive/typer.unit.test.ts @@ -0,0 +1,452 @@ +import type { A } from 'ts-toolbelt' + +import { DynamoDBToolboxError } from 'v1/errors' +import { prefix } from 'v1/transformers' + +import { Never, AtLeastOnce, Always } from '../constants' +import { + $type, + $required, + $hidden, + $key, + $savedAs, + $enum, + $defaults, + $transform +} from '../constants/attributeOptions' + +import { string, number, boolean, binary } from './typer' +import type { PrimitiveAttribute, $PrimitiveAttribute } from './interface' + +describe('primitiveAttribute', () => { + const path = 'some.path' + + describe('string', () => { + it('returns default string', () => { + const str = string() + + const assertStr: A.Contains< + typeof str, + { + [$type]: 'string' + [$required]: AtLeastOnce + [$hidden]: false + [$savedAs]: undefined + [$key]: false + [$enum]: undefined + [$defaults]: { + key: undefined + put: undefined + update: undefined + } + [$transform]: undefined + } + > = 1 + assertStr + + const assertExtends: A.Extends = 1 + assertExtends + + const frozenStr = str.freeze(path) + const assertFrozenExtends: A.Extends = 1 + assertFrozenExtends + + expect(str).toMatchObject({ + [$type]: 'string', + [$required]: 'atLeastOnce', + [$hidden]: false, + [$savedAs]: undefined, + [$key]: false, + [$enum]: undefined, + [$defaults]: { + key: undefined, + put: undefined, + update: undefined + } + }) + }) + + it('returns required string (option)', () => { + const strAtLeastOnce = string({ required: 'atLeastOnce' }) + const strAlways = string({ required: 'always' }) + const strNever = string({ required: 'never' }) + + const assertAtLeastOnce: A.Contains = 1 + assertAtLeastOnce + const assertAlways: A.Contains = 1 + assertAlways + const assertNever: A.Contains = 1 + assertNever + + expect(strAtLeastOnce).toMatchObject({ [$required]: 'atLeastOnce' }) + expect(strAlways).toMatchObject({ [$required]: 'always' }) + expect(strNever).toMatchObject({ [$required]: 'never' }) + }) + + it('returns required string (method)', () => { + const strAtLeastOnce = string().required() + const strAlways = string().required('always') + const strNever = string().required('never') + const strOpt = string().optional() + + const assertAtLeastOnce: A.Contains = 1 + assertAtLeastOnce + const assertAlways: A.Contains = 1 + assertAlways + const assertNever: A.Contains = 1 + assertNever + const assertOpt: A.Contains = 1 + assertOpt + + expect(strAtLeastOnce).toMatchObject({ [$required]: 'atLeastOnce' }) + expect(strAlways).toMatchObject({ [$required]: 'always' }) + expect(strNever).toMatchObject({ [$required]: 'never' }) + expect(strOpt).toMatchObject({ [$required]: 'never' }) + }) + + it('returns hidden string (option)', () => { + const str = string({ hidden: true }) + + const assertStr: A.Contains = 1 + assertStr + + expect(str).toMatchObject({ [$hidden]: true }) + }) + + it('returns hidden string (method)', () => { + const str = string().hidden() + + const assertStr: A.Contains = 1 + assertStr + + expect(str).toMatchObject({ [$hidden]: true }) + }) + + it('returns key string (option)', () => { + const str = string({ key: true }) + + const assertStr: A.Contains = 1 + assertStr + + expect(str).toMatchObject({ [$key]: true, [$required]: 'atLeastOnce' }) + }) + + it('returns key string (method)', () => { + const str = string().key() + + const assertStr: A.Contains = 1 + assertStr + + expect(str).toMatchObject({ [$key]: true, [$required]: 'always' }) + }) + + it('returns savedAs string (option)', () => { + const str = string({ savedAs: 'foo' }) + + const assertStr: A.Contains = 1 + assertStr + + expect(str).toMatchObject({ [$savedAs]: 'foo' }) + }) + + it('returns savedAs string (method)', () => { + const str = string().savedAs('foo') + + const assertStr: A.Contains = 1 + assertStr + + expect(str).toMatchObject({ [$savedAs]: 'foo' }) + }) + + it('returns string with enum values (method)', () => { + const invalidStr = string().enum( + // @ts-expect-error + 42, + 'foo', + 'bar' + ) + + const invalidCall = () => invalidStr.freeze(path) + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow( + expect.objectContaining({ code: 'schema.primitiveAttribute.invalidEnumValueType', path }) + ) + + const str = string().enum('foo', 'bar') + + const assertStr: A.Contains = 1 + assertStr + + expect(str).toMatchObject({ [$enum]: ['foo', 'bar'] }) + }) + + it('returns defaulted string (option)', () => { + const invalidStr = string({ + // TOIMPROVE: add type constraints here + defaults: { put: 42, update: undefined, key: undefined } + }) + + const invalidCall = () => invalidStr.freeze(path) + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow( + expect.objectContaining({ code: 'schema.primitiveAttribute.invalidDefaultValueType', path }) + ) + + string({ + defaults: { + key: undefined, + put: undefined, + // TOIMPROVE: add type constraints here + update: () => 42 + } + }) + + const strA = string({ defaults: { key: 'hello', put: undefined, update: undefined } }) + const strB = string({ defaults: { key: undefined, put: 'world', update: undefined } }) + const sayHello = () => 'hello' + const strC = string({ defaults: { key: undefined, put: undefined, update: sayHello } }) + + const assertStrA: A.Contains< + typeof strA, + { [$defaults]: { key: string; put: undefined; update: undefined } } + > = 1 + assertStrA + + expect(strA).toMatchObject({ + [$defaults]: { key: 'hello', put: undefined, update: undefined } + }) + + const assertStrB: A.Contains< + typeof strB, + { [$defaults]: { key: undefined; put: string; update: undefined } } + > = 1 + assertStrB + + expect(strB).toMatchObject({ + [$defaults]: { key: undefined, put: 'world', update: undefined } + }) + + const assertStrC: A.Contains< + typeof strC, + { [$defaults]: { key: undefined; put: undefined; update: () => string } } + > = 1 + assertStrC + + expect(strC).toMatchObject({ + [$defaults]: { key: undefined, put: undefined, update: sayHello } + }) + }) + + it('returns defaulted string (method)', () => { + const invalidStr = string() + // @ts-expect-error + .putDefault(42) + + const invalidCall = () => invalidStr.freeze(path) + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow( + expect.objectContaining({ code: 'schema.primitiveAttribute.invalidDefaultValueType', path }) + ) + + string() + // @ts-expect-error Unable to throw here (it would require executing the fn) + .updateDefault(() => 42) + + const strA = string().keyDefault('hello') + const strB = string().putDefault('world') + const sayHello = () => 'hello' + const strC = string().updateDefault(sayHello) + + const assertStrA: A.Contains< + typeof strA, + { [$defaults]: { key: unknown; put: undefined; update: undefined } } + > = 1 + assertStrA + + expect(strA).toMatchObject({ + [$defaults]: { key: 'hello', put: undefined, update: undefined } + }) + + const assertStrB: A.Contains< + typeof strB, + { [$defaults]: { key: undefined; put: unknown; update: undefined } } + > = 1 + assertStrB + + expect(strB).toMatchObject({ + [$defaults]: { key: undefined, put: 'world', update: undefined } + }) + + const assertStrC: A.Contains< + typeof strC, + { [$defaults]: { key: undefined; put: undefined; update: unknown } } + > = 1 + assertStrC + + expect(strC).toMatchObject({ + [$defaults]: { key: undefined, put: undefined, update: sayHello } + }) + }) + + it('returns string with constant value (method)', () => { + const invalidStr = string().const( + // @ts-expect-error + 42 + ) + + const invalidCall = () => invalidStr.freeze(path) + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow( + expect.objectContaining({ code: 'schema.primitiveAttribute.invalidEnumValueType', path }) + ) + + const nonKeyStr = string().const('foo') + + const assertNonKeyStr: A.Contains< + typeof nonKeyStr, + { [$enum]: ['foo']; [$defaults]: { key: undefined; put: unknown; update: undefined } } + > = 1 + assertNonKeyStr + + expect(nonKeyStr).toMatchObject({ + [$enum]: ['foo'], + [$defaults]: { key: undefined, put: 'foo', update: undefined } + }) + + const keyStr = string().key().const('foo') + + const assertKeyStr: A.Contains< + typeof keyStr, + { [$enum]: ['foo']; [$defaults]: { key: unknown; put: undefined; update: undefined } } + > = 1 + assertKeyStr + + expect(keyStr).toMatchObject({ + [$enum]: ['foo'], + [$defaults]: { key: 'foo', put: undefined, update: undefined } + }) + }) + + it('returns string with PUT default value if it is not key (default shorthand)', () => { + const str = string().default('hello') + + const assertStr: A.Contains< + typeof str, + { [$defaults]: { key: undefined; put: unknown; update: undefined } } + > = 1 + assertStr + + expect(str).toMatchObject({ + [$defaults]: { key: undefined, put: 'hello', update: undefined } + }) + }) + + it('returns string with KEY default value if it is key (default shorthand)', () => { + const str = string().key().default('hello') + + const assertStr: A.Contains< + typeof str, + { [$defaults]: { key: unknown; put: undefined; update: undefined } } + > = 1 + assertStr + + expect(str).toMatchObject({ + [$defaults]: { key: 'hello', put: undefined, update: undefined } + }) + }) + + it('default with enum values', () => { + const invalidStr = string().enum('foo', 'bar').default( + // @ts-expect-error + 'baz' + ) + + const invalidCall = () => invalidStr.freeze(path) + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow( + expect.objectContaining({ + code: 'schema.primitiveAttribute.invalidDefaultValueRange', + path + }) + ) + + const strA = string().enum('foo', 'bar').default('foo') + const sayFoo = (): 'foo' => 'foo' + const strB = string().enum('foo', 'bar').default(sayFoo) + + const assertStrA: A.Contains< + typeof strA, + { [$defaults]: { put: unknown }; [$enum]: ['foo', 'bar'] } + > = 1 + assertStrA + + expect(strA).toMatchObject({ [$defaults]: { put: 'foo' }, [$enum]: ['foo', 'bar'] }) + + const assertStrB: A.Contains< + typeof strB, + { [$defaults]: { put: unknown }; [$enum]: ['foo', 'bar'] } + > = 1 + assertStrB + + expect(strB).toMatchObject({ [$defaults]: { put: sayFoo }, [$enum]: ['foo', 'bar'] }) + }) + + it('returns transformed string (option)', () => { + const transformer = prefix('test') + const str = string({ transform: transformer }) + + const assertStr: A.Contains = 1 + assertStr + + expect(str).toMatchObject({ [$transform]: transformer }) + }) + + it('returns transformed string (method)', () => { + const transformer = prefix('test') + const str = string().transform(transformer) + + const assertStr: A.Contains = 1 + assertStr + + expect(str).toMatchObject({ [$transform]: transformer }) + }) + }) + + describe('number', () => { + it('returns default number', () => { + const num = number() + + const assertNum: A.Contains = 1 + assertNum + + expect(num).toMatchObject({ [$type]: 'number' }) + }) + }) + + describe('boolean', () => { + it('returns default boolean', () => { + const bool = boolean() + + const assertBool: A.Contains = 1 + assertBool + + expect(bool).toMatchObject({ [$type]: 'boolean' }) + }) + }) + + describe('binary', () => { + it('returns default binary', () => { + const bin = binary() + + const assertBin: A.Contains = 1 + assertBin + + expect(bin).toMatchObject({ [$type]: 'binary' }) + }) + }) +}) diff --git a/src/v1/schema/attributes/primitive/types.ts b/src/v1/schema/attributes/primitive/types.ts new file mode 100644 index 000000000..06ce44a4b --- /dev/null +++ b/src/v1/schema/attributes/primitive/types.ts @@ -0,0 +1,51 @@ +import type { SharedAttributeState } from '../shared/interface' + +export interface PrimitiveAttributeState< + TYPE extends PrimitiveAttributeType = PrimitiveAttributeType +> extends SharedAttributeState { + enum: PrimitiveAttributeEnumValues + transform: undefined | unknown +} + +/** + * Possible Primitive Attribute type + */ +export type PrimitiveAttributeType = 'string' | 'boolean' | 'number' | 'binary' + +/** + * Returns the corresponding TS type of a Primitive Attribute type + * + * @param TYPE Primitive Type + */ +export type ResolvePrimitiveAttributeType< + TYPE extends PrimitiveAttributeType +> = TYPE extends 'string' + ? string + : TYPE extends 'number' + ? number + : TYPE extends 'boolean' + ? boolean + : TYPE extends 'binary' + ? Buffer + : never + +/** + * TS type of any Primitive Attribute + */ +export type ResolvedPrimitiveAttribute = ResolvePrimitiveAttributeType + +/** + * Primitive Enum values constraint + */ +export type PrimitiveAttributeEnumValues = + | ResolvePrimitiveAttributeType[] + | undefined + +export interface Transformer< + INPUT extends ResolvedPrimitiveAttribute = ResolvedPrimitiveAttribute, + OUTPUT extends ResolvedPrimitiveAttribute = ResolvedPrimitiveAttribute, + PARSED_OUTPUT extends ResolvedPrimitiveAttribute = OUTPUT +> { + parse: (inputValue: INPUT) => OUTPUT + format: (savedValue: OUTPUT) => PARSED_OUTPUT +} diff --git a/src/v1/schema/attributes/primitive/types.type-test.ts b/src/v1/schema/attributes/primitive/types.type-test.ts new file mode 100644 index 000000000..bf3403be4 --- /dev/null +++ b/src/v1/schema/attributes/primitive/types.type-test.ts @@ -0,0 +1,15 @@ +import type { A } from 'ts-toolbelt' + +import type { ResolvePrimitiveAttributeType } from './types' + +const assertResolveString: A.Equals, string> = 1 +assertResolveString + +const assertResolveNumber: A.Equals, number> = 1 +assertResolveNumber + +const assertResolveBoolean: A.Equals, boolean> = 1 +assertResolveBoolean + +const assertResolveBinary: A.Equals, Buffer> = 1 +assertResolveBinary diff --git a/src/v1/schema/attributes/record/errors.ts b/src/v1/schema/attributes/record/errors.ts new file mode 100644 index 000000000..6fa604e55 --- /dev/null +++ b/src/v1/schema/attributes/record/errors.ts @@ -0,0 +1,80 @@ +import type { ErrorBlueprint } from 'v1/errors/blueprint' + +type InvalidKeysErrorBlueprint = ErrorBlueprint<{ + code: 'schema.recordAttribute.invalidKeys' + hasPath: true + payload: undefined +}> + +type OptionalKeysErrorBlueprint = ErrorBlueprint<{ + code: 'schema.recordAttribute.optionalKeys' + hasPath: true + payload: undefined +}> + +type HiddenKeysErrorBlueprint = ErrorBlueprint<{ + code: 'schema.recordAttribute.hiddenKeys' + hasPath: true + payload: undefined +}> + +type KeyKeysErrorBlueprint = ErrorBlueprint<{ + code: 'schema.recordAttribute.keyKeys' + hasPath: true + payload: undefined +}> + +type SavedAsKeysErrorBlueprint = ErrorBlueprint<{ + code: 'schema.recordAttribute.savedAsKeys' + hasPath: true + payload: undefined +}> + +type DefaultedKeysErrorBlueprint = ErrorBlueprint<{ + code: 'schema.recordAttribute.defaultedKeys' + hasPath: true + payload: undefined +}> + +type OptionalElementsErrorBlueprint = ErrorBlueprint<{ + code: 'schema.recordAttribute.optionalElements' + hasPath: true + payload: undefined +}> + +type HiddenElementsErrorBlueprint = ErrorBlueprint<{ + code: 'schema.recordAttribute.hiddenElements' + hasPath: true + payload: undefined +}> + +type KeyElementsErrorBlueprint = ErrorBlueprint<{ + code: 'schema.recordAttribute.keyElements' + hasPath: true + payload: undefined +}> + +type SavedAsElementsErrorBlueprint = ErrorBlueprint<{ + code: 'schema.recordAttribute.savedAsElements' + hasPath: true + payload: undefined +}> + +type DefaultedElementsErrorBlueprint = ErrorBlueprint<{ + code: 'schema.recordAttribute.defaultedElements' + hasPath: true + payload: undefined +}> + +export type RecordAttributeErrorBlueprints = + | InvalidKeysErrorBlueprint + | OptionalKeysErrorBlueprint + | HiddenKeysErrorBlueprint + | KeyKeysErrorBlueprint + | SavedAsKeysErrorBlueprint + | DefaultedKeysErrorBlueprint + | OptionalElementsErrorBlueprint + | HiddenElementsErrorBlueprint + | KeyElementsErrorBlueprint + | SavedAsElementsErrorBlueprint + | DefaultedElementsErrorBlueprint diff --git a/src/v1/schema/attributes/record/freeze.ts b/src/v1/schema/attributes/record/freeze.ts new file mode 100644 index 000000000..839f7696b --- /dev/null +++ b/src/v1/schema/attributes/record/freeze.ts @@ -0,0 +1,162 @@ +import type { O } from 'ts-toolbelt' + +import { DynamoDBToolboxError } from 'v1/errors' + +import type { FreezeAttribute } from '../freeze' +import { validateAttributeProperties } from '../shared/validate' +import { hasDefinedDefault } from '../shared/hasDefinedDefault' +import { + $type, + $keys, + $elements, + $required, + $hidden, + $key, + $savedAs, + $defaults +} from '../constants/attributeOptions' + +import type { SharedAttributeState } from '../shared/interface' +import type { $RecordAttributeState, RecordAttribute } from './interface' +import type { $RecordAttributeElements, $RecordAttributeKeys } from './types' + +export type FreezeRecordAttribute<$RECORD_ATTRIBUTE extends $RecordAttributeState> = + // Applying void O.Update improves type display + O.Update< + RecordAttribute< + FreezeAttribute<$RECORD_ATTRIBUTE[$keys]>, + FreezeAttribute<$RECORD_ATTRIBUTE[$elements]>, + { + required: $RECORD_ATTRIBUTE[$required] + hidden: $RECORD_ATTRIBUTE[$hidden] + key: $RECORD_ATTRIBUTE[$key] + savedAs: $RECORD_ATTRIBUTE[$savedAs] + defaults: $RECORD_ATTRIBUTE[$defaults] + } + >, + never, + never + > + +type RecordAttributeFreezer = < + $KEYS extends $RecordAttributeKeys, + $ELEMENTS extends $RecordAttributeElements, + STATE extends SharedAttributeState +>( + keys: $KEYS, + elements: $ELEMENTS, + state: STATE, + path: string +) => FreezeRecordAttribute<$RecordAttributeState<$KEYS, $ELEMENTS, STATE>> + +/** + * Freezes a warm `record` attribute + * + * @param keys Attribute keys + * @param elements Attribute elements + * @param state Attribute options + * @param path Path of the instance in the related schema (string) + * @return void + */ +export const freezeRecordAttribute: RecordAttributeFreezer = < + $KEYS extends $RecordAttributeKeys, + $ELEMENTS extends $RecordAttributeElements, + STATE extends SharedAttributeState +>( + keys: $KEYS, + elements: $ELEMENTS, + state: STATE, + path: string +): FreezeRecordAttribute<$RecordAttributeState<$KEYS, $ELEMENTS, STATE>> => { + validateAttributeProperties(state, path) + + if (keys[$type] !== 'string') { + throw new DynamoDBToolboxError('schema.recordAttribute.invalidKeys', { + message: `Invalid record keys at path ${path}: Record keys must be a string`, + path + }) + } + + // Checking $key before $required as $key implies attribute is always $required + if (keys[$key] !== false) { + throw new DynamoDBToolboxError('schema.recordAttribute.keyKeys', { + message: `Invalid record keys at path ${path}: Record keys cannot be part of primary key`, + path + }) + } + + if (keys[$required] !== 'atLeastOnce') { + throw new DynamoDBToolboxError('schema.recordAttribute.optionalKeys', { + message: `Invalid record keys at path ${path}: Record keys must be required`, + path + }) + } + + if (keys[$hidden] !== false) { + throw new DynamoDBToolboxError('schema.recordAttribute.hiddenKeys', { + message: `Invalid record keys at path ${path}: Record keys cannot be hidden`, + path + }) + } + + if (keys[$savedAs] !== undefined) { + throw new DynamoDBToolboxError('schema.recordAttribute.savedAsKeys', { + message: `Invalid record keys at path ${path}: Record keys cannot be renamed (have savedAs option)`, + path + }) + } + + if (hasDefinedDefault(keys)) { + throw new DynamoDBToolboxError('schema.recordAttribute.defaultedKeys', { + message: `Invalid record keys at path ${path}: Record keys cannot have default values`, + path + }) + } + + // Checking $key before $required as $key implies attribute is always $required + if (elements[$key] !== false) { + throw new DynamoDBToolboxError('schema.recordAttribute.keyElements', { + message: `Invalid record elements at path ${path}: Record elements cannot be part of primary key`, + path + }) + } + + if (elements[$required] !== 'atLeastOnce') { + throw new DynamoDBToolboxError('schema.recordAttribute.optionalElements', { + message: `Invalid record elements at path ${path}: Record elements must be required`, + path + }) + } + + if (elements[$hidden] !== false) { + throw new DynamoDBToolboxError('schema.recordAttribute.hiddenElements', { + message: `Invalid record elements at path ${path}: Record elements cannot be hidden`, + path + }) + } + + if (elements[$savedAs] !== undefined) { + throw new DynamoDBToolboxError('schema.recordAttribute.savedAsElements', { + message: `Invalid record elements at path ${path}: Record elements cannot be renamed (have savedAs option)`, + path + }) + } + + if (hasDefinedDefault(elements)) { + throw new DynamoDBToolboxError('schema.recordAttribute.defaultedElements', { + message: `Invalid record elements at path ${path}: Records elements cannot have default values`, + path + }) + } + + const frozenKeys = keys.freeze(`${path} (KEY)`) as FreezeAttribute<$KEYS> + const frozenElements = elements.freeze(`${path}[string]`) as FreezeAttribute<$ELEMENTS> + + return { + path, + type: 'record', + keys: frozenKeys, + elements: frozenElements, + ...state + } +} diff --git a/src/v1/schema/attributes/record/index.ts b/src/v1/schema/attributes/record/index.ts new file mode 100644 index 000000000..e9d4ce91f --- /dev/null +++ b/src/v1/schema/attributes/record/index.ts @@ -0,0 +1,8 @@ +export { record } from './typer' +export type { + $RecordAttributeState, + $RecordAttributeNestedState, + $RecordAttribute, + RecordAttribute +} from './interface' +export type { FreezeRecordAttribute } from './freeze' diff --git a/src/v1/schema/attributes/record/interface.ts b/src/v1/schema/attributes/record/interface.ts new file mode 100644 index 000000000..0cf515f61 --- /dev/null +++ b/src/v1/schema/attributes/record/interface.ts @@ -0,0 +1,330 @@ +import type { O } from 'ts-toolbelt' + +import type { If, ValueOrGetter } from 'v1/types' +import type { + AttributeKeyInput, + AttributePutItemInput, + AttributeUpdateItemInput, + KeyInput, + PutItemInput, + UpdateItemInput +} from 'v1/operations' + +import type { Schema } from '../../interface' +import type { RequiredOption, AtLeastOnce, Never, Always } from '../constants' +import type { $type, $elements, $keys } from '../constants/attributeOptions' +import type { $SharedAttributeState, SharedAttributeState } from '../shared/interface' +import type { + $RecordAttributeKeys, + RecordAttributeKeys, + $RecordAttributeElements, + RecordAttributeElements +} from './types' +import type { FreezeRecordAttribute } from './freeze' + +export interface $RecordAttributeState< + $KEYS extends $RecordAttributeKeys = $RecordAttributeKeys, + $ELEMENTS extends $RecordAttributeElements = $RecordAttributeElements, + STATE extends SharedAttributeState = SharedAttributeState +> extends $SharedAttributeState { + [$type]: 'record' + [$keys]: $KEYS + [$elements]: $ELEMENTS +} + +export interface $RecordAttributeNestedState< + $KEYS extends $RecordAttributeKeys = $RecordAttributeKeys, + $ELEMENTS extends $RecordAttributeElements = $RecordAttributeElements, + STATE extends SharedAttributeState = SharedAttributeState +> extends $RecordAttributeState<$KEYS, $ELEMENTS, STATE> { + freeze: (path: string) => FreezeRecordAttribute<$RecordAttributeState<$KEYS, $ELEMENTS, STATE>> +} + +/** + * Record attribute interface + */ +export interface $RecordAttribute< + $KEYS extends $RecordAttributeKeys = $RecordAttributeKeys, + $ELEMENTS extends $RecordAttributeElements = $RecordAttributeElements, + STATE extends SharedAttributeState = SharedAttributeState +> extends $RecordAttributeNestedState<$KEYS, $ELEMENTS, STATE> { + /** + * Tag attribute as required. Possible values are: + * - `"atLeastOnce"` _(default)_: Required in PUTs, optional in UPDATEs + * - `"never"`: Optional in PUTs and UPDATEs + * - `"always"`: Required in PUTs and UPDATEs + * + * @param nextRequired RequiredOption + */ + required: ( + nextRequired?: NEXT_IS_REQUIRED + ) => $RecordAttribute<$KEYS, $ELEMENTS, O.Overwrite> + /** + * Shorthand for `required('never')` + */ + optional: () => $RecordAttribute<$KEYS, $ELEMENTS, O.Overwrite> + /** + * Hide attribute after fetch commands and formatting + */ + hidden: () => $RecordAttribute<$KEYS, $ELEMENTS, O.Overwrite> + /** + * Tag attribute as needed for Primary Key computing + */ + key: () => $RecordAttribute<$KEYS, $ELEMENTS, O.Overwrite> + /** + * Rename attribute before save commands + */ + savedAs: ( + nextSavedAs: NEXT_SAVED_AS + ) => $RecordAttribute<$KEYS, $ELEMENTS, O.Overwrite> + /** + * Provide a default value for attribute in Primary Key computing + * + * @param nextKeyDefault `keyAttributeInput | (() => keyAttributeInput)` + */ + keyDefault: ( + nextKeyDefault: ValueOrGetter< + AttributeKeyInput>, true> + > + ) => $RecordAttribute< + $KEYS, + $ELEMENTS, + O.Overwrite< + STATE, + { + defaults: { + key: unknown + put: STATE['defaults']['put'] + update: STATE['defaults']['update'] + } + } + > + > + /** + * Provide a default value for attribute in PUT commands + * + * @param nextPutDefault `putAttributeInput | (() => putAttributeInput)` + */ + putDefault: ( + nextPutDefault: ValueOrGetter< + AttributePutItemInput< + FreezeRecordAttribute<$RecordAttributeState<$KEYS, $ELEMENTS, STATE>>, + true + > + > + ) => $RecordAttribute< + $KEYS, + $ELEMENTS, + O.Overwrite< + STATE, + { + defaults: { + key: STATE['defaults']['key'] + put: unknown + update: STATE['defaults']['update'] + } + } + > + > + /** + * Provide a default value for attribute in UPDATE commands + * + * @param nextUpdateDefault `updateAttributeInput | (() => updateAttributeInput)` + */ + updateDefault: ( + nextUpdateDefault: ValueOrGetter< + AttributeUpdateItemInput< + FreezeRecordAttribute<$RecordAttributeState<$KEYS, $ELEMENTS, STATE>>, + true + > + > + ) => $RecordAttribute< + $KEYS, + $ELEMENTS, + O.Overwrite< + STATE, + { + defaults: { + key: STATE['defaults']['key'] + put: STATE['defaults']['put'] + update: unknown + } + } + > + > + /** + * Provide a default value for attribute in PUT commands OR Primary Key computing if attribute is tagged as key + * + * @param nextDefault `key/putAttributeInput | (() => key/putAttributeInput)` + */ + default: ( + nextDefault: ValueOrGetter< + If< + STATE['key'], + AttributeKeyInput< + FreezeRecordAttribute<$RecordAttributeState<$KEYS, $ELEMENTS, STATE>>, + true + >, + AttributePutItemInput< + FreezeRecordAttribute<$RecordAttributeState<$KEYS, $ELEMENTS, STATE>>, + true + > + > + > + ) => $RecordAttribute< + $KEYS, + $ELEMENTS, + O.Overwrite< + STATE, + { + defaults: If< + STATE['key'], + { + key: unknown + put: STATE['defaults']['put'] + update: STATE['defaults']['update'] + }, + { + key: STATE['defaults']['key'] + put: unknown + update: STATE['defaults']['update'] + } + > + } + > + > + /** + * Provide a **linked** default value for attribute in Primary Key computing + * + * @param nextKeyDefault `keyAttributeInput | ((keyInput) => keyAttributeInput)` + */ + keyLink: ( + nextKeyDefault: ValueOrGetter< + AttributeKeyInput< + FreezeRecordAttribute<$RecordAttributeState<$KEYS, $ELEMENTS, STATE>>, + true + >, + [KeyInput] + > + ) => $RecordAttribute< + $KEYS, + $ELEMENTS, + O.Overwrite< + STATE, + { + defaults: { + key: unknown + put: STATE['defaults']['put'] + update: STATE['defaults']['update'] + } + } + > + > + /** + * Provide a **linked** default value for attribute in PUT commands + * + * @param nextPutDefault `putAttributeInput | ((putItemInput) => putAttributeInput)` + */ + putLink: ( + nextPutDefault: ValueOrGetter< + AttributePutItemInput< + FreezeRecordAttribute<$RecordAttributeState<$KEYS, $ELEMENTS, STATE>>, + true + >, + [PutItemInput] + > + ) => $RecordAttribute< + $KEYS, + $ELEMENTS, + O.Overwrite< + STATE, + { + defaults: { + key: STATE['defaults']['key'] + put: unknown + update: STATE['defaults']['update'] + } + } + > + > + /** + * Provide a **linked** default value for attribute in UPDATE commands + * + * @param nextUpdateDefault `unknown | ((updateItemInput) => updateAttributeInput)` + */ + updateLink: ( + nextUpdateDefault: ValueOrGetter< + AttributeUpdateItemInput< + FreezeRecordAttribute<$RecordAttributeState<$KEYS, $ELEMENTS, STATE>>, + true + >, + [UpdateItemInput] + > + ) => $RecordAttribute< + $KEYS, + $ELEMENTS, + O.Overwrite< + STATE, + { + defaults: { + key: STATE['defaults']['key'] + put: STATE['defaults']['put'] + update: unknown + } + } + > + > + /** + * Provide a **linked** default value for attribute in PUT commands OR Primary Key computing if attribute is tagged as key + * + * @param nextDefault `key/putAttributeInput | (() => key/putAttributeInput)` + */ + link: ( + nextDefault: ValueOrGetter< + If< + STATE['key'], + AttributeKeyInput< + FreezeRecordAttribute<$RecordAttributeState<$KEYS, $ELEMENTS, STATE>>, + true + >, + AttributePutItemInput< + FreezeRecordAttribute<$RecordAttributeState<$KEYS, $ELEMENTS, STATE>>, + true + > + >, + [If, PutItemInput>] + > + ) => $RecordAttribute< + $KEYS, + $ELEMENTS, + O.Overwrite< + STATE, + { + defaults: If< + STATE['key'], + { + key: unknown + put: STATE['defaults']['put'] + update: STATE['defaults']['update'] + }, + { + key: STATE['defaults']['key'] + put: unknown + update: STATE['defaults']['update'] + } + > + } + > + > +} + +export interface RecordAttribute< + KEYS extends RecordAttributeKeys = RecordAttributeKeys, + ELEMENTS extends RecordAttributeElements = RecordAttributeElements, + STATE extends SharedAttributeState = SharedAttributeState +> extends SharedAttributeState { + path: string + type: 'record' + keys: KEYS + elements: ELEMENTS +} diff --git a/src/v1/schema/attributes/record/options.ts b/src/v1/schema/attributes/record/options.ts new file mode 100644 index 000000000..067794e5d --- /dev/null +++ b/src/v1/schema/attributes/record/options.ts @@ -0,0 +1,60 @@ +import type { RequiredOption, AtLeastOnce } from '../constants' + +// Note: May look like a duplicate of AnyAttributeState but actually adds JSDocs + +/** + * Input options of Record Attribute + */ +export interface RecordAttributeOptions { + /** + * Tag attribute as required. Possible values are: + * - `"atLeastOnce"` _(default)_: Required in PUTs, optional in UPDATEs + * - `"never"`: Optional in PUTs and UPDATEs + * - `"always"`: Required in PUTs and UPDATEs + */ + required: RequiredOption + /** + * Hide attribute after fetch commands and formatting + */ + hidden: boolean + /** + * Tag attribute as needed for Primary Key computing + */ + key: boolean + /** + * Rename attribute before save commands + */ + savedAs: string | undefined + /** + * Provide default values for attribute + */ + defaults: { + key: undefined | unknown + put: undefined | unknown + update: undefined | unknown + } +} + +export type RecordAttributeDefaultOptions = { + required: AtLeastOnce + hidden: false + key: false + savedAs: undefined + defaults: { + key: undefined + put: undefined + update: undefined + } +} + +export const RECORD_DEFAULT_OPTIONS: RecordAttributeDefaultOptions = { + required: 'atLeastOnce', + hidden: false, + key: false, + savedAs: undefined, + defaults: { + key: undefined, + put: undefined, + update: undefined + } +} diff --git a/src/v1/schema/attributes/record/typer.ts b/src/v1/schema/attributes/record/typer.ts new file mode 100644 index 000000000..9826e6759 --- /dev/null +++ b/src/v1/schema/attributes/record/typer.ts @@ -0,0 +1,210 @@ +import type { NarrowObject } from 'v1/types/narrowObject' +import { overwrite } from 'v1/utils/overwrite' + +import type { RequiredOption, AtLeastOnce } from '../constants' +import { + $type, + $keys, + $elements, + $required, + $hidden, + $key, + $savedAs, + $defaults +} from '../constants/attributeOptions' +import type { InferStateFromOptions } from '../shared/inferStateFromOptions' +import type { SharedAttributeState } from '../shared/interface' + +import type { $RecordAttributeKeys, $RecordAttributeElements } from './types' +import type { $RecordAttribute } from './interface' +import { + RecordAttributeOptions, + RecordAttributeDefaultOptions, + RECORD_DEFAULT_OPTIONS +} from './options' +import { freezeRecordAttribute } from './freeze' + +type $RecordAttributeTyper = < + $KEYS extends $RecordAttributeKeys, + $ELEMENTS extends $RecordAttributeElements, + STATE extends SharedAttributeState = SharedAttributeState +>( + keys: $KEYS, + elements: $ELEMENTS, + state: STATE +) => $RecordAttribute<$KEYS, $ELEMENTS, STATE> + +const $record: $RecordAttributeTyper = < + $KEYS extends $RecordAttributeKeys, + $ELEMENTS extends $RecordAttributeElements, + STATE extends SharedAttributeState = SharedAttributeState +>( + keys: $KEYS, + elements: $ELEMENTS, + state: STATE +) => { + const $recordAttribute: $RecordAttribute<$KEYS, $ELEMENTS, STATE> = { + [$type]: 'record', + [$keys]: keys, + [$elements]: elements, + [$required]: state.required, + [$hidden]: state.hidden, + [$key]: state.key, + [$savedAs]: state.savedAs, + [$defaults]: state.defaults, + required: ( + nextRequired: NEXT_IS_REQUIRED = 'atLeastOnce' as NEXT_IS_REQUIRED + ) => $record(keys, elements, overwrite(state, { required: nextRequired })), + optional: () => $record(keys, elements, overwrite(state, { required: 'never' })), + hidden: () => $record(keys, elements, overwrite(state, { hidden: true })), + key: () => $record(keys, elements, overwrite(state, { key: true, required: 'always' })), + savedAs: nextSavedAs => $record(keys, elements, overwrite(state, { savedAs: nextSavedAs })), + keyDefault: nextKeyDefault => + $record( + keys, + elements, + overwrite(state, { + defaults: { + key: nextKeyDefault, + put: state.defaults.put, + update: state.defaults.update + } + }) + ), + putDefault: nextPutDefault => + $record( + keys, + elements, + overwrite(state, { + defaults: { + key: state.defaults.key, + put: nextPutDefault, + update: state.defaults.update + } + }) + ), + updateDefault: nextUpdateDefault => + $record( + keys, + elements, + overwrite(state, { + defaults: { + key: state.defaults.key, + put: state.defaults.put, + update: nextUpdateDefault + } + }) + ), + default: nextDefault => + $record( + keys, + elements, + overwrite(state, { + defaults: state.key + ? { key: nextDefault, put: state.defaults.put, update: state.defaults.update } + : { key: state.defaults.key, put: nextDefault, update: state.defaults.update } + }) + ), + keyLink: nextKeyDefault => + $record( + keys, + elements, + overwrite(state, { + defaults: { + key: nextKeyDefault, + put: state.defaults.put, + update: state.defaults.update + } + }) + ), + putLink: nextPutDefault => + $record( + keys, + elements, + overwrite(state, { + defaults: { + key: state.defaults.key, + put: nextPutDefault, + update: state.defaults.update + } + }) + ), + updateLink: nextUpdateDefault => + $record( + keys, + elements, + overwrite(state, { + defaults: { + key: state.defaults.key, + put: state.defaults.put, + update: nextUpdateDefault + } + }) + ), + link: nextDefault => + $record( + keys, + elements, + overwrite(state, { + defaults: state.key + ? { key: nextDefault, put: state.defaults.put, update: state.defaults.update } + : { key: state.defaults.key, put: nextDefault, update: state.defaults.update } + }) + ), + freeze: path => freezeRecordAttribute(keys, elements, state, path) + } + + return $recordAttribute +} + +type RecordAttributeTyper = < + $KEYS extends $RecordAttributeKeys, + $ELEMENTS extends $RecordAttributeElements, + OPTIONS extends Partial = RecordAttributeOptions +>( + keys: $KEYS, + elements: $ELEMENTS, + options?: NarrowObject +) => $RecordAttribute< + $KEYS, + $ELEMENTS, + InferStateFromOptions +> + +/** + * Define a new record attribute + * Not that record keys and elements have constraints. They must be: + * - Required (required: AtLeastOnce) + * - Displayed (hidden: false) + * - Not key (key: false) + * - Not renamed (savedAs: undefined) + * - Not defaulted (defaults: undefined) + * + * @param keys Keys (With constraints) + * @param elements Attribute (With constraints) + * @param options _(optional)_ Record Options + */ +export const record: RecordAttributeTyper = < + $KEYS extends $RecordAttributeKeys, + $ELEMENTS extends $RecordAttributeElements, + OPTIONS extends Partial = RecordAttributeOptions +>( + keys: $KEYS, + elements: $ELEMENTS, + options?: NarrowObject +): $RecordAttribute< + $KEYS, + $ELEMENTS, + InferStateFromOptions +> => { + const state = { + ...RECORD_DEFAULT_OPTIONS, + ...options, + defaults: { + ...RECORD_DEFAULT_OPTIONS.defaults, + ...options?.defaults + } + } as InferStateFromOptions + + return $record(keys, elements, state) +} diff --git a/src/v1/schema/attributes/record/typer.unit.test.ts b/src/v1/schema/attributes/record/typer.unit.test.ts new file mode 100644 index 000000000..f722326a2 --- /dev/null +++ b/src/v1/schema/attributes/record/typer.unit.test.ts @@ -0,0 +1,475 @@ +import type { A } from 'ts-toolbelt' + +import { DynamoDBToolboxError } from 'v1/errors' + +import { Never, AtLeastOnce, Always } from '../constants' +import { string, number } from '../primitive' +import { + $type, + $keys, + $elements, + $required, + $hidden, + $key, + $savedAs, + $defaults +} from '../constants/attributeOptions' + +import { record } from './typer' +import type { RecordAttribute, $RecordAttributeState } from './interface' + +describe('record', () => { + const path = 'some.path' + const fooBar = string().enum('foo', 'bar') + const str = string() + + it('rejects non-string keys', () => { + const invalidRecord = record( + // @ts-expect-error + number(), + str + ) + + const invalidCall = () => invalidRecord.freeze(path) + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow( + expect.objectContaining({ code: 'schema.recordAttribute.invalidKeys', path }) + ) + }) + + it('rejects non-required keys', () => { + const invalidRecord = record( + // @ts-expect-error + str.optional(), + str + ) + + const invalidCall = () => invalidRecord.freeze(path) + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow( + expect.objectContaining({ code: 'schema.recordAttribute.optionalKeys', path }) + ) + }) + + it('rejects hidden keys', () => { + const invalidRecord = record( + // @ts-expect-error + str.hidden(), + str + ) + + const invalidCall = () => invalidRecord.freeze(path) + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow( + expect.objectContaining({ code: 'schema.recordAttribute.hiddenKeys', path }) + ) + }) + + it('rejects key keys', () => { + const invalidRecord = record( + // @ts-expect-error + str.key(), + str + ) + + const invalidCall = () => invalidRecord.freeze(path) + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow( + expect.objectContaining({ code: 'schema.recordAttribute.keyKeys', path }) + ) + }) + + it('rejects keys with savedAs values', () => { + const invalidRecord = record( + // @ts-expect-error + str.savedAs('foo'), + str + ) + + const invalidCall = () => invalidRecord.freeze(path) + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow( + expect.objectContaining({ code: 'schema.recordAttribute.savedAsKeys', path }) + ) + }) + + it('rejects keys with default values', () => { + const invalidRecord = record( + // @ts-expect-error + str.putDefault('foo'), + str + ) + + const invalidCall = () => invalidRecord.freeze(path) + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow( + expect.objectContaining({ code: 'schema.recordAttribute.defaultedKeys', path }) + ) + }) + + it('rejects non-required elements', () => { + const invalidRecord = record( + str, + // @ts-expect-error + str.optional() + ) + + const invalidCall = () => invalidRecord.freeze(path) + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow( + expect.objectContaining({ code: 'schema.recordAttribute.optionalElements', path }) + ) + }) + + it('rejects hidden elements', () => { + const invalidRecord = record( + str, + // @ts-expect-error + str.hidden() + ) + + const invalidCall = () => invalidRecord.freeze(path) + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow( + expect.objectContaining({ code: 'schema.recordAttribute.hiddenElements', path }) + ) + }) + + it('rejects key elements', () => { + const invalidRecord = record( + str, + // @ts-expect-error + str.key() + ) + + const invalidCall = () => invalidRecord.freeze(path) + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow( + expect.objectContaining({ code: 'schema.recordAttribute.keyElements', path }) + ) + }) + + it('rejects elements with savedAs values', () => { + const invalidRecord = record( + str, + // @ts-expect-error + str.savedAs('foo') + ) + + const invalidCall = () => invalidRecord.freeze(path) + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow( + expect.objectContaining({ code: 'schema.recordAttribute.savedAsElements', path }) + ) + }) + + it('rejects elements with default values', () => { + const invalidRecord = record( + str, + // @ts-expect-error + str.putDefault('foo') + ) + + const invalidCall = () => invalidRecord.freeze(path) + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow( + expect.objectContaining({ code: 'schema.recordAttribute.defaultedElements', path }) + ) + }) + + it('returns default record', () => { + const rec = record(fooBar, str) + + const assertRec: A.Contains< + typeof rec, + { + [$type]: 'record' + [$keys]: typeof fooBar + [$elements]: typeof str + [$required]: AtLeastOnce + [$hidden]: false + [$key]: false + [$savedAs]: undefined + [$defaults]: { + key: undefined + put: undefined + update: undefined + } + } + > = 1 + assertRec + + const assertExtends: A.Extends = 1 + assertExtends + + const frozenRecord = rec.freeze(path) + const assertFrozen: A.Extends = 1 + assertFrozen + + expect(rec).toMatchObject({ + [$type]: 'record', + [$keys]: str, + [$elements]: str, + [$required]: 'atLeastOnce', + [$key]: false, + [$savedAs]: undefined, + [$hidden]: false, + [$defaults]: { + key: undefined, + put: undefined, + update: undefined + } + }) + }) + + it('returns required record (option)', () => { + const recAtLeastOnce = record(fooBar, str, { required: 'atLeastOnce' }) + const recAlways = record(fooBar, str, { required: 'always' }) + const recNever = record(fooBar, str, { required: 'never' }) + + const assertAtLeastOnce: A.Contains = 1 + assertAtLeastOnce + const assertAlways: A.Contains = 1 + assertAlways + const assertNever: A.Contains = 1 + assertNever + + expect(recAtLeastOnce).toMatchObject({ [$required]: 'atLeastOnce' }) + expect(recAlways).toMatchObject({ [$required]: 'always' }) + expect(recNever).toMatchObject({ [$required]: 'never' }) + }) + + it('returns required record (method)', () => { + const recAtLeastOnce = record(fooBar, str).required() + const recAlways = record(fooBar, str).required('always') + const recNever = record(fooBar, str).required('never') + const recOpt = record(fooBar, str).optional() + + const assertAtLeastOnce: A.Contains = 1 + assertAtLeastOnce + const assertAlways: A.Contains = 1 + assertAlways + const assertNever: A.Contains = 1 + assertNever + const assertOpt: A.Contains = 1 + assertOpt + + expect(recAtLeastOnce).toMatchObject({ [$required]: 'atLeastOnce' }) + expect(recAlways).toMatchObject({ [$required]: 'always' }) + expect(recNever).toMatchObject({ [$required]: 'never' }) + }) + + it('returns hidden record (option)', () => { + const rec = record(fooBar, str, { hidden: true }) + + const assertRec: A.Contains = 1 + assertRec + + expect(rec).toMatchObject({ [$hidden]: true }) + }) + + it('returns hidden record (method)', () => { + const rec = record(fooBar, str).hidden() + + const assertRec: A.Contains = 1 + assertRec + + expect(rec).toMatchObject({ [$hidden]: true }) + }) + + it('returns key record (option)', () => { + const rec = record(fooBar, str, { key: true }) + + const assertRec: A.Contains = 1 + assertRec + + expect(rec).toMatchObject({ [$key]: true, [$required]: 'atLeastOnce' }) + }) + + it('returns key record (method)', () => { + const rec = record(fooBar, str).key() + + const assertRec: A.Contains = 1 + assertRec + + expect(rec).toMatchObject({ [$key]: true, [$required]: 'always' }) + }) + + it('returns savedAs record (option)', () => { + const rec = record(fooBar, str, { savedAs: 'foo' }) + + const assertRec: A.Contains = 1 + assertRec + + expect(rec).toMatchObject({ [$savedAs]: 'foo' }) + }) + + it('returns savedAs record (method)', () => { + const rec = record(fooBar, str).savedAs('foo') + + const assertRec: A.Contains = 1 + assertRec + + expect(rec).toMatchObject({ [$savedAs]: 'foo' }) + }) + + it('returns defaulted record (option)', () => { + const stA = record(fooBar, str, { + // TOIMPROVE: Reintroduce type constraints here + defaults: { key: { foo: 'foo' }, put: undefined, update: undefined } + }) + + const assertSetA: A.Contains< + typeof stA, + { [$defaults]: { key: unknown; put: undefined; update: undefined } } + > = 1 + assertSetA + + expect(stA).toMatchObject({ + [$defaults]: { key: { foo: 'foo' }, put: undefined, update: undefined } + }) + + const stB = record(fooBar, str, { + // TOIMPROVE: Reintroduce type constraints here + defaults: { key: undefined, put: { bar: 'bar' }, update: undefined } + }) + + const assertSetB: A.Contains< + typeof stB, + { [$defaults]: { key: undefined; put: unknown; update: undefined } } + > = 1 + assertSetB + + expect(stB).toMatchObject({ + [$defaults]: { key: undefined, put: { bar: 'bar' }, update: undefined } + }) + + const stC = record(fooBar, str, { + // TOIMPROVE: Reintroduce type constraints here + defaults: { key: undefined, put: undefined, update: { foo: 'bar' } } + }) + + const assertSetC: A.Contains< + typeof stC, + { [$defaults]: { key: undefined; put: undefined; update: unknown } } + > = 1 + assertSetC + + expect(stC).toMatchObject({ + [$defaults]: { key: undefined, put: undefined, update: { foo: 'bar' } } + }) + }) + + it('returns defaulted record (method)', () => { + const stA = record(fooBar, str).key().keyDefault({ foo: 'foo' }) + + const assertSetA: A.Contains< + typeof stA, + { [$defaults]: { key: unknown; put: undefined; update: undefined } } + > = 1 + assertSetA + + expect(stA).toMatchObject({ + [$defaults]: { key: { foo: 'foo' }, put: undefined, update: undefined } + }) + + const stB = record(fooBar, str).putDefault({ bar: 'bar' }) + + const assertSetB: A.Contains< + typeof stB, + { [$defaults]: { key: undefined; put: unknown; update: undefined } } + > = 1 + assertSetB + + expect(stB).toMatchObject({ + [$defaults]: { key: undefined, put: { bar: 'bar' }, update: undefined } + }) + + const stC = record(fooBar, str).updateDefault({ foo: 'bar' }) + + const assertSetC: A.Contains< + typeof stC, + { [$defaults]: { key: undefined; put: undefined; update: unknown } } + > = 1 + assertSetC + + expect(stC).toMatchObject({ + [$defaults]: { key: undefined, put: undefined, update: { foo: 'bar' } } + }) + }) + + it('record of records', () => { + const rec = record(fooBar, record(fooBar, str)) + + const assertRec: A.Contains< + typeof rec, + { + [$type]: 'record' + [$keys]: typeof fooBar + [$elements]: { + [$type]: 'record' + [$keys]: typeof fooBar + [$elements]: typeof str + [$required]: AtLeastOnce + [$hidden]: false + [$key]: false + [$savedAs]: undefined + [$defaults]: { + key: undefined + put: undefined + update: undefined + } + } + [$required]: AtLeastOnce + [$hidden]: false + [$key]: false + [$savedAs]: undefined + [$defaults]: { + key: undefined + put: undefined + update: undefined + } + } + > = 1 + assertRec + + expect(rec).toMatchObject({ + [$type]: 'record', + [$keys]: fooBar, + [$elements]: { + [$type]: 'record', + [$keys]: fooBar, + [$elements]: str, + [$required]: 'atLeastOnce', + [$hidden]: false, + [$key]: false, + [$savedAs]: undefined, + [$defaults]: { + key: undefined, + put: undefined, + update: undefined + } + }, + [$required]: 'atLeastOnce', + [$hidden]: false, + [$key]: false, + [$savedAs]: undefined, + [$defaults]: { + key: undefined, + put: undefined, + update: undefined + } + }) + }) +}) diff --git a/src/v1/schema/attributes/record/types.ts b/src/v1/schema/attributes/record/types.ts new file mode 100644 index 000000000..f810dac5b --- /dev/null +++ b/src/v1/schema/attributes/record/types.ts @@ -0,0 +1,45 @@ +import type { AtLeastOnce } from '../constants' +import type { $PrimitiveAttributeNestedState } from '../primitive' +import type { PrimitiveAttributeEnumValues } from '../primitive/types' +import type { $SharedAttributeState, SharedAttributeState } from '../shared/interface' +import type { $AttributeNestedState, Attribute } from '../types' + +type RecordAttributeKeysState = { + required: AtLeastOnce + hidden: false + key: false + savedAs: undefined + enum: PrimitiveAttributeEnumValues<'string'> + defaults: { + key: undefined + put: undefined + update: undefined + } + transform: undefined | unknown +} + +export type $RecordAttributeKeys = $PrimitiveAttributeNestedState< + 'string', + RecordAttributeKeysState +> + +export type RecordAttributeKeys = Attribute & { + type: 'string' +} & SharedAttributeState + +type RecordAttributeElementsState = { + required: AtLeastOnce + hidden: false + key: false + savedAs: undefined + defaults: { + key: undefined + put: undefined + update: undefined + } +} + +export type $RecordAttributeElements = $AttributeNestedState & + $SharedAttributeState + +export type RecordAttributeElements = Attribute & SharedAttributeState diff --git a/src/v1/schema/attributes/set/errors.ts b/src/v1/schema/attributes/set/errors.ts new file mode 100644 index 000000000..826b81650 --- /dev/null +++ b/src/v1/schema/attributes/set/errors.ts @@ -0,0 +1,31 @@ +import type { ErrorBlueprint } from 'v1/errors/blueprint' + +type OptionalElementsErrorBlueprint = ErrorBlueprint<{ + code: 'schema.setAttribute.optionalElements' + hasPath: true + payload: undefined +}> + +type HiddenElementsErrorBlueprint = ErrorBlueprint<{ + code: 'schema.setAttribute.hiddenElements' + hasPath: true + payload: undefined +}> + +type SavedAsElementsErrorBlueprint = ErrorBlueprint<{ + code: 'schema.setAttribute.savedAsElements' + hasPath: true + payload: undefined +}> + +type DefaultedElementsErrorBlueprint = ErrorBlueprint<{ + code: 'schema.setAttribute.defaultedElements' + hasPath: true + payload: undefined +}> + +export type SetAttributeErrorBlueprints = + | OptionalElementsErrorBlueprint + | HiddenElementsErrorBlueprint + | SavedAsElementsErrorBlueprint + | DefaultedElementsErrorBlueprint diff --git a/src/v1/schema/attributes/set/freeze.ts b/src/v1/schema/attributes/set/freeze.ts new file mode 100644 index 000000000..94fef293c --- /dev/null +++ b/src/v1/schema/attributes/set/freeze.ts @@ -0,0 +1,101 @@ +import type { O } from 'ts-toolbelt' + +import { DynamoDBToolboxError } from 'v1/errors' + +import type { FreezeAttribute } from '../freeze' +import { validateAttributeProperties } from '../shared/validate' +import { hasDefinedDefault } from '../shared/hasDefinedDefault' +import { + $elements, + $required, + $hidden, + $key, + $savedAs, + $defaults +} from '../constants/attributeOptions' + +import type { SharedAttributeState } from '../shared/interface' +import type { $SetAttributeState, SetAttribute } from './interface' +import type { $SetAttributeElements } from './types' + +export type FreezeSetAttribute<$SET_ATTRIBUTE extends $SetAttributeState> = + // Applying void O.Update improves type display + O.Update< + SetAttribute< + FreezeAttribute<$SET_ATTRIBUTE[$elements]>, + { + required: $SET_ATTRIBUTE[$required] + hidden: $SET_ATTRIBUTE[$hidden] + key: $SET_ATTRIBUTE[$key] + savedAs: $SET_ATTRIBUTE[$savedAs] + defaults: $SET_ATTRIBUTE[$defaults] + } + >, + never, + never + > + +type SetAttributeFreezer = < + $ELEMENTS extends $SetAttributeElements, + STATE extends SharedAttributeState +>( + $elements: $ELEMENTS, + state: STATE, + path: string +) => FreezeSetAttribute<$SetAttributeState<$ELEMENTS, STATE>> + +/** + * Validates a set instance + * + * @param elements Attribute elements + * @param state Attribute options + * @param path Path of the instance in the related schema (string) + * @return void + */ +export const freezeSetAttribute: SetAttributeFreezer = < + $ELEMENTS extends $SetAttributeElements, + STATE extends SharedAttributeState +>( + elements: $ELEMENTS, + state: STATE, + path: string +): FreezeSetAttribute<$SetAttributeState<$ELEMENTS, STATE>> => { + validateAttributeProperties(state, path) + + if (elements[$required] !== 'atLeastOnce') { + throw new DynamoDBToolboxError('schema.setAttribute.optionalElements', { + message: `Invalid set elements at path ${path}: Set elements must be required`, + path + }) + } + + if (elements[$hidden] !== false) { + throw new DynamoDBToolboxError('schema.setAttribute.hiddenElements', { + message: `Invalid set elements at path ${path}: Set elements cannot be hidden`, + path + }) + } + + if (elements[$savedAs] !== undefined) { + throw new DynamoDBToolboxError('schema.setAttribute.savedAsElements', { + message: `Invalid set elements at path ${path}: Set elements cannot be renamed (have savedAs option)`, + path + }) + } + + if (hasDefinedDefault(elements)) { + throw new DynamoDBToolboxError('schema.setAttribute.defaultedElements', { + message: `Invalid set elements at path ${path}: Set elements cannot have default values`, + path + }) + } + + const frozenElements = elements.freeze(`${path}[x]`) as FreezeAttribute<$ELEMENTS> + + return { + path, + type: 'set', + elements: frozenElements, + ...state + } +} diff --git a/src/v1/schema/attributes/set/index.ts b/src/v1/schema/attributes/set/index.ts new file mode 100644 index 000000000..4bf814932 --- /dev/null +++ b/src/v1/schema/attributes/set/index.ts @@ -0,0 +1,8 @@ +export { set } from './typer' +export type { + $SetAttributeState, + $SetAttributeNestedState, + $SetAttribute, + SetAttribute +} from './interface' +export type { FreezeSetAttribute } from './freeze' diff --git a/src/v1/schema/attributes/set/interface.ts b/src/v1/schema/attributes/set/interface.ts new file mode 100644 index 000000000..6d6f0b249 --- /dev/null +++ b/src/v1/schema/attributes/set/interface.ts @@ -0,0 +1,287 @@ +import type { O } from 'ts-toolbelt' + +import type { If, ValueOrGetter } from 'v1/types' +import type { + AttributeKeyInput, + AttributePutItemInput, + AttributeUpdateItemInput, + KeyInput, + PutItemInput, + UpdateItemInput +} from 'v1/operations' + +import type { Schema } from '../../interface' +import type { RequiredOption, AtLeastOnce, Never, Always } from '../constants/requiredOptions' +import type { $type, $elements } from '../constants/attributeOptions' +import type { $SharedAttributeState, SharedAttributeState } from '../shared/interface' + +import type { $SetAttributeElements, SetAttributeElements } from './types' +import type { FreezeSetAttribute } from './freeze' + +export interface $SetAttributeState< + $ELEMENTS extends $SetAttributeElements = $SetAttributeElements, + STATE extends SharedAttributeState = SharedAttributeState +> extends $SharedAttributeState { + [$type]: 'set' + [$elements]: $ELEMENTS +} + +export interface $SetAttributeNestedState< + $ELEMENTS extends $SetAttributeElements = $SetAttributeElements, + STATE extends SharedAttributeState = SharedAttributeState +> extends $SetAttributeState<$ELEMENTS, STATE> { + freeze: (path: string) => FreezeSetAttribute<$SetAttributeState<$ELEMENTS, STATE>> +} + +/** + * Set attribute interface + */ +export interface $SetAttribute< + $ELEMENTS extends $SetAttributeElements = $SetAttributeElements, + STATE extends SharedAttributeState = SharedAttributeState +> extends $SetAttributeNestedState<$ELEMENTS, STATE> { + [$type]: 'set' + [$elements]: $ELEMENTS + /** + * Tag attribute as required. Possible values are: + * - `"atLeastOnce"` _(default)_: Required in PUTs, optional in UPDATEs + * - `"never"`: Optional in PUTs and UPDATEs + * - `"always"`: Required in PUTs and UPDATEs + * + * @param nextRequired RequiredOption + */ + required: ( + nextRequired?: NEXT_IS_REQUIRED + ) => $SetAttribute<$ELEMENTS, O.Overwrite> + /** + * Shorthand for `required('never')` + */ + optional: () => $SetAttribute<$ELEMENTS, O.Overwrite> + /** + * Hide attribute after fetch commands and formatting + */ + hidden: () => $SetAttribute<$ELEMENTS, O.Overwrite> + /** + * Tag attribute as needed for Primary Key computing + */ + key: () => $SetAttribute<$ELEMENTS, O.Overwrite> + /** + * Rename attribute before save commands + */ + savedAs: ( + nextSavedAs: NEXT_SAVED_AS + ) => $SetAttribute<$ELEMENTS, O.Overwrite> + /** + * Provide a default value for attribute in Primary Key computing + * + * @param nextKeyDefault `keyAttributeInput | (() => keyAttributeInput)` + */ + keyDefault: ( + nextKeyDefault: ValueOrGetter< + AttributeKeyInput>, true> + > + ) => $SetAttribute< + $ELEMENTS, + O.Overwrite< + STATE, + { + defaults: { + key: unknown + put: STATE['defaults']['put'] + update: STATE['defaults']['update'] + } + } + > + > + /** + * Provide a default value for attribute in PUT commands + * + * @param nextPutDefault `putAttributeInput | (() => putAttributeInput)` + */ + putDefault: ( + nextPutDefault: ValueOrGetter< + AttributePutItemInput>, true> + > + ) => $SetAttribute< + $ELEMENTS, + O.Overwrite< + STATE, + { + defaults: { + key: STATE['defaults']['key'] + put: unknown + update: STATE['defaults']['update'] + } + } + > + > + /** + * Provide a default value for attribute in UPDATE commands + * + * @param nextUpdateDefault `updateAttributeInput | (() => updateAttributeInput)` + */ + updateDefault: ( + nextUpdateDefault: ValueOrGetter< + AttributeUpdateItemInput>, true> + > + ) => $SetAttribute< + $ELEMENTS, + O.Overwrite< + STATE, + { + defaults: { + key: STATE['defaults']['key'] + put: STATE['defaults']['put'] + update: unknown + } + } + > + > + /** + * Provide a default value for attribute in PUT commands OR Primary Key computing if attribute is tagged as key + * + * @param nextDefault `key/putAttributeInput | (() => key/putAttributeInput)` + */ + default: ( + nextDefault: ValueOrGetter< + If< + STATE['key'], + AttributeKeyInput>, true>, + AttributePutItemInput>, true> + > + > + ) => $SetAttribute< + $ELEMENTS, + O.Overwrite< + STATE, + { + defaults: If< + STATE['key'], + { + key: unknown + put: STATE['defaults']['put'] + update: STATE['defaults']['update'] + }, + { + key: STATE['defaults']['key'] + put: unknown + update: STATE['defaults']['update'] + } + > + } + > + > + /** + * Provide a **linked** default value for attribute in Primary Key computing + * + * @param nextKeyDefault `keyAttributeInput | ((keyInput) => keyAttributeInput)` + */ + keyLink: ( + nextKeyDefault: ValueOrGetter< + AttributeKeyInput>, true>, + [KeyInput] + > + ) => $SetAttribute< + $ELEMENTS, + O.Overwrite< + STATE, + { + defaults: { + key: unknown + put: STATE['defaults']['put'] + update: STATE['defaults']['update'] + } + } + > + > + /** + * Provide a **linked** default value for attribute in PUT commands + * + * @param nextPutDefault `putAttributeInput | ((putItemInput) => putAttributeInput)` + */ + putLink: ( + nextPutDefault: ValueOrGetter< + AttributePutItemInput>, true>, + [PutItemInput] + > + ) => $SetAttribute< + $ELEMENTS, + O.Overwrite< + STATE, + { + defaults: { + key: STATE['defaults']['key'] + put: unknown + update: STATE['defaults']['update'] + } + } + > + > + /** + * Provide a **linked** default value for attribute in UPDATE commands + * + * @param nextUpdateDefault `unknown | ((updateItemInput) => updateAttributeInput)` + */ + updateLink: ( + nextUpdateDefault: ValueOrGetter< + AttributeUpdateItemInput>, true>, + [UpdateItemInput] + > + ) => $SetAttribute< + $ELEMENTS, + O.Overwrite< + STATE, + { + defaults: { + key: STATE['defaults']['key'] + put: STATE['defaults']['put'] + update: unknown + } + } + > + > + /** + * Provide a **linked** default value for attribute in PUT commands OR Primary Key computing if attribute is tagged as key + * + * @param nextDefault `key/putAttributeInput | (() => key/putAttributeInput)` + */ + link: ( + nextDefault: ValueOrGetter< + If< + STATE['key'], + AttributeKeyInput>, true>, + AttributePutItemInput>, true> + >, + [If, PutItemInput>] + > + ) => $SetAttribute< + $ELEMENTS, + O.Overwrite< + STATE, + { + defaults: If< + STATE['key'], + { + key: unknown + put: STATE['defaults']['put'] + update: STATE['defaults']['update'] + }, + { + key: STATE['defaults']['key'] + put: unknown + update: STATE['defaults']['update'] + } + > + } + > + > +} + +export interface SetAttribute< + ELEMENTS extends SetAttributeElements = SetAttributeElements, + STATE extends SharedAttributeState = SharedAttributeState +> extends SharedAttributeState { + path: string + type: 'set' + elements: ELEMENTS +} diff --git a/src/v1/schema/attributes/set/options.ts b/src/v1/schema/attributes/set/options.ts new file mode 100644 index 000000000..faab54efe --- /dev/null +++ b/src/v1/schema/attributes/set/options.ts @@ -0,0 +1,60 @@ +import type { RequiredOption, AtLeastOnce } from '../constants/requiredOptions' + +// Note: May look like a duplicate of AnyAttributeState but actually adds JSDocs + +/** + * Input options of Set Attribute + */ +export interface SetAttributeOptions { + /** + * Tag attribute as required. Possible values are: + * - `"atLeastOnce"` _(default)_: Required in PUTs, optional in UPDATEs + * - `"never"`: Optional in PUTs and UPDATEs + * - `"always"`: Required in PUTs and UPDATEs + */ + required: RequiredOption + /** + * Hide attribute after fetch commands and formatting + */ + hidden: boolean + /** + * Tag attribute as needed for Primary Key computing + */ + key: boolean + /** + * Rename attribute before save commands + */ + savedAs: string | undefined + /** + * Provide default values for attribute + */ + defaults: { + key: undefined | unknown + put: undefined | unknown + update: undefined | unknown + } +} + +export type SetAttributeDefaultOptions = { + required: AtLeastOnce + hidden: false + key: false + savedAs: undefined + defaults: { + key: undefined + put: undefined + update: undefined + } +} + +export const SET_ATTRIBUTE_DEFAULT_OPTIONS: SetAttributeDefaultOptions = { + required: 'atLeastOnce', + hidden: false, + key: false, + savedAs: undefined, + defaults: { + key: undefined, + put: undefined, + update: undefined + } +} diff --git a/src/v1/schema/attributes/set/typer.ts b/src/v1/schema/attributes/set/typer.ts new file mode 100644 index 000000000..3cb8af14a --- /dev/null +++ b/src/v1/schema/attributes/set/typer.ts @@ -0,0 +1,185 @@ +import type { NarrowObject } from 'v1/types/narrowObject' +import { overwrite } from 'v1/utils/overwrite' + +import type { RequiredOption, AtLeastOnce } from '../constants/requiredOptions' +import { + $type, + $elements, + $required, + $hidden, + $key, + $savedAs, + $defaults +} from '../constants/attributeOptions' +import type { InferStateFromOptions } from '../shared/inferStateFromOptions' +import type { SharedAttributeState } from '../shared/interface' + +import type { $SetAttribute } from './interface' +import type { $SetAttributeElements } from './types' +import { + SetAttributeOptions, + SetAttributeDefaultOptions, + SET_ATTRIBUTE_DEFAULT_OPTIONS +} from './options' +import { freezeSetAttribute } from './freeze' + +type $SetAttributeTyper = < + $ELEMENTS extends $SetAttributeElements, + STATE extends SharedAttributeState = SharedAttributeState +>( + elements: $ELEMENTS, + state: STATE +) => $SetAttribute<$ELEMENTS, STATE> + +const $set: $SetAttributeTyper = < + $ELEMENTS extends $SetAttributeElements, + STATE extends SharedAttributeState = SharedAttributeState +>( + elements: $ELEMENTS, + state: STATE +) => { + const $setAttribute: $SetAttribute<$ELEMENTS, STATE> = { + [$type]: 'set', + [$elements]: elements, + [$required]: state.required, + [$hidden]: state.hidden, + [$key]: state.key, + [$savedAs]: state.savedAs, + [$defaults]: state.defaults, + required: ( + nextRequired: NEXT_IS_REQUIRED = 'atLeastOnce' as NEXT_IS_REQUIRED + ) => $set(elements, overwrite(state, { required: nextRequired })), + optional: () => $set(elements, overwrite(state, { required: 'never' })), + hidden: () => $set(elements, overwrite(state, { hidden: true })), + key: () => $set(elements, overwrite(state, { key: true, required: 'always' })), + savedAs: nextSavedAs => $set(elements, overwrite(state, { savedAs: nextSavedAs })), + keyDefault: nextKeyDefault => + $set( + elements, + overwrite(state, { + defaults: { + key: nextKeyDefault, + put: state.defaults.put, + update: state.defaults.update + } + }) + ), + putDefault: nextPutDefault => + $set( + elements, + overwrite(state, { + defaults: { + key: state.defaults.key, + put: nextPutDefault, + update: state.defaults.update + } + }) + ), + updateDefault: nextUpdateDefault => + $set( + elements, + overwrite(state, { + defaults: { + key: state.defaults.key, + put: state.defaults.put, + update: nextUpdateDefault + } + }) + ), + default: nextDefault => + $set( + elements, + overwrite(state, { + defaults: state.key + ? { key: nextDefault, put: state.defaults.put, update: state.defaults.update } + : { key: state.defaults.key, put: nextDefault, update: state.defaults.update } + }) + ), + keyLink: nextKeyDefault => + $set( + elements, + overwrite(state, { + defaults: { + key: nextKeyDefault, + put: state.defaults.put, + update: state.defaults.update + } + }) + ), + putLink: nextPutDefault => + $set( + elements, + overwrite(state, { + defaults: { + key: state.defaults.key, + put: nextPutDefault, + update: state.defaults.update + } + }) + ), + updateLink: nextUpdateDefault => + $set( + elements, + overwrite(state, { + defaults: { + key: state.defaults.key, + put: state.defaults.put, + update: nextUpdateDefault + } + }) + ), + link: nextDefault => + $set( + elements, + overwrite(state, { + defaults: state.key + ? { key: nextDefault, put: state.defaults.put, update: state.defaults.update } + : { key: state.defaults.key, put: nextDefault, update: state.defaults.update } + }) + ), + freeze: path => freezeSetAttribute(elements, state, path) + } + + return $setAttribute +} + +type SetAttributeTyper = < + $ELEMENTS extends $SetAttributeElements, + OPTIONS extends Partial = SetAttributeOptions +>( + elements: $ELEMENTS, + options?: NarrowObject +) => $SetAttribute< + $ELEMENTS, + InferStateFromOptions +> + +/** + * Define a new set attribute + * Not that set elements have constraints. They must be: + * - Required (required: AtLeastOnce) + * - Displayed (hidden: false) + * - Not renamed (savedAs: undefined) + * - Not defaulted (defaults: undefined) + * + * @param elements Attribute (With constraints) + * @param options _(optional)_ List Options + */ +export const set: SetAttributeTyper = < + ELEMENTS extends $SetAttributeElements, + OPTIONS extends Partial = SetAttributeOptions +>( + elements: ELEMENTS, + options?: NarrowObject +): $SetAttribute< + ELEMENTS, + InferStateFromOptions +> => { + const state = { + ...SET_ATTRIBUTE_DEFAULT_OPTIONS, + ...options, + defaults: { ...SET_ATTRIBUTE_DEFAULT_OPTIONS.defaults, ...options?.defaults } + } as InferStateFromOptions + + return $set(elements, state) +} diff --git a/src/v1/schema/attributes/set/typer.unit.test.ts b/src/v1/schema/attributes/set/typer.unit.test.ts new file mode 100644 index 000000000..255cfe76c --- /dev/null +++ b/src/v1/schema/attributes/set/typer.unit.test.ts @@ -0,0 +1,324 @@ +import type { A } from 'ts-toolbelt' + +import { DynamoDBToolboxError } from 'v1/errors' + +import { Never, AtLeastOnce, Always } from '../constants' +import { string } from '../primitive' +import { + $type, + $elements, + $required, + $hidden, + $key, + $savedAs, + $defaults +} from '../constants/attributeOptions' + +import { set } from './typer' +import { SetAttribute, $SetAttributeState } from './interface' + +describe('set', () => { + const path = 'some.path' + const strElement = string() + + it('rejects non-required elements', () => { + const invalidSet = set( + // @ts-expect-error + string().optional() + ) + + const invalidCall = () => invalidSet.freeze(path) + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow( + expect.objectContaining({ code: 'schema.setAttribute.optionalElements', path }) + ) + }) + + it('rejects hidden elements', () => { + const invalidSet = set( + // @ts-expect-error + strElement.hidden() + ) + + const invalidCall = () => invalidSet.freeze(path) + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow( + expect.objectContaining({ code: 'schema.setAttribute.hiddenElements', path }) + ) + }) + + it('rejects elements with savedAs values', () => { + const invalidSet = set( + // @ts-expect-error + strElement.savedAs('foo') + ) + + const invalidCall = () => invalidSet.freeze(path) + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow( + expect.objectContaining({ code: 'schema.setAttribute.savedAsElements', path }) + ) + }) + + it('rejects elements with default values', () => { + const invalidSet = set( + // @ts-expect-error + strElement.default('foo') + ) + + const invalidCall = () => invalidSet.freeze(path) + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow( + expect.objectContaining({ code: 'schema.setAttribute.defaultedElements', path }) + ) + }) + + it('returns default set', () => { + const st = set(strElement) + + const assertSet: A.Contains< + typeof st, + { + [$type]: 'set' + [$elements]: typeof strElement + [$required]: AtLeastOnce + [$hidden]: false + [$key]: false + [$savedAs]: undefined + [$defaults]: { + key: undefined + put: undefined + update: undefined + } + } + > = 1 + assertSet + + const assertExtends: A.Extends = 1 + assertExtends + + const frozenSet = st.freeze(path) + const assertFrozenExtends: A.Extends = 1 + assertFrozenExtends + + expect(st).toMatchObject({ + [$type]: 'set', + [$elements]: strElement, + [$required]: 'atLeastOnce', + [$key]: false, + [$savedAs]: undefined, + [$hidden]: false, + [$defaults]: { + key: undefined, + put: undefined, + update: undefined + } + }) + }) + + it('returns required set (option)', () => { + const stAtLeastOnce = set(strElement, { required: 'atLeastOnce' }) + const stAlways = set(strElement, { required: 'always' }) + const stNever = set(strElement, { required: 'never' }) + + const assertAtLeastOnce: A.Contains = 1 + assertAtLeastOnce + const assertAlways: A.Contains = 1 + assertAlways + const assertNever: A.Contains = 1 + assertNever + + expect(stAtLeastOnce).toMatchObject({ [$required]: 'atLeastOnce' }) + expect(stAlways).toMatchObject({ [$required]: 'always' }) + expect(stNever).toMatchObject({ [$required]: 'never' }) + }) + + it('returns required set (method)', () => { + const stAtLeastOnce = set(strElement).required() + const stAlways = set(strElement).required('always') + const stNever = set(strElement).required('never') + const stOpt = set(strElement).optional() + + const assertAtLeastOnce: A.Contains = 1 + assertAtLeastOnce + const assertAlways: A.Contains = 1 + assertAlways + const assertNever: A.Contains = 1 + assertNever + const assertOpt: A.Contains = 1 + assertOpt + + expect(stAtLeastOnce).toMatchObject({ [$required]: 'atLeastOnce' }) + expect(stAlways).toMatchObject({ [$required]: 'always' }) + expect(stNever).toMatchObject({ [$required]: 'never' }) + expect(stOpt).toMatchObject({ [$required]: 'never' }) + }) + + it('returns hidden set (option)', () => { + const st = set(strElement, { hidden: true }) + + const assertSet: A.Contains = 1 + assertSet + + expect(st).toMatchObject({ [$hidden]: true }) + }) + + it('returns hidden set (method)', () => { + const st = set(strElement).hidden() + + const assertSet: A.Contains = 1 + assertSet + + expect(st).toMatchObject({ [$hidden]: true }) + }) + + it('returns key set (option)', () => { + const st = set(strElement, { key: true }) + + const assertSet: A.Contains = 1 + assertSet + + expect(st).toMatchObject({ [$key]: true, [$required]: 'atLeastOnce' }) + }) + + it('returns key set (method)', () => { + const st = set(strElement).key() + + const assertSet: A.Contains = 1 + assertSet + + expect(st).toMatchObject({ [$key]: true, [$required]: 'always' }) + }) + + it('returns savedAs set (option)', () => { + const st = set(strElement, { savedAs: 'foo' }) + + const assertSet: A.Contains = 1 + assertSet + + expect(st).toMatchObject({ [$savedAs]: 'foo' }) + }) + + it('returns savedAs set (method)', () => { + const st = set(strElement).savedAs('foo') + + const assertSet: A.Contains = 1 + assertSet + + expect(st).toMatchObject({ [$savedAs]: 'foo' }) + }) + + it('returns defaulted set (option)', () => { + const stA = set(strElement, { + defaults: { key: new Set(['foo']), put: undefined, update: undefined } + }) + + const assertSetA: A.Contains< + typeof stA, + { [$defaults]: { key: unknown; put: undefined; update: undefined } } + > = 1 + assertSetA + + expect(stA).toMatchObject({ + [$defaults]: { key: new Set(['foo']), put: undefined, update: undefined } + }) + + const stB = set(strElement, { + defaults: { key: undefined, put: new Set(['bar']), update: undefined } + }) + + const assertSetB: A.Contains< + typeof stB, + { [$defaults]: { key: undefined; put: unknown; update: undefined } } + > = 1 + assertSetB + + expect(stB).toMatchObject({ + [$defaults]: { key: undefined, put: new Set(['bar']), update: undefined } + }) + + const stC = set(strElement, { + defaults: { key: undefined, put: undefined, update: new Set(['baz']) } + }) + + const assertSetC: A.Contains< + typeof stC, + { [$defaults]: { key: undefined; put: undefined; update: unknown } } + > = 1 + assertSetC + + expect(stC).toMatchObject({ + [$defaults]: { key: undefined, put: undefined, update: new Set(['baz']) } + }) + }) + + it('accepts ComputedDefault as default value (method)', () => { + const stA = set(strElement).keyDefault(new Set('foo')) + + const assertSetA: A.Contains< + typeof stA, + { [$defaults]: { key: unknown; put: undefined; update: undefined } } + > = 1 + assertSetA + + expect(stA).toMatchObject({ + [$defaults]: { key: new Set('foo'), put: undefined, update: undefined } + }) + + const stB = set(strElement).putDefault(new Set('bar')) + + const assertSetB: A.Contains< + typeof stB, + { [$defaults]: { key: undefined; put: unknown; update: undefined } } + > = 1 + assertSetB + + expect(stB).toMatchObject({ + [$defaults]: { key: undefined, put: new Set('bar'), update: undefined } + }) + + const stC = set(strElement).updateDefault(new Set('baz')) + + const assertSetC: A.Contains< + typeof stC, + { [$defaults]: { key: undefined; put: undefined; update: unknown } } + > = 1 + assertSetC + + expect(stC).toMatchObject({ + [$defaults]: { key: undefined, put: undefined, update: new Set('baz') } + }) + }) + + it('returns set with PUT default value if it is not key (default shorthand)', () => { + const st = set(string()).default(new Set('foo')) + + const assertSt: A.Contains< + typeof st, + { [$defaults]: { key: undefined; put: unknown; update: undefined } } + > = 1 + assertSt + + expect(st).toMatchObject({ + [$defaults]: { key: undefined, put: new Set('foo'), update: undefined } + }) + }) + + it('returns set with KEY default value if it is key (default shorthand)', () => { + const st = set(string()).key().default(new Set('foo')) + + const assertSt: A.Contains< + typeof st, + { [$defaults]: { key: unknown; put: undefined; update: undefined } } + > = 1 + assertSt + + expect(st).toMatchObject({ + [$defaults]: { key: new Set('foo'), put: undefined, update: undefined } + }) + }) +}) diff --git a/src/v1/schema/attributes/set/types.ts b/src/v1/schema/attributes/set/types.ts new file mode 100644 index 000000000..1b15de303 --- /dev/null +++ b/src/v1/schema/attributes/set/types.ts @@ -0,0 +1,27 @@ +import type { AtLeastOnce } from '../constants' +import type { $PrimitiveAttributeNestedState, PrimitiveAttribute } from '../primitive/interface' +import type { PrimitiveAttributeEnumValues } from '../primitive/types' + +interface SetAttributeElementState { + required: AtLeastOnce + hidden: false + key: boolean + savedAs: undefined + enum: PrimitiveAttributeEnumValues<'string' | 'number' | 'binary'> + defaults: { + key: undefined + put: undefined + update: undefined + } + transform: undefined | unknown +} + +export type $SetAttributeElements = $PrimitiveAttributeNestedState< + 'string' | 'number' | 'binary', + SetAttributeElementState +> + +export type SetAttributeElements = PrimitiveAttribute< + 'string' | 'number' | 'binary', + SetAttributeElementState +> diff --git a/src/v1/schema/attributes/shared/errors.ts b/src/v1/schema/attributes/shared/errors.ts new file mode 100644 index 000000000..6ee10b1c3 --- /dev/null +++ b/src/v1/schema/attributes/shared/errors.ts @@ -0,0 +1,13 @@ +import type { ErrorBlueprint } from 'v1/errors/blueprint' + +type InvalidPropertyErrorBlueprint = ErrorBlueprint<{ + code: 'schema.attribute.invalidProperty' + hasPath: true + payload: { + propertyName: string + expected?: unknown + received: unknown + } +}> + +export type SharedAttributeErrorBlueprints = InvalidPropertyErrorBlueprint diff --git a/src/v1/schema/attributes/shared/hasDefinedDefault.ts b/src/v1/schema/attributes/shared/hasDefinedDefault.ts new file mode 100644 index 000000000..0545244b4 --- /dev/null +++ b/src/v1/schema/attributes/shared/hasDefinedDefault.ts @@ -0,0 +1,7 @@ +import { $defaults } from '../constants/attributeOptions' +import type { $AttributeState } from '../types' + +export const hasDefinedDefault = (attribute: $AttributeState): boolean => + (['key', 'put', 'update'] as const).some( + operation => attribute[$defaults][operation] !== undefined + ) diff --git a/src/v1/schema/attributes/shared/inferStateFromOptions.ts b/src/v1/schema/attributes/shared/inferStateFromOptions.ts new file mode 100644 index 000000000..81b09c8b7 --- /dev/null +++ b/src/v1/schema/attributes/shared/inferStateFromOptions.ts @@ -0,0 +1,36 @@ +import type { O } from 'ts-toolbelt' + +import type { AttributeOptions, AttributeOptionName } from '../constants/attributeOptions' + +type InferStateValueFromOption< + OPTIONS_CONSTRAINTS extends Partial, + DEFAULT_OPTIONS extends OPTIONS_CONSTRAINTS, + OPTIONS extends Partial, + OPTION_NAME extends keyof OPTIONS_CONSTRAINTS, + OPTION_VALUE = undefined extends OPTIONS_CONSTRAINTS[OPTION_NAME] + ? OPTIONS[OPTION_NAME] + : NonNullable +> = OPTIONS_CONSTRAINTS[OPTION_NAME] extends OPTION_VALUE + ? DEFAULT_OPTIONS[OPTION_NAME] + : OPTION_VALUE + +export type InferStateFromOptions< + OPTIONS_CONSTRAINTS extends Partial, + DEFAULT_OPTIONS extends OPTIONS_CONSTRAINTS, + OPTIONS extends Partial, + ADDITIONAL_OPTIONS extends object = {} +> = + // Applying void O.Update improves type display + O.Update< + { + [KEY in Extract]: InferStateValueFromOption< + OPTIONS_CONSTRAINTS, + DEFAULT_OPTIONS, + OPTIONS, + KEY + > + } & + ADDITIONAL_OPTIONS, + never, + never + > diff --git a/src/v1/schema/attributes/shared/interface.ts b/src/v1/schema/attributes/shared/interface.ts new file mode 100644 index 000000000..010f00273 --- /dev/null +++ b/src/v1/schema/attributes/shared/interface.ts @@ -0,0 +1,34 @@ +import type { RequiredOption } from '../constants/requiredOptions' +import type { $required, $hidden, $key, $savedAs, $defaults } from '../constants/attributeOptions' + +interface SharedAttributeStateConstraint { + required: RequiredOption + hidden: boolean + key: boolean + savedAs: string | undefined + defaults: { + key: undefined | unknown + put: undefined | unknown + update: undefined | unknown + } +} + +export interface $SharedAttributeState< + STATE extends SharedAttributeStateConstraint = SharedAttributeStateConstraint +> { + [$required]: STATE['required'] + [$hidden]: STATE['hidden'] + [$key]: STATE['key'] + [$savedAs]: STATE['savedAs'] + [$defaults]: STATE['defaults'] +} + +export interface SharedAttributeState< + STATE extends SharedAttributeStateConstraint = SharedAttributeStateConstraint +> { + required: STATE['required'] + hidden: STATE['hidden'] + key: STATE['key'] + savedAs: STATE['savedAs'] + defaults: STATE['defaults'] +} diff --git a/src/v1/schema/attributes/shared/validate.ts b/src/v1/schema/attributes/shared/validate.ts new file mode 100644 index 000000000..70f6bcb07 --- /dev/null +++ b/src/v1/schema/attributes/shared/validate.ts @@ -0,0 +1,75 @@ +import { DynamoDBToolboxError } from 'v1/errors' +import { isBoolean, isString } from 'v1/utils/validation' + +import { requiredOptionsSet } from '../constants/requiredOptions' + +import type { SharedAttributeState } from './interface' + +/** + * Validates an attribute shared properties + * + * @param attribute Attribute + * @param path Path of the instance in the related schema (string) + * @return void + */ +export const validateAttributeProperties = ( + attribute: SharedAttributeState, + path: string +): void => { + const attributeRequired = attribute.required + if (!requiredOptionsSet.has(attributeRequired)) { + throw new DynamoDBToolboxError('schema.attribute.invalidProperty', { + message: `Invalid option value type at path ${path}. Property: 'required'. Expected: ${[ + ...requiredOptionsSet + ].join(', ')}. Received: ${String(attributeRequired)}.`, + path, + payload: { + propertyName: 'required', + expected: [...requiredOptionsSet].join(', '), + received: attributeRequired + } + }) + } + + const attributeHidden = attribute.hidden + if (!isBoolean(attributeHidden)) { + throw new DynamoDBToolboxError('schema.attribute.invalidProperty', { + message: `Invalid option value type at path ${path}. Property: 'hidden'. Expected: boolean. Received: ${String( + attributeHidden + )}.`, + path, + payload: { + propertyName: 'hidden', + received: attributeRequired + } + }) + } + + const attributeKey = attribute.key + if (!isBoolean(attributeKey)) { + throw new DynamoDBToolboxError('schema.attribute.invalidProperty', { + message: `Invalid option value type at path ${path}. Property: 'key'. Expected: boolean. Received: ${String( + attributeHidden + )}.`, + path, + payload: { + propertyName: 'key', + received: attributeRequired + } + }) + } + + const attributeSavedAs = attribute.savedAs + if (attributeSavedAs !== undefined && !isString(attributeSavedAs)) { + throw new DynamoDBToolboxError('schema.attribute.invalidProperty', { + message: `Invalid option value type at path ${path}. Property: 'savedAs'. Expected: string. Received: ${String( + attributeHidden + )}.`, + path, + payload: { + propertyName: 'savedAs', + received: attributeRequired + } + }) + } +} diff --git a/src/v1/schema/attributes/shared/validate.unit.test.ts b/src/v1/schema/attributes/shared/validate.unit.test.ts new file mode 100644 index 000000000..9442c00e8 --- /dev/null +++ b/src/v1/schema/attributes/shared/validate.unit.test.ts @@ -0,0 +1,129 @@ +import { DynamoDBToolboxError } from 'v1/errors' + +import type { Never } from '../constants/requiredOptions' + +import type { SharedAttributeState } from './interface' +import { validateAttributeProperties } from './validate' + +describe('shared properties validation', () => { + const path = 'some/path' + + const validProperties: SharedAttributeState<{ + required: Never + hidden: false + key: false + savedAs: undefined + defaults: { + key: undefined + put: undefined + update: undefined + } + }> = { + required: 'never', + hidden: false, + key: false, + savedAs: undefined, + defaults: { + key: undefined, + put: undefined, + update: undefined + } + } + + it('throws if required option is invalid', () => { + const invalidRequiredOption = 'invalid' + + const invalidCall = () => + validateAttributeProperties( + { + ...validProperties, + // @ts-expect-error + required: invalidRequiredOption + }, + path + ) + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow( + expect.objectContaining({ code: 'schema.attribute.invalidProperty', path }) + ) + + expect(() => validateAttributeProperties(validProperties, path)).not.toThrow() + expect(() => + validateAttributeProperties({ ...validProperties, required: 'atLeastOnce' }, path) + ).not.toThrow() + expect(() => + validateAttributeProperties({ ...validProperties, required: 'always' }, path) + ).not.toThrow() + }) + + it('throws if hidden option is invalid', () => { + const invalidKeyOption = 'invalid' + + const invalidCall = () => + validateAttributeProperties( + { + ...validProperties, + // @ts-expect-error + hidden: invalidKeyOption + }, + path + ) + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow( + expect.objectContaining({ code: 'schema.attribute.invalidProperty', path }) + ) + + expect(() => validateAttributeProperties(validProperties, path)).not.toThrow() + expect(() => + validateAttributeProperties({ ...validProperties, hidden: true }, path) + ).not.toThrow() + }) + + it('throws if key option is invalid', () => { + const invalidKeyOption = 'invalid' + + const invalidCall = () => + validateAttributeProperties( + { + ...validProperties, + // @ts-expect-error + key: invalidKeyOption + }, + path + ) + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow( + expect.objectContaining({ code: 'schema.attribute.invalidProperty', path }) + ) + + expect(() => validateAttributeProperties(validProperties, path)).not.toThrow() + expect(() => validateAttributeProperties({ ...validProperties, key: true }, path)).not.toThrow() + }) + + it('throws if savedAs option is invalid', () => { + const invalidSavedAsOption = 42 + + const invalidCall = () => + validateAttributeProperties( + { + ...validProperties, + // @ts-expect-error + savedAs: invalidSavedAsOption + }, + path + ) + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow( + expect.objectContaining({ code: 'schema.attribute.invalidProperty', path }) + ) + + expect(() => validateAttributeProperties(validProperties, path)).not.toThrow() + expect(() => + validateAttributeProperties({ ...validProperties, savedAs: 'foo' }, path) + ).not.toThrow() + }) +}) diff --git a/src/v1/schema/attributes/types/$attribute.ts b/src/v1/schema/attributes/types/$attribute.ts new file mode 100644 index 000000000..5d1fd0978 --- /dev/null +++ b/src/v1/schema/attributes/types/$attribute.ts @@ -0,0 +1,26 @@ +import type { $AnyAttribute } from '../any' +import type { $PrimitiveAttribute } from '../primitive' +import type { $SetAttribute } from '../set' +import type { $ListAttribute } from '../list' +import type { $MapAttribute } from '../map' +import type { $RecordAttribute } from '../record' +import type { $AnyOfAttribute } from '../anyOf' + +/** + * Any warm attribute + */ +export type $Attribute = + | $AnyAttribute + | $PrimitiveAttribute + | $SetAttribute + | $ListAttribute + | $MapAttribute + | $RecordAttribute + | $AnyOfAttribute + +/** + * Any warm schema attributes + */ +export interface $SchemaAttributes { + [key: string]: $Attribute +} diff --git a/src/v1/schema/attributes/types/$attributeNestedState.ts b/src/v1/schema/attributes/types/$attributeNestedState.ts new file mode 100644 index 000000000..f56351e7d --- /dev/null +++ b/src/v1/schema/attributes/types/$attributeNestedState.ts @@ -0,0 +1,26 @@ +import type { $AnyAttributeNestedState } from '../any' +import type { $PrimitiveAttributeNestedState } from '../primitive' +import type { $SetAttributeNestedState } from '../set' +import type { $ListAttributeNestedState } from '../list' +import type { $MapAttributeNestedState } from '../map' +import type { $RecordAttributeNestedState } from '../record' +import type { $AnyOfAttributeNestedState } from '../anyOf' + +/** + * Any warm attribute nested state (i.e. with `freeze` method) + */ +export type $AttributeNestedState = + | $AnyAttributeNestedState + | $PrimitiveAttributeNestedState + | $SetAttributeNestedState + | $ListAttributeNestedState + | $MapAttributeNestedState + | $RecordAttributeNestedState + | $AnyOfAttributeNestedState + +/** + * Any warm schema attribute states (i.e. with `freeze` method) + */ +export interface $SchemaAttributeNestedStates { + [key: string]: $AttributeNestedState +} diff --git a/src/v1/schema/attributes/types/$attributeState.ts b/src/v1/schema/attributes/types/$attributeState.ts new file mode 100644 index 000000000..db9ed3f06 --- /dev/null +++ b/src/v1/schema/attributes/types/$attributeState.ts @@ -0,0 +1,26 @@ +import type { $AnyAttributeState } from '../any' +import type { $PrimitiveAttributeState } from '../primitive' +import type { $SetAttributeState } from '../set' +import type { $ListAttributeState } from '../list' +import type { $MapAttributeState } from '../map' +import type { $RecordAttributeState } from '../record' +import type { $AnyOfAttributeState } from '../anyOf' + +/** + * Any warm attribute state + */ +export type $AttributeState = + | $AnyAttributeState + | $PrimitiveAttributeState + | $SetAttributeState + | $ListAttributeState + | $MapAttributeState + | $RecordAttributeState + | $AnyOfAttributeState + +/** + * Any warm schema attribute states + */ +export interface $SchemaAttributeStates { + [key: string]: $AttributeState +} diff --git a/src/v1/schema/attributes/types/attribute.ts b/src/v1/schema/attributes/types/attribute.ts new file mode 100644 index 000000000..471e7e64d --- /dev/null +++ b/src/v1/schema/attributes/types/attribute.ts @@ -0,0 +1,101 @@ +import type { AnyAttribute } from '../any' +import type { + ResolvedPrimitiveAttribute, + PrimitiveAttribute, + PrimitiveAttributeType +} from '../primitive' +import type { SetAttribute } from '../set' +import type { ListAttribute } from '../list' +import type { MapAttribute } from '../map' +import type { RecordAttribute } from '../record' +import type { AnyOfAttribute } from '../anyOf' + +/** + * Any attribute + */ +export type Attribute = + | AnyAttribute + | PrimitiveAttribute + | SetAttribute + | ListAttribute + | MapAttribute + | RecordAttribute + | AnyOfAttribute + +/** + * Any schema attributes + */ +export interface SchemaAttributes { + [key: string]: Attribute +} + +export type Extension = { + type: Attribute['type'] | '*' + value: unknown +} + +export type ExtendedValue< + EXTENSION extends Extension, + TYPE extends Attribute['type'] | '*' +> = Extract['value'] + +export type PrimitiveAttributeValue = + | ExtendedValue + | PrimitiveAttributeBasicValue + +export type PrimitiveAttributeBasicValue = ResolvedPrimitiveAttribute + +export type SetAttributeValue = + | ExtendedValue + | SetAttributeBasicValue + +export type SetAttributeBasicValue = Set< + AttributeValue +> + +export type ListAttributeValue = + | ExtendedValue + | ListAttributeBasicValue + +export type ListAttributeBasicValue< + EXTENSION extends Extension = never +> = AttributeValue[] + +export type MapAttributeValue = + | ExtendedValue + | MapAttributeBasicValue + +export type MapAttributeBasicValue = { + [key: string]: AttributeValue +} + +export type RecordAttributeValue = + | ExtendedValue + | RecordAttributeBasicValue + +export type RecordAttributeBasicValue = { + [key: string]: AttributeValue | undefined +} + +/** + * Any possible resolved attribute type + */ +export type AttributeValue = + | PrimitiveAttributeValue + | SetAttributeValue + | ListAttributeValue + | MapAttributeValue + | RecordAttributeValue + +export type Item = { + [key: string]: AttributeValue +} + +export type AttributeBasicValue = + | PrimitiveAttributeBasicValue + | SetAttributeBasicValue + | ListAttributeBasicValue + | MapAttributeBasicValue + | RecordAttributeBasicValue + +export type UndefinedAttrExtension = { type: '*'; value: undefined } diff --git a/src/v1/schema/attributes/types/index.ts b/src/v1/schema/attributes/types/index.ts new file mode 100644 index 000000000..411de584a --- /dev/null +++ b/src/v1/schema/attributes/types/index.ts @@ -0,0 +1,4 @@ +export * from './$attribute' +export * from './$attributeNestedState' +export * from './$attributeState' +export * from './attribute' diff --git a/src/v1/schema/errors.ts b/src/v1/schema/errors.ts new file mode 100644 index 000000000..4f4694d30 --- /dev/null +++ b/src/v1/schema/errors.ts @@ -0,0 +1,20 @@ +import type { AttributeErrorBlueprints } from './attributes/errors' + +import type { ErrorBlueprint } from 'v1/errors/blueprint' + +type DuplicateAttributeErrorBlueprint = ErrorBlueprint<{ + code: 'schema.duplicateAttributeNames' + hasPath: false + payload: { name: string } +}> + +type DuplicateSavedAsErrorBlueprint = ErrorBlueprint<{ + code: 'schema.duplicateSavedAsAttributes' + hasPath: false + payload: { savedAs: string } +}> + +export type SchemaErrorBlueprints = + | AttributeErrorBlueprints + | DuplicateAttributeErrorBlueprint + | DuplicateSavedAsErrorBlueprint diff --git a/src/v1/schema/index.ts b/src/v1/schema/index.ts new file mode 100644 index 000000000..543b55291 --- /dev/null +++ b/src/v1/schema/index.ts @@ -0,0 +1,4 @@ +export * from './attributes' +export * from './errors' +export type { Schema } from './interface' +export { schema } from './typer' diff --git a/src/v1/schema/interface.ts b/src/v1/schema/interface.ts new file mode 100644 index 000000000..11e93cc6e --- /dev/null +++ b/src/v1/schema/interface.ts @@ -0,0 +1,27 @@ +import type { NarrowObject } from 'v1/types' + +import type { SchemaAttributes, RequiredOption, $SchemaAttributeNestedStates } from './attributes' +import type { FreezeAttribute } from './attributes/freeze' + +export interface Schema { + type: 'schema' + savedAttributeNames: Set + keyAttributeNames: Set + requiredAttributeNames: Record> + attributes: ATTRIBUTES + and: <$ADDITIONAL_ATTRIBUTES extends $SchemaAttributeNestedStates = $SchemaAttributeNestedStates>( + additionalAttributes: + | NarrowObject<$ADDITIONAL_ATTRIBUTES> + | ((schema: Schema) => NarrowObject<$ADDITIONAL_ATTRIBUTES>) + ) => Schema< + { + [KEY in + | keyof ATTRIBUTES + | keyof $ADDITIONAL_ATTRIBUTES]: KEY extends keyof $ADDITIONAL_ATTRIBUTES + ? FreezeAttribute<$ADDITIONAL_ATTRIBUTES[KEY]> + : KEY extends keyof ATTRIBUTES + ? ATTRIBUTES[KEY] + : never + } + > +} diff --git a/src/v1/schema/typer.ts b/src/v1/schema/typer.ts new file mode 100644 index 000000000..b8801a948 --- /dev/null +++ b/src/v1/schema/typer.ts @@ -0,0 +1,114 @@ +import { DynamoDBToolboxError } from 'v1/errors' +import type { NarrowObject } from 'v1/types' + +import type { SchemaAttributes, $SchemaAttributeNestedStates } from './attributes' +import { $key, $savedAs, $required } from './attributes/constants/attributeOptions' + +import type { Schema } from './interface' +import type { RequiredOption } from './attributes/constants/requiredOptions' +import type { FreezeAttribute } from './attributes/freeze' + +type $SchemaTyper = (arg: { + attributes: NarrowObject + savedAttributeNames: Set + keyAttributeNames: Set + requiredAttributeNames: Record> +}) => Schema + +const $schema: $SchemaTyper = ({ + attributes, + savedAttributeNames, + keyAttributeNames, + requiredAttributeNames +}: { + attributes: NarrowObject + savedAttributeNames: Set + keyAttributeNames: Set + requiredAttributeNames: Record> +}) => + ({ + type: 'schema', + attributes, + savedAttributeNames, + keyAttributeNames, + requiredAttributeNames, + and: additionalAttr => { + const additionalAttributes = (typeof additionalAttr === 'function' + ? additionalAttr( + $schema({ attributes, savedAttributeNames, keyAttributeNames, requiredAttributeNames }) + ) + : additionalAttr) as $SchemaAttributeNestedStates + + const nextAttributes = { ...attributes } as SchemaAttributes + const nextSavedAttributeNames = new Set(savedAttributeNames) + const nextKeyAttributeNames = new Set(keyAttributeNames) + const nextRequiredAttributeNames: Record> = { + always: new Set(requiredAttributeNames.always), + atLeastOnce: new Set(requiredAttributeNames.atLeastOnce), + never: new Set(requiredAttributeNames.never) + } + + for (const attributeName in additionalAttributes) { + if (nextSavedAttributeNames.has(attributeName)) { + throw new DynamoDBToolboxError('schema.duplicateAttributeNames', { + message: `Invalid schema: More than two attributes are named '${attributeName}'`, + payload: { name: attributeName } + }) + } + + const attribute = additionalAttributes[attributeName] + + const attributeSavedAs = attribute[$savedAs] ?? attributeName + if (nextSavedAttributeNames.has(attributeSavedAs)) { + throw new DynamoDBToolboxError('schema.duplicateSavedAsAttributes', { + message: `Invalid schema: More than two attributes are saved as '${attributeSavedAs}'`, + payload: { savedAs: attributeSavedAs } + }) + } + nextSavedAttributeNames.add(attributeSavedAs) + + if (attribute[$key]) { + nextKeyAttributeNames.add(attributeName) + } + + nextRequiredAttributeNames[attribute[$required]].add(attributeName) + + nextAttributes[attributeName] = attribute.freeze(attributeName) + } + + return $schema({ + attributes: nextAttributes, + savedAttributeNames: nextSavedAttributeNames, + keyAttributeNames: nextKeyAttributeNames, + requiredAttributeNames: nextRequiredAttributeNames + }) + } + } as Schema) + +type SchemaTyper = <$ATTRIBUTES extends $SchemaAttributeNestedStates = {}>( + attributes: NarrowObject<$ATTRIBUTES> +) => Schema<{ [KEY in keyof $ATTRIBUTES]: FreezeAttribute<$ATTRIBUTES[KEY]> }> + +/** + * Defines an Entity schema + * + * @param attributes Dictionary of warm attributes + * @return Schema + */ +export const schema: SchemaTyper = < + $MAP_ATTRIBUTE_ATTRIBUTES extends $SchemaAttributeNestedStates = {} +>( + attributes: NarrowObject<$MAP_ATTRIBUTE_ATTRIBUTES> +): Schema< + { [KEY in keyof $MAP_ATTRIBUTE_ATTRIBUTES]: FreezeAttribute<$MAP_ATTRIBUTE_ATTRIBUTES[KEY]> } +> => + $schema({ + attributes: {}, + savedAttributeNames: new Set(), + keyAttributeNames: new Set(), + requiredAttributeNames: { + always: new Set(), + atLeastOnce: new Set(), + never: new Set() + } + }).and(attributes) diff --git a/src/v1/schema/typer.unit.test.ts b/src/v1/schema/typer.unit.test.ts new file mode 100644 index 000000000..395e5196e --- /dev/null +++ b/src/v1/schema/typer.unit.test.ts @@ -0,0 +1,156 @@ +import type { A } from 'ts-toolbelt' + +import { schema } from './typer' +import { boolean, binary, number, string, set, list, map } from './attributes' +import type { FreezeAttribute } from './attributes/freeze' + +describe('schema', () => { + it('primitives', () => { + const reqStr = string() + const hidBool = boolean().hidden() + const defNum = number().putDefault(42) + const savedAsBin = binary().savedAs('_b') + const keyStr = string().key() + const enumStr = string().enum('foo', 'bar') + + const sch = schema({ + reqStr, + hidBool, + defNum, + savedAsBin, + keyStr, + enumStr + }) + + const assertSch: A.Contains< + typeof sch, + { + type: 'schema' + attributes: { + reqStr: FreezeAttribute + hidBool: FreezeAttribute + defNum: FreezeAttribute + savedAsBin: FreezeAttribute + keyStr: FreezeAttribute + enumStr: FreezeAttribute + } + } + > = 1 + assertSch + + expect(sch).toMatchObject({ + attributes: { + reqStr: reqStr.freeze('reqStr'), + hidBool: hidBool.freeze('hidBool'), + defNum: defNum.freeze('defNum'), + savedAsBin: savedAsBin.freeze('savedAsBin'), + keyStr: keyStr.freeze('keyStr'), + enumStr: enumStr.freeze('enumStr') + } + }) + }) + + it('maps', () => { + const str = string() + const flatMap = map({ str }) + const nestedMap = map({ + nested: map({ str }) + }) + const reqMap = map({ str }) + const hiddenMap = map({ str }).hidden() + + const sch = schema({ flatMap, nestedMap, reqMap, hiddenMap }) + + const assertSch: A.Contains< + typeof sch, + { + attributes: { + flatMap: FreezeAttribute + nestedMap: FreezeAttribute + reqMap: FreezeAttribute + hiddenMap: FreezeAttribute + } + } + > = 1 + assertSch + + expect(sch).toMatchObject({ + attributes: { + flatMap: flatMap.freeze('flatMap'), + nestedMap: nestedMap.freeze('nestedMap'), + reqMap: reqMap.freeze('reqMap'), + hiddenMap: hiddenMap.freeze('hiddenMap') + } + }) + }) + + it('list', () => { + const str = string() + const optList = list(str).optional() + const nestedList = list(list(str)) + const reqList = list(str) + const hiddenList = list(str).optional().hidden() + + const sch = schema({ + optList, + nestedList, + reqList, + hiddenList + }) + + const assertSch: A.Contains< + typeof sch, + { + attributes: { + optList: FreezeAttribute + nestedList: FreezeAttribute + reqList: FreezeAttribute + hiddenList: FreezeAttribute + } + } + > = 1 + assertSch + + expect(sch).toMatchObject({ + attributes: { + optList: optList.freeze('optList'), + nestedList: nestedList.freeze('nestedList'), + reqList: reqList.freeze('reqList'), + hiddenList: hiddenList.freeze('hiddenList') + } + }) + }) + + it('sets', () => { + const str = string() + const optSet = set(str).optional() + const reqSet = set(str) + const hiddenSet = set(str).optional().hidden() + + const sch = schema({ + optSet, + reqSet, + hiddenSet + }) + + const assertSch: A.Contains< + typeof sch, + { + attributes: { + optSet: FreezeAttribute + reqSet: FreezeAttribute + hiddenSet: FreezeAttribute + } + } + > = 1 + assertSch + + expect(sch).toMatchObject({ + attributes: { + optSet: optSet.freeze('optSet'), + reqSet: reqSet.freeze('reqSet'), + hiddenSet: hiddenSet.freeze('hiddenSet') + } + }) + }) +}) diff --git a/src/v1/schema/utils/isDynamicDefault.ts b/src/v1/schema/utils/isDynamicDefault.ts new file mode 100644 index 000000000..de55a961a --- /dev/null +++ b/src/v1/schema/utils/isDynamicDefault.ts @@ -0,0 +1,7 @@ +import { isFunction } from 'v1/utils/validation' + +import type { AttributeValue } from '../attributes' + +export const isDynamicDefault = ( + defaultValue: unknown +): defaultValue is (input?: unknown) => AttributeValue => isFunction(defaultValue) diff --git a/src/v1/schema/utils/isKeyAttribute.ts b/src/v1/schema/utils/isKeyAttribute.ts new file mode 100644 index 000000000..401224efe --- /dev/null +++ b/src/v1/schema/utils/isKeyAttribute.ts @@ -0,0 +1,3 @@ +import { Attribute } from 'v1/schema' + +export const isKeyAttribute = ({ key }: Attribute): boolean => key diff --git a/src/v1/schema/utils/isPrimitiveAttribute.ts b/src/v1/schema/utils/isPrimitiveAttribute.ts new file mode 100644 index 000000000..15c1f87a0 --- /dev/null +++ b/src/v1/schema/utils/isPrimitiveAttribute.ts @@ -0,0 +1,11 @@ +import type { Attribute, PrimitiveAttribute, PrimitiveAttributeType } from 'v1/schema' + +const primitiveAttributeTypeSet = new Set([ + 'boolean', + 'number', + 'string', + 'binary' +]) + +export const isPrimitiveAttribute = (attribute: Attribute): attribute is PrimitiveAttribute => + primitiveAttributeTypeSet.has(attribute.type as PrimitiveAttributeType) diff --git a/src/v1/schema/utils/isStaticDefault.ts b/src/v1/schema/utils/isStaticDefault.ts new file mode 100644 index 000000000..1d252aed9 --- /dev/null +++ b/src/v1/schema/utils/isStaticDefault.ts @@ -0,0 +1,5 @@ +import type { AttributeValue } from '../attributes' +import { isDynamicDefault } from './isDynamicDefault' + +export const isStaticDefault = (defaultValue: unknown): defaultValue is AttributeValue => + !isDynamicDefault(defaultValue) diff --git a/src/v1/table/class.ts b/src/v1/table/class.ts new file mode 100644 index 000000000..e01ed3f17 --- /dev/null +++ b/src/v1/table/class.ts @@ -0,0 +1,70 @@ +import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb' + +import type { TableOperation } from 'v1/operations/class' +import type { NarrowObject, NarrowObjectRec } from 'v1/types/narrowObject' +import { isString } from 'v1/utils/validation/isString' + +import type { Index, Key } from './types' + +export class TableV2< + PARTITION_KEY extends Key = Key, + SORT_KEY extends Key = Key, + INDEXES extends Record = Key extends PARTITION_KEY ? Record : {}, + ENTITY_ATTRIBUTE_SAVED_AS extends string = Key extends PARTITION_KEY ? string : '_et' +> { + public documentClient: DynamoDBDocumentClient + public name: string | (() => string) + public partitionKey: PARTITION_KEY + public sortKey?: SORT_KEY + public indexes: INDEXES + public entityAttributeSavedAs: ENTITY_ATTRIBUTE_SAVED_AS + + public getName: () => string + public build: = TableOperation>( + tableOperationClass: new (table: this) => TABLE_OPERATION_CLASS + ) => TABLE_OPERATION_CLASS + + /** + * Define a Table + * + * @param documentClient DynamoDBDocumentClient + * @param name string + * @param partitionKey Partition key + * @param sortKey _(optional)_ Sort key + * @param entityAttributeSavedAs _(optional)_ Entity name attribute savedAs (defaults to `'_et'`) + */ + constructor({ + documentClient, + name, + partitionKey, + sortKey, + indexes = {} as INDEXES, + entityAttributeSavedAs = '_et' as ENTITY_ATTRIBUTE_SAVED_AS + }: { + documentClient: DynamoDBDocumentClient + name: string | (() => string) + partitionKey: NarrowObject + sortKey?: NarrowObject + indexes?: NarrowObjectRec + entityAttributeSavedAs?: ENTITY_ATTRIBUTE_SAVED_AS + }) { + this.documentClient = documentClient + this.name = name + this.partitionKey = partitionKey + if (sortKey) { + this.sortKey = sortKey + } + this.indexes = indexes as INDEXES + this.entityAttributeSavedAs = entityAttributeSavedAs + + this.getName = () => { + if (isString(this.name)) { + return this.name + } else { + return this.name() + } + } + + this.build = commandClass => new commandClass(this) as any + } +} diff --git a/src/v1/table/generics/index.ts b/src/v1/table/generics/index.ts new file mode 100644 index 000000000..2543c9694 --- /dev/null +++ b/src/v1/table/generics/index.ts @@ -0,0 +1,2 @@ +export type { PrimaryKey } from './primaryKey' +export type { IndexNames, IndexSchema } from './indexes' diff --git a/src/v1/table/generics/indexes.ts b/src/v1/table/generics/indexes.ts new file mode 100644 index 000000000..de30270ec --- /dev/null +++ b/src/v1/table/generics/indexes.ts @@ -0,0 +1,21 @@ +import type { TableV2 } from '../class' + +/** + * Returns the indexes of a Table + * + * @param TABLE Table + * @return Object + */ +export type IndexNames
= Extract + +/** + * Returns a specific index of a Table + * + * @param TABLE Table + * @param INDEX_NAME String + * @return Object + */ +export type IndexSchema< + TABLE extends TableV2 = TableV2, + INDEX_NAME extends IndexNames
= IndexNames
+> = TABLE['indexes'][INDEX_NAME] diff --git a/src/v1/table/generics/primaryKey.ts b/src/v1/table/generics/primaryKey.ts new file mode 100644 index 000000000..4c252cfa9 --- /dev/null +++ b/src/v1/table/generics/primaryKey.ts @@ -0,0 +1,26 @@ +import type { TableV2 } from '../class' +import type { IndexableKeyType, ResolveIndexableKeyType, Key } from '../types' + +/** + * Returns the TS type of a Table Primary Key + * + * @param TABLE Table + * @return Object + */ +export type PrimaryKey
= TableV2 extends TABLE + ? Record> + : Key extends TABLE['sortKey'] + ? { + [KEY in TABLE['partitionKey']['name']]: ResolveIndexableKeyType + } + : NonNullable extends Key + ? { + [KEY in + | TABLE['partitionKey']['name'] + | NonNullable['name']]: KEY extends TABLE['partitionKey']['name'] + ? ResolveIndexableKeyType + : KEY extends NonNullable['name'] + ? ResolveIndexableKeyType['type']> + : never + } + : never diff --git a/src/v1/table/index.ts b/src/v1/table/index.ts new file mode 100644 index 000000000..a0edfdd04 --- /dev/null +++ b/src/v1/table/index.ts @@ -0,0 +1,3 @@ +export { TableV2 } from './class' +export * from './types' +export * from './generics' diff --git a/src/v1/table/types/entityAttributeSavedAs.ts b/src/v1/table/types/entityAttributeSavedAs.ts new file mode 100644 index 000000000..7793721ba --- /dev/null +++ b/src/v1/table/types/entityAttributeSavedAs.ts @@ -0,0 +1,3 @@ +import { TableV2 } from '../class' + +export type EntityAttributeSavedAs
= TABLE['entityAttributeSavedAs'] diff --git a/src/v1/table/types/index.ts b/src/v1/table/types/index.ts new file mode 100644 index 000000000..80699847d --- /dev/null +++ b/src/v1/table/types/index.ts @@ -0,0 +1,4 @@ +export type { Key } from './key' +export type { IndexableKeyType, ResolveIndexableKeyType } from './keyType' +export type { EntityAttributeSavedAs } from './entityAttributeSavedAs' +export type { Index, LocalIndex, GlobalIndex } from './indexes' diff --git a/src/v1/table/types/indexes.ts b/src/v1/table/types/indexes.ts new file mode 100644 index 000000000..c83faf685 --- /dev/null +++ b/src/v1/table/types/indexes.ts @@ -0,0 +1,22 @@ +import type { Key } from './key' + +export interface LocalIndex { + type: 'local' + partitionKey?: undefined + sortKey: Key +} + +export interface GlobalIndex { + type: 'global' + partitionKey: Key + sortKey: Key +} + +/** + * Define an index of a Table + * + * @param KEY_NAME Key attribute name + * @param KEY_TYPE Key value type + * @return Key + */ +export type Index = LocalIndex | GlobalIndex diff --git a/src/v1/table/types/key.ts b/src/v1/table/types/key.ts new file mode 100644 index 000000000..9cb554fa5 --- /dev/null +++ b/src/v1/table/types/key.ts @@ -0,0 +1,16 @@ +import { IndexableKeyType } from './keyType' + +/** + * Define a partition or sort key of a Table or Table index + * + * @param KEY_NAME Key attribute name + * @param KEY_TYPE Key value type + * @return Key + */ +export interface Key< + KEY_NAME extends string = string, + KEY_TYPE extends IndexableKeyType = IndexableKeyType +> { + name: KEY_NAME + type: KEY_TYPE +} diff --git a/src/v1/table/types/keyType.ts b/src/v1/table/types/keyType.ts new file mode 100644 index 000000000..bdd491a6b --- /dev/null +++ b/src/v1/table/types/keyType.ts @@ -0,0 +1,18 @@ +/** + * Possible Table Key or Index attribute type + */ +export type IndexableKeyType = 'string' | 'binary' | 'number' + +/** + * Returns the corresponding TS type of a Key or Index attribute type + * + * @param KEY_TYPE Attribute Type + * @return Type + */ +export type ResolveIndexableKeyType = KEY_TYPE extends 'string' + ? string + : KEY_TYPE extends 'number' + ? number + : KEY_TYPE extends 'binary' + ? Buffer + : never diff --git a/src/v1/test-tools/index.ts b/src/v1/test-tools/index.ts new file mode 100644 index 000000000..4c0e28abf --- /dev/null +++ b/src/v1/test-tools/index.ts @@ -0,0 +1 @@ +export { mockEntity } from './mockEntity' diff --git a/src/v1/test-tools/mockEntity.ts b/src/v1/test-tools/mockEntity.ts new file mode 100644 index 000000000..6742ee184 --- /dev/null +++ b/src/v1/test-tools/mockEntity.ts @@ -0,0 +1,7 @@ +import type { EntityV2 } from 'v1/entity/class' + +import { MockedEntity } from './mocks/entity' + +export const mockEntity = ( + entity: ENTITY +): MockedEntity => new MockedEntity(entity) diff --git a/src/v1/test-tools/mocks/commandMocker.ts b/src/v1/test-tools/mocks/commandMocker.ts new file mode 100644 index 000000000..50bb5a761 --- /dev/null +++ b/src/v1/test-tools/mocks/commandMocker.ts @@ -0,0 +1,83 @@ +import { isString } from 'v1/utils/validation/isString' + +import type { operationName } from './types' +import { $operationName, $mockedEntity, $mockedImplementations } from './constants' + +// NOTE: Those types come from @aws-sdk +interface Error { + name: string + message: string + stack?: string +} + +interface MetadataBearer { + $metadata: { + httpStatusCode?: number + requestId?: string + extendedRequestId?: string + cfId?: string + attempts?: number + totalRetryDelay?: number + } +} + +interface AwsError + extends Partial<{ name: string; message: string; stack?: string }>, + Partial { + Type?: string + Code?: string + $fault?: 'client' | 'server' + $service?: string +} + +export class OperationMocker { + [$operationName]: OPERATION_TYPE; + [$mockedEntity]: { + [$mockedImplementations]: Partial< + Record RESPONSE> + > + } + + resolve: (response: RESPONSE) => OperationMocker + reject: ( + error?: string | Error | AwsError + ) => OperationMocker + mockImplementation: ( + implementation: (key: INPUT, options?: OPTIONS) => RESPONSE + ) => OperationMocker + + constructor( + operationName: OPERATION_TYPE, + mockedEntity: { + [$mockedImplementations]: Partial< + Record RESPONSE> + > + } + ) { + this[$operationName] = operationName + this[$mockedEntity] = mockedEntity + + this.resolve = response => { + this[$mockedEntity][$mockedImplementations][this[$operationName]] = () => response + return this + } + + this.reject = error => { + this[$mockedEntity][$mockedImplementations][this[$operationName]] = () => { + if (error === undefined || isString(error)) { + throw new Error(error) + } else { + throw error + } + } + + return this + } + + this.mockImplementation = implementation => { + this[$mockedEntity][$mockedImplementations][this[$operationName]] = implementation + + return this + } + } +} diff --git a/src/v1/test-tools/mocks/commandResults.ts b/src/v1/test-tools/mocks/commandResults.ts new file mode 100644 index 000000000..ad544f4d0 --- /dev/null +++ b/src/v1/test-tools/mocks/commandResults.ts @@ -0,0 +1,27 @@ +import { isInteger } from 'v1/utils/validation' + +import { $receivedCommands } from './constants' + +export class CommandResults { + [$receivedCommands]: [input?: INPUT, options?: OPTIONS][] + + count: () => number + args: (at: number) => [input?: INPUT, options?: OPTIONS] | undefined + allArgs: () => [input?: INPUT, options?: OPTIONS][] + + constructor(receivedCommands: [input?: INPUT, options?: OPTIONS][]) { + this[$receivedCommands] = receivedCommands + + this.count = () => this[$receivedCommands].length + + this.args = (at: number) => { + if (!isInteger(at)) { + throw new Error('Please provide an integer when searching for received command arguments') + } + + return this[$receivedCommands][at] + } + + this.allArgs = () => this[$receivedCommands] + } +} diff --git a/src/v1/test-tools/mocks/constants.ts b/src/v1/test-tools/mocks/constants.ts new file mode 100644 index 000000000..89d79971b --- /dev/null +++ b/src/v1/test-tools/mocks/constants.ts @@ -0,0 +1,14 @@ +export const $operationName = Symbol('$operationName') +export type $operationName = typeof $operationName + +export const $originalEntity = Symbol('$originalEntity') +export type $originalEntity = typeof $originalEntity + +export const $mockedEntity = Symbol('$mockedEntity') +export type $mockedEntity = typeof $mockedEntity + +export const $mockedImplementations = Symbol('$mockedImplementations') +export type $mockedImplementations = typeof $mockedImplementations + +export const $receivedCommands = Symbol('$receivedCommands') +export type $receivedCommands = typeof $receivedCommands diff --git a/src/v1/test-tools/mocks/deleteItemCommand.ts b/src/v1/test-tools/mocks/deleteItemCommand.ts new file mode 100644 index 000000000..5958a6ee6 --- /dev/null +++ b/src/v1/test-tools/mocks/deleteItemCommand.ts @@ -0,0 +1,83 @@ +import type { DeleteCommandInput } from '@aws-sdk/lib-dynamodb' + +import type { EntityV2 } from 'v1/entity' +import { DeleteItemCommand, DeleteItemOptions, DeleteItemResponse } from 'v1/operations/deleteItem' +import { $key, $options } from 'v1/operations/deleteItem/command' +import { deleteItemParams } from 'v1/operations/deleteItem/deleteItemParams' +import type { KeyInput } from 'v1/operations/types' +import { $entity } from 'v1/operations/class' +import { DynamoDBToolboxError } from 'v1/errors' + +import type { MockedEntity } from './entity' +import { + $operationName, + $originalEntity, + $mockedEntity, + $mockedImplementations, + $receivedCommands +} from './constants' + +export class DeleteItemCommandMock< + ENTITY extends EntityV2 = EntityV2, + OPTIONS extends DeleteItemOptions = DeleteItemOptions +> implements DeleteItemCommand { + static operationType = 'entity' as const + static operationName = 'delete' as const + static [$operationName] = 'delete' as const; + + [$entity]: ENTITY; + [$key]?: KeyInput + key: (nextKey: KeyInput) => DeleteItemCommandMock; + [$options]: OPTIONS + options: >( + nextOptions: NEXT_OPTIONS + ) => DeleteItemCommandMock; + + [$mockedEntity]: MockedEntity + + constructor( + mockedEntity: MockedEntity, + key?: KeyInput, + options: OPTIONS = {} as OPTIONS + ) { + this[$entity] = mockedEntity[$originalEntity] + this[$mockedEntity] = mockedEntity + this[$key] = key + this[$options] = options + + this.key = nextKey => new DeleteItemCommandMock(this[$mockedEntity], nextKey, this[$options]) + this.options = nextOptions => + new DeleteItemCommandMock(this[$mockedEntity], this[$key], nextOptions) + } + + params = (): DeleteCommandInput => { + if (!this[$key]) { + throw new DynamoDBToolboxError('operations.incompleteCommand', { + message: 'DeleteItemCommand incomplete: Missing "key" property' + }) + } + + return deleteItemParams(this[$entity], this[$key], this[$options]) + } + + send = async (): Promise> => { + this[$mockedEntity][$receivedCommands].delete.push([this[$key], this[$options]]) + + const implementation = this[$mockedEntity][$mockedImplementations].delete + + if (implementation !== undefined) { + if (!this[$key]) { + throw new DynamoDBToolboxError('operations.incompleteCommand', { + message: 'DeleteItemCommand incomplete: Missing "key" property' + }) + } + + return (implementation(this[$key], this[$options]) as unknown) as DeleteItemResponse< + ENTITY, + OPTIONS + > + } + + return new DeleteItemCommand(this[$entity], this[$key], this[$options]).send() + } +} diff --git a/src/v1/test-tools/mocks/entity.ts b/src/v1/test-tools/mocks/entity.ts new file mode 100644 index 000000000..e94e5c368 --- /dev/null +++ b/src/v1/test-tools/mocks/entity.ts @@ -0,0 +1,111 @@ +import type { EntityV2 } from 'v1/entity' +import type { KeyInput } from 'v1/operations' +import { GetItemCommand, GetItemOptions, GetItemResponse } from 'v1/operations/getItem' +import type { GetItemCommandClass } from 'v1/operations/getItem/command' +import { + PutItemCommand, + PutItemInput, + PutItemOptions, + PutItemResponse +} from 'v1/operations/putItem' +import type { PutItemCommandClass } from 'v1/operations/putItem/command' +import { DeleteItemCommand, DeleteItemOptions, DeleteItemResponse } from 'v1/operations/deleteItem' +import type { DeleteItemCommandClass } from 'v1/operations/deleteItem/command' +import { + UpdateItemCommand, + UpdateItemInput, + UpdateItemOptions, + UpdateItemResponse +} from 'v1/operations/updateItem' +import type { UpdateItemCommandClass } from 'v1/operations/updateItem/command' + +import type { OperationClassMocker, OperationClassResults, operationName } from './types' +import { GetItemCommandMock } from './getItemCommand' +import { PutItemCommandMock } from './putItemCommand' +import { DeleteItemCommandMock } from './deleteItemCommand' +import { UpdateItemCommandMock } from './updateItemCommand' +import { OperationMocker } from './commandMocker' +import { CommandResults } from './commandResults' +import { $originalEntity, $mockedImplementations, $receivedCommands } from './constants' + +export class MockedEntity { + [$originalEntity]: ENTITY; + + [$mockedImplementations]: Partial<{ + get: (input: KeyInput, options?: GetItemOptions) => GetItemResponse + put: (input: PutItemInput, options?: PutItemOptions) => PutItemResponse + delete: ( + input: KeyInput, + options?: DeleteItemOptions + ) => DeleteItemResponse + update: ( + input: UpdateItemInput, + options?: UpdateItemOptions + ) => UpdateItemResponse + }>; + [$receivedCommands]: { + get: [input?: KeyInput, options?: GetItemOptions][] + put: [input?: PutItemInput, options?: PutItemOptions][] + delete: [input?: KeyInput, options?: DeleteItemOptions][] + update: [input?: UpdateItemInput, options?: UpdateItemOptions][] + } + reset: () => void + + constructor(entity: ENTITY) { + this[$originalEntity] = entity + + this[$mockedImplementations] = {} + this[$receivedCommands] = { get: [], put: [], delete: [], update: [] } + + this.reset = () => { + this[$mockedImplementations] = {} + this[$receivedCommands] = { get: [], put: [], delete: [], update: [] } + } + + entity.build = command => { + switch (command) { + // @ts-expect-error impossible to fix + case GetItemCommand: + return new GetItemCommandMock(this) as any + // @ts-expect-error impossible to fix + case PutItemCommand: + return new PutItemCommandMock(this) as any + // @ts-expect-error impossible to fix + case DeleteItemCommand: + return new DeleteItemCommandMock(this) as any + // @ts-expect-error impossible to fix + case UpdateItemCommand: + return new UpdateItemCommandMock(this) as any + default: + throw new Error(`Unable to mock entity command: ${String(command)}`) + } + } + } + + on = < + OPERATION_CLASS extends + | GetItemCommandClass + | PutItemCommandClass + | DeleteItemCommandClass + | UpdateItemCommandClass + >( + operation: OPERATION_CLASS + ): OperationClassMocker => + new OperationMocker( + operation.operationName, + this + ) as OperationClassMocker + + received = < + OPERATION_CLASS extends + | GetItemCommandClass + | PutItemCommandClass + | DeleteItemCommandClass + | UpdateItemCommandClass + >( + operation: OPERATION_CLASS + ): OperationClassResults => + new CommandResults( + this[$receivedCommands][operation.operationName] + ) as OperationClassResults +} diff --git a/src/v1/test-tools/mocks/getItemCommand.ts b/src/v1/test-tools/mocks/getItemCommand.ts new file mode 100644 index 000000000..15a32c1be --- /dev/null +++ b/src/v1/test-tools/mocks/getItemCommand.ts @@ -0,0 +1,80 @@ +import type { GetCommandInput } from '@aws-sdk/lib-dynamodb' + +import type { EntityV2 } from 'v1/entity' +import { GetItemCommand, GetItemOptions, GetItemResponse } from 'v1/operations/getItem' +import { $key, $options } from 'v1/operations/getItem/command' +import { getItemParams } from 'v1/operations/getItem/getItemParams' +import type { KeyInput } from 'v1/operations/types' +import { $entity } from 'v1/operations/class' +import { DynamoDBToolboxError } from 'v1/errors' + +import type { MockedEntity } from './entity' +import { + $operationName, + $originalEntity, + $mockedEntity, + $mockedImplementations, + $receivedCommands +} from './constants' + +export class GetItemCommandMock< + ENTITY extends EntityV2 = EntityV2, + OPTIONS extends GetItemOptions = GetItemOptions +> implements GetItemCommand { + static operationType = 'entity' as const + static operationName = 'get' as const + static [$operationName] = 'get' as const; + + [$entity]: ENTITY; + [$key]?: KeyInput + key: (nextKey: KeyInput) => GetItemCommandMock; + [$options]: OPTIONS + options: >( + nextOptions: NEXT_OPTIONS + ) => GetItemCommandMock; + + [$mockedEntity]: MockedEntity + + constructor( + mockedEntity: MockedEntity, + key?: KeyInput, + options: OPTIONS = {} as OPTIONS + ) { + this[$entity] = mockedEntity[$originalEntity] + this[$mockedEntity] = mockedEntity + this[$key] = key + this[$options] = options + + this.key = nextKey => new GetItemCommandMock(this[$mockedEntity], nextKey, this[$options]) + this.options = nextOptions => + new GetItemCommandMock(this[$mockedEntity], this[$key], nextOptions) + } + + params = (): GetCommandInput => { + if (!this[$key]) { + throw new DynamoDBToolboxError('operations.incompleteCommand', { + message: 'GetItemCommand incomplete: Missing "key" property' + }) + } + + return getItemParams(this[$entity], this[$key], this[$options]) + } + + send = async (): Promise> => { + this[$mockedEntity][$receivedCommands].get.push([this[$key], this[$options]]) + + const implementation = this[$mockedEntity][$mockedImplementations].get + + if (implementation !== undefined) { + if (!this[$key]) { + throw new DynamoDBToolboxError('operations.incompleteCommand', { + message: 'GetItemCommand incomplete: Missing "key" property' + }) + } + + return implementation(this[$key], this[$options]) + } + + return new GetItemCommand(this[$entity], this[$key], this[$options]).send() + } +} diff --git a/src/v1/test-tools/mocks/putItemCommand.ts b/src/v1/test-tools/mocks/putItemCommand.ts new file mode 100644 index 000000000..f76b76491 --- /dev/null +++ b/src/v1/test-tools/mocks/putItemCommand.ts @@ -0,0 +1,87 @@ +import type { PutCommandInput } from '@aws-sdk/lib-dynamodb' + +import type { EntityV2 } from 'v1/entity' +import { + PutItemCommand, + PutItemInput, + PutItemOptions, + PutItemResponse +} from 'v1/operations/putItem' +import { putItemParams } from 'v1/operations/putItem/putItemParams' +import { $item, $options } from 'v1/operations/putItem/command' +import { $entity } from 'v1/operations/class' +import { DynamoDBToolboxError } from 'v1/errors' + +import type { MockedEntity } from './entity' +import { + $operationName, + $originalEntity, + $mockedEntity, + $mockedImplementations, + $receivedCommands +} from './constants' + +export class PutItemCommandMock< + ENTITY extends EntityV2 = EntityV2, + OPTIONS extends PutItemOptions = PutItemOptions +> implements PutItemCommand { + static operationType = 'entity' as const + static operationName = 'put' as const + static [$operationName] = 'put' as const; + + [$entity]: ENTITY; + [$item]?: PutItemInput + item: (nextItem: PutItemInput) => PutItemCommandMock; + [$options]: OPTIONS + options: >( + nextOptions: NEXT_OPTIONS + ) => PutItemCommandMock; + + [$mockedEntity]: MockedEntity + + constructor( + mockedEntity: MockedEntity, + item?: PutItemInput, + options: OPTIONS = {} as OPTIONS + ) { + this[$entity] = mockedEntity[$originalEntity] + this[$mockedEntity] = mockedEntity + this[$item] = item + this[$options] = options + + this.item = nextItem => new PutItemCommandMock(this[$mockedEntity], nextItem, this[$options]) + this.options = nextOptions => + new PutItemCommandMock(this[$mockedEntity], this[$item], nextOptions) + } + + params = (): PutCommandInput => { + if (!this[$item]) { + throw new DynamoDBToolboxError('operations.incompleteCommand', { + message: 'PutItemCommand incomplete: Missing "item" property' + }) + } + + return putItemParams(this[$entity], this[$item], this[$options]) + } + + send = async (): Promise> => { + this[$mockedEntity][$receivedCommands].put.push([this[$item], this[$options]]) + + const implementation = this[$mockedEntity][$mockedImplementations].put + + if (implementation !== undefined) { + if (!this[$item]) { + throw new DynamoDBToolboxError('operations.incompleteCommand', { + message: 'PutItemCommand incomplete: Missing "item" property' + }) + } + + return (implementation(this[$item], this[$options]) as unknown) as PutItemResponse< + ENTITY, + OPTIONS + > + } + + return new PutItemCommand(this[$entity], this[$item], this[$options]).send() + } +} diff --git a/src/v1/test-tools/mocks/types.ts b/src/v1/test-tools/mocks/types.ts new file mode 100644 index 000000000..8f44c1436 --- /dev/null +++ b/src/v1/test-tools/mocks/types.ts @@ -0,0 +1,94 @@ +import type { EntityV2 } from 'v1/entity' +import type { KeyInput } from 'v1/operations' +import type { GetItemOptions, GetItemResponse } from 'v1/operations/getItem' +import type { GetItemCommandClass } from 'v1/operations/getItem/command' +import type { PutItemInput, PutItemOptions, PutItemResponse } from 'v1/operations/putItem' +import type { PutItemCommandClass } from 'v1/operations/putItem/command' +import type { DeleteItemOptions, DeleteItemResponse } from 'v1/operations/deleteItem' +import type { DeleteItemCommandClass } from 'v1/operations/deleteItem/command' +import type { + UpdateItemInput, + UpdateItemOptions, + UpdateItemResponse +} from 'v1/operations/updateItem' +import type { UpdateItemCommandClass } from 'v1/operations/updateItem/command' + +import type { GetItemCommandMock } from './getItemCommand' +import type { PutItemCommandMock } from './putItemCommand' +import type { DeleteItemCommandMock } from './deleteItemCommand' +import type { UpdateItemCommandMock } from './updateItemCommand' +import type { $operationName } from './constants' +import type { CommandResults } from './commandResults' +import type { OperationMocker } from './commandMocker' + +type ClassStaticProperties = CLASSES extends infer CLASS + ? { + [KEY in keyof CLASS as KEY extends 'prototype' + ? never + : CLASS[KEY] extends (...args: unknown[]) => unknown + ? never + : KEY]: CLASS[KEY] + } + : never + +type OperationMock = + | typeof GetItemCommandMock + | typeof PutItemCommandMock + | typeof DeleteItemCommandMock + | typeof UpdateItemCommandMock + +export type operationName = ClassStaticProperties[$operationName] + +export type OperationClassResults< + ENTITY extends EntityV2, + OPERATION_CLASS extends + | GetItemCommandClass + | PutItemCommandClass + | DeleteItemCommandClass + | UpdateItemCommandClass +> = OPERATION_CLASS extends GetItemCommandClass + ? CommandResults, GetItemOptions> + : OPERATION_CLASS extends PutItemCommandClass + ? CommandResults, PutItemOptions> + : OPERATION_CLASS extends DeleteItemCommandClass + ? CommandResults, DeleteItemOptions> + : OPERATION_CLASS extends UpdateItemCommandClass + ? CommandResults, UpdateItemOptions> + : never + +export type OperationClassMocker< + ENTITY extends EntityV2, + OPERATION_CLASS extends + | GetItemCommandClass + | PutItemCommandClass + | DeleteItemCommandClass + | UpdateItemCommandClass +> = OPERATION_CLASS extends GetItemCommandClass + ? OperationMocker< + 'get', + KeyInput, + GetItemOptions, + Partial> + > + : OPERATION_CLASS extends PutItemCommandClass + ? OperationMocker< + 'put', + PutItemInput, + PutItemOptions, + Partial> + > + : OPERATION_CLASS extends DeleteItemCommandClass + ? OperationMocker< + 'delete', + KeyInput, + DeleteItemOptions, + Partial> + > + : OPERATION_CLASS extends UpdateItemCommandClass + ? OperationMocker< + 'update', + UpdateItemInput, + UpdateItemOptions, + Partial> + > + : never diff --git a/src/v1/test-tools/mocks/updateItemCommand.ts b/src/v1/test-tools/mocks/updateItemCommand.ts new file mode 100644 index 000000000..19c125391 --- /dev/null +++ b/src/v1/test-tools/mocks/updateItemCommand.ts @@ -0,0 +1,87 @@ +import type { UpdateCommandInput } from '@aws-sdk/lib-dynamodb' + +import type { EntityV2 } from 'v1/entity' +import { + UpdateItemCommand, + UpdateItemInput, + UpdateItemOptions, + UpdateItemResponse +} from 'v1/operations/updateItem' +import { $item, $options } from 'v1/operations/updateItem/command' +import { updateItemParams } from 'v1/operations/updateItem/updateItemParams' +import { $entity } from 'v1/operations/class' +import { DynamoDBToolboxError } from 'v1/errors' + +import type { MockedEntity } from './entity' +import { + $operationName, + $originalEntity, + $mockedEntity, + $mockedImplementations, + $receivedCommands +} from './constants' + +export class UpdateItemCommandMock< + ENTITY extends EntityV2 = EntityV2, + OPTIONS extends UpdateItemOptions = UpdateItemOptions +> implements UpdateItemCommand { + static operationType = 'entity' as const + static operationName = 'update' as const + static [$operationName] = 'update' as const; + + [$entity]: ENTITY; + [$item]?: UpdateItemInput + item: (nextItem: UpdateItemInput) => UpdateItemCommandMock; + [$options]: OPTIONS + options: >( + nextOptions: NEXT_OPTIONS + ) => UpdateItemCommandMock; + + [$mockedEntity]: MockedEntity + + constructor( + mockedEntity: MockedEntity, + item?: UpdateItemInput, + options: OPTIONS = {} as OPTIONS + ) { + this[$entity] = mockedEntity[$originalEntity] + this[$mockedEntity] = mockedEntity + this[$item] = item + this[$options] = options + + this.item = nextItem => new UpdateItemCommandMock(this[$mockedEntity], nextItem, this[$options]) + this.options = nextOptions => + new UpdateItemCommandMock(this[$mockedEntity], this[$item], nextOptions) + } + + params = (): UpdateCommandInput => { + if (!this[$item]) { + throw new DynamoDBToolboxError('operations.incompleteCommand', { + message: 'UpdateItemCommand incomplete: Missing "item" property' + }) + } + + return updateItemParams(this[$entity], this[$item], this[$options]) + } + + send = async (): Promise> => { + this[$mockedEntity][$receivedCommands].update.push([this[$item], this[$options]]) + + const implementation = this[$mockedEntity][$mockedImplementations].update + + if (implementation !== undefined) { + if (!this[$item]) { + throw new DynamoDBToolboxError('operations.incompleteCommand', { + message: 'UpdateItemCommand incomplete: Missing "item" property' + }) + } + + return (implementation(this[$item], this[$options]) as unknown) as UpdateItemResponse< + ENTITY, + OPTIONS + > + } + + return new UpdateItemCommand(this[$entity], this[$item], this[$options]).send() + } +} diff --git a/src/v1/transformers/index.ts b/src/v1/transformers/index.ts new file mode 100644 index 000000000..ca9ed13e6 --- /dev/null +++ b/src/v1/transformers/index.ts @@ -0,0 +1 @@ +export { prefix } from './prefix' diff --git a/src/v1/transformers/prefix.ts b/src/v1/transformers/prefix.ts new file mode 100644 index 000000000..082549134 --- /dev/null +++ b/src/v1/transformers/prefix.ts @@ -0,0 +1,11 @@ +import { Transformer } from 'v1/schema' + +type Prefixer = (prefix: string, options?: { delimiter?: string }) => Transformer + +export const prefix: Prefixer = (prefix, { delimiter = '#' } = {}) => ({ + parse: (inputValue: string) => [prefix, inputValue].join(delimiter), + format: (savedValue: string) => + savedValue.startsWith([prefix, ''].join(delimiter)) + ? savedValue.slice(prefix.length + delimiter.length) + : savedValue +}) diff --git a/src/v1/types/if.ts b/src/v1/types/if.ts new file mode 100644 index 000000000..67646dfba --- /dev/null +++ b/src/v1/types/if.ts @@ -0,0 +1 @@ +export type If = CONDITION extends true ? THEN : ELSE diff --git a/src/v1/types/index.ts b/src/v1/types/index.ts new file mode 100644 index 000000000..343b55d13 --- /dev/null +++ b/src/v1/types/index.ts @@ -0,0 +1,6 @@ +export type { NarrowObject } from './narrowObject' +export type { DefinedProperties, OmitUndefinedProperties } from './omitUndefinedProperties' +export type { Or } from './or' +export type { OptionalizeUndefinableProperties } from './optionalizeUndefinableProperties' +export type { If } from './if' +export type { ValueOrGetter } from './valueOrGetter' diff --git a/src/v1/types/narrowObject.ts b/src/v1/types/narrowObject.ts new file mode 100644 index 000000000..317d0dbe9 --- /dev/null +++ b/src/v1/types/narrowObject.ts @@ -0,0 +1,7 @@ +export type NarrowObject = { + [KEY in keyof OBJECT]: OBJECT[KEY] +} + +export type NarrowObjectRec = { + [KEY in keyof OBJECT]: OBJECT[KEY] extends object ? NarrowObjectRec : OBJECT[KEY] +} diff --git a/src/v1/types/omitUndefinedProperties.ts b/src/v1/types/omitUndefinedProperties.ts new file mode 100644 index 000000000..76595bc01 --- /dev/null +++ b/src/v1/types/omitUndefinedProperties.ts @@ -0,0 +1,7 @@ +export type DefinedProperties> = { + [KEY in keyof OBJECT]: OBJECT[KEY] extends undefined ? never : KEY +}[keyof OBJECT] + +export type OmitUndefinedProperties> = { + [KEY in DefinedProperties]: OBJECT[KEY] +} diff --git a/src/v1/types/optionalizeUndefinableProperties.ts b/src/v1/types/optionalizeUndefinableProperties.ts new file mode 100644 index 000000000..464a4c0c1 --- /dev/null +++ b/src/v1/types/optionalizeUndefinableProperties.ts @@ -0,0 +1,6 @@ +import type { O } from 'ts-toolbelt' + +export type OptionalizeUndefinableProperties< + OBJECT extends Record, + UNDEFINABLE_PROPERTIES_OVERRIDE extends string = never +> = O.Optional | UNDEFINABLE_PROPERTIES_OVERRIDE> diff --git a/src/v1/types/or.ts b/src/v1/types/or.ts new file mode 100644 index 000000000..5979279d2 --- /dev/null +++ b/src/v1/types/or.ts @@ -0,0 +1,5 @@ +export type Or = BOOL_A extends true + ? true + : BOOL_B extends true + ? true + : false diff --git a/src/v1/types/valueOrGetter.ts b/src/v1/types/valueOrGetter.ts new file mode 100644 index 000000000..aa201793d --- /dev/null +++ b/src/v1/types/valueOrGetter.ts @@ -0,0 +1 @@ +export type ValueOrGetter = VALUE | ((...ags: ARGS) => VALUE) diff --git a/src/v1/utils/addProperty.ts b/src/v1/utils/addProperty.ts new file mode 100644 index 000000000..8ba053ef4 --- /dev/null +++ b/src/v1/utils/addProperty.ts @@ -0,0 +1,7 @@ +type PropertyAdder = , NAME extends string, VALUE>( + object: OBJECT, + name: NAME, + value: VALUE +) => Omit & Record + +export const addProperty: PropertyAdder = (object, name, value) => ({ ...object, [name]: value }) diff --git a/src/v1/utils/overwrite.ts b/src/v1/utils/overwrite.ts new file mode 100644 index 000000000..c5307554e --- /dev/null +++ b/src/v1/utils/overwrite.ts @@ -0,0 +1,11 @@ +import type { O } from 'ts-toolbelt' + +type Overwriter = ( + objectA: OBJECT_A, + objectB: OBJECT_B +) => O.Overwrite + +export const overwrite: Overwriter = ( + objectA: OBJECT_A, + objectB: OBJECT_B +) => ({ ...objectA, ...objectB } as O.Overwrite) diff --git a/src/v1/utils/update.ts b/src/v1/utils/update.ts new file mode 100644 index 000000000..8bc806d5f --- /dev/null +++ b/src/v1/utils/update.ts @@ -0,0 +1,13 @@ +import type { O } from 'ts-toolbelt' + +type Updater = ( + object: OBJECT, + property: PROPERTY, + newValue: NEW_VALUE +) => O.Update + +export const update: Updater = ( + object: OBJECT, + property: PROPERTY, + newValue: NEW_VALUE +) => ({ ...object, [property]: newValue } as O.Update) diff --git a/src/v1/utils/validation/index.ts b/src/v1/utils/validation/index.ts new file mode 100644 index 000000000..24a1944e4 --- /dev/null +++ b/src/v1/utils/validation/index.ts @@ -0,0 +1,10 @@ +export * from './isArray' +export * from './isBinary' +export * from './isBoolean' +export * from './isFunction' +export * from './isNumber' +export * from './isInteger' +export * from './isObject' +export * from './isSet' +export * from './isString' +export * from './validatorsByPrimitiveType' diff --git a/src/v1/utils/validation/isArray.ts b/src/v1/utils/validation/isArray.ts new file mode 100644 index 000000000..9e5277783 --- /dev/null +++ b/src/v1/utils/validation/isArray.ts @@ -0,0 +1 @@ +export const isArray = (candidate: unknown): candidate is unknown[] => Array.isArray(candidate) diff --git a/src/v1/utils/validation/isArray.unit.test.ts b/src/v1/utils/validation/isArray.unit.test.ts new file mode 100644 index 000000000..5884eef86 --- /dev/null +++ b/src/v1/utils/validation/isArray.unit.test.ts @@ -0,0 +1,11 @@ +import { isArray } from './isArray' + +describe('isArray', () => { + it('returns true if input is an array', () => { + expect(isArray([1, 2, 3])).toBe(true) + }) + + it('returns false if input is not an array', () => { + expect(isArray('not an array')).toBe(false) + }) +}) diff --git a/src/v1/utils/validation/isBinary.ts b/src/v1/utils/validation/isBinary.ts new file mode 100644 index 000000000..c7b6d47a3 --- /dev/null +++ b/src/v1/utils/validation/isBinary.ts @@ -0,0 +1 @@ +export const isBinary = Buffer.isBuffer diff --git a/src/v1/utils/validation/isBinary.unit.test.ts b/src/v1/utils/validation/isBinary.unit.test.ts new file mode 100644 index 000000000..c3a548107 --- /dev/null +++ b/src/v1/utils/validation/isBinary.unit.test.ts @@ -0,0 +1,11 @@ +import { isBinary } from './isBinary' + +describe('isBinary', () => { + it('returns true if input is a binary', () => { + expect(isBinary(Buffer.from('binary'))).toBe(true) + }) + + it('returns false if input is not a binary', () => { + expect(isBinary('not a binary')).toBe(false) + }) +}) diff --git a/src/v1/utils/validation/isBoolean.ts b/src/v1/utils/validation/isBoolean.ts new file mode 100644 index 000000000..57bcb0bd9 --- /dev/null +++ b/src/v1/utils/validation/isBoolean.ts @@ -0,0 +1,2 @@ +export const isBoolean = (candidate: unknown): candidate is boolean => + typeof candidate === 'boolean' diff --git a/src/v1/utils/validation/isBoolean.unit.test.ts b/src/v1/utils/validation/isBoolean.unit.test.ts new file mode 100644 index 000000000..fd02e4612 --- /dev/null +++ b/src/v1/utils/validation/isBoolean.unit.test.ts @@ -0,0 +1,11 @@ +import { isBoolean } from './isBoolean' + +describe('isBoolean', () => { + it('returns true if input is a boolean', () => { + expect(isBoolean(true)).toBe(true) + }) + + it('returns false if input is not a boolean', () => { + expect(isBoolean('not a boolean')).toBe(false) + }) +}) diff --git a/src/v1/utils/validation/isFunction.ts b/src/v1/utils/validation/isFunction.ts new file mode 100644 index 000000000..cb0f6467b --- /dev/null +++ b/src/v1/utils/validation/isFunction.ts @@ -0,0 +1,2 @@ +export const isFunction = (candidate: unknown): candidate is (...args: unknown[]) => unknown => + typeof candidate === 'function' diff --git a/src/v1/utils/validation/isFunction.unit.test.ts b/src/v1/utils/validation/isFunction.unit.test.ts new file mode 100644 index 000000000..9f5c6dd09 --- /dev/null +++ b/src/v1/utils/validation/isFunction.unit.test.ts @@ -0,0 +1,11 @@ +import { isFunction } from './isFunction' + +describe('isFunction', () => { + it('returns true if input is a function', () => { + expect(isFunction(() => null)).toBe(true) + }) + + it('returns false if input is not a function', () => { + expect(isFunction('not a function')).toBe(false) + }) +}) diff --git a/src/v1/utils/validation/isInteger.ts b/src/v1/utils/validation/isInteger.ts new file mode 100644 index 000000000..31dc69257 --- /dev/null +++ b/src/v1/utils/validation/isInteger.ts @@ -0,0 +1,4 @@ +import { isNumber } from './isNumber' + +export const isInteger = (candidate: unknown): candidate is number => + isNumber(candidate) && Number.isInteger(candidate) diff --git a/src/v1/utils/validation/isInteger.unit.test.ts b/src/v1/utils/validation/isInteger.unit.test.ts new file mode 100644 index 000000000..64f2c0894 --- /dev/null +++ b/src/v1/utils/validation/isInteger.unit.test.ts @@ -0,0 +1,19 @@ +import { isInteger } from './isInteger' + +describe('isInteger', () => { + it('returns true if input is a number', () => { + expect(isInteger(1)).toBe(true) + }) + + it('returns false if input is not a number', () => { + expect(isInteger('not a number')).toBe(false) + }) + + it('returns false if input is NaN', () => { + expect(isInteger(NaN)).toBe(false) + }) + + it('returns false if input is not an integer', () => { + expect(isInteger(1.5)).toBe(false) + }) +}) diff --git a/src/v1/utils/validation/isNumber.ts b/src/v1/utils/validation/isNumber.ts new file mode 100644 index 000000000..1600f34e7 --- /dev/null +++ b/src/v1/utils/validation/isNumber.ts @@ -0,0 +1,2 @@ +export const isNumber = (candidate: unknown): candidate is number => + typeof candidate === 'number' && !isNaN(candidate) diff --git a/src/v1/utils/validation/isNumber.unit.test.ts b/src/v1/utils/validation/isNumber.unit.test.ts new file mode 100644 index 000000000..3b5b2dcf9 --- /dev/null +++ b/src/v1/utils/validation/isNumber.unit.test.ts @@ -0,0 +1,15 @@ +import { isNumber } from './isNumber' + +describe('isNumber', () => { + it('returns true if input is a number', () => { + expect(isNumber(1)).toBe(true) + }) + + it('returns false if input is not a number', () => { + expect(isNumber('not a number')).toBe(false) + }) + + it('returns false if input is NaN', () => { + expect(isNumber(NaN)).toBe(false) + }) +}) diff --git a/src/v1/utils/validation/isObject.ts b/src/v1/utils/validation/isObject.ts new file mode 100644 index 000000000..3cf3a3539 --- /dev/null +++ b/src/v1/utils/validation/isObject.ts @@ -0,0 +1,5 @@ +import { isArray } from './isArray' +import { isSet } from './isSet' + +export const isObject = (candidate: unknown): candidate is Record => + typeof candidate === 'object' && candidate !== null && !isArray(candidate) && !isSet(candidate) diff --git a/src/v1/utils/validation/isObject.unit.test.ts b/src/v1/utils/validation/isObject.unit.test.ts new file mode 100644 index 000000000..172171a24 --- /dev/null +++ b/src/v1/utils/validation/isObject.unit.test.ts @@ -0,0 +1,11 @@ +import { isObject } from './isObject' + +describe('isObject', () => { + it('returns true if input is an object', () => { + expect(isObject({ a: 1, b: 2 })).toBe(true) + }) + + it('returns false if input is not an object', () => { + expect(isObject('not an object')).toBe(false) + }) +}) diff --git a/src/v1/utils/validation/isPrimitive.ts b/src/v1/utils/validation/isPrimitive.ts new file mode 100644 index 000000000..4fdf33e56 --- /dev/null +++ b/src/v1/utils/validation/isPrimitive.ts @@ -0,0 +1,7 @@ +import { isString } from './isString' +import { isNumber } from './isNumber' +import { isBoolean } from './isBoolean' +import { isBinary } from './isBinary' + +export const isPrimitive = (candidate: unknown): candidate is boolean | number | string | Buffer => + isString(candidate) || isNumber(candidate) || isBoolean(candidate) || isBinary(candidate) diff --git a/src/v1/utils/validation/isPrimitive.unit.test.ts b/src/v1/utils/validation/isPrimitive.unit.test.ts new file mode 100644 index 000000000..d9dbf493f --- /dev/null +++ b/src/v1/utils/validation/isPrimitive.unit.test.ts @@ -0,0 +1,27 @@ +import { isPrimitive } from './isPrimitive' + +describe('isPrimitive', () => { + it('returns true if input is a boolean', () => { + expect(isPrimitive(true)).toBe(true) + }) + + it('returns true if input is a number', () => { + expect(isPrimitive(1)).toBe(true) + }) + + it('returns false if input is NaN', () => { + expect(isPrimitive(NaN)).toBe(false) + }) + + it('returns true if input is a string', () => { + expect(isPrimitive('string')).toBe(true) + }) + + it('returns true if input is a binary', () => { + expect(isPrimitive(Buffer.from('binary'))).toBe(true) + }) + + it('returns false if input is not a primitive', () => { + expect(isPrimitive({ foo: 'bar' })).toBe(false) + }) +}) diff --git a/src/v1/utils/validation/isSet.ts b/src/v1/utils/validation/isSet.ts new file mode 100644 index 000000000..63e6f7fc6 --- /dev/null +++ b/src/v1/utils/validation/isSet.ts @@ -0,0 +1 @@ +export const isSet = (candidate: unknown): candidate is Set => candidate instanceof Set diff --git a/src/v1/utils/validation/isSet.unit.test.ts b/src/v1/utils/validation/isSet.unit.test.ts new file mode 100644 index 000000000..672ab99d1 --- /dev/null +++ b/src/v1/utils/validation/isSet.unit.test.ts @@ -0,0 +1,11 @@ +import { isSet } from './isSet' + +describe('isSet', () => { + it('returns true if input is a set', () => { + expect(isSet(new Set())).toBe(true) + }) + + it('returns false if input is not a set', () => { + expect(isSet('not an set')).toBe(false) + }) +}) diff --git a/src/v1/utils/validation/isString.ts b/src/v1/utils/validation/isString.ts new file mode 100644 index 000000000..9b9b16a45 --- /dev/null +++ b/src/v1/utils/validation/isString.ts @@ -0,0 +1 @@ +export const isString = (candidate: unknown): candidate is string => typeof candidate === 'string' diff --git a/src/v1/utils/validation/isString.unit.test.ts b/src/v1/utils/validation/isString.unit.test.ts new file mode 100644 index 000000000..5c026a9cc --- /dev/null +++ b/src/v1/utils/validation/isString.unit.test.ts @@ -0,0 +1,11 @@ +import { isString } from './isString' + +describe('isString', () => { + it('returns true if input is a string', () => { + expect(isString('string')).toBe(true) + }) + + it('returns false if input is not a string', () => { + expect(isString(1)).toBe(false) + }) +}) diff --git a/src/v1/utils/validation/validatorsByPrimitiveType.ts b/src/v1/utils/validation/validatorsByPrimitiveType.ts new file mode 100644 index 000000000..4c5d82a1d --- /dev/null +++ b/src/v1/utils/validation/validatorsByPrimitiveType.ts @@ -0,0 +1,16 @@ +import { PrimitiveAttributeType } from 'v1/schema/attributes/primitive/types' + +import { isBinary } from './isBinary' +import { isBoolean } from './isBoolean' +import { isNumber } from './isNumber' +import { isString } from './isString' + +export const validatorsByPrimitiveType: Record< + PrimitiveAttributeType, + (value: unknown) => boolean +> = { + string: isString, + number: isNumber, + boolean: isBoolean, + binary: isBinary +} diff --git a/src/v1/validation/errors.ts b/src/v1/validation/errors.ts new file mode 100644 index 000000000..43e303424 --- /dev/null +++ b/src/v1/validation/errors.ts @@ -0,0 +1 @@ +export type { ParsingErrorBlueprints } from './parseClonedInput/errors' diff --git a/src/v1/validation/parseClonedInput/any.ts b/src/v1/validation/parseClonedInput/any.ts new file mode 100644 index 000000000..5ebe25220 --- /dev/null +++ b/src/v1/validation/parseClonedInput/any.ts @@ -0,0 +1,16 @@ +import cloneDeep from 'lodash.clonedeep' + +import type { AttributeBasicValue, Extension, AttributeValue } from 'v1/schema' + +export function* parseAnyAttributeClonedInput( + inputValue: AttributeBasicValue +): Generator, AttributeValue> { + const clonedValue = cloneDeep(inputValue) + yield clonedValue + + const parsedValue = clonedValue + yield parsedValue + + const collapsedValue = parsedValue + return collapsedValue +} diff --git a/src/v1/validation/parseClonedInput/anyOf.ts b/src/v1/validation/parseClonedInput/anyOf.ts new file mode 100644 index 000000000..cbf236bc3 --- /dev/null +++ b/src/v1/validation/parseClonedInput/anyOf.ts @@ -0,0 +1,59 @@ +import cloneDeep from 'lodash.clonedeep' + +import type { AnyOfAttribute, AttributeBasicValue, Extension, AttributeValue } from 'v1/schema' +import type { If } from 'v1/types' +import { DynamoDBToolboxError } from 'v1/errors' + +import type { HasExtension } from '../types' +import type { ParsingOptions } from './types' +import { parseAttributeClonedInput } from './attribute' + +export function* parseAnyOfAttributeClonedInput< + INPUT_EXTENSION extends Extension = never, + SCHEMA_EXTENSION extends Extension = INPUT_EXTENSION +>( + attribute: AnyOfAttribute, + inputValue: AttributeBasicValue, + ...[options = {} as ParsingOptions]: If< + HasExtension, + [options: ParsingOptions], + [options?: ParsingOptions] + > +): Generator, AttributeValue> { + let parser: Generator> | undefined = undefined + let _clonedValue: AttributeValue | undefined = undefined + let _parsedValue: AttributeValue | undefined = undefined + + for (const elementAttribute of attribute.elements) { + try { + parser = parseAttributeClonedInput(elementAttribute, inputValue, options) + _clonedValue = parser.next().value + _parsedValue = parser.next().value + break + } catch (error) { + parser = undefined + _clonedValue = undefined + _parsedValue = undefined + continue + } + } + + const clonedValue = _clonedValue ?? cloneDeep(inputValue) + yield clonedValue + + const parsedValue = _parsedValue + if (parser === undefined || parsedValue === undefined) { + throw new DynamoDBToolboxError('parsing.invalidAttributeInput', { + message: `Attribute ${attribute.path} does not match any of the possible sub-types`, + path: attribute.path, + payload: { + received: inputValue + } + }) + } + + yield parsedValue + + const collapsedValue = parser.next().value + return collapsedValue +} diff --git a/src/v1/validation/parseClonedInput/attribute.ts b/src/v1/validation/parseClonedInput/attribute.ts new file mode 100644 index 000000000..e049cb714 --- /dev/null +++ b/src/v1/validation/parseClonedInput/attribute.ts @@ -0,0 +1,103 @@ +import type { RequiredOption, Attribute, Extension, AttributeValue } from 'v1/schema' +import type { If } from 'v1/types' +import { DynamoDBToolboxError } from 'v1/errors' +import { isFunction } from 'v1/utils/validation' + +import type { HasExtension } from '../types' +import type { ParsingOptions, ExtensionParser } from './types' +import { parseAnyAttributeClonedInput } from './any' +import { parsePrimitiveAttributeClonedInput } from './primitive' +import { parseSetAttributeClonedInput } from './set' +import { parseListAttributeClonedInput } from './list' +import { parseMapAttributeClonedInput } from './map' +import { parseRecordAttributeClonedInput } from './record' +import { parseAnyOfAttributeClonedInput } from './anyOf' +import { defaultParseExtension } from './utils' + +const defaultRequiringOptions = new Set(['atLeastOnce', 'always']) + +export function* parseAttributeClonedInput< + INPUT_EXTENSION extends Extension = never, + SCHEMA_EXTENSION extends Extension = INPUT_EXTENSION +>( + attribute: Attribute, + inputValue: AttributeValue | undefined, + ...[options = {} as ParsingOptions]: If< + HasExtension, + [options: ParsingOptions], + [options?: ParsingOptions] + > +): Generator, AttributeValue> { + const { + operationName, + schemaInput, + requiringOptions = defaultRequiringOptions, + /** + * @debt type "Maybe there's a way not to have to cast here" + */ + parseExtension = (defaultParseExtension as unknown) as ExtensionParser< + INPUT_EXTENSION, + SCHEMA_EXTENSION + > + } = options + + let defaultedValue = inputValue + + if (defaultedValue === undefined) { + const operationDefault = attribute.key + ? attribute.defaults.key + : operationName && attribute.defaults[operationName] + + defaultedValue = isFunction(operationDefault) + ? (operationDefault(schemaInput) as AttributeValue | undefined) + : (operationDefault as AttributeValue | undefined) + } + + const { isExtension, extensionParser, basicInput } = parseExtension( + attribute, + defaultedValue, + options + ) + + if (isExtension) { + return yield* extensionParser() + } + + if (basicInput === undefined) { + const clonedValue = undefined + yield clonedValue + + if (requiringOptions.has(attribute.required)) { + throw new DynamoDBToolboxError('parsing.attributeRequired', { + message: `Attribute ${attribute.path} is required`, + path: attribute.path + }) + } + + const parsedValue = clonedValue + yield parsedValue + + const collapsedValue = parsedValue + return collapsedValue + } + + switch (attribute.type) { + case 'any': + return yield* parseAnyAttributeClonedInput(basicInput) + case 'boolean': + case 'binary': + case 'number': + case 'string': + return yield* parsePrimitiveAttributeClonedInput(attribute, basicInput, options) + case 'set': + return yield* parseSetAttributeClonedInput(attribute, basicInput, options) + case 'list': + return yield* parseListAttributeClonedInput(attribute, basicInput, options) + case 'map': + return yield* parseMapAttributeClonedInput(attribute, basicInput, options) + case 'record': + return yield* parseRecordAttributeClonedInput(attribute, basicInput, options) + case 'anyOf': + return yield* parseAnyOfAttributeClonedInput(attribute, basicInput, options) + } +} diff --git a/src/v1/validation/parseClonedInput/doesAttributeMatchFilter.ts b/src/v1/validation/parseClonedInput/doesAttributeMatchFilter.ts new file mode 100644 index 000000000..924a9dde1 --- /dev/null +++ b/src/v1/validation/parseClonedInput/doesAttributeMatchFilter.ts @@ -0,0 +1,11 @@ +import type { Attribute } from 'v1/schema' + +import type { AttributeFilters } from './types' + +export const doesAttributeMatchFilters = ( + attribute: Attribute, + filters: AttributeFilters = {} +): boolean => + Object.entries(filters).every( + ([key, value]) => attribute[key as keyof AttributeFilters] === value + ) diff --git a/src/v1/validation/parseClonedInput/errors.ts b/src/v1/validation/parseClonedInput/errors.ts new file mode 100644 index 000000000..5db382614 --- /dev/null +++ b/src/v1/validation/parseClonedInput/errors.ts @@ -0,0 +1,30 @@ +import type { ErrorBlueprint } from 'v1/errors/blueprint' + +type InvalidItemErrorBlueprint = ErrorBlueprint<{ + code: 'parsing.invalidItem' + hasPath: false + payload: { + received: unknown + expected?: unknown + } +}> + +type AttributeRequiredErrorBlueprint = ErrorBlueprint<{ + code: 'parsing.attributeRequired' + hasPath: true + payload: undefined +}> + +type InvalidAttributeInputErrorBlueprint = ErrorBlueprint<{ + code: 'parsing.invalidAttributeInput' + hasPath: true + payload: { + received: unknown + expected?: unknown + } +}> + +export type ParsingErrorBlueprints = + | InvalidItemErrorBlueprint + | AttributeRequiredErrorBlueprint + | InvalidAttributeInputErrorBlueprint diff --git a/src/v1/validation/parseClonedInput/index.ts b/src/v1/validation/parseClonedInput/index.ts new file mode 100644 index 000000000..150186502 --- /dev/null +++ b/src/v1/validation/parseClonedInput/index.ts @@ -0,0 +1,3 @@ +export { parseSchemaClonedInput } from './schema' +export { parseAttributeClonedInput } from './attribute' +export * from './types' diff --git a/src/v1/validation/parseClonedInput/list.ts b/src/v1/validation/parseClonedInput/list.ts new file mode 100644 index 000000000..516edc083 --- /dev/null +++ b/src/v1/validation/parseClonedInput/list.ts @@ -0,0 +1,60 @@ +import cloneDeep from 'lodash.clonedeep' + +import type { + Extension, + AttributeValue, + ListAttribute, + ListAttributeBasicValue, + AttributeBasicValue +} from 'v1/schema' +import type { If } from 'v1/types' +import { isArray } from 'v1/utils/validation/isArray' +import { DynamoDBToolboxError } from 'v1/errors' + +import type { HasExtension } from '../types' +import type { ParsingOptions } from './types' +import { parseAttributeClonedInput } from './attribute' + +export function* parseListAttributeClonedInput< + INPUT_EXTENSION extends Extension = never, + SCHEMA_EXTENSION extends Extension = INPUT_EXTENSION +>( + listAttribute: ListAttribute, + inputValue: AttributeBasicValue, + ...[options = {} as ParsingOptions]: If< + HasExtension, + [options: ParsingOptions], + [options?: ParsingOptions] + > +): Generator, ListAttributeBasicValue> { + const parsers: Generator, AttributeValue>[] = [] + + const isInputValueArray = isArray(inputValue) + if (isInputValueArray) { + for (const element of inputValue) { + parsers.push(parseAttributeClonedInput(listAttribute.elements, element, options)) + } + } + + const clonedValue = isInputValueArray + ? parsers.map(parser => parser.next().value) + : cloneDeep(inputValue) + yield clonedValue as ListAttributeBasicValue + + if (!isInputValueArray) { + throw new DynamoDBToolboxError('parsing.invalidAttributeInput', { + message: `Attribute ${listAttribute.path} should be a ${listAttribute.type}`, + path: listAttribute.path, + payload: { + received: inputValue, + expected: listAttribute.type + } + }) + } + + const parsedValue = parsers.map(parser => parser.next().value) + yield parsedValue + + const collapsedValue = parsers.map(parser => parser.next().value) + return collapsedValue +} diff --git a/src/v1/validation/parseClonedInput/list.unit.test.ts b/src/v1/validation/parseClonedInput/list.unit.test.ts new file mode 100644 index 000000000..9dc3a7e99 --- /dev/null +++ b/src/v1/validation/parseClonedInput/list.unit.test.ts @@ -0,0 +1,61 @@ +import { DynamoDBToolboxError } from 'v1/errors' +import { list, string } from 'v1/schema' + +import { parseListAttributeClonedInput } from './list' +import * as parseAttributeClonedInputModule from './attribute' + +const parseAttributeClonedInput = jest.spyOn( + parseAttributeClonedInputModule, + 'parseAttributeClonedInput' +) + +const listAttr = list(string()).freeze('path') + +describe('parseListAttributeClonedInput', () => { + beforeEach(() => { + parseAttributeClonedInput.mockClear() + }) + + it('throws an error if input is not a list', () => { + const parser = parseListAttributeClonedInput(listAttr, { foo: 'bar' }) + + const clonedState = parser.next() + expect(clonedState.done).toBe(false) + expect(clonedState.value).toStrictEqual({ foo: 'bar' }) + + const invalidCall = () => { + const parser = parseListAttributeClonedInput(listAttr, { foo: 'bar' }) + parser.next() + parser.next() + } + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow(expect.objectContaining({ code: 'parsing.invalidAttributeInput' })) + }) + + it('applies parseAttributeClonesInput on input elements otherwise (and pass options)', () => { + const options = { some: 'options' } + const parser = parseListAttributeClonedInput( + listAttr, + ['foo', 'bar'], + // @ts-expect-error we don't really care about the type here + options + ) + + const clonedState = parser.next() + expect(clonedState.done).toBe(false) + expect(clonedState.value).toStrictEqual(['foo', 'bar']) + + expect(parseAttributeClonedInput).toHaveBeenCalledTimes(2) + expect(parseAttributeClonedInput).toHaveBeenCalledWith(listAttr.elements, 'foo', options) + expect(parseAttributeClonedInput).toHaveBeenCalledWith(listAttr.elements, 'bar', options) + + const parsedState = parser.next() + expect(parsedState.done).toBe(false) + expect(parsedState.value).toStrictEqual(['foo', 'bar']) + + const collapsedState = parser.next() + expect(collapsedState.done).toBe(true) + expect(collapsedState.value).toStrictEqual(['foo', 'bar']) + }) +}) diff --git a/src/v1/validation/parseClonedInput/map.ts b/src/v1/validation/parseClonedInput/map.ts new file mode 100644 index 000000000..a1655b3f0 --- /dev/null +++ b/src/v1/validation/parseClonedInput/map.ts @@ -0,0 +1,106 @@ +import cloneDeep from 'lodash.clonedeep' + +import type { + MapAttribute, + MapAttributeBasicValue, + AttributeBasicValue, + Extension, + AttributeValue +} from 'v1/schema' +import type { If } from 'v1/types' +import { DynamoDBToolboxError } from 'v1/errors' +import { isObject } from 'v1/utils/validation/isObject' + +import type { HasExtension } from '../types' +import type { ParsingOptions } from './types' +import { parseAttributeClonedInput } from './attribute' +import { doesAttributeMatchFilters } from './doesAttributeMatchFilter' + +export function* parseMapAttributeClonedInput< + INPUT_EXTENSION extends Extension = never, + SCHEMA_EXTENSION extends Extension = INPUT_EXTENSION +>( + mapAttribute: MapAttribute, + inputValue: AttributeBasicValue, + ...[options = {} as ParsingOptions]: If< + HasExtension, + [options: ParsingOptions], + [options?: ParsingOptions] + > +): Generator, MapAttributeBasicValue> { + const { filters } = options + const parsers: Record>> = {} + let additionalAttributeNames: Set = new Set() + + const isInputValueObject = isObject(inputValue) + if (isInputValueObject) { + additionalAttributeNames = new Set(Object.keys(inputValue)) + + Object.entries(mapAttribute.attributes) + .filter(([, attribute]) => doesAttributeMatchFilters(attribute, filters)) + .forEach(([attributeName, attribute]) => { + parsers[attributeName] = parseAttributeClonedInput( + attribute, + inputValue[attributeName], + options + ) + + additionalAttributeNames.delete(attributeName) + }) + } + + const clonedValue = isInputValueObject + ? { + ...Object.fromEntries( + [...additionalAttributeNames.values()].map(attributeName => { + const additionalAttribute = mapAttribute.attributes[attributeName] + + const clonedAttributeValue = + additionalAttribute !== undefined + ? parseAttributeClonedInput( + additionalAttribute, + inputValue[attributeName], + options + ).next().value + : cloneDeep(inputValue[attributeName]) + + return [attributeName, clonedAttributeValue] + }) + ), + ...Object.fromEntries( + Object.entries(parsers) + .map(([attributeName, attribute]) => [attributeName, attribute.next().value]) + .filter(([, attributeValue]) => attributeValue !== undefined) + ) + } + : cloneDeep(inputValue) + yield clonedValue + + if (!isInputValueObject) { + throw new DynamoDBToolboxError('parsing.invalidAttributeInput', { + message: `Attribute ${mapAttribute.path} should be a ${mapAttribute.type}`, + path: mapAttribute.path, + payload: { + received: inputValue, + expected: mapAttribute.type + } + }) + } + + const parsedValue = Object.fromEntries( + Object.entries(parsers) + .map(([attributeName, attribute]) => [attributeName, attribute.next().value]) + .filter(([, attributeValue]) => attributeValue !== undefined) + ) + yield parsedValue + + const collapsedValue = Object.fromEntries( + Object.entries(parsers) + .map(([attributeName, attribute]) => [ + mapAttribute.attributes[attributeName].savedAs ?? attributeName, + attribute.next().value + ]) + .filter(([, attributeValue]) => attributeValue !== undefined) + ) + return collapsedValue +} diff --git a/src/v1/validation/parseClonedInput/map.unit.test.ts b/src/v1/validation/parseClonedInput/map.unit.test.ts new file mode 100644 index 000000000..48ca89f97 --- /dev/null +++ b/src/v1/validation/parseClonedInput/map.unit.test.ts @@ -0,0 +1,61 @@ +import { DynamoDBToolboxError } from 'v1/errors' +import { map, string } from 'v1/schema' + +import { parseMapAttributeClonedInput } from './map' +import * as parseAttributeClonedInputModule from './attribute' + +const parseAttributeClonedInput = jest.spyOn( + parseAttributeClonedInputModule, + 'parseAttributeClonedInput' +) + +const mapAttr = map({ foo: string(), bar: string() }).freeze('path') + +describe('parseMapAttributeClonedInput', () => { + beforeEach(() => { + parseAttributeClonedInput.mockClear() + }) + + it('throws an error if input is not a map', () => { + const parser = parseMapAttributeClonedInput(mapAttr, ['foo', 'bar']) + + const clonedState = parser.next() + expect(clonedState.done).toBe(false) + expect(clonedState.value).toStrictEqual(['foo', 'bar']) + + const invalidCall = () => { + const parser = parseMapAttributeClonedInput(mapAttr, ['foo', 'bar']) + parser.next() + parser.next() + } + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow(expect.objectContaining({ code: 'parsing.invalidAttributeInput' })) + }) + + it('applies parseAttributeClonesInput on input properties otherwise (and pass options)', () => { + const options = { some: 'options' } + const parser = parseMapAttributeClonedInput( + mapAttr, + { foo: 'foo', bar: 'bar' }, + // @ts-expect-error we don't really care about the type here + options + ) + + const clonedState = parser.next() + expect(clonedState.done).toBe(false) + expect(clonedState.value).toStrictEqual({ foo: 'foo', bar: 'bar' }) + + expect(parseAttributeClonedInput).toHaveBeenCalledTimes(2) + expect(parseAttributeClonedInput).toHaveBeenCalledWith(mapAttr.attributes.foo, 'foo', options) + expect(parseAttributeClonedInput).toHaveBeenCalledWith(mapAttr.attributes.bar, 'bar', options) + + const parsedState = parser.next() + expect(parsedState.done).toBe(false) + expect(parsedState.value).toStrictEqual({ foo: 'foo', bar: 'bar' }) + + const collapsedState = parser.next() + expect(collapsedState.done).toBe(true) + expect(collapsedState.value).toStrictEqual({ foo: 'foo', bar: 'bar' }) + }) +}) diff --git a/src/v1/validation/parseClonedInput/primitive.ts b/src/v1/validation/parseClonedInput/primitive.ts new file mode 100644 index 000000000..167c716d1 --- /dev/null +++ b/src/v1/validation/parseClonedInput/primitive.ts @@ -0,0 +1,75 @@ +import cloneDeep from 'lodash.clonedeep' + +import type { + PrimitiveAttribute, + PrimitiveAttributeBasicValue, + AttributeBasicValue, + ResolvedPrimitiveAttribute, + Extension, + Transformer +} from 'v1/schema' +import type { If } from 'v1/types' +import { validatorsByPrimitiveType } from 'v1/utils/validation' +import { DynamoDBToolboxError } from 'v1/errors' + +import type { HasExtension } from '../types' +import type { ParsingOptions } from './types' + +export function* parsePrimitiveAttributeClonedInput< + INPUT_EXTENSION extends Extension = never, + SCHEMA_EXTENSION extends Extension = INPUT_EXTENSION +>( + primitiveAttribute: PrimitiveAttribute, + inputValue: AttributeBasicValue, + ...[options = {} as ParsingOptions]: If< + HasExtension, + [options: ParsingOptions], + [options?: ParsingOptions] + > +): Generator { + const clonedValue = cloneDeep(inputValue) + yield clonedValue as PrimitiveAttributeBasicValue + + const { transform = true } = options + + const validator = validatorsByPrimitiveType[primitiveAttribute.type] + if (!validator(clonedValue)) { + throw new DynamoDBToolboxError('parsing.invalidAttributeInput', { + message: `Attribute ${primitiveAttribute.path} should be a ${primitiveAttribute.type}`, + path: primitiveAttribute.path, + payload: { + received: clonedValue, + expected: primitiveAttribute.type + } + }) + } + + if ( + primitiveAttribute.enum !== undefined && + !primitiveAttribute.enum.includes(clonedValue as ResolvedPrimitiveAttribute) + ) { + throw new DynamoDBToolboxError('parsing.invalidAttributeInput', { + message: `Attribute ${ + primitiveAttribute.path + } should be one of: ${primitiveAttribute.enum.map(String).join(', ')}`, + path: primitiveAttribute.path, + payload: { + received: clonedValue, + expected: primitiveAttribute.enum + } + }) + } + + /** + * @debt type "validator should act as typeguard" + */ + const parsedValue = clonedValue as PrimitiveAttributeBasicValue + yield parsedValue + + const collapsedValue = + transform && primitiveAttribute.transform !== undefined + ? (primitiveAttribute.transform as Transformer).parse(parsedValue) + : parsedValue + + return collapsedValue +} diff --git a/src/v1/validation/parseClonedInput/record.ts b/src/v1/validation/parseClonedInput/record.ts new file mode 100644 index 000000000..7e855b739 --- /dev/null +++ b/src/v1/validation/parseClonedInput/record.ts @@ -0,0 +1,81 @@ +import cloneDeep from 'lodash.clonedeep' + +import type { + RecordAttribute, + RecordAttributeBasicValue, + AttributeBasicValue, + Extension, + AttributeValue +} from 'v1/schema' +import type { If } from 'v1/types' +import { DynamoDBToolboxError } from 'v1/errors' +import { isObject } from 'v1/utils/validation/isObject' + +import type { HasExtension } from '../types' +import type { ParsingOptions } from './types' +import { parseAttributeClonedInput } from './attribute' + +export function* parseRecordAttributeClonedInput< + INPUT_EXTENSION extends Extension = never, + SCHEMA_EXTENSION extends Extension = INPUT_EXTENSION +>( + recordAttribute: RecordAttribute, + inputValue: AttributeBasicValue, + ...[options = {} as ParsingOptions]: If< + HasExtension, + [options: ParsingOptions], + [options?: ParsingOptions] + > +): Generator< + RecordAttributeBasicValue, + RecordAttributeBasicValue +> { + const parsers: [ + Generator, AttributeValue>, + Generator, AttributeValue> + ][] = [] + + const isInputValueObject = isObject(inputValue) + if (isInputValueObject) { + for (const [key, element] of Object.entries(inputValue)) { + parsers.push([ + parseAttributeClonedInput(recordAttribute.keys, key, options), + parseAttributeClonedInput(recordAttribute.elements, element, options) + ]) + } + } + + const clonedValue = isInputValueObject + ? Object.fromEntries( + parsers + .map(([keyParser, elementParser]) => [keyParser.next().value, elementParser.next().value]) + .filter(([, element]) => element !== undefined) + ) + : cloneDeep(inputValue) + yield clonedValue + + if (!isInputValueObject) { + throw new DynamoDBToolboxError('parsing.invalidAttributeInput', { + message: `Attribute ${recordAttribute.path} should be a ${recordAttribute.type}`, + path: recordAttribute.path, + payload: { + received: inputValue, + expected: recordAttribute.type + } + }) + } + + const parsedValue = Object.fromEntries( + parsers + .map(([keyParser, elementParser]) => [keyParser.next().value, elementParser.next().value]) + .filter(([, element]) => element !== undefined) + ) + yield parsedValue + + const collapsedValue = Object.fromEntries( + parsers + .map(([keyParser, elementParser]) => [keyParser.next().value, elementParser.next().value]) + .filter(([, element]) => element !== undefined) + ) + return collapsedValue +} diff --git a/src/v1/validation/parseClonedInput/record.unit.test.ts b/src/v1/validation/parseClonedInput/record.unit.test.ts new file mode 100644 index 000000000..bb53229a7 --- /dev/null +++ b/src/v1/validation/parseClonedInput/record.unit.test.ts @@ -0,0 +1,63 @@ +import { DynamoDBToolboxError } from 'v1/errors' +import { record, string } from 'v1/schema' + +import { parseRecordAttributeClonedInput } from './record' +import * as parseAttributeClonedInputModule from './attribute' + +const parseAttributeClonedInput = jest.spyOn( + parseAttributeClonedInputModule, + 'parseAttributeClonedInput' +) + +const recordAttr = record(string(), string()).freeze('path') + +describe('parseRecordAttributeClonedInput', () => { + beforeEach(() => { + parseAttributeClonedInput.mockClear() + }) + + it('throws an error if input is not a record', () => { + const parser = parseRecordAttributeClonedInput(recordAttr, ['foo', 'bar']) + + const clonedState = parser.next() + expect(clonedState.done).toBe(false) + expect(clonedState.value).toStrictEqual(['foo', 'bar']) + + const invalidCall = () => { + const parser = parseRecordAttributeClonedInput(recordAttr, ['foo', 'bar']) + parser.next() + parser.next() + } + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow(expect.objectContaining({ code: 'parsing.invalidAttributeInput' })) + }) + + it('applies parseAttributeClonesInput on input properties otherwise (and pass options)', () => { + const options = { some: 'options' } + const parser = parseRecordAttributeClonedInput( + recordAttr, + { foo: 'foo1', bar: 'bar1' }, + // @ts-expect-error we don't really care about the type here + options + ) + + const clonedState = parser.next() + expect(clonedState.done).toBe(false) + expect(clonedState.value).toStrictEqual({ foo: 'foo1', bar: 'bar1' }) + + expect(parseAttributeClonedInput).toHaveBeenCalledTimes(4) + expect(parseAttributeClonedInput).toHaveBeenCalledWith(recordAttr.keys, 'foo', options) + expect(parseAttributeClonedInput).toHaveBeenCalledWith(recordAttr.keys, 'bar', options) + expect(parseAttributeClonedInput).toHaveBeenCalledWith(recordAttr.elements, 'foo1', options) + expect(parseAttributeClonedInput).toHaveBeenCalledWith(recordAttr.elements, 'bar1', options) + + const parsedState = parser.next() + expect(parsedState.done).toBe(false) + expect(parsedState.value).toStrictEqual({ foo: 'foo1', bar: 'bar1' }) + + const collapsedState = parser.next() + expect(collapsedState.done).toBe(true) + expect(collapsedState.value).toStrictEqual({ foo: 'foo1', bar: 'bar1' }) + }) +}) diff --git a/src/v1/validation/parseClonedInput/schema.ts b/src/v1/validation/parseClonedInput/schema.ts new file mode 100644 index 000000000..6458f2c43 --- /dev/null +++ b/src/v1/validation/parseClonedInput/schema.ts @@ -0,0 +1,95 @@ +import cloneDeep from 'lodash.clonedeep' + +import type { Schema, Item, Extension, AttributeValue } from 'v1/schema' +import type { If } from 'v1/types' +import { isObject } from 'v1/utils/validation/isObject' +import { DynamoDBToolboxError } from 'v1/errors' + +import type { HasExtension } from '../types' +import type { ParsingOptions } from './types' +import { parseAttributeClonedInput } from './attribute' +import { doesAttributeMatchFilters } from './doesAttributeMatchFilter' + +export function* parseSchemaClonedInput( + schema: Schema, + inputValue: Item, + ...[options = {} as ParsingOptions]: If< + HasExtension, + [options: ParsingOptions], + [options?: ParsingOptions] + > +): Generator, Item> { + const { filters } = options + const parsers: Record>> = {} + let additionalAttributeNames: Set = new Set() + + const isInputValueObject = isObject(inputValue) + if (isInputValueObject) { + additionalAttributeNames = new Set(Object.keys(inputValue)) + + Object.entries(schema.attributes) + .filter(([, attribute]) => doesAttributeMatchFilters(attribute, filters)) + .forEach(([attributeName, attribute]) => { + parsers[attributeName] = parseAttributeClonedInput(attribute, inputValue[attributeName], { + ...options, + schemaInput: inputValue + }) + + additionalAttributeNames.delete(attributeName) + }) + } + + const clonedValue = isInputValueObject + ? { + ...Object.fromEntries( + [...additionalAttributeNames.values()].map(attributeName => { + const additionalAttribute = schema.attributes[attributeName] + + const clonedAttributeValue = + additionalAttribute !== undefined + ? parseAttributeClonedInput( + additionalAttribute, + inputValue[attributeName], + options + ).next().value + : cloneDeep(inputValue[attributeName]) + + return [attributeName, clonedAttributeValue] + }) + ), + ...Object.fromEntries( + Object.entries(parsers) + .map(([attributeName, attribute]) => [attributeName, attribute.next().value]) + .filter(([, attributeValue]) => attributeValue !== undefined) + ) + } + : cloneDeep(inputValue) + yield clonedValue + + if (!isInputValueObject) { + throw new DynamoDBToolboxError('parsing.invalidItem', { + message: 'Items should be objects', + payload: { + received: inputValue, + expected: 'object' + } + }) + } + + const parsedValue = Object.fromEntries( + Object.entries(parsers) + .map(([attributeName, attribute]) => [attributeName, attribute.next().value]) + .filter(([, attributeValue]) => attributeValue !== undefined) + ) + yield parsedValue + + const collapsedValue = Object.fromEntries( + Object.entries(parsers) + .map(([attributeName, attribute]) => [ + schema.attributes[attributeName].savedAs ?? attributeName, + attribute.next().value + ]) + .filter(([, attributeValue]) => attributeValue !== undefined) + ) + return collapsedValue +} diff --git a/src/v1/validation/parseClonedInput/set.ts b/src/v1/validation/parseClonedInput/set.ts new file mode 100644 index 000000000..7be2684e3 --- /dev/null +++ b/src/v1/validation/parseClonedInput/set.ts @@ -0,0 +1,60 @@ +import cloneDeep from 'lodash.clonedeep' + +import type { + SetAttribute, + AttributeValue, + AttributeBasicValue, + SetAttributeBasicValue, + Extension +} from 'v1/schema' +import type { If } from 'v1/types' +import { isSet } from 'v1/utils/validation/isSet' +import { DynamoDBToolboxError } from 'v1/errors' + +import type { HasExtension } from '../types' +import type { ParsingOptions } from './types' +import { parseAttributeClonedInput } from './attribute' + +export function* parseSetAttributeClonedInput< + INPUT_EXTENSION extends Extension = never, + SCHEMA_EXTENSION extends Extension = INPUT_EXTENSION +>( + setAttribute: SetAttribute, + inputValue: AttributeBasicValue, + ...[options = {} as ParsingOptions]: If< + HasExtension, + [options: ParsingOptions], + [options?: ParsingOptions] + > +): Generator, SetAttributeBasicValue> { + const parsers: Generator, AttributeValue>[] = [] + + const isInputValueSet = isSet(inputValue) + if (isInputValueSet) { + for (const element of inputValue.values()) { + parsers.push(parseAttributeClonedInput(setAttribute.elements, element, options)) + } + } + + const clonedValue = isInputValueSet + ? new Set(parsers.map(parser => parser.next().value)) + : cloneDeep(inputValue) + yield clonedValue as SetAttributeBasicValue + + if (!isInputValueSet) { + throw new DynamoDBToolboxError('parsing.invalidAttributeInput', { + message: `Attribute ${setAttribute.path} should be a ${setAttribute.type}`, + path: setAttribute.path, + payload: { + received: inputValue, + expected: setAttribute.type + } + }) + } + + const parsedValue = new Set(parsers.map(parser => parser.next().value)) + yield parsedValue + + const collapsedValue = new Set(parsers.map(parser => parser.next().value)) + return collapsedValue +} diff --git a/src/v1/validation/parseClonedInput/set.unit.test.ts b/src/v1/validation/parseClonedInput/set.unit.test.ts new file mode 100644 index 000000000..e7fc4b34c --- /dev/null +++ b/src/v1/validation/parseClonedInput/set.unit.test.ts @@ -0,0 +1,61 @@ +import { DynamoDBToolboxError } from 'v1/errors' +import { set, string } from 'v1/schema' + +import { parseSetAttributeClonedInput } from './set' +import * as parseAttributeClonedInputModule from './attribute' + +const parseAttributeClonedInput = jest.spyOn( + parseAttributeClonedInputModule, + 'parseAttributeClonedInput' +) + +const setAttr = set(string()).freeze('path') + +describe('parseSetAttributeClonedInput', () => { + beforeEach(() => { + parseAttributeClonedInput.mockClear() + }) + + it('throws an error if input is not a set', () => { + const parser = parseSetAttributeClonedInput(setAttr, { foo: 'bar' }) + + const clonedState = parser.next() + expect(clonedState.done).toBe(false) + expect(clonedState.value).toStrictEqual({ foo: 'bar' }) + + const invalidCall = () => { + const parser = parseSetAttributeClonedInput(setAttr, { foo: 'bar' }) + parser.next() + parser.next() + } + + expect(invalidCall).toThrow(DynamoDBToolboxError) + expect(invalidCall).toThrow(expect.objectContaining({ code: 'parsing.invalidAttributeInput' })) + }) + + it('applies parseAttributeClonesInput on input elements otherwise (and pass options)', () => { + const options = { some: 'options' } + const parser = parseSetAttributeClonedInput( + setAttr, + new Set(['foo', 'bar']), + // @ts-expect-error we don't really care about the type here + options + ) + + const clonedState = parser.next() + expect(clonedState.done).toBe(false) + expect(clonedState.value).toStrictEqual(new Set(['foo', 'bar'])) + + expect(parseAttributeClonedInput).toHaveBeenCalledTimes(2) + expect(parseAttributeClonedInput).toHaveBeenCalledWith(setAttr.elements, 'foo', options) + expect(parseAttributeClonedInput).toHaveBeenCalledWith(setAttr.elements, 'bar', options) + + const parsedState = parser.next() + expect(parsedState.done).toBe(false) + expect(parsedState.value).toStrictEqual(new Set(['foo', 'bar'])) + + const collapsedState = parser.next() + expect(collapsedState.done).toBe(true) + expect(collapsedState.value).toStrictEqual(new Set(['foo', 'bar'])) + }) +}) diff --git a/src/v1/validation/parseClonedInput/types.ts b/src/v1/validation/parseClonedInput/types.ts new file mode 100644 index 000000000..70ca123d0 --- /dev/null +++ b/src/v1/validation/parseClonedInput/types.ts @@ -0,0 +1,54 @@ +import type { + Attribute, + AttributeBasicValue, + AttributeValue, + RequiredOption, + Extension, + Item +} from 'v1/schema' +import type { If } from 'v1/types' + +import type { HasExtension } from '../types' + +export type ExtensionParser< + INPUT_EXTENSION extends Extension, + SCHEMA_EXTENSION extends Extension = INPUT_EXTENSION +> = ( + attribute: Attribute, + input: AttributeValue | undefined, + options: ParsingOptions +) => + | { + isExtension: true + extensionParser: () => Generator< + AttributeValue, + AttributeValue + > + basicInput?: never + } + | { + isExtension: false + extensionParser?: never + basicInput: AttributeBasicValue | undefined + } + +export interface AttributeFilters { + key?: boolean +} + +export type OperationName = 'key' | 'put' | 'update' + +export type ParsingOptions< + INPUT_EXTENSION extends Extension, + SCHEMA_EXTENSION extends Extension = INPUT_EXTENSION +> = { + operationName?: OperationName + requiringOptions?: Set + filters?: AttributeFilters + transform?: boolean + schemaInput?: Item +} & If< + HasExtension, + { parseExtension: ExtensionParser }, + { parseExtension?: never } +> diff --git a/src/v1/validation/parseClonedInput/utils.ts b/src/v1/validation/parseClonedInput/utils.ts new file mode 100644 index 000000000..9fa9740fa --- /dev/null +++ b/src/v1/validation/parseClonedInput/utils.ts @@ -0,0 +1,6 @@ +import type { ExtensionParser } from './types' + +export const defaultParseExtension: ExtensionParser = (_, input) => ({ + isExtension: false, + basicInput: input +}) diff --git a/src/v1/validation/types.ts b/src/v1/validation/types.ts new file mode 100644 index 000000000..89196b53e --- /dev/null +++ b/src/v1/validation/types.ts @@ -0,0 +1,3 @@ +import type { Extension } from 'v1/schema' + +export type HasExtension = [EXTENSION] extends [never] ? false : true diff --git a/tsconfig.json b/tsconfig.json index b817efa81..f17726b7b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,8 +19,15 @@ "lib": ["esnext"], "types": ["node", "jest"], "allowSyntheticDefaultImports": true, - "resolveJsonModule": true + "resolveJsonModule": true, + "paths": { + "v1": ["v1"], + "v1/*": ["v1/*"] + } }, "compileOnSave": false, - "exclude": ["node_modules", "dist", "**/*.test-d.ts"] + "exclude": ["node_modules", "dist", "**/*.test-d.ts"], + "ts-node": { + "require": ["tsconfig-paths/register"] + } } diff --git a/tsconfig.v1-build.json b/tsconfig.v1-build.json new file mode 100644 index 000000000..07899b8c1 --- /dev/null +++ b/tsconfig.v1-build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.v1.json", + "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test-d.ts"] +} diff --git a/tsconfig.v1.json b/tsconfig.v1.json new file mode 100644 index 000000000..4afc9e294 --- /dev/null +++ b/tsconfig.v1.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "baseUrl": "src", + "rootDir": "src/v1", + "paths": { + "v1": ["v1"], + "v1/*": ["v1/*"] + } + }, + "include": ["src/v1/**/*"] +} diff --git a/yarn.lock b/yarn.lock deleted file mode 100644 index efd2a7c2f..000000000 --- a/yarn.lock +++ /dev/null @@ -1,4216 +0,0 @@ -# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. -# yarn lockfile v1 - - -"@ampproject/remapping@^2.2.0": - version "2.2.1" - resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.2.1.tgz#99e8e11851128b8702cd57c33684f1d0f260b630" - integrity sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg== - dependencies: - "@jridgewell/gen-mapping" "^0.3.0" - "@jridgewell/trace-mapping" "^0.3.9" - -"@aws-crypto/ie11-detection@^3.0.0": - version "3.0.0" - resolved "https://registry.yarnpkg.com/@aws-crypto/ie11-detection/-/ie11-detection-3.0.0.tgz#640ae66b4ec3395cee6a8e94ebcd9f80c24cd688" - integrity sha512-341lBBkiY1DfDNKai/wXM3aujNBkXR7tq1URPQDL9wi3AUbI80NR74uF1TXHMm7po1AcnFk8iu2S2IeU/+/A+Q== - dependencies: - tslib "^1.11.1" - -"@aws-crypto/sha256-browser@3.0.0": - version "3.0.0" - resolved "https://registry.yarnpkg.com/@aws-crypto/sha256-browser/-/sha256-browser-3.0.0.tgz#05f160138ab893f1c6ba5be57cfd108f05827766" - integrity sha512-8VLmW2B+gjFbU5uMeqtQM6Nj0/F1bro80xQXCW6CQBWgosFWXTx77aeOF5CAIAmbOK64SdMBJdNr6J41yP5mvQ== - dependencies: - "@aws-crypto/ie11-detection" "^3.0.0" - "@aws-crypto/sha256-js" "^3.0.0" - "@aws-crypto/supports-web-crypto" "^3.0.0" - "@aws-crypto/util" "^3.0.0" - "@aws-sdk/types" "^3.222.0" - "@aws-sdk/util-locate-window" "^3.0.0" - "@aws-sdk/util-utf8-browser" "^3.0.0" - tslib "^1.11.1" - -"@aws-crypto/sha256-js@3.0.0", "@aws-crypto/sha256-js@^3.0.0": - version "3.0.0" - resolved "https://registry.yarnpkg.com/@aws-crypto/sha256-js/-/sha256-js-3.0.0.tgz#f06b84d550d25521e60d2a0e2a90139341e007c2" - integrity sha512-PnNN7os0+yd1XvXAy23CFOmTbMaDxgxXtTKHybrJ39Y8kGzBATgBFibWJKH6BhytLI/Zyszs87xCOBNyBig6vQ== - dependencies: - "@aws-crypto/util" "^3.0.0" - "@aws-sdk/types" "^3.222.0" - tslib "^1.11.1" - -"@aws-crypto/supports-web-crypto@^3.0.0": - version "3.0.0" - resolved "https://registry.yarnpkg.com/@aws-crypto/supports-web-crypto/-/supports-web-crypto-3.0.0.tgz#5d1bf825afa8072af2717c3e455f35cda0103ec2" - integrity sha512-06hBdMwUAb2WFTuGG73LSC0wfPu93xWwo5vL2et9eymgmu3Id5vFAHBbajVWiGhPO37qcsdCap/FqXvJGJWPIg== - dependencies: - tslib "^1.11.1" - -"@aws-crypto/util@^3.0.0": - version "3.0.0" - resolved "https://registry.yarnpkg.com/@aws-crypto/util/-/util-3.0.0.tgz#1c7ca90c29293f0883468ad48117937f0fe5bfb0" - integrity sha512-2OJlpeJpCR48CC8r+uKVChzs9Iungj9wkZrl8Z041DWEWvyIHILYKCPNzJghKsivj+S3mLo6BVc7mBNzdxA46w== - dependencies: - "@aws-sdk/types" "^3.222.0" - "@aws-sdk/util-utf8-browser" "^3.0.0" - tslib "^1.11.1" - -"@aws-sdk/abort-controller@3.310.0": - version "3.310.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/abort-controller/-/abort-controller-3.310.0.tgz#0da2d29b823daa03b7c1f0b43de1f030583b4f51" - integrity sha512-v1zrRQxDLA1MdPim159Vx/CPHqsB4uybSxRi1CnfHO5ZjHryx3a5htW2gdGAykVCul40+yJXvfpufMrELVxH+g== - dependencies: - "@aws-sdk/types" "3.310.0" - tslib "^2.5.0" - -"@aws-sdk/client-dynamodb@^3.287.0": - version "3.312.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/client-dynamodb/-/client-dynamodb-3.312.0.tgz#9c254b0331c2752af0dd7108d7f27e8a7dac5368" - integrity sha512-3R7vXifiQbuxfd6YqvOsea6C1R9NfKnJsrSfpXAFrCiANsj8fHeNKwAvXcIfI/uppg5JgRE65CNtGdbyLAUKqQ== - dependencies: - "@aws-crypto/sha256-browser" "3.0.0" - "@aws-crypto/sha256-js" "3.0.0" - "@aws-sdk/client-sts" "3.312.0" - "@aws-sdk/config-resolver" "3.310.0" - "@aws-sdk/credential-provider-node" "3.310.0" - "@aws-sdk/fetch-http-handler" "3.310.0" - "@aws-sdk/hash-node" "3.310.0" - "@aws-sdk/invalid-dependency" "3.310.0" - "@aws-sdk/middleware-content-length" "3.310.0" - "@aws-sdk/middleware-endpoint" "3.310.0" - "@aws-sdk/middleware-endpoint-discovery" "3.310.0" - "@aws-sdk/middleware-host-header" "3.310.0" - "@aws-sdk/middleware-logger" "3.310.0" - "@aws-sdk/middleware-recursion-detection" "3.310.0" - "@aws-sdk/middleware-retry" "3.310.0" - "@aws-sdk/middleware-serde" "3.310.0" - "@aws-sdk/middleware-signing" "3.310.0" - "@aws-sdk/middleware-stack" "3.310.0" - "@aws-sdk/middleware-user-agent" "3.310.0" - "@aws-sdk/node-config-provider" "3.310.0" - "@aws-sdk/node-http-handler" "3.310.0" - "@aws-sdk/protocol-http" "3.310.0" - "@aws-sdk/smithy-client" "3.310.0" - "@aws-sdk/types" "3.310.0" - "@aws-sdk/url-parser" "3.310.0" - "@aws-sdk/util-base64" "3.310.0" - "@aws-sdk/util-body-length-browser" "3.310.0" - "@aws-sdk/util-body-length-node" "3.310.0" - "@aws-sdk/util-defaults-mode-browser" "3.310.0" - "@aws-sdk/util-defaults-mode-node" "3.310.0" - "@aws-sdk/util-endpoints" "3.310.0" - "@aws-sdk/util-retry" "3.310.0" - "@aws-sdk/util-user-agent-browser" "3.310.0" - "@aws-sdk/util-user-agent-node" "3.310.0" - "@aws-sdk/util-utf8" "3.310.0" - "@aws-sdk/util-waiter" "3.310.0" - tslib "^2.5.0" - uuid "^8.3.2" - -"@aws-sdk/client-sso-oidc@3.310.0": - version "3.310.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.310.0.tgz#f71eeb9cc73c13661728cf88d8513b0209b6d265" - integrity sha512-3GKaRSfMD3OiYWGa+qg5KvJw0nLV0Vu7zRiulLuKDvgmWw3SNJKn3frWlmq/bKFUKahLsV8zozbeJItxtKAD6g== - dependencies: - "@aws-crypto/sha256-browser" "3.0.0" - "@aws-crypto/sha256-js" "3.0.0" - "@aws-sdk/config-resolver" "3.310.0" - "@aws-sdk/fetch-http-handler" "3.310.0" - "@aws-sdk/hash-node" "3.310.0" - "@aws-sdk/invalid-dependency" "3.310.0" - "@aws-sdk/middleware-content-length" "3.310.0" - "@aws-sdk/middleware-endpoint" "3.310.0" - "@aws-sdk/middleware-host-header" "3.310.0" - "@aws-sdk/middleware-logger" "3.310.0" - "@aws-sdk/middleware-recursion-detection" "3.310.0" - "@aws-sdk/middleware-retry" "3.310.0" - "@aws-sdk/middleware-serde" "3.310.0" - "@aws-sdk/middleware-stack" "3.310.0" - "@aws-sdk/middleware-user-agent" "3.310.0" - "@aws-sdk/node-config-provider" "3.310.0" - "@aws-sdk/node-http-handler" "3.310.0" - "@aws-sdk/protocol-http" "3.310.0" - "@aws-sdk/smithy-client" "3.310.0" - "@aws-sdk/types" "3.310.0" - "@aws-sdk/url-parser" "3.310.0" - "@aws-sdk/util-base64" "3.310.0" - "@aws-sdk/util-body-length-browser" "3.310.0" - "@aws-sdk/util-body-length-node" "3.310.0" - "@aws-sdk/util-defaults-mode-browser" "3.310.0" - "@aws-sdk/util-defaults-mode-node" "3.310.0" - "@aws-sdk/util-endpoints" "3.310.0" - "@aws-sdk/util-retry" "3.310.0" - "@aws-sdk/util-user-agent-browser" "3.310.0" - "@aws-sdk/util-user-agent-node" "3.310.0" - "@aws-sdk/util-utf8" "3.310.0" - tslib "^2.5.0" - -"@aws-sdk/client-sso@3.310.0": - version "3.310.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/client-sso/-/client-sso-3.310.0.tgz#1ead31442c34ed660479ea9317faab4f1fa47130" - integrity sha512-netFap3Mp9I7bzAjsswHPA5WEbQtNMmXvW9/IVb7tmf85/esXCWindtyI43e/Xerut9ZVyEACPBFn30CLLE2xQ== - dependencies: - "@aws-crypto/sha256-browser" "3.0.0" - "@aws-crypto/sha256-js" "3.0.0" - "@aws-sdk/config-resolver" "3.310.0" - "@aws-sdk/fetch-http-handler" "3.310.0" - "@aws-sdk/hash-node" "3.310.0" - "@aws-sdk/invalid-dependency" "3.310.0" - "@aws-sdk/middleware-content-length" "3.310.0" - "@aws-sdk/middleware-endpoint" "3.310.0" - "@aws-sdk/middleware-host-header" "3.310.0" - "@aws-sdk/middleware-logger" "3.310.0" - "@aws-sdk/middleware-recursion-detection" "3.310.0" - "@aws-sdk/middleware-retry" "3.310.0" - "@aws-sdk/middleware-serde" "3.310.0" - "@aws-sdk/middleware-stack" "3.310.0" - "@aws-sdk/middleware-user-agent" "3.310.0" - "@aws-sdk/node-config-provider" "3.310.0" - "@aws-sdk/node-http-handler" "3.310.0" - "@aws-sdk/protocol-http" "3.310.0" - "@aws-sdk/smithy-client" "3.310.0" - "@aws-sdk/types" "3.310.0" - "@aws-sdk/url-parser" "3.310.0" - "@aws-sdk/util-base64" "3.310.0" - "@aws-sdk/util-body-length-browser" "3.310.0" - "@aws-sdk/util-body-length-node" "3.310.0" - "@aws-sdk/util-defaults-mode-browser" "3.310.0" - "@aws-sdk/util-defaults-mode-node" "3.310.0" - "@aws-sdk/util-endpoints" "3.310.0" - "@aws-sdk/util-retry" "3.310.0" - "@aws-sdk/util-user-agent-browser" "3.310.0" - "@aws-sdk/util-user-agent-node" "3.310.0" - "@aws-sdk/util-utf8" "3.310.0" - tslib "^2.5.0" - -"@aws-sdk/client-sts@3.312.0": - version "3.312.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/client-sts/-/client-sts-3.312.0.tgz#7b49a04bab2d12a8ca566ef579fd887b71986498" - integrity sha512-t0U7vRvWaMjrzBUo6tPrHe6HE97Blqx+b4GOjFbcbLtzxLlcRfhnWJik0Lp8hJtVqzNoN5mL4OeYgK7CRpL/Sw== - dependencies: - "@aws-crypto/sha256-browser" "3.0.0" - "@aws-crypto/sha256-js" "3.0.0" - "@aws-sdk/config-resolver" "3.310.0" - "@aws-sdk/credential-provider-node" "3.310.0" - "@aws-sdk/fetch-http-handler" "3.310.0" - "@aws-sdk/hash-node" "3.310.0" - "@aws-sdk/invalid-dependency" "3.310.0" - "@aws-sdk/middleware-content-length" "3.310.0" - "@aws-sdk/middleware-endpoint" "3.310.0" - "@aws-sdk/middleware-host-header" "3.310.0" - "@aws-sdk/middleware-logger" "3.310.0" - "@aws-sdk/middleware-recursion-detection" "3.310.0" - "@aws-sdk/middleware-retry" "3.310.0" - "@aws-sdk/middleware-sdk-sts" "3.310.0" - "@aws-sdk/middleware-serde" "3.310.0" - "@aws-sdk/middleware-signing" "3.310.0" - "@aws-sdk/middleware-stack" "3.310.0" - "@aws-sdk/middleware-user-agent" "3.310.0" - "@aws-sdk/node-config-provider" "3.310.0" - "@aws-sdk/node-http-handler" "3.310.0" - "@aws-sdk/protocol-http" "3.310.0" - "@aws-sdk/smithy-client" "3.310.0" - "@aws-sdk/types" "3.310.0" - "@aws-sdk/url-parser" "3.310.0" - "@aws-sdk/util-base64" "3.310.0" - "@aws-sdk/util-body-length-browser" "3.310.0" - "@aws-sdk/util-body-length-node" "3.310.0" - "@aws-sdk/util-defaults-mode-browser" "3.310.0" - "@aws-sdk/util-defaults-mode-node" "3.310.0" - "@aws-sdk/util-endpoints" "3.310.0" - "@aws-sdk/util-retry" "3.310.0" - "@aws-sdk/util-user-agent-browser" "3.310.0" - "@aws-sdk/util-user-agent-node" "3.310.0" - "@aws-sdk/util-utf8" "3.310.0" - fast-xml-parser "4.1.2" - tslib "^2.5.0" - -"@aws-sdk/config-resolver@3.310.0": - version "3.310.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/config-resolver/-/config-resolver-3.310.0.tgz#c02dce96546d5cd25551bc89907b27224e16ca7f" - integrity sha512-8vsT+/50lOqfDxka9m/rRt6oxv1WuGZoP8oPMk0Dt+TxXMbAzf4+rejBgiB96wshI1k3gLokYRjSQZn+dDtT8g== - dependencies: - "@aws-sdk/types" "3.310.0" - "@aws-sdk/util-config-provider" "3.310.0" - "@aws-sdk/util-middleware" "3.310.0" - tslib "^2.5.0" - -"@aws-sdk/credential-provider-env@3.310.0": - version "3.310.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-env/-/credential-provider-env-3.310.0.tgz#c52694fb276341db6ce4e816cf9ca90fa5830dad" - integrity sha512-vvIPQpI16fj95xwS7M3D48F7QhZJBnnCgB5lR+b7So+vsG9ibm1mZRVGzVpdxCvgyOhHFbvrby9aalNJmmIP1A== - dependencies: - "@aws-sdk/property-provider" "3.310.0" - "@aws-sdk/types" "3.310.0" - tslib "^2.5.0" - -"@aws-sdk/credential-provider-imds@3.310.0": - version "3.310.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-imds/-/credential-provider-imds-3.310.0.tgz#d8fb1223fee7e289a81e28177fe55dedf4d2745e" - integrity sha512-baxK7Zp6dai5AGW01FIW27xS2KAaPUmKLIXv5SvFYsUgXXvNW55im4uG3b+2gA0F7V+hXvVBH08OEqmwW6we5w== - dependencies: - "@aws-sdk/node-config-provider" "3.310.0" - "@aws-sdk/property-provider" "3.310.0" - "@aws-sdk/types" "3.310.0" - "@aws-sdk/url-parser" "3.310.0" - tslib "^2.5.0" - -"@aws-sdk/credential-provider-ini@3.310.0": - version "3.310.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.310.0.tgz#c317c803b78d6b322a440de15069b35b88c737e5" - integrity sha512-gtRz7I+4BBpwZ3tc6UIt5lQuiAFnkpOibxHh95x1M6HDxBjm+uqD6RPZYVH+dULZPYXOtOTsHV0IGjrcV0sSRg== - dependencies: - "@aws-sdk/credential-provider-env" "3.310.0" - "@aws-sdk/credential-provider-imds" "3.310.0" - "@aws-sdk/credential-provider-process" "3.310.0" - "@aws-sdk/credential-provider-sso" "3.310.0" - "@aws-sdk/credential-provider-web-identity" "3.310.0" - "@aws-sdk/property-provider" "3.310.0" - "@aws-sdk/shared-ini-file-loader" "3.310.0" - "@aws-sdk/types" "3.310.0" - tslib "^2.5.0" - -"@aws-sdk/credential-provider-node@3.310.0": - version "3.310.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-node/-/credential-provider-node-3.310.0.tgz#e4f69cf95e839c626c41e23b1d8b3cd24c667d8e" - integrity sha512-FrOztUcOq2Sp32xGtJvxfvdlmuAeoxIu/AElHzV1bkx6Pzo9DkQBhXrSQ+JFSpI++weOD4ZGFhAvgbgUOT4VAg== - dependencies: - "@aws-sdk/credential-provider-env" "3.310.0" - "@aws-sdk/credential-provider-imds" "3.310.0" - "@aws-sdk/credential-provider-ini" "3.310.0" - "@aws-sdk/credential-provider-process" "3.310.0" - "@aws-sdk/credential-provider-sso" "3.310.0" - "@aws-sdk/credential-provider-web-identity" "3.310.0" - "@aws-sdk/property-provider" "3.310.0" - "@aws-sdk/shared-ini-file-loader" "3.310.0" - "@aws-sdk/types" "3.310.0" - tslib "^2.5.0" - -"@aws-sdk/credential-provider-process@3.310.0": - version "3.310.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-process/-/credential-provider-process-3.310.0.tgz#0b2ee77f0c48262442d2768044d72332a4ad8884" - integrity sha512-h73sg6GPMUWC+3zMCbA1nZ2O03nNJt7G96JdmnantiXBwHpRKWW8nBTLzx5uhXn6hTuTaoQRP/P+oxQJKYdMmA== - dependencies: - "@aws-sdk/property-provider" "3.310.0" - "@aws-sdk/shared-ini-file-loader" "3.310.0" - "@aws-sdk/types" "3.310.0" - tslib "^2.5.0" - -"@aws-sdk/credential-provider-sso@3.310.0": - version "3.310.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.310.0.tgz#86ab095ede5024a4e16aabaf3b2fa92d61656b8d" - integrity sha512-nXkpT8mrM/wRqSiz/a4p9U2UrOKyfZXhbPHIHyQj8K+uLjsYS+WPuH287J4A5Q57A6uarTrj5RjHmVeZVLaHmg== - dependencies: - "@aws-sdk/client-sso" "3.310.0" - "@aws-sdk/property-provider" "3.310.0" - "@aws-sdk/shared-ini-file-loader" "3.310.0" - "@aws-sdk/token-providers" "3.310.0" - "@aws-sdk/types" "3.310.0" - tslib "^2.5.0" - -"@aws-sdk/credential-provider-web-identity@3.310.0": - version "3.310.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.310.0.tgz#c9fa09b0068027e58d31178e3fa06bf4e9ae9d36" - integrity sha512-H4SzuZXILNhK6/IR1uVvsUDZvzc051hem7GLyYghBCu8mU+tq28YhKE8MfSroi6eL2e5Vujloij1OM2EQQkPkw== - dependencies: - "@aws-sdk/property-provider" "3.310.0" - "@aws-sdk/types" "3.310.0" - tslib "^2.5.0" - -"@aws-sdk/endpoint-cache@3.310.0": - version "3.310.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/endpoint-cache/-/endpoint-cache-3.310.0.tgz#e6f84bfcd55462966811390ef797145559bab15a" - integrity sha512-y3wipforet41EDTI0vnzxILqwAGll1KfI5qcdX9pXF/WF1f+3frcOtPiWtQEZQpy4czRogKm3BHo70QBYAZxlQ== - dependencies: - mnemonist "0.38.3" - tslib "^2.5.0" - -"@aws-sdk/fetch-http-handler@3.310.0": - version "3.310.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/fetch-http-handler/-/fetch-http-handler-3.310.0.tgz#f31006b7b3103683d72e177cd27d80354f7a37c4" - integrity sha512-Bi9vIwzdkw1zMcvi/zGzlWS9KfIEnAq4NNhsnCxbQ4OoIRU9wvU+WGZdBBhxg0ZxZmpp1j1aZhU53lLjA07MHw== - dependencies: - "@aws-sdk/protocol-http" "3.310.0" - "@aws-sdk/querystring-builder" "3.310.0" - "@aws-sdk/types" "3.310.0" - "@aws-sdk/util-base64" "3.310.0" - tslib "^2.5.0" - -"@aws-sdk/hash-node@3.310.0": - version "3.310.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/hash-node/-/hash-node-3.310.0.tgz#4c1c89b9a2da3bb9783de84f0b762cc055b90d67" - integrity sha512-NvE2fhRc8GRwCXBfDehxVAWCmVwVMILliAKVPAEr4yz2CkYs0tqU51S48x23dtna07H4qHtgpeNqVTthcIQOEQ== - dependencies: - "@aws-sdk/types" "3.310.0" - "@aws-sdk/util-buffer-from" "3.310.0" - "@aws-sdk/util-utf8" "3.310.0" - tslib "^2.5.0" - -"@aws-sdk/invalid-dependency@3.310.0": - version "3.310.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/invalid-dependency/-/invalid-dependency-3.310.0.tgz#b96da9b9f63b12d1c390f9a06eeb28840fcb5b3c" - integrity sha512-1s5RG5rSPXoa/aZ/Kqr5U/7lqpx+Ry81GprQ2bxWqJvWQIJ0IRUwo5pk8XFxbKVr/2a+4lZT/c3OGoBOM1yRRA== - dependencies: - "@aws-sdk/types" "3.310.0" - tslib "^2.5.0" - -"@aws-sdk/is-array-buffer@3.310.0": - version "3.310.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/is-array-buffer/-/is-array-buffer-3.310.0.tgz#f87a79f1b858c88744f07e8d8d0a791df204017e" - integrity sha512-urnbcCR+h9NWUnmOtet/s4ghvzsidFmspfhYaHAmSRdy9yDjdjBJMFjjsn85A1ODUktztm+cVncXjQ38WCMjMQ== - dependencies: - tslib "^2.5.0" - -"@aws-sdk/lib-dynamodb@^3.287.0": - version "3.312.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/lib-dynamodb/-/lib-dynamodb-3.312.0.tgz#6e400b8cabb5752dc8e883a8767e4185024dd1f8" - integrity sha512-+MfR1KwX36O8C9e+rPUb70k9qrtcuXjHe5zYu9dCalMpWf6r1rrTMyy7MkQrCvUr26+Yrc01Y1OgyLH5XfrURw== - dependencies: - "@aws-sdk/util-dynamodb" "3.312.0" - tslib "^2.5.0" - -"@aws-sdk/middleware-content-length@3.310.0": - version "3.310.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-content-length/-/middleware-content-length-3.310.0.tgz#cc9b6c25c10736cec41d0219c94b57cfdb4582a3" - integrity sha512-P8tQZxgDt6CAh1wd/W6WPzjc+uWPJwQkm+F7rAwRlM+k9q17HrhnksGDKcpuuLyIhPQYdmOMIkpKVgXGa4avhQ== - dependencies: - "@aws-sdk/protocol-http" "3.310.0" - "@aws-sdk/types" "3.310.0" - tslib "^2.5.0" - -"@aws-sdk/middleware-endpoint-discovery@3.310.0": - version "3.310.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-endpoint-discovery/-/middleware-endpoint-discovery-3.310.0.tgz#08860030f42cef48cb2bd7eea8c27bd1296728ec" - integrity sha512-+zZlFQ6rpvzBJrj/48iV+4tDOjnOB0syVT7Q0y+ekXzbGcNuIlLUustnLuPK1cIlMKWdmwfRzfGgGQAwyZ0l2A== - dependencies: - "@aws-sdk/endpoint-cache" "3.310.0" - "@aws-sdk/protocol-http" "3.310.0" - "@aws-sdk/types" "3.310.0" - tslib "^2.5.0" - -"@aws-sdk/middleware-endpoint@3.310.0": - version "3.310.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-endpoint/-/middleware-endpoint-3.310.0.tgz#d4bf8ac3cd4800af789d6bcb469b7e8cfa10badb" - integrity sha512-Z+N2vOL8K354/lstkClxLLsr6hCpVRh+0tCMXrVj66/NtKysCEZ/0b9LmqOwD9pWHNiI2mJqXwY0gxNlKAroUg== - dependencies: - "@aws-sdk/middleware-serde" "3.310.0" - "@aws-sdk/types" "3.310.0" - "@aws-sdk/url-parser" "3.310.0" - "@aws-sdk/util-middleware" "3.310.0" - tslib "^2.5.0" - -"@aws-sdk/middleware-host-header@3.310.0": - version "3.310.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-host-header/-/middleware-host-header-3.310.0.tgz#bdd4fbffb58b331bda517df8340aa8b44ce55550" - integrity sha512-QWSA+46/hXorXyWa61ic2K7qZzwHTiwfk2e9mRRjeIRepUgI3qxFjsYqrWtrOGBjmFmq0pYIY8Bb/DCJuQqcoA== - dependencies: - "@aws-sdk/protocol-http" "3.310.0" - "@aws-sdk/types" "3.310.0" - tslib "^2.5.0" - -"@aws-sdk/middleware-logger@3.310.0": - version "3.310.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-logger/-/middleware-logger-3.310.0.tgz#8cc6381f49ef867cae1364b8517f939629e4dd9d" - integrity sha512-Lurm8XofrASBRnAVtiSNuDSRsRqPNg27RIFLLsLp/pqog9nFJ0vz0kgdb9S5Z+zw83Mm+UlqOe6D8NTUNp4fVg== - dependencies: - "@aws-sdk/types" "3.310.0" - tslib "^2.5.0" - -"@aws-sdk/middleware-recursion-detection@3.310.0": - version "3.310.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.310.0.tgz#020c986ed8da751bd613fd84c8c8a805c89e0952" - integrity sha512-SuB75/xk/gyue24gkriTwO2jFd7YcUGZDClQYuRejgbXSa3CO0lWyawQtfLcSSEBp9izrEVXuFH24K1eAft5nQ== - dependencies: - "@aws-sdk/protocol-http" "3.310.0" - "@aws-sdk/types" "3.310.0" - tslib "^2.5.0" - -"@aws-sdk/middleware-retry@3.310.0": - version "3.310.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-retry/-/middleware-retry-3.310.0.tgz#12e95e962875d44af4acbdebe02db337a1ad5c35" - integrity sha512-oTPsRy2W4s+dfxbJPW7Km+hHtv/OMsNsVfThAq8DDYKC13qlr1aAyOqGLD+dpBy2aKe7ss517Sy2HcHtHqm7/g== - dependencies: - "@aws-sdk/protocol-http" "3.310.0" - "@aws-sdk/service-error-classification" "3.310.0" - "@aws-sdk/types" "3.310.0" - "@aws-sdk/util-middleware" "3.310.0" - "@aws-sdk/util-retry" "3.310.0" - tslib "^2.5.0" - uuid "^8.3.2" - -"@aws-sdk/middleware-sdk-sts@3.310.0": - version "3.310.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-sdk-sts/-/middleware-sdk-sts-3.310.0.tgz#2001b421f317404ca98d4a1cfea408b7a64c35f5" - integrity sha512-+5PFwlYNLvLLIfw0ASAoWV/iIF8Zv6R6QGtyP0CclhRSvNjgbQDVnV0g95MC5qvh+GB/Yjlkt8qAjLSPjHfsrQ== - dependencies: - "@aws-sdk/middleware-signing" "3.310.0" - "@aws-sdk/types" "3.310.0" - tslib "^2.5.0" - -"@aws-sdk/middleware-serde@3.310.0": - version "3.310.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-serde/-/middleware-serde-3.310.0.tgz#e334031b66a1a155375ec901478b26570fbe1783" - integrity sha512-RNeeTVWSLTaentUeCgQKZhAl+C6hxtwD78cQWS10UymWpQFwbaxztzKUu4UQS5xA2j6PxwPRRUjqa4jcFjfLsg== - dependencies: - "@aws-sdk/types" "3.310.0" - tslib "^2.5.0" - -"@aws-sdk/middleware-signing@3.310.0": - version "3.310.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-signing/-/middleware-signing-3.310.0.tgz#bd62d5623c80f6318b0d738c44780875500c911a" - integrity sha512-f9mKq+XMdW207Af3hKjdTnpNhdtwqWuvFs/ZyXoOkp/g1MY1O6L23Jy6i52m29LxbT4AuNRG1oKODfXM0vYVjQ== - dependencies: - "@aws-sdk/property-provider" "3.310.0" - "@aws-sdk/protocol-http" "3.310.0" - "@aws-sdk/signature-v4" "3.310.0" - "@aws-sdk/types" "3.310.0" - "@aws-sdk/util-middleware" "3.310.0" - tslib "^2.5.0" - -"@aws-sdk/middleware-stack@3.310.0": - version "3.310.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-stack/-/middleware-stack-3.310.0.tgz#06c83963998fbdc83e99b67a7a138529312a6224" - integrity sha512-010O1PD+UAcZVKRvqEusE1KJqN96wwrf6QsqbRM0ywsKQ21NDweaHvEDlds2VHpgmofxkRLRu/IDrlPkKRQrRg== - dependencies: - tslib "^2.5.0" - -"@aws-sdk/middleware-user-agent@3.310.0": - version "3.310.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.310.0.tgz#2aa3982cbc5e9c137024cec47914e86610ab0a09" - integrity sha512-x3IOwSwSbwKidlxRk3CNVHVUb06SRuaELxggCaR++QVI8NU6qD/l4VHXKVRvbTHiC/cYxXE/GaBDgQVpDR7V/g== - dependencies: - "@aws-sdk/protocol-http" "3.310.0" - "@aws-sdk/types" "3.310.0" - "@aws-sdk/util-endpoints" "3.310.0" - tslib "^2.5.0" - -"@aws-sdk/node-config-provider@3.310.0": - version "3.310.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/node-config-provider/-/node-config-provider-3.310.0.tgz#ba8fb41af2db0316291ba9002267627553ec65ac" - integrity sha512-T/Pp6htc6hq/Cq+MLNDSyiwWCMVF6GqbBbXKVlO5L8rdHx4sq9xPdoPveZhGWrxvkanjA6eCwUp6E0riBOSVng== - dependencies: - "@aws-sdk/property-provider" "3.310.0" - "@aws-sdk/shared-ini-file-loader" "3.310.0" - "@aws-sdk/types" "3.310.0" - tslib "^2.5.0" - -"@aws-sdk/node-http-handler@3.310.0": - version "3.310.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/node-http-handler/-/node-http-handler-3.310.0.tgz#bd8e72c1c7cf4b48c2a21851f638ad5e63001787" - integrity sha512-irv9mbcM9xC2xYjArQF5SYmHBMu4ciMWtGsoHII1nRuFOl9FoT4ffTvEPuLlfC6pznzvKt9zvnm6xXj7gDChKg== - dependencies: - "@aws-sdk/abort-controller" "3.310.0" - "@aws-sdk/protocol-http" "3.310.0" - "@aws-sdk/querystring-builder" "3.310.0" - "@aws-sdk/types" "3.310.0" - tslib "^2.5.0" - -"@aws-sdk/property-provider@3.310.0": - version "3.310.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/property-provider/-/property-provider-3.310.0.tgz#5fae8a4c11bda052afa9747d47b031f1c4f0f246" - integrity sha512-3lxDb0akV6BBzmFe4nLPaoliQbAifyWJhuvuDOu7e8NzouvpQXs0275w9LePhhcgjKAEVXUIse05ZW2DLbxo/g== - dependencies: - "@aws-sdk/types" "3.310.0" - tslib "^2.5.0" - -"@aws-sdk/protocol-http@3.310.0": - version "3.310.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/protocol-http/-/protocol-http-3.310.0.tgz#855c3314cba7ff3024a9a9701ca3c641691d997e" - integrity sha512-fgZ1aw/irQtnrsR58pS8ThKOWo57Py3xX6giRvwSgZDEcxHfVzuQjy9yPuV++v04fdmdtgpbGf8WfvAAJ11yXQ== - dependencies: - "@aws-sdk/types" "3.310.0" - tslib "^2.5.0" - -"@aws-sdk/querystring-builder@3.310.0": - version "3.310.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/querystring-builder/-/querystring-builder-3.310.0.tgz#5307ea52c3a4a1ae6818bbb6987cc6fce68b043f" - integrity sha512-ZHH8GV/80+pWGo7DzsvwvXR5xVxUHXUvPJPFAkhr6nCf78igdoF8gR10ScFoEKbtEapoNTaZlKHPXxpD8aPG7A== - dependencies: - "@aws-sdk/types" "3.310.0" - "@aws-sdk/util-uri-escape" "3.310.0" - tslib "^2.5.0" - -"@aws-sdk/querystring-parser@3.310.0": - version "3.310.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/querystring-parser/-/querystring-parser-3.310.0.tgz#438183927e0b06e7c2ee004a1681b8d37c22e104" - integrity sha512-YkIznoP6lsiIUHinx++/lbb3tlMURGGqMpo0Pnn32zYzGrJXA6eC3D0as2EcMjo55onTfuLcIiX4qzXes2MYOA== - dependencies: - "@aws-sdk/types" "3.310.0" - tslib "^2.5.0" - -"@aws-sdk/service-error-classification@3.310.0": - version "3.310.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/service-error-classification/-/service-error-classification-3.310.0.tgz#352c1db426dcf54a44393bc9a0607dde796b2abb" - integrity sha512-PuyC7k3qfIKeH2LCnDwbttMOKq3qAx4buvg0yfnJtQOz6t1AR8gsnAq0CjKXXyfkXwNKWTqCpE6lVNUIkXgsMw== - -"@aws-sdk/shared-ini-file-loader@3.310.0": - version "3.310.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/shared-ini-file-loader/-/shared-ini-file-loader-3.310.0.tgz#07e9c8e8e8bb0de7ed19b8cea908c920a493c9c9" - integrity sha512-N0q9pG0xSjQwc690YQND5bofm+4nfUviQ/Ppgan2kU6aU0WUq8KwgHJBto/YEEI+VlrME30jZJnxtOvcZJc2XA== - dependencies: - "@aws-sdk/types" "3.310.0" - tslib "^2.5.0" - -"@aws-sdk/signature-v4@3.310.0": - version "3.310.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/signature-v4/-/signature-v4-3.310.0.tgz#ad26426d3f72fa18e6808a36f827beb72d12bf2d" - integrity sha512-1M60P1ZBNAjCFv9sYW29OF6okktaeibWyW3lMXqzoHF70lHBZh+838iUchznXUA5FLabfn4jBFWMRxlAXJUY2Q== - dependencies: - "@aws-sdk/is-array-buffer" "3.310.0" - "@aws-sdk/types" "3.310.0" - "@aws-sdk/util-hex-encoding" "3.310.0" - "@aws-sdk/util-middleware" "3.310.0" - "@aws-sdk/util-uri-escape" "3.310.0" - "@aws-sdk/util-utf8" "3.310.0" - tslib "^2.5.0" - -"@aws-sdk/smithy-client@3.310.0": - version "3.310.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/smithy-client/-/smithy-client-3.310.0.tgz#04fca042ffc120c35eeea1335fa055d39f1bd7bd" - integrity sha512-UHMFvhoB2RLzsTb0mQe1ofvBUg/+/JEu1uptavxf/hEpEKZnRAaHH5FNkTG+mbFd/olay/QFjqNcMD6t8LcsNQ== - dependencies: - "@aws-sdk/middleware-stack" "3.310.0" - "@aws-sdk/types" "3.310.0" - tslib "^2.5.0" - -"@aws-sdk/token-providers@3.310.0": - version "3.310.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/token-providers/-/token-providers-3.310.0.tgz#2d0b0d3ef729f6cdc6a0cc859e80bb9efea2d8fa" - integrity sha512-G1JvB+2v8k900VJFkKVQXgLGF50ShOEIPxfK1gSQLkSU85vPwGIAANs1KvnlW08FsNbWp3+sKca4kfYKsooXMw== - dependencies: - "@aws-sdk/client-sso-oidc" "3.310.0" - "@aws-sdk/property-provider" "3.310.0" - "@aws-sdk/shared-ini-file-loader" "3.310.0" - "@aws-sdk/types" "3.310.0" - tslib "^2.5.0" - -"@aws-sdk/types@3.310.0", "@aws-sdk/types@^3.222.0": - version "3.310.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/types/-/types-3.310.0.tgz#b83a0580feb38b58417abb8b4ed3eae1a0cb7bc1" - integrity sha512-j8eamQJ7YcIhw7fneUfs8LYl3t01k4uHi4ZDmNRgtbmbmTTG3FZc2MotStZnp3nZB6vLiPF1o5aoJxWVvkzS6A== - dependencies: - tslib "^2.5.0" - -"@aws-sdk/url-parser@3.310.0": - version "3.310.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/url-parser/-/url-parser-3.310.0.tgz#928c9eac2e3d74c3c5db4c6e364a1de00185dcaa" - integrity sha512-mCLnCaSB9rQvAgx33u0DujLvr4d5yEm/W5r789GblwwQnlNXedVu50QRizMLTpltYWyAUoXjJgQnJHmJMaKXhw== - dependencies: - "@aws-sdk/querystring-parser" "3.310.0" - "@aws-sdk/types" "3.310.0" - tslib "^2.5.0" - -"@aws-sdk/util-base64@3.310.0": - version "3.310.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/util-base64/-/util-base64-3.310.0.tgz#d0fd49aff358c5a6e771d0001c63b1f97acbe34c" - integrity sha512-v3+HBKQvqgdzcbL+pFswlx5HQsd9L6ZTlyPVL2LS9nNXnCcR3XgGz9jRskikRUuUvUXtkSG1J88GAOnJ/apTPg== - dependencies: - "@aws-sdk/util-buffer-from" "3.310.0" - tslib "^2.5.0" - -"@aws-sdk/util-body-length-browser@3.310.0": - version "3.310.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/util-body-length-browser/-/util-body-length-browser-3.310.0.tgz#3fca9d2f73c058edf1907e4a1d99a392fdd23eca" - integrity sha512-sxsC3lPBGfpHtNTUoGXMQXLwjmR0zVpx0rSvzTPAuoVILVsp5AU/w5FphNPxD5OVIjNbZv9KsKTuvNTiZjDp9g== - dependencies: - tslib "^2.5.0" - -"@aws-sdk/util-body-length-node@3.310.0": - version "3.310.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/util-body-length-node/-/util-body-length-node-3.310.0.tgz#4846ae72834ab0636f29f89fc1878520f6543fed" - integrity sha512-2tqGXdyKhyA6w4zz7UPoS8Ip+7sayOg9BwHNidiGm2ikbDxm1YrCfYXvCBdwaJxa4hJfRVz+aL9e+d3GqPI9pQ== - dependencies: - tslib "^2.5.0" - -"@aws-sdk/util-buffer-from@3.310.0": - version "3.310.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/util-buffer-from/-/util-buffer-from-3.310.0.tgz#7a72cb965984d3c6a7e256ae6cf1621f52e54a57" - integrity sha512-i6LVeXFtGih5Zs8enLrt+ExXY92QV25jtEnTKHsmlFqFAuL3VBeod6boeMXkN2p9lbSVVQ1sAOOYZOHYbYkntw== - dependencies: - "@aws-sdk/is-array-buffer" "3.310.0" - tslib "^2.5.0" - -"@aws-sdk/util-config-provider@3.310.0": - version "3.310.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/util-config-provider/-/util-config-provider-3.310.0.tgz#ff21f73d4774cfd7bd16ae56f905828600dda95f" - integrity sha512-xIBaYo8dwiojCw8vnUcIL4Z5tyfb1v3yjqyJKJWV/dqKUFOOS0U591plmXbM+M/QkXyML3ypon1f8+BoaDExrg== - dependencies: - tslib "^2.5.0" - -"@aws-sdk/util-defaults-mode-browser@3.310.0": - version "3.310.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/util-defaults-mode-browser/-/util-defaults-mode-browser-3.310.0.tgz#db82bfdf339eea0bc8b1b059dfe9b62e5d3adbf4" - integrity sha512-Mr2AoQsjAYNM5oAS2YJlYJqhiCvkFV/hu48slOZgbY4G7ueW4cM0DPkR16wqjcRCGqZ4JmAZB8Q5R0DMrLjhOQ== - dependencies: - "@aws-sdk/property-provider" "3.310.0" - "@aws-sdk/types" "3.310.0" - bowser "^2.11.0" - tslib "^2.5.0" - -"@aws-sdk/util-defaults-mode-node@3.310.0": - version "3.310.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/util-defaults-mode-node/-/util-defaults-mode-node-3.310.0.tgz#aee459c2da09e2c6e85c6db0406565312f45ccbb" - integrity sha512-JyBlvhQGR8w8NpFRZZXRVTDesafFKTu/gTWjcoxP7twa+fYHSIgPPFGnlcJ/iHaucjamSaWi5EQ+YQmnSZ8yHA== - dependencies: - "@aws-sdk/config-resolver" "3.310.0" - "@aws-sdk/credential-provider-imds" "3.310.0" - "@aws-sdk/node-config-provider" "3.310.0" - "@aws-sdk/property-provider" "3.310.0" - "@aws-sdk/types" "3.310.0" - tslib "^2.5.0" - -"@aws-sdk/util-dynamodb@3.312.0", "@aws-sdk/util-dynamodb@^3.287.0": - version "3.312.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/util-dynamodb/-/util-dynamodb-3.312.0.tgz#6efa1e2be46ba909dc19006ee4c4d2c3b9f0a88a" - integrity sha512-595aijJNNDzTyzrPP8DsIi3e7MedXP9dIq+0xZCdtG8Ktli8e1VwyHQHtHTow4PYAv8aYhnarg7BxxQsQPfYVw== - dependencies: - tslib "^2.5.0" - -"@aws-sdk/util-endpoints@3.310.0": - version "3.310.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/util-endpoints/-/util-endpoints-3.310.0.tgz#fea8757038b62d49dacd653061ba04a2ea102a36" - integrity sha512-zG+/d/O5KPmAaeOMPd6bW1abifdT0H03f42keLjYEoRZzYtHPC5DuPE0UayiWGckI6BCDgy0sRKXCYS49UNFaQ== - dependencies: - "@aws-sdk/types" "3.310.0" - tslib "^2.5.0" - -"@aws-sdk/util-hex-encoding@3.310.0": - version "3.310.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/util-hex-encoding/-/util-hex-encoding-3.310.0.tgz#19294c78986c90ae33f04491487863dc1d33bd87" - integrity sha512-sVN7mcCCDSJ67pI1ZMtk84SKGqyix6/0A1Ab163YKn+lFBQRMKexleZzpYzNGxYzmQS6VanP/cfU7NiLQOaSfA== - dependencies: - tslib "^2.5.0" - -"@aws-sdk/util-locate-window@^3.0.0": - version "3.310.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/util-locate-window/-/util-locate-window-3.310.0.tgz#b071baf050301adee89051032bd4139bba32cc40" - integrity sha512-qo2t/vBTnoXpjKxlsC2e1gBrRm80M3bId27r0BRB2VniSSe7bL1mmzM+/HFtujm0iAxtPM+aLEflLJlJeDPg0w== - dependencies: - tslib "^2.5.0" - -"@aws-sdk/util-middleware@3.310.0": - version "3.310.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/util-middleware/-/util-middleware-3.310.0.tgz#713c5bfa296f4cf707150a0a1e911afd50dcf939" - integrity sha512-FTSUKL/eRb9X6uEZClrTe27QFXUNNp7fxYrPndZwk1hlaOP5ix+MIHBcI7pIiiY/JPfOUmPyZOu+HetlFXjWog== - dependencies: - tslib "^2.5.0" - -"@aws-sdk/util-retry@3.310.0": - version "3.310.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/util-retry/-/util-retry-3.310.0.tgz#4cdc35e2dfdacf2d928ab474ba8b67bbadd6be3c" - integrity sha512-FwWGhCBLfoivTMUHu1LIn4NjrN9JLJ/aX5aZmbcPIOhZVFJj638j0qDgZXyfvVqBuBZh7M8kGq0Oahy3dp69OA== - dependencies: - "@aws-sdk/service-error-classification" "3.310.0" - tslib "^2.5.0" - -"@aws-sdk/util-uri-escape@3.310.0": - version "3.310.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/util-uri-escape/-/util-uri-escape-3.310.0.tgz#9f942f09a715d8278875013a416295746b6085ba" - integrity sha512-drzt+aB2qo2LgtDoiy/3sVG8w63cgLkqFIa2NFlGpUgHFWTXkqtbgf4L5QdjRGKWhmZsnqkbtL7vkSWEcYDJ4Q== - dependencies: - tslib "^2.5.0" - -"@aws-sdk/util-user-agent-browser@3.310.0": - version "3.310.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.310.0.tgz#48d463a93351b78b678df324f3518a9798029c44" - integrity sha512-yU/4QnHHuQ5z3vsUqMQVfYLbZGYwpYblPiuZx4Zo9+x0PBkNjYMqctdDcrpoH9Z2xZiDN16AmQGK1tix117ZKw== - dependencies: - "@aws-sdk/types" "3.310.0" - bowser "^2.11.0" - tslib "^2.5.0" - -"@aws-sdk/util-user-agent-node@3.310.0": - version "3.310.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.310.0.tgz#ebefbedc5a4759adc958885741628ec0de1ab197" - integrity sha512-Ra3pEl+Gn2BpeE7KiDGpi4zj7WJXZA5GXnGo3mjbi9+Y3zrbuhJAbdZO3mO/o7xDgMC6ph4xCTbaSGzU6b6EDg== - dependencies: - "@aws-sdk/node-config-provider" "3.310.0" - "@aws-sdk/types" "3.310.0" - tslib "^2.5.0" - -"@aws-sdk/util-utf8-browser@^3.0.0": - version "3.259.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/util-utf8-browser/-/util-utf8-browser-3.259.0.tgz#3275a6f5eb334f96ca76635b961d3c50259fd9ff" - integrity sha512-UvFa/vR+e19XookZF8RzFZBrw2EUkQWxiBW0yYQAhvk3C+QVGl0H3ouca8LDBlBfQKXwmW3huo/59H8rwb1wJw== - dependencies: - tslib "^2.3.1" - -"@aws-sdk/util-utf8@3.310.0": - version "3.310.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/util-utf8/-/util-utf8-3.310.0.tgz#4a7b9dcebb88e830d3811aeb21e9a6df4273afb4" - integrity sha512-DnLfFT8uCO22uOJc0pt0DsSNau1GTisngBCDw8jQuWT5CqogMJu4b/uXmwEqfj8B3GX6Xsz8zOd6JpRlPftQoA== - dependencies: - "@aws-sdk/util-buffer-from" "3.310.0" - tslib "^2.5.0" - -"@aws-sdk/util-waiter@3.310.0": - version "3.310.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/util-waiter/-/util-waiter-3.310.0.tgz#a410739cfc637af9ccea21de079d00652e9b8363" - integrity sha512-AV5j3guH/Y4REu+Qh3eXQU9igljHuU4XjX2sADAgf54C0kkhcCCkkiuzk3IsX089nyJCqIcj5idbjdvpnH88Vw== - dependencies: - "@aws-sdk/abort-controller" "3.310.0" - "@aws-sdk/types" "3.310.0" - tslib "^2.5.0" - -"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.12.13", "@babel/code-frame@^7.18.6", "@babel/code-frame@^7.21.4": - version "7.21.4" - resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.21.4.tgz#d0fa9e4413aca81f2b23b9442797bda1826edb39" - integrity sha512-LYvhNKfwWSPpocw8GI7gpK2nq3HSDuEPC/uSYaALSJu9xjsalaaYFOq0Pwt5KmVqwEbZlDu81aLXwBOmD/Fv9g== - dependencies: - "@babel/highlight" "^7.18.6" - -"@babel/compat-data@^7.21.4": - version "7.21.4" - resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.21.4.tgz#457ffe647c480dff59c2be092fc3acf71195c87f" - integrity sha512-/DYyDpeCfaVinT40FPGdkkb+lYSKvsVuMjDAG7jPOWWiM1ibOaB9CXJAlc4d1QpP/U2q2P9jbrSlClKSErd55g== - -"@babel/core@^7.11.6", "@babel/core@^7.12.3": - version "7.21.4" - resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.21.4.tgz#c6dc73242507b8e2a27fd13a9c1814f9fa34a659" - integrity sha512-qt/YV149Jman/6AfmlxJ04LMIu8bMoyl3RB91yTFrxQmgbrSvQMy7cI8Q62FHx1t8wJ8B5fu0UDoLwHAhUo1QA== - dependencies: - "@ampproject/remapping" "^2.2.0" - "@babel/code-frame" "^7.21.4" - "@babel/generator" "^7.21.4" - "@babel/helper-compilation-targets" "^7.21.4" - "@babel/helper-module-transforms" "^7.21.2" - "@babel/helpers" "^7.21.0" - "@babel/parser" "^7.21.4" - "@babel/template" "^7.20.7" - "@babel/traverse" "^7.21.4" - "@babel/types" "^7.21.4" - convert-source-map "^1.7.0" - debug "^4.1.0" - gensync "^1.0.0-beta.2" - json5 "^2.2.2" - semver "^6.3.0" - -"@babel/generator@^7.21.4", "@babel/generator@^7.7.2": - version "7.21.4" - resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.21.4.tgz#64a94b7448989f421f919d5239ef553b37bb26bc" - integrity sha512-NieM3pVIYW2SwGzKoqfPrQsf4xGs9M9AIG3ThppsSRmO+m7eQhmI6amajKMUeIO37wFfsvnvcxQFx6x6iqxDnA== - dependencies: - "@babel/types" "^7.21.4" - "@jridgewell/gen-mapping" "^0.3.2" - "@jridgewell/trace-mapping" "^0.3.17" - jsesc "^2.5.1" - -"@babel/helper-compilation-targets@^7.21.4": - version "7.21.4" - resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.21.4.tgz#770cd1ce0889097ceacb99418ee6934ef0572656" - integrity sha512-Fa0tTuOXZ1iL8IeDFUWCzjZcn+sJGd9RZdH9esYVjEejGmzf+FFYQpMi/kZUk2kPy/q1H3/GPw7np8qar/stfg== - dependencies: - "@babel/compat-data" "^7.21.4" - "@babel/helper-validator-option" "^7.21.0" - browserslist "^4.21.3" - lru-cache "^5.1.1" - semver "^6.3.0" - -"@babel/helper-environment-visitor@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.18.9.tgz#0c0cee9b35d2ca190478756865bb3528422f51be" - integrity sha512-3r/aACDJ3fhQ/EVgFy0hpj8oHyHpQc+LPtJoY9SzTThAsStm4Ptegq92vqKoE3vD706ZVFWITnMnxucw+S9Ipg== - -"@babel/helper-function-name@^7.21.0": - version "7.21.0" - resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.21.0.tgz#d552829b10ea9f120969304023cd0645fa00b1b4" - integrity sha512-HfK1aMRanKHpxemaY2gqBmL04iAPOPRj7DxtNbiDOrJK+gdwkiNRVpCpUJYbUT+aZyemKN8brqTOxzCaG6ExRg== - dependencies: - "@babel/template" "^7.20.7" - "@babel/types" "^7.21.0" - -"@babel/helper-hoist-variables@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.18.6.tgz#d4d2c8fb4baeaa5c68b99cc8245c56554f926678" - integrity sha512-UlJQPkFqFULIcyW5sbzgbkxn2FKRgwWiRexcuaR8RNJRy8+LLveqPjwZV/bwrLZCN0eUHD/x8D0heK1ozuoo6Q== - dependencies: - "@babel/types" "^7.18.6" - -"@babel/helper-module-imports@^7.18.6": - version "7.21.4" - resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.21.4.tgz#ac88b2f76093637489e718a90cec6cf8a9b029af" - integrity sha512-orajc5T2PsRYUN3ZryCEFeMDYwyw09c/pZeaQEZPH0MpKzSvn3e0uXsDBu3k03VI+9DBiRo+l22BfKTpKwa/Wg== - dependencies: - "@babel/types" "^7.21.4" - -"@babel/helper-module-transforms@^7.21.2": - version "7.21.2" - resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.21.2.tgz#160caafa4978ac8c00ac66636cb0fa37b024e2d2" - integrity sha512-79yj2AR4U/Oqq/WOV7Lx6hUjau1Zfo4cI+JLAVYeMV5XIlbOhmjEk5ulbTc9fMpmlojzZHkUUxAiK+UKn+hNQQ== - dependencies: - "@babel/helper-environment-visitor" "^7.18.9" - "@babel/helper-module-imports" "^7.18.6" - "@babel/helper-simple-access" "^7.20.2" - "@babel/helper-split-export-declaration" "^7.18.6" - "@babel/helper-validator-identifier" "^7.19.1" - "@babel/template" "^7.20.7" - "@babel/traverse" "^7.21.2" - "@babel/types" "^7.21.2" - -"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.12.13", "@babel/helper-plugin-utils@^7.14.5", "@babel/helper-plugin-utils@^7.20.2", "@babel/helper-plugin-utils@^7.8.0": - version "7.20.2" - resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.20.2.tgz#d1b9000752b18d0877cff85a5c376ce5c3121629" - integrity sha512-8RvlJG2mj4huQ4pZ+rU9lqKi9ZKiRmuvGuM2HlWmkmgOhbs6zEAw6IEiJ5cQqGbDzGZOhwuOQNtZMi/ENLjZoQ== - -"@babel/helper-simple-access@^7.20.2": - version "7.20.2" - resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.20.2.tgz#0ab452687fe0c2cfb1e2b9e0015de07fc2d62dd9" - integrity sha512-+0woI/WPq59IrqDYbVGfshjT5Dmk/nnbdpcF8SnMhhXObpTq2KNBdLFRFrkVdbDOyUmHBCxzm5FHV1rACIkIbA== - dependencies: - "@babel/types" "^7.20.2" - -"@babel/helper-split-export-declaration@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.18.6.tgz#7367949bc75b20c6d5a5d4a97bba2824ae8ef075" - integrity sha512-bde1etTx6ZyTmobl9LLMMQsaizFVZrquTEHOqKeQESMKo4PlObf+8+JA25ZsIpZhT/WEd39+vOdLXAFG/nELpA== - dependencies: - "@babel/types" "^7.18.6" - -"@babel/helper-string-parser@^7.19.4": - version "7.19.4" - resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.19.4.tgz#38d3acb654b4701a9b77fb0615a96f775c3a9e63" - integrity sha512-nHtDoQcuqFmwYNYPz3Rah5ph2p8PFeFCsZk9A/48dPc/rGocJ5J3hAAZ7pb76VWX3fZKu+uEr/FhH5jLx7umrw== - -"@babel/helper-validator-identifier@^7.18.6", "@babel/helper-validator-identifier@^7.19.1": - version "7.19.1" - resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.19.1.tgz#7eea834cf32901ffdc1a7ee555e2f9c27e249ca2" - integrity sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w== - -"@babel/helper-validator-option@^7.21.0": - version "7.21.0" - resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.21.0.tgz#8224c7e13ace4bafdc4004da2cf064ef42673180" - integrity sha512-rmL/B8/f0mKS2baE9ZpyTcTavvEuWhTTW8amjzXNvYG4AwBsqTLikfXsEofsJEfKHf+HQVQbFOHy6o+4cnC/fQ== - -"@babel/helpers@^7.21.0": - version "7.21.0" - resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.21.0.tgz#9dd184fb5599862037917cdc9eecb84577dc4e7e" - integrity sha512-XXve0CBtOW0pd7MRzzmoyuSj0e3SEzj8pgyFxnTT1NJZL38BD1MK7yYrm8yefRPIDvNNe14xR4FdbHwpInD4rA== - dependencies: - "@babel/template" "^7.20.7" - "@babel/traverse" "^7.21.0" - "@babel/types" "^7.21.0" - -"@babel/highlight@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.18.6.tgz#81158601e93e2563795adcbfbdf5d64be3f2ecdf" - integrity sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g== - dependencies: - "@babel/helper-validator-identifier" "^7.18.6" - chalk "^2.0.0" - js-tokens "^4.0.0" - -"@babel/parser@^7.1.0", "@babel/parser@^7.14.7", "@babel/parser@^7.20.7", "@babel/parser@^7.21.4": - version "7.21.4" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.21.4.tgz#94003fdfc520bbe2875d4ae557b43ddb6d880f17" - integrity sha512-alVJj7k7zIxqBZ7BTRhz0IqJFxW1VJbm6N8JbcYhQ186df9ZBPbZBmWSqAMXwHGsCJdYks7z/voa3ibiS5bCIw== - -"@babel/plugin-syntax-async-generators@^7.8.4": - version "7.8.4" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz#a983fb1aeb2ec3f6ed042a210f640e90e786fe0d" - integrity sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw== - dependencies: - "@babel/helper-plugin-utils" "^7.8.0" - -"@babel/plugin-syntax-bigint@^7.8.3": - version "7.8.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz#4c9a6f669f5d0cdf1b90a1671e9a146be5300cea" - integrity sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg== - dependencies: - "@babel/helper-plugin-utils" "^7.8.0" - -"@babel/plugin-syntax-class-properties@^7.8.3": - version "7.12.13" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz#b5c987274c4a3a82b89714796931a6b53544ae10" - integrity sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA== - dependencies: - "@babel/helper-plugin-utils" "^7.12.13" - -"@babel/plugin-syntax-import-meta@^7.8.3": - version "7.10.4" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz#ee601348c370fa334d2207be158777496521fd51" - integrity sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g== - dependencies: - "@babel/helper-plugin-utils" "^7.10.4" - -"@babel/plugin-syntax-json-strings@^7.8.3": - version "7.8.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz#01ca21b668cd8218c9e640cb6dd88c5412b2c96a" - integrity sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA== - dependencies: - "@babel/helper-plugin-utils" "^7.8.0" - -"@babel/plugin-syntax-jsx@^7.7.2": - version "7.21.4" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.21.4.tgz#f264ed7bf40ffc9ec239edabc17a50c4f5b6fea2" - integrity sha512-5hewiLct5OKyh6PLKEYaFclcqtIgCb6bmELouxjF6up5q3Sov7rOayW4RwhbaBL0dit8rA80GNfY+UuDp2mBbQ== - dependencies: - "@babel/helper-plugin-utils" "^7.20.2" - -"@babel/plugin-syntax-logical-assignment-operators@^7.8.3": - version "7.10.4" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz#ca91ef46303530448b906652bac2e9fe9941f699" - integrity sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig== - dependencies: - "@babel/helper-plugin-utils" "^7.10.4" - -"@babel/plugin-syntax-nullish-coalescing-operator@^7.8.3": - version "7.8.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz#167ed70368886081f74b5c36c65a88c03b66d1a9" - integrity sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ== - dependencies: - "@babel/helper-plugin-utils" "^7.8.0" - -"@babel/plugin-syntax-numeric-separator@^7.8.3": - version "7.10.4" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz#b9b070b3e33570cd9fd07ba7fa91c0dd37b9af97" - integrity sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug== - dependencies: - "@babel/helper-plugin-utils" "^7.10.4" - -"@babel/plugin-syntax-object-rest-spread@^7.8.3": - version "7.8.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz#60e225edcbd98a640332a2e72dd3e66f1af55871" - integrity sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA== - dependencies: - "@babel/helper-plugin-utils" "^7.8.0" - -"@babel/plugin-syntax-optional-catch-binding@^7.8.3": - version "7.8.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz#6111a265bcfb020eb9efd0fdfd7d26402b9ed6c1" - integrity sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q== - dependencies: - "@babel/helper-plugin-utils" "^7.8.0" - -"@babel/plugin-syntax-optional-chaining@^7.8.3": - version "7.8.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz#4f69c2ab95167e0180cd5336613f8c5788f7d48a" - integrity sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg== - dependencies: - "@babel/helper-plugin-utils" "^7.8.0" - -"@babel/plugin-syntax-top-level-await@^7.8.3": - version "7.14.5" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz#c1cfdadc35a646240001f06138247b741c34d94c" - integrity sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw== - dependencies: - "@babel/helper-plugin-utils" "^7.14.5" - -"@babel/plugin-syntax-typescript@^7.7.2": - version "7.21.4" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.21.4.tgz#2751948e9b7c6d771a8efa59340c15d4a2891ff8" - integrity sha512-xz0D39NvhQn4t4RNsHmDnnsaQizIlUkdtYvLs8La1BlfjQ6JEwxkJGeqJMW2tAXx+q6H+WFuUTXNdYVpEya0YA== - dependencies: - "@babel/helper-plugin-utils" "^7.20.2" - -"@babel/template@^7.20.7", "@babel/template@^7.3.3": - version "7.20.7" - resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.20.7.tgz#a15090c2839a83b02aa996c0b4994005841fd5a8" - integrity sha512-8SegXApWe6VoNw0r9JHpSteLKTpTiLZ4rMlGIm9JQ18KiCtyQiAMEazujAHrUS5flrcqYZa75ukev3P6QmUwUw== - dependencies: - "@babel/code-frame" "^7.18.6" - "@babel/parser" "^7.20.7" - "@babel/types" "^7.20.7" - -"@babel/traverse@^7.21.0", "@babel/traverse@^7.21.2", "@babel/traverse@^7.21.4", "@babel/traverse@^7.7.2": - version "7.21.4" - resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.21.4.tgz#a836aca7b116634e97a6ed99976236b3282c9d36" - integrity sha512-eyKrRHKdyZxqDm+fV1iqL9UAHMoIg0nDaGqfIOd8rKH17m5snv7Gn4qgjBoFfLz9APvjFU/ICT00NVCv1Epp8Q== - dependencies: - "@babel/code-frame" "^7.21.4" - "@babel/generator" "^7.21.4" - "@babel/helper-environment-visitor" "^7.18.9" - "@babel/helper-function-name" "^7.21.0" - "@babel/helper-hoist-variables" "^7.18.6" - "@babel/helper-split-export-declaration" "^7.18.6" - "@babel/parser" "^7.21.4" - "@babel/types" "^7.21.4" - debug "^4.1.0" - globals "^11.1.0" - -"@babel/types@^7.0.0", "@babel/types@^7.18.6", "@babel/types@^7.20.2", "@babel/types@^7.20.7", "@babel/types@^7.21.0", "@babel/types@^7.21.2", "@babel/types@^7.21.4", "@babel/types@^7.3.0", "@babel/types@^7.3.3": - version "7.21.4" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.21.4.tgz#2d5d6bb7908699b3b416409ffd3b5daa25b030d4" - integrity sha512-rU2oY501qDxE8Pyo7i/Orqma4ziCOrby0/9mvbDUGEfvZjb279Nk9k19e2fiCxHbRRpY2ZyrgW1eq22mvmOIzA== - dependencies: - "@babel/helper-string-parser" "^7.19.4" - "@babel/helper-validator-identifier" "^7.19.1" - to-fast-properties "^2.0.0" - -"@bcoe/v8-coverage@^0.2.3": - version "0.2.3" - resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" - integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== - -"@eslint-community/eslint-utils@^4.2.0": - version "4.4.0" - resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz#a23514e8fb9af1269d5f7788aa556798d61c6b59" - integrity sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA== - dependencies: - eslint-visitor-keys "^3.3.0" - -"@eslint-community/regexpp@^4.4.0": - version "4.5.0" - resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.5.0.tgz#f6f729b02feee2c749f57e334b7a1b5f40a81724" - integrity sha512-vITaYzIcNmjn5tF5uxcZ/ft7/RXGrMUIS9HalWckEOF6ESiwXKoMzAQf2UW0aVd6rnOeExTJVd5hmWXucBKGXQ== - -"@eslint/eslintrc@^2.0.2": - version "2.0.2" - resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-2.0.2.tgz#01575e38707add677cf73ca1589abba8da899a02" - integrity sha512-3W4f5tDUra+pA+FzgugqL2pRimUTDJWKr7BINqOpkZrC0uYI0NIc0/JFgBROCU07HR6GieA5m3/rsPIhDmCXTQ== - dependencies: - ajv "^6.12.4" - debug "^4.3.2" - espree "^9.5.1" - globals "^13.19.0" - ignore "^5.2.0" - import-fresh "^3.2.1" - js-yaml "^4.1.0" - minimatch "^3.1.2" - strip-json-comments "^3.1.1" - -"@eslint/js@8.38.0": - version "8.38.0" - resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.38.0.tgz#73a8a0d8aa8a8e6fe270431c5e72ae91b5337892" - integrity sha512-IoD2MfUnOV58ghIHCiil01PcohxjbYR/qCxsoC+xNgUwh1EY8jOOrYmu3d3a71+tJJ23uscEV4X2HJWMsPJu4g== - -"@humanwhocodes/config-array@^0.11.8": - version "0.11.8" - resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.8.tgz#03595ac2075a4dc0f191cc2131de14fbd7d410b9" - integrity sha512-UybHIJzJnR5Qc/MsD9Kr+RpO2h+/P1GhOwdiLPXK5TWk5sgTdu88bTD9UP+CKbPPh5Rni1u0GjAdYQLemG8g+g== - dependencies: - "@humanwhocodes/object-schema" "^1.2.1" - debug "^4.1.1" - minimatch "^3.0.5" - -"@humanwhocodes/module-importer@^1.0.1": - version "1.0.1" - resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c" - integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== - -"@humanwhocodes/object-schema@^1.2.1": - version "1.2.1" - resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz#b520529ec21d8e5945a1851dfd1c32e94e39ff45" - integrity sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA== - -"@istanbuljs/load-nyc-config@^1.0.0": - version "1.1.0" - resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz#fd3db1d59ecf7cf121e80650bb86712f9b55eced" - integrity sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ== - dependencies: - camelcase "^5.3.1" - find-up "^4.1.0" - get-package-type "^0.1.0" - js-yaml "^3.13.1" - resolve-from "^5.0.0" - -"@istanbuljs/schema@^0.1.2": - version "0.1.3" - resolved "https://registry.yarnpkg.com/@istanbuljs/schema/-/schema-0.1.3.tgz#e45e384e4b8ec16bce2fd903af78450f6bf7ec98" - integrity sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA== - -"@jest/console@^29.5.0": - version "29.5.0" - resolved "https://registry.yarnpkg.com/@jest/console/-/console-29.5.0.tgz#593a6c5c0d3f75689835f1b3b4688c4f8544cb57" - integrity sha512-NEpkObxPwyw/XxZVLPmAGKE89IQRp4puc6IQRPru6JKd1M3fW9v1xM1AnzIJE65hbCkzQAdnL8P47e9hzhiYLQ== - dependencies: - "@jest/types" "^29.5.0" - "@types/node" "*" - chalk "^4.0.0" - jest-message-util "^29.5.0" - jest-util "^29.5.0" - slash "^3.0.0" - -"@jest/core@^29.5.0": - version "29.5.0" - resolved "https://registry.yarnpkg.com/@jest/core/-/core-29.5.0.tgz#76674b96904484e8214614d17261cc491e5f1f03" - integrity sha512-28UzQc7ulUrOQw1IsN/kv1QES3q2kkbl/wGslyhAclqZ/8cMdB5M68BffkIdSJgKBUt50d3hbwJ92XESlE7LiQ== - dependencies: - "@jest/console" "^29.5.0" - "@jest/reporters" "^29.5.0" - "@jest/test-result" "^29.5.0" - "@jest/transform" "^29.5.0" - "@jest/types" "^29.5.0" - "@types/node" "*" - ansi-escapes "^4.2.1" - chalk "^4.0.0" - ci-info "^3.2.0" - exit "^0.1.2" - graceful-fs "^4.2.9" - jest-changed-files "^29.5.0" - jest-config "^29.5.0" - jest-haste-map "^29.5.0" - jest-message-util "^29.5.0" - jest-regex-util "^29.4.3" - jest-resolve "^29.5.0" - jest-resolve-dependencies "^29.5.0" - jest-runner "^29.5.0" - jest-runtime "^29.5.0" - jest-snapshot "^29.5.0" - jest-util "^29.5.0" - jest-validate "^29.5.0" - jest-watcher "^29.5.0" - micromatch "^4.0.4" - pretty-format "^29.5.0" - slash "^3.0.0" - strip-ansi "^6.0.0" - -"@jest/environment@^29.5.0": - version "29.5.0" - resolved "https://registry.yarnpkg.com/@jest/environment/-/environment-29.5.0.tgz#9152d56317c1fdb1af389c46640ba74ef0bb4c65" - integrity sha512-5FXw2+wD29YU1d4I2htpRX7jYnAyTRjP2CsXQdo9SAM8g3ifxWPSV0HnClSn71xwctr0U3oZIIH+dtbfmnbXVQ== - dependencies: - "@jest/fake-timers" "^29.5.0" - "@jest/types" "^29.5.0" - "@types/node" "*" - jest-mock "^29.5.0" - -"@jest/expect-utils@^29.5.0": - version "29.5.0" - resolved "https://registry.yarnpkg.com/@jest/expect-utils/-/expect-utils-29.5.0.tgz#f74fad6b6e20f924582dc8ecbf2cb800fe43a036" - integrity sha512-fmKzsidoXQT2KwnrwE0SQq3uj8Z763vzR8LnLBwC2qYWEFpjX8daRsk6rHUM1QvNlEW/UJXNXm59ztmJJWs2Mg== - dependencies: - jest-get-type "^29.4.3" - -"@jest/expect@^29.5.0": - version "29.5.0" - resolved "https://registry.yarnpkg.com/@jest/expect/-/expect-29.5.0.tgz#80952f5316b23c483fbca4363ce822af79c38fba" - integrity sha512-PueDR2HGihN3ciUNGr4uelropW7rqUfTiOn+8u0leg/42UhblPxHkfoh0Ruu3I9Y1962P3u2DY4+h7GVTSVU6g== - dependencies: - expect "^29.5.0" - jest-snapshot "^29.5.0" - -"@jest/fake-timers@^29.5.0": - version "29.5.0" - resolved "https://registry.yarnpkg.com/@jest/fake-timers/-/fake-timers-29.5.0.tgz#d4d09ec3286b3d90c60bdcd66ed28d35f1b4dc2c" - integrity sha512-9ARvuAAQcBwDAqOnglWq2zwNIRUDtk/SCkp/ToGEhFv5r86K21l+VEs0qNTaXtyiY0lEePl3kylijSYJQqdbDg== - dependencies: - "@jest/types" "^29.5.0" - "@sinonjs/fake-timers" "^10.0.2" - "@types/node" "*" - jest-message-util "^29.5.0" - jest-mock "^29.5.0" - jest-util "^29.5.0" - -"@jest/globals@^29.5.0": - version "29.5.0" - resolved "https://registry.yarnpkg.com/@jest/globals/-/globals-29.5.0.tgz#6166c0bfc374c58268677539d0c181f9c1833298" - integrity sha512-S02y0qMWGihdzNbUiqSAiKSpSozSuHX5UYc7QbnHP+D9Lyw8DgGGCinrN9uSuHPeKgSSzvPom2q1nAtBvUsvPQ== - dependencies: - "@jest/environment" "^29.5.0" - "@jest/expect" "^29.5.0" - "@jest/types" "^29.5.0" - jest-mock "^29.5.0" - -"@jest/reporters@^29.5.0": - version "29.5.0" - resolved "https://registry.yarnpkg.com/@jest/reporters/-/reporters-29.5.0.tgz#985dfd91290cd78ddae4914ba7921bcbabe8ac9b" - integrity sha512-D05STXqj/M8bP9hQNSICtPqz97u7ffGzZu+9XLucXhkOFBqKcXe04JLZOgIekOxdb73MAoBUFnqvf7MCpKk5OA== - dependencies: - "@bcoe/v8-coverage" "^0.2.3" - "@jest/console" "^29.5.0" - "@jest/test-result" "^29.5.0" - "@jest/transform" "^29.5.0" - "@jest/types" "^29.5.0" - "@jridgewell/trace-mapping" "^0.3.15" - "@types/node" "*" - chalk "^4.0.0" - collect-v8-coverage "^1.0.0" - exit "^0.1.2" - glob "^7.1.3" - graceful-fs "^4.2.9" - istanbul-lib-coverage "^3.0.0" - istanbul-lib-instrument "^5.1.0" - istanbul-lib-report "^3.0.0" - istanbul-lib-source-maps "^4.0.0" - istanbul-reports "^3.1.3" - jest-message-util "^29.5.0" - jest-util "^29.5.0" - jest-worker "^29.5.0" - slash "^3.0.0" - string-length "^4.0.1" - strip-ansi "^6.0.0" - v8-to-istanbul "^9.0.1" - -"@jest/schemas@^29.4.3": - version "29.4.3" - resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-29.4.3.tgz#39cf1b8469afc40b6f5a2baaa146e332c4151788" - integrity sha512-VLYKXQmtmuEz6IxJsrZwzG9NvtkQsWNnWMsKxqWNu3+CnfzJQhp0WDDKWLVV9hLKr0l3SLLFRqcYHjhtyuDVxg== - dependencies: - "@sinclair/typebox" "^0.25.16" - -"@jest/source-map@^29.4.3": - version "29.4.3" - resolved "https://registry.yarnpkg.com/@jest/source-map/-/source-map-29.4.3.tgz#ff8d05cbfff875d4a791ab679b4333df47951d20" - integrity sha512-qyt/mb6rLyd9j1jUts4EQncvS6Yy3PM9HghnNv86QBlV+zdL2inCdK1tuVlL+J+lpiw2BI67qXOrX3UurBqQ1w== - dependencies: - "@jridgewell/trace-mapping" "^0.3.15" - callsites "^3.0.0" - graceful-fs "^4.2.9" - -"@jest/test-result@^29.5.0": - version "29.5.0" - resolved "https://registry.yarnpkg.com/@jest/test-result/-/test-result-29.5.0.tgz#7c856a6ca84f45cc36926a4e9c6b57f1973f1408" - integrity sha512-fGl4rfitnbfLsrfx1uUpDEESS7zM8JdgZgOCQuxQvL1Sn/I6ijeAVQWGfXI9zb1i9Mzo495cIpVZhA0yr60PkQ== - dependencies: - "@jest/console" "^29.5.0" - "@jest/types" "^29.5.0" - "@types/istanbul-lib-coverage" "^2.0.0" - collect-v8-coverage "^1.0.0" - -"@jest/test-sequencer@^29.5.0": - version "29.5.0" - resolved "https://registry.yarnpkg.com/@jest/test-sequencer/-/test-sequencer-29.5.0.tgz#34d7d82d3081abd523dbddc038a3ddcb9f6d3cc4" - integrity sha512-yPafQEcKjkSfDXyvtgiV4pevSeyuA6MQr6ZIdVkWJly9vkqjnFfcfhRQqpD5whjoU8EORki752xQmjaqoFjzMQ== - dependencies: - "@jest/test-result" "^29.5.0" - graceful-fs "^4.2.9" - jest-haste-map "^29.5.0" - slash "^3.0.0" - -"@jest/transform@^29.5.0": - version "29.5.0" - resolved "https://registry.yarnpkg.com/@jest/transform/-/transform-29.5.0.tgz#cf9c872d0965f0cbd32f1458aa44a2b1988b00f9" - integrity sha512-8vbeZWqLJOvHaDfeMuoHITGKSz5qWc9u04lnWrQE3VyuSw604PzQM824ZeX9XSjUCeDiE3GuxZe5UKa8J61NQw== - dependencies: - "@babel/core" "^7.11.6" - "@jest/types" "^29.5.0" - "@jridgewell/trace-mapping" "^0.3.15" - babel-plugin-istanbul "^6.1.1" - chalk "^4.0.0" - convert-source-map "^2.0.0" - fast-json-stable-stringify "^2.1.0" - graceful-fs "^4.2.9" - jest-haste-map "^29.5.0" - jest-regex-util "^29.4.3" - jest-util "^29.5.0" - micromatch "^4.0.4" - pirates "^4.0.4" - slash "^3.0.0" - write-file-atomic "^4.0.2" - -"@jest/types@^29.5.0": - version "29.5.0" - resolved "https://registry.yarnpkg.com/@jest/types/-/types-29.5.0.tgz#f59ef9b031ced83047c67032700d8c807d6e1593" - integrity sha512-qbu7kN6czmVRc3xWFQcAN03RAUamgppVUdXrvl1Wr3jlNF93o9mJbGcDWrwGB6ht44u7efB1qCFgVQmca24Uog== - dependencies: - "@jest/schemas" "^29.4.3" - "@types/istanbul-lib-coverage" "^2.0.0" - "@types/istanbul-reports" "^3.0.0" - "@types/node" "*" - "@types/yargs" "^17.0.8" - chalk "^4.0.0" - -"@jridgewell/gen-mapping@^0.3.0", "@jridgewell/gen-mapping@^0.3.2": - version "0.3.3" - resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz#7e02e6eb5df901aaedb08514203b096614024098" - integrity sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ== - dependencies: - "@jridgewell/set-array" "^1.0.1" - "@jridgewell/sourcemap-codec" "^1.4.10" - "@jridgewell/trace-mapping" "^0.3.9" - -"@jridgewell/resolve-uri@3.1.0": - version "3.1.0" - resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz#2203b118c157721addfe69d47b70465463066d78" - integrity sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w== - -"@jridgewell/set-array@^1.0.1": - version "1.1.2" - resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.1.2.tgz#7c6cf998d6d20b914c0a55a91ae928ff25965e72" - integrity sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw== - -"@jridgewell/sourcemap-codec@1.4.14": - version "1.4.14" - resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz#add4c98d341472a289190b424efbdb096991bb24" - integrity sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw== - -"@jridgewell/sourcemap-codec@^1.4.10": - version "1.4.15" - resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz#d7c6e6755c78567a951e04ab52ef0fd26de59f32" - integrity sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg== - -"@jridgewell/trace-mapping@^0.3.12", "@jridgewell/trace-mapping@^0.3.15", "@jridgewell/trace-mapping@^0.3.17", "@jridgewell/trace-mapping@^0.3.9": - version "0.3.18" - resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.18.tgz#25783b2086daf6ff1dcb53c9249ae480e4dd4cd6" - integrity sha512-w+niJYzMHdd7USdiH2U6869nqhD2nbfZXND5Yp93qIbEmnDNk7PD48o+YchRVpzMU7M6jVCbenTR7PA1FLQ9pA== - dependencies: - "@jridgewell/resolve-uri" "3.1.0" - "@jridgewell/sourcemap-codec" "1.4.14" - -"@nodelib/fs.scandir@2.1.5": - version "2.1.5" - resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" - integrity sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g== - dependencies: - "@nodelib/fs.stat" "2.0.5" - run-parallel "^1.1.9" - -"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2": - version "2.0.5" - resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz#5bd262af94e9d25bd1e71b05deed44876a222e8b" - integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A== - -"@nodelib/fs.walk@^1.2.3", "@nodelib/fs.walk@^1.2.8": - version "1.2.8" - resolved "https://registry.yarnpkg.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz#e95737e8bb6746ddedf69c556953494f196fe69a" - integrity sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg== - dependencies: - "@nodelib/fs.scandir" "2.1.5" - fastq "^1.6.0" - -"@sinclair/typebox@^0.25.16": - version "0.25.24" - resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.25.24.tgz#8c7688559979f7079aacaf31aa881c3aa410b718" - integrity sha512-XJfwUVUKDHF5ugKwIcxEgc9k8b7HbznCp6eUfWgu710hMPNIO4aw4/zB5RogDQz8nd6gyCDpU9O/m6qYEWY6yQ== - -"@sinonjs/commons@^2.0.0": - version "2.0.0" - resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-2.0.0.tgz#fd4ca5b063554307e8327b4564bd56d3b73924a3" - integrity sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg== - dependencies: - type-detect "4.0.8" - -"@sinonjs/fake-timers@^10.0.2": - version "10.0.2" - resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-10.0.2.tgz#d10549ed1f423d80639c528b6c7f5a1017747d0c" - integrity sha512-SwUDyjWnah1AaNl7kxsa7cfLhlTYoiyhDAIgyh+El30YvXs/o7OLXpYH88Zdhyx9JExKrmHDJ+10bwIcY80Jmw== - dependencies: - "@sinonjs/commons" "^2.0.0" - -"@tsd/typescript@^4.8.2": - version "4.9.5" - resolved "https://registry.yarnpkg.com/@tsd/typescript/-/typescript-4.9.5.tgz#85daafcf51f4af92bd8caf0e82b655ceaf948f99" - integrity sha512-+UgxOvJUl5rQdPFSSOOwhmSmpThm8DJ3HwHxAOq5XYe7CcmG1LcM2QeqWwILzUIT5tbeMqY8qABiCsRtIjk/2g== - -"@types/babel__core@^7.1.14": - version "7.20.0" - resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.20.0.tgz#61bc5a4cae505ce98e1e36c5445e4bee060d8891" - integrity sha512-+n8dL/9GWblDO0iU6eZAwEIJVr5DWigtle+Q6HLOrh/pdbXOhOtqzq8VPPE2zvNJzSKY4vH/z3iT3tn0A3ypiQ== - dependencies: - "@babel/parser" "^7.20.7" - "@babel/types" "^7.20.7" - "@types/babel__generator" "*" - "@types/babel__template" "*" - "@types/babel__traverse" "*" - -"@types/babel__generator@*": - version "7.6.4" - resolved "https://registry.yarnpkg.com/@types/babel__generator/-/babel__generator-7.6.4.tgz#1f20ce4c5b1990b37900b63f050182d28c2439b7" - integrity sha512-tFkciB9j2K755yrTALxD44McOrk+gfpIpvC3sxHjRawj6PfnQxrse4Clq5y/Rq+G3mrBurMax/lG8Qn2t9mSsg== - dependencies: - "@babel/types" "^7.0.0" - -"@types/babel__template@*": - version "7.4.1" - resolved "https://registry.yarnpkg.com/@types/babel__template/-/babel__template-7.4.1.tgz#3d1a48fd9d6c0edfd56f2ff578daed48f36c8969" - integrity sha512-azBFKemX6kMg5Io+/rdGT0dkGreboUVR0Cdm3fz9QJWpaQGJRQXl7C+6hOTCZcMll7KFyEQpgbYI2lHdsS4U7g== - dependencies: - "@babel/parser" "^7.1.0" - "@babel/types" "^7.0.0" - -"@types/babel__traverse@*", "@types/babel__traverse@^7.0.6": - version "7.18.3" - resolved "https://registry.yarnpkg.com/@types/babel__traverse/-/babel__traverse-7.18.3.tgz#dfc508a85781e5698d5b33443416b6268c4b3e8d" - integrity sha512-1kbcJ40lLB7MHsj39U4Sh1uTd2E7rLEa79kmDpI6cy+XiXsteB3POdQomoq4FxszMrO3ZYchkhYJw7A2862b3w== - dependencies: - "@babel/types" "^7.3.0" - -"@types/eslint@^7.2.13": - version "7.29.0" - resolved "https://registry.yarnpkg.com/@types/eslint/-/eslint-7.29.0.tgz#e56ddc8e542815272720bb0b4ccc2aff9c3e1c78" - integrity sha512-VNcvioYDH8/FxaeTKkM4/TiTwt6pBV9E3OfGmvaw8tPl0rrHCJ4Ll15HRT+pMiFAf/MLQvAzC+6RzUMEL9Ceng== - dependencies: - "@types/estree" "*" - "@types/json-schema" "*" - -"@types/estree@*": - version "1.0.0" - resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.0.tgz#5fb2e536c1ae9bf35366eed879e827fa59ca41c2" - integrity sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ== - -"@types/graceful-fs@^4.1.3": - version "4.1.6" - resolved "https://registry.yarnpkg.com/@types/graceful-fs/-/graceful-fs-4.1.6.tgz#e14b2576a1c25026b7f02ede1de3b84c3a1efeae" - integrity sha512-Sig0SNORX9fdW+bQuTEovKj3uHcUL6LQKbCrrqb1X7J6/ReAbhCXRAhc+SMejhLELFj2QcyuxmUooZ4bt5ReSw== - dependencies: - "@types/node" "*" - -"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1": - version "2.0.4" - resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz#8467d4b3c087805d63580480890791277ce35c44" - integrity sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g== - -"@types/istanbul-lib-report@*": - version "3.0.0" - resolved "https://registry.yarnpkg.com/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz#c14c24f18ea8190c118ee7562b7ff99a36552686" - integrity sha512-plGgXAPfVKFoYfa9NpYDAkseG+g6Jr294RqeqcqDixSbU34MZVJRi/P+7Y8GDpzkEwLaGZZOpKIEmeVZNtKsrg== - dependencies: - "@types/istanbul-lib-coverage" "*" - -"@types/istanbul-reports@^3.0.0": - version "3.0.1" - resolved "https://registry.yarnpkg.com/@types/istanbul-reports/-/istanbul-reports-3.0.1.tgz#9153fe98bba2bd565a63add9436d6f0d7f8468ff" - integrity sha512-c3mAZEuK0lvBp8tmuL74XRKn1+y2dcwOUpH7x4WrF6gk1GIgiluDRgMYQtw2OFcBvAJWlt6ASU3tSqxp0Uu0Aw== - dependencies: - "@types/istanbul-lib-report" "*" - -"@types/jest@^29.2.1": - version "29.5.0" - resolved "https://registry.yarnpkg.com/@types/jest/-/jest-29.5.0.tgz#337b90bbcfe42158f39c2fb5619ad044bbb518ac" - integrity sha512-3Emr5VOl/aoBwnWcH/EFQvlSAmjV+XtV9GGu5mwdYew5vhQh0IUZx/60x0TzHDu09Bi7HMx10t/namdJw5QIcg== - dependencies: - expect "^29.0.0" - pretty-format "^29.0.0" - -"@types/json-schema@*", "@types/json-schema@^7.0.9": - version "7.0.11" - resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.11.tgz#d421b6c527a3037f7c84433fd2c4229e016863d3" - integrity sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ== - -"@types/minimist@^1.2.0": - version "1.2.2" - resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.2.tgz#ee771e2ba4b3dc5b372935d549fd9617bf345b8c" - integrity sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ== - -"@types/node@*": - version "18.15.11" - resolved "https://registry.yarnpkg.com/@types/node/-/node-18.15.11.tgz#b3b790f09cb1696cffcec605de025b088fa4225f" - integrity sha512-E5Kwq2n4SbMzQOn6wnmBjuK9ouqlURrcZDVfbo9ftDDTFt3nk7ZKK4GMOzoYgnpQJKcxwQw+lGaBvvlMo0qN/Q== - -"@types/node@^14.14.16": - version "14.18.42" - resolved "https://registry.yarnpkg.com/@types/node/-/node-14.18.42.tgz#fa39b2dc8e0eba61bdf51c66502f84e23b66e114" - integrity sha512-xefu+RBie4xWlK8hwAzGh3npDz/4VhF6icY/shU+zv/1fNn+ZVG7T7CRwe9LId9sAYRPxI+59QBPuKL3WpyGRg== - -"@types/normalize-package-data@^2.4.0": - version "2.4.1" - resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz#d3357479a0fdfdd5907fe67e17e0a85c906e1301" - integrity sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw== - -"@types/prettier@^2.1.5": - version "2.7.2" - resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.7.2.tgz#6c2324641cc4ba050a8c710b2b251b377581fbf0" - integrity sha512-KufADq8uQqo1pYKVIYzfKbJfBAc0sOeXqGbFaSpv8MRmC/zXgowNZmFcbngndGk922QDmOASEXUZCaY48gs4cg== - -"@types/semver@^7.3.12": - version "7.3.13" - resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.3.13.tgz#da4bfd73f49bd541d28920ab0e2bf0ee80f71c91" - integrity sha512-21cFJr9z3g5dW8B0CVI9g2O9beqaThGQ6ZFBqHfwhzLDKUxaqTIy3vnfah/UPkfOiF2pLq+tGz+W8RyCskuslw== - -"@types/stack-utils@^2.0.0": - version "2.0.1" - resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.1.tgz#20f18294f797f2209b5f65c8e3b5c8e8261d127c" - integrity sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw== - -"@types/yargs-parser@*": - version "21.0.0" - resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.0.tgz#0c60e537fa790f5f9472ed2776c2b71ec117351b" - integrity sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA== - -"@types/yargs@^17.0.8": - version "17.0.24" - resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-17.0.24.tgz#b3ef8d50ad4aa6aecf6ddc97c580a00f5aa11902" - integrity sha512-6i0aC7jV6QzQB8ne1joVZ0eSFIstHsCrobmOtghM11yGlH0j43FKL2UhWdELkyps0zuf7qVTUVCCR+tgSlyLLw== - dependencies: - "@types/yargs-parser" "*" - -"@typescript-eslint/eslint-plugin@^5.43.0": - version "5.58.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.58.0.tgz#b1d4b0ad20243269d020ef9bbb036a40b0849829" - integrity sha512-vxHvLhH0qgBd3/tW6/VccptSfc8FxPQIkmNTVLWcCOVqSBvqpnKkBTYrhcGlXfSnd78azwe+PsjYFj0X34/njA== - dependencies: - "@eslint-community/regexpp" "^4.4.0" - "@typescript-eslint/scope-manager" "5.58.0" - "@typescript-eslint/type-utils" "5.58.0" - "@typescript-eslint/utils" "5.58.0" - debug "^4.3.4" - grapheme-splitter "^1.0.4" - ignore "^5.2.0" - natural-compare-lite "^1.4.0" - semver "^7.3.7" - tsutils "^3.21.0" - -"@typescript-eslint/parser@^5.43.0": - version "5.58.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.58.0.tgz#2ac4464cf48bef2e3234cb178ede5af352dddbc6" - integrity sha512-ixaM3gRtlfrKzP8N6lRhBbjTow1t6ztfBvQNGuRM8qH1bjFFXIJ35XY+FC0RRBKn3C6cT+7VW1y8tNm7DwPHDQ== - dependencies: - "@typescript-eslint/scope-manager" "5.58.0" - "@typescript-eslint/types" "5.58.0" - "@typescript-eslint/typescript-estree" "5.58.0" - debug "^4.3.4" - -"@typescript-eslint/scope-manager@5.58.0": - version "5.58.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.58.0.tgz#5e023a48352afc6a87be6ce3c8e763bc9e2f0bc8" - integrity sha512-b+w8ypN5CFvrXWQb9Ow9T4/6LC2MikNf1viLkYTiTbkQl46CnR69w7lajz1icW0TBsYmlpg+mRzFJ4LEJ8X9NA== - dependencies: - "@typescript-eslint/types" "5.58.0" - "@typescript-eslint/visitor-keys" "5.58.0" - -"@typescript-eslint/type-utils@5.58.0": - version "5.58.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.58.0.tgz#f7d5b3971483d4015a470d8a9e5b8a7d10066e52" - integrity sha512-FF5vP/SKAFJ+LmR9PENql7fQVVgGDOS+dq3j+cKl9iW/9VuZC/8CFmzIP0DLKXfWKpRHawJiG70rVH+xZZbp8w== - dependencies: - "@typescript-eslint/typescript-estree" "5.58.0" - "@typescript-eslint/utils" "5.58.0" - debug "^4.3.4" - tsutils "^3.21.0" - -"@typescript-eslint/types@5.58.0": - version "5.58.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.58.0.tgz#54c490b8522c18986004df7674c644ffe2ed77d8" - integrity sha512-JYV4eITHPzVQMnHZcYJXl2ZloC7thuUHrcUmxtzvItyKPvQ50kb9QXBkgNAt90OYMqwaodQh2kHutWZl1fc+1g== - -"@typescript-eslint/typescript-estree@5.58.0": - version "5.58.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.58.0.tgz#4966e6ff57eaf6e0fce2586497edc097e2ab3e61" - integrity sha512-cRACvGTodA+UxnYM2uwA2KCwRL7VAzo45syNysqlMyNyjw0Z35Icc9ihPJZjIYuA5bXJYiJ2YGUB59BqlOZT1Q== - dependencies: - "@typescript-eslint/types" "5.58.0" - "@typescript-eslint/visitor-keys" "5.58.0" - debug "^4.3.4" - globby "^11.1.0" - is-glob "^4.0.3" - semver "^7.3.7" - tsutils "^3.21.0" - -"@typescript-eslint/utils@5.58.0": - version "5.58.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.58.0.tgz#430d7c95f23ec457b05be5520c1700a0dfd559d5" - integrity sha512-gAmLOTFXMXOC+zP1fsqm3VceKSBQJNzV385Ok3+yzlavNHZoedajjS4UyS21gabJYcobuigQPs/z71A9MdJFqQ== - dependencies: - "@eslint-community/eslint-utils" "^4.2.0" - "@types/json-schema" "^7.0.9" - "@types/semver" "^7.3.12" - "@typescript-eslint/scope-manager" "5.58.0" - "@typescript-eslint/types" "5.58.0" - "@typescript-eslint/typescript-estree" "5.58.0" - eslint-scope "^5.1.1" - semver "^7.3.7" - -"@typescript-eslint/visitor-keys@5.58.0": - version "5.58.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.58.0.tgz#eb9de3a61d2331829e6761ce7fd13061781168b4" - integrity sha512-/fBraTlPj0jwdyTwLyrRTxv/3lnU2H96pNTVM6z3esTWLtA5MZ9ghSMJ7Rb+TtUAdtEw9EyJzJ0EydIMKxQ9gA== - dependencies: - "@typescript-eslint/types" "5.58.0" - eslint-visitor-keys "^3.3.0" - -acorn-jsx@^5.3.2: - version "5.3.2" - resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" - integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== - -acorn@^8.8.0: - version "8.8.2" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.2.tgz#1b2f25db02af965399b9776b0c2c391276d37c4a" - integrity sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw== - -ajv@^6.10.0, ajv@^6.12.3, ajv@^6.12.4: - version "6.12.6" - resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" - integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== - dependencies: - fast-deep-equal "^3.1.1" - fast-json-stable-stringify "^2.0.0" - json-schema-traverse "^0.4.1" - uri-js "^4.2.2" - -ansi-escapes@^4.2.1: - version "4.3.2" - resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e" - integrity sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ== - dependencies: - type-fest "^0.21.3" - -ansi-regex@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" - integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== - -ansi-styles@^3.2.1: - version "3.2.1" - resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" - integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA== - dependencies: - color-convert "^1.9.0" - -ansi-styles@^4.0.0, ansi-styles@^4.1.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" - integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== - dependencies: - color-convert "^2.0.1" - -ansi-styles@^5.0.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-5.2.0.tgz#07449690ad45777d1924ac2abb2fc8895dba836b" - integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA== - -anymatch@^3.0.3: - version "3.1.3" - resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e" - integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw== - dependencies: - normalize-path "^3.0.0" - picomatch "^2.0.4" - -argparse@^1.0.7: - version "1.0.10" - resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" - integrity sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg== - dependencies: - sprintf-js "~1.0.2" - -argparse@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" - integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== - -array-union@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/array-union/-/array-union-2.1.0.tgz#b798420adbeb1de828d84acd8a2e23d3efe85e8d" - integrity sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw== - -arrify@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d" - integrity sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA== - -asn1@~0.2.3: - version "0.2.6" - resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.6.tgz#0d3a7bb6e64e02a90c0303b31f292868ea09a08d" - integrity sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ== - dependencies: - safer-buffer "~2.1.0" - -assert-plus@1.0.0, assert-plus@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525" - integrity sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw== - -asynckit@^0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" - integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== - -aws-sign2@~0.7.0: - version "0.7.0" - resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8" - integrity sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA== - -aws4@^1.8.0: - version "1.12.0" - resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.12.0.tgz#ce1c9d143389679e253b314241ea9aa5cec980d3" - integrity sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg== - -babel-jest@^29.5.0: - version "29.5.0" - resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-29.5.0.tgz#3fe3ddb109198e78b1c88f9ebdecd5e4fc2f50a5" - integrity sha512-mA4eCDh5mSo2EcA9xQjVTpmbbNk32Zb3Q3QFQsNhaK56Q+yoXowzFodLux30HRgyOho5rsQ6B0P9QpMkvvnJ0Q== - dependencies: - "@jest/transform" "^29.5.0" - "@types/babel__core" "^7.1.14" - babel-plugin-istanbul "^6.1.1" - babel-preset-jest "^29.5.0" - chalk "^4.0.0" - graceful-fs "^4.2.9" - slash "^3.0.0" - -babel-plugin-istanbul@^6.1.1: - version "6.1.1" - resolved "https://registry.yarnpkg.com/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz#fa88ec59232fd9b4e36dbbc540a8ec9a9b47da73" - integrity sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA== - dependencies: - "@babel/helper-plugin-utils" "^7.0.0" - "@istanbuljs/load-nyc-config" "^1.0.0" - "@istanbuljs/schema" "^0.1.2" - istanbul-lib-instrument "^5.0.4" - test-exclude "^6.0.0" - -babel-plugin-jest-hoist@^29.5.0: - version "29.5.0" - resolved "https://registry.yarnpkg.com/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.5.0.tgz#a97db437936f441ec196990c9738d4b88538618a" - integrity sha512-zSuuuAlTMT4mzLj2nPnUm6fsE6270vdOfnpbJ+RmruU75UhLFvL0N2NgI7xpeS7NaB6hGqmd5pVpGTDYvi4Q3w== - dependencies: - "@babel/template" "^7.3.3" - "@babel/types" "^7.3.3" - "@types/babel__core" "^7.1.14" - "@types/babel__traverse" "^7.0.6" - -babel-preset-current-node-syntax@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.0.1.tgz#b4399239b89b2a011f9ddbe3e4f401fc40cff73b" - integrity sha512-M7LQ0bxarkxQoN+vz5aJPsLBn77n8QgTFmo8WK0/44auK2xlCXrYcUxHFxgU7qW5Yzw/CjmLRK2uJzaCd7LvqQ== - dependencies: - "@babel/plugin-syntax-async-generators" "^7.8.4" - "@babel/plugin-syntax-bigint" "^7.8.3" - "@babel/plugin-syntax-class-properties" "^7.8.3" - "@babel/plugin-syntax-import-meta" "^7.8.3" - "@babel/plugin-syntax-json-strings" "^7.8.3" - "@babel/plugin-syntax-logical-assignment-operators" "^7.8.3" - "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.3" - "@babel/plugin-syntax-numeric-separator" "^7.8.3" - "@babel/plugin-syntax-object-rest-spread" "^7.8.3" - "@babel/plugin-syntax-optional-catch-binding" "^7.8.3" - "@babel/plugin-syntax-optional-chaining" "^7.8.3" - "@babel/plugin-syntax-top-level-await" "^7.8.3" - -babel-preset-jest@^29.5.0: - version "29.5.0" - resolved "https://registry.yarnpkg.com/babel-preset-jest/-/babel-preset-jest-29.5.0.tgz#57bc8cc88097af7ff6a5ab59d1cd29d52a5916e2" - integrity sha512-JOMloxOqdiBSxMAzjRaH023/vvcaSaec49zvg+2LmNsktC7ei39LTJGw02J+9uUtTZUq6xbLyJ4dxe9sSmIuAg== - dependencies: - babel-plugin-jest-hoist "^29.5.0" - babel-preset-current-node-syntax "^1.0.0" - -balanced-match@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" - integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== - -bcrypt-pbkdf@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e" - integrity sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w== - dependencies: - tweetnacl "^0.14.3" - -bowser@^2.11.0: - version "2.11.0" - resolved "https://registry.yarnpkg.com/bowser/-/bowser-2.11.0.tgz#5ca3c35757a7aa5771500c70a73a9f91ef420a8f" - integrity sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA== - -brace-expansion@^1.1.7: - version "1.1.11" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" - integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== - dependencies: - balanced-match "^1.0.0" - concat-map "0.0.1" - -braces@^3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" - integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A== - dependencies: - fill-range "^7.0.1" - -browserslist@^4.21.3: - version "4.21.5" - resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.21.5.tgz#75c5dae60063ee641f977e00edd3cfb2fb7af6a7" - integrity sha512-tUkiguQGW7S3IhB7N+c2MV/HZPSCPAAiYBZXLsBhFB/PCy6ZKKsZrmBayHV9fdGV/ARIfJ14NkxKzRDjvp7L6w== - dependencies: - caniuse-lite "^1.0.30001449" - electron-to-chromium "^1.4.284" - node-releases "^2.0.8" - update-browserslist-db "^1.0.10" - -bs-logger@0.x: - version "0.2.6" - resolved "https://registry.yarnpkg.com/bs-logger/-/bs-logger-0.2.6.tgz#eb7d365307a72cf974cc6cda76b68354ad336bd8" - integrity sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog== - dependencies: - fast-json-stable-stringify "2.x" - -bser@2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/bser/-/bser-2.1.1.tgz#e6787da20ece9d07998533cfd9de6f5c38f4bc05" - integrity sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ== - dependencies: - node-int64 "^0.4.0" - -buffer-from@^1.0.0: - version "1.1.2" - resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" - integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== - -callsites@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" - integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== - -camelcase-keys@^6.2.2: - version "6.2.2" - resolved "https://registry.yarnpkg.com/camelcase-keys/-/camelcase-keys-6.2.2.tgz#5e755d6ba51aa223ec7d3d52f25778210f9dc3c0" - integrity sha512-YrwaA0vEKazPBkn0ipTiMpSajYDSe+KjQfrjhcBMxJt/znbvlHd8Pw/Vamaz5EB4Wfhs3SUR3Z9mwRu/P3s3Yg== - dependencies: - camelcase "^5.3.1" - map-obj "^4.0.0" - quick-lru "^4.0.1" - -camelcase@^5.3.1: - version "5.3.1" - resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" - integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== - -camelcase@^6.2.0: - version "6.3.0" - resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" - integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== - -caniuse-lite@^1.0.30001449: - version "1.0.30001478" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001478.tgz#0ef8a1cf8b16be47a0f9fc4ecfc952232724b32a" - integrity sha512-gMhDyXGItTHipJj2ApIvR+iVB5hd0KP3svMWWXDvZOmjzJJassGLMfxRkQCSYgGd2gtdL/ReeiyvMSFD1Ss6Mw== - -caseless@~0.12.0: - version "0.12.0" - resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" - integrity sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw== - -chalk@^2.0.0: - version "2.4.2" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" - integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== - dependencies: - ansi-styles "^3.2.1" - escape-string-regexp "^1.0.5" - supports-color "^5.3.0" - -chalk@^4.0.0, chalk@^4.1.0: - version "4.1.2" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" - integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== - dependencies: - ansi-styles "^4.1.0" - supports-color "^7.1.0" - -char-regex@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/char-regex/-/char-regex-1.0.2.tgz#d744358226217f981ed58f479b1d6bcc29545dcf" - integrity sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw== - -ci-info@^3.2.0: - version "3.8.0" - resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.8.0.tgz#81408265a5380c929f0bc665d62256628ce9ef91" - integrity sha512-eXTggHWSooYhq49F2opQhuHWgzucfF2YgODK4e1566GQs5BIfP30B0oenwBJHfWxAs2fyPB1s7Mg949zLf61Yw== - -cjs-module-lexer@^1.0.0: - version "1.2.2" - resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-1.2.2.tgz#9f84ba3244a512f3a54e5277e8eef4c489864e40" - integrity sha512-cOU9usZw8/dXIXKtwa8pM0OTJQuJkxMN6w30csNRUerHfeQ5R6U3kkU/FtJeIf3M202OHfY2U8ccInBG7/xogA== - -cliui@^8.0.1: - version "8.0.1" - resolved "https://registry.yarnpkg.com/cliui/-/cliui-8.0.1.tgz#0c04b075db02cbfe60dc8e6cf2f5486b1a3608aa" - integrity sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ== - dependencies: - string-width "^4.2.0" - strip-ansi "^6.0.1" - wrap-ansi "^7.0.0" - -co@^4.6.0: - version "4.6.0" - resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" - integrity sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ== - -collect-v8-coverage@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/collect-v8-coverage/-/collect-v8-coverage-1.0.1.tgz#cc2c8e94fc18bbdffe64d6534570c8a673b27f59" - integrity sha512-iBPtljfCNcTKNAto0KEtDfZ3qzjJvqE3aTGZsbhjSBlorqpXJlaWWtPO35D+ZImoC3KWejX64o+yPGxhWSTzfg== - -color-convert@^1.9.0: - version "1.9.3" - resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" - integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== - dependencies: - color-name "1.1.3" - -color-convert@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" - integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== - dependencies: - color-name "~1.1.4" - -color-name@1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" - integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw== - -color-name@~1.1.4: - version "1.1.4" - resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" - integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== - -combined-stream@^1.0.6, combined-stream@~1.0.6: - version "1.0.8" - resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" - integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== - dependencies: - delayed-stream "~1.0.0" - -concat-map@0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" - integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== - -convert-source-map@^1.6.0, convert-source-map@^1.7.0: - version "1.9.0" - resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.9.0.tgz#7faae62353fb4213366d0ca98358d22e8368b05f" - integrity sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A== - -convert-source-map@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a" - integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== - -core-util-is@1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" - integrity sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ== - -coveralls@^3.1.0: - version "3.1.1" - resolved "https://registry.yarnpkg.com/coveralls/-/coveralls-3.1.1.tgz#f5d4431d8b5ae69c5079c8f8ca00d64ac77cf081" - integrity sha512-+dxnG2NHncSD1NrqbSM3dn/lE57O6Qf/koe9+I7c+wzkqRmEvcp0kgJdxKInzYzkICKkFMZsX3Vct3++tsF9ww== - dependencies: - js-yaml "^3.13.1" - lcov-parse "^1.0.0" - log-driver "^1.2.7" - minimist "^1.2.5" - request "^2.88.2" - -cross-spawn@^7.0.2, cross-spawn@^7.0.3: - version "7.0.3" - resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" - integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== - dependencies: - path-key "^3.1.0" - shebang-command "^2.0.0" - which "^2.0.1" - -dashdash@^1.12.0: - version "1.14.1" - resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0" - integrity sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g== - dependencies: - assert-plus "^1.0.0" - -debug@^4.1.0, debug@^4.1.1, debug@^4.3.2, debug@^4.3.4: - version "4.3.4" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" - integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== - dependencies: - ms "2.1.2" - -decamelize-keys@^1.1.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/decamelize-keys/-/decamelize-keys-1.1.1.tgz#04a2d523b2f18d80d0158a43b895d56dff8d19d8" - integrity sha512-WiPxgEirIV0/eIOMcnFBA3/IJZAZqKnwAwWyvvdi4lsr1WCN22nhdf/3db3DoZcUjTV2SqfzIwNyp6y2xs3nmg== - dependencies: - decamelize "^1.1.0" - map-obj "^1.0.0" - -decamelize@^1.1.0, decamelize@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" - integrity sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA== - -dedent@^0.7.0: - version "0.7.0" - resolved "https://registry.yarnpkg.com/dedent/-/dedent-0.7.0.tgz#2495ddbaf6eb874abb0e1be9df22d2e5a544326c" - integrity sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA== - -deep-copy@^1.4.2: - version "1.4.2" - resolved "https://registry.yarnpkg.com/deep-copy/-/deep-copy-1.4.2.tgz#0622719257e4bd60240e401ea96718211c5c4697" - integrity sha512-VxZwQ/1+WGQPl5nE67uLhh7OqdrmqI1OazrraO9Bbw/M8Bt6Mol/RxzDA6N6ZgRXpsG/W9PgUj8E1LHHBEq2GQ== - -deep-is@^0.1.3: - version "0.1.4" - resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" - integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== - -deepmerge@^4.2.2: - version "4.3.1" - resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a" - integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A== - -delayed-stream@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" - integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== - -detect-newline@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651" - integrity sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA== - -diff-sequences@^29.4.3: - version "29.4.3" - resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-29.4.3.tgz#9314bc1fabe09267ffeca9cbafc457d8499a13f2" - integrity sha512-ofrBgwpPhCD85kMKtE9RYFFq6OC1A89oW2vvgWZNCwxrUpRUILopY7lsYyMDSjc8g6U6aiO0Qubg6r4Wgt5ZnA== - -dir-glob@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f" - integrity sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA== - dependencies: - path-type "^4.0.0" - -doctrine@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-3.0.0.tgz#addebead72a6574db783639dc87a121773973961" - integrity sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w== - dependencies: - esutils "^2.0.2" - -ecc-jsbn@~0.1.1: - version "0.1.2" - resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9" - integrity sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw== - dependencies: - jsbn "~0.1.0" - safer-buffer "^2.1.0" - -electron-to-chromium@^1.4.284: - version "1.4.361" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.361.tgz#010ddd5e623470ab9d1bf776b009d11c3669a4e3" - integrity sha512-VocVwjPp05HUXzf3xmL0boRn5b0iyqC7amtDww84Jb1QJNPBc7F69gJyEeXRoriLBC4a5pSyckdllrXAg4mmRA== - -emittery@^0.13.1: - version "0.13.1" - resolved "https://registry.yarnpkg.com/emittery/-/emittery-0.13.1.tgz#c04b8c3457490e0847ae51fced3af52d338e3dad" - integrity sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ== - -emoji-regex@^8.0.0: - version "8.0.0" - resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" - integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== - -error-ex@^1.3.1: - version "1.3.2" - resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf" - integrity sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g== - dependencies: - is-arrayish "^0.2.1" - -escalade@^3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" - integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw== - -escape-string-regexp@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" - integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg== - -escape-string-regexp@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz#a30304e99daa32e23b2fd20f51babd07cffca344" - integrity sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w== - -escape-string-regexp@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" - integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== - -eslint-formatter-pretty@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/eslint-formatter-pretty/-/eslint-formatter-pretty-4.1.0.tgz#7a6877c14ffe2672066c853587d89603e97c7708" - integrity sha512-IsUTtGxF1hrH6lMWiSl1WbGaiP01eT6kzywdY1U+zLc0MP+nwEnUiS9UI8IaOTUhTeQJLlCEWIbXINBH4YJbBQ== - dependencies: - "@types/eslint" "^7.2.13" - ansi-escapes "^4.2.1" - chalk "^4.1.0" - eslint-rule-docs "^1.1.5" - log-symbols "^4.0.0" - plur "^4.0.0" - string-width "^4.2.0" - supports-hyperlinks "^2.0.0" - -eslint-rule-docs@^1.1.5: - version "1.1.235" - resolved "https://registry.yarnpkg.com/eslint-rule-docs/-/eslint-rule-docs-1.1.235.tgz#be6ef1fc3525f17b3c859ae2997fedadc89bfb9b" - integrity sha512-+TQ+x4JdTnDoFEXXb3fDvfGOwnyNV7duH8fXWTPD1ieaBmB8omj7Gw/pMBBu4uI2uJCCU8APDaQJzWuXnTsH4A== - -eslint-scope@^5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-5.1.1.tgz#e786e59a66cb92b3f6c1fb0d508aab174848f48c" - integrity sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw== - dependencies: - esrecurse "^4.3.0" - estraverse "^4.1.1" - -eslint-scope@^7.1.1: - version "7.1.1" - resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-7.1.1.tgz#fff34894c2f65e5226d3041ac480b4513a163642" - integrity sha512-QKQM/UXpIiHcLqJ5AOyIW7XZmzjkzQXYE54n1++wb0u9V/abW3l9uQnxX8Z5Xd18xyKIMTUAyQ0k1e8pz6LUrw== - dependencies: - esrecurse "^4.3.0" - estraverse "^5.2.0" - -eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.0: - version "3.4.0" - resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.0.tgz#c7f0f956124ce677047ddbc192a68f999454dedc" - integrity sha512-HPpKPUBQcAsZOsHAFwTtIKcYlCje62XB7SEAcxjtmW6TD1WVpkS6i6/hOVtTZIl4zGj/mBqpFVGvaDneik+VoQ== - -eslint@^8.2.0: - version "8.38.0" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.38.0.tgz#a62c6f36e548a5574dd35728ac3c6209bd1e2f1a" - integrity sha512-pIdsD2jwlUGf/U38Jv97t8lq6HpaU/G9NKbYmpWpZGw3LdTNhZLbJePqxOXGB5+JEKfOPU/XLxYxFh03nr1KTg== - dependencies: - "@eslint-community/eslint-utils" "^4.2.0" - "@eslint-community/regexpp" "^4.4.0" - "@eslint/eslintrc" "^2.0.2" - "@eslint/js" "8.38.0" - "@humanwhocodes/config-array" "^0.11.8" - "@humanwhocodes/module-importer" "^1.0.1" - "@nodelib/fs.walk" "^1.2.8" - ajv "^6.10.0" - chalk "^4.0.0" - cross-spawn "^7.0.2" - debug "^4.3.2" - doctrine "^3.0.0" - escape-string-regexp "^4.0.0" - eslint-scope "^7.1.1" - eslint-visitor-keys "^3.4.0" - espree "^9.5.1" - esquery "^1.4.2" - esutils "^2.0.2" - fast-deep-equal "^3.1.3" - file-entry-cache "^6.0.1" - find-up "^5.0.0" - glob-parent "^6.0.2" - globals "^13.19.0" - grapheme-splitter "^1.0.4" - ignore "^5.2.0" - import-fresh "^3.0.0" - imurmurhash "^0.1.4" - is-glob "^4.0.0" - is-path-inside "^3.0.3" - js-sdsl "^4.1.4" - js-yaml "^4.1.0" - json-stable-stringify-without-jsonify "^1.0.1" - levn "^0.4.1" - lodash.merge "^4.6.2" - minimatch "^3.1.2" - natural-compare "^1.4.0" - optionator "^0.9.1" - strip-ansi "^6.0.1" - strip-json-comments "^3.1.0" - text-table "^0.2.0" - -espree@^9.5.1: - version "9.5.1" - resolved "https://registry.yarnpkg.com/espree/-/espree-9.5.1.tgz#4f26a4d5f18905bf4f2e0bd99002aab807e96dd4" - integrity sha512-5yxtHSZXRSW5pvv3hAlXM5+/Oswi1AUFqBmbibKb5s6bp3rGIDkyXU6xCoyuuLhijr4SFwPrXRoZjz0AZDN9tg== - dependencies: - acorn "^8.8.0" - acorn-jsx "^5.3.2" - eslint-visitor-keys "^3.4.0" - -esprima@^4.0.0: - version "4.0.1" - resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" - integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== - -esquery@^1.4.2: - version "1.5.0" - resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.5.0.tgz#6ce17738de8577694edd7361c57182ac8cb0db0b" - integrity sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg== - dependencies: - estraverse "^5.1.0" - -esrecurse@^4.3.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921" - integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag== - dependencies: - estraverse "^5.2.0" - -estraverse@^4.1.1: - version "4.3.0" - resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d" - integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw== - -estraverse@^5.1.0, estraverse@^5.2.0: - version "5.3.0" - resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123" - integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== - -esutils@^2.0.2: - version "2.0.3" - resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" - integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== - -execa@^5.0.0: - version "5.1.1" - resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd" - integrity sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg== - dependencies: - cross-spawn "^7.0.3" - get-stream "^6.0.0" - human-signals "^2.1.0" - is-stream "^2.0.0" - merge-stream "^2.0.0" - npm-run-path "^4.0.1" - onetime "^5.1.2" - signal-exit "^3.0.3" - strip-final-newline "^2.0.0" - -exit@^0.1.2: - version "0.1.2" - resolved "https://registry.yarnpkg.com/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c" - integrity sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ== - -expect@^29.0.0, expect@^29.5.0: - version "29.5.0" - resolved "https://registry.yarnpkg.com/expect/-/expect-29.5.0.tgz#68c0509156cb2a0adb8865d413b137eeaae682f7" - integrity sha512-yM7xqUrCO2JdpFo4XpM82t+PJBFybdqoQuJLDGeDX2ij8NZzqRHyu3Hp188/JX7SWqud+7t4MUdvcgGBICMHZg== - dependencies: - "@jest/expect-utils" "^29.5.0" - jest-get-type "^29.4.3" - jest-matcher-utils "^29.5.0" - jest-message-util "^29.5.0" - jest-util "^29.5.0" - -extend@~3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" - integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== - -extsprintf@1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05" - integrity sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g== - -extsprintf@^1.2.0: - version "1.4.1" - resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.4.1.tgz#8d172c064867f235c0c84a596806d279bf4bcc07" - integrity sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA== - -fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: - version "3.1.3" - resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" - integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== - -fast-glob@^3.2.9: - version "3.2.12" - resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.12.tgz#7f39ec99c2e6ab030337142da9e0c18f37afae80" - integrity sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w== - dependencies: - "@nodelib/fs.stat" "^2.0.2" - "@nodelib/fs.walk" "^1.2.3" - glob-parent "^5.1.2" - merge2 "^1.3.0" - micromatch "^4.0.4" - -fast-json-stable-stringify@2.x, fast-json-stable-stringify@^2.0.0, fast-json-stable-stringify@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" - integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== - -fast-levenshtein@^2.0.6: - version "2.0.6" - resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" - integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== - -fast-xml-parser@4.1.2: - version "4.1.2" - resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-4.1.2.tgz#5a98c18238d28a57bbdfa9fe4cda01211fff8f4a" - integrity sha512-CDYeykkle1LiA/uqQyNwYpFbyF6Axec6YapmpUP+/RHWIoR1zKjocdvNaTsxCxZzQ6v9MLXaSYm9Qq0thv0DHg== - dependencies: - strnum "^1.0.5" - -fastq@^1.6.0: - version "1.15.0" - resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.15.0.tgz#d04d07c6a2a68fe4599fea8d2e103a937fae6b3a" - integrity sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw== - dependencies: - reusify "^1.0.4" - -fb-watchman@^2.0.0: - version "2.0.2" - resolved "https://registry.yarnpkg.com/fb-watchman/-/fb-watchman-2.0.2.tgz#e9524ee6b5c77e9e5001af0f85f3adbb8623255c" - integrity sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA== - dependencies: - bser "2.1.1" - -file-entry-cache@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz#211b2dd9659cb0394b073e7323ac3c933d522027" - integrity sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg== - dependencies: - flat-cache "^3.0.4" - -fill-range@^7.0.1: - version "7.0.1" - resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40" - integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ== - dependencies: - to-regex-range "^5.0.1" - -find-up@^4.0.0, find-up@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19" - integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw== - dependencies: - locate-path "^5.0.0" - path-exists "^4.0.0" - -find-up@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" - integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== - dependencies: - locate-path "^6.0.0" - path-exists "^4.0.0" - -flat-cache@^3.0.4: - version "3.0.4" - resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.0.4.tgz#61b0338302b2fe9f957dcc32fc2a87f1c3048b11" - integrity sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg== - dependencies: - flatted "^3.1.0" - rimraf "^3.0.2" - -flatted@^3.1.0: - version "3.2.7" - resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.7.tgz#609f39207cb614b89d0765b477cb2d437fbf9787" - integrity sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ== - -forever-agent@~0.6.1: - version "0.6.1" - resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" - integrity sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw== - -form-data@~2.3.2: - version "2.3.3" - resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.3.tgz#dcce52c05f644f298c6a7ab936bd724ceffbf3a6" - integrity sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ== - dependencies: - asynckit "^0.4.0" - combined-stream "^1.0.6" - mime-types "^2.1.12" - -fs.realpath@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" - integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== - -fsevents@^2.3.2: - version "2.3.2" - resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" - integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== - -function-bind@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" - integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== - -gensync@^1.0.0-beta.2: - version "1.0.0-beta.2" - resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" - integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg== - -get-caller-file@^2.0.5: - version "2.0.5" - resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" - integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== - -get-package-type@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/get-package-type/-/get-package-type-0.1.0.tgz#8de2d803cff44df3bc6c456e6668b36c3926e11a" - integrity sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q== - -get-stream@^6.0.0: - version "6.0.1" - resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7" - integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg== - -getpass@^0.1.1: - version "0.1.7" - resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa" - integrity sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng== - dependencies: - assert-plus "^1.0.0" - -glob-parent@^5.1.2: - version "5.1.2" - resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" - integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== - dependencies: - is-glob "^4.0.1" - -glob-parent@^6.0.2: - version "6.0.2" - resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3" - integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== - dependencies: - is-glob "^4.0.3" - -glob@^7.1.3, glob@^7.1.4: - version "7.2.3" - resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" - integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== - dependencies: - fs.realpath "^1.0.0" - inflight "^1.0.4" - inherits "2" - minimatch "^3.1.1" - once "^1.3.0" - path-is-absolute "^1.0.0" - -globals@^11.1.0: - version "11.12.0" - resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" - integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== - -globals@^13.19.0: - version "13.20.0" - resolved "https://registry.yarnpkg.com/globals/-/globals-13.20.0.tgz#ea276a1e508ffd4f1612888f9d1bad1e2717bf82" - integrity sha512-Qg5QtVkCy/kv3FUSlu4ukeZDVf9ee0iXLAUYX13gbR17bnejFTzr4iS9bY7kwCf1NztRNm1t91fjOiyx4CSwPQ== - dependencies: - type-fest "^0.20.2" - -globby@^11.0.1, globby@^11.1.0: - version "11.1.0" - resolved "https://registry.yarnpkg.com/globby/-/globby-11.1.0.tgz#bd4be98bb042f83d796f7e3811991fbe82a0d34b" - integrity sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g== - dependencies: - array-union "^2.1.0" - dir-glob "^3.0.1" - fast-glob "^3.2.9" - ignore "^5.2.0" - merge2 "^1.4.1" - slash "^3.0.0" - -graceful-fs@^4.2.9: - version "4.2.11" - resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" - integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== - -grapheme-splitter@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz#9cf3a665c6247479896834af35cf1dbb4400767e" - integrity sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ== - -har-schema@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92" - integrity sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q== - -har-validator@~5.1.3: - version "5.1.5" - resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-5.1.5.tgz#1f0803b9f8cb20c0fa13822df1ecddb36bde1efd" - integrity sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w== - dependencies: - ajv "^6.12.3" - har-schema "^2.0.0" - -hard-rejection@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/hard-rejection/-/hard-rejection-2.1.0.tgz#1c6eda5c1685c63942766d79bb40ae773cecd883" - integrity sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA== - -has-flag@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" - integrity sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw== - -has-flag@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" - integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== - -has@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796" - integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw== - dependencies: - function-bind "^1.1.1" - -hosted-git-info@^2.1.4: - version "2.8.9" - resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9" - integrity sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw== - -hosted-git-info@^4.0.1: - version "4.1.0" - resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-4.1.0.tgz#827b82867e9ff1c8d0c4d9d53880397d2c86d224" - integrity sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA== - dependencies: - lru-cache "^6.0.0" - -html-escaper@^2.0.0: - version "2.0.2" - resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453" - integrity sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg== - -http-signature@~1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1" - integrity sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ== - dependencies: - assert-plus "^1.0.0" - jsprim "^1.2.2" - sshpk "^1.7.0" - -human-signals@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" - integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw== - -ignore@^5.2.0: - version "5.2.4" - resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.4.tgz#a291c0c6178ff1b960befe47fcdec301674a6324" - integrity sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ== - -import-fresh@^3.0.0, import-fresh@^3.2.1: - version "3.3.0" - resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b" - integrity sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw== - dependencies: - parent-module "^1.0.0" - resolve-from "^4.0.0" - -import-local@^3.0.2: - version "3.1.0" - resolved "https://registry.yarnpkg.com/import-local/-/import-local-3.1.0.tgz#b4479df8a5fd44f6cdce24070675676063c95cb4" - integrity sha512-ASB07uLtnDs1o6EHjKpX34BKYDSqnFerfTOJL2HvMqF70LnxpjkzDB8J44oT9pu4AMPkQwf8jl6szgvNd2tRIg== - dependencies: - pkg-dir "^4.2.0" - resolve-cwd "^3.0.0" - -imurmurhash@^0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" - integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== - -indent-string@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-4.0.0.tgz#624f8f4497d619b2d9768531d58f4122854d7251" - integrity sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg== - -inflight@^1.0.4: - version "1.0.6" - resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" - integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA== - dependencies: - once "^1.3.0" - wrappy "1" - -inherits@2: - version "2.0.4" - resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" - integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== - -irregular-plurals@^3.2.0: - version "3.5.0" - resolved "https://registry.yarnpkg.com/irregular-plurals/-/irregular-plurals-3.5.0.tgz#0835e6639aa8425bdc8b0d33d0dc4e89d9c01d2b" - integrity sha512-1ANGLZ+Nkv1ptFb2pa8oG8Lem4krflKuX/gINiHJHjJUKaJHk/SXk5x6K3J+39/p0h1RQ2saROclJJ+QLvETCQ== - -is-arrayish@^0.2.1: - version "0.2.1" - resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" - integrity sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg== - -is-core-module@^2.11.0, is-core-module@^2.5.0: - version "2.12.0" - resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.12.0.tgz#36ad62f6f73c8253fd6472517a12483cf03e7ec4" - integrity sha512-RECHCBCd/viahWmwj6enj19sKbHfJrddi/6cBDsNTKbNq0f7VeaUkBo60BqzvPqo/W54ChS62Z5qyun7cfOMqQ== - dependencies: - has "^1.0.3" - -is-extglob@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" - integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== - -is-fullwidth-code-point@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" - integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== - -is-generator-fn@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/is-generator-fn/-/is-generator-fn-2.1.0.tgz#7d140adc389aaf3011a8f2a2a4cfa6faadffb118" - integrity sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ== - -is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3: - version "4.0.3" - resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" - integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== - dependencies: - is-extglob "^2.1.1" - -is-number@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" - integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== - -is-path-inside@^3.0.3: - version "3.0.3" - resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283" - integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ== - -is-plain-obj@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-1.1.0.tgz#71a50c8429dfca773c92a390a4a03b39fcd51d3e" - integrity sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg== - -is-stream@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077" - integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg== - -is-typedarray@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" - integrity sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA== - -is-unicode-supported@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz#3f26c76a809593b52bfa2ecb5710ed2779b522a7" - integrity sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw== - -isexe@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" - integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== - -isstream@~0.1.2: - version "0.1.2" - resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a" - integrity sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g== - -istanbul-lib-coverage@^3.0.0, istanbul-lib-coverage@^3.2.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz#189e7909d0a39fa5a3dfad5b03f71947770191d3" - integrity sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw== - -istanbul-lib-instrument@^5.0.4, istanbul-lib-instrument@^5.1.0: - version "5.2.1" - resolved "https://registry.yarnpkg.com/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz#d10c8885c2125574e1c231cacadf955675e1ce3d" - integrity sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg== - dependencies: - "@babel/core" "^7.12.3" - "@babel/parser" "^7.14.7" - "@istanbuljs/schema" "^0.1.2" - istanbul-lib-coverage "^3.2.0" - semver "^6.3.0" - -istanbul-lib-report@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz#7518fe52ea44de372f460a76b5ecda9ffb73d8a6" - integrity sha512-wcdi+uAKzfiGT2abPpKZ0hSU1rGQjUQnLvtY5MpQ7QCTahD3VODhcu4wcfY1YtkGaDD5yuydOLINXsfbus9ROw== - dependencies: - istanbul-lib-coverage "^3.0.0" - make-dir "^3.0.0" - supports-color "^7.1.0" - -istanbul-lib-source-maps@^4.0.0: - version "4.0.1" - resolved "https://registry.yarnpkg.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz#895f3a709fcfba34c6de5a42939022f3e4358551" - integrity sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw== - dependencies: - debug "^4.1.1" - istanbul-lib-coverage "^3.0.0" - source-map "^0.6.1" - -istanbul-reports@^3.1.3: - version "3.1.5" - resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-3.1.5.tgz#cc9a6ab25cb25659810e4785ed9d9fb742578bae" - integrity sha512-nUsEMa9pBt/NOHqbcbeJEgqIlY/K7rVWUX6Lql2orY5e9roQOthbR3vtY4zzf2orPELg80fnxxk9zUyPlgwD1w== - dependencies: - html-escaper "^2.0.0" - istanbul-lib-report "^3.0.0" - -jest-changed-files@^29.5.0: - version "29.5.0" - resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-29.5.0.tgz#e88786dca8bf2aa899ec4af7644e16d9dcf9b23e" - integrity sha512-IFG34IUMUaNBIxjQXF/iu7g6EcdMrGRRxaUSw92I/2g2YC6vCdTltl4nHvt7Ci5nSJwXIkCu8Ka1DKF+X7Z1Ag== - dependencies: - execa "^5.0.0" - p-limit "^3.1.0" - -jest-circus@^29.5.0: - version "29.5.0" - resolved "https://registry.yarnpkg.com/jest-circus/-/jest-circus-29.5.0.tgz#b5926989449e75bff0d59944bae083c9d7fb7317" - integrity sha512-gq/ongqeQKAplVxqJmbeUOJJKkW3dDNPY8PjhJ5G0lBRvu0e3EWGxGy5cI4LAGA7gV2UHCtWBI4EMXK8c9nQKA== - dependencies: - "@jest/environment" "^29.5.0" - "@jest/expect" "^29.5.0" - "@jest/test-result" "^29.5.0" - "@jest/types" "^29.5.0" - "@types/node" "*" - chalk "^4.0.0" - co "^4.6.0" - dedent "^0.7.0" - is-generator-fn "^2.0.0" - jest-each "^29.5.0" - jest-matcher-utils "^29.5.0" - jest-message-util "^29.5.0" - jest-runtime "^29.5.0" - jest-snapshot "^29.5.0" - jest-util "^29.5.0" - p-limit "^3.1.0" - pretty-format "^29.5.0" - pure-rand "^6.0.0" - slash "^3.0.0" - stack-utils "^2.0.3" - -jest-cli@^29.5.0: - version "29.5.0" - resolved "https://registry.yarnpkg.com/jest-cli/-/jest-cli-29.5.0.tgz#b34c20a6d35968f3ee47a7437ff8e53e086b4a67" - integrity sha512-L1KcP1l4HtfwdxXNFCL5bmUbLQiKrakMUriBEcc1Vfz6gx31ORKdreuWvmQVBit+1ss9NNR3yxjwfwzZNdQXJw== - dependencies: - "@jest/core" "^29.5.0" - "@jest/test-result" "^29.5.0" - "@jest/types" "^29.5.0" - chalk "^4.0.0" - exit "^0.1.2" - graceful-fs "^4.2.9" - import-local "^3.0.2" - jest-config "^29.5.0" - jest-util "^29.5.0" - jest-validate "^29.5.0" - prompts "^2.0.1" - yargs "^17.3.1" - -jest-config@^29.5.0: - version "29.5.0" - resolved "https://registry.yarnpkg.com/jest-config/-/jest-config-29.5.0.tgz#3cc972faec8c8aaea9ae158c694541b79f3748da" - integrity sha512-kvDUKBnNJPNBmFFOhDbm59iu1Fii1Q6SxyhXfvylq3UTHbg6o7j/g8k2dZyXWLvfdKB1vAPxNZnMgtKJcmu3kA== - dependencies: - "@babel/core" "^7.11.6" - "@jest/test-sequencer" "^29.5.0" - "@jest/types" "^29.5.0" - babel-jest "^29.5.0" - chalk "^4.0.0" - ci-info "^3.2.0" - deepmerge "^4.2.2" - glob "^7.1.3" - graceful-fs "^4.2.9" - jest-circus "^29.5.0" - jest-environment-node "^29.5.0" - jest-get-type "^29.4.3" - jest-regex-util "^29.4.3" - jest-resolve "^29.5.0" - jest-runner "^29.5.0" - jest-util "^29.5.0" - jest-validate "^29.5.0" - micromatch "^4.0.4" - parse-json "^5.2.0" - pretty-format "^29.5.0" - slash "^3.0.0" - strip-json-comments "^3.1.1" - -jest-diff@^29.5.0: - version "29.5.0" - resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-29.5.0.tgz#e0d83a58eb5451dcc1fa61b1c3ee4e8f5a290d63" - integrity sha512-LtxijLLZBduXnHSniy0WMdaHjmQnt3g5sa16W4p0HqukYTTsyTW3GD1q41TyGl5YFXj/5B2U6dlh5FM1LIMgxw== - dependencies: - chalk "^4.0.0" - diff-sequences "^29.4.3" - jest-get-type "^29.4.3" - pretty-format "^29.5.0" - -jest-docblock@^29.4.3: - version "29.4.3" - resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-29.4.3.tgz#90505aa89514a1c7dceeac1123df79e414636ea8" - integrity sha512-fzdTftThczeSD9nZ3fzA/4KkHtnmllawWrXO69vtI+L9WjEIuXWs4AmyME7lN5hU7dB0sHhuPfcKofRsUb/2Fg== - dependencies: - detect-newline "^3.0.0" - -jest-each@^29.5.0: - version "29.5.0" - resolved "https://registry.yarnpkg.com/jest-each/-/jest-each-29.5.0.tgz#fc6e7014f83eac68e22b7195598de8554c2e5c06" - integrity sha512-HM5kIJ1BTnVt+DQZ2ALp3rzXEl+g726csObrW/jpEGl+CDSSQpOJJX2KE/vEg8cxcMXdyEPu6U4QX5eruQv5hA== - dependencies: - "@jest/types" "^29.5.0" - chalk "^4.0.0" - jest-get-type "^29.4.3" - jest-util "^29.5.0" - pretty-format "^29.5.0" - -jest-environment-node@^29.5.0: - version "29.5.0" - resolved "https://registry.yarnpkg.com/jest-environment-node/-/jest-environment-node-29.5.0.tgz#f17219d0f0cc0e68e0727c58b792c040e332c967" - integrity sha512-ExxuIK/+yQ+6PRGaHkKewYtg6hto2uGCgvKdb2nfJfKXgZ17DfXjvbZ+jA1Qt9A8EQSfPnt5FKIfnOO3u1h9qw== - dependencies: - "@jest/environment" "^29.5.0" - "@jest/fake-timers" "^29.5.0" - "@jest/types" "^29.5.0" - "@types/node" "*" - jest-mock "^29.5.0" - jest-util "^29.5.0" - -jest-get-type@^29.4.3: - version "29.4.3" - resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-29.4.3.tgz#1ab7a5207c995161100b5187159ca82dd48b3dd5" - integrity sha512-J5Xez4nRRMjk8emnTpWrlkyb9pfRQQanDrvWHhsR1+VUfbwxi30eVcZFlcdGInRibU4G5LwHXpI7IRHU0CY+gg== - -jest-haste-map@^29.5.0: - version "29.5.0" - resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-29.5.0.tgz#69bd67dc9012d6e2723f20a945099e972b2e94de" - integrity sha512-IspOPnnBro8YfVYSw6yDRKh/TiCdRngjxeacCps1cQ9cgVN6+10JUcuJ1EabrgYLOATsIAigxA0rLR9x/YlrSA== - dependencies: - "@jest/types" "^29.5.0" - "@types/graceful-fs" "^4.1.3" - "@types/node" "*" - anymatch "^3.0.3" - fb-watchman "^2.0.0" - graceful-fs "^4.2.9" - jest-regex-util "^29.4.3" - jest-util "^29.5.0" - jest-worker "^29.5.0" - micromatch "^4.0.4" - walker "^1.0.8" - optionalDependencies: - fsevents "^2.3.2" - -jest-leak-detector@^29.5.0: - version "29.5.0" - resolved "https://registry.yarnpkg.com/jest-leak-detector/-/jest-leak-detector-29.5.0.tgz#cf4bdea9615c72bac4a3a7ba7e7930f9c0610c8c" - integrity sha512-u9YdeeVnghBUtpN5mVxjID7KbkKE1QU4f6uUwuxiY0vYRi9BUCLKlPEZfDGR67ofdFmDz9oPAy2G92Ujrntmow== - dependencies: - jest-get-type "^29.4.3" - pretty-format "^29.5.0" - -jest-matcher-utils@^29.5.0: - version "29.5.0" - resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-29.5.0.tgz#d957af7f8c0692c5453666705621ad4abc2c59c5" - integrity sha512-lecRtgm/rjIK0CQ7LPQwzCs2VwW6WAahA55YBuI+xqmhm7LAaxokSB8C97yJeYyT+HvQkH741StzpU41wohhWw== - dependencies: - chalk "^4.0.0" - jest-diff "^29.5.0" - jest-get-type "^29.4.3" - pretty-format "^29.5.0" - -jest-message-util@^29.5.0: - version "29.5.0" - resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-29.5.0.tgz#1f776cac3aca332ab8dd2e3b41625435085c900e" - integrity sha512-Kijeg9Dag6CKtIDA7O21zNTACqD5MD/8HfIV8pdD94vFyFuer52SigdC3IQMhab3vACxXMiFk+yMHNdbqtyTGA== - dependencies: - "@babel/code-frame" "^7.12.13" - "@jest/types" "^29.5.0" - "@types/stack-utils" "^2.0.0" - chalk "^4.0.0" - graceful-fs "^4.2.9" - micromatch "^4.0.4" - pretty-format "^29.5.0" - slash "^3.0.0" - stack-utils "^2.0.3" - -jest-mock@^29.5.0: - version "29.5.0" - resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-29.5.0.tgz#26e2172bcc71d8b0195081ff1f146ac7e1518aed" - integrity sha512-GqOzvdWDE4fAV2bWQLQCkujxYWL7RxjCnj71b5VhDAGOevB3qj3Ovg26A5NI84ZpODxyzaozXLOh2NCgkbvyaw== - dependencies: - "@jest/types" "^29.5.0" - "@types/node" "*" - jest-util "^29.5.0" - -jest-pnp-resolver@^1.2.2: - version "1.2.3" - resolved "https://registry.yarnpkg.com/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz#930b1546164d4ad5937d5540e711d4d38d4cad2e" - integrity sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w== - -jest-regex-util@^29.4.3: - version "29.4.3" - resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-29.4.3.tgz#a42616141e0cae052cfa32c169945d00c0aa0bb8" - integrity sha512-O4FglZaMmWXbGHSQInfXewIsd1LMn9p3ZXB/6r4FOkyhX2/iP/soMG98jGvk/A3HAN78+5VWcBGO0BJAPRh4kg== - -jest-resolve-dependencies@^29.5.0: - version "29.5.0" - resolved "https://registry.yarnpkg.com/jest-resolve-dependencies/-/jest-resolve-dependencies-29.5.0.tgz#f0ea29955996f49788bf70996052aa98e7befee4" - integrity sha512-sjV3GFr0hDJMBpYeUuGduP+YeCRbd7S/ck6IvL3kQ9cpySYKqcqhdLLC2rFwrcL7tz5vYibomBrsFYWkIGGjOg== - dependencies: - jest-regex-util "^29.4.3" - jest-snapshot "^29.5.0" - -jest-resolve@^29.5.0: - version "29.5.0" - resolved "https://registry.yarnpkg.com/jest-resolve/-/jest-resolve-29.5.0.tgz#b053cc95ad1d5f6327f0ac8aae9f98795475ecdc" - integrity sha512-1TzxJ37FQq7J10jPtQjcc+MkCkE3GBpBecsSUWJ0qZNJpmg6m0D9/7II03yJulm3H/fvVjgqLh/k2eYg+ui52w== - dependencies: - chalk "^4.0.0" - graceful-fs "^4.2.9" - jest-haste-map "^29.5.0" - jest-pnp-resolver "^1.2.2" - jest-util "^29.5.0" - jest-validate "^29.5.0" - resolve "^1.20.0" - resolve.exports "^2.0.0" - slash "^3.0.0" - -jest-runner@^29.5.0: - version "29.5.0" - resolved "https://registry.yarnpkg.com/jest-runner/-/jest-runner-29.5.0.tgz#6a57c282eb0ef749778d444c1d758c6a7693b6f8" - integrity sha512-m7b6ypERhFghJsslMLhydaXBiLf7+jXy8FwGRHO3BGV1mcQpPbwiqiKUR2zU2NJuNeMenJmlFZCsIqzJCTeGLQ== - dependencies: - "@jest/console" "^29.5.0" - "@jest/environment" "^29.5.0" - "@jest/test-result" "^29.5.0" - "@jest/transform" "^29.5.0" - "@jest/types" "^29.5.0" - "@types/node" "*" - chalk "^4.0.0" - emittery "^0.13.1" - graceful-fs "^4.2.9" - jest-docblock "^29.4.3" - jest-environment-node "^29.5.0" - jest-haste-map "^29.5.0" - jest-leak-detector "^29.5.0" - jest-message-util "^29.5.0" - jest-resolve "^29.5.0" - jest-runtime "^29.5.0" - jest-util "^29.5.0" - jest-watcher "^29.5.0" - jest-worker "^29.5.0" - p-limit "^3.1.0" - source-map-support "0.5.13" - -jest-runtime@^29.5.0: - version "29.5.0" - resolved "https://registry.yarnpkg.com/jest-runtime/-/jest-runtime-29.5.0.tgz#c83f943ee0c1da7eb91fa181b0811ebd59b03420" - integrity sha512-1Hr6Hh7bAgXQP+pln3homOiEZtCDZFqwmle7Ew2j8OlbkIu6uE3Y/etJQG8MLQs3Zy90xrp2C0BRrtPHG4zryw== - dependencies: - "@jest/environment" "^29.5.0" - "@jest/fake-timers" "^29.5.0" - "@jest/globals" "^29.5.0" - "@jest/source-map" "^29.4.3" - "@jest/test-result" "^29.5.0" - "@jest/transform" "^29.5.0" - "@jest/types" "^29.5.0" - "@types/node" "*" - chalk "^4.0.0" - cjs-module-lexer "^1.0.0" - collect-v8-coverage "^1.0.0" - glob "^7.1.3" - graceful-fs "^4.2.9" - jest-haste-map "^29.5.0" - jest-message-util "^29.5.0" - jest-mock "^29.5.0" - jest-regex-util "^29.4.3" - jest-resolve "^29.5.0" - jest-snapshot "^29.5.0" - jest-util "^29.5.0" - slash "^3.0.0" - strip-bom "^4.0.0" - -jest-snapshot@^29.5.0: - version "29.5.0" - resolved "https://registry.yarnpkg.com/jest-snapshot/-/jest-snapshot-29.5.0.tgz#c9c1ce0331e5b63cd444e2f95a55a73b84b1e8ce" - integrity sha512-x7Wolra5V0tt3wRs3/ts3S6ciSQVypgGQlJpz2rsdQYoUKxMxPNaoHMGJN6qAuPJqS+2iQ1ZUn5kl7HCyls84g== - dependencies: - "@babel/core" "^7.11.6" - "@babel/generator" "^7.7.2" - "@babel/plugin-syntax-jsx" "^7.7.2" - "@babel/plugin-syntax-typescript" "^7.7.2" - "@babel/traverse" "^7.7.2" - "@babel/types" "^7.3.3" - "@jest/expect-utils" "^29.5.0" - "@jest/transform" "^29.5.0" - "@jest/types" "^29.5.0" - "@types/babel__traverse" "^7.0.6" - "@types/prettier" "^2.1.5" - babel-preset-current-node-syntax "^1.0.0" - chalk "^4.0.0" - expect "^29.5.0" - graceful-fs "^4.2.9" - jest-diff "^29.5.0" - jest-get-type "^29.4.3" - jest-matcher-utils "^29.5.0" - jest-message-util "^29.5.0" - jest-util "^29.5.0" - natural-compare "^1.4.0" - pretty-format "^29.5.0" - semver "^7.3.5" - -jest-util@^29.0.0, jest-util@^29.5.0: - version "29.5.0" - resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-29.5.0.tgz#24a4d3d92fc39ce90425311b23c27a6e0ef16b8f" - integrity sha512-RYMgG/MTadOr5t8KdhejfvUU82MxsCu5MF6KuDUHl+NuwzUt+Sm6jJWxTJVrDR1j5M/gJVCPKQEpWXY+yIQ6lQ== - dependencies: - "@jest/types" "^29.5.0" - "@types/node" "*" - chalk "^4.0.0" - ci-info "^3.2.0" - graceful-fs "^4.2.9" - picomatch "^2.2.3" - -jest-validate@^29.5.0: - version "29.5.0" - resolved "https://registry.yarnpkg.com/jest-validate/-/jest-validate-29.5.0.tgz#8e5a8f36178d40e47138dc00866a5f3bd9916ffc" - integrity sha512-pC26etNIi+y3HV8A+tUGr/lph9B18GnzSRAkPaaZJIE1eFdiYm6/CewuiJQ8/RlfHd1u/8Ioi8/sJ+CmbA+zAQ== - dependencies: - "@jest/types" "^29.5.0" - camelcase "^6.2.0" - chalk "^4.0.0" - jest-get-type "^29.4.3" - leven "^3.1.0" - pretty-format "^29.5.0" - -jest-watcher@^29.5.0: - version "29.5.0" - resolved "https://registry.yarnpkg.com/jest-watcher/-/jest-watcher-29.5.0.tgz#cf7f0f949828ba65ddbbb45c743a382a4d911363" - integrity sha512-KmTojKcapuqYrKDpRwfqcQ3zjMlwu27SYext9pt4GlF5FUgB+7XE1mcCnSm6a4uUpFyQIkb6ZhzZvHl+jiBCiA== - dependencies: - "@jest/test-result" "^29.5.0" - "@jest/types" "^29.5.0" - "@types/node" "*" - ansi-escapes "^4.2.1" - chalk "^4.0.0" - emittery "^0.13.1" - jest-util "^29.5.0" - string-length "^4.0.1" - -jest-worker@^29.5.0: - version "29.5.0" - resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-29.5.0.tgz#bdaefb06811bd3384d93f009755014d8acb4615d" - integrity sha512-NcrQnevGoSp4b5kg+akIpthoAFHxPBcb5P6mYPY0fUNT+sSvmtu6jlkEle3anczUKIKEbMxFimk9oTP/tpIPgA== - dependencies: - "@types/node" "*" - jest-util "^29.5.0" - merge-stream "^2.0.0" - supports-color "^8.0.0" - -jest@^29.2.2: - version "29.5.0" - resolved "https://registry.yarnpkg.com/jest/-/jest-29.5.0.tgz#f75157622f5ce7ad53028f2f8888ab53e1f1f24e" - integrity sha512-juMg3he2uru1QoXX078zTa7pO85QyB9xajZc6bU+d9yEGwrKX6+vGmJQ3UdVZsvTEUARIdObzH68QItim6OSSQ== - dependencies: - "@jest/core" "^29.5.0" - "@jest/types" "^29.5.0" - import-local "^3.0.2" - jest-cli "^29.5.0" - -js-sdsl@^4.1.4: - version "4.4.0" - resolved "https://registry.yarnpkg.com/js-sdsl/-/js-sdsl-4.4.0.tgz#8b437dbe642daa95760400b602378ed8ffea8430" - integrity sha512-FfVSdx6pJ41Oa+CF7RDaFmTnCaFhua+SNYQX74riGOpl96x+2jQCqEfQ2bnXu/5DPCqlRuiqyvTJM0Qjz26IVg== - -js-tokens@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" - integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== - -js-yaml@^3.13.1: - version "3.14.1" - resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.1.tgz#dae812fdb3825fa306609a8717383c50c36a0537" - integrity sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g== - dependencies: - argparse "^1.0.7" - esprima "^4.0.0" - -js-yaml@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" - integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== - dependencies: - argparse "^2.0.1" - -jsbn@~0.1.0: - version "0.1.1" - resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" - integrity sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg== - -jsesc@^2.5.1: - version "2.5.2" - resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4" - integrity sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA== - -json-parse-even-better-errors@^2.3.0: - version "2.3.1" - resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d" - integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w== - -json-schema-traverse@^0.4.1: - version "0.4.1" - resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" - integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== - -json-schema@0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.4.0.tgz#f7de4cf6efab838ebaeb3236474cbba5a1930ab5" - integrity sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA== - -json-stable-stringify-without-jsonify@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" - integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw== - -json-stringify-safe@~5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" - integrity sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA== - -json5@^2.2.2, json5@^2.2.3: - version "2.2.3" - resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" - integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== - -jsprim@^1.2.2: - version "1.4.2" - resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.2.tgz#712c65533a15c878ba59e9ed5f0e26d5b77c5feb" - integrity sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw== - dependencies: - assert-plus "1.0.0" - extsprintf "1.3.0" - json-schema "0.4.0" - verror "1.10.0" - -kind-of@^6.0.3: - version "6.0.3" - resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd" - integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw== - -kleur@^3.0.3: - version "3.0.3" - resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e" - integrity sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w== - -lcov-parse@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/lcov-parse/-/lcov-parse-1.0.0.tgz#eb0d46b54111ebc561acb4c408ef9363bdc8f7e0" - integrity sha512-aprLII/vPzuQvYZnDRU78Fns9I2Ag3gi4Ipga/hxnVMCZC8DnR2nI7XBqrPoywGfxqIx/DgarGvDJZAD3YBTgQ== - -leven@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/leven/-/leven-3.1.0.tgz#77891de834064cccba82ae7842bb6b14a13ed7f2" - integrity sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A== - -levn@^0.4.1: - version "0.4.1" - resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade" - integrity sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ== - dependencies: - prelude-ls "^1.2.1" - type-check "~0.4.0" - -lines-and-columns@^1.1.6: - version "1.2.4" - resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" - integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== - -locate-path@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0" - integrity sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g== - dependencies: - p-locate "^4.1.0" - -locate-path@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286" - integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== - dependencies: - p-locate "^5.0.0" - -lodash.memoize@4.x: - version "4.1.2" - resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" - integrity sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag== - -lodash.merge@^4.6.2: - version "4.6.2" - resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" - integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== - -log-driver@^1.2.7: - version "1.2.7" - resolved "https://registry.yarnpkg.com/log-driver/-/log-driver-1.2.7.tgz#63b95021f0702fedfa2c9bb0a24e7797d71871d8" - integrity sha512-U7KCmLdqsGHBLeWqYlFA0V0Sl6P08EE1ZrmA9cxjUE0WVqT9qnyVDPz1kzpFEP0jdJuFnasWIfSd7fsaNXkpbg== - -log-symbols@^4.0.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-4.1.0.tgz#3fbdbb95b4683ac9fc785111e792e558d4abd503" - integrity sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg== - dependencies: - chalk "^4.1.0" - is-unicode-supported "^0.1.0" - -lru-cache@^5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920" - integrity sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w== - dependencies: - yallist "^3.0.2" - -lru-cache@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94" - integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA== - dependencies: - yallist "^4.0.0" - -make-dir@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f" - integrity sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw== - dependencies: - semver "^6.0.0" - -make-error@1.x: - version "1.3.6" - resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2" - integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw== - -makeerror@1.0.12: - version "1.0.12" - resolved "https://registry.yarnpkg.com/makeerror/-/makeerror-1.0.12.tgz#3e5dd2079a82e812e983cc6610c4a2cb0eaa801a" - integrity sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg== - dependencies: - tmpl "1.0.5" - -map-obj@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/map-obj/-/map-obj-1.0.1.tgz#d933ceb9205d82bdcf4886f6742bdc2b4dea146d" - integrity sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg== - -map-obj@^4.0.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/map-obj/-/map-obj-4.3.0.tgz#9304f906e93faae70880da102a9f1df0ea8bb05a" - integrity sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ== - -meow@^9.0.0: - version "9.0.0" - resolved "https://registry.yarnpkg.com/meow/-/meow-9.0.0.tgz#cd9510bc5cac9dee7d03c73ee1f9ad959f4ea364" - integrity sha512-+obSblOQmRhcyBt62furQqRAQpNyWXo8BuQ5bN7dG8wmwQ+vwHKp/rCFD4CrTP8CsDQD1sjoZ94K417XEUk8IQ== - dependencies: - "@types/minimist" "^1.2.0" - camelcase-keys "^6.2.2" - decamelize "^1.2.0" - decamelize-keys "^1.1.0" - hard-rejection "^2.1.0" - minimist-options "4.1.0" - normalize-package-data "^3.0.0" - read-pkg-up "^7.0.1" - redent "^3.0.0" - trim-newlines "^3.0.0" - type-fest "^0.18.0" - yargs-parser "^20.2.3" - -merge-stream@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" - integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w== - -merge2@^1.3.0, merge2@^1.4.1: - version "1.4.1" - resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" - integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== - -micromatch@^4.0.4: - version "4.0.5" - resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.5.tgz#bc8999a7cbbf77cdc89f132f6e467051b49090c6" - integrity sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA== - dependencies: - braces "^3.0.2" - picomatch "^2.3.1" - -mime-db@1.52.0: - version "1.52.0" - resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" - integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== - -mime-types@^2.1.12, mime-types@~2.1.19: - version "2.1.35" - resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" - integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== - dependencies: - mime-db "1.52.0" - -mimic-fn@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" - integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== - -min-indent@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/min-indent/-/min-indent-1.0.1.tgz#a63f681673b30571fbe8bc25686ae746eefa9869" - integrity sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg== - -minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2: - version "3.1.2" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" - integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== - dependencies: - brace-expansion "^1.1.7" - -minimist-options@4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/minimist-options/-/minimist-options-4.1.0.tgz#c0655713c53a8a2ebd77ffa247d342c40f010619" - integrity sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A== - dependencies: - arrify "^1.0.1" - is-plain-obj "^1.1.0" - kind-of "^6.0.3" - -minimist@^1.2.5: - version "1.2.8" - resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" - integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== - -mnemonist@0.38.3: - version "0.38.3" - resolved "https://registry.yarnpkg.com/mnemonist/-/mnemonist-0.38.3.tgz#35ec79c1c1f4357cfda2fe264659c2775ccd7d9d" - integrity sha512-2K9QYubXx/NAjv4VLq1d1Ly8pWNC5L3BrixtdkyTegXWJIqY+zLNDhhX/A+ZwWt70tB1S8H4BE8FLYEFyNoOBw== - dependencies: - obliterator "^1.6.1" - -ms@2.1.2: - version "2.1.2" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" - integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== - -natural-compare-lite@^1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/natural-compare-lite/-/natural-compare-lite-1.4.0.tgz#17b09581988979fddafe0201e931ba933c96cbb4" - integrity sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g== - -natural-compare@^1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" - integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== - -node-int64@^0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" - integrity sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw== - -node-releases@^2.0.8: - version "2.0.10" - resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.10.tgz#c311ebae3b6a148c89b1813fd7c4d3c024ef537f" - integrity sha512-5GFldHPXVG/YZmFzJvKK2zDSzPKhEp0+ZR5SVaoSag9fsL5YgHbUHDfnG5494ISANDcK4KwPXAx2xqVEydmd7w== - -normalize-package-data@^2.5.0: - version "2.5.0" - resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.5.0.tgz#e66db1838b200c1dfc233225d12cb36520e234a8" - integrity sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA== - dependencies: - hosted-git-info "^2.1.4" - resolve "^1.10.0" - semver "2 || 3 || 4 || 5" - validate-npm-package-license "^3.0.1" - -normalize-package-data@^3.0.0: - version "3.0.3" - resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-3.0.3.tgz#dbcc3e2da59509a0983422884cd172eefdfa525e" - integrity sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA== - dependencies: - hosted-git-info "^4.0.1" - is-core-module "^2.5.0" - semver "^7.3.4" - validate-npm-package-license "^3.0.1" - -normalize-path@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" - integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== - -npm-run-path@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea" - integrity sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw== - dependencies: - path-key "^3.0.0" - -oauth-sign@~0.9.0: - version "0.9.0" - resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455" - integrity sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ== - -obliterator@^1.6.1: - version "1.6.1" - resolved "https://registry.yarnpkg.com/obliterator/-/obliterator-1.6.1.tgz#dea03e8ab821f6c4d96a299e17aef6a3af994ef3" - integrity sha512-9WXswnqINnnhOG/5SLimUlzuU1hFJUc8zkwyD59Sd+dPOMf05PmnYG/d6Q7HZ+KmgkZJa1PxRso6QdM3sTNHig== - -once@^1.3.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" - integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== - dependencies: - wrappy "1" - -onetime@^5.1.2: - version "5.1.2" - resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e" - integrity sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg== - dependencies: - mimic-fn "^2.1.0" - -optionator@^0.9.1: - version "0.9.1" - resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.1.tgz#4f236a6373dae0566a6d43e1326674f50c291499" - integrity sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw== - dependencies: - deep-is "^0.1.3" - fast-levenshtein "^2.0.6" - levn "^0.4.1" - prelude-ls "^1.2.1" - type-check "^0.4.0" - word-wrap "^1.2.3" - -p-limit@^2.2.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" - integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w== - dependencies: - p-try "^2.0.0" - -p-limit@^3.0.2, p-limit@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" - integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== - dependencies: - yocto-queue "^0.1.0" - -p-locate@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07" - integrity sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A== - dependencies: - p-limit "^2.2.0" - -p-locate@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834" - integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== - dependencies: - p-limit "^3.0.2" - -p-try@^2.0.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" - integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== - -parent-module@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2" - integrity sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g== - dependencies: - callsites "^3.0.0" - -parse-json@^5.0.0, parse-json@^5.2.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.2.0.tgz#c76fc66dee54231c962b22bcc8a72cf2f99753cd" - integrity sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg== - dependencies: - "@babel/code-frame" "^7.0.0" - error-ex "^1.3.1" - json-parse-even-better-errors "^2.3.0" - lines-and-columns "^1.1.6" - -path-exists@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" - integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== - -path-is-absolute@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" - integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg== - -path-key@^3.0.0, path-key@^3.1.0: - version "3.1.1" - resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" - integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== - -path-parse@^1.0.7: - version "1.0.7" - resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" - integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== - -path-type@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" - integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== - -performance-now@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" - integrity sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow== - -picocolors@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" - integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ== - -picomatch@^2.0.4, picomatch@^2.2.3, picomatch@^2.3.1: - version "2.3.1" - resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" - integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== - -pirates@^4.0.4: - version "4.0.5" - resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.5.tgz#feec352ea5c3268fb23a37c702ab1699f35a5f3b" - integrity sha512-8V9+HQPupnaXMA23c5hvl69zXvTwTzyAYasnkb0Tts4XvO4CliqONMOnvlq26rkhLC3nWDFBJf73LU1e1VZLaQ== - -pkg-dir@^4.2.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3" - integrity sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ== - dependencies: - find-up "^4.0.0" - -plur@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/plur/-/plur-4.0.0.tgz#729aedb08f452645fe8c58ef115bf16b0a73ef84" - integrity sha512-4UGewrYgqDFw9vV6zNV+ADmPAUAfJPKtGvb/VdpQAx25X5f3xXdGdyOEVFwkl8Hl/tl7+xbeHqSEM+D5/TirUg== - dependencies: - irregular-plurals "^3.2.0" - -prelude-ls@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" - integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== - -prettier@^2.2.1: - version "2.8.7" - resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.8.7.tgz#bb79fc8729308549d28fe3a98fce73d2c0656450" - integrity sha512-yPngTo3aXUUmyuTjeTUT75txrf+aMh9FiD7q9ZE/i6r0bPb22g4FsE6Y338PQX1bmfy08i9QQCB7/rcUAVntfw== - -pretty-format@^29.0.0, pretty-format@^29.5.0: - version "29.5.0" - resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.5.0.tgz#283134e74f70e2e3e7229336de0e4fce94ccde5a" - integrity sha512-V2mGkI31qdttvTFX7Mt4efOqHXqJWMu4/r66Xh3Z3BwZaPfPJgp6/gbwoujRpPUtfEF6AUUWx3Jim3GCw5g/Qw== - dependencies: - "@jest/schemas" "^29.4.3" - ansi-styles "^5.0.0" - react-is "^18.0.0" - -prompts@^2.0.1: - version "2.4.2" - resolved "https://registry.yarnpkg.com/prompts/-/prompts-2.4.2.tgz#7b57e73b3a48029ad10ebd44f74b01722a4cb069" - integrity sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q== - dependencies: - kleur "^3.0.3" - sisteransi "^1.0.5" - -psl@^1.1.28: - version "1.9.0" - resolved "https://registry.yarnpkg.com/psl/-/psl-1.9.0.tgz#d0df2a137f00794565fcaf3b2c00cd09f8d5a5a7" - integrity sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag== - -punycode@^2.1.0, punycode@^2.1.1: - version "2.3.0" - resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.0.tgz#f67fa67c94da8f4d0cfff981aee4118064199b8f" - integrity sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA== - -pure-rand@^6.0.0: - version "6.0.1" - resolved "https://registry.yarnpkg.com/pure-rand/-/pure-rand-6.0.1.tgz#31207dddd15d43f299fdcdb2f572df65030c19af" - integrity sha512-t+x1zEHDjBwkDGY5v5ApnZ/utcd4XYDiJsaQQoptTXgUXX95sDg1elCdJghzicm7n2mbCBJ3uYWr6M22SO19rg== - -qs@~6.5.2: - version "6.5.3" - resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.3.tgz#3aeeffc91967ef6e35c0e488ef46fb296ab76aad" - integrity sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA== - -queue-microtask@^1.2.2: - version "1.2.3" - resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" - integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== - -quick-lru@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-4.0.1.tgz#5b8878f113a58217848c6482026c73e1ba57727f" - integrity sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g== - -react-is@^18.0.0: - version "18.2.0" - resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b" - integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w== - -read-pkg-up@^7.0.0, read-pkg-up@^7.0.1: - version "7.0.1" - resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-7.0.1.tgz#f3a6135758459733ae2b95638056e1854e7ef507" - integrity sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg== - dependencies: - find-up "^4.1.0" - read-pkg "^5.2.0" - type-fest "^0.8.1" - -read-pkg@^5.2.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-5.2.0.tgz#7bf295438ca5a33e56cd30e053b34ee7250c93cc" - integrity sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg== - dependencies: - "@types/normalize-package-data" "^2.4.0" - normalize-package-data "^2.5.0" - parse-json "^5.0.0" - type-fest "^0.6.0" - -redent@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/redent/-/redent-3.0.0.tgz#e557b7998316bb53c9f1f56fa626352c6963059f" - integrity sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg== - dependencies: - indent-string "^4.0.0" - strip-indent "^3.0.0" - -request@^2.88.2: - version "2.88.2" - resolved "https://registry.yarnpkg.com/request/-/request-2.88.2.tgz#d73c918731cb5a87da047e207234146f664d12b3" - integrity sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw== - dependencies: - aws-sign2 "~0.7.0" - aws4 "^1.8.0" - caseless "~0.12.0" - combined-stream "~1.0.6" - extend "~3.0.2" - forever-agent "~0.6.1" - form-data "~2.3.2" - har-validator "~5.1.3" - http-signature "~1.2.0" - is-typedarray "~1.0.0" - isstream "~0.1.2" - json-stringify-safe "~5.0.1" - mime-types "~2.1.19" - oauth-sign "~0.9.0" - performance-now "^2.1.0" - qs "~6.5.2" - safe-buffer "^5.1.2" - tough-cookie "~2.5.0" - tunnel-agent "^0.6.0" - uuid "^3.3.2" - -require-directory@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" - integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== - -resolve-cwd@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/resolve-cwd/-/resolve-cwd-3.0.0.tgz#0f0075f1bb2544766cf73ba6a6e2adfebcb13f2d" - integrity sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg== - dependencies: - resolve-from "^5.0.0" - -resolve-from@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" - integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== - -resolve-from@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69" - integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== - -resolve.exports@^2.0.0: - version "2.0.2" - resolved "https://registry.yarnpkg.com/resolve.exports/-/resolve.exports-2.0.2.tgz#f8c934b8e6a13f539e38b7098e2e36134f01e800" - integrity sha512-X2UW6Nw3n/aMgDVy+0rSqgHlv39WZAlZrXCdnbyEiKm17DSqHX4MmQMaST3FbeWR5FTuRcUwYAziZajji0Y7mg== - -resolve@^1.10.0, resolve@^1.20.0: - version "1.22.2" - resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.2.tgz#0ed0943d4e301867955766c9f3e1ae6d01c6845f" - integrity sha512-Sb+mjNHOULsBv818T40qSPeRiuWLyaGMa5ewydRLFimneixmVy2zdivRl+AF6jaYPC8ERxGDmFSiqui6SfPd+g== - dependencies: - is-core-module "^2.11.0" - path-parse "^1.0.7" - supports-preserve-symlinks-flag "^1.0.0" - -reusify@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" - integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw== - -rimraf@^3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" - integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== - dependencies: - glob "^7.1.3" - -run-parallel@^1.1.9: - version "1.2.0" - resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee" - integrity sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA== - dependencies: - queue-microtask "^1.2.2" - -safe-buffer@^5.0.1, safe-buffer@^5.1.2: - version "5.2.1" - resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" - integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== - -safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: - version "2.1.2" - resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" - integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== - -"semver@2 || 3 || 4 || 5": - version "5.7.1" - resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" - integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== - -semver@7.x, semver@^7.3.4, semver@^7.3.5, semver@^7.3.7: - version "7.4.0" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.4.0.tgz#8481c92feffc531ab1e012a8ffc15bdd3a0f4318" - integrity sha512-RgOxM8Mw+7Zus0+zcLEUn8+JfoLpj/huFTItQy2hsM4khuC1HYRDp0cU482Ewn/Fcy6bCjufD8vAj7voC66KQw== - dependencies: - lru-cache "^6.0.0" - -semver@^6.0.0, semver@^6.3.0: - version "6.3.0" - resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" - integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== - -shebang-command@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" - integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== - dependencies: - shebang-regex "^3.0.0" - -shebang-regex@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" - integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== - -signal-exit@^3.0.3, signal-exit@^3.0.7: - version "3.0.7" - resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" - integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== - -sisteransi@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed" - integrity sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg== - -slash@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" - integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== - -source-map-support@0.5.13: - version "0.5.13" - resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.13.tgz#31b24a9c2e73c2de85066c0feb7d44767ed52932" - integrity sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w== - dependencies: - buffer-from "^1.0.0" - source-map "^0.6.0" - -source-map@^0.6.0, source-map@^0.6.1: - version "0.6.1" - resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" - integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== - -spdx-correct@^3.0.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.2.0.tgz#4f5ab0668f0059e34f9c00dce331784a12de4e9c" - integrity sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA== - dependencies: - spdx-expression-parse "^3.0.0" - spdx-license-ids "^3.0.0" - -spdx-exceptions@^2.1.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz#3f28ce1a77a00372683eade4a433183527a2163d" - integrity sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A== - -spdx-expression-parse@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz#cf70f50482eefdc98e3ce0a6833e4a53ceeba679" - integrity sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q== - dependencies: - spdx-exceptions "^2.1.0" - spdx-license-ids "^3.0.0" - -spdx-license-ids@^3.0.0: - version "3.0.13" - resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.13.tgz#7189a474c46f8d47c7b0da4b987bb45e908bd2d5" - integrity sha512-XkD+zwiqXHikFZm4AX/7JSCXA98U5Db4AFd5XUg/+9UNtnH75+Z9KxtpYiJZx36mUDVOwH83pl7yvCer6ewM3w== - -sprintf-js@~1.0.2: - version "1.0.3" - resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" - integrity sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g== - -sshpk@^1.7.0: - version "1.17.0" - resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.17.0.tgz#578082d92d4fe612b13007496e543fa0fbcbe4c5" - integrity sha512-/9HIEs1ZXGhSPE8X6Ccm7Nam1z8KcoCqPdI7ecm1N33EzAetWahvQWVqLZtaZQ+IDKX4IyA2o0gBzqIMkAagHQ== - dependencies: - asn1 "~0.2.3" - assert-plus "^1.0.0" - bcrypt-pbkdf "^1.0.0" - dashdash "^1.12.0" - ecc-jsbn "~0.1.1" - getpass "^0.1.1" - jsbn "~0.1.0" - safer-buffer "^2.0.2" - tweetnacl "~0.14.0" - -stack-utils@^2.0.3: - version "2.0.6" - resolved "https://registry.yarnpkg.com/stack-utils/-/stack-utils-2.0.6.tgz#aaf0748169c02fc33c8232abccf933f54a1cc34f" - integrity sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ== - dependencies: - escape-string-regexp "^2.0.0" - -string-length@^4.0.1: - version "4.0.2" - resolved "https://registry.yarnpkg.com/string-length/-/string-length-4.0.2.tgz#a8a8dc7bd5c1a82b9b3c8b87e125f66871b6e57a" - integrity sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ== - dependencies: - char-regex "^1.0.2" - strip-ansi "^6.0.0" - -string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-bom@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-4.0.0.tgz#9c3505c1db45bcedca3d9cf7a16f5c5aa3901878" - integrity sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w== - -strip-final-newline@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad" - integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA== - -strip-indent@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-3.0.0.tgz#c32e1cee940b6b3432c771bc2c54bcce73cd3001" - integrity sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ== - dependencies: - min-indent "^1.0.0" - -strip-json-comments@^3.1.0, strip-json-comments@^3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" - integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== - -strnum@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/strnum/-/strnum-1.0.5.tgz#5c4e829fe15ad4ff0d20c3db5ac97b73c9b072db" - integrity sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA== - -supports-color@^5.3.0: - version "5.5.0" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" - integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== - dependencies: - has-flag "^3.0.0" - -supports-color@^7.0.0, supports-color@^7.1.0: - version "7.2.0" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" - integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== - dependencies: - has-flag "^4.0.0" - -supports-color@^8.0.0: - version "8.1.1" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" - integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== - dependencies: - has-flag "^4.0.0" - -supports-hyperlinks@^2.0.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/supports-hyperlinks/-/supports-hyperlinks-2.3.0.tgz#3943544347c1ff90b15effb03fc14ae45ec10624" - integrity sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA== - dependencies: - has-flag "^4.0.0" - supports-color "^7.0.0" - -supports-preserve-symlinks-flag@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" - integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== - -test-exclude@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-6.0.0.tgz#04a8698661d805ea6fa293b6cb9e63ac044ef15e" - integrity sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w== - dependencies: - "@istanbuljs/schema" "^0.1.2" - glob "^7.1.4" - minimatch "^3.0.4" - -text-table@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" - integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw== - -tmpl@1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.5.tgz#8683e0b902bb9c20c4f726e3c0b69f36518c07cc" - integrity sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw== - -to-fast-properties@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e" - integrity sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog== - -to-regex-range@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" - integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== - dependencies: - is-number "^7.0.0" - -tough-cookie@~2.5.0: - version "2.5.0" - resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.5.0.tgz#cd9fb2a0aa1d5a12b473bd9fb96fa3dcff65ade2" - integrity sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g== - dependencies: - psl "^1.1.28" - punycode "^2.1.1" - -trim-newlines@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-3.0.1.tgz#260a5d962d8b752425b32f3a7db0dcacd176c144" - integrity sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw== - -ts-jest@^29.0.3: - version "29.1.0" - resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-29.1.0.tgz#4a9db4104a49b76d2b368ea775b6c9535c603891" - integrity sha512-ZhNr7Z4PcYa+JjMl62ir+zPiNJfXJN6E8hSLnaUKhOgqcn8vb3e537cpkd0FuAfRK3sR1LSqM1MOhliXNgOFPA== - dependencies: - bs-logger "0.x" - fast-json-stable-stringify "2.x" - jest-util "^29.0.0" - json5 "^2.2.3" - lodash.memoize "4.x" - make-error "1.x" - semver "7.x" - yargs-parser "^21.0.1" - -ts-toolbelt@^9.6.0: - version "9.6.0" - resolved "https://registry.yarnpkg.com/ts-toolbelt/-/ts-toolbelt-9.6.0.tgz#50a25426cfed500d4a09bd1b3afb6f28879edfd5" - integrity sha512-nsZd8ZeNUzukXPlJmTBwUAuABDe/9qtVDelJeT/qW0ow3ZS3BsQJtNkan1802aM9Uf68/Y8ljw86Hu0h5IUW3w== - -tsd@^0.23.0: - version "0.23.0" - resolved "https://registry.yarnpkg.com/tsd/-/tsd-0.23.0.tgz#52109a9564a77435f6972fafc4a22c8cd57e39e6" - integrity sha512-dY4p7LbshRQ1hizr+xlbebgkfB0kT8wnQZW7LjBrOsmbws5mt1YYY4VSKoLYXyzYxObIOSQ3qns+tX8tP0Mz6g== - dependencies: - "@tsd/typescript" "^4.8.2" - eslint-formatter-pretty "^4.1.0" - globby "^11.0.1" - meow "^9.0.0" - path-exists "^4.0.0" - read-pkg-up "^7.0.0" - -tslib@^1.11.1, tslib@^1.8.1: - version "1.14.1" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" - integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== - -tslib@^2.3.1, tslib@^2.5.0: - version "2.5.0" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.5.0.tgz#42bfed86f5787aeb41d031866c8f402429e0fddf" - integrity sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg== - -tsutils@^3.21.0: - version "3.21.0" - resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623" - integrity sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA== - dependencies: - tslib "^1.8.1" - -tunnel-agent@^0.6.0: - version "0.6.0" - resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd" - integrity sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w== - dependencies: - safe-buffer "^5.0.1" - -tweetnacl@^0.14.3, tweetnacl@~0.14.0: - version "0.14.5" - resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64" - integrity sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA== - -type-check@^0.4.0, type-check@~0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1" - integrity sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew== - dependencies: - prelude-ls "^1.2.1" - -type-detect@4.0.8: - version "4.0.8" - resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" - integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g== - -type-fest@^0.18.0: - version "0.18.1" - resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.18.1.tgz#db4bc151a4a2cf4eebf9add5db75508db6cc841f" - integrity sha512-OIAYXk8+ISY+qTOwkHtKqzAuxchoMiD9Udx+FSGQDuiRR+PJKJHc2NJAXlbhkGwTt/4/nKZxELY1w3ReWOL8mw== - -type-fest@^0.20.2: - version "0.20.2" - resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4" - integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ== - -type-fest@^0.21.3: - version "0.21.3" - resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.21.3.tgz#d260a24b0198436e133fa26a524a6d65fa3b2e37" - integrity sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w== - -type-fest@^0.6.0: - version "0.6.0" - resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.6.0.tgz#8d2a2370d3df886eb5c90ada1c5bf6188acf838b" - integrity sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg== - -type-fest@^0.8.1: - version "0.8.1" - resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d" - integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA== - -typescript@^4.1.3: - version "4.9.5" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a" - integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g== - -update-browserslist-db@^1.0.10: - version "1.0.10" - resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.10.tgz#0f54b876545726f17d00cd9a2561e6dade943ff3" - integrity sha512-OztqDenkfFkbSG+tRxBeAnCVPckDBcvibKd35yDONx6OU8N7sqgwc7rCbkJ/WcYtVRZ4ba68d6byhC21GFh7sQ== - dependencies: - escalade "^3.1.1" - picocolors "^1.0.0" - -uri-js@^4.2.2: - version "4.4.1" - resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e" - integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== - dependencies: - punycode "^2.1.0" - -uuid@^3.3.2: - version "3.4.0" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" - integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A== - -uuid@^8.3.2: - version "8.3.2" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" - integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== - -v8-to-istanbul@^9.0.1: - version "9.1.0" - resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-9.1.0.tgz#1b83ed4e397f58c85c266a570fc2558b5feb9265" - integrity sha512-6z3GW9x8G1gd+JIIgQQQxXuiJtCXeAjp6RaPEPLv62mH3iPHPxV6W3robxtCzNErRo6ZwTmzWhsbNvjyEBKzKA== - dependencies: - "@jridgewell/trace-mapping" "^0.3.12" - "@types/istanbul-lib-coverage" "^2.0.1" - convert-source-map "^1.6.0" - -validate-npm-package-license@^3.0.1: - version "3.0.4" - resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a" - integrity sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew== - dependencies: - spdx-correct "^3.0.0" - spdx-expression-parse "^3.0.0" - -verror@1.10.0: - version "1.10.0" - resolved "https://registry.yarnpkg.com/verror/-/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400" - integrity sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw== - dependencies: - assert-plus "^1.0.0" - core-util-is "1.0.2" - extsprintf "^1.2.0" - -walker@^1.0.8: - version "1.0.8" - resolved "https://registry.yarnpkg.com/walker/-/walker-1.0.8.tgz#bd498db477afe573dc04185f011d3ab8a8d7653f" - integrity sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ== - dependencies: - makeerror "1.0.12" - -which@^2.0.1: - version "2.0.2" - resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" - integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== - dependencies: - isexe "^2.0.0" - -word-wrap@^1.2.3: - version "1.2.3" - resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c" - integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ== - -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - -wrappy@1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" - integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== - -write-file-atomic@^4.0.2: - version "4.0.2" - resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-4.0.2.tgz#a9df01ae5b77858a027fd2e80768ee433555fcfd" - integrity sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg== - dependencies: - imurmurhash "^0.1.4" - signal-exit "^3.0.7" - -y18n@^5.0.5: - version "5.0.8" - resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" - integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== - -yallist@^3.0.2: - version "3.1.1" - resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" - integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== - -yallist@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" - integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== - -yargs-parser@^20.2.3: - version "20.2.9" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.9.tgz#2eb7dc3b0289718fc295f362753845c41a0c94ee" - integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w== - -yargs-parser@^21.0.1, yargs-parser@^21.1.1: - version "21.1.1" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" - integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== - -yargs@^17.3.1: - version "17.7.1" - resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.1.tgz#34a77645201d1a8fc5213ace787c220eabbd0967" - integrity sha512-cwiTb08Xuv5fqF4AovYacTFNxk62th7LKJ6BL9IGUpTJrWoU7/7WdQGTP2SjKf1dUNBGzDd28p/Yfs/GI6JrLw== - dependencies: - cliui "^8.0.1" - escalade "^3.1.1" - get-caller-file "^2.0.5" - require-directory "^2.1.1" - string-width "^4.2.3" - y18n "^5.0.5" - yargs-parser "^21.1.1" - -yocto-queue@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" - integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==