diff --git a/.eslintrc.json b/.eslintrc.json index 8e71b4b..9bc3365 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -14,21 +14,24 @@ "ecmaVersion": "latest", "sourceType": "module" }, - "plugins": ["@typescript-eslint", "functional", "unused-imports"], + "plugins": ["@typescript-eslint", "functional", "unused-imports", "eslint-plugin-tsdoc"], "rules": { "unicorn/prevent-abbreviations": "off", "@typescript-eslint/no-empty-interface": "warn", "@typescript-eslint/ban-types": [ "error", { - "types": { "BigInt": false }, + "types": { "BigInt": false, "Function": false, "Object": false }, "extendDefaults": true } ], "@typescript-eslint/semi": ["error"], "unused-imports/no-unused-imports": "error", "quotes": "off", - "@typescript-eslint/quotes": ["error"] + "unicorn/expiring-todo-comments": "off", + "unicorn/prefer-node-protocol": "off", + "@typescript-eslint/quotes": ["error"], + "tsdoc/syntax": "error" }, "ignorePatterns": ["src/__tests__/*", "dist/*"] } diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..bf02349 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,48 @@ +--- +name: Bug report +about: File a bug +title: "[BUG]: " +labels: bug +assignees: "" +--- + +# Prerequisites + +Please answer the following questions for yourself before submitting an issue. **YOU MAY DELETE THE PREREQUISITES SECTION.** + +- [ ] I am running the latest version +- [ ] I checked the documentation and found no answer +- [ ] I checked to make sure that this issue has not already been filed + +# Expected Behavior + +Please describe the behavior you are expecting + +# Current Behavior + +What is the current behavior? + +# Failure Information (for bugs) + +Please help provide information about the failure if this is a bug. If it is not a bug, please remove the rest of this template. + +## Steps to Reproduce + +Please provide detailed steps for reproducing the issue. + +1. step 1 +2. step 2 +3. you get it... + +## Context + +Please provide any relevant information about your setup. This is important in case the issue is not reproducible except for under certain conditions. + +> You can find Tigris SDK version on your local dev project by executing `npm list | grep "tigrisdata/core"` + +- [Tigris SDK version](https://www.npmjs.com/package/@tigrisdata/core?activeTab=versions): +- Operating System: + +## Failure Logs + +Please include any relevant log snippets or files here. diff --git a/.github/ISSUE_TEMPLATE/custom.md b/.github/ISSUE_TEMPLATE/custom.md new file mode 100644 index 0000000..babf9b2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/custom.md @@ -0,0 +1,7 @@ +--- +name: Custom issue template +about: Describe this issue template's purpose here. +title: "" +labels: "" +assignees: "" +--- diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..2bc5d5f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,19 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: "" +labels: "" +assignees: "" +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..d8fd9f1 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,49 @@ +## What type of PR is this? (check all applicable) + +- [ ] Refactor +- [ ] Feature +- [ ] Bug Fix +- [ ] Optimization +- [ ] Documentation Update + +## Description + +## Related Tickets & Documents + + + +- Related Issue # +- Closes # + +## Added/updated tests? + +- [ ] Yes +- [ ] No, and this is why: _please replace this line with details on why tests + have not been included_ +- [ ] I need help with writing tests + +### Is this change backwards compatible? + +- [ ] Yes +- [ ] No, and this is why: _please replace this line with details on why?_ + +### Does it require updates to [Tigris docs](https://docs.tigrisdata.com/)? + +- [ ] Yes, and here is the link: _please create an issue in [tigris-docs](https://github.com/tigrisdata/tigris-docs/issues) repo + and replace this text as `tigrisdata/tigris-docs#123`_ +- [ ] No + +### Checklist + +- [ ] `npm run build` - builds successfully +- [ ] `npm run test` - tests passing +- [ ] `npm run lint` - no lint errors + +## [optional] Are there any post deployment tasks we need to perform? diff --git a/.github/workflows/pre-release.yaml b/.github/workflows/pre-release.yaml index 121406f..67726d7 100644 --- a/.github/workflows/pre-release.yaml +++ b/.github/workflows/pre-release.yaml @@ -1,6 +1,6 @@ name: release-test on: - pull_request_target: + pull_request: types: - "opened" - "reopened" @@ -17,17 +17,13 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v3 with: - node-version: 16 - - name: Build package + node-version: 18 + - name: Install dependencies run: npm ci - - name: Install semantic-release - run: | - npm install --no-package-lock --no-save \ - @semantic-release/commit-analyzer \ - @semantic-release/release-notes-generator \ - @semantic-release/github + - name: Verify the signatures for installed dependencies + run: npm audit signatures - name: Release dry run env: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: npx semantic-release --debug --dryRun + run: npx semantic-release@18 --debug --dryRun diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 7a8ebc9..a562aac 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -19,11 +19,10 @@ jobs: node-version: ${{ matrix.node-version }} cache: "npm" - run: npm run clean - - run: | - npm install - npx eslint . - npm run build + - run: npm ci - run: npm run lint + - run: npm run prettier-check + - run: npm run build - run: npm run test release: @@ -35,17 +34,13 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v3 with: - node-version: 16 + node-version: 18 - name: Build package run: npm ci - - name: Install semantic-release - run: | - npm install --no-package-lock --no-save \ - @semantic-release/commit-analyzer \ - @semantic-release/release-notes-generator \ - @semantic-release/github + - name: Verify the signatures for installed dependencies + run: npm audit signatures - name: Release env: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: npx semantic-release --debug + run: npx semantic-release@18 --debug diff --git a/.github/workflows/ts-ci.yml b/.github/workflows/ts-ci.yml index 30d9b8a..40627be 100644 --- a/.github/workflows/ts-ci.yml +++ b/.github/workflows/ts-ci.yml @@ -1,6 +1,13 @@ name: ts-ci -on: [push] +on: + pull_request: + types: [opened, synchronize, reopened] + branches: + - main + push: + branches: + - main jobs: build: @@ -17,10 +24,11 @@ jobs: node-version: ${{ matrix.node-version }} cache: "npm" - run: npm run clean - - run: | - npm install - npx eslint . - npm run build + - name: Clean install + run: npm ci + - run: npm run lint + - run: npm run prettier-check + - run: npm run build - run: npm run test -- --coverage - name: Upload coverage to Codecov uses: codecov/codecov-action@v3 diff --git a/.prettierignore b/.prettierignore index 7bc8d00..8ab9dd1 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,6 +1,5 @@ # Ignore artifacts: dist/ -src/__tests__/ src/proto/ api/ coverage/ diff --git a/README.md b/README.md index af17624..21cc2b5 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,20 @@ # Tigris TypeScript Client Library -[![npm](https://img.shields.io/npm/v/@tigrisdata/core)](https://www.npmjs.com/package/@tigrisdata/core) -[![ts-ci](https://github.com/tigrisdata/tigris-client-ts/actions/workflows/ts-ci.yml/badge.svg?branch=main)](https://github.com/tigrisdata/tigris-client-ts/actions/workflows/ts-ci.yml) +[![npm](https://img.shields.io/npm/v/@tigrisdata/core?logo=npm&logoColor=white)](https://www.npmjs.com/package/@tigrisdata/core) +[![build](https://github.com/tigrisdata/tigris-client-ts/actions/workflows/ts-ci.yml/badge.svg?branch=main)](https://github.com/tigrisdata/tigris-client-ts/actions/workflows/ts-ci.yml) [![codecov](https://codecov.io/gh/tigrisdata/tigris-client-ts/branch/main/graph/badge.svg)](https://codecov.io/gh/tigrisdata/tigris-client-ts) -![LGTM Grade](https://img.shields.io/lgtm/grade/javascript/github/tigrisdata/tigris-client-ts) -[![slack](https://img.shields.io/badge/slack-tigrisdata-34D058.svg?logo=slack)](https://tigrisdata.slack.com) [![GitHub](https://img.shields.io/github/license/tigrisdata/tigris-client-ts)](https://github.com/tigrisdata/tigris-client-ts/blob/main/LICENSE) +[![Discord](https://img.shields.io/discord/1033842669983633488?color=%23596fff&label=Discord&logo=discord&logoColor=%23ffffff)](https://tigris.dev/discord) +[![Twitter Follow](https://img.shields.io/twitter/follow/tigrisdata?style=social)](https://twitter.com/tigrisdata) # Documentation -- [Tigris Overview](https://docs.tigrisdata.com/overview/) -- [Getting Started](https://docs.tigrisdata.com/typescript/getting-started) -- [CRUD operations API](https://docs.tigrisdata.com/typescript/documents) -- [Event Streaming API](https://docs.tigrisdata.com/typescript/event_streaming) +- [Tigris Overview](https://www.tigrisdata.com/docs/concepts/) +- [Getting Started](https://www.tigrisdata.com/docs/quickstarts/quickstart-typescript/) +- [Database](https://www.tigrisdata.com/docs/sdkstools/typescript/database/) +- [Database + Search](https://www.tigrisdata.com/docs/sdkstools/typescript/database/search/) +- [Search Only](https://www.tigrisdata.com/docs/sdkstools/typescript/search/) +- [Vector Search](https://www.tigrisdata.com/docs/quickstarts/quickstart-vector-search/) # Building @@ -32,8 +34,8 @@ npm run lint # Installation note for Apple M1 -Since ARM binaries are not provided for `grpc-tools` package by the grpc team. Hence, the x86_64 -version of `grpc-tools` must be installed. +Since ARM binaries are not provided for `grpc-tools` package by the grpc +team. Hence, the x86_64 version of `grpc-tools` must be installed. ```shell npm_config_target_arch=x64 npm i grpc-tools @@ -63,3 +65,11 @@ On every `git commit` we check the code quality using prettier and eslint. # License This software is licensed under the [Apache 2.0](LICENSE). + +# Contributors + +Thanks to all the people who contributed! + + + + diff --git a/api/proto b/api/proto index 49dd117..f346cf7 160000 --- a/api/proto +++ b/api/proto @@ -1 +1 @@ -Subproject commit 49dd117755e390fa01dfc48b64fed2f44d02bc67 +Subproject commit f346cf710062aaaeb8648d5d4f33bc34bb94dfe6 diff --git a/jest.config.json b/jest.config.json index 969a33f..39daa37 100644 --- a/jest.config.json +++ b/jest.config.json @@ -9,5 +9,13 @@ "collectCoverage": true, "coverageDirectory": "coverage", "coverageProvider": "v8", - "collectCoverageFrom": ["src/*.ts", "src/consumables/*.ts", "src/utils/*.ts", "src/search/*.ts"] + "coveragePathIgnorePatterns": ["/src/decorators/metadata/*.ts"], + "collectCoverageFrom": [ + "src/*.ts", + "src/consumables/*.ts", + "src/decorators/*.ts", + "src/schema/**/*.ts", + "src/search/**/*.ts", + "src/utils/**/*.ts" + ] } diff --git a/package-lock.json b/package-lock.json index f031d00..a1d6fad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,18 +14,20 @@ "chalk": "4.1.2", "dotenv": "^16.0.3", "google-protobuf": "^3.21.0", - "json-bigint": "^1.0.0", - "typescript": "^4.7.2" + "json-bigint": "github:sidorares/json-bigint", + "reflect-metadata": "^0.1.13" }, "devDependencies": { "@semantic-release/npm": "^9.0.1", "@types/jest": "^28.1.8", + "@types/json-bigint": "^1.0.1", "@typescript-eslint/eslint-plugin": "^5.35.1", "@typescript-eslint/parser": "^5.35.1", "copyfiles": "^2.4.1", "eslint": "^8.22.0", "eslint-config-prettier": "^8.5.0", "eslint-plugin-functional": "^4.2.2", + "eslint-plugin-tsdoc": "0.2.17", "eslint-plugin-unicorn": "^43.0.2", "eslint-plugin-unused-imports": "^2.0.0", "grpc_tools_node_protoc_ts": "^5.3.2", @@ -35,6 +37,7 @@ "ts-jest": "^28.0.8", "ts-mockito": "^2.6.1", "tsutils": "^3.21.0", + "typescript": "^4.7.2", "uuid": "^8.3.2" }, "engines": { @@ -1426,6 +1429,37 @@ "node-pre-gyp": "bin/node-pre-gyp" } }, + "node_modules/@microsoft/tsdoc": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.14.2.tgz", + "integrity": "sha512-9b8mPpKrfeGRuhFH5iO1iwCLeIIsV6+H1sRfxbkoGXIyQE2BTsPd9zqSqQJ+pv5sJ/hT5M1zvOFL02MnEezFug==", + "dev": true + }, + "node_modules/@microsoft/tsdoc-config": { + "version": "0.16.2", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc-config/-/tsdoc-config-0.16.2.tgz", + "integrity": "sha512-OGiIzzoBLgWWR0UdRJX98oYO+XKGf7tiK4Zk6tQ/E4IJqGCe7dvkTvgDZV5cFJUzLGDOjeAXrnZoA6QkVySuxw==", + "dev": true, + "dependencies": { + "@microsoft/tsdoc": "0.14.2", + "ajv": "~6.12.6", + "jju": "~1.4.0", + "resolve": "~1.19.0" + } + }, + "node_modules/@microsoft/tsdoc-config/node_modules/resolve": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.19.0.tgz", + "integrity": "sha512-rArEXAgsBG4UgRGcynxWIWKFvh/XZCcS8UJdHhwy91zwAvCZIbcs+vAbflgBnNjYMs/i/i+/Ux6IZhML1yPvxg==", + "dev": true, + "dependencies": { + "is-core-module": "^2.1.0", + "path-parse": "^1.0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1918,6 +1952,12 @@ "pretty-format": "^28.0.0" } }, + "node_modules/@types/json-bigint": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@types/json-bigint/-/json-bigint-1.0.1.tgz", + "integrity": "sha512-zpchZLNsNuzJHi6v64UBoFWAvQlPhch7XAi36FkH6tL1bbbmimIF+cS7vwkzY4u5RaSWMoflQfu+TshMPPw8uw==", + "dev": true + }, "node_modules/@types/json-schema": { "version": "7.0.11", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz", @@ -3492,6 +3532,16 @@ } } }, + "node_modules/eslint-plugin-tsdoc": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/eslint-plugin-tsdoc/-/eslint-plugin-tsdoc-0.2.17.tgz", + "integrity": "sha512-xRmVi7Zx44lOBuYqG8vzTXuL6IdGOeF9nHX17bjJ8+VE6fsxpdGem0/SBTmAwgYMKYB1WBkqRJVQ+n8GK041pA==", + "dev": true, + "dependencies": { + "@microsoft/tsdoc": "0.14.2", + "@microsoft/tsdoc-config": "0.16.2" + } + }, "node_modules/eslint-plugin-unicorn": { "version": "43.0.2", "resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-43.0.2.tgz", @@ -5939,6 +5989,12 @@ "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" } }, + "node_modules/jju": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jju/-/jju-1.4.0.tgz", + "integrity": "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==", + "dev": true + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -5971,8 +6027,8 @@ }, "node_modules/json-bigint": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", - "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "resolved": "git+ssh://git@github.com/sidorares/json-bigint.git#3391780b2a3f613bb51536c47e6cddbca31013eb", + "license": "MIT", "dependencies": { "bignumber.js": "^9.0.0" } @@ -6010,9 +6066,9 @@ "peer": true }, "node_modules/json5": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz", - "integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==", + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "dev": true, "bin": { "json5": "lib/cli.js" @@ -6735,9 +6791,9 @@ } }, "node_modules/npm": { - "version": "8.19.2", - "resolved": "https://registry.npmjs.org/npm/-/npm-8.19.2.tgz", - "integrity": "sha512-MWkISVv5f7iZbfNkry5/5YBqSYJEDAKSJdL+uzSQuyLg+hgLQUyZynu3SH6bOZlvR9ZvJYk2EiJO6B1r+ynwHg==", + "version": "8.19.4", + "resolved": "https://registry.npmjs.org/npm/-/npm-8.19.4.tgz", + "integrity": "sha512-3HANl8i9DKnUA89P4KEgVNN28EjSeDCmvEqbzOAuxCFDzdBZzjUl99zgnGpOUumvW5lvJo2HKcjrsc+tfyv1Hw==", "bundleDependencies": [ "@isaacs/string-locale-compare", "@npmcli/arborist", @@ -6746,7 +6802,6 @@ "@npmcli/fs", "@npmcli/map-workspaces", "@npmcli/package-json", - "@npmcli/promise-spawn", "@npmcli/run-script", "abbrev", "archy", @@ -6817,13 +6872,12 @@ "dev": true, "dependencies": { "@isaacs/string-locale-compare": "^1.1.0", - "@npmcli/arborist": "^5.6.2", + "@npmcli/arborist": "^5.6.3", "@npmcli/ci-detect": "^2.0.0", "@npmcli/config": "^4.2.1", "@npmcli/fs": "^2.1.0", "@npmcli/map-workspaces": "^2.0.3", "@npmcli/package-json": "^2.0.0", - "@npmcli/promise-spawn": "*", "@npmcli/run-script": "^4.2.1", "abbrev": "~1.1.1", "archy": "~1.0.0", @@ -6834,18 +6888,18 @@ "cli-table3": "^0.6.2", "columnify": "^1.6.0", "fastest-levenshtein": "^1.0.12", - "fs-minipass": "*", + "fs-minipass": "^2.1.0", "glob": "^8.0.1", "graceful-fs": "^4.2.10", - "hosted-git-info": "^5.1.0", + "hosted-git-info": "^5.2.1", "ini": "^3.0.1", "init-package-json": "^3.0.2", "is-cidr": "^4.0.2", "json-parse-even-better-errors": "^2.3.1", "libnpmaccess": "^6.0.4", "libnpmdiff": "^4.0.5", - "libnpmexec": "^4.0.13", - "libnpmfund": "^3.0.4", + "libnpmexec": "^4.0.14", + "libnpmfund": "^3.0.5", "libnpmhook": "^8.0.4", "libnpmorg": "^4.0.4", "libnpmpack": "^4.1.3", @@ -6854,7 +6908,7 @@ "libnpmteam": "^4.0.4", "libnpmversion": "^3.0.7", "make-fetch-happen": "^10.2.0", - "minimatch": "*", + "minimatch": "^5.1.0", "minipass": "^3.1.6", "minipass-pipeline": "^1.2.4", "mkdirp": "^1.0.4", @@ -6896,7 +6950,7 @@ "npx": "bin/npx-cli.js" }, "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16" + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, "node_modules/npm-run-path": { @@ -6934,7 +6988,7 @@ "license": "ISC" }, "node_modules/npm/node_modules/@npmcli/arborist": { - "version": "5.6.2", + "version": "5.6.3", "dev": true, "inBundle": true, "license": "ISC", @@ -6952,6 +7006,7 @@ "bin-links": "^3.0.3", "cacache": "^16.1.3", "common-ancestor-path": "^1.0.1", + "hosted-git-info": "^5.2.1", "json-parse-even-better-errors": "^2.3.1", "json-stringify-nice": "^1.1.4", "minimatch": "^5.1.0", @@ -7763,7 +7818,7 @@ "license": "ISC" }, "node_modules/npm/node_modules/hosted-git-info": { - "version": "5.1.0", + "version": "5.2.1", "dev": true, "inBundle": true, "license": "ISC", @@ -7775,7 +7830,7 @@ } }, "node_modules/npm/node_modules/http-cache-semantics": { - "version": "4.1.0", + "version": "4.1.1", "dev": true, "inBundle": true, "license": "BSD-2-Clause" @@ -8039,12 +8094,12 @@ } }, "node_modules/npm/node_modules/libnpmexec": { - "version": "4.0.13", + "version": "4.0.14", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^5.6.2", + "@npmcli/arborist": "^5.6.3", "@npmcli/ci-detect": "^2.0.0", "@npmcli/fs": "^2.1.1", "@npmcli/run-script": "^4.2.0", @@ -8064,12 +8119,12 @@ } }, "node_modules/npm/node_modules/libnpmfund": { - "version": "3.0.4", + "version": "3.0.5", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^5.6.2" + "@npmcli/arborist": "^5.6.3" }, "engines": { "node": "^12.13.0 || ^14.15.0 || >=16.0.0" @@ -10070,6 +10125,11 @@ "esprima": "~4.0.0" } }, + "node_modules/reflect-metadata": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.13.tgz", + "integrity": "sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg==" + }, "node_modules/regexp-tree": { "version": "0.1.24", "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.24.tgz", @@ -11143,6 +11203,7 @@ "version": "4.7.4", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.7.4.tgz", "integrity": "sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==", + "dev": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12502,6 +12563,36 @@ "tar": "^6.1.11" } }, + "@microsoft/tsdoc": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.14.2.tgz", + "integrity": "sha512-9b8mPpKrfeGRuhFH5iO1iwCLeIIsV6+H1sRfxbkoGXIyQE2BTsPd9zqSqQJ+pv5sJ/hT5M1zvOFL02MnEezFug==", + "dev": true + }, + "@microsoft/tsdoc-config": { + "version": "0.16.2", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc-config/-/tsdoc-config-0.16.2.tgz", + "integrity": "sha512-OGiIzzoBLgWWR0UdRJX98oYO+XKGf7tiK4Zk6tQ/E4IJqGCe7dvkTvgDZV5cFJUzLGDOjeAXrnZoA6QkVySuxw==", + "dev": true, + "requires": { + "@microsoft/tsdoc": "0.14.2", + "ajv": "~6.12.6", + "jju": "~1.4.0", + "resolve": "~1.19.0" + }, + "dependencies": { + "resolve": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.19.0.tgz", + "integrity": "sha512-rArEXAgsBG4UgRGcynxWIWKFvh/XZCcS8UJdHhwy91zwAvCZIbcs+vAbflgBnNjYMs/i/i+/Ux6IZhML1yPvxg==", + "dev": true, + "requires": { + "is-core-module": "^2.1.0", + "path-parse": "^1.0.6" + } + } + } + }, "@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -12920,6 +13011,12 @@ "pretty-format": "^28.0.0" } }, + "@types/json-bigint": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@types/json-bigint/-/json-bigint-1.0.1.tgz", + "integrity": "sha512-zpchZLNsNuzJHi6v64UBoFWAvQlPhch7XAi36FkH6tL1bbbmimIF+cS7vwkzY4u5RaSWMoflQfu+TshMPPw8uw==", + "dev": true + }, "@types/json-schema": { "version": "7.0.11", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz", @@ -14095,6 +14192,16 @@ "escape-string-regexp": "^4.0.0" } }, + "eslint-plugin-tsdoc": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/eslint-plugin-tsdoc/-/eslint-plugin-tsdoc-0.2.17.tgz", + "integrity": "sha512-xRmVi7Zx44lOBuYqG8vzTXuL6IdGOeF9nHX17bjJ8+VE6fsxpdGem0/SBTmAwgYMKYB1WBkqRJVQ+n8GK041pA==", + "dev": true, + "requires": { + "@microsoft/tsdoc": "0.14.2", + "@microsoft/tsdoc-config": "0.16.2" + } + }, "eslint-plugin-unicorn": { "version": "43.0.2", "resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-43.0.2.tgz", @@ -15942,6 +16049,12 @@ "string-length": "^4.0.1" } }, + "jju": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jju/-/jju-1.4.0.tgz", + "integrity": "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==", + "dev": true + }, "js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -15964,9 +16077,8 @@ "dev": true }, "json-bigint": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", - "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "version": "git+ssh://git@github.com/sidorares/json-bigint.git#3391780b2a3f613bb51536c47e6cddbca31013eb", + "from": "json-bigint@github:sidorares/json-bigint", "requires": { "bignumber.js": "^9.0.0" } @@ -16004,9 +16116,9 @@ "peer": true }, "json5": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz", - "integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==", + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "dev": true }, "jsonfile": { @@ -16563,19 +16675,18 @@ "dev": true }, "npm": { - "version": "8.19.2", - "resolved": "https://registry.npmjs.org/npm/-/npm-8.19.2.tgz", - "integrity": "sha512-MWkISVv5f7iZbfNkry5/5YBqSYJEDAKSJdL+uzSQuyLg+hgLQUyZynu3SH6bOZlvR9ZvJYk2EiJO6B1r+ynwHg==", + "version": "8.19.4", + "resolved": "https://registry.npmjs.org/npm/-/npm-8.19.4.tgz", + "integrity": "sha512-3HANl8i9DKnUA89P4KEgVNN28EjSeDCmvEqbzOAuxCFDzdBZzjUl99zgnGpOUumvW5lvJo2HKcjrsc+tfyv1Hw==", "dev": true, "requires": { "@isaacs/string-locale-compare": "^1.1.0", - "@npmcli/arborist": "^5.6.2", + "@npmcli/arborist": "^5.6.3", "@npmcli/ci-detect": "^2.0.0", "@npmcli/config": "^4.2.1", "@npmcli/fs": "^2.1.0", "@npmcli/map-workspaces": "^2.0.3", "@npmcli/package-json": "^2.0.0", - "@npmcli/promise-spawn": "*", "@npmcli/run-script": "^4.2.1", "abbrev": "~1.1.1", "archy": "~1.0.0", @@ -16586,18 +16697,18 @@ "cli-table3": "^0.6.2", "columnify": "^1.6.0", "fastest-levenshtein": "^1.0.12", - "fs-minipass": "*", + "fs-minipass": "^2.1.0", "glob": "^8.0.1", "graceful-fs": "^4.2.10", - "hosted-git-info": "^5.1.0", + "hosted-git-info": "^5.2.1", "ini": "^3.0.1", "init-package-json": "^3.0.2", "is-cidr": "^4.0.2", "json-parse-even-better-errors": "^2.3.1", "libnpmaccess": "^6.0.4", "libnpmdiff": "^4.0.5", - "libnpmexec": "^4.0.13", - "libnpmfund": "^3.0.4", + "libnpmexec": "^4.0.14", + "libnpmfund": "^3.0.5", "libnpmhook": "^8.0.4", "libnpmorg": "^4.0.4", "libnpmpack": "^4.1.3", @@ -16606,7 +16717,7 @@ "libnpmteam": "^4.0.4", "libnpmversion": "^3.0.7", "make-fetch-happen": "^10.2.0", - "minimatch": "*", + "minimatch": "^5.1.0", "minipass": "^3.1.6", "minipass-pipeline": "^1.2.4", "mkdirp": "^1.0.4", @@ -16661,7 +16772,7 @@ "dev": true }, "@npmcli/arborist": { - "version": "5.6.2", + "version": "5.6.3", "bundled": true, "dev": true, "requires": { @@ -16678,6 +16789,7 @@ "bin-links": "^3.0.3", "cacache": "^16.1.3", "common-ancestor-path": "^1.0.1", + "hosted-git-info": "^5.2.1", "json-parse-even-better-errors": "^2.3.1", "json-stringify-nice": "^1.1.4", "minimatch": "^5.1.0", @@ -17254,7 +17366,7 @@ "dev": true }, "hosted-git-info": { - "version": "5.1.0", + "version": "5.2.1", "bundled": true, "dev": true, "requires": { @@ -17262,7 +17374,7 @@ } }, "http-cache-semantics": { - "version": "4.1.0", + "version": "4.1.1", "bundled": true, "dev": true }, @@ -17451,11 +17563,11 @@ } }, "libnpmexec": { - "version": "4.0.13", + "version": "4.0.14", "bundled": true, "dev": true, "requires": { - "@npmcli/arborist": "^5.6.2", + "@npmcli/arborist": "^5.6.3", "@npmcli/ci-detect": "^2.0.0", "@npmcli/fs": "^2.1.1", "@npmcli/run-script": "^4.2.0", @@ -17472,11 +17584,11 @@ } }, "libnpmfund": { - "version": "3.0.4", + "version": "3.0.5", "bundled": true, "dev": true, "requires": { - "@npmcli/arborist": "^5.6.2" + "@npmcli/arborist": "^5.6.3" } }, "libnpmhook": { @@ -18917,6 +19029,11 @@ "esprima": "~4.0.0" } }, + "reflect-metadata": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.13.tgz", + "integrity": "sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg==" + }, "regexp-tree": { "version": "0.1.24", "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.24.tgz", @@ -19737,7 +19854,8 @@ "typescript": { "version": "4.7.4", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.7.4.tgz", - "integrity": "sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==" + "integrity": "sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==", + "dev": true }, "uglify-js": { "version": "3.17.0", diff --git a/package.json b/package.json index a5edc76..b849fff 100644 --- a/package.json +++ b/package.json @@ -61,20 +61,21 @@ }, "scripts": { "clean": "rm -rf ./src/proto/* && rm -rf dist && rm -rf node_modules", + "init_api": "git submodule update --init --recursive", "update_api": "git submodule update --init --recursive && git submodule update --remote --recursive --rebase && git submodule foreach --recursive git reset --hard origin/main", + "copy_api": "copyfiles -u 2 \"./src/proto/**/*.{js,ts}\" \"./dist/proto\"", "protoc": "./scripts/protoc.sh", + "all": "npm run clean && npm run build && npm run prettier-check && npm run lint && npm run test", "lint": "node ./node_modules/eslint/bin/eslint src/ --ext .ts", "lint-fix": "npx eslint --ext .ts --fix src/", - "copy_api": "copyfiles -u 2 \"./src/proto/**/*.{js,ts}\" \"./dist/proto\"", "tsc": "tsc && npm run copy_api", - "build": "npm install && npm run prettier-check && npm run protoc && npm run tsc", + "build": "npm install && npm audit signatures && npm run protoc && npm run tsc", "test": "jest --runInBand --coverage --silent --detectOpenHandles", "prettier-check": "npx prettier --check .", - "all": "npm run clean && npm run build && npm run prettier-check && npm run lint && npm run test", - "prepare": "npm run update_api && npm run protoc && npm run tsc", - "prepublishOnly": "npm test && npm run lint", "prettify": "npx prettier --write .", - "preversion": "npm run lint" + "preversion": "npm run lint && npm run prettier-check", + "prepare": "npm run init_api && npm run protoc && npm run tsc", + "prepublishOnly": "npm audit signatures && npm test && npm run lint && npm run prettier-check" }, "engines": { "node": ">= 12.0.0" @@ -86,8 +87,10 @@ "devDependencies": { "@semantic-release/npm": "^9.0.1", "@types/jest": "^28.1.8", + "@types/json-bigint": "^1.0.1", "@typescript-eslint/eslint-plugin": "^5.35.1", "@typescript-eslint/parser": "^5.35.1", + "eslint-plugin-tsdoc": "0.2.17", "copyfiles": "^2.4.1", "eslint": "^8.22.0", "eslint-config-prettier": "^8.5.0", @@ -101,15 +104,16 @@ "ts-jest": "^28.0.8", "ts-mockito": "^2.6.1", "tsutils": "^3.21.0", + "typescript": "^4.7.2", "uuid": "^8.3.2" }, "dependencies": { "@grpc/grpc-js": "^1.6.10", - "app-root-path": "^3.1.0", "chalk": "4.1.2", "dotenv": "^16.0.3", "google-protobuf": "^3.21.0", - "json-bigint": "^1.0.0", - "typescript": "^4.7.2" + "json-bigint": "github:sidorares/json-bigint", + "reflect-metadata": "^0.1.13", + "app-root-path": "^3.1.0" } } diff --git a/reviewpad.yml b/reviewpad.yml new file mode 100644 index 0000000..a33ad08 --- /dev/null +++ b/reviewpad.yml @@ -0,0 +1,88 @@ +# Define the list of labels to be used by Reviewpad. +# For more details see https://docs.reviewpad.com/guides/syntax#label. +labels: + small: + description: Pull request is small + color: "#76dbbe" + medium: + description: Pull request is medium + color: "#2986cc" + large: + description: Pull request is large + color: "#c90076" + +rules: + - name: is-release-branch + spec: $base() == "beta" + - name: is-main-branch + spec: $base() == "main" + +# Define the list of workflows to be run by Reviewpad. +# A workflow is a list of actions that will be executed based on the defined rules. +# For more details see https://docs.reviewpad.com/guides/syntax#workflow. +workflows: + # This workflow praises contributors on their pull request contributions. + # This helps contributors feel appreciated. + - name: praise-contributors-on-milestones + description: Praise contributors based on their contributions + always-run: true + if: + # Praise contributors on their first pull request. + - rule: $pullRequestCountBy($author()) == 1 + extra-actions: + - $commentOnce($sprintf("Thank you @%s for this first contribution!", [$author()])) + + # This workflow validates that pull requests follow the conventional commits specification. + # This helps developers automatically generate changelogs. + # For more details, see https://www.conventionalcommits.org/en/v1.0.0/. + - name: check-conventional-commits + description: Validate that pull requests follow the conventional commits + always-run: true + if: + - rule: $isDraft() == false + then: + # Check commits messages against the conventional commits specification + - $commitLint() + + - name: check-conventional-commits-title + description: Pull request titles to follow conventional commits when squashing and merging + always-run: true + run: + if: + - rule: $isDraft() == false && $rule("is-main-branch") + then: + - $titleLint() + + - name: size-labeling + description: Label pull request based on the number of lines changed + always-run: true + if: + - rule: $size() < 100 + extra-actions: + - $removeLabels(["medium", "large"]) + - $addLabel("small") + - rule: $size() >= 100 && $size() < 300 + extra-actions: + - $removeLabels(["small", "large"]) + - $addLabel("medium") + - rule: $size() >= 300 + extra-actions: + - $removeLabels(["small", "medium"]) + - $addLabel("large") + + - name: license-validation + description: Validate that licenses are not modified + always-run: true + if: + # Fail Reviewpad check on pull requests that modify any LICENSE; + - rule: $hasFilePattern("**/LICENSE*") + extra-actions: + - $fail("License files cannot be modified") + + - name: auto-merge-release-pr + description: Automatically merge with a merge commit on release branch + run: + if: + - rule: $rule("is-release-branch") && $approvalsCount() >= 1 && $haveAllChecksRunCompleted([], "success") + then: + - $merge("merge") diff --git a/src/__tests__/consumables/cursor.spec.ts b/src/__tests__/consumables/cursor.spec.ts index d6a094a..df5b4ab 100644 --- a/src/__tests__/consumables/cursor.spec.ts +++ b/src/__tests__/consumables/cursor.spec.ts @@ -1,19 +1,19 @@ -import {Server, ServerCredentials} from "@grpc/grpc-js"; -import TestService, {TestTigrisService} from "../test-service"; -import {TigrisService} from "../../proto/server/v1/api_grpc_pb"; -import {IBook} from "../tigris.rpc.spec"; -import {Tigris} from "../../tigris"; -import {TigrisCursorInUseError} from "../../error"; -import {ObservabilityService} from "../../proto/server/v1/observability_grpc_pb"; +import { Server, ServerCredentials } from "@grpc/grpc-js"; +import TestService, { TestTigrisService } from "../test-service"; +import { TigrisService } from "../../proto/server/v1/api_grpc_pb"; +import { IBook } from "../tigris.rpc.spec"; +import { Tigris } from "../../tigris"; +import { CursorInUseError } from "../../error"; +import { ObservabilityService } from "../../proto/server/v1/observability_grpc_pb"; import TestObservabilityService from "../test-observability-service"; -import {DB} from "../../db"; +import { DB } from "../../db"; -describe("class FindCursor", () => { +describe("FindCursor", () => { let server: Server; const SERVER_PORT = 5003; let db: DB; - beforeAll((done) => { + beforeAll(() => { server = new Server(); TestTigrisService.reset(); server.addService(TigrisService, TestService.handler.impl); @@ -30,9 +30,12 @@ describe("class FindCursor", () => { } } ); - const tigris = new Tigris({serverUrl: "localhost:" + SERVER_PORT}); - db = tigris.getDatabase("db3"); - done(); + const tigris = new Tigris({ + serverUrl: "localhost:" + SERVER_PORT, + projectName: "db3", + branch: TestTigrisService.ExpectedBranch, + }); + db = tigris.getDatabase(); }); beforeEach(() => { @@ -52,7 +55,7 @@ describe("class FindCursor", () => { bookCounter++; } expect(bookCounter).toBeGreaterThan(0); - }) + }); it("Pipes the stream as iterable", async () => { const cursor = db.getCollection("books").findMany(); @@ -62,22 +65,22 @@ describe("class FindCursor", () => { bookCounter++; } expect(bookCounter).toBeGreaterThan(0); - }) + }); it("returns stream as an array", () => { const cursor = db.getCollection("books").findMany(); const booksPromise = cursor.toArray(); - booksPromise.then(books => expect(books.length).toBeGreaterThan(0)); + booksPromise.then((books) => expect(books.length).toBeGreaterThan(0)); return booksPromise; - }) + }); it("does not allow cursor to be re-used", () => { const cursor = db.getCollection("books").findMany(); // cursor is backed by is a generator fn, calling next() would retrieve item from stream cursor[Symbol.asyncIterator]().next(); - expect(() => cursor.toArray()).toThrow(TigrisCursorInUseError); - }) + expect(() => cursor.toArray()).toThrow(CursorInUseError); + }); it("allows cursor to be re-used once reset", async () => { const cursor = db.getCollection("books").findMany(); @@ -88,8 +91,8 @@ describe("class FindCursor", () => { bookCounter++; } - cursor.reset() + cursor.reset(); const books = await cursor.toArray(); expect(books.length).toBe(bookCounter); - }) + }); }); diff --git a/src/__tests__/data/basicCollection.json b/src/__tests__/data/basicCollection.json deleted file mode 100644 index cd0a37c..0000000 --- a/src/__tests__/data/basicCollection.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "title": "basicCollection", - "additionalProperties": false, - "type": "object", - "properties": { - "id": { - "type": "integer", - "format": "int32", - "autoGenerate": true - }, - "active": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "uuid": { - "type": "string", - "format": "uuid" - }, - "int32Number": { - "type": "integer", - "format": "int32" - }, - "int64Number": { - "type": "integer", - "format": "int64" - }, - "date": { - "type": "string", - "format": "date-time" - }, - "bytes": { - "type": "string", - "format": "byte" - } - }, - "collection_type": "documents", - "primary_key": [ - "id" - ] -} diff --git a/src/__tests__/data/basicCollectionWithObjectType.json b/src/__tests__/data/basicCollectionWithObjectType.json deleted file mode 100644 index 60810a3..0000000 --- a/src/__tests__/data/basicCollectionWithObjectType.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "title": "basicCollectionWithObjectType", - "additionalProperties": false, - "type": "object", - "properties": { - "id": { - "type": "integer", - "format": "int64", - "autoGenerate": true - }, - "name": { - "type": "string" - }, - "metadata": { - "type": "object" - } - }, - "collection_type": "documents", - "primary_key": [ - "id" - ] -} diff --git a/src/__tests__/data/collectionWithObjectArrays.json b/src/__tests__/data/collectionWithObjectArrays.json deleted file mode 100644 index f63d30e..0000000 --- a/src/__tests__/data/collectionWithObjectArrays.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "title": "collectionWithObjectArrays", - "additionalProperties": false, - "type": "object", - "properties": { - "id": { - "type": "number" - }, - "name": { - "type": "string" - }, - "knownAddresses": { - "type": "array", - "items": { - "type": "object", - "properties": { - "city": { - "type": "string" - }, - "state": { - "type": "string" - }, - "zipcode": { - "type": "number" - } - } - } - }, - "id": { - "type": "string", - "format": "uuid" - } - }, - "collection_type": "documents", - "primary_key": [ - "id" - ] -} diff --git a/src/__tests__/data/collectionWithPrimitiveArrays.json b/src/__tests__/data/collectionWithPrimitiveArrays.json deleted file mode 100644 index cb64066..0000000 --- a/src/__tests__/data/collectionWithPrimitiveArrays.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "title": "collectionWithPrimitiveArrays", - "additionalProperties": false, - "type": "object", - "properties": { - "id": { - "type": "number" - }, - "name": { - "type": "string" - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "id": { - "type": "string", - "format": "uuid" - } - }, - "collection_type": "documents", - "primary_key": [ - "id" - ] -} diff --git a/src/__tests__/data/invalidModels/multiExport/users.ts b/src/__tests__/data/invalidModels/multiExport/users.ts deleted file mode 100644 index 6d37fce..0000000 --- a/src/__tests__/data/invalidModels/multiExport/users.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { TigrisCollectionType, TigrisDataTypes, TigrisSchema } from "../../../../types"; - -export interface Identity { - connection: string; - isSocial: boolean; - provider: string; - user_id: string; -} - -export const identitySchema: TigrisSchema = { - connection: { - type: TigrisDataTypes.STRING, - }, - isSocial: { - type: TigrisDataTypes.BOOLEAN, - }, - provider: { - type: TigrisDataTypes.STRING, - }, - user_id: { - type: TigrisDataTypes.STRING, - }, -}; - -export interface Stat { - loginsCount: string; -} - -export const statSchema: TigrisSchema = { - loginsCount: { - type: TigrisDataTypes.INT64, - }, -}; - -export interface User extends TigrisCollectionType { - created: string; - email: string; - identities: Identity; - name: string; - picture: string; - stats: Stat; - updated: string; - user_id: string; -} - -export const userSchema: TigrisSchema = { - created: { - type: TigrisDataTypes.DATE_TIME, - }, - email: { - type: TigrisDataTypes.STRING, - }, - identities: { - type: TigrisDataTypes.ARRAY, - items: { - type: identitySchema, - }, - }, - name: { - type: TigrisDataTypes.STRING, - }, - picture: { - type: TigrisDataTypes.STRING, - }, - stats: { - type: statSchema, - }, - updated: { - type: TigrisDataTypes.DATE_TIME, - }, - user_id: { - type: TigrisDataTypes.STRING, - primary_key: { - order: 1, - }, - }, -}; diff --git a/src/__tests__/data/models/catalog/orders.ts b/src/__tests__/data/models/catalog/orders.ts deleted file mode 100644 index 5435d1d..0000000 --- a/src/__tests__/data/models/catalog/orders.ts +++ /dev/null @@ -1 +0,0 @@ -export const NotSchema = { key: "value" }; diff --git a/src/__tests__/data/models/catalog/products.ts b/src/__tests__/data/models/catalog/products.ts deleted file mode 100644 index 757ee63..0000000 --- a/src/__tests__/data/models/catalog/products.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { - TigrisCollectionType, - TigrisDataTypes, - TigrisSchema -} from '../../../../types' - -export interface Product extends TigrisCollectionType { - id?: number; - title: string; - description: string; - price: number; -} - -export const ProductSchema: TigrisSchema = { - id: { - type: TigrisDataTypes.INT32, - primary_key: { order: 1, autoGenerate: true } - }, - title: { type: TigrisDataTypes.STRING }, - description: { type: TigrisDataTypes.STRING }, - price: { type: TigrisDataTypes.NUMBER } -} diff --git a/src/__tests__/data/models/embedded/users.ts b/src/__tests__/data/models/embedded/users.ts deleted file mode 100644 index ad7c361..0000000 --- a/src/__tests__/data/models/embedded/users.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { TigrisCollectionType, TigrisDataTypes, TigrisSchema } from "../../../../types"; - -export interface Identity { - connection: string; - isSocial: boolean; - provider: string; - user_id: string; -} - -const identitySchema: TigrisSchema = { - connection: { - type: TigrisDataTypes.STRING, - }, - isSocial: { - type: TigrisDataTypes.BOOLEAN, - }, - provider: { - type: TigrisDataTypes.STRING, - }, - user_id: { - type: TigrisDataTypes.STRING, - }, -}; - -export interface Stat { - loginsCount: string; -} - -const statSchema: TigrisSchema = { - loginsCount: { - type: TigrisDataTypes.INT64, - }, -}; - -export interface User extends TigrisCollectionType { - created: string; - email: string; - identities: Identity; - name: string; - picture: string; - stats: Stat; - updated: string; - user_id: string; -} - -export const userSchema: TigrisSchema = { - created: { - type: TigrisDataTypes.DATE_TIME, - }, - email: { - type: TigrisDataTypes.STRING, - }, - identities: { - type: TigrisDataTypes.ARRAY, - items: { - type: identitySchema, - }, - }, - name: { - type: TigrisDataTypes.STRING, - }, - picture: { - type: TigrisDataTypes.STRING, - }, - stats: { - type: statSchema, - }, - updated: { - type: TigrisDataTypes.DATE_TIME, - }, - user_id: { - type: TigrisDataTypes.STRING, - primary_key: { - order: 1, - }, - }, -}; diff --git a/src/__tests__/data/models/empty/.gitkeep b/src/__tests__/data/models/empty/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/__tests__/data/multiLevelObjectArray.json b/src/__tests__/data/multiLevelObjectArray.json deleted file mode 100644 index 8ede319..0000000 --- a/src/__tests__/data/multiLevelObjectArray.json +++ /dev/null @@ -1,130 +0,0 @@ -{ - "title": "multiLevelObjectArray", - "additionalProperties": false, - "type": "object", - "properties": { - "oneDArray": { - "type": "array", - "items": { - "type": "object", - "properties": { - "city": { - "type": "string" - }, - "state": { - "type": "string" - }, - "zipcode": { - "type": "number" - } - } - } - }, - "twoDArray": { - "type": "array", - "items": { - "type": "array", - "items": { - "type": "object", - "properties": { - "city": { - "type": "string" - }, - "state": { - "type": "string" - }, - "zipcode": { - "type": "number" - } - } - } - } - }, - "threeDArray": { - "type": "array", - "items": { - "type": "array", - "items": { - "type": "array", - "items": { - "type": "object", - "properties": { - "city": { - "type": "string" - }, - "state": { - "type": "string" - }, - "zipcode": { - "type": "number" - } - } - } - } - } - }, - "fourDArray": { - "type": "array", - "items": { - "type": "array", - "items": { - "type": "array", - "items": { - "type": "array", - "items": { - "type": "object", - "properties": { - "city": { - "type": "string" - }, - "state": { - "type": "string" - }, - "zipcode": { - "type": "number" - } - } - } - } - } - } - }, - "fiveDArray": { - "type": "array", - "items": { - "type": "array", - "items": { - "type": "array", - "items": { - "type": "array", - "items": { - "type": "array", - "items": { - "type": "object", - "properties": { - "city": { - "type": "string" - }, - "state": { - "type": "string" - }, - "zipcode": { - "type": "number" - } - } - } - } - } - } - } - }, - "id": { - "type": "string", - "format": "uuid" - } - }, - "collection_type": "documents", - "primary_key": [ - "id" - ] -} diff --git a/src/__tests__/data/multiLevelPrimitiveArray.json b/src/__tests__/data/multiLevelPrimitiveArray.json deleted file mode 100644 index ca00ec6..0000000 --- a/src/__tests__/data/multiLevelPrimitiveArray.json +++ /dev/null @@ -1,75 +0,0 @@ -{ - "title": "multiLevelPrimitiveArray", - "additionalProperties": false, - "type": "object", - "properties": { - "oneDArray": { - "type": "array", - "items": { - "type": "string" - } - }, - "twoDArray": { - "type": "array", - "items": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "threeDArray": { - "type": "array", - "items": { - "type": "array", - "items": { - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "fourDArray": { - "type": "array", - "items": { - "type": "array", - "items": { - "type": "array", - "items": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - }, - "fiveDArray": { - "type": "array", - "items": { - "type": "array", - "items": { - "type": "array", - "items": { - "type": "array", - "items": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - } - }, - "id": { - "type": "string", - "format": "uuid" - } - }, - "collection_type": "documents", - "primary_key": [ - "id" - ] -} diff --git a/src/__tests__/data/multiplePKeys.json b/src/__tests__/data/multiplePKeys.json deleted file mode 100644 index 9247138..0000000 --- a/src/__tests__/data/multiplePKeys.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "title": "multiplePKeys", - "additionalProperties": false, - "type": "object", - "properties": { - "id": { - "type": "integer", - "format": "int64" - }, - "active": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "uuid": { - "type": "string", - "format": "uuid", - "autoGenerate": true - }, - "int32Number": { - "type": "integer", - "format": "int32" - }, - "int64Number": { - "type": "integer", - "format": "int64" - }, - "date": { - "type": "string", - "format": "date-time" - }, - "bytes": { - "type": "string", - "format": "byte" - } - }, - "collection_type": "documents", - "primary_key": [ - "uuid", - "id" - ] -} diff --git a/src/__tests__/data/nestedCollection.json b/src/__tests__/data/nestedCollection.json deleted file mode 100644 index 5a045e1..0000000 --- a/src/__tests__/data/nestedCollection.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "title": "nestedCollection", - "additionalProperties": false, - "type": "object", - "properties": { - "id": { - "type": "number" - }, - "name": { - "type": "string" - }, - "address": { - "type": "object", - "properties": { - "city": { - "type": "string" - }, - "state": { - "type": "string" - }, - "zipcode": { - "type": "number" - } - } - }, - "id": { - "type": "string", - "format": "uuid" - } - }, - "collection_type": "documents", - "primary_key": [ - "id" - ] -} diff --git a/src/__tests__/fixtures/json-schema/matrices.json b/src/__tests__/fixtures/json-schema/matrices.json new file mode 100644 index 0000000..0417056 --- /dev/null +++ b/src/__tests__/fixtures/json-schema/matrices.json @@ -0,0 +1,44 @@ +{ + "title": "matrices", + "additionalProperties": false, + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "cells": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "x": { + "type": "number", + "default": 0 + }, + "y": { + "type": "number", + "default": 0 + }, + "value": { + "type": "object", + "properties": { + "length": { + "type": "number" + }, + "type": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "primary_key": ["id"] +} diff --git a/src/__tests__/fixtures/json-schema/movies.json b/src/__tests__/fixtures/json-schema/movies.json new file mode 100644 index 0000000..b254b75 --- /dev/null +++ b/src/__tests__/fixtures/json-schema/movies.json @@ -0,0 +1,54 @@ +{ + "title": "movies", + "additionalProperties": false, + "type": "object", + "properties": { + "movieId": { + "type": "string" + }, + "title": { + "type": "string", + "searchIndex": true + }, + "year": { + "type": "integer", + "format": "int32" + }, + "actors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "firstName": { + "type": "string", + "maxLength": 64 + }, + "lastName": { + "type": "string", + "maxLength": 64 + } + } + } + }, + "genres": { + "type": "array", + "items": { + "type": "string" + }, + "searchIndex": true, + "facet": true + }, + "productionHouse": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "city": { + "type": "string" + } + } + } + }, + "primary_key": ["movieId"] +} diff --git a/src/__tests__/fixtures/json-schema/orders.json b/src/__tests__/fixtures/json-schema/orders.json new file mode 100644 index 0000000..479417d --- /dev/null +++ b/src/__tests__/fixtures/json-schema/orders.json @@ -0,0 +1,51 @@ +{ + "title": "orders", + "additionalProperties": false, + "type": "object", + "properties": { + "orderId": { + "type": "string", + "format": "uuid", + "autoGenerate": true + }, + "customerId": { + "type": "string", + "searchIndex": false, + "sort": false, + "facet": false + }, + "products": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "maxLength": 64 + }, + "brand": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "upc": { + "type": "integer" + }, + "price": { + "type": "number" + } + } + } + } + }, + "primary_key": ["orderId", "customerId"] +} diff --git a/src/__tests__/fixtures/json-schema/search/matrices.json b/src/__tests__/fixtures/json-schema/search/matrices.json new file mode 100644 index 0000000..143fb0d --- /dev/null +++ b/src/__tests__/fixtures/json-schema/search/matrices.json @@ -0,0 +1,42 @@ +{ + "title": "matrices", + "type": "object", + "properties": { + "id": { + "type": "string", + "searchIndex": true, + "facet": false + }, + "cells": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "value": { + "type": "string" + } + } + } + } + }, + "searchIndex": true + }, + "relevance": { + "type": "array", + "dimensions": 4, + "format": "vector", + "searchIndex": true, + "sort": false + } + } +} diff --git a/src/__tests__/fixtures/json-schema/search/orders.json b/src/__tests__/fixtures/json-schema/search/orders.json new file mode 100644 index 0000000..66e2aa6 --- /dev/null +++ b/src/__tests__/fixtures/json-schema/search/orders.json @@ -0,0 +1,65 @@ +{ + "title": "orders", + "type": "object", + "properties": { + "orderId": { + "type": "string", + "format": "uuid", + "searchIndex": true, + "sort": true + }, + "customerId": { + "type": "string", + "searchIndex": true, + "facet": false, + "id": true + }, + "products": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "brand": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "upc": { + "type": "integer" + }, + "price": { + "type": "number" + } + } + }, + "searchIndex": true + }, + "status": { + "type": "object", + "properties": { + "statusType": { + "type": "string", + "searchIndex": true, + "facet": true + }, + "createdAt": { + "type": "string", + "format": "date-time", + "searchIndex": true + } + } + } + } +} diff --git a/src/__tests__/fixtures/json-schema/students.json b/src/__tests__/fixtures/json-schema/students.json new file mode 100644 index 0000000..6681b4b --- /dev/null +++ b/src/__tests__/fixtures/json-schema/students.json @@ -0,0 +1,27 @@ +{ + "title": "students", + "additionalProperties": false, + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64", + "autoGenerate": true + }, + "email": { + "type": "string" + }, + "firstName": { + "type": "string" + }, + "lastName": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "createdAt": true + } + }, + "primary_key": ["id", "email"] +} diff --git a/src/__tests__/fixtures/json-schema/users.json b/src/__tests__/fixtures/json-schema/users.json new file mode 100644 index 0000000..02efbeb --- /dev/null +++ b/src/__tests__/fixtures/json-schema/users.json @@ -0,0 +1,43 @@ +{ + "title": "users", + "additionalProperties": false, + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "created": { + "type": "string", + "format": "date-time", + "index": true + }, + "identities": { + "type": "array", + "items": { + "type": "object", + "properties": { + "connection": { + "type": "string", + "maxLength": 128 + }, + "isSocial": { + "type": "boolean" + }, + "provider": { + "type": "array", + "items": { + "type": "number" + } + }, + "linkedAccounts": { + "type": "number" + } + } + } + }, + "name": { + "type": "string" + } + }, + "primary_key": ["id"] +} diff --git a/src/__tests__/fixtures/json-schema/vacationRentals.json b/src/__tests__/fixtures/json-schema/vacationRentals.json new file mode 100644 index 0000000..e6bd034 --- /dev/null +++ b/src/__tests__/fixtures/json-schema/vacationRentals.json @@ -0,0 +1,132 @@ +{ + "title": "vacation_rentals", + "additionalProperties": false, + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "autoGenerate": true + }, + "name": { + "type": "string", + "maxLength": 64 + }, + "description": { + "type": "string", + "maxLength": 256, + "default": "" + }, + "propertyType": { + "type": "string", + "default": "Home" + }, + "bedrooms": { + "type": "number", + "default": 0 + }, + "bathrooms": { + "type": "number", + "default": 0 + }, + "minimumNights": { + "type": "integer", + "format": "int32", + "default": 1 + }, + "isOwnerOccupied": { + "type": "boolean", + "default": false + }, + "hasWiFi": { + "type": "boolean", + "default": true + }, + "address": { + "type": "object", + "properties": { + "city": { + "type": "string" + }, + "countryCode": { + "type": "string", + "maxLength": 2, + "default": "US" + } + } + }, + "verifications": { + "type": "object", + "properties": {}, + "default": { + "stateId": true + } + }, + "amenities": { + "type": "array", + "items": { + "type": "string" + }, + "default": ["Beds"] + }, + "attractions": { + "type": "array", + "items": { + "type": "string" + }, + "default": [] + }, + "host": { + "type": "object", + "properties": {}, + "default": null + }, + "reviews": { + "type": "array", + "items": { + "type": "object", + "properties": {} + }, + "default": null + }, + "availableSince": { + "type": "string", + "format": "date-time", + "default": "now()" + }, + "lastSeen": { + "type": "string", + "format": "date-time", + "default": "now()", + "updatedAt": true + }, + "createdAt": { + "type": "string", + "format": "date-time", + "createdAt": true + }, + "lastModified": { + "type": "string", + "format": "date-time", + "updatedAt": true + }, + "partnerId": { + "type": "string", + "default": "cuid()", + "index": true + }, + "referralId": { + "type": "string", + "format": "uuid", + "default": "uuid()", + "index": true + }, + "relevance": { + "type": "array", + "dimensions": 3, + "format": "vector", + "default": [1.0, 1.0, 1.0] + } + }, + "primary_key": ["id"] +} diff --git a/src/__tests__/fixtures/schema/matrices.ts b/src/__tests__/fixtures/schema/matrices.ts new file mode 100644 index 0000000..6cd27b8 --- /dev/null +++ b/src/__tests__/fixtures/schema/matrices.ts @@ -0,0 +1,89 @@ +import { Field } from "../../../decorators/tigris-field"; +import { TigrisCollectionType, TigrisDataTypes, TigrisSchema } from "../../../types"; +import { TigrisCollection } from "../../../decorators/tigris-collection"; +import { PrimaryKey } from "../../../decorators/tigris-primary-key"; + +/****************************************************************************** + * `Matrix` class demonstrates a Tigris collection schema generated using + * decorators. Type of collection fields is inferred using Reflection APIs. This + * particular schema example: + * - has a nested Array (Array of Arrays) + * - infers the type of collection fields automatically using Reflection APIs + *****************************************************************************/ +export const MATRICES_COLLECTION_NAME = "matrices"; + +export class CellValue { + @Field() + length: number; + + @Field() + type: string; +} + +export class Cell { + @Field({ default: 0 }) + x: number; + + @Field({ default: 0 }) + y: number; + + @Field() + value: CellValue; +} + +@TigrisCollection(MATRICES_COLLECTION_NAME) +export class Matrix { + @PrimaryKey({ order: 1 }) + id: string; + + @Field({ elements: Cell, depth: 3 }) + cells: Array>>; +} +/********************************** END **************************************/ + +/** + * `TigrisSchema` representation of the collection class above. + * + * NOTE: This is only an illustration; you don't have to write this definition, + * it will be auto generated. + */ +export const MatrixSchema: TigrisSchema = { + id: { + type: TigrisDataTypes.STRING, + primary_key: { + order: 1, + autoGenerate: false, + }, + }, + cells: { + type: TigrisDataTypes.ARRAY, + items: { + type: TigrisDataTypes.ARRAY, + items: { + type: TigrisDataTypes.ARRAY, + items: { + type: { + x: { + type: TigrisDataTypes.NUMBER, + default: 0, + }, + y: { + type: TigrisDataTypes.NUMBER, + default: 0, + }, + value: { + type: { + length: { + type: TigrisDataTypes.NUMBER, + }, + type: { + type: TigrisDataTypes.STRING, + }, + }, + }, + }, + }, + }, + }, + }, +}; diff --git a/src/__tests__/fixtures/schema/movies.ts b/src/__tests__/fixtures/schema/movies.ts new file mode 100644 index 0000000..14e85d2 --- /dev/null +++ b/src/__tests__/fixtures/schema/movies.ts @@ -0,0 +1,113 @@ +import { TigrisCollection } from "../../../decorators/tigris-collection"; +import { PrimaryKey } from "../../../decorators/tigris-primary-key"; +import { TigrisCollectionType, TigrisDataTypes, TigrisSchema } from "../../../types"; +import { Field } from "../../../decorators/tigris-field"; +import { SearchField } from "../../../decorators/tigris-search-field"; + +/****************************************************************************** + * `Movie` class demonstrates a Tigris collection schema generated using + * decorators. This particular schema example: + * - has an Array of another class as embedded Object + * - has an Array of primitive types + * - has an Object of type `Studio` + * - does not use reflection, all the collection fields are explicitly typed + *****************************************************************************/ +export const MOVIES_COLLECTION_NAME = "movies"; + +export class Studio { + @Field(TigrisDataTypes.STRING) + name: string; + + @Field(TigrisDataTypes.STRING) + city: string; +} + +export class Actor { + @Field(TigrisDataTypes.STRING, { maxLength: 64, index: true }) + firstName: string; + + @Field(TigrisDataTypes.STRING, { maxLength: 64 }) + lastName: string; +} + +@TigrisCollection(MOVIES_COLLECTION_NAME) +export class Movie { + // Ignored order in primary key options as it was only primary key. + @PrimaryKey(TigrisDataTypes.STRING) + movieId: string; + + @SearchField(TigrisDataTypes.STRING) + @Field(TigrisDataTypes.STRING) + title: string; + + @Field(TigrisDataTypes.INT32) + year: number; + + @Field(TigrisDataTypes.ARRAY, { elements: Actor }) + actors: Array; + + @SearchField(TigrisDataTypes.ARRAY, { elements: TigrisDataTypes.STRING, facet: true }) + @Field(TigrisDataTypes.ARRAY, { elements: TigrisDataTypes.STRING }) + genres: Array; + + @Field(TigrisDataTypes.OBJECT, { elements: Studio }) + productionHouse: Studio; +} + +/********************************** END **************************************/ + +/** + * `TigrisSchema` representation of the collection class above. + * + * NOTE: This is only an illustration; you don't have to write this definition, + * it will be auto generated. + */ +export const MovieSchema: TigrisSchema = { + movieId: { + type: TigrisDataTypes.STRING, + primary_key: { + order: 1, + autoGenerate: false, + }, + }, + title: { + type: TigrisDataTypes.STRING, + searchIndex: true, + }, + year: { + type: TigrisDataTypes.INT32, + }, + actors: { + type: TigrisDataTypes.ARRAY, + items: { + type: { + firstName: { + type: TigrisDataTypes.STRING, + maxLength: 64, + }, + lastName: { + type: TigrisDataTypes.STRING, + maxLength: 64, + }, + }, + }, + }, + genres: { + type: TigrisDataTypes.ARRAY, + items: { + type: TigrisDataTypes.STRING, + }, + searchIndex: true, + facet: true, + }, + productionHouse: { + type: { + name: { + type: TigrisDataTypes.STRING, + }, + city: { + type: TigrisDataTypes.STRING, + }, + }, + }, +}; diff --git a/src/__tests__/fixtures/schema/orders.ts b/src/__tests__/fixtures/schema/orders.ts new file mode 100644 index 0000000..f04abb3 --- /dev/null +++ b/src/__tests__/fixtures/schema/orders.ts @@ -0,0 +1,112 @@ +import { TigrisCollection } from "../../../decorators/tigris-collection"; +import { PrimaryKey } from "../../../decorators/tigris-primary-key"; +import { TigrisDataTypes, TigrisSchema } from "../../../types"; +import { Field } from "../../../decorators/tigris-field"; +import { SearchField } from "../../../decorators/tigris-search-field"; + +/****************************************************************************** + * `Order` class demonstrates a Tigris collection schema generated using + * decorators. Type of collection fields is inferred using Reflection APIs. This + * particular schema example: + * - has multiple primary keys + * - has embedded objects + * - has an Array of embedded objects + * - collection has search indexing enabled on certain fields + * - and infers the type of collection fields automatically using Reflection APIs + *****************************************************************************/ +export const ORDERS_COLLECTION_NAME = "orders"; + +export class Brand { + @Field() + @SearchField() + name: string; + + @Field({ elements: TigrisDataTypes.STRING }) + tags: Set; +} + +export class Product { + @Field({ maxLength: 64 }) + name: string; + + @Field() + brand: Brand; + + @Field() + upc: bigint; + + @Field() + @SearchField({ sort: true, facet: false }) + price: number; +} + +@TigrisCollection(ORDERS_COLLECTION_NAME) +export class Order { + @PrimaryKey(TigrisDataTypes.UUID, { order: 1, autoGenerate: true }) + orderId: string; + + @PrimaryKey({ order: 2 }) + @SearchField({ searchIndex: false, sort: false, facet: false }) + customerId: string; + + @Field({ elements: Product }) + products: Array; +} + +/********************************** END **************************************/ + +/** + * `TigrisSchema` representation of the collection class above. + * + * NOTE: This is only an illustration; you don't have to write this definition, + * it will be auto generated. + */ +export const OrderSchema: TigrisSchema = { + orderId: { + type: TigrisDataTypes.UUID, + primary_key: { + order: 1, + autoGenerate: true, + }, + }, + customerId: { + type: TigrisDataTypes.STRING, + searchIndex: false, + sort: false, + facet: false, + primary_key: { + order: 2, + autoGenerate: false, + }, + }, + products: { + type: TigrisDataTypes.ARRAY, + items: { + type: { + name: { + type: TigrisDataTypes.STRING, + maxLength: 64, + }, + brand: { + type: { + name: { + type: TigrisDataTypes.STRING, + }, + tags: { + type: TigrisDataTypes.ARRAY, + items: { + type: TigrisDataTypes.STRING, + }, + }, + }, + }, + upc: { + type: TigrisDataTypes.NUMBER_BIGINT, + }, + price: { + type: TigrisDataTypes.NUMBER, + }, + }, + }, + }, +}; diff --git a/src/__tests__/fixtures/schema/search/matrices.ts b/src/__tests__/fixtures/schema/search/matrices.ts new file mode 100644 index 0000000..36bb7c6 --- /dev/null +++ b/src/__tests__/fixtures/schema/search/matrices.ts @@ -0,0 +1,84 @@ +import { SearchField } from "../../../../decorators/tigris-search-field"; +import { TigrisSearchIndex } from "../../../../decorators/tigris-search-index"; +import { TigrisIndexSchema } from "../../../../search"; +import { TigrisDataTypes } from "../../../../types"; + +/****************************************************************************** + * `Matrix` class demonstrates a Tigris search index generated using + * decorators. Type of schema fields is inferred using Reflection APIs. This + * particular schema example: + * - has a nested Array (Array of Arrays) + * - infers the type of index fields automatically using Reflection APIs + *****************************************************************************/ +export const MATRICES_INDEX_NAME = "matrices"; + +export class Cell { + @SearchField() + x: number; + + @SearchField() + y: number; + + @SearchField({ id: true }) + value: string; +} + +@TigrisSearchIndex(MATRICES_INDEX_NAME) +export class Matrix { + @SearchField({ facet: false }) + id: string; + + @SearchField({ elements: Cell, depth: 3 }) + cells: Cell[][][]; + + @SearchField({ dimensions: 4, sort: false }) + relevance: Array; +} +/********************************** END **************************************/ + +/** + * `TigrisSchema` representation of the collection class above. + * + * NOTE: This is only an illustration; you don't have to write this definition, + * it will be auto generated. + */ + +export const MatrixSchema: TigrisIndexSchema = { + id: { + type: TigrisDataTypes.STRING, + searchIndex: true, + facet: false, + }, + cells: { + type: TigrisDataTypes.ARRAY, + searchIndex: true, + items: { + type: TigrisDataTypes.ARRAY, + items: { + type: TigrisDataTypes.ARRAY, + items: { + type: { + x: { + type: TigrisDataTypes.NUMBER, + }, + y: { + type: TigrisDataTypes.NUMBER, + }, + value: { + type: TigrisDataTypes.STRING, + }, + }, + }, + }, + }, + }, + relevance: { + type: TigrisDataTypes.ARRAY, + searchIndex: true, + sort: false, + dimensions: 4, + items: { + type: TigrisDataTypes.NUMBER, + }, + }, +}; diff --git a/src/__tests__/fixtures/schema/search/orders.ts b/src/__tests__/fixtures/schema/search/orders.ts new file mode 100644 index 0000000..7081637 --- /dev/null +++ b/src/__tests__/fixtures/schema/search/orders.ts @@ -0,0 +1,122 @@ +import { TigrisDataTypes } from "../../../../types"; +import { TigrisIndexSchema } from "../../../../search"; +import { SearchField } from "../../../../decorators/tigris-search-field"; +import { TigrisSearchIndex } from "../../../../decorators/tigris-search-index"; + +/****************************************************************************** + * `Order` class demonstrates a Tigris search index schema generated using + * decorators. Type of index fields is inferred using Reflection APIs. This + * particular schema example: + * - has embedded objects + * - has an Array of embedded objects + * - and infers the type of fields automatically using Reflection APIs + *****************************************************************************/ +export const ORDERS_INDEX_NAME = "orders"; + +export class Brand { + @SearchField() + name: string; + + @SearchField({ elements: TigrisDataTypes.STRING, searchIndex: false }) + tags: Set; +} + +export class Product { + @SearchField() + name: string; + + @SearchField() + brand: Brand; + + @SearchField({ searchIndex: false, sort: false }) + upc: bigint; + + @SearchField({ sort: true, facet: false }) + price: number; +} + +export class OrderStatus { + @SearchField({ facet: true }) + statusType: string; + + @SearchField() + createdAt: Date; +} + +@TigrisSearchIndex(ORDERS_INDEX_NAME) +export class Order { + @SearchField(TigrisDataTypes.UUID, { sort: true }) + orderId: string; + + @SearchField({ facet: false, id: true }) + customerId: string; + + @SearchField({ elements: Product }) + products: Array; + + @SearchField() + status: OrderStatus; +} + +/** + * `TigrisIndexSchema` representation of the Order class above. + * + * NOTE: This is only an illustration; you don't have to write this definition, + * it will be auto generated. + */ +export const OrderSchema: TigrisIndexSchema = { + orderId: { + type: TigrisDataTypes.UUID, + searchIndex: true, + sort: true, + }, + customerId: { + type: TigrisDataTypes.STRING, + searchIndex: true, + facet: false, + id: true, + }, + products: { + type: TigrisDataTypes.ARRAY, + searchIndex: true, + items: { + type: { + name: { + type: TigrisDataTypes.STRING, + }, + brand: { + type: { + name: { + type: TigrisDataTypes.STRING, + }, + tags: { + type: TigrisDataTypes.ARRAY, + items: { + type: TigrisDataTypes.STRING, + }, + }, + }, + }, + upc: { + type: TigrisDataTypes.NUMBER_BIGINT, + }, + price: { + type: TigrisDataTypes.NUMBER, + }, + }, + }, + }, + status: { + type: { + statusType: { + type: TigrisDataTypes.STRING, + searchIndex: true, + facet: true, + }, + createdAt: { + type: TigrisDataTypes.DATE_TIME, + searchIndex: true, + }, + }, + }, +}; diff --git a/src/__tests__/fixtures/schema/student.ts b/src/__tests__/fixtures/schema/student.ts new file mode 100644 index 0000000..a6feac4 --- /dev/null +++ b/src/__tests__/fixtures/schema/student.ts @@ -0,0 +1,66 @@ +import { TigrisCollection } from "../../../decorators/tigris-collection"; +import { PrimaryKey } from "../../../decorators/tigris-primary-key"; +import { TigrisDataTypes, TigrisSchema } from "../../../types"; +import { Field } from "../../../decorators/tigris-field"; + +/****************************************************************************** + * `Student` class demonstrates a Tigris collection schema generated using + * decorators. Type of collection fields is inferred using Reflection APIs. This + * particular schema example: + * - infers the type of collection fields automatically using Reflection APIs + * - has multiple primary keys + *****************************************************************************/ +export const STUDENT_COLLECTION_NAME = "students"; + +@TigrisCollection(STUDENT_COLLECTION_NAME) +export class Student { + @PrimaryKey(TigrisDataTypes.INT64, { order: 1, autoGenerate: true }) + id?: string; + + @PrimaryKey(TigrisDataTypes.STRING, { order: 2 }) + email: string; + + @Field() + firstName!: string; + + @Field() + lastName!: string; + + @Field({ timestamp: "createdAt" }) + createdAt?: Date; +} + +/********************************** END **************************************/ + +/** + * `TigrisSchema` representation of the `Student` collection class . + * + * NOTE: This is only an illustration; you don't have to write this definition, + * it will be auto generated. + */ +export const StudentSchema: TigrisSchema = { + id: { + type: TigrisDataTypes.INT64, + primary_key: { + order: 1, + autoGenerate: true, + }, + }, + email: { + type: TigrisDataTypes.STRING, + primary_key: { + order: 2, + autoGenerate: false, + }, + }, + firstName: { + type: TigrisDataTypes.STRING, + }, + lastName: { + type: TigrisDataTypes.STRING, + }, + createdAt: { + type: TigrisDataTypes.DATE_TIME, + timestamp: "createdAt", + }, +}; diff --git a/src/__tests__/fixtures/schema/users.ts b/src/__tests__/fixtures/schema/users.ts new file mode 100644 index 0000000..4577856 --- /dev/null +++ b/src/__tests__/fixtures/schema/users.ts @@ -0,0 +1,91 @@ +import { TigrisCollectionType, TigrisDataTypes, TigrisSchema } from "../../../types"; +import { PrimaryKey } from "../../../decorators/tigris-primary-key"; +import { Field } from "../../../decorators/tigris-field"; +import { TigrisCollection } from "../../../decorators/tigris-collection"; + +/****************************************************************************** + * `User` class demonstrates a Tigris collection schema generated using + * decorators. Type of collection fields is inferred using Reflection APIs. This + * particular schema example: + * - has an Array of embedded objects + * - has an Array of primitive types + * - infers the type of collection fields automatically using Reflection APIs + *****************************************************************************/ +export const USERS_COLLECTION_NAME = "users"; + +export class Identity { + @Field({ maxLength: 128 }) + connection?: string; + + @Field() + isSocial: boolean; + + @Field({ elements: TigrisDataTypes.NUMBER }) + provider: Array; + + @Field() + linkedAccounts: number; +} + +@TigrisCollection(USERS_COLLECTION_NAME) +export class User { + @PrimaryKey({ order: 1 }) + id: number; + + @Field({ index: true }) + created: Date; + + @Field({ elements: Identity }) + identities: Array; + + @Field() + name: string; +} + +/********************************** END **************************************/ + +/** + * `TigrisSchema` representation of the collection class above. + * + * NOTE: This is only an illustration; you don't have to write this definition, + * it will be auto generated. + */ +export const UserSchema: TigrisSchema = { + id: { + type: TigrisDataTypes.NUMBER, + primary_key: { + order: 1, + autoGenerate: false, + }, + }, + created: { + type: TigrisDataTypes.DATE_TIME, + index: true, + }, + identities: { + type: TigrisDataTypes.ARRAY, + items: { + type: { + connection: { + type: TigrisDataTypes.STRING, + maxLength: 128, + }, + isSocial: { + type: TigrisDataTypes.BOOLEAN, + }, + provider: { + type: TigrisDataTypes.ARRAY, + items: { + type: TigrisDataTypes.NUMBER, + }, + }, + linkedAccounts: { + type: TigrisDataTypes.NUMBER, + }, + }, + }, + }, + name: { + type: TigrisDataTypes.STRING, + }, +}; diff --git a/src/__tests__/fixtures/schema/vacationRentals.ts b/src/__tests__/fixtures/schema/vacationRentals.ts new file mode 100644 index 0000000..7ea4482 --- /dev/null +++ b/src/__tests__/fixtures/schema/vacationRentals.ts @@ -0,0 +1,216 @@ +import { TigrisCollection } from "../../../decorators/tigris-collection"; +import { GeneratedField, TigrisDataTypes, TigrisSchema } from "../../../types"; +import { PrimaryKey } from "../../../decorators/tigris-primary-key"; +import { Field } from "../../../decorators/tigris-field"; + +/****************************************************************************** + * `VacationRentals` class demonstrates a Tigris collection schema generated using + * decorators. This particular schema example: + * - has an embedded object + * - infers the type of collection fields automatically using Reflection APIs + * - demonstrates how to set optional properties like 'defaults' for schema fields + *****************************************************************************/ +export const RENTALS_COLLECTION_NAME = "vacation_rentals"; + +class Address { + @Field() + city: string; + + @Field({ default: "US", maxLength: 2, index: true }) + countryCode: string; +} + +@TigrisCollection(RENTALS_COLLECTION_NAME) +export class VacationRentals { + @PrimaryKey(TigrisDataTypes.UUID, { autoGenerate: true, order: 1 }) + id: string; + + @Field({ maxLength: 64 }) + name: string; + + @Field({ maxLength: 256, default: "" }) + description: string; + + @Field({ default: "Home" }) + propertyType: string; + + @Field({ default: 0 }) + bedrooms: number; + + @Field({ default: 0.0 }) + bathrooms: number; + + @Field(TigrisDataTypes.INT32, { default: 1 }) + minimumNights: number; + + @Field({ default: false }) + isOwnerOccupied: boolean; + + @Field({ default: true }) + hasWiFi: boolean; + + @Field() + address: Address; + + @Field({ default: { stateId: true } }) + verifications: Object; + + @Field({ elements: TigrisDataTypes.STRING, default: ["Beds"] }) + amenities: Array; + + @Field({ elements: TigrisDataTypes.STRING, default: [] }) + attractions: Array; + + @Field({ default: null, index: true }) + host: object; + + @Field({ elements: TigrisDataTypes.OBJECT, default: undefined }) + reviews: Array; + + @Field({ default: GeneratedField.NOW }) + availableSince: Date; + + @Field({ default: GeneratedField.NOW, timestamp: "updatedAt" }) + lastSeen: Date; + + @Field({ timestamp: "createdAt" }) + createdAt: Date; + + @Field({ timestamp: "updatedAt" }) + lastModified: Date; + + @Field({ default: GeneratedField.CUID, index: true }) + partnerId: string; + + @Field(TigrisDataTypes.UUID, { default: GeneratedField.UUID, index: true }) + referralId: string; + + @Field({ dimensions: 3, default: [1.0, 1.0, 1.0] }) + relevance: number[]; +} +/********************************** END **************************************/ + +/** + * `TigrisSchema` representation of the collection class above. + * + * NOTE: This is only an illustration; you don't have to write this definition, + * it will be auto generated. + */ +export const VacationsRentalSchema: TigrisSchema = { + id: { + type: TigrisDataTypes.UUID, + primary_key: { + autoGenerate: true, + order: 1, + }, + }, + name: { + type: TigrisDataTypes.STRING, + maxLength: 64, + }, + description: { + type: TigrisDataTypes.STRING, + maxLength: 256, + default: "", + }, + propertyType: { + type: TigrisDataTypes.STRING, + default: "Home", + }, + bedrooms: { + type: TigrisDataTypes.NUMBER, + default: 0, + }, + bathrooms: { + type: TigrisDataTypes.NUMBER, + default: 0.0, + }, + minimumNights: { + type: TigrisDataTypes.INT32, + default: 1, + }, + isOwnerOccupied: { + type: TigrisDataTypes.BOOLEAN, + default: false, + }, + hasWiFi: { + type: TigrisDataTypes.BOOLEAN, + default: true, + }, + address: { + type: { + city: { + type: TigrisDataTypes.STRING, + }, + countryCode: { + type: TigrisDataTypes.STRING, + default: "US", + maxLength: 2, + }, + }, + }, + verifications: { + type: TigrisDataTypes.OBJECT, + default: { stateId: true }, + }, + amenities: { + type: TigrisDataTypes.ARRAY, + items: { + type: TigrisDataTypes.STRING, + }, + default: ["Beds"], + }, + attractions: { + type: TigrisDataTypes.ARRAY, + items: { + type: TigrisDataTypes.STRING, + }, + default: [], + }, + host: { + type: TigrisDataTypes.OBJECT, + default: null, + }, + reviews: { + type: TigrisDataTypes.ARRAY, + items: { + type: TigrisDataTypes.OBJECT, + }, + default: undefined, + }, + availableSince: { + type: TigrisDataTypes.DATE_TIME, + default: GeneratedField.NOW, + }, + lastSeen: { + type: TigrisDataTypes.DATE_TIME, + default: GeneratedField.NOW, + timestamp: "updatedAt", + }, + createdAt: { + type: TigrisDataTypes.DATE_TIME, + timestamp: "createdAt", + }, + lastModified: { + type: TigrisDataTypes.DATE_TIME, + timestamp: "updatedAt", + }, + partnerId: { + type: TigrisDataTypes.STRING, + default: GeneratedField.CUID, + index: true, + }, + referralId: { + type: TigrisDataTypes.UUID, + default: GeneratedField.UUID, + index: true, + }, + relevance: { + type: TigrisDataTypes.ARRAY, + dimensions: 3, + default: [1.0, 1.0, 1.0], + items: { + type: TigrisDataTypes.NUMBER, + }, + }, +}; diff --git a/src/__tests__/index.spec.ts b/src/__tests__/index.spec.ts new file mode 100644 index 0000000..77ebc8d --- /dev/null +++ b/src/__tests__/index.spec.ts @@ -0,0 +1,71 @@ +import * as tigris from "../index"; + +const EXPECTED_EXPORTS = [ + "CacheDelResponse", + "CacheGetResponse", + "CacheGetSetResponse", + "CacheMetadata", + "CacheSetResponse", + "Case", + "Collection", + "CollectionDescription", + "CollectionInfo", + "CollectionMetadata", + "CollectionOptions", + "CommitTransactionResponse", + "CreateBranchResponse", + "Cursor", + "DB", + "DMLMetadata", + "DatabaseDescription", + "DatabaseInfo", + "DatabaseMetadata", + "DatabaseOptions", + "DeleteBranchResponse", + "DeleteCacheResponse", + "DeleteIndexResponse", + "DeleteQueryOptions", + "DeleteResponse", + "DocMeta", + "DocStatus", + "DropCollectionResponse", + "FacetCount", + "FacetStats", + "Field", + "FindQueryOptions", + "GeneratedField", + "IndexInfo", + "IndexedDoc", + "ListCachesResponse", + "MATCH_ALL_QUERY_STRING", + "Page", + "PrimaryKey", + "RollbackTransactionResponse", + "Search", + "SearchField", + "SearchIndex", + "SearchIterator", + "SearchMeta", + "SearchResult", + "ServerMetadata", + "Session", + "Status", + "TextMatchInfo", + "Tigris", + "TigrisCollection", + "TigrisDataTypes", + "TigrisSearchIndex", + "TransactionOptions", + "TransactionResponse", + "UpdateQueryOptions", + "UpdateResponse", + "WriteOptions", +]; + +// we are using * exports for files, this test ensures that no unwanted export gets added to package entry path +describe("tigris entrypaths", () => { + it("should export only expected paths", () => { + const actualExports = Object.keys(tigris).sort(); + expect(actualExports).toStrictEqual(EXPECTED_EXPORTS); + }); +}); diff --git a/src/__tests__/search/schema.spec.ts b/src/__tests__/search/schema.spec.ts new file mode 100644 index 0000000..d2996a3 --- /dev/null +++ b/src/__tests__/search/schema.spec.ts @@ -0,0 +1,62 @@ +import { TigrisIndexSchema, TigrisIndexType } from "../../search"; +import { Order, ORDERS_INDEX_NAME, OrderSchema } from "../fixtures/schema/search/orders"; +import { Utility } from "../../utility"; +import { readJSONFileAsObj } from "../utils"; +import { DecoratedSchemaProcessor, IndexSchema } from "../../schema/decorated-schema-processor"; +import { MATRICES_INDEX_NAME, Matrix, MatrixSchema } from "../fixtures/schema/search/matrices"; +import { TigrisCollection } from "../../decorators/tigris-collection"; +import { Field } from "../../decorators/tigris-field"; +import { TigrisDataTypes } from "../../types"; +import { IncorrectVectorDefError } from "../../error"; + +type SchemaTestCase = { + schemaClass: T; + expectedSchema: TigrisIndexSchema; + name: string; + expectedJSON: string; +}; + +const schemas: Array> = [ + { + schemaClass: Order, + expectedSchema: OrderSchema, + name: ORDERS_INDEX_NAME, + expectedJSON: "orders.json", + }, + { + schemaClass: Matrix, + expectedSchema: MatrixSchema, + name: MATRICES_INDEX_NAME, + expectedJSON: "matrices.json", + }, +]; + +describe.each(schemas)("Schema conversion for: '$name'", (tc) => { + const processor = DecoratedSchemaProcessor.Instance; + + test("Convert decorated class to TigrisSchema", () => { + const generated: IndexSchema = processor.processIndex(tc.schemaClass); + expect(generated.schema).toStrictEqual(tc.expectedSchema); + }); + + test("Convert TigrisIndexSchema to JSON spec", () => { + expect(Utility._indexSchematoJSON(tc.name, tc.expectedSchema)).toBe( + readJSONFileAsObj("src/__tests__/fixtures/json-schema/search/" + tc.expectedJSON) + ); + }); +}); + +test("throws error when Vector fields have incorrect type", () => { + let caught; + + try { + @TigrisCollection("test_studio") + class Studio { + @Field({ dimensions: 3, elements: TigrisDataTypes.STRING }) + actors: Array; + } + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(IncorrectVectorDefError); +}); diff --git a/src/__tests__/search/search.spec.ts b/src/__tests__/search/search.spec.ts new file mode 100644 index 0000000..62ca4e2 --- /dev/null +++ b/src/__tests__/search/search.spec.ts @@ -0,0 +1,139 @@ +import { + Search, + SearchField, + SearchIndex, + TigrisIndexSchema, + TigrisSearchIndex, +} from "../../search"; +import { capture, instance, mock, reset, spy } from "ts-mockito"; +import { SearchClient as ProtoSearchClient } from "../../proto/server/v1/search_grpc_pb"; +import { CreateOrUpdateIndexResponse as ProtoCreateIndexResponse } from "../../proto/server/v1/search_pb"; +import { Utility } from "../../utility"; +import { TigrisDataTypes } from "../../types"; +import { Status as ProtoStatus } from "@grpc/grpc-js/build/src/constants"; +import { ServiceError } from "@grpc/grpc-js"; + +describe("Search", () => { + let target: Search; + let mockClient: ProtoSearchClient; + + beforeEach(() => { + mockClient = mock(ProtoSearchClient); + target = new Search(instance(mockClient), { projectName: "test_project" }); + }); + + afterEach(() => { + reset(mockClient); + }); + + describe("createOrUpdateIndex", () => { + const decoratedModelName = "decorated_model"; + @TigrisSearchIndex(decoratedModelName) + class Book { + @SearchField() + name: string; + } + + const IBook: TigrisIndexSchema = { + name: { + type: TigrisDataTypes.STRING, + searchIndex: true, + }, + }; + + it("creates index using decorated model class", async () => { + const expectedSchema = JSON.stringify({ + title: decoratedModelName, + type: "object", + properties: { + name: { + type: "string", + searchIndex: true, + }, + }, + }); + + const resp = target.createOrUpdateIndex(Book); + // verifies that grpc gets invoked with the right arguments + const [capturedReq, capturedCallback] = capture(mockClient.createOrUpdateIndex).last(); + // @ts-ignore + capturedCallback(null, new ProtoCreateIndexResponse()); + + expect(capturedReq.getName()).toBe(decoratedModelName); + expect(capturedReq.getProject()).toBe(target.projectName); + expect(capturedReq.getOnlyCreate()).toBe(false); + expect(Utility.uint8ArrayToString(capturedReq.getSchema_asU8())).toBe(expectedSchema); + + return expect(resp).resolves.toBeInstanceOf(SearchIndex); + }); + + it("creates index using decorated model and specified name", async () => { + const overridenName = "my_custom_name"; + const expectedSchema = JSON.stringify({ + title: overridenName, + type: "object", + properties: { + name: { + type: "string", + searchIndex: true, + }, + }, + }); + const resp = target.createOrUpdateIndex(overridenName, Book); + // verifies that grpc gets invoked with the right arguments + const [capturedReq, capturedCallback] = capture(mockClient.createOrUpdateIndex).last(); + // @ts-ignore + capturedCallback(null, new ProtoCreateIndexResponse()); + + expect(capturedReq.getName()).toBe(overridenName); + expect(capturedReq.getProject()).toBe(target.projectName); + expect(capturedReq.getOnlyCreate()).toBe(false); + expect(Utility.uint8ArrayToString(capturedReq.getSchema_asU8())).toBe(expectedSchema); + + return expect(resp).resolves.toBeInstanceOf(SearchIndex); + }); + + it("creates index using interface schema", async () => { + const expectedIndexName = "interface_schema"; + const expectedSchema = JSON.stringify({ + title: expectedIndexName, + type: "object", + properties: { + name: { + type: "string", + searchIndex: true, + }, + }, + }); + const resp = target.createOrUpdateIndex(expectedIndexName, IBook); + // verifies that grpc gets invoked with the right arguments + const [capturedReq, capturedCallback] = capture(mockClient.createOrUpdateIndex).last(); + // @ts-ignore + capturedCallback(null, new ProtoCreateIndexResponse()); + + expect(capturedReq.getName()).toBe(expectedIndexName); + expect(capturedReq.getProject()).toBe(target.projectName); + expect(capturedReq.getOnlyCreate()).toBe(false); + expect(Utility.uint8ArrayToString(capturedReq.getSchema_asU8())).toBe(expectedSchema); + + return expect(resp).resolves.toBeInstanceOf(SearchIndex); + }); + + it("fails when underlying grpc throws error", async () => { + const expectedError: ServiceError = { + details: "", + metadata: undefined, + name: "", + code: ProtoStatus.INVALID_ARGUMENT, + message: "invalid schema", + }; + const resp = target.createOrUpdateIndex(Book); + const [capturedReq, capturedCallback] = capture(mockClient.createOrUpdateIndex).last(); + // @ts-ignore + capturedCallback(expectedError, new ProtoCreateIndexResponse()); + + expect(capturedReq.getSchema()).toBeDefined(); + return expect(resp).rejects.toStrictEqual(expectedError); + }); + }); +}); diff --git a/src/__tests__/search/search.types.spec.ts b/src/__tests__/search/search.types.spec.ts index 6cc94e9..33a156e 100644 --- a/src/__tests__/search/search.types.spec.ts +++ b/src/__tests__/search/search.types.spec.ts @@ -7,33 +7,38 @@ import { SearchHitMeta as ProtoSearchHitMeta, SearchMetadata as ProtoSearchMetadata, SearchResponse as ProtoSearchResponse, + Match as ProtoMatch, + MatchField as ProtoMatchField, } from "../../proto/server/v1/api_pb"; -import {SearchResult} from "../../search/types"; -import {TestTigrisService} from "../test-service"; -import {IBook} from "../tigris.rpc.spec"; +import { TestTigrisService } from "../test-service"; +import { IBook } from "../tigris.rpc.spec"; import * as google_protobuf_timestamp_pb from "google-protobuf/google/protobuf/timestamp_pb"; +import { TextMatchInfo, SearchResult, DocMeta, SearchMeta } from "../../search"; describe("SearchResponse parsing", () => { it("generates search hits appropriately", () => { - const expectedTimeInSeconds = Math.floor(Date.now()/1000); - const expectedHits: ProtoSearchHit[] = [...TestTigrisService.BOOKS_B64_BY_ID].map( + const expectedTimeInSeconds = Math.floor(Date.now() / 1000); + const expectedHits: ProtoSearchHit[] = [...TestTigrisService.BOOKS_B64_BY_ID].map( // eslint-disable-next-line @typescript-eslint/no-unused-vars - ([_id, value]) => new ProtoSearchHit() - .setData(value) - .setMetadata(new ProtoSearchHitMeta().setUpdatedAt( - new google_protobuf_timestamp_pb.Timestamp().setSeconds(expectedTimeInSeconds) - )) + ([_id, value]) => + new ProtoSearchHit() + .setData(value) + .setMetadata( + new ProtoSearchHitMeta().setUpdatedAt( + new google_protobuf_timestamp_pb.Timestamp().setSeconds(expectedTimeInSeconds) + ) + ) ); const input: ProtoSearchResponse = new ProtoSearchResponse(); input.setHitsList(expectedHits); const parsed: SearchResult = SearchResult.from(input, { - serverUrl: "test" + serverUrl: "test", }); expect(parsed.hits).toHaveLength(expectedHits.length); - const receivedIds: string[] = parsed.hits.map(h => h.document.id.toString()); + const receivedIds: string[] = parsed.hits.map((h) => h.document.id.toString()); // eslint-disable-next-line @typescript-eslint/no-unused-vars - for (const [id] of TestTigrisService.BOOKS_B64_BY_ID) { + for (const [id] of TestTigrisService.BOOKS_B64_BY_ID) { expect(receivedIds).toContain(id); } for (const hit of parsed.hits) { @@ -45,15 +50,16 @@ describe("SearchResponse parsing", () => { it("generates facets appropriately", () => { const input: ProtoSearchResponse = new ProtoSearchResponse(); - const searchFacet = new ProtoSearchFacet().setCountsList( - [new ProtoFacetCount().setCount(2).setValue("Marcel Proust")]); + const searchFacet = new ProtoSearchFacet().setCountsList([ + new ProtoFacetCount().setCount(2).setValue("Marcel Proust"), + ]); input.getFacetsMap().set("author", searchFacet); - const parsed: SearchResult = SearchResult.from(input, {serverUrl: "test"}); + const parsed: SearchResult = SearchResult.from(input, { serverUrl: "test" }); - expect(parsed.facets.size).toBe(1); - expect(parsed.facets.get("author")).toBeDefined(); + expect(Object.keys(parsed.facets).length).toBe(1); + expect(parsed.facets["author"]).toBeDefined(); - const facetDistribution = parsed.facets.get("author"); + const facetDistribution = parsed.facets["author"]; expect(facetDistribution.counts).toHaveLength(1); expect(facetDistribution.counts[0].count).toBe(2); expect(facetDistribution.counts[0].value).toBe("Marcel Proust"); @@ -65,9 +71,9 @@ describe("SearchResponse parsing", () => { const input: ProtoSearchResponse = new ProtoSearchResponse(); const searchFacet = new ProtoSearchFacet().setStats(new ProtoFacetStats().setAvg(4.5)); input.getFacetsMap().set("author", searchFacet); - const parsed: SearchResult = SearchResult.from(input, {serverUrl: "test"}); + const parsed: SearchResult = SearchResult.from(input, { serverUrl: "test" }); - const facetDistribution = parsed.facets.get("author"); + const facetDistribution = parsed.facets["author"]; expect(facetDistribution.stats).toBeDefined(); expect(facetDistribution.stats.avg).toBe(4.5); expect(facetDistribution.stats.min).toBe(0); @@ -78,46 +84,108 @@ describe("SearchResponse parsing", () => { it("generates empty result with empty response", () => { const input: ProtoSearchResponse = new ProtoSearchResponse(); - const parsed: SearchResult = SearchResult.from(input, {serverUrl: "test"}); + const parsed: SearchResult = SearchResult.from(input, { serverUrl: "test" }); expect(parsed).toBeDefined(); expect(parsed.hits).toBeDefined(); expect(parsed.hits).toHaveLength(0); expect(parsed.facets).toBeDefined(); - expect(parsed.facets.size).toBe(0); - expect(parsed.meta).toBeUndefined(); - }); - - it("generates default meta values with empty meta", () => { - const input: ProtoSearchResponse = new ProtoSearchResponse(); - input.setMeta(new ProtoSearchMetadata()); - const parsed: SearchResult = SearchResult.from(input, {serverUrl: "test"}); - + expect(Object.keys(parsed.facets).length).toBe(0); expect(parsed.meta).toBeDefined(); expect(parsed.meta.found).toBe(0); - expect(parsed.meta.totalPages).toBe(0); - expect(parsed.meta.page).toBeUndefined(); - }); - - it("generates no page values with empty page", () => { - const input: ProtoSearchResponse = new ProtoSearchResponse(); - input.setMeta(new ProtoSearchMetadata().setFound(5)); - const parsed: SearchResult = SearchResult.from(input, {serverUrl: "test"}); - - expect(parsed.meta.found).toBe(5); - expect(parsed.meta.totalPages).toBe(0); - expect(parsed.meta.page).toBeUndefined(); + expect(parsed.meta.totalPages).toBe(1); + expect(parsed.meta.page).toBeDefined(); + expect(parsed.meta.page.current).toBe(1); + expect(parsed.meta.page.size).toBe(20); }); - it ("generates meta appropriately with complete response", () => { + it("generates meta appropriately with complete response", () => { const input: ProtoSearchResponse = new ProtoSearchResponse(); const page: ProtoPage = new ProtoPage().setSize(3).setCurrent(2); input.setMeta(new ProtoSearchMetadata().setPage(page).setTotalPages(100)); - const parsed: SearchResult = SearchResult.from(input, {serverUrl: "test"}); + const parsed: SearchResult = SearchResult.from(input, { serverUrl: "test" }); expect(parsed.meta.page.size).toBe(3); expect(parsed.meta.page.current).toBe(2); expect(parsed.meta.totalPages).toBe(100); }); + describe("SearchMeta", () => { + it("generates default SearchMeta with empty input", () => { + const input: ProtoSearchMetadata = new ProtoSearchMetadata(); + const parsed = SearchMeta.from(input); + expect(parsed.totalPages).toBe(0); + expect(parsed.found).toBe(0); + expect(parsed.page).toBeUndefined(); + expect(parsed.matchedFields).toEqual([]); + }); + + it("generates no page values with empty page", () => { + const input = new ProtoSearchMetadata().setFound(5); + const parsed = SearchMeta.from(input); + + expect(parsed.found).toBe(5); + expect(parsed.totalPages).toBe(0); + expect(parsed.page).toBeUndefined(); + expect(parsed.matchedFields).toEqual([]); + }); + + it("generates meta with complete input", () => { + const page: ProtoPage = new ProtoPage().setSize(3).setCurrent(2); + const input: ProtoSearchMetadata = new ProtoSearchMetadata() + .setPage(page) + .setTotalPages(100) + .setMatchedFieldsList(["empId", "name"]); + const parsed = SearchMeta.from(input); + + expect(parsed.page.size).toBe(3); + expect(parsed.page.current).toBe(2); + expect(parsed.totalPages).toBe(100); + expect(parsed.matchedFields).toEqual(["empId", "name"]); + }); + }); + + describe("DocMeta", () => { + it("generates DocMeta with empty", () => { + const input: ProtoSearchHitMeta = new ProtoSearchHitMeta(); + const parsed: DocMeta = DocMeta.from(input); + expect(parsed.createdAt).toBeUndefined(); + expect(parsed.updatedAt).toBeUndefined(); + expect(parsed.textMatch).toBeUndefined(); + }); + it("generates DocMeta with empty", () => { + const input: ProtoSearchHitMeta = new ProtoSearchHitMeta(); + const expectedTimeInSeconds = Math.floor(Date.now() / 1000); + input.setCreatedAt( + new google_protobuf_timestamp_pb.Timestamp().setSeconds(expectedTimeInSeconds) + ); + input.setMatch(new ProtoMatch()); + const parsed: DocMeta = DocMeta.from(input); + expect(parsed.updatedAt).toBeUndefined(); + expect(parsed.createdAt).toStrictEqual(new Date(expectedTimeInSeconds * 1000)); + expect(parsed.textMatch).toBeDefined(); + }); + }); + + describe("TextMatchInfo", () => { + it("generates match field with empty inputs", () => { + const input: ProtoMatch = new ProtoMatch(); + const parsed: TextMatchInfo = TextMatchInfo.from(input); + expect(parsed.fields).toStrictEqual([]); + expect(parsed.score).toBe(""); + expect(parsed.vectorDistance).toBeUndefined(); + }); + it("generates match field from input", () => { + const input: ProtoMatch = new ProtoMatch(); + input.setScore("456"); + input.addFields(new ProtoMatchField().setName("person")); + input.addFields(new ProtoMatchField().setName("user")); + input.setVectorDistance(0.24); + + const parsed: TextMatchInfo = TextMatchInfo.from(input); + expect(parsed.fields).toEqual(expect.arrayContaining(["person", "user"])); + expect(parsed.score).toBe("456"); + expect(parsed.vectorDistance).toBe(0.24); + }); + }); }); diff --git a/src/__tests__/test-cache-service.ts b/src/__tests__/test-cache-service.ts new file mode 100644 index 0000000..809717e --- /dev/null +++ b/src/__tests__/test-cache-service.ts @@ -0,0 +1,172 @@ +import { CacheService, ICacheServer } from "../proto/server/v1/cache_grpc_pb"; +import { sendUnaryData, ServerUnaryCall, ServerWritableStream } from "@grpc/grpc-js"; +import { + CacheMetadata, + CreateCacheRequest, + CreateCacheResponse, + DeleteCacheRequest, + DeleteCacheResponse, + DelResponse, + GetRequest, + GetResponse, + GetSetRequest, + GetSetResponse, + KeysRequest, + KeysResponse, + ListCachesRequest, + ListCachesResponse, + SetRequest, + SetResponse, +} from "../proto/server/v1/cache_pb"; +import { DelRequest } from "../../dist/proto/server/v1/cache_pb"; +import { Utility } from "../utility"; +import * as grpc from "@grpc/grpc-js"; +import * as server_v1_cache_pb from "../proto/server/v1/cache_pb"; +import { ReadRequest, ReadResponse } from "../proto/server/v1/api_pb"; + +export class TestCacheService { + private static CACHE_MAP = new Map>(); + + static reset() { + TestCacheService.CACHE_MAP.clear(); + } + public impl: ICacheServer = { + createCache( + call: ServerUnaryCall, + callback: sendUnaryData + ): void { + const cacheName = call.request.getProject() + "_" + call.request.getName(); + if (TestCacheService.CACHE_MAP.has(cacheName)) { + callback(new Error(), undefined); + } else { + TestCacheService.CACHE_MAP.set(cacheName, new Map()); + callback( + undefined, + new CreateCacheResponse().setStatus("created").setMessage("Cache created successfully") + ); + } + }, + del( + call: ServerUnaryCall, + callback: sendUnaryData + ): void { + const cacheName = call.request.getProject() + "_" + call.request.getName(); + if (TestCacheService.CACHE_MAP.has(cacheName)) { + if (TestCacheService.CACHE_MAP.get(cacheName).has(call.request.getKey())) { + TestCacheService.CACHE_MAP.get(cacheName).delete(call.request.getKey()); + callback( + undefined, + new DelResponse().setStatus("deleted").setMessage("Deleted key count# 1") + ); + } + } else { + callback(new Error("cache does not exist"), undefined); + } + }, + deleteCache( + call: ServerUnaryCall, + callback: sendUnaryData + ): void { + const cacheName = call.request.getProject() + "_" + call.request.getName(); + if (TestCacheService.CACHE_MAP.has(cacheName)) { + TestCacheService.CACHE_MAP.delete(cacheName); + callback( + undefined, + new DeleteCacheResponse().setStatus("deleted").setMessage("Deleted cache") + ); + } else { + callback(new Error("cache does not exist"), undefined); + } + }, + get( + call: ServerUnaryCall, + callback: sendUnaryData + ): void { + const cacheName = call.request.getProject() + "_" + call.request.getName(); + if (TestCacheService.CACHE_MAP.has(cacheName)) { + if (TestCacheService.CACHE_MAP.get(cacheName).has(call.request.getKey())) { + const value = TestCacheService.CACHE_MAP.get(cacheName).get(call.request.getKey()); + callback(undefined, new GetResponse().setValue(value)); + } else { + callback(new Error("cache key does not exist"), undefined); + } + } else { + callback(new Error("cache does not exist"), undefined); + } + }, + + listCaches( + call: ServerUnaryCall, + callback: sendUnaryData + ): void { + const result: Array = new Array(); + for (let key of TestCacheService.CACHE_MAP.keys()) { + if (key.startsWith(call.request.getProject())) + result.push( + new CacheMetadata().setName(key.replace(call.request.getProject() + "_", "")) + ); + } + callback(undefined, new ListCachesResponse().setCachesList(result)); + }, + set( + call: ServerUnaryCall, + callback: sendUnaryData + ): void { + const cacheName = call.request.getProject() + "_" + call.request.getName(); + if (TestCacheService.CACHE_MAP.has(cacheName)) { + TestCacheService.CACHE_MAP.get(cacheName).set( + call.request.getKey(), + call.request.getValue_asB64() + ); + callback(undefined, new SetResponse().setStatus("set").setMessage("set" + " successfully")); + } else { + callback(new Error("cache does not exist"), undefined); + } + }, + keys(call: ServerWritableStream): void { + const cacheName = call.request.getProject() + "_" + call.request.getName(); + if (TestCacheService.CACHE_MAP.has(cacheName)) { + const result: Array = new Array(); + for (let key of TestCacheService.CACHE_MAP.get(cacheName).keys()) { + result.push(key); + } + call.write(new KeysResponse().setKeysList(result)); + call.end(); + } else { + call.emit("error", { + message: "cache does not exist", + }); + call.end(); + } + }, + getSet( + call: ServerUnaryCall, + callback: sendUnaryData + ): void { + const cacheName = call.request.getProject() + "_" + call.request.getName(); + if (TestCacheService.CACHE_MAP.has(cacheName)) { + let oldValue = undefined; + if (TestCacheService.CACHE_MAP.get(cacheName).has(call.request.getKey())) { + oldValue = TestCacheService.CACHE_MAP.get(cacheName).get(call.request.getKey()); + } + + TestCacheService.CACHE_MAP.get(cacheName).set( + call.request.getKey(), + call.request.getValue_asB64() + ); + const result = new GetSetResponse().setStatus("set").setMessage("set" + " successfully"); + if (oldValue !== undefined) { + result.setOldValue(oldValue); + } + callback(undefined, result); + } else { + callback(new Error("cache does not exist"), undefined); + } + }, + }; +} + +export default { + service: CacheService, + handler: new TestCacheService(), +}; diff --git a/src/__tests__/test-observability-service.ts b/src/__tests__/test-observability-service.ts index 9b2b58f..cb1eb0f 100644 --- a/src/__tests__/test-observability-service.ts +++ b/src/__tests__/test-observability-service.ts @@ -1,23 +1,35 @@ -import {IObservabilityServer, ObservabilityService} from "../proto/server/v1/observability_grpc_pb"; -import {sendUnaryData, ServerUnaryCall} from "@grpc/grpc-js"; +import { + IObservabilityServer, + ObservabilityService, +} from "../proto/server/v1/observability_grpc_pb"; +import { sendUnaryData, ServerUnaryCall } from "@grpc/grpc-js"; import { GetInfoRequest, GetInfoResponse, QueryTimeSeriesMetricsRequest, QueryTimeSeriesMetricsResponse, QuotaLimitsRequest, - QuotaLimitsResponse, QuotaUsageRequest, QuotaUsageResponse + QuotaLimitsResponse, + QuotaUsageRequest, + QuotaUsageResponse, } from "../proto/server/v1/observability_pb"; export class TestTigrisObservabilityService { public impl: IObservabilityServer = { - quotaLimits(call: ServerUnaryCall, callback: sendUnaryData): void { - }, - quotaUsage(call: ServerUnaryCall, callback: sendUnaryData): void { - }, - queryTimeSeriesMetrics(call: ServerUnaryCall, callback: sendUnaryData): void { + quotaLimits( + call: ServerUnaryCall, + callback: sendUnaryData + ): void {}, + quotaUsage( + call: ServerUnaryCall, + callback: sendUnaryData + ): void {}, + queryTimeSeriesMetrics( + call: ServerUnaryCall, + callback: sendUnaryData + ): void { // not implemented - }, + }, /* eslint-disable @typescript-eslint/no-empty-function */ getInfo( // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -28,8 +40,8 @@ export class TestTigrisObservabilityService { const reply: GetInfoResponse = new GetInfoResponse(); reply.setServerVersion("1.0.0-test-service"); callback(undefined, reply); - } - } + }, + }; } export default { diff --git a/src/__tests__/test-search-service.ts b/src/__tests__/test-search-service.ts new file mode 100644 index 0000000..7364492 --- /dev/null +++ b/src/__tests__/test-search-service.ts @@ -0,0 +1,245 @@ +import { ISearchServer, SearchService } from "../proto/server/v1/search_grpc_pb"; +import { sendUnaryData, ServerUnaryCall, ServerWritableStream } from "@grpc/grpc-js"; +import { + CreateByIdRequest, + CreateByIdResponse, + CreateDocumentRequest, + CreateDocumentResponse, + CreateOrReplaceDocumentRequest, + CreateOrReplaceDocumentResponse, + CreateOrUpdateIndexRequest, + CreateOrUpdateIndexResponse, + DeleteByQueryRequest, + DeleteByQueryResponse, + DeleteDocumentRequest, + DeleteDocumentResponse, + DeleteIndexRequest, + DeleteIndexResponse, + DocStatus, + GetDocumentRequest, + GetDocumentResponse, + GetIndexRequest, + GetIndexResponse, + IndexInfo, + ListIndexesRequest, + ListIndexesResponse, + SearchIndexRequest, + SearchIndexResponse, + UpdateDocumentRequest, + UpdateDocumentResponse, +} from "../proto/server/v1/search_pb"; +import * as google_protobuf_timestamp_pb from "google-protobuf/google/protobuf/timestamp_pb"; +import { Utility } from "../utility"; +import { + FacetCount, + Page, + SearchFacet, + SearchMetadata, + SearchHit, + SearchHitMeta, +} from "../proto/server/v1/api_pb"; + +export const SearchServiceFixtures = { + Success: "validIndex", + AlreadyExists: "existingIndex", + DoesNotExist: "NoIndex", + Docs: new Map([ + ["1", { title: "नमस्ते to India", tags: ["travel"] }], + ["2", { title: "reliable systems 🙏", tags: ["it"] }], + ]), + CreateIndex: { + Blog: "blogPosts", + BlogOverride: "blogPostsOverride", + }, + SearchDocs: { + UpdatedAtSeconds: Math.floor(Date.now() / 1000), + }, + GetDocs: { + CreatedAtSeconds: 1672574400, + }, +}; +const enc = new TextEncoder(); + +class TestSearchService { + public impl: ISearchServer = { + create( + call: ServerUnaryCall, + callback: sendUnaryData + ): void { + switch (call.request.getIndex()) { + case SearchServiceFixtures.Success: + const input: Object[] = call.request.getDocumentsList_asB64().map((d) => { + return JSON.parse(Utility._base64Decode(d)); + }); + const response = new CreateDocumentResponse(); + input.forEach((i) => response.addStatus(new DocStatus().setId(i["title"]))); + callback(undefined, response); + return; + default: + callback(new Error("Failed to update documents")); + return; + } + }, + createById( + call: ServerUnaryCall, + callback: sendUnaryData + ): void {}, + createOrReplace( + call: ServerUnaryCall, + callback: sendUnaryData + ): void {}, + delete( + call: ServerUnaryCall, + callback: sendUnaryData + ): void { + const resp = new DeleteDocumentResponse(); + call.request.getIdsList().forEach((id) => resp.addStatus(new DocStatus().setId(id))); + callback(undefined, resp); + return; + }, + deleteByQuery( + call: ServerUnaryCall, + callback: sendUnaryData + ): void {}, + get( + call: ServerUnaryCall, + callback: sendUnaryData + ): void { + const resp = new GetDocumentResponse(); + call.request.getIdsList().forEach((id) => { + const docAsString = JSON.stringify(SearchServiceFixtures.Docs.get(id)); + resp.addDocuments( + new SearchHit() + .setData(enc.encode(docAsString)) + .setMetadata( + new SearchHitMeta().setCreatedAt( + new google_protobuf_timestamp_pb.Timestamp().setSeconds( + SearchServiceFixtures.GetDocs.CreatedAtSeconds + ) + ) + ) + ); + }); + callback(undefined, resp); + return; + }, + search(call: ServerWritableStream): void { + const expectedUpdatedAt = new google_protobuf_timestamp_pb.Timestamp().setSeconds( + SearchServiceFixtures.SearchDocs.UpdatedAtSeconds + ); + const resp = new SearchIndexResponse(); + SearchServiceFixtures.Docs.forEach((d) => + resp.addHits( + new SearchHit() + .setData(enc.encode(JSON.stringify(d))) + .setMetadata(new SearchHitMeta().setUpdatedAt(expectedUpdatedAt)) + ) + ); + resp.setMeta( + new SearchMetadata() + .setFound(5) + .setTotalPages(5) + .setPage(new Page().setSize(1).setCurrent(1)) + ); + resp + .getFacetsMap() + .set( + "title", + new SearchFacet().setCountsList([new FacetCount().setCount(2).setValue("Philosophy")]) + ); + call.write(resp); + call.end(); + }, + update( + call: ServerUnaryCall, + callback: sendUnaryData + ): void {}, + createOrUpdateIndex( + call: ServerUnaryCall, + callback: sendUnaryData + ): void { + switch (call.request.getName()) { + case SearchServiceFixtures.Success: + const response = new CreateOrUpdateIndexResponse() + .setStatus("created") + .setMessage("index created"); + callback(undefined, response); + return; + case SearchServiceFixtures.CreateIndex.Blog: + const schema = Buffer.from(call.request.getSchema_asB64(), "base64").toString(); + expect(schema).toBe( + '{"title":"blogPosts","type":"object","properties":{"text":{"type":"string","searchIndex":true,"facet":true},"comments":{"type":"array","items":{"type":"string"},"searchIndex":true},"author":{"type":"string","searchIndex":true},"createdAt":{"type":"string","format":"date-time","searchIndex":true,"sort":true}}}' + ); + const resp = new CreateOrUpdateIndexResponse() + .setStatus("created") + .setMessage("index created"); + callback(undefined, resp); + return; + case SearchServiceFixtures.CreateIndex.BlogOverride: + const schema2 = Buffer.from(call.request.getSchema_asB64(), "base64").toString(); + expect(schema2).toBe( + '{"title":"blogPostsOverride","type":"object","properties":{"text":{"type":"string","searchIndex":true,"facet":true},"comments":{"type":"array","items":{"type":"string"},"searchIndex":true},"author":{"type":"string","searchIndex":true},"createdAt":{"type":"string","format":"date-time","searchIndex":true,"sort":true}}}' + ); + const resp2 = new CreateOrUpdateIndexResponse() + .setStatus("created") + .setMessage("index created"); + callback(undefined, resp2); + return; + case SearchServiceFixtures.AlreadyExists: + callback(new Error("already exists")); + return; + default: + callback(new Error("Server error"), undefined); + return; + } + }, + getIndex( + call: ServerUnaryCall, + callback: sendUnaryData + ): void { + switch (call.request.getName()) { + case SearchServiceFixtures.Success: + const response = new GetIndexResponse().setIndex( + new IndexInfo().setName(SearchServiceFixtures.Success) + ); + callback(undefined, response); + return; + case SearchServiceFixtures.DoesNotExist: + callback(new Error("search index not found")); + return; + } + }, + deleteIndex( + call: ServerUnaryCall, + callback: sendUnaryData + ): void { + switch (call.request.getName()) { + case SearchServiceFixtures.Success: + const response = new DeleteIndexResponse() + .setStatus("deleted") + .setMessage("Index deleted"); + callback(undefined, response); + return; + case SearchServiceFixtures.DoesNotExist: + callback(new Error("search index not found")); + return; + } + }, + listIndexes( + call: ServerUnaryCall, + callback: sendUnaryData + ): void { + const response = new ListIndexesResponse().setIndexesList([ + new IndexInfo().setName("i1"), + new IndexInfo().setName("i2"), + ]); + callback(undefined, response); + return; + }, + }; +} + +export default { + service: SearchService, + handler: new TestSearchService(), +}; diff --git a/src/__tests__/test-service.ts b/src/__tests__/test-service.ts index 7be4233..b233f33 100644 --- a/src/__tests__/test-service.ts +++ b/src/__tests__/test-service.ts @@ -1,20 +1,29 @@ -import {ITigrisServer, TigrisService} from "../proto/server/v1/api_grpc_pb"; -import {sendUnaryData, ServerUnaryCall, ServerWritableStream} from "@grpc/grpc-js"; -import {v4 as uuidv4} from "uuid"; +import { TigrisService } from "../proto/server/v1/api_grpc_pb"; +import * as grpc from "@grpc/grpc-js"; +import { sendUnaryData, Server, ServerUnaryCall, ServerWritableStream } from "@grpc/grpc-js"; +import { v4 as uuidv4 } from "uuid"; import { BeginTransactionRequest, BeginTransactionResponse, CollectionDescription, + CollectionIndex, CollectionInfo, CollectionMetadata, CommitTransactionRequest, CommitTransactionResponse, - CreateDatabaseRequest, - CreateDatabaseResponse, + CountRequest, + CountResponse, + CreateBranchRequest, + CreateBranchResponse, CreateOrUpdateCollectionRequest, CreateOrUpdateCollectionResponse, - DatabaseInfo, + CreateProjectRequest, + CreateProjectResponse, DatabaseMetadata, + DeleteBranchRequest, + DeleteBranchResponse, + DeleteProjectRequest, + DeleteProjectResponse, DeleteRequest, DeleteResponse, DescribeCollectionRequest, @@ -23,20 +32,17 @@ import { DescribeDatabaseResponse, DropCollectionRequest, DropCollectionResponse, - DropDatabaseRequest, - DropDatabaseResponse, - EventsRequest, - EventsResponse, + ExplainResponse, FacetCount, + GroupedSearchHits, InsertRequest, InsertResponse, ListCollectionsRequest, ListCollectionsResponse, - ListDatabasesRequest, - ListDatabasesResponse, + ListProjectsRequest, + ListProjectsResponse, Page, - PublishRequest, - PublishResponse, + ProjectInfo, ReadRequest, ReadResponse, ReplaceRequest, @@ -50,48 +56,63 @@ import { SearchMetadata, SearchRequest, SearchResponse, - StreamEvent, - SubscribeRequest, - SubscribeResponse, TransactionCtx, UpdateRequest, - UpdateResponse + UpdateResponse, } from "../proto/server/v1/api_pb"; import * as google_protobuf_timestamp_pb from "google-protobuf/google/protobuf/timestamp_pb"; -import {Utility} from "../utility"; +import { Utility } from "../utility"; +import { Status } from "@grpc/grpc-js/build/src/constants"; +import assert from "assert"; +import { Tigris } from "../tigris"; +import { Collection } from "../collection"; export class TestTigrisService { - private static DBS: string[] = []; + public static readonly ExpectedBranch = "unit-tests"; + private static PROJECTS: string[] = []; private static COLLECTION_MAP = new Map>(); private static txId: string; private static txOrigin: string; public static readonly BOOKS_B64_BY_ID: ReadonlyMap = new Map([ // base64 of {"id":1,"title":"A Passage to India","author":"E.M. Forster","tags":["Novel","India"]} - ["1", "eyJpZCI6MSwidGl0bGUiOiJBIFBhc3NhZ2UgdG8gSW5kaWEiLCJhdXRob3IiOiJFLk0uIEZvcnN0ZXIiLCJ0YWdzIjpbIk5vdmVsIiwiSW5kaWEiXX0="], + [ + "1", + "eyJpZCI6MSwidGl0bGUiOiJBIFBhc3NhZ2UgdG8gSW5kaWEiLCJhdXRob3IiOiJFLk0uIEZvcnN0ZXIiLCJ0YWdzIjpbIk5vdmVsIiwiSW5kaWEiXX0=", + ], // base64 of {"id":3,"title":"In Search of Lost Time","author":"Marcel Proust","tags":["Novel","Childhood"]} - ["3", "eyJpZCI6MywidGl0bGUiOiJJbiBTZWFyY2ggb2YgTG9zdCBUaW1lIiwiYXV0aG9yIjoiTWFyY2VsIFByb3VzdCIsInRhZ3MiOlsiTm92ZWwiLCJDaGlsZGhvb2QiXX0="], + [ + "3", + "eyJpZCI6MywidGl0bGUiOiJJbiBTZWFyY2ggb2YgTG9zdCBUaW1lIiwiYXV0aG9yIjoiTWFyY2VsIFByb3VzdCIsInRhZ3MiOlsiTm92ZWwiLCJDaGlsZGhvb2QiXX0=", + ], // base64 of {"id":4,"title":"Swann's Way","author":"Marcel Proust"} ["4", "eyJpZCI6NCwidGl0bGUiOiJTd2FubidzIFdheSIsImF1dGhvciI6Ik1hcmNlbCBQcm91c3QifQ=="], // base64 of {"id":5,"title":"Time Regained","author":"Marcel Proust"} ["5", "eyJpZCI6NSwidGl0bGUiOiJUaW1lIFJlZ2FpbmVkIiwiYXV0aG9yIjoiTWFyY2VsIFByb3VzdCJ9"], // base64 of {"id":6,"title":"The Prisoner","author":"Marcel Proust"} - ["6", "eyJpZCI6NiwidGl0bGUiOiJUaGUgUHJpc29uZXIiLCJhdXRob3IiOiJNYXJjZWwgUHJvdXN0In0="] + ["6", "eyJpZCI6NiwidGl0bGUiOiJUaGUgUHJpc29uZXIiLCJhdXRob3IiOiJNYXJjZWwgUHJvdXN0In0="], + // base64 of {"id":7,"title":"A Passage to India","author":"E.M. Forster","tags":["Novel","India"], "purchasedOn": "2023-04-14T09:39:19.288Z"} + [ + "7", + "eyJpZCI6NywidGl0bGUiOiJBIFBhc3NhZ2UgdG8gSW5kaWEiLCJhdXRob3IiOiJFLk0uIEZvcnN0ZXIiLCJ0YWdzIjpbIk5vdmVsIiwiSW5kaWEiXSwgInB1cmNoYXNlZE9uIjogIjIwMjMtMDQtMTRUMDk6Mzk6MTkuMjg4WiJ9", + ], ]); + private myDatabase: any; + public static readonly ALERTS_B64_BY_ID: ReadonlyMap = new Map([ // base64 of {"id":1,"text":"test"} [1, "eyJpZCI6MSwidGV4dCI6InRlc3QifQ=="], // base64 of {"id":2,"text":"test message 25"} - [2, "eyJpZCI6MiwidGV4dCI6InRlc3QgbWVzc2FnZSAyNSJ9"] + [2, "eyJpZCI6MiwidGV4dCI6InRlc3QgbWVzc2FnZSAyNSJ9"], ]); static reset() { - TestTigrisService.DBS = []; + TestTigrisService.PROJECTS = []; TestTigrisService.COLLECTION_MAP = new Map>(); this.txId = ""; this.txOrigin = ""; for (let d = 1; d <= 5; d++) { - TestTigrisService.DBS.push("db" + d); + TestTigrisService.PROJECTS.push("db" + d); const collections: string[] = []; for (let c = 1; c <= 5; c++) { collections[c - 1] = "db" + d + "_coll_" + c; @@ -100,36 +121,148 @@ export class TestTigrisService { } } - public impl: ITigrisServer = { - // eslint-disable-next-line @typescript-eslint/no-empty-function - events(call: ServerWritableStream): void { - const event = new StreamEvent(); - event.setTxId(Utility._base64Encode(uuidv4())); - event.setCollection("books"); - event.setOp("insert"); - event.setData(TestTigrisService.BOOKS_B64_BY_ID.get("5")); - event.setLast(true); - call.write(new EventsResponse().setEvent(event)); - call.end(); - }, - // eslint-disable-next-line @typescript-eslint/no-empty-function,@typescript-eslint/no-unused-vars - publish(call: ServerUnaryCall, callback: sendUnaryData): void { - const reply: PublishResponse = new PublishResponse(); - callback(undefined, reply); + public impl: { + deleteBranch( + call: ServerUnaryCall, + callback: sendUnaryData + ): void; + read(call: ServerWritableStream): void; + describeDatabase( + call: ServerUnaryCall, + callback: sendUnaryData + ): void; + replace( + call: ServerUnaryCall, + callback: sendUnaryData + ): void; + rollbackTransaction( + call: ServerUnaryCall, + callback: sendUnaryData + ): void; + insert( + call: ServerUnaryCall, + callback: sendUnaryData + ): void; + update( + call: ServerUnaryCall, + callback: sendUnaryData + ): void; + createProject( + call: ServerUnaryCall, + callback: sendUnaryData + ): void; + listProjects( + call: ServerUnaryCall, + callback: sendUnaryData + ): void; + delete( + call: ServerUnaryCall, + callback: sendUnaryData + ): void; + describeCollection( + _call: ServerUnaryCall, + _callback: sendUnaryData + ): void; + search(call: ServerWritableStream): void; + createOrUpdateCollection( + call: ServerUnaryCall, + callback: sendUnaryData + ): void; + beginTransaction( + call: ServerUnaryCall, + callback: sendUnaryData + ): void; + commitTransaction( + call: ServerUnaryCall, + callback: sendUnaryData + ): void; + dropCollection( + call: ServerUnaryCall, + callback: sendUnaryData + ): void; + deleteProject( + call: ServerUnaryCall, + callback: sendUnaryData + ): void; + createBranch( + call: ServerUnaryCall, + callback: sendUnaryData + ): void; + listCollections( + call: ServerUnaryCall, + callback: sendUnaryData + ): void; + explain( + call: ServerUnaryCall, + callback: sendUnaryData + ): void; + count( + call: ServerUnaryCall, + callback: sendUnaryData + ): void; + } = { + createBranch( + call: ServerUnaryCall, + callback: sendUnaryData + ): void { + let err: Partial; + const reply = new CreateBranchResponse(); + + switch (call.request.getBranch()) { + case Branch.Existing: + err = { + code: Status.ALREADY_EXISTS, + details: `branch already exists '${Branch.Existing}'`, + }; + break; + case Branch.NotFound: + err = { + code: Status.NOT_FOUND, + details: `project not found`, + }; + break; + default: + reply.setStatus("created"); + reply.setMessage("branch successfully created"); + } + + if (err) { + return callback(err, undefined); + } else { + return callback(undefined, reply); + } }, - // eslint-disable-next-line @typescript-eslint/no-empty-function,@typescript-eslint/no-unused-vars - subscribe(call: ServerWritableStream): void { - for (const alert of TestTigrisService.ALERTS_B64_BY_ID) { - call.write(new SubscribeResponse().setMessage(alert[1])); + deleteBranch( + call: ServerUnaryCall, + callback: sendUnaryData + ): void { + let err: Partial; + const reply = new DeleteBranchResponse(); + switch (call.request.getBranch()) { + case Branch.NotFound: + err = { + code: Status.NOT_FOUND, + details: `Branch doesn't exist`, + }; + break; + default: + reply.setStatus("deleted"); + reply.setMessage("branch deleted successfully"); + } + if (err) { + return callback(err, undefined); + } else { + return callback(undefined, reply); } - call.end(); }, beginTransaction( call: ServerUnaryCall, callback: sendUnaryData ): void { + assert(call.request.getBranch() === TestTigrisService.ExpectedBranch); + const reply: BeginTransactionResponse = new BeginTransactionResponse(); - if (call.request.getDb() === "test-tx") { + if (call.request.getProject() === "test-tx") { TestTigrisService.txId = uuidv4(); TestTigrisService.txOrigin = uuidv4(); reply.setTxCtx( @@ -141,43 +274,43 @@ export class TestTigrisService { reply.setTxCtx(new TransactionCtx().setId("id-test").setOrigin("origin-test")); callback(undefined, reply); }, - // eslint-disable-next-line @typescript-eslint/no-empty-function commitTransaction( call: ServerUnaryCall, callback: sendUnaryData ): void { + assert(call.request.getBranch() === TestTigrisService.ExpectedBranch); + const reply: CommitTransactionResponse = new CommitTransactionResponse(); - reply.setStatus("committed-test"); callback(undefined, reply); }, - createDatabase( - call: ServerUnaryCall, - callback: sendUnaryData + createProject( + call: ServerUnaryCall, + callback: sendUnaryData ): void { - TestTigrisService.DBS.push(call.request.getDb()); - const reply: CreateDatabaseResponse = new CreateDatabaseResponse(); - reply.setMessage(call.request.getDb() + " created successfully"); + TestTigrisService.PROJECTS.push(call.request.getProject()); + const reply: CreateProjectResponse = new CreateProjectResponse(); + reply.setMessage(call.request.getProject() + " created successfully"); reply.setStatus("created"); callback(undefined, reply); }, - /* eslint-disable @typescript-eslint/no-empty-function */ createOrUpdateCollection( - // eslint-disable-next-line @typescript-eslint/no-unused-vars call: ServerUnaryCall, - // eslint-disable-next-line @typescript-eslint/no-unused-vars callback: sendUnaryData ): void { + assert(call.request.getBranch() === TestTigrisService.ExpectedBranch); + const reply: CreateOrUpdateCollectionResponse = new CreateOrUpdateCollectionResponse(); reply.setStatus("Collections created successfully"); reply.setStatus(call.request.getCollection()); callback(undefined, reply); }, - /* eslint-enable @typescript-eslint/no-empty-function */ delete( call: ServerUnaryCall, callback: sendUnaryData ): void { - if (call.request.getDb() === "test-tx") { + assert(call.request.getBranch() === TestTigrisService.ExpectedBranch); + + if (call.request.getProject() === "test-tx") { const txIdHeader = call.metadata.get("Tigris-Tx-Id").toString(); const txOriginHeader = call.metadata.get("Tigris-Tx-Origin").toString(); if (txIdHeader != TestTigrisService.txId || txOriginHeader != TestTigrisService.txOrigin) { @@ -194,15 +327,27 @@ export class TestTigrisService { ); callback(undefined, reply); }, + /* eslint-disable @typescript-eslint/no-empty-function */ describeCollection( + call: ServerUnaryCall, // eslint-disable-next-line @typescript-eslint/no-unused-vars - _call: ServerUnaryCall, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - _callback: sendUnaryData + callback: sendUnaryData ): void { + assert(call.request.getBranch() === TestTigrisService.ExpectedBranch); + + const indexList: CollectionIndex[] = [ + new CollectionIndex().setName("title").setState("INDEX ACTIVE").setFieldsList([]), + new CollectionIndex().setName("author").setState("INDEX WRITE MODE").setFieldsList([]), + ]; + + const reply = new DescribeCollectionResponse() + .setSchema("schema") + .setCollection(call.request.getCollection()) + .setIndexesList(indexList); + + callback(undefined, reply); }, - /* eslint-enable @typescript-eslint/no-empty-function */ describeDatabase( call: ServerUnaryCall, @@ -212,20 +357,20 @@ export class TestTigrisService { const collectionsDescription: CollectionDescription[] = []; for ( let index = 0; - index < TestTigrisService.COLLECTION_MAP.get(call.request.getDb()).length; + index < TestTigrisService.COLLECTION_MAP.get(call.request.getProject()).length; index++ ) { collectionsDescription.push( new CollectionDescription() - .setCollection(TestTigrisService.COLLECTION_MAP.get(call.request.getDb())[index]) + .setCollection(TestTigrisService.COLLECTION_MAP.get(call.request.getProject())[index]) .setMetadata(new CollectionMetadata()) .setSchema("schema" + index) ); } result - .setDb(call.request.getDb()) .setMetadata(new DatabaseMetadata()) - .setCollectionsList(collectionsDescription); + .setCollectionsList(collectionsDescription) + .setBranchesList(["main", "staging", TestTigrisService.ExpectedBranch]); callback(undefined, result); }, @@ -233,24 +378,26 @@ export class TestTigrisService { call: ServerUnaryCall, callback: sendUnaryData ): void { - const newCollections = TestTigrisService.COLLECTION_MAP.get(call.request.getDb()).filter( + assert(call.request.getBranch() === TestTigrisService.ExpectedBranch); + + const newCollections = TestTigrisService.COLLECTION_MAP.get(call.request.getProject()).filter( (coll) => coll !== call.request.getCollection() ); - TestTigrisService.COLLECTION_MAP.set(call.request.getDb(), newCollections); + TestTigrisService.COLLECTION_MAP.set(call.request.getProject(), newCollections); const reply: DropCollectionResponse = new DropCollectionResponse(); reply.setMessage(call.request.getCollection() + " dropped successfully"); reply.setStatus("dropped"); callback(undefined, reply); }, - dropDatabase( - call: ServerUnaryCall, - callback: sendUnaryData + deleteProject( + call: ServerUnaryCall, + callback: sendUnaryData ): void { - TestTigrisService.DBS = TestTigrisService.DBS.filter( - (database) => database !== call.request.getDb() + TestTigrisService.PROJECTS = TestTigrisService.PROJECTS.filter( + (database) => database !== call.request.getProject() ); - const reply: DropDatabaseResponse = new DropDatabaseResponse(); - reply.setMessage(call.request.getDb() + " dropped successfully"); + const reply: DeleteProjectResponse = new DeleteProjectResponse(); + reply.setMessage(call.request.getProject() + " dropped successfully"); reply.setStatus("dropped"); callback(undefined, reply); }, @@ -258,7 +405,9 @@ export class TestTigrisService { call: ServerUnaryCall, callback: sendUnaryData ): void { - if (call.request.getDb() === "test-tx") { + assert(call.request.getBranch() === TestTigrisService.ExpectedBranch); + + if (call.request.getProject() === "test-tx") { const txIdHeader = call.metadata.get("Tigris-Tx-Id").toString(); const txOriginHeader = call.metadata.get("Tigris-Tx-Origin").toString(); if (txIdHeader != TestTigrisService.txId || txOriginHeader != TestTigrisService.txOrigin) { @@ -271,16 +420,20 @@ export class TestTigrisService { const keyList: Array = []; for (let i = 1; i <= call.request.getDocumentsList().length; i++) { if (call.request.getCollection() === "books-with-optional-field") { - const extractedKeyFromAuthor: number = JSON.parse(Utility._base64Decode(call.request.getDocumentsList_asB64()[i - 1]))["author"]; - keyList.push(Utility._base64Encode("{\"id\":" + extractedKeyFromAuthor + "}")); + const extractedKeyFromAuthor: number = JSON.parse( + Utility._base64Decode(call.request.getDocumentsList_asB64()[i - 1]) + )["author"]; + keyList.push(Utility._base64Encode('{"id":' + extractedKeyFromAuthor + "}")); + } else if (call.request.getCollection() === "books-multi-pk") { + keyList.push(Utility._base64Encode('{"id":' + i + ', "id2":' + i + 1 + "}")); } else { - keyList.push(Utility._base64Encode("{\"id\":" + i + "}")); + keyList.push(Utility._base64Encode('{"id":' + i + "}")); } } reply.setKeysList(keyList); reply.setStatus( "inserted: " + - JSON.stringify(new TextDecoder().decode(call.request.getDocumentsList_asU8()[0])) + JSON.stringify(new TextDecoder().decode(call.request.getDocumentsList_asU8()[0])) ); reply.setMetadata( new ResponseMetadata() @@ -293,40 +446,45 @@ export class TestTigrisService { call: ServerUnaryCall, callback: sendUnaryData ): void { + assert(call.request.getBranch() === TestTigrisService.ExpectedBranch); + const reply: ListCollectionsResponse = new ListCollectionsResponse(); const collectionInfos: CollectionInfo[] = []; for ( let index = 0; - index < TestTigrisService.COLLECTION_MAP.get(call.request.getDb()).length; + index < TestTigrisService.COLLECTION_MAP.get(call.request.getProject()).length; index++ ) { collectionInfos.push( new CollectionInfo() - .setCollection(TestTigrisService.COLLECTION_MAP.get(call.request.getDb())[index]) + .setCollection(TestTigrisService.COLLECTION_MAP.get(call.request.getProject())[index]) .setMetadata(new CollectionMetadata()) ); } reply.setCollectionsList(collectionInfos); callback(undefined, reply); }, - listDatabases( - call: ServerUnaryCall, - callback: sendUnaryData + listProjects( + call: ServerUnaryCall, + callback: sendUnaryData ): void { - const reply: ListDatabasesResponse = new ListDatabasesResponse(); - const databaseInfos: DatabaseInfo[] = []; - for (let index = 0; index < TestTigrisService.DBS.length; index++) { + const reply: ListProjectsResponse = new ListProjectsResponse(); + const databaseInfos: ProjectInfo[] = []; + for (let index = 0; index < TestTigrisService.PROJECTS.length; index++) { databaseInfos.push( - new DatabaseInfo().setDb(TestTigrisService.DBS[index]).setMetadata(new DatabaseMetadata()) + new ProjectInfo() + .setProject(TestTigrisService.PROJECTS[index]) + .setMetadata(new DatabaseMetadata()) ); } - reply.setDatabasesList(databaseInfos); + reply.setProjectsList(databaseInfos); callback(undefined, reply); }, - // eslint-disable-next-line @typescript-eslint/no-empty-function read(call: ServerWritableStream): void { - if (call.request.getDb() === "test-tx") { + assert(call.request.getBranch() === TestTigrisService.ExpectedBranch); + + if (call.request.getProject() === "test-tx") { const txIdHeader = call.metadata.get("Tigris-Tx-Id").toString(); const txOriginHeader = call.metadata.get("Tigris-Tx-Origin").toString(); if (txIdHeader != TestTigrisService.txId || txOriginHeader != TestTigrisService.txOrigin) { @@ -358,9 +516,17 @@ export class TestTigrisService { filter["id"] == 1 ) { // base64 of book id "1" - call.write( - new ReadResponse().setData(TestTigrisService.BOOKS_B64_BY_ID.get("1")) - ); + call.write(new ReadResponse().setData(TestTigrisService.BOOKS_B64_BY_ID.get("1"))); + call.end(); + } + // for date type test purpose if id = 7, we find the record, else we don't + else if ( + call.request.getOptions() != undefined && + call.request.getOptions().getLimit() == 1 && + filter["id"] == 7 + ) { + // base64 of book id "7" + call.write(new ReadResponse().setData(TestTigrisService.BOOKS_B64_BY_ID.get("7"))); call.end(); } else if ( call.request.getOptions() != undefined && @@ -376,13 +542,11 @@ export class TestTigrisService { ) { // case with logicalFilter passed in // base64 of book id "3" - call.write( - new ReadResponse().setData(TestTigrisService.BOOKS_B64_BY_ID.get("3")) - ); + call.write(new ReadResponse().setData(TestTigrisService.BOOKS_B64_BY_ID.get("3"))); call.end(); } else if (filter["id"] === -1) { // throw an error - call.emit("error", {message: "unknown record requested"}); + call.emit("error", { message: "unknown record requested" }); call.end(); } else { // returns 4 books @@ -392,12 +556,44 @@ export class TestTigrisService { call.end(); } }, - // eslint-disable-next-line @typescript-eslint/no-empty-function search(call: ServerWritableStream): void { + assert(call.request.getBranch() === TestTigrisService.ExpectedBranch); + const searchMeta = new SearchMetadata().setFound(5).setTotalPages(5); + const isGroupByQuery = Utility.uint8ArrayToString(call.request.getGroupBy_asU8()).length; + if (isGroupByQuery) { + const searchHitArray1: SearchHit[] = []; + const searchHitArray2: SearchHit[] = []; + + const setSearchHitFn = (searchHitArray, id) => { + const searchHitMeta = new SearchHitMeta().setUpdatedAt( + new google_protobuf_timestamp_pb.Timestamp() + ); + const searchHit = new SearchHit().setMetadata(searchHitMeta); + searchHit.setData(TestTigrisService.BOOKS_B64_BY_ID.get(id)); + searchHitArray.push(searchHit); + }; + for (const id of ["1", "7"]) { + setSearchHitFn(searchHitArray1, id); + } + for (const id of ["3", "4", "5", "6"]) { + setSearchHitFn(searchHitArray2, id); + } + + const groupedSearchHits1 = new GroupedSearchHits() + .setGroupKeysList(["E.M. Forster"]) + .setHitsList(searchHitArray1); + const groupedSearchHits2 = new GroupedSearchHits() + .setGroupKeysList(["Marcel Proust"]) + .setHitsList(searchHitArray2); + + const resp = new SearchResponse().setGroupList([groupedSearchHits1, groupedSearchHits2]); + call.write(resp); + call.end(); + } // paginated search impl - if (call.request.getPage() > 0) { + else if (call.request.getPage() > 0) { const searchPage = new Page() .setSize(call.request.getPageSize()) .setCurrent(call.request.getPage()); @@ -416,14 +612,17 @@ export class TestTigrisService { call.write(new SearchResponse().setMeta(searchMeta.setPage(searchPage))); // with facets, meta and page - const searchFacet = new SearchFacet().setCountsList( - [new FacetCount().setCount(2).setValue("Marcel Proust")]); + const searchFacet = new SearchFacet().setCountsList([ + new FacetCount().setCount(2).setValue("Marcel Proust"), + ]); const resp = new SearchResponse().setMeta(searchMeta.setPage(searchPage)); resp.getFacetsMap().set("author", searchFacet); call.write(resp); // with first hit, meta and page - const searchHitMeta = new SearchHitMeta().setUpdatedAt(new google_protobuf_timestamp_pb.Timestamp()); + const searchHitMeta = new SearchHitMeta().setUpdatedAt( + new google_protobuf_timestamp_pb.Timestamp() + ); const searchHit = new SearchHit().setMetadata(searchHitMeta); // write all search hits to stream 1 by 1 @@ -436,14 +635,13 @@ export class TestTigrisService { call.end(); } }, - /* eslint-disable @typescript-eslint/no-empty-function */ replace( - // eslint-disable-next-line @typescript-eslint/no-unused-vars call: ServerUnaryCall, - // eslint-disable-next-line @typescript-eslint/no-unused-vars callback: sendUnaryData ): void { - if (call.request.getDb() === "test-tx") { + assert(call.request.getBranch() === TestTigrisService.ExpectedBranch); + + if (call.request.getProject() === "test-tx") { const txIdHeader = call.metadata.get("Tigris-Tx-Id").toString(); const txOriginHeader = call.metadata.get("Tigris-Tx-Origin").toString(); if (txIdHeader != TestTigrisService.txId || txOriginHeader != TestTigrisService.txOrigin) { @@ -455,16 +653,18 @@ export class TestTigrisService { const keyList: Array = []; for (let i = 1; i <= call.request.getDocumentsList().length; i++) { if (call.request.getCollection() === "books-with-optional-field") { - const extractedKeyFromAuthor: number = JSON.parse(Utility._base64Decode(call.request.getDocumentsList_asB64()[i - 1]))["author"]; - keyList.push(Utility._base64Encode("{\"id\":" + extractedKeyFromAuthor + "}")); + const extractedKeyFromAuthor: number = JSON.parse( + Utility._base64Decode(call.request.getDocumentsList_asB64()[i - 1]) + )["author"]; + keyList.push(Utility._base64Encode('{"id":' + extractedKeyFromAuthor + "}")); } else { - keyList.push(Utility._base64Encode("{\"id\":" + i + "}")); + keyList.push(Utility._base64Encode('{"id":' + i + "}")); } } reply.setKeysList(keyList); reply.setStatus( "insertedOrReplaced: " + - JSON.stringify(new TextDecoder().decode(call.request.getDocumentsList_asU8()[0])) + JSON.stringify(new TextDecoder().decode(call.request.getDocumentsList_asU8()[0])) ); reply.setMetadata( new ResponseMetadata() @@ -477,16 +677,18 @@ export class TestTigrisService { call: ServerUnaryCall, callback: sendUnaryData ): void { + assert(call.request.getBranch() === TestTigrisService.ExpectedBranch); + const reply: RollbackTransactionResponse = new RollbackTransactionResponse(); - reply.setStatus("rollback-test"); callback(undefined, reply); }, - /* eslint-enable @typescript-eslint/no-empty-function */ update( call: ServerUnaryCall, callback: sendUnaryData ): void { - if (call.request.getDb() === "test-tx") { + assert(call.request.getBranch() === TestTigrisService.ExpectedBranch); + + if (call.request.getProject() === "test-tx") { const txIdHeader = call.metadata.get("Tigris-Tx-Id").toString(); const txOriginHeader = call.metadata.get("Tigris-Tx-Origin").toString(); if (txIdHeader != TestTigrisService.txId || txOriginHeader != TestTigrisService.txOrigin) { @@ -497,9 +699,9 @@ export class TestTigrisService { const reply: UpdateResponse = new UpdateResponse(); reply.setStatus( "updated: " + - Utility.uint8ArrayToString(call.request.getFilter_asU8()) + - ", " + - Utility.uint8ArrayToString(call.request.getFields_asU8()) + Utility.uint8ArrayToString(call.request.getFilter_asU8()) + + ", " + + Utility.uint8ArrayToString(call.request.getFields_asU8()) ); reply.setModifiedCount(1); reply.setMetadata( @@ -508,7 +710,37 @@ export class TestTigrisService { .setUpdatedAt(new google_protobuf_timestamp_pb.Timestamp()) ); callback(undefined, reply); - } + }, + explain( + call: ServerUnaryCall, + callback: sendUnaryData + ): void { + assert(call.request.getBranch() === TestTigrisService.ExpectedBranch); + + if (call.request.getProject() === "test-tx") { + const txIdHeader = call.metadata.get("Tigris-Tx-Id").toString(); + const txOriginHeader = call.metadata.get("Tigris-Tx-Origin").toString(); + if (txIdHeader != TestTigrisService.txId || txOriginHeader != TestTigrisService.txOrigin) { + callback(new Error("transaction mismatch - explain")); + return; + } + } + const reply: ExplainResponse = new ExplainResponse(); + reply.setFilter(JSON.stringify({ author: "Marcel Proust" })); + reply.setReadType("secondary index"); + callback(undefined, reply); + }, + + count( + call: ServerUnaryCall, + callback: sendUnaryData + ): void { + assert(call.request.getBranch() === TestTigrisService.ExpectedBranch); + + const reply: CountResponse = new CountResponse(); + reply.setCount(3); + callback(undefined, reply); + }, }; } @@ -516,3 +748,8 @@ export default { service: TigrisService, handler: new TestTigrisService(), }; + +export enum Branch { + Existing = "existing", + NotFound = "no-project", +} diff --git a/src/__tests__/tigris.filters.spec.ts b/src/__tests__/tigris.filters.spec.ts index b5e3a44..252ab94 100644 --- a/src/__tests__/tigris.filters.spec.ts +++ b/src/__tests__/tigris.filters.spec.ts @@ -1,325 +1,314 @@ -import { - LogicalFilter, - LogicalOperator, - Selector, - SelectorFilter, - SelectorFilterOperator, - TigrisCollectionType, -} from "../types"; -import {Utility} from "../utility"; +import { Filter, TigrisCollectionType, TigrisDataTypes } from "../types"; +import { Utility } from "../utility"; +import { TigrisCollection } from "../decorators/tigris-collection"; +import { PrimaryKey } from "../decorators/tigris-primary-key"; +import { Field } from "../decorators/tigris-field"; describe("filters tests", () => { it("simpleSelectorFilterTest", () => { - const filterNothing: SelectorFilter = { - op: SelectorFilterOperator.NONE - }; + const filterNothing: Filter = {}; expect(Utility.filterToString(filterNothing)).toBe("{}"); - const filter1: Selector = { - name: "Alice" + const filter1: Filter = { + name: "Alice", + }; + expect(Utility.filterToString(filter1)).toBe('{"name":"Alice"}'); + + const filter2: Filter = { + balance: 100, }; - expect(Utility.filterToString(filter1)).toBe("{\"name\":\"Alice\"}"); + expect(Utility.filterToString(filter2)).toBe('{"balance":100}'); - const filter2: Selector = { - balance: 100 + const filter3: Filter = { + isActive: true, }; - expect(Utility.filterToString(filter2)).toBe("{\"balance\":100}"); + expect(Utility.filterToString(filter3)).toBe('{"isActive":true}'); + }); - const filter3: Selector = { - isActive: true + it("persists date string as it is", () => { + const dateFilter: Filter = { + createdAt: { + $gt: "1980-01-01T18:29:28.000Z", + }, }; - expect(Utility.filterToString(filter3)).toBe("{\"isActive\":true}"); + expect(Utility.filterToString(dateFilter)).toBe( + '{"createdAt":{"$gt":"1980-01-01T18:29:28.000Z"}}' + ); + }); + it("serializes Date object to string", () => { + const dateFilter: Filter = { + updatedAt: { + $lt: new Date("1980-01-01"), + }, + }; + expect(Utility.filterToString(dateFilter)).toBe( + '{"updatedAt":{"$lt":"1980-01-01T00:00:00.000Z"}}' + ); }); it("simplerSelectorWithinLogicalFilterTest", () => { - const filter1: LogicalFilter = { - op: LogicalOperator.AND, - selectorFilters: [ + const filter1: Filter = { + $and: [ { - name: "Alice" + name: "Alice", }, { - balance: 100 - } - ] + balance: 100, + }, + ], }; - expect(Utility.filterToString(filter1)).toBe("{\"$and\":[{\"name\":\"Alice\"},{\"balance\":100}]}"); + expect(Utility.filterToString(filter1)).toBe('{"$and":[{"name":"Alice"},{"balance":100}]}'); - const filter2: LogicalFilter = { - op: LogicalOperator.OR, - selectorFilters: [ + const filter2: Filter = { + $or: [ { - name: "Alice" + name: "Alice", }, { - name: "Emma" - } - ] + name: "Emma", + }, + ], }; - expect(Utility.filterToString(filter2)).toBe("{\"$or\":[{\"name\":\"Alice\"},{\"name\":\"Emma\"}]}"); + expect(Utility.filterToString(filter2)).toBe('{"$or":[{"name":"Alice"},{"name":"Emma"}]}'); }); it("basicSelectorFilterTest", () => { - const filter1: SelectorFilter = { - op: SelectorFilterOperator.EQ, - fields: { - name: "Alice" - } + const filter1: Filter = { + name: "Alice", }; - expect(Utility.filterToString(filter1)).toBe("{\"name\":\"Alice\"}"); + expect(Utility.filterToString(filter1)).toBe('{"name":"Alice"}'); - const filter2: SelectorFilter = { - op: SelectorFilterOperator.EQ, - fields: { - id: BigInt(123) - } + const filter2: Filter = { + id: BigInt(123), }; - expect(Utility.filterToString(filter2)).toBe("{\"id\":123}"); + expect(Utility.filterToString(filter2)).toBe('{"id":123}'); - const filter3: SelectorFilter = { - op: SelectorFilterOperator.EQ, - fields: { - isActive: true - } + const filter3: Filter = { + isActive: true, }; - expect(Utility.filterToString(filter3)).toBe("{\"isActive\":true}"); + expect(Utility.filterToString(filter3)).toBe('{"isActive":true}'); }); it("selectorFilter_1", () => { - const tigrisFilter: SelectorFilter = { - op: SelectorFilterOperator.EQ, - fields: { - id: BigInt(1), - name: "alice", - } + const tigrisFilter: Filter = { + id: BigInt(1), + name: "alice", }; - expect(Utility.filterToString(tigrisFilter)).toBe("{\"id\":1,\"name\":\"alice\"}"); + expect(Utility.filterToString(tigrisFilter)).toBe('{"id":1,"name":"alice"}'); }); it("selectorFilter_2", () => { - const tigrisFilter: SelectorFilter = { - op: SelectorFilterOperator.EQ, - fields: { - id: BigInt(1), - name: "alice", - balance: 12.34 - } + const tigrisFilter: Filter = { + id: BigInt(1), + name: "alice", + balance: 12.34, }; - expect(Utility.filterToString(tigrisFilter)).toBe("{\"id\":1,\"name\":\"alice\",\"balance\":12.34}"); + expect(Utility.filterToString(tigrisFilter)).toBe('{"id":1,"name":"alice","balance":12.34}'); }); it("selectorFilter_3", () => { - const tigrisFilter: SelectorFilter = { - op: SelectorFilterOperator.EQ, - fields: { - id: BigInt(1), - name: "alice", - balance: 12.34, - address: { - city: "San Francisco" - } - } + const tigrisFilter: Filter = { + id: BigInt(1), + name: "alice", + balance: 12.34, + "address.city": "San Francisco", }; - expect(Utility.filterToString(tigrisFilter)).toBe("{\"id\":1,\"name\":\"alice\",\"balance\":12.34,\"address.city\":\"San Francisco\"}"); + expect(Utility.filterToString(tigrisFilter)).toBe( + '{"id":1,"name":"alice","balance":12.34,"address.city":"San Francisco"}' + ); }); it("less than Filter", () => { - const tigrisFilter: SelectorFilter = { - op: SelectorFilterOperator.LT, - fields: { - balance: 10 - } + const tigrisFilter: Filter = { + balance: { + $lt: 10, + }, }; - expect(Utility.filterToString(tigrisFilter)).toBe("{\"balance\":{\"$lt\":10}}"); + expect(Utility.filterToString(tigrisFilter)).toBe('{"balance":{"$lt":10}}'); }); it("less than equals Filter", () => { - const tigrisFilter: SelectorFilter = { - op: SelectorFilterOperator.LTE, - fields: { - address: { - zipcode: 10 - } - } + const tigrisFilter: Filter = { + "address.zipcode": { + $lte: 10, + }, }; - expect(Utility.filterToString(tigrisFilter)).toBe("{\"address.zipcode\":{\"$lte\":10}}"); + expect(Utility.filterToString(tigrisFilter)).toBe('{"address.zipcode":{"$lte":10}}'); }); it("greater than Filter", () => { - const tigrisFilter: SelectorFilter = { - op: SelectorFilterOperator.GT, - fields: { - balance: 10 - } + const tigrisFilter: Filter = { + balance: { + $gt: 10, + }, }; - expect(Utility.filterToString(tigrisFilter)).toBe("{\"balance\":{\"$gt\":10}}"); + expect(Utility.filterToString(tigrisFilter)).toBe('{"balance":{"$gt":10}}'); }); it("greater than equals Filter", () => { - const tigrisFilter: SelectorFilter = { - op: SelectorFilterOperator.GTE, - fields: { - balance: 10 - } + const tigrisFilter: Filter = { + balance: { + $gte: 10, + }, + }; + expect(Utility.filterToString(tigrisFilter)).toBe('{"balance":{"$gte":10}}'); + }); + + it("not Filter", () => { + const tigrisFilter: Filter = { + name: { + $not: "Jack", + }, }; - expect(Utility.filterToString(tigrisFilter)).toBe("{\"balance\":{\"$gte\":10}}"); + expect(Utility.filterToString(tigrisFilter)).toBe('{"name":{"$not":"Jack"}}'); + }); + + it("contains Filter(string)", () => { + const tigrisFilter: Filter = { + name: { + $contains: "Adam", + }, + }; + expect(Utility.filterToString(tigrisFilter)).toBe('{"name":{"$contains":"Adam"}}'); + }); + + it("regex Filter", () => { + const tigrisFilter: Filter = { + name: { + $regex: "/andy/i", + }, + }; + expect(Utility.filterToString(tigrisFilter)).toBe('{"name":{"$regex":"/andy/i"}}'); }); it("logicalFilterTest1", () => { - const logicalFilter: LogicalFilter = { - op: LogicalOperator.OR, - selectorFilters: [ + const logicalFilter: Filter = { + $or: [ { - op: SelectorFilterOperator.EQ, - fields: { - name: "alice" - } + name: "alice", }, { - op: SelectorFilterOperator.EQ, - fields: { - name: "emma" - } + name: "emma", }, { - op: SelectorFilterOperator.GT, - fields: { - balance: 300 - } - } - ] + balance: { + $gt: 300, + }, + }, + ], }; - expect(Utility.filterToString(logicalFilter)).toBe("{\"$or\":[{\"name\":\"alice\"},{\"name\":\"emma\"},{\"balance\":{\"$gt\":300}}]}"); + expect(Utility.filterToString(logicalFilter)).toBe( + '{"$or":[{"name":"alice"},{"name":"emma"},{"balance":{"$gt":300}}]}' + ); }); it("logicalFilterTest2", () => { - const logicalFilter: LogicalFilter = { - op: LogicalOperator.AND, - selectorFilters: [ + const logicalFilter: Filter = { + $and: [ { - op: SelectorFilterOperator.EQ, - fields: { - name: "alice" - } + name: "alice", }, { - op: SelectorFilterOperator.EQ, - fields: { - rank: 1 - } - } - ] + rank: 1, + }, + ], }; - expect(Utility.filterToString(logicalFilter)).toBe("{\"$and\":[{\"name\":\"alice\"},{\"rank\":1}]}"); + expect(Utility.filterToString(logicalFilter)).toBe('{"$and":[{"name":"alice"},{"rank":1}]}'); }); it("nestedLogicalFilter1", () => { - const logicalFilter1: LogicalFilter = { - op: LogicalOperator.AND, - selectorFilters: [ + const logicalFilter1: Filter = { + $and: [ { - op: SelectorFilterOperator.EQ, - fields: { - name: "alice", - } + name: "alice", }, { - address: { - city: "Paris", - } - } - ] + "address.city": "Paris", + }, + ], }; - const logicalFilter2: LogicalFilter = { - op: LogicalOperator.AND, - selectorFilters: [ + const logicalFilter2: Filter = { + $and: [ { - op: SelectorFilterOperator.GTE, - fields: { - address: { - zipcode: 1200 - }, - } + "address.zipcode": { + $gte: 1200, + }, }, { - op: SelectorFilterOperator.LTE, - fields: { - balance: 1000 - } - } - ] + balance: { + $lte: 1000, + }, + }, + ], }; - const nestedLogicalFilter: LogicalFilter = { - op: LogicalOperator.OR, - logicalFilters: [logicalFilter1, logicalFilter2] + const nestedLogicalFilter: Filter = { + $or: [logicalFilter1, logicalFilter2], }; - expect(Utility.filterToString(nestedLogicalFilter)).toBe("{\"$or\":[{\"$and\":[{\"name\":\"alice\"},{\"address.city\":\"Paris\"}]},{\"$and\":[{\"address.zipcode\":{\"$gte\":1200}},{\"balance\":{\"$lte\":1000}}]}]}"); + expect(Utility.filterToString(nestedLogicalFilter)).toBe( + '{"$or":[{"$and":[{"name":"alice"},{"address.city":"Paris"}]},{"$and":[{"address.zipcode":{"$gte":1200}},{"balance":{"$lte":1000}}]}]}' + ); }); it("nestedLogicalFilter2", () => { - const logicalFilter1: LogicalFilter = { - op: LogicalOperator.OR, - selectorFilters: [ + const logicalFilter1: Filter = { + $or: [ { - op: SelectorFilterOperator.EQ, - fields: { - name: "alice", - } + name: "alice", }, { - op: SelectorFilterOperator.EQ, - fields: { - rank: 1 - } - } - ] + rank: 1, + }, + ], }; - const logicalFilter2: LogicalFilter = { - op: LogicalOperator.OR, - selectorFilters: [ + const logicalFilter2: Filter = { + $or: [ { - op: SelectorFilterOperator.EQ, - fields: { - name: "emma", - } + name: "emma", }, { - op: SelectorFilterOperator.EQ, - fields: { - rank: 1 - } - } - ] + rank: 1, + }, + ], }; - const nestedLogicalFilter: LogicalFilter = { - op: LogicalOperator.AND, - logicalFilters: [logicalFilter1, logicalFilter2] + const nestedLogicalFilter: Filter = { + $and: [logicalFilter1, logicalFilter2], }; - expect(Utility.filterToString(nestedLogicalFilter)).toBe("{\"$and\":[{\"$or\":[{\"name\":\"alice\"},{\"rank\":1}]},{\"$or\":[{\"name\":\"emma\"},{\"rank\":1}]}]}"); + expect(Utility.filterToString(nestedLogicalFilter)).toBe( + '{"$and":[{"$or":[{"name":"alice"},{"rank":1}]},{"$or":[{"name":"emma"},{"rank":1}]}]}' + ); }); }); export interface IUser extends TigrisCollectionType { - id: BigInt; + id: bigint; name: string; balance: number; } -export interface IUser1 extends TigrisCollectionType { - id: BigInt; +@TigrisCollection("user1") +export class IUser1 { + @PrimaryKey({ order: 1 }) + id: bigint; + @Field() name: string; + @Field() balance: number; + @Field() isActive: boolean; + @Field(TigrisDataTypes.DATE_TIME) + createdAt: string; + @Field() + updatedAt: Date; } export interface IUser2 extends TigrisCollectionType { - id: BigInt; + id: bigint; name: string; rank: number; } export interface Student extends TigrisCollectionType { - id: BigInt; + id: bigint; name: string; balance: number; address: Address; diff --git a/src/__tests__/tigris.jsonserde.spec.ts b/src/__tests__/tigris.jsonserde.spec.ts index cd393d1..8f37610 100644 --- a/src/__tests__/tigris.jsonserde.spec.ts +++ b/src/__tests__/tigris.jsonserde.spec.ts @@ -1,117 +1,136 @@ -import {TigrisCollectionType} from "../types"; -import {Utility} from "../utility"; +import { TigrisCollectionType } from "../types"; +import { Utility } from "../utility"; describe("JSON serde tests", () => { - it("jsonSerDe", () => { - interface IUser extends TigrisCollectionType { + interface IUser extends TigrisCollectionType { id: bigint; name: string; balance: number; + longitude: number; } - const user: IUser = - { - id: BigInt("9223372036854775807"), - name: "Alice", - balance: 123 - }; + const user: IUser = { + id: BigInt("9223372036854775807"), + name: "Alice", + balance: 123, + longitude: -73.96340000000001, + }; const userString = Utility.objToJsonString(user); - expect(userString).toBe("{\"id\":9223372036854775807,\"name\":\"Alice\",\"balance\":123}"); + expect(userString).toBe( + '{"id":9223372036854775807,"name":"Alice","balance":123,"longitude":-73.96340000000001}' + ); - const deserializedUser = Utility.jsonStringToObj("{\"id\":9223372036854775807,\"name\":\"Alice\",\"balance\":123}" , {serverUrl: "test"}); + const deserializedUser = Utility.jsonStringToObj( + '{"id":9223372036854775807,"name":"Alice","balance":123,"longitude":-73.96340000000001}', + { serverUrl: "test" } + ); expect(deserializedUser.id).toBe("9223372036854775807"); expect(deserializedUser.name).toBe("Alice"); expect(deserializedUser.balance).toBe(123); + expect(deserializedUser.longitude).toBe(-73.96340000000001); }); it("jsonSerDeStringAsBigInt", () => { interface TestUser { - id: number, - name: string, - balance: string + id: number; + name: string; + balance: string; } const user: TestUser = { id: 1, name: "Alice", - balance: "9223372036854775807" - } + balance: "9223372036854775807", + }; // default serde - expect(JSON.stringify(user)).toBe("{\"id\":1,\"name\":\"Alice\",\"balance\":\"9223372036854775807\"}"); - const reconstructedUser1: TestUser = JSON.parse("{\"id\":1,\"name\":\"Alice\",\"balance\":\"9223372036854775807\"}"); - expect(reconstructedUser1.id).toBe(1) - expect(reconstructedUser1.name).toBe("Alice") - expect(reconstructedUser1.balance).toBe("9223372036854775807") + expect(JSON.stringify(user)).toBe('{"id":1,"name":"Alice","balance":"9223372036854775807"}'); + const reconstructedUser1: TestUser = JSON.parse( + '{"id":1,"name":"Alice","balance":"9223372036854775807"}' + ); + expect(reconstructedUser1.id).toBe(1); + expect(reconstructedUser1.name).toBe("Alice"); + expect(reconstructedUser1.balance).toBe("9223372036854775807"); // Tigris serde - expect(Utility.objToJsonString(user)).toBe("{\"id\":1,\"name\":\"Alice\",\"balance\":\"9223372036854775807\"}"); - const reconstructedUser2: TestUser = Utility.jsonStringToObj("{\"id\":1,\"name\":\"Alice\",\"balance\":\"9223372036854775807\"}", {serverUrl: "test"}); - expect(reconstructedUser2.id).toBe(1) - expect(reconstructedUser2.name).toBe("Alice") - expect(reconstructedUser2.balance).toBe("9223372036854775807") + expect(Utility.objToJsonString(user)).toBe( + '{"id":1,"name":"Alice","balance":"9223372036854775807"}' + ); + const reconstructedUser2: TestUser = Utility.jsonStringToObj( + '{"id":1,"name":"Alice","balance":"9223372036854775807"}', + { serverUrl: "test" } + ); + expect(reconstructedUser2.id).toBe(1); + expect(reconstructedUser2.name).toBe("Alice"); + expect(reconstructedUser2.balance).toBe("9223372036854775807"); }); it("jsonSerDeNativeBigInt", () => { interface TestUser { - id: number, - name: string, - balance: bigint + id: number; + name: string; + balance: bigint; } const user: TestUser = { id: 1, name: "Alice", - balance: BigInt("9223372036854775807") - } + balance: BigInt("9223372036854775807"), + }; // Tigris serde - expect(Utility.objToJsonString(user)).toBe("{\"id\":1,\"name\":\"Alice\",\"balance\":9223372036854775807}"); - const reconstructedUser2: TestUser = Utility.jsonStringToObj("{\"id\":1,\"name\":\"Alice\",\"balance\":9223372036854775807}", { - serverUrl: "test", - supportBigInt: true - }); - expect(reconstructedUser2.id).toBe(1) - expect(reconstructedUser2.name).toBe("Alice") - expect(reconstructedUser2.balance).toBe(BigInt("9223372036854775807")) + expect(Utility.objToJsonString(user)).toBe( + '{"id":1,"name":"Alice","balance":9223372036854775807}' + ); + const reconstructedUser2: TestUser = Utility.jsonStringToObj( + '{"id":1,"name":"Alice","balance":9223372036854775807}', + { + serverUrl: "test", + supportBigInt: true, + } + ); + expect(reconstructedUser2.id).toBe(1); + expect(reconstructedUser2.name).toBe("Alice"); + expect(reconstructedUser2.balance).toBe(BigInt("9223372036854775807")); }); it("jsonSerDeNativeBigIntNested", () => { interface TestUser { - id: number, - name: string, - balance: bigint - savings: Account - checkin: Account + id: number; + name: string; + balance: bigint; + savings: Account; + checkin: Account; } interface Account { - accountId: bigint + accountId: bigint; } const user: TestUser = { id: 1, name: "Alice", balance: BigInt("9223372036854775807"), checkin: { - accountId: BigInt("9223372036854775806") + accountId: BigInt("9223372036854775806"), }, savings: { - accountId: BigInt("9223372036854775807") - } - } + accountId: BigInt("9223372036854775807"), + }, + }; // Tigris serde - expect(Utility.objToJsonString(user)).toBe("{\"id\":1,\"name\":\"Alice\",\"balance\":9223372036854775807,\"checkin\":{\"accountId\":9223372036854775806},\"savings\":{\"accountId\":9223372036854775807}}" - ); - const reconstructedUser2: TestUser = Utility.jsonStringToObj("{\"id\":1,\"name\":\"Alice\",\"balance\":9223372036854775807,\"checkin\":{\"accountId\":9223372036854775806},\"savings\":{\"accountId\":9223372036854775807}}" - , { - serverUrl: "test", - supportBigInt: true - }); - expect(reconstructedUser2.id).toBe(1) - expect(reconstructedUser2.name).toBe("Alice") - expect(reconstructedUser2.balance).toBe(BigInt("9223372036854775807")) - expect(reconstructedUser2.checkin.accountId).toBe(BigInt("9223372036854775806")) - expect(reconstructedUser2.savings.accountId).toBe(BigInt("9223372036854775807")) + expect(Utility.objToJsonString(user)).toBe( + '{"id":1,"name":"Alice","balance":9223372036854775807,"checkin":{"accountId":9223372036854775806},"savings":{"accountId":9223372036854775807}}' + ); + const reconstructedUser2: TestUser = Utility.jsonStringToObj( + '{"id":1,"name":"Alice","balance":9223372036854775807,"checkin":{"accountId":9223372036854775806},"savings":{"accountId":9223372036854775807}}', + { + serverUrl: "test", + supportBigInt: true, + } + ); + expect(reconstructedUser2.id).toBe(1); + expect(reconstructedUser2.name).toBe("Alice"); + expect(reconstructedUser2.balance).toBe(BigInt("9223372036854775807")); + expect(reconstructedUser2.checkin.accountId).toBe(BigInt("9223372036854775806")); + expect(reconstructedUser2.savings.accountId).toBe(BigInt("9223372036854775807")); }); }); - - diff --git a/src/__tests__/tigris.readfields.spec.ts b/src/__tests__/tigris.readfields.spec.ts index 9628e78..70a20c9 100644 --- a/src/__tests__/tigris.readfields.spec.ts +++ b/src/__tests__/tigris.readfields.spec.ts @@ -1,26 +1,39 @@ -import { - ReadFields, -} from "../types"; -import {Utility} from "../utility"; +import { ReadFields, TigrisCollectionType } from "../types"; +import { Utility } from "../utility"; +export interface IBook1 extends TigrisCollectionType { + id?: number; + title: string; + author: Author; + tags?: string[]; +} + +export interface Author extends TigrisCollectionType { + firstName: string; + lastName: string; +} describe("readFields tests", () => { it("readFields1", () => { - const readFields: ReadFields = { - include: ["id", "title"], + const readFields: ReadFields = { + include: ["id", "title", "author.firstName", "author.lastName"], }; - expect(Utility.readFieldString(readFields)).toBe("{\"id\":true,\"title\":true}"); + expect(Utility.readFieldString(readFields)).toBe( + '{"id":true,"title":true,"author.firstName":true,"author.lastName":true}' + ); }); it("readFields2", () => { - const readFields: ReadFields = { + const readFields: ReadFields = { exclude: ["id", "title"], }; - expect(Utility.readFieldString(readFields)).toBe("{\"id\":false,\"title\":false}"); + expect(Utility.readFieldString(readFields)).toBe('{"id":false,"title":false}'); }); it("readFields3", () => { - const readFields: ReadFields = { + const readFields: ReadFields = { include: ["id", "title"], - exclude: ["author"] + exclude: ["author"], }; - expect(Utility.readFieldString(readFields)).toBe("{\"id\":true,\"title\":true,\"author\":false}"); + expect(Utility.readFieldString(readFields)).toBe( + '{"id":true,"title":true,"author":false}' + ); }); }); diff --git a/src/__tests__/tigris.rpc.spec.ts b/src/__tests__/tigris.rpc.spec.ts index 0c0f000..fe77681 100644 --- a/src/__tests__/tigris.rpc.spec.ts +++ b/src/__tests__/tigris.rpc.spec.ts @@ -1,37 +1,47 @@ -import {Server, ServerCredentials} from "@grpc/grpc-js"; -import {TigrisService} from "../proto/server/v1/api_grpc_pb"; -import TestService, {TestTigrisService} from "./test-service"; +import { Server, ServerCredentials, ServiceError } from "@grpc/grpc-js"; +import { TigrisService } from "../proto/server/v1/api_grpc_pb"; +import TestService, { Branch, TestTigrisService } from "./test-service"; +import TestServiceCache, { TestCacheService } from "./test-cache-service"; + import { - DatabaseOptions, - DeleteRequestOptions, - LogicalOperator, - SelectorFilterOperator, + DeleteQueryOptions, TigrisCollectionType, TigrisDataTypes, - TigrisSchema, TigrisTopicSchema, - TigrisTopicType, - UpdateFieldsOperator, - UpdateRequestOptions + TigrisSchema, + UpdateQueryOptions, } from "../types"; -import {Tigris} from "../tigris"; -import {Case, Collation, SearchRequest, SearchRequestOptions} from "../search/types"; -import {Utility} from "../utility"; -import {ObservabilityService} from "../proto/server/v1/observability_grpc_pb"; +import { Tigris, TigrisClientConfig } from "../tigris"; +import { Utility } from "../utility"; +import { ObservabilityService } from "../proto/server/v1/observability_grpc_pb"; import TestObservabilityService from "./test-observability-service"; -import {Readable} from "node:stream"; -import {capture, spy } from "ts-mockito"; +import { anything, capture, reset, spy, when } from "ts-mockito"; +import { TigrisCollection } from "../decorators/tigris-collection"; +import { PrimaryKey } from "../decorators/tigris-primary-key"; +import { Field } from "../decorators/tigris-field"; +import { SearchIterator } from "../consumables/search-iterator"; +import { CacheService } from "../proto/server/v1/cache_grpc_pb"; +import { + BranchNameRequiredError, + DuplicatePrimaryKeyOrderError, + MissingPrimaryKeyOrderInSchemaDefinitionError, +} from "../error"; +import { Status } from "@grpc/grpc-js/build/src/constants"; +import { Status as TigrisStatus } from "../constants"; +import { Case, Collation, SearchQuery } from "../search"; +import { SearchResult } from "../search"; describe("rpc tests", () => { let server: Server; - const SERVER_PORT = 5002; + const testConfig = { serverUrl: "localhost:" + 5002, projectName: "db1", branch: "unit-tests" }; beforeAll((done) => { server = new Server(); TestTigrisService.reset(); server.addService(TigrisService, TestService.handler.impl); - server.addService(ObservabilityService, TestObservabilityService.handler.impl) + server.addService(CacheService, TestServiceCache.handler.impl); + server.addService(ObservabilityService, TestObservabilityService.handler.impl); server.bindAsync( - "localhost:" + SERVER_PORT, + testConfig.serverUrl, // test purpose only ServerCredentials.createInsecure(), (err: Error | null) => { @@ -43,11 +53,11 @@ describe("rpc tests", () => { } ); done(); - }); beforeEach(() => { TestTigrisService.reset(); + TestCacheService.reset(); }); afterAll((done) => { @@ -55,59 +65,18 @@ describe("rpc tests", () => { done(); }); - it("listDatabase", () => { - const tigris = new Tigris({serverUrl: "localhost:" + SERVER_PORT}); - const listDbsPromise = tigris.listDatabases(); - listDbsPromise - .then((value) => { - expect(value.length).toBe(5); - expect(value[0].name).toBe("db1"); - expect(value[1].name).toBe("db2"); - expect(value[2].name).toBe("db3"); - expect(value[3].name).toBe("db4"); - expect(value[4].name).toBe("db5"); - }, - ); - - return listDbsPromise; - }); - - it("createDatabaseIfNotExists", () => { - const tigris = new Tigris({serverUrl: "localhost:" + SERVER_PORT}); - const dbCreationPromise = tigris.createDatabaseIfNotExists("db6", new DatabaseOptions()); - dbCreationPromise - .then((value) => { - expect(value.db).toBe("db6"); - }, - ); - - return dbCreationPromise; - }); - - it("dropDatabase", () => { - const tigris = new Tigris({serverUrl: "localhost:" + SERVER_PORT}); - const dbDropPromise = tigris.dropDatabase("db6", new DatabaseOptions()); - dbDropPromise - .then((value) => { - expect(value.status).toBe("dropped"); - expect(value.message).toBe("db6 dropped successfully"); - }, - ); - return dbDropPromise; - }); - it("getDatabase", () => { - const tigris = new Tigris({serverUrl: "localhost:" + SERVER_PORT}); - const db1 = tigris.getDatabase("db1"); - expect(db1.db).toBe("db1"); + const tigris = new Tigris(testConfig); + const db1 = tigris.getDatabase(); + expect(db1.name).toBe("db1"); }); - it("listCollections1", () => { - const tigris = new Tigris({serverUrl: "localhost:" + SERVER_PORT}); - const db1 = tigris.getDatabase("db1"); + it("listCollections1", async () => { + const tigris = new Tigris(testConfig); + const db1 = tigris.getDatabase(); const listCollectionPromise = db1.listCollections(); - listCollectionPromise.then(value => { + listCollectionPromise.then((value) => { expect(value.length).toBe(5); expect(value[0].name).toBe("db1_coll_1"); expect(value[1].name).toBe("db1_coll_2"); @@ -118,12 +87,12 @@ describe("rpc tests", () => { return listCollectionPromise; }); - it("listCollections2", () => { - const tigris = new Tigris({serverUrl: "localhost:" + SERVER_PORT}); - const db1 = tigris.getDatabase("db3"); + it("listCollections2", async () => { + const tigris = new Tigris({ ...testConfig, projectName: "db3" }); + const db1 = tigris.getDatabase(); const listCollectionPromise = db1.listCollections(); - listCollectionPromise.then(value => { + listCollectionPromise.then((value) => { expect(value.length).toBe(5); expect(value[0].name).toBe("db3_coll_1"); expect(value[1].name).toBe("db3_coll_2"); @@ -134,13 +103,12 @@ describe("rpc tests", () => { return listCollectionPromise; }); - it("describeDatabase", () => { - const tigris = new Tigris({serverUrl: "localhost:" + SERVER_PORT}); - const db1 = tigris.getDatabase("db3"); + it("describeDatabase", async () => { + const tigris = new Tigris({ ...testConfig, projectName: "db3" }); + const db1 = tigris.getDatabase(); const databaseDescriptionPromise = db1.describe(); - databaseDescriptionPromise.then(value => { - expect(value.db).toBe("db3"); + databaseDescriptionPromise.then((value) => { expect(value.collectionsDescription.length).toBe(5); expect(value.collectionsDescription[0].collection).toBe("db3_coll_1"); expect(value.collectionsDescription[1].collection).toBe("db3_coll_2"); @@ -151,205 +119,275 @@ describe("rpc tests", () => { return databaseDescriptionPromise; }); - it("dropCollection", () => { - const tigris = new Tigris({serverUrl: "localhost:" + SERVER_PORT}); - const db1 = tigris.getDatabase("db3"); + it("dropCollection", async () => { + const tigris = new Tigris({ ...testConfig, projectName: "db3" }); + const db1 = tigris.getDatabase(); const dropCollectionPromise = db1.dropCollection("db3_coll_2"); - dropCollectionPromise.then(value => { + dropCollectionPromise.then((value) => { expect(value.status).toBe("dropped"); expect(value.message).toBe("db3_coll_2 dropped successfully"); }); return dropCollectionPromise; }); - it("getCollection", () => { - const tigris = new Tigris({serverUrl: "localhost:" + SERVER_PORT}); - const db1 = tigris.getDatabase("db3"); + it("getCollection", async () => { + const tigris = new Tigris({ ...testConfig, projectName: "db3" }); + const db1 = tigris.getDatabase(); const books = db1.getCollection("books"); expect(books.collectionName).toBe("books"); }); - it("insert", () => { - const tigris = new Tigris({serverUrl: "localhost:" + SERVER_PORT}); - const db1 = tigris.getDatabase("db3"); + it("insert", async () => { + const tigris = new Tigris({ ...testConfig, projectName: "db3" }); + const db1 = tigris.getDatabase(); const insertionPromise = db1.getCollection("books").insertOne({ author: "author name", id: 0, tags: ["science"], - title: "science book" + title: "science book", }); - insertionPromise.then(insertedBook => { + insertionPromise.then((insertedBook) => { expect(insertedBook.id).toBe(1); + expect(insertedBook.createdAt).toBeDefined(); }); return insertionPromise; }); - it("insert2", () => { - const tigris = new Tigris({serverUrl: "localhost:" + SERVER_PORT}); - const db1 = tigris.getDatabase("db3"); + it("insert_with_createdAt_value", async () => { + const tigris = new Tigris({ ...testConfig, projectName: "db3" }); + const db1 = tigris.getDatabase(); + const book: IBook = { + author: "author name", + id: 0, + tags: ["science"], + title: "science book", + createdAt: new Date(), + }; + const insertionPromise = db1.getCollection("books").insertOne(book); + insertionPromise.then((insertedBook) => { + expect(insertedBook.id).toBe(1); + expect(insertedBook.createdAt).toEqual(book.createdAt); + }); + return insertionPromise; + }); + + it("insert2", async () => { + const tigris = new Tigris({ ...testConfig, projectName: "db3" }); + const db1 = tigris.getDatabase(); const insertionPromise = db1.getCollection("books").insertOne({ id: 0, title: "science book", metadata: { publish_date: new Date(), num_pages: 100, - } + }, }); - insertionPromise.then(insertedBook => { + insertionPromise.then((insertedBook) => { expect(insertedBook.id).toBe(1); }); return insertionPromise; }); - it("insertWithOptionalField", () => { - const tigris = new Tigris({serverUrl: "localhost:" + SERVER_PORT}); - const db1 = tigris.getDatabase("db3"); + it("insert_multi_pk", async () => { + const tigris = new Tigris({ ...testConfig, projectName: "db3" }); + const db1 = tigris.getDatabase(); + const insertionPromise = db1.getCollection("books-multi-pk").insertOne({ + id: 0, + id2: 0, + title: "science book", + metadata: { + publish_date: new Date(), + num_pages: 100, + }, + }); + insertionPromise.then((insertedBook) => { + expect(insertedBook.id).toBe(1); + expect(insertedBook.id2).toBe(11); + }); + return insertionPromise; + }); + + it("insert_multi_pk_many", async () => { + const tigris = new Tigris({ ...testConfig, projectName: "db3" }); + const db1 = tigris.getDatabase(); + const insertionPromise = db1.getCollection("books-multi-pk").insertMany([ + { + id: 0, + id2: 0, + title: "science book", + metadata: { + publish_date: new Date(), + num_pages: 100, + }, + }, + { + id: 0, + id2: 0, + title: "science book", + metadata: { + publish_date: new Date(), + num_pages: 100, + }, + }, + ]); + insertionPromise.then((insertedBook) => { + expect(insertedBook.length).toBe(2); + expect(insertedBook[0].id).toBe(1); + expect(insertedBook[0].id2).toBe(11); + expect(insertedBook[1].id).toBe(2); + expect(insertedBook[1].id2).toBe(21); + }); + return insertionPromise; + }); + + it("insertWithOptionalField", async () => { + const tigris = new Tigris({ ...testConfig, projectName: "db3" }); + const db1 = tigris.getDatabase(); const randomNumber: number = Math.floor(Math.random() * 100); // pass the random number in author field. mock server reads author and sets as the // primaryKey field. const insertionPromise = db1.getCollection("books-with-optional-field").insertOne({ author: "" + randomNumber, tags: ["science"], - title: "science book" + title: "science book", }); - insertionPromise.then(insertedBook => { + insertionPromise.then((insertedBook) => { expect(insertedBook.id).toBe(randomNumber); }); return insertionPromise; }); - it("insertOrReplace", () => { - const tigris = new Tigris({serverUrl: "localhost:" + SERVER_PORT}); - const db1 = tigris.getDatabase("db3"); + it("insertOrReplace", async () => { + const tigris = new Tigris({ ...testConfig, projectName: "db3" }); + const db1 = tigris.getDatabase(); const insertOrReplacePromise = db1.getCollection("books").insertOrReplaceOne({ author: "author name", id: 0, tags: ["science"], - title: "science book" + title: "science book", }); - insertOrReplacePromise.then(insertedOrReplacedBook => { + insertOrReplacePromise.then((insertedOrReplacedBook) => { expect(insertedOrReplacedBook.id).toBe(1); }); return insertOrReplacePromise; }); - it("insertOrReplaceWithOptionalField", () => { - const tigris = new Tigris({serverUrl: "localhost:" + SERVER_PORT}); - const db1 = tigris.getDatabase("db3"); + it("insertOrReplaceWithOptionalField", async () => { + const tigris = new Tigris({ ...testConfig, projectName: "db3" }); + const db1 = tigris.getDatabase(); const randomNumber: number = Math.floor(Math.random() * 100); // pass the random number in author field. mock server reads author and sets as the // primaryKey field. - const insertOrReplacePromise = db1.getCollection("books-with-optional-field").insertOrReplaceOne({ - author: "" + randomNumber, - tags: ["science"], - title: "science book" - }); - insertOrReplacePromise.then(insertedOrReplacedBook => { + const insertOrReplacePromise = db1 + .getCollection("books-with-optional-field") + .insertOrReplaceOne({ + author: "" + randomNumber, + tags: ["science"], + title: "science book", + }); + insertOrReplacePromise.then((insertedOrReplacedBook) => { expect(insertedOrReplacedBook.id).toBe(randomNumber); }); return insertOrReplacePromise; }); - it("delete", () => { - const tigris = new Tigris({serverUrl: "localhost:" + SERVER_PORT}); - const db1 = tigris.getDatabase("db3"); - const deletionPromise = db1.getCollection("books").deleteMany({ - op: SelectorFilterOperator.EQ, - fields: { - id: 1 - } - }); - deletionPromise.then(value => { - expect(value.status).toBe("deleted: {\"id\":1}"); + it("delete", async () => { + const tigris = new Tigris({ ...testConfig, projectName: "db3" }); + const db1 = tigris.getDatabase(); + const deletionPromise = db1.getCollection(IBook).deleteMany({ + filter: { id: 1 }, }); + deletionPromise + .then((value) => { + expect(value.status).toBe('deleted: {"id":1}'); + }) + .catch((r) => console.log(r)); return deletionPromise; }); - it("deleteOne", () => { - const tigris = new Tigris({serverUrl: "localhost:" + SERVER_PORT}); - const collection = tigris.getDatabase("db3").getCollection("books"); + it("deleteOne", async () => { + const tigris = new Tigris({ ...testConfig, projectName: "db3" }); + const collection = tigris.getDatabase().getCollection("books"); const spyCollection = spy(collection); - const expectedFilter = {id: 1}; - const expectedCollation: Collation = {case: Case.CaseInsensitive}; - const options = new DeleteRequestOptions(5, expectedCollation); + const expectedFilter = { id: 1 }; + const expectedCollation: Collation = { case: Case.CaseInsensitive }; + const options = new DeleteQueryOptions(5, expectedCollation); - const deletePromise = collection.deleteOne(expectedFilter, undefined, options); - const [capturedFilter, capturedTx, capturedOptions] = capture(spyCollection.deleteMany).last(); + const deletePromise = collection.deleteOne({ filter: expectedFilter, options: options }); + const [capturedQuery, capturedTx] = capture(spyCollection.deleteMany).last(); // filter passed as it is - expect(capturedFilter).toBe(expectedFilter); + expect(capturedQuery.filter).toBe(expectedFilter); // tx passed as it is expect(capturedTx).toBe(undefined); // options.collation passed as it is - expect(capturedOptions.collation).toBe(expectedCollation); + expect(capturedQuery.options.collation).toBe(expectedCollation); // options.limit === 1 while original was 5 - expect(capturedOptions.limit).toBe(1); + expect(capturedQuery.options.limit).toBe(1); return deletePromise; }); - it("update", () => { - const tigris = new Tigris({serverUrl: "localhost:" + SERVER_PORT}); - const db1 = tigris.getDatabase("db3"); - const updatePromise = db1.getCollection("books").updateMany( - { - op: SelectorFilterOperator.EQ, - fields: { - id: 1 - } + it("update", async () => { + const tigris = new Tigris({ ...testConfig, projectName: "db3" }); + const db1 = tigris.getDatabase(); + const updatePromise = db1.getCollection("books").updateMany({ + filter: { + id: 1, }, - { - op: UpdateFieldsOperator.SET, - fields: { - title: "New Title" - } - }); - updatePromise.then(value => { - expect(value.status).toBe("updated: {\"id\":1}, {\"$set\":{\"title\":\"New Title\"}}"); + fields: { + title: "New Title", + }, + }); + updatePromise.then((value) => { + expect(value.status).toBe(TigrisStatus.Updated); expect(value.modifiedCount).toBe(1); }); return updatePromise; }); - it("updateOne", () => { - const tigris = new Tigris({serverUrl: "localhost:" + SERVER_PORT}); - const collection = tigris.getDatabase("db3").getCollection("books"); + it("updateOne", async () => { + const tigris = new Tigris({ ...testConfig, projectName: "db3" }); + const collection = tigris.getDatabase().getCollection("books"); const spyCollection = spy(collection); - const expectedFilter = {id: 1}; - const expectedCollation: Collation = {case: Case.CaseInsensitive}; - const expectedUpdateFields = {title: "one"}; - const options = new UpdateRequestOptions(5, expectedCollation); + const expectedFilter = { id: 1 }; + const expectedCollation: Collation = { case: Case.CaseInsensitive }; + const expectedUpdateFields = { title: "one" }; + const options = new UpdateQueryOptions(5, expectedCollation); - const updatePromise = collection.updateOne(expectedFilter, expectedUpdateFields, undefined, options); - const [capturedFilter, capturedFields, capturedTx, capturedOptions] = capture(spyCollection.updateMany).last(); + const updatePromise = collection.updateOne({ + filter: expectedFilter, + fields: expectedUpdateFields, + options: options, + }); + const [capturedQuery, capturedTx] = capture(spyCollection.updateMany).last(); // filter passed as it is - expect(capturedFilter).toBe(expectedFilter); + expect(capturedQuery.filter).toBe(expectedFilter); // updateFields passed as it is - expect(capturedFields).toBe(expectedUpdateFields); + expect(capturedQuery.fields).toBe(expectedUpdateFields); // tx passed as it is expect(capturedTx).toBe(undefined); // options.collation passed as it is - expect(capturedOptions.collation).toBe(expectedCollation); + expect(capturedQuery.options.collation).toBe(expectedCollation); // options.limit === 1 while original was 5 - expect(capturedOptions.limit).toBe(1); + expect(capturedQuery.options.limit).toBe(1); return updatePromise; }); - it("readOne", () => { - const tigris = new Tigris({serverUrl: "localhost:" + SERVER_PORT}); - const db1 = tigris.getDatabase("db3"); - const readOnePromise = db1.getCollection("books").findOne( { - op: SelectorFilterOperator.EQ, - fields: { - id: 1 - } + it("readOne", async () => { + const tigris = new Tigris({ ...testConfig, projectName: "db3" }); + const db1 = tigris.getDatabase(); + const readOnePromise = db1.getCollection("books").findOne({ + filter: { + id: 1, + }, }); - readOnePromise.then(value => { + readOnePromise.then((value) => { const book: IBook = value; expect(book.id).toBe(1); expect(book.title).toBe("A Passage to India"); @@ -359,14 +397,33 @@ describe("rpc tests", () => { return readOnePromise; }); - it("readOneRecordNotFound", () => { - const tigris = new Tigris({serverUrl: "localhost:" + SERVER_PORT}); - const db1 = tigris.getDatabase("db3"); + it("readOne_with_date_field", async () => { + const tigris = new Tigris({ ...testConfig, projectName: "db3" }); + const db1 = tigris.getDatabase(); const readOnePromise = db1.getCollection("books").findOne({ - op: SelectorFilterOperator.EQ, - fields: { - id: 2 - } + filter: { + id: 7, + }, + }); + readOnePromise.then((value) => { + const book: IBook = value; + console.log("BOOK ::", value); + expect(book.id).toBe(7); + expect(book.title).toBe("A Passage to India"); + expect(book.author).toBe("E.M. Forster"); + expect(book.tags).toStrictEqual(["Novel", "India"]); + expect(book.purchasedOn).toBeInstanceOf(Date); + }); + return readOnePromise; + }); + + it("readOneRecordNotFound", async () => { + const tigris = new Tigris({ ...testConfig, projectName: "db3" }); + const db1 = tigris.getDatabase(); + const readOnePromise = db1.getCollection("books").findOne({ + filter: { + id: 2, + }, }); readOnePromise.then((value) => { expect(value).toBe(undefined); @@ -374,27 +431,22 @@ describe("rpc tests", () => { return readOnePromise; }); - it("readOneWithLogicalFilter", () => { - const tigris = new Tigris({serverUrl: "localhost:" + SERVER_PORT}); - const db1 = tigris.getDatabase("db3"); + it("readOneWithLogicalFilter", async () => { + const tigris = new Tigris({ ...testConfig, projectName: "db3" }); + const db1 = tigris.getDatabase(); const readOnePromise: Promise = db1.getCollection("books").findOne({ - op: LogicalOperator.AND, - selectorFilters: [ - { - op: SelectorFilterOperator.EQ, - fields: { - id: 3 - } - }, - { - op: SelectorFilterOperator.EQ, - fields: { - title: "In Search of Lost Time" - } - } - ] + filter: { + $and: [ + { + id: 3, + }, + { + title: "In Search of Lost Time", + }, + ], + }, }); - readOnePromise.then(value => { + readOnePromise.then((value) => { const book: IBook = value; expect(book.id).toBe(3); expect(book.title).toBe("In Search of Lost Time"); @@ -404,16 +456,50 @@ describe("rpc tests", () => { return readOnePromise; }); + it("explain", async () => { + const tigris = new Tigris({ ...testConfig, projectName: "db3" }); + + const db = tigris.getDatabase(); + const explainResp = await db.getCollection("books").explain({ + filter: { + author: "Marcel Proust", + }, + }); + expect(explainResp.readType).toEqual("secondary index"); + expect(explainResp.filter).toEqual(JSON.stringify({ author: "Marcel Proust" })); + }); + + it("count", async () => { + const tigris = new Tigris({ ...testConfig, projectName: "db3" }); + const db = tigris.getDatabase(); + const countResponse = await db.getCollection("books").count({ + author: "Marcel Proust", + }); + expect(countResponse).toEqual(3); + }); + + it("describe collection", async () => { + const tigris = new Tigris({ ...testConfig, projectName: "db3" }); + + const db = tigris.getDatabase(); + const describe = await db.getCollection("books").describe(); + expect(describe.collection).toEqual("books"); + expect(describe.indexDescriptions).toHaveLength(2); + expect(describe.indexDescriptions[0].name).toEqual("title"); + expect(describe.indexDescriptions[0].state).toEqual("INDEX ACTIVE"); + expect(describe.indexDescriptions[1].name).toEqual("author"); + expect(describe.indexDescriptions[1].state).toEqual("INDEX WRITE MODE"); + }); + describe("findMany", () => { - const tigris = new Tigris({serverUrl: "localhost:" + SERVER_PORT}); - const db = tigris.getDatabase("db3"); + const tigris = new Tigris({ ...testConfig, projectName: "db3" }); it("with filter using for await on cursor", async () => { + const db = tigris.getDatabase(); const cursor = db.getCollection("books").findMany({ - op: SelectorFilterOperator.EQ, - fields: { - author: "Marcel Proust" - } + filter: { + author: "Marcel Proust", + }, }); let bookCounter = 0; @@ -424,15 +510,17 @@ describe("rpc tests", () => { expect(bookCounter).toBe(4); }); - it("finds all and retrieves results as array", () => { + it("finds all and retrieves results as array", async () => { + const db = tigris.getDatabase(); const cursor = db.getCollection("books").findMany(); const booksPromise = cursor.toArray(); - booksPromise.then(books => expect(books.length).toBe(4)); + booksPromise.then((books) => expect(books.length).toBe(4)); return booksPromise; }); it("finds all and streams through results", async () => { + const db = tigris.getDatabase(); const cursor = db.getCollection("books").findMany(); const booksIterator = cursor.stream(); @@ -445,11 +533,11 @@ describe("rpc tests", () => { }); it("throws an error", async () => { + const db = tigris.getDatabase(); const cursor = db.getCollection("books").findMany({ - op: SelectorFilterOperator.EQ, - fields: { - id: -1 - } + filter: { + id: -1, + }, }); try { @@ -463,362 +551,549 @@ describe("rpc tests", () => { }); }); - it("search", () => { - const tigris = new Tigris({serverUrl: "localhost:" + SERVER_PORT}); - const db3 = tigris.getDatabase("db3"); - const options: SearchRequestOptions = { - page: 2, - perPage: 12 - } - const request: SearchRequest = { - q: "philosophy", - facets: { - tags: Utility.createFacetQueryOptions() - } - }; + describe("search", () => { + const tigris = new Tigris({ ...testConfig, projectName: "db3" }); - const searchPromise = db3.getCollection("books").search(request, options); + describe("with page number", () => { + const pageNumber = 2; - searchPromise.then(res => { - expect(res.meta.found).toBe(5); - expect(res.meta.totalPages).toBe(5); - expect(res.meta.page.current).toBe(options.page); - expect(res.meta.page.size).toBe(options.perPage); - }); + it("returns a promise", async () => { + const db = tigris.getDatabase(); + const query: SearchQuery = { + q: "philosophy", + facets: { + tags: Utility.defaultFacetingOptions(), + }, + }; - return searchPromise; - }); + const maybePromise = db.getCollection("books").search(query, pageNumber); + expect(maybePromise).toBeInstanceOf(Promise); - it("searchStream using iteration", async () => { - const tigris = new Tigris({serverUrl: "localhost:" + SERVER_PORT}); - const db3 = tigris.getDatabase("db3"); - const request: SearchRequest = { - q: "philosophy", - facets: { - tags: Utility.createFacetQueryOptions() - } - }; - let bookCounter = 0; - - const searchIterator = db3.getCollection("books").searchStream(request); - // for await loop the iterator - for await (const searchResult of searchIterator) { - expect(searchResult.hits).toBeDefined(); - expect(searchResult.facets).toBeDefined(); - bookCounter += searchResult.hits.length; - } - expect(bookCounter).toBe(TestTigrisService.BOOKS_B64_BY_ID.size); - }); + maybePromise.then((res: SearchResult) => { + expect(res.meta.found).toBe(5); + expect(res.meta.totalPages).toBe(5); + expect(res.meta.page.current).toBe(pageNumber); + }); + return maybePromise; + }); + }); - it("searchStream using next", async () => { - const tigris = new Tigris({serverUrl: "localhost:" + SERVER_PORT}); - const db3 = tigris.getDatabase("db3"); - const request: SearchRequest = { - q: "philosophy", - facets: { - tags: Utility.createFacetQueryOptions() - } - }; - let bookCounter = 0; - - const searchIterator = db3.getCollection("books").searchStream(request); - let iterableResult = await searchIterator.next(); - while (!iterableResult.done) { - const searchResult = await iterableResult.value; - expect(searchResult.hits).toBeDefined(); - expect(searchResult.facets).toBeDefined(); - bookCounter += searchResult.hits.length; - iterableResult = await searchIterator.next(); - } - expect(bookCounter).toBe(TestTigrisService.BOOKS_B64_BY_ID.size); + describe("without explicit page number", () => { + it("returns an iterator", async () => { + const db = tigris.getDatabase(); + const query: SearchQuery = { + q: "philosophy", + facets: { + tags: Utility.defaultFacetingOptions(), + }, + }; + let bookCounter = 0; + + const maybeIterator = db.getCollection("books").search(query); + expect(maybeIterator).toBeInstanceOf(SearchIterator); + + // for await loop the iterator + for await (const searchResult of maybeIterator) { + expect(searchResult.hits).toBeDefined(); + expect(searchResult.facets).toBeDefined(); + bookCounter += searchResult.hits.length; + } + expect(bookCounter).toBe(TestTigrisService.BOOKS_B64_BY_ID.size); + }); + }); + + describe("with group by", () => { + it("returns promise", async () => { + const pageNumber = 1; + const db = tigris.getDatabase(); + const query: SearchQuery = { + groupBy: ["author"], + }; + + const maybePromise = db.getCollection("books").search(query, pageNumber); + expect(maybePromise).toBeInstanceOf(Promise); + + maybePromise.then((res: SearchResult) => { + expect(res.groupedHits?.length).toEqual(2); + expect(res.groupedHits?.[0]?.hits?.length).toEqual(2); + expect(res.groupedHits?.[1]?.hits?.length).toEqual(4); + expect(res.groupedHits?.[0]?.groupKeys).toEqual(["E.M. Forster"]); + expect(res.groupedHits?.[1]?.groupKeys).toEqual(["Marcel Proust"]); + }); + return maybePromise; + }); + }); }); - it("beginTx", () => { - const tigris = new Tigris({serverUrl: "localhost:" + SERVER_PORT}); - const db3 = tigris.getDatabase("db3"); + it("beginTx", async () => { + const tigris = new Tigris({ ...testConfig, projectName: "db3" }); + const db3 = tigris.getDatabase(); const beginTxPromise = db3.beginTransaction(); - beginTxPromise.then(value => { + beginTxPromise.then((value) => { expect(value.id).toBe("id-test"); expect(value.origin).toBe("origin-test"); }); return beginTxPromise; }); - it("commitTx", (done) => { - const tigris = new Tigris({serverUrl: "localhost:" + SERVER_PORT}); - const db3 = tigris.getDatabase("db3"); + it("commitTx", async () => { + const tigris = new Tigris({ ...testConfig, projectName: "db3" }); + const db3 = tigris.getDatabase(); const beginTxPromise = db3.beginTransaction(); - beginTxPromise.then(session => { + beginTxPromise.then((session) => { const commitTxResponse = session.commit(); - commitTxResponse.then(value => { - expect(value.status).toBe("committed-test"); - done(); + commitTxResponse.then((value) => { + expect(value.status).toBe(TigrisStatus.Ok); }); + return beginTxPromise; }); }); - it("rollbackTx", (done) => { - const tigris = new Tigris({serverUrl: "localhost:" + SERVER_PORT}); - const db3 = tigris.getDatabase("db3"); + it("rollbackTx", async () => { + const tigris = new Tigris({ ...testConfig, projectName: "db3" }); + const db3 = tigris.getDatabase(); const beginTxPromise = db3.beginTransaction(); - beginTxPromise.then(session => { + beginTxPromise.then((session) => { const rollbackTransactionResponsePromise = session.rollback(); - rollbackTransactionResponsePromise.then(value => { - expect(value.status).toBe("rollback-test"); - done(); + rollbackTransactionResponsePromise.then((value) => { + expect(value.status).toBe(TigrisStatus.Ok); }); }); + return beginTxPromise; }); - it("transact", (done) => { - const tigris = new Tigris({serverUrl: "localhost:" + SERVER_PORT}); - const txDB = tigris.getDatabase("test-tx"); + it("transact", async () => { + const tigris = new Tigris({ projectName: "test-tx", ...testConfig }); + const txDB = tigris.getDatabase(); const books = txDB.getCollection("books"); - txDB.transact(tx => { - books.insertOne( - { - id: 1, - author: "Alice", - title: "Some book title" - }, - tx - // eslint-disable-next-line @typescript-eslint/no-unused-vars - ).then(_value => { - books.findOne({ - op: SelectorFilterOperator.EQ, - fields: { - id: 1 - } - }, undefined, tx).then(() => { - books.updateMany({ - op: SelectorFilterOperator.EQ, - fields: { - id: 1 - } - }, - { - op: UpdateFieldsOperator.SET, - fields: { - "author": - "Dr. Author" - } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - }, tx).then(() => { - books.deleteMany({ - op: SelectorFilterOperator.EQ, - fields: { - id: 1 - } - }, tx).then(() => done()); - }); + return txDB.transact((tx) => { + books + .insertOne( + { + id: 1, + author: "Alice", + title: "Some book title", + }, + tx + // eslint-disable-next-line @typescript-eslint/no-unused-vars + ) + .then((_value) => { + books + .findOne( + { + filter: { + id: 1, + }, + }, + tx + ) + .then(() => { + books + .updateMany( + { + filter: { + id: 1, + }, + fields: { + author: "Dr. Author", + }, + }, + tx + ) + .then(() => { + books + .deleteMany( + { + filter: { + id: 1, + }, + }, + tx + ) + .then(); + }); + }); }); - }); }); }); - it("createOrUpdateCollections", () => { - const tigris = new Tigris({serverUrl: "localhost:" + SERVER_PORT}); - const db3 = tigris.getDatabase("db3"); + it("createOrUpdateCollections", async () => { + const tigris = new Tigris({ ...testConfig, projectName: "db3" }); + const db3 = tigris.getDatabase(); const bookSchema: TigrisSchema = { id: { type: TigrisDataTypes.INT64, primary_key: { order: 1, - autoGenerate: true - } + autoGenerate: true, + }, }, author: { - type: TigrisDataTypes.STRING + type: TigrisDataTypes.STRING, }, title: { - type: TigrisDataTypes.STRING + type: TigrisDataTypes.STRING, }, tags: { type: TigrisDataTypes.ARRAY, items: { - type: TigrisDataTypes.STRING - } - } + type: TigrisDataTypes.STRING, + }, + }, }; - return db3.createOrUpdateCollection("books", bookSchema).then(value => { + return db3.createOrUpdateCollection("books", bookSchema).then((value) => { expect(value).toBeDefined(); }); }); - it("createOrUpdateTopic", () => { - const tigris = new Tigris({serverUrl: "localhost:" + SERVER_PORT}); - const db = tigris.getDatabase("test_db"); - const alertSchema: TigrisTopicSchema = { + it("createOrUpdateCollections with no order specified for primary key", async () => { + const tigris = new Tigris({ ...testConfig, projectName: "db3" }); + const db3 = tigris.getDatabase(); + const bookSchema: TigrisSchema = { id: { type: TigrisDataTypes.INT64, - key: { - order: 1 - } + primary_key: { + autoGenerate: true, + }, }, - name: { + author: { type: TigrisDataTypes.STRING, - key: { - order: 2 - } }, - text: { - type: TigrisDataTypes.STRING - } + title: { + type: TigrisDataTypes.STRING, + }, + tags: { + type: TigrisDataTypes.ARRAY, + items: { + type: TigrisDataTypes.STRING, + }, + }, }; - return db.createOrUpdateTopic("alerts", alertSchema).then(value => { + return db3.createOrUpdateCollection("books", bookSchema).then((value) => { expect(value).toBeDefined(); }); }); - it("serverMetadata", () => { - const tigris = new Tigris({serverUrl: "localhost:" + SERVER_PORT}); + it("createOrUpdateCollections should throw IncompletePrimaryKeyOrderError", async () => { + const tigris = new Tigris({ ...testConfig, projectName: "db3" }); + const db3 = tigris.getDatabase(); + const bookSchema: TigrisSchema = { + id: { + type: TigrisDataTypes.INT64, + primary_key: { + autoGenerate: true, + }, + }, + id2: { + type: TigrisDataTypes.INT64, + primary_key: { + autoGenerate: true, + }, + }, + title: { + type: TigrisDataTypes.STRING, + }, + metadata: { + type: { + publishedDate: { + type: TigrisDataTypes.DATE_TIME, + }, + authorName: { + type: TigrisDataTypes.STRING, + }, + }, + }, + }; + let caught; + try { + await db3.createOrUpdateCollection("books", bookSchema); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(MissingPrimaryKeyOrderInSchemaDefinitionError); + }); + + it("createOrUpdateCollections should throw DuplicatePrimaryKeyOrderError", async () => { + const tigris = new Tigris({ ...testConfig, projectName: "db3" }); + const db3 = tigris.getDatabase(); + const bookSchema: TigrisSchema = { + id: { + type: TigrisDataTypes.INT64, + primary_key: { + order: 1, + autoGenerate: true, + }, + }, + id2: { + type: TigrisDataTypes.INT64, + primary_key: { + order: 1, + autoGenerate: true, + }, + }, + title: { + type: TigrisDataTypes.STRING, + }, + metadata: { + type: { + publishedDate: { + type: TigrisDataTypes.DATE_TIME, + }, + authorName: { + type: TigrisDataTypes.STRING, + }, + }, + }, + }; + let caught; + try { + await db3.createOrUpdateCollection("books", bookSchema); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(DuplicatePrimaryKeyOrderError); + }); + + it("serverMetadata", async () => { + const tigris = new Tigris({ ...testConfig, projectName: "db3" }); const serverMetadataPromise = tigris.getServerMetadata(); - serverMetadataPromise.then(value => { + serverMetadataPromise.then((value) => { expect(value.serverVersion).toBe("1.0.0-test-service"); }); return serverMetadataPromise; }); - it("publish", () => { - const tigris = new Tigris({serverUrl: "localhost:" + SERVER_PORT}); - const db = tigris.getDatabase("test_db"); - const topic = db.getTopic("test_topic"); - expect(topic.topicName).toBe("test_topic"); - - const promise = topic.publish({ - id: 34, - text: "test" + it("createCache", async () => { + const tigris = new Tigris({ ...testConfig, projectName: "db3" }); + const cacheC1Promise = tigris.createCacheIfNotExists("c1"); + cacheC1Promise.then((value) => { + expect(value.getCacheName()).toBe("c1"); }); + return cacheC1Promise; + }); - promise.then(alert => { - expect(alert.id).toBe(34); - expect(alert.text).toBe("test"); - }); + it("listCaches", async () => { + const tigris = new Tigris({ ...testConfig, projectName: "db3" }); + for (let i = 0; i < 5; i++) { + await tigris.createCacheIfNotExists("c" + i); + } + const listCachesResponse = await tigris.listCaches(); + for (let i = 0; i < 5; i++) { + let found = false; + for (let cache of listCachesResponse.caches) { + if (cache.name === "c" + i) { + if (found) { + throw new Error("already found " + cache.name); + } + found = true; + break; + } + } + expect(found).toBe(true); + } + }); + + it("deleteCache", async () => { + const tigris = new Tigris({ ...testConfig, projectName: "db3" }); + for (let i = 0; i < 5; i++) { + await tigris.createCacheIfNotExists("c" + i); + } + let listCachesResponse = await tigris.listCaches(); + expect(listCachesResponse.caches.length).toBe(5); + + const deleteResponse = await tigris.deleteCache("c3"); + expect(deleteResponse.status).toBe("deleted"); + + listCachesResponse = await tigris.listCaches(); + expect(listCachesResponse.caches.length).toBe(4); + + await tigris.deleteCache("c2"); + listCachesResponse = await tigris.listCaches(); + expect(listCachesResponse.caches.length).toBe(3); + + // deleting non-existing cache + let errored = false; + try { + await tigris.deleteCache("c3"); + } catch (error) { + errored = true; + } + expect(errored).toBe(true); - return promise; + listCachesResponse = await tigris.listCaches(); + expect(listCachesResponse.caches.length).toBe(3); }); - it("subscribe using callback", (done) => { - const tigris = new Tigris({serverUrl: "localhost:" + SERVER_PORT}); - const db = tigris.getDatabase("test_db"); - const topic = db.getTopic("test_topic"); - let success = true; - const expectedIds = new Set(TestTigrisService.ALERTS_B64_BY_ID.keys()); + it.skip("cacheCrud", async () => { + const tigris = new Tigris({ ...testConfig, projectName: "db3" }); + const c1 = await tigris.createCacheIfNotExists("c1"); - topic.subscribe({ - onNext(alert: Alert) { - expect(expectedIds).toContain(alert.id); - expectedIds.delete(alert.id); - }, - onEnd() { - expect(success).toBe(true); - done(); - }, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - onError(error: Error) { - success = false; - } - }); + await c1.set("k1", "val1"); + expect((await c1.get("k1")).value).toBe("val1"); + + await c1.set("k1", "val1-new"); + expect((await c1.get("k1")).value).toBe("val1-new"); + + await c1.set("k2", 123); + expect((await c1.get("k2")).value).toBe(123); + + await c1.set("k3", true); + expect((await c1.get("k3")).value).toBe(true); + + await c1.set("k4", { a: "b", n: 12 }); + expect((await c1.get("k4")).value).toEqual({ a: "b", n: 12 }); + + const keysArrays = await c1.keys().toArray(); + const keys: Array = new Array(); + keysArrays.forEach((keysArray) => keysArray.forEach((key) => keys.push(key))); + + expect(keys).toHaveLength(4); + expect(keys).toContain("k1"); + expect(keys).toContain("k2"); + expect(keys).toContain("k3"); + expect(keys).toContain("k4"); + + await c1.del("k1"); + let errored = false; + + try { + await c1.get("k1"); + } catch (error) { + errored = true; + } + expect(errored).toBe(true); + + // k1 is deleted + const keysNewArray = await c1.keys().toArray(); + const keysNew: Array = new Array(); + keysNewArray.forEach((keysArray) => keysArray.forEach((key) => keysNew.push(key))); + expect(keysNew).toHaveLength(3); + expect(keysNew).toContain("k2"); + expect(keysNew).toContain("k3"); + expect(keysNew).toContain("k4"); + + // getset + let getSetResp = await c1.getSet("k2", 123_456); + expect(getSetResp.old_value).toBe(123); + + getSetResp = await c1.getSet("k2", 123_457); + expect(getSetResp.old_value).toBe(123_456); + + // getset for new key + try { + getSetResp = await c1.getSet("k6", "val6"); + expect(getSetResp.old_value).toBeUndefined(); + } catch (error) { + console.log(error); + } }); - it("subscribe using stream", (done) => { - const tigris = new Tigris({serverUrl: "localhost:" + SERVER_PORT}); - const db = tigris.getDatabase("test_db"); - const topic = db.getTopic("test_topic"); - const subscription: Readable = topic.subscribe() as Readable; - const expectedIds = new Set(TestTigrisService.ALERTS_B64_BY_ID.keys()); - let success = true; + describe("DB branching", () => { + const tigris = new Tigris(testConfig); - subscription.on("data", (alert) =>{ - expect(expectedIds).toContain(alert.id); - expectedIds.delete(alert.id); - }); - subscription.on("error", () => { - success = false; + it("creates a new branch", async () => { + expect.hasAssertions(); + const db = tigris.getDatabase(); + const createResp = db.createBranch("staging"); + + return createResp.then((r) => expect(r.status).toBe("created")); }); - subscription.on("end", () => { - expect(success).toBe(true); - done(); + + it("fails to create existing branch", async () => { + expect.assertions(2); + const db = tigris.getDatabase(); + const createResp = db.createBranch(Branch.Existing); + createResp.catch((r) => { + expect((r as ServiceError).code).toEqual(Status.ALREADY_EXISTS); + }); + + return expect(createResp).rejects.toBeDefined(); }); - }); - it("subscribeWithFilter", (done) => { - const tigris = new Tigris({serverUrl: "localhost:" + SERVER_PORT}); - const db = tigris.getDatabase("test_db"); - const topic = db.getTopic("test_topic"); - let success = true; - const expectedIds = new Set(TestTigrisService.ALERTS_B64_BY_ID.keys()); + it("deletes a branch successfully", async () => { + expect.hasAssertions(); + const db = tigris.getDatabase(); + const deleteResp = db.deleteBranch("staging"); - topic.subscribeWithFilter({ - op: SelectorFilterOperator.EQ, - fields: { - text: "test" - } - }, - { - onNext(alert: Alert) { - expect(expectedIds).toContain(alert.id); - expectedIds.delete(alert.id); - }, - onEnd() { - expect(success).toBe(true); - done(); - }, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - onError(error: Error) { - success = false; - } + return deleteResp.then((r) => expect(r.status).toBe("deleted")); + }); + + it("fails to delete a branch if not existing already", async () => { + expect.assertions(2); + const db = tigris.getDatabase(); + const deleteResp = db.deleteBranch(Branch.NotFound); + deleteResp.catch((r) => { + expect((r as ServiceError).code).toEqual(Status.NOT_FOUND); }); + + return expect(deleteResp).rejects.toBeDefined(); + }); }); - it("subscribeToPartitions", (done) => { - const tigris = new Tigris({serverUrl: "localhost:" + SERVER_PORT}); - const db = tigris.getDatabase("test_db"); - const topic = db.getTopic("test_topic"); - let success = true; - const expectedIds = new Set(TestTigrisService.ALERTS_B64_BY_ID.keys()); + describe("initializeBranch()", () => { + let mockedUtil; + let config: TigrisClientConfig = { + serverUrl: testConfig.serverUrl, + projectName: testConfig.projectName, + }; + beforeEach(() => { + mockedUtil = spy(Utility); + }); - const partitions = new Array(); - partitions.push(55); + afterEach(() => { + reset(mockedUtil); + }); - topic.subscribeToPartitions(partitions,{ - onNext(alert: Alert) { - expect(expectedIds).toContain(alert.id); - expectedIds.delete(alert.id); - }, - onEnd() { - expect(success).toBe(true); - done(); - }, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - onError(error: Error) { - success = false; - } + it("throws error no branch name given", () => { + expect(config["branch"]).toBeUndefined(); + when(mockedUtil.branchNameFromEnv(anything())).thenReturn(undefined); + const tigris = new Tigris(config); + expect(() => tigris.getDatabase()).toThrow(BranchNameRequiredError); }); - }); - it("findMany in topic", async () => { - const tigris = new Tigris({serverUrl: "localhost:" + SERVER_PORT}); - const db = tigris.getDatabase("test_db"); - const topic = db.getTopic("test_topic"); - const expectedIds = new Set(TestTigrisService.ALERTS_B64_BY_ID.keys()); - let seenAlerts = 0; + it("creating branch for existing succeeds", async () => { + when(mockedUtil.branchNameFromEnv(anything())).thenReturn(Branch.Existing); + const tigris = new Tigris(config); + const db = tigris.getDatabase(); - for await (const alert of topic.findMany()) { - expect(expectedIds).toContain(alert.id); - expectedIds.delete(alert.id); - seenAlerts++; - } + expect(db.branch).toBe(Branch.Existing); + + return db.initializeBranch(); + }); + + it("create a branch if not exist", async () => { + when(mockedUtil.branchNameFromEnv(anything())).thenReturn("fork_feature_1"); + const tigris = new Tigris(config); + const db = tigris.getDatabase(); - expect(seenAlerts).toBe(2); + expect(db.branch).toBe("fork_feature_1"); + return db.initializeBranch(); + }); + + it("fails to create branch if project does not exist", async () => { + when(mockedUtil.branchNameFromEnv(anything())).thenReturn(Branch.NotFound); + const tigris = new Tigris(config); + const db = tigris.getDatabase(); + + return expect(db.initializeBranch()).rejects.toThrow(Error); + }); }); }); -export interface IBook extends TigrisCollectionType { +@TigrisCollection("books") +export class IBook implements TigrisCollectionType { + @PrimaryKey({ order: 1 }) id: number; + @Field() title: string; + @Field() author: string; + @Field({ elements: TigrisDataTypes.STRING }) tags?: string[]; + @Field(TigrisDataTypes.DATE_TIME, { timestamp: "createdAt" }) + createdAt?: Date; + @Field() + purchasedOn?: Date; } export interface IBook1 extends TigrisCollectionType { @@ -834,8 +1109,9 @@ export interface IBook2 extends TigrisCollectionType { metadata: object; } -export interface Alert extends TigrisTopicType { - id: number; - name?: number; - text: string; +export interface IBookMPK extends TigrisCollectionType { + id?: number; + id2?: number; + title: string; + metadata: object; } diff --git a/src/__tests__/tigris.schema.spec.ts b/src/__tests__/tigris.schema.spec.ts index 7be4c12..89083ce 100644 --- a/src/__tests__/tigris.schema.spec.ts +++ b/src/__tests__/tigris.schema.spec.ts @@ -1,376 +1,154 @@ -import {CollectionType, TigrisCollectionType, TigrisDataTypes, TigrisSchema,} from "../types"; -import {Utility} from "../utility"; - -describe("schema tests", () => { - - it("basicCollection", () => { - const schema: TigrisSchema = { - id: { - type: TigrisDataTypes.INT32, - primary_key: { - order: 1, - autoGenerate: true - } - }, - active: { - type: TigrisDataTypes.BOOLEAN - }, - name: { - type: TigrisDataTypes.STRING - }, - uuid: { - type: TigrisDataTypes.UUID - }, - int32Number: { - type: TigrisDataTypes.INT32 - }, - int64Number: { - type: TigrisDataTypes.INT64 - }, - date: { - type: TigrisDataTypes.DATE_TIME - }, - bytes: { - type: TigrisDataTypes.BYTE_STRING - } - }; - expect(Utility._toJSONSchema("basicCollection", CollectionType.DOCUMENTS, schema)) - .toBe(Utility._readTestDataFile("basicCollection.json")); - }); - - it("basicCollectionWithObjectType", () => { - const schema: TigrisSchema = { - id: { - type: TigrisDataTypes.INT64, - primary_key: { - order: 1, - autoGenerate: true - } - }, - name: { - type: TigrisDataTypes.STRING - }, - metadata: { - type: TigrisDataTypes.OBJECT - } - }; - expect(Utility._toJSONSchema("basicCollectionWithObjectType", CollectionType.DOCUMENTS, schema)) - .toBe(Utility._readTestDataFile("basicCollectionWithObjectType.json")); - }); - - it("multiplePKeys", () => { - const schema: TigrisSchema = { - id: { - type: TigrisDataTypes.INT64, - primary_key: { - order: 2, // intentionally the order is skewed to test - } - }, - active: { - type: TigrisDataTypes.BOOLEAN - }, - name: { - type: TigrisDataTypes.STRING - }, - uuid: { - type: TigrisDataTypes.UUID, - primary_key: { - order: 1, - autoGenerate: true - } - }, - int32Number: { - type: TigrisDataTypes.INT32 - }, - int64Number: { - type: TigrisDataTypes.INT64 - }, - date: { - type: TigrisDataTypes.DATE_TIME - }, - bytes: { - type: TigrisDataTypes.BYTE_STRING - } - }; - expect(Utility._toJSONSchema("multiplePKeys", CollectionType.DOCUMENTS, schema)) - .toBe(Utility._readTestDataFile("multiplePKeys.json")); - }); - - it("nestedCollection", () => { - const addressSchema: TigrisSchema
= { - city: { - type: TigrisDataTypes.STRING - }, - state: { - type: TigrisDataTypes.STRING - }, - zipcode: { - type: TigrisDataTypes.NUMBER - } - }; - const schema: TigrisSchema = { - id: { - type: TigrisDataTypes.NUMBER - }, - name: { - type: TigrisDataTypes.STRING - }, - address: { - type: addressSchema - } - }; - expect(Utility._toJSONSchema("nestedCollection", CollectionType.DOCUMENTS, schema)) - .toBe(Utility._readTestDataFile("nestedCollection.json")); - }); - - it("collectionWithPrimitiveArrays", () => { - const schema: TigrisSchema = { - id: { - type: TigrisDataTypes.NUMBER - }, - name: { - type: TigrisDataTypes.STRING - }, - tags: { - type: TigrisDataTypes.ARRAY, - items: { - type: TigrisDataTypes.STRING - } - } - }; - expect(Utility._toJSONSchema("collectionWithPrimitiveArrays", CollectionType.DOCUMENTS, schema)) - .toBe(Utility._readTestDataFile("collectionWithPrimitiveArrays.json")); - }); - - it("collectionWithObjectArrays", () => { - const addressSchema: TigrisSchema
= { - city: { - type: TigrisDataTypes.STRING - }, - state: { - type: TigrisDataTypes.STRING - }, - zipcode: { - type: TigrisDataTypes.NUMBER - } - }; - const schema: TigrisSchema = { - id: { - type: TigrisDataTypes.NUMBER - }, - name: { - type: TigrisDataTypes.STRING - }, - knownAddresses: { - type: TigrisDataTypes.ARRAY, - items: { - type: addressSchema - } - } - }; - expect(Utility._toJSONSchema("collectionWithObjectArrays", CollectionType.DOCUMENTS, schema)) - .toBe(Utility._readTestDataFile("collectionWithObjectArrays.json")); - }); - - it("multiLevelPrimitiveArray", () => { - const schema: TigrisSchema = { - oneDArray: { - type: TigrisDataTypes.ARRAY, - items: { - type: TigrisDataTypes.STRING - } - }, - twoDArray: { - type: TigrisDataTypes.ARRAY, - items: { - type: TigrisDataTypes.ARRAY, - items: { - type: TigrisDataTypes.STRING - } - } - }, - threeDArray: { - type: TigrisDataTypes.ARRAY, - items: { - type: TigrisDataTypes.ARRAY, - items: { - type: TigrisDataTypes.ARRAY, - items: { - type: TigrisDataTypes.STRING - } - } - } - }, - fourDArray: { - type: TigrisDataTypes.ARRAY, - items: { - type: TigrisDataTypes.ARRAY, - items: { - type: TigrisDataTypes.ARRAY, - items: { - type: TigrisDataTypes.ARRAY, - items: { - type: TigrisDataTypes.STRING - } - } - } - } - }, - fiveDArray: { - type: TigrisDataTypes.ARRAY, - items: { - type: TigrisDataTypes.ARRAY, - items: { - type: TigrisDataTypes.ARRAY, - items: { - type: TigrisDataTypes.ARRAY, - items: { - type: TigrisDataTypes.ARRAY, - items: { - type: TigrisDataTypes.STRING - } - } - } - } - } - } - }; - expect(Utility._toJSONSchema("multiLevelPrimitiveArray", CollectionType.DOCUMENTS, schema)) - .toBe(Utility._readTestDataFile("multiLevelPrimitiveArray.json")); +import { CollectionSchema, DecoratedSchemaProcessor } from "../schema/decorated-schema-processor"; +import { TigrisCollectionType, TigrisDataTypes, TigrisSchema } from "../types"; +import { User, USERS_COLLECTION_NAME, UserSchema } from "./fixtures/schema/users"; +import { + RENTALS_COLLECTION_NAME, + VacationRentals, + VacationsRentalSchema, +} from "./fixtures/schema/vacationRentals"; +import { Field } from "../decorators/tigris-field"; +import { + IncompleteArrayTypeDefError, + IncompletePrimaryKeyOrderError, + IncorrectVectorDefError, +} from "../error"; +import { TigrisCollection } from "../decorators/tigris-collection"; +import { Utility } from "../utility"; +import { Order, ORDERS_COLLECTION_NAME, OrderSchema } from "./fixtures/schema/orders"; +import { Movie, MOVIES_COLLECTION_NAME, MovieSchema } from "./fixtures/schema/movies"; +import { MATRICES_COLLECTION_NAME, Matrix, MatrixSchema } from "./fixtures/schema/matrices"; +import { readJSONFileAsObj } from "./utils"; +import { STUDENT_COLLECTION_NAME, Student, StudentSchema } from "./fixtures/schema/student"; +import { PrimaryKey } from "../decorators/tigris-primary-key"; + +type SchemaTestCase = { + schemaClass: T; + expectedSchema: TigrisSchema; + name: string; + expectedJson: string; +}; + +const schemas: Array> = [ + { + schemaClass: User, + expectedSchema: UserSchema, + name: USERS_COLLECTION_NAME, + expectedJson: "users.json", + }, + { + schemaClass: Order, + expectedSchema: OrderSchema, + name: ORDERS_COLLECTION_NAME, + expectedJson: "orders.json", + }, + { + schemaClass: Movie, + expectedSchema: MovieSchema, + name: MOVIES_COLLECTION_NAME, + expectedJson: "movies.json", + }, + { + schemaClass: Matrix, + expectedSchema: MatrixSchema, + name: MATRICES_COLLECTION_NAME, + expectedJson: "matrices.json", + }, + { + schemaClass: VacationRentals, + expectedSchema: VacationsRentalSchema, + name: RENTALS_COLLECTION_NAME, + expectedJson: "vacationRentals.json", + }, + { + schemaClass: Student, + expectedSchema: StudentSchema, + name: STUDENT_COLLECTION_NAME, + expectedJson: "students.json", + }, +]; + +/* + * TODO: Add following tests + * + * readonly properties (getter/setter) + * custom constructor + * embedded definitions are empty + */ +describe.each(schemas)("Schema conversion for: '$name'", (tc) => { + const processor = DecoratedSchemaProcessor.Instance; + + test("Convert decorated class to TigrisSchema", () => { + const generated: CollectionSchema = processor.processCollection(tc.schemaClass); + expect(generated.schema).toStrictEqual(tc.expectedSchema); }); - it("multiLevelObjectArray", () => { - const addressSchema: TigrisSchema
= { - city: { - type: TigrisDataTypes.STRING - }, - state: { - type: TigrisDataTypes.STRING - }, - zipcode: { - type: TigrisDataTypes.NUMBER - } - }; - const schema: TigrisSchema = { - oneDArray: { - type: TigrisDataTypes.ARRAY, - items: { - type: addressSchema - } - }, - twoDArray: { - type: TigrisDataTypes.ARRAY, - items: { - type: TigrisDataTypes.ARRAY, - items: { - type: addressSchema - } - } - }, - threeDArray: { - type: TigrisDataTypes.ARRAY, - items: { - type: TigrisDataTypes.ARRAY, - items: { - type: TigrisDataTypes.ARRAY, - items: { - type: addressSchema - } - } - } - }, - fourDArray: { - type: TigrisDataTypes.ARRAY, - items: { - type: TigrisDataTypes.ARRAY, - items: { - type: TigrisDataTypes.ARRAY, - items: { - type: TigrisDataTypes.ARRAY, - items: { - type: addressSchema - } - } - } - } - }, - fiveDArray: { - type: TigrisDataTypes.ARRAY, - items: { - type: TigrisDataTypes.ARRAY, - items: { - type: TigrisDataTypes.ARRAY, - items: { - type: TigrisDataTypes.ARRAY, - items: { - type: TigrisDataTypes.ARRAY, - items: { - type: addressSchema - } - } - } - } - } - } - }; - expect(Utility._toJSONSchema("multiLevelObjectArray", CollectionType.DOCUMENTS, schema)) - .toBe(Utility._readTestDataFile("multiLevelObjectArray.json")); + test("Convert TigrisSchema to JSON spec", () => { + expect(Utility._collectionSchematoJSON(tc.name, tc.expectedSchema)).toBe( + readJSONFileAsObj("src/__tests__/fixtures/json-schema/" + tc.expectedJson) + ); }); }); -interface BasicCollection extends TigrisCollectionType { - id: number; - active: boolean; - name: string; - uuid: string; - int32Number: number; - int64Number: string; - date: string; - bytes: string; -} - -interface BasicCollectionWithObject extends TigrisCollectionType { - id: number; - name: string; - metadata: object; -} - -interface NestedCollection extends TigrisCollectionType { - id: number; - name: string; - address: Address -} - -interface CollectionWithPrimitiveArrays extends TigrisCollectionType { - id: number; - name: string; - tags: string[]; -} - -interface CollectionWithObjectArrays extends TigrisCollectionType { - id: number; - name: string; - knownAddresses: Address[]; -} - -interface MultiLevelPrimitiveArray extends TigrisCollectionType { - oneDArray: string[]; - twoDArray: string[][]; - threeDArray: string[][][]; - fourDArray: string[][][][]; - fiveDArray: string[][][][][]; -} +test("throws error when Schema is invalid with more than one primary key but no orders specified", () => { + const processor = DecoratedSchemaProcessor.Instance; + let caught; + try { + /** + * Schema is INVALID as it contains two primary keys but no order was specified + * in decorator under PrimaryKeyOptions. + */ + const INVALID_STUDENT_COLLECTION_NAME = "invalid_students"; + @TigrisCollection(INVALID_STUDENT_COLLECTION_NAME) + class InvalidStudent { + @PrimaryKey(TigrisDataTypes.INT64) + id?: string; + + @PrimaryKey(TigrisDataTypes.STRING) + email: string; + + @Field() + firstName!: string; + + @Field() + lastName!: string; + + @Field({ timestamp: "createdAt" }) + createdAt?: Date; + } + + processor.processCollection(InvalidStudent); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(IncompletePrimaryKeyOrderError); +}); -interface MultiLevelObjectArray extends TigrisCollectionType { - oneDArray: Address[]; - twoDArray: Address[][]; - threeDArray: Address[][][]; - fourDArray: Address[][][][]; - fiveDArray: Address[][][][][]; -} +test("throws error when Arrays are not properly decorated", () => { + let caught; + + try { + @TigrisCollection("test_studio") + class Studio { + @Field() + actors: Array; + } + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(IncompleteArrayTypeDefError); +}); -interface Address { - city: string; - state: string; - zipcode: number; -} +test("throws error when Vector fields have incorrect type", () => { + let caught; + + try { + @TigrisCollection("test_studio") + class Studio { + @Field({ dimensions: 3, elements: TigrisDataTypes.STRING }) + actors: Array; + } + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(IncorrectVectorDefError); +}); diff --git a/src/__tests__/tigris.search.spec.ts b/src/__tests__/tigris.search.spec.ts new file mode 100644 index 0000000..fbbe102 --- /dev/null +++ b/src/__tests__/tigris.search.spec.ts @@ -0,0 +1,226 @@ +import { TigrisDataTypes } from "../types"; +import { Tigris } from "../tigris"; +import { Status } from "../constants"; +import { + IndexedDoc, + MATCH_ALL_QUERY_STRING, + Search, + SearchIndex, + SearchIterator, + TigrisIndexSchema, + TigrisIndexType, +} from "../search"; +import { Server, ServerCredentials } from "@grpc/grpc-js"; +import TestSearchService, { SearchServiceFixtures } from "./test-search-service"; +import { SearchService } from "../proto/server/v1/search_grpc_pb"; +import { SearchField } from "../decorators/tigris-search-field"; +import { TigrisSearchIndex } from "../decorators/tigris-search-index"; + +describe("Search Indexing", () => { + let tigris: Search; + let server: Server; + beforeAll((done) => { + const testConfig = { serverUrl: "localhost:" + 5004, projectName: "db1", branch: "unit-tests" }; + server = new Server(); + server.addService(SearchService, TestSearchService.handler.impl); + server.bindAsync( + testConfig.serverUrl, + ServerCredentials.createInsecure(), + (err: Error | null) => { + if (err) { + console.log(err); + } else { + server.start(); + } + } + ); + tigris = new Tigris(testConfig).getSearch(); + done(); + }); + + afterAll((done) => { + server.forceShutdown(); + done(); + }); + + describe("createOrUpdateIndex", () => { + it("creates index if not exists", async () => { + const createPromise = tigris.createOrUpdateIndex(SearchServiceFixtures.Success, bookSchema); + await expect(createPromise).resolves.toBeInstanceOf(SearchIndex); + }); + it("creates index from decorated schema model", async () => { + const createPromise = tigris.createOrUpdateIndex(BlogPost); + await expect(createPromise).resolves.toBeInstanceOf(SearchIndex); + }); + it("fails when index already exists", async () => { + const createPromise = tigris.createOrUpdateIndex( + SearchServiceFixtures.AlreadyExists, + bookSchema + ); + await expect(createPromise).rejects.toThrow("already exists"); + }); + it("fails for server error", async () => { + const createPromise = tigris.createOrUpdateIndex("any other index", bookSchema); + await expect(createPromise).rejects.toThrow("Server error"); + }); + }); + + describe("getIndex", () => { + it("succeeds if index exists", async () => { + const getIndexPromise = tigris.getIndex(SearchServiceFixtures.Success); + return expect(getIndexPromise).resolves.toBeInstanceOf(SearchIndex); + }); + it("fails if index does not exist", async () => { + await expect(tigris.getIndex(SearchServiceFixtures.DoesNotExist)).rejects.toThrow( + "search index not found" + ); + }); + }); + + describe("deleteIndex", () => { + it("succeeds if index exists", async () => { + const deleteResp = await tigris.deleteIndex(SearchServiceFixtures.Success); + expect(deleteResp.status).toBe(Status.Deleted); + }); + + it("fails if index does not exist", async () => { + await expect(tigris.deleteIndex(SearchServiceFixtures.DoesNotExist)).rejects.toThrow( + "search index not found" + ); + }); + }); + + it("listIndexes", async () => { + const names = (await tigris.listIndexes()).map((idx) => idx.name); + return expect(names).toEqual(expect.arrayContaining(["i1", "i2"])); + }); + + describe("createDocuments", () => { + const docs: Map = new Map([ + ["italy", { title: "italy", tags: ["travel"] }], + ["reliable systems", { title: "reliable systems", tags: ["it"] }], + ]); + + it("successfully creates multiple documents", async () => { + expect.assertions(docs.size); + const index: SearchIndex = await tigris.getIndex(SearchServiceFixtures.Success); + const result = await index.createMany(Array.from(docs.values())); + console.log(result); + result.forEach((r) => expect(docs.has(r.id)).toBeTruthy()); + }); + + it("creates a single document", async () => { + const index: SearchIndex = await tigris.getIndex(SearchServiceFixtures.Success); + const result = await index.createOne(docs.get("italy")); + expect(result.id).toEqual("italy"); + }); + }); + + describe("deleteDocuments", () => { + it("deletes a single document", async () => { + const index = await tigris.getIndex(SearchServiceFixtures.Success); + const result = await index.deleteOne("12345"); + expect(result.id).toBe("12345"); + }); + + it("deletes multiple documents", async () => { + const index = await tigris.getIndex(SearchServiceFixtures.Success); + const docIds = ["1", "2", "3"]; + expect.assertions(docIds.length); + const result = await index.deleteMany(docIds); + result.forEach((r) => expect(docIds).toContain(r.id)); + }); + }); + + describe("getDocuments", () => { + it("gets multiple documents", async () => { + const index = await tigris.getIndex(SearchServiceFixtures.Success); + const expectedDocs = Array.from(SearchServiceFixtures.Docs.values()); + const recvdDocs = await index.getMany(Array.from(SearchServiceFixtures.Docs.keys())); + for (let i = 0; i < recvdDocs.length; i++) { + expect(recvdDocs[i].meta.createdAt).toStrictEqual( + new Date(SearchServiceFixtures.GetDocs.CreatedAtSeconds * 1000) + ); + expect(recvdDocs[i].document).toEqual(expectedDocs[i]); + } + }); + + it("gets a single document", async () => { + const index = await tigris.getIndex(SearchServiceFixtures.Success); + const result = await index.getOne("1"); + expect(result.document).toEqual(SearchServiceFixtures.Docs.get("1")); + expect(result.meta.updatedAt).toBeUndefined(); + expect(result.meta.createdAt).toStrictEqual( + new Date(SearchServiceFixtures.GetDocs.CreatedAtSeconds * 1000) + ); + }); + }); + + describe("searchDocuments", () => { + it("returns an iterator", async () => { + const index = await tigris.getIndex(SearchServiceFixtures.Success); + const maybeIterator = await index.search({ q: MATCH_ALL_QUERY_STRING }); + expect(maybeIterator).toBeInstanceOf(SearchIterator); + const expectedDocs = Array.from(SearchServiceFixtures.Docs.values()); + // for await loop the iterator + for await (const searchResult of maybeIterator) { + searchResult.hits.forEach((h: IndexedDoc) => { + expect(expectedDocs).toContainEqual(h.document); + expect(h.meta.updatedAt).toBeDefined(); + expect(h.meta.updatedAt).toStrictEqual( + new Date(SearchServiceFixtures.SearchDocs.UpdatedAtSeconds * 1000) + ); + expect(h.meta.createdAt).toBeUndefined(); + }); + expect(searchResult.meta.found).toBe(5); + expect(searchResult.meta.totalPages).toBe(5); + expect(searchResult.facets["title"]).toBeDefined(); + expect(searchResult.facets["title"].counts).toEqual([ + { + count: 2, + value: "Philosophy", + }, + ]); + } + }); + }); + + it("returns a promise with page number", async () => { + const index = await tigris.getIndex(SearchServiceFixtures.Success); + const maybePromise = index.search({ q: MATCH_ALL_QUERY_STRING }, 1); + expect(maybePromise).toBeInstanceOf(Promise); + return expect(maybePromise).resolves.toBeDefined(); + }); +}); + +interface Book extends TigrisIndexType { + title: string; + tags?: string[]; +} + +const bookSchema: TigrisIndexSchema = { + title: { + type: TigrisDataTypes.STRING, + }, + tags: { + type: TigrisDataTypes.ARRAY, + items: { + type: TigrisDataTypes.STRING, + }, + }, +}; + +@TigrisSearchIndex(SearchServiceFixtures.CreateIndex.Blog) +class BlogPost { + @SearchField({ facet: true }) + text: string; + + @SearchField({ elements: TigrisDataTypes.STRING }) + comments: Array; + + @SearchField() + author: string; + + @SearchField({ sort: true }) + createdAt: Date; +} diff --git a/src/__tests__/tigris.updatefields.spec.ts b/src/__tests__/tigris.updatefields.spec.ts index 42b188c..2a17192 100644 --- a/src/__tests__/tigris.updatefields.spec.ts +++ b/src/__tests__/tigris.updatefields.spec.ts @@ -1,26 +1,106 @@ -import {SimpleUpdateField, UpdateFields, UpdateFieldsOperator} from "../types"; -import {Utility} from "../utility"; +import { Utility } from "../utility"; +import { TigrisCollection } from "../decorators/tigris-collection"; +import { PrimaryKey } from "../decorators/tigris-primary-key"; +import { Field } from "../decorators/tigris-field"; +import { TigrisDataTypes, UpdateFields } from "../types"; describe("updateFields tests", () => { - - it("updateFields", () => { - const updateFields: UpdateFields = { - op: UpdateFieldsOperator.SET, - fields: { - title: "New Title", + const testCases: Array<{ + name: string; + input: UpdateFields; + expected: string; + }> = [ + { + name: "simple update field", + input: { + title: "New title", price: 499, active: true, - } - }; - expect(Utility.updateFieldsString(updateFields)).toBe("{\"$set\":{\"title\":\"New Title\",\"price\":499,\"active\":true}}"); - }); + }, + expected: '{"$set":{"title":"New title","price":499,"active":true}}', + }, + { + name: "nested schema", + input: { + $increment: { + "publisher.totalPublished": 1, + }, + "publisher.name": "Wonderbooks", + }, + expected: + '{"$increment":{"publisher.totalPublished":1},"$set":{"publisher.name":"Wonderbooks"}}', + }, + { + name: "all operators", + input: { + $set: { title: "Kite Runner" }, + $unset: ["publisher.name", "active"], + $multiply: { rating: 2.2 }, + $decrement: { quantity: 1, price: 3.53 }, + $increment: { "publisher.totalPublished": 1, price: 4.1 }, + }, + expected: + '{"$set":{"title":"Kite Runner"},"$unset":["publisher.name","active"],"$multiply":{"rating":2.2},"$decrement":{"quantity":1,"price":3.53},"$increment":{"publisher.totalPublished":1,"price":4.1}}', + }, + { + name: "division update only", + input: { + $divide: { rating: 2.34 }, + }, + expected: '{"$divide":{"rating":2.34}}', + }, + { + name: "setting field to an object", + input: { + publisher: { totalPublished: 24, name: "Urban books" } as Publisher, + }, + expected: '{"$set":{"publisher":{"totalPublished":24,"name":"Urban books"}}}', + }, + { + name: "setting update fields to an array", + input: { + categories: ["tales", "stories"], + }, + expected: '{"$set":{"categories":["tales","stories"]}}', + }, + ]; - it("simpleUpdateField", () => { - const updateFields: SimpleUpdateField = { - title: "New Title", - price: 499, - active: true, - }; - expect(Utility.updateFieldsString(updateFields)).toBe("{\"$set\":{\"title\":\"New Title\",\"price\":499,\"active\":true}}"); + it.each(testCases)("Serializing '$name' to string", (fixture) => { + expect(Utility.updateFieldsString(fixture.input)).toBe(fixture.expected); }); }); + +class Publisher { + @Field() + totalPublished: number; + + @Field() + name: string; +} + +@TigrisCollection("books") +class Books { + @PrimaryKey({ order: 1 }) + id: string; + + @Field() + title: string; + + @Field() + price: number; + + @Field() + active: boolean; + + @Field() + quantity: number; + + @Field({ elements: TigrisDataTypes.STRING }) + categories: string[]; + + @Field() + rating: number; + + @Field() + publisher: Publisher; +} diff --git a/src/__tests__/tigris.utility.spec.ts b/src/__tests__/tigris.utility.spec.ts index c92355d..54a8097 100644 --- a/src/__tests__/tigris.utility.spec.ts +++ b/src/__tests__/tigris.utility.spec.ts @@ -1,15 +1,31 @@ -import {Utility} from "../utility"; +import { Utility } from "../utility"; import { Case, FacetFieldOptions, FacetFields, FacetFieldsQuery, - FacetQueryFieldType, MATCH_ALL_QUERY_STRING, - Ordering, - SearchRequestOptions, - SortOrder -} from "../search/types"; + SearchQuery, + SearchQueryOptions, +} from "../search"; +import { SearchRequest as ProtoSearchRequest } from "../proto/server/v1/api_pb"; +import { SortOrder, TigrisCollectionType } from "../types"; +import { Field } from "../decorators/tigris-field"; +import { TigrisCollection } from "../decorators/tigris-collection"; +import { PrimaryKey } from "../decorators/tigris-primary-key"; + +interface ICollectionFields extends TigrisCollectionType { + field_1: string; + field_2: string; + field_3: string; + parent?: IParent; +} + +interface IParent extends TigrisCollectionType { + field_1?: string; + field_2?: string; + field_3?: string; +} describe("utility tests", () => { it("base64encode", () => { @@ -23,106 +39,274 @@ describe("utility tests", () => { }); it("generates default facet query options", () => { - const generatedOptions = Utility.createFacetQueryOptions(); + const generatedOptions = Utility.defaultFacetingOptions(); expect(generatedOptions.size).toBe(10); - expect(generatedOptions.type).toBe(FacetQueryFieldType.VALUE); - }); - - describe("createSearchRequestOptions",() => { - it("generates default with empty options", () => { - const generated: SearchRequestOptions = Utility.createSearchRequestOptions(); - expect(generated.page).toBe(1); - expect(generated.perPage).toBe(20); - }); - - it("fills missing options", () => { - const actual: SearchRequestOptions = { - page: 2, collation: { - case: Case.CaseInsensitive - } - }; - const generated: SearchRequestOptions = Utility.createSearchRequestOptions(actual); - expect(generated.page).toBe(actual.page); - expect(generated.perPage).toBe(20); - expect(generated.collation).toBe(actual.collation); - }); + expect(generatedOptions.type).toBe("value"); }); it("backfills missing facet query options", () => { - const generatedOptions = Utility.createFacetQueryOptions({ - size: 55 + const generatedOptions = Utility.defaultFacetingOptions({ + size: 55, }); expect(generatedOptions.size).toBe(55); - expect(generatedOptions.type).toBe(FacetQueryFieldType.VALUE); + expect(generatedOptions.type).toBe("value"); }); it("serializes FacetFields to string", () => { - const fields: FacetFields = ["field_1", "field_2"]; + const fields: FacetFields = ["field_1", "field_2"]; const serialized: string = Utility.facetQueryToString(fields); - expect(serialized).toBe("{\"field_1\":{\"size\":10,\"type\":\"value\"},\"field_2\":{\"size\":10,\"type\":\"value\"}}"); + expect(serialized).toBe( + '{"field_1":{"size":10,"type":"value"},"field_2":{"size":10,"type":"value"}}' + ); }); it("serializes FacetFieldOptions to string", () => { - const fields: FacetFieldOptions = { - field_1: Utility.createFacetQueryOptions(), - field_2: {size: 10, type: FacetQueryFieldType.VALUE} + const fields: FacetFieldOptions = { + field_1: Utility.defaultFacetingOptions(), + field_2: { size: 10 }, }; const serialized: string = Utility.facetQueryToString(fields); - expect(serialized).toBe("{\"field_1\":{\"size\":10,\"type\":\"value\"},\"field_2\":{\"size\":10,\"type\":\"value\"}}"); + expect(serialized).toBe( + '{"field_1":{"size":10,"type":"value"},"field_2":{"size":10,"type":"value"}}' + ); }); - it("equivalent serialization of FacetFieldsQuery",() => { - const facetFields: FacetFieldsQuery = ["field_1", "field_2"]; - const fieldOptions: FacetFieldsQuery = { - field_1: Utility.createFacetQueryOptions(), - field_2: {size: 10, type: FacetQueryFieldType.VALUE} + it("equivalent serialization of FacetFieldsQuery", () => { + const facetFields: FacetFieldsQuery = ["field_1", "field_2"]; + const fieldOptions: FacetFieldsQuery = { + field_1: Utility.defaultFacetingOptions(), + field_2: { size: 10, type: "value" }, }; const serializedFields = Utility.facetQueryToString(facetFields); expect(serializedFields).toBe(Utility.facetQueryToString(fieldOptions)); }); - it("serializes empty sort order", () => { - expect(Utility.sortOrderingToString([])).toBe("[]"); - }); - - it("serializes sort orders to string", () => { - const ordering: Ordering = [ - {field: "field_1", order: SortOrder.ASC}, - {field: "parent.field_2", order: SortOrder.DESC} - ]; - const expected = "[{\"field_1\":\"$asc\"},{\"parent.field_2\":\"$desc\"}]"; - expect(Utility.sortOrderingToString(ordering)).toBe(expected); + it.each<[string, SortOrder, string]>([ + [ + "multiple sort fields", + [ + { field: "field_1", order: "$asc" }, + { field: "parent.field_2", order: "$desc" }, + ], + '[{"field_1":"$asc"},{"parent.field_2":"$desc"}]', + ], + ["single sort field", { field: "field_3", order: "$desc" }, '[{"field_3":"$desc"}]'], + ["empty array", [], "[]"], + ])("_sortOrderingToString() with '%s'", (testName, input, expected) => { + expect(Utility._sortOrderingToString(input)).toBe(expected); }); describe("createProtoSearchRequest", () => { - const dbName = "my_test_db"; - const collectionName = "my_test_collection"; + let request: ProtoSearchRequest; + beforeEach(() => { + request = new ProtoSearchRequest(); + }); + + it("creates default match all search request", () => { + const query = {}; + Utility.protoSearchRequestFromQuery(query, request); + expect(request.getQ()).toBe(MATCH_ALL_QUERY_STRING); + expect(request.getSearchFieldsList()).toEqual([]); + expect(request.getFilter()).toBe(""); + expect(request.getFacet()).toBe(""); + expect(request.getVector()).toBe(""); + expect(request.getSort()).toBe(""); + expect(request.getGroupBy()).toBe(""); + expect(request.getIncludeFieldsList()).toEqual([]); + expect(request.getExcludeFieldsList()).toEqual([]); + expect(request.getPage()).toBe(0); + expect(request.getPageSize()).toBe(0); + expect(request.getCollation()).toBeUndefined(); + }); - it("populates dbName and collection name", () => { - const emptyRequest = {q: ""}; - const generated = Utility.createProtoSearchRequest(dbName, collectionName, emptyRequest); - expect(generated.getDb()).toBe(dbName); - expect(generated.getCollection()).toBe(collectionName); + it("sets searchFields", () => { + const query: SearchQuery = { searchFields: ["name", "address.street"] }; + Utility.protoSearchRequestFromQuery(query, request); + + expect(request.getSearchFieldsList()).toEqual(["name", "address.street"]); }); - it("creates default match all query string", () => { - const request = {q: undefined}; - const generated = Utility.createProtoSearchRequest(dbName, collectionName, request); - expect(generated.getQ()).toBe(MATCH_ALL_QUERY_STRING); + it("sets filter", () => { + const query: SearchQuery = { + filter: { + balance: { + $gt: 25, + }, + }, + }; + Utility.protoSearchRequestFromQuery(query, request); + + expect(request.getFilter()).toEqual(Utility.stringToUint8Array('{"balance":{"$gt":25}}')); }); - it ("sets collation options", () => { - const emptyRequest = {q: ""}; - const options: SearchRequestOptions = { + it("sets facets", () => { + const query: SearchQuery = { + facets: ["address.city"], + }; + Utility.protoSearchRequestFromQuery(query, request); + + expect(request.getFacet()).toEqual( + Utility.stringToUint8Array('{"address.city":{"size":10,"type":"value"}}') + ); + }); + + it("sets vector query", () => { + const query: SearchQuery = { + vectorQuery: { + "address.street": [0.4, -0.15, 0.9], + }, + }; + Utility.protoSearchRequestFromQuery(query, request); + expect(request.getVector()).toEqual( + Utility.stringToUint8Array('{"address.street":[0.4,-0.15,0.9]}') + ); + }); + + it("sets sort order", () => { + const query: SearchQuery = { + sort: { field: "balance", order: "$desc" }, + }; + Utility.protoSearchRequestFromQuery(query, request); + + expect(request.getSort()).toEqual(Utility.stringToUint8Array('[{"balance":"$desc"}]')); + }); + + it("sets group by", () => { + const query: SearchQuery = { + groupBy: ["city"], + }; + Utility.protoSearchRequestFromQuery(query, request); + + expect(request.getGroupBy()).toEqual(Utility.stringToUint8Array('{"fields":["city"]}')); + }); + + it("sets includeFields", () => { + const query: SearchQuery = { includeFields: ["name", "address.street"] }; + Utility.protoSearchRequestFromQuery(query, request); + + expect(request.getIncludeFieldsList()).toEqual(["name", "address.street"]); + }); + + it("sets excludeFields", () => { + const query: SearchQuery = { excludeFields: ["name", "address.street"] }; + Utility.protoSearchRequestFromQuery(query, request); + + expect(request.getExcludeFieldsList()).toEqual(["name", "address.street"]); + }); + + it("sets hitsPerPage", () => { + const query: SearchQuery = { hitsPerPage: 57 }; + Utility.protoSearchRequestFromQuery(query, request); + + expect(request.getPageSize()).toEqual(57); + }); + + it("sets page", () => { + Utility.protoSearchRequestFromQuery({}, request, 3); + + expect(request.getPage()).toEqual(3); + }); + + it("sets collation options", () => { + const options: SearchQueryOptions = { collation: { - case: Case.CaseInsensitive - } + case: Case.CaseInsensitive, + }, }; - const generated = Utility.createProtoSearchRequest(dbName, collectionName, emptyRequest, options); - expect(generated.getPage()).toBe(0); - expect(generated.getPageSize()).toBe(0); - expect(generated.getCollation().getCase()).toBe("ci"); + const optionsQuery = { q: "", options: options }; + Utility.protoSearchRequestFromQuery(optionsQuery, request); + + expect(request.getPage()).toBe(0); + expect(request.getPageSize()).toBe(0); + expect(request.getCollation().getCase()).toBe("ci"); + }); + }); + + const nerfingTestCases = [ + ["main/fork", "main_fork"], + ["main-fork", "main-fork"], + ["main?fork", "main?fork"], + ["sTaging21", "sTaging21"], + ["hotfix/jira-23$4", "hotfix_jira-23$4"], + ["", ""], + ["release", "release"], + ["zero ops", "zero_ops"], + ["under_score", "under_score"], + ["bot/fork1.2#server/main_beta new", "bot_fork1.2_server_main_beta_new"], + ]; + + test.each(nerfingTestCases)("nerfs the name - '%s'", (original, nerfed) => { + expect(Utility.nerfGitBranchName(original)).toBe(nerfed); + }); + + describe("character encoding", () => { + it("read back data into utf-8", () => { + expect(Utility._base64Decode("4KSo4KSu4KS44KWN4KSk4KWH")).toBe("नमस्ते"); + expect(Utility._base64Decode("0L/RgNC40LLQtdGC")).toBe("привет"); + expect(Utility._base64Decode("44GT44KT44Gr44Gh44Gv")).toBe("こんにちは"); + expect(Utility._base64Decode("7JWI64WV7ZWY7IS47JqU")).toBe("안녕하세요"); + expect(Utility._base64Decode("8J+Zjw==")).toBe("🙏"); + expect(Utility._base64Decode("8J+YgQ==")).toBe("😁"); + }); + }); + + describe("get branch name from environment", () => { + const OLD_ENV = Object.assign({}, process.env); + + beforeEach(() => { + jest.resetModules(); + }); + + afterEach(() => { + process.env = OLD_ENV; + }); + + it.each([ + ["preview_${GIT_BRANCH}", "GIT_BRANCH", "feature_1", "preview_feature_1"], + ["staging", undefined, undefined, "staging"], + ["integration_${MY_VAR}_auto", undefined, undefined, undefined], + ["integration_${MY_VAR}_auto", "NOT_SET", "feature_2", undefined], + ["${MY_GIT_BRANCH}", "MY_GIT_BRANCH", "jira/1234", "jira_1234"], + ["${MY_GIT_BRANCH", "MY_GIT_BRANCH", "jira/1234", "${MY_GIT_BRANCH"], + [undefined, undefined, undefined, undefined], + ])("envVar - '%s'", (branchEnvValue, templateEnvKey, templateEnvValue, expected) => { + process.env["TIGRIS_DB_BRANCH"] = branchEnvValue; + if (templateEnvKey) { + process.env[templateEnvKey] = templateEnvValue; + } + expect(Utility.branchNameFromEnv()).toEqual(expected); }); + it.each([ + ["any_given_branch", "any_given_branch"], + ["", ""], + [undefined, undefined], + ])("given branch - '%s'", (givenBranch, expected) => { + const actual = Utility.branchNameFromEnv(givenBranch); + expect(actual).toBe(expected); + }); }); }); + +class Address { + @Field() + street: string; + + @Field() + city: string; +} + +@TigrisCollection("students") +class Student { + @PrimaryKey({ order: 1 }) + id: string; + + @Field() + name: string; + + @Field() + balance: number; + + @Field() + address: Address; +} diff --git a/src/__tests__/utils.ts b/src/__tests__/utils.ts new file mode 100644 index 0000000..5b10f40 --- /dev/null +++ b/src/__tests__/utils.ts @@ -0,0 +1,10 @@ +import { Utility } from "../utility"; +import * as fs from "fs"; + +export function readJSONFileAsObj(filePath: string): string { + return Utility.objToJsonString( + Utility.jsonStringToObj(fs.readFileSync(filePath, "utf8"), { + serverUrl: "test", + }) + ); +} diff --git a/src/__tests__/utils/env-loader.spec.ts b/src/__tests__/utils/env-loader.spec.ts new file mode 100644 index 0000000..c8f3788 --- /dev/null +++ b/src/__tests__/utils/env-loader.spec.ts @@ -0,0 +1,17 @@ +import { initializeEnvironment } from "../../utils/env-loader"; + +describe("configLoader", () => { + const OLD_ENV = Object.assign({}, process.env); + + beforeEach(() => { + jest.resetModules(); + }); + + afterEach(() => { + process.env = OLD_ENV; + }); + + it("no side effect if files not found", () => { + initializeEnvironment(); + }); +}); diff --git a/src/__tests__/utils/manifest-loader.spec.ts b/src/__tests__/utils/manifest-loader.spec.ts deleted file mode 100644 index 16fea17..0000000 --- a/src/__tests__/utils/manifest-loader.spec.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { canBeSchema, loadTigrisManifest, TigrisManifest } from "../../utils/manifest-loader"; -import { TigrisDataTypes } from "../../types"; -import { TigrisFileNotFoundError, TigrisMoreThanOneSchemaDefined } from "../../error"; - -describe("Manifest loader", () => { - - it("generates manifest from file system", () => { - const schemaPath = process.cwd() + "/src/__tests__/data/models"; - const manifest: TigrisManifest = loadTigrisManifest(schemaPath); - expect(manifest).toHaveLength(3); - - const expected: TigrisManifest = [{ - "dbName": "catalog", - "collections": [{ - "collectionName": "products", - "schema": { - "id": { "type": "int32", "primary_key": { "order": 1, "autoGenerate": true } }, - "title": { "type": "string" }, - "description": { "type": "string" }, - "price": { "type": "number" } - }, - "schemaName": "ProductSchema" - }] - }, - { "dbName": "embedded", - "collections": [{ - "collectionName": "users", - "schemaName": "userSchema", - "schema": { - "created": { "type": "date-time" }, - "email": { "type": "string" }, - "identities": { - "type": "array", - "items": { - "type": { - "connection": { "type": "string" }, - "isSocial": { "type": "boolean" }, - "provider": { "type": "string" }, - "user_id": { "type": "string" } - } - } - }, - "name": { "type": "string" }, - "picture": { "type": "string" }, - "stats": { - "type": { - "loginsCount": { "type": "int64" } - } - }, - "updated": { "type": "date-time" }, - "user_id": { "type": "string", "primary_key": { "order": 1 } } - } - }]}, - { "dbName": "empty", "collections": [] }, - ]; - expect(manifest).toStrictEqual(expected); - }); - - it("throws error for invalid path", () => { - const schemaPath = "/src/__tests__/data/models"; - expect(() => loadTigrisManifest(schemaPath)).toThrow(TigrisFileNotFoundError); - }); - - it("throws error for multiple schema exports", () => { - const schemaPath = process.cwd() + "/src/__tests__/data/invalidModels"; - expect(() => loadTigrisManifest(schemaPath)).toThrow(TigrisMoreThanOneSchemaDefined); - }); - - const validSchemaDefinitions = [ - { key: { type: "value" } }, - { - id: { - type: TigrisDataTypes.INT32, - primary_key: { - order: 1, - autoGenerate: true - } - }, - active: { type: TigrisDataTypes.BOOLEAN } - } - ]; - test.each(validSchemaDefinitions)( - "identifies valid schema definition %p", - (definition) => { - expect(canBeSchema(definition)).toBeTruthy(); - } - ); - - const invalidSchemaDefinitions = [ - { key: "value" }, - 12, - { - id: { - type: TigrisDataTypes.INT32, - primary_key: { - order: 1, - autoGenerate: true - } - }, - active: false - }, - { - id: { - key: "value" - } - }, - { type: "string" }, - undefined, - null - ]; - - test.each(invalidSchemaDefinitions)( - "identifies invalid schema definition %p", - (definition) => { - expect(canBeSchema(definition)).toBeFalsy(); - } - ); -}); diff --git a/src/cache.ts b/src/cache.ts new file mode 100644 index 0000000..2a5c537 --- /dev/null +++ b/src/cache.ts @@ -0,0 +1,211 @@ +import { + CacheDelResponse, + CacheGetResponse, + CacheGetSetResponse, + CacheSetOptions, + CacheSetResponse, +} from "./types"; +import { CacheClient } from "./proto/server/v1/cache_grpc_pb"; +import { + DelRequest as ProtoDelRequest, + GetRequest as ProtoGetRequest, + GetSetRequest as ProtoGetSetRequest, + KeysRequest as ProtoKeysRequest, + SetRequest as ProtoSetRequest, +} from "./proto/server/v1/cache_pb"; +import { Utility } from "./utility"; +import { TigrisClientConfig } from "./tigris"; +import { CacheKeysCursor, CacheKeysCursorInitializer } from "./consumables/cursor"; + +export class Cache { + private readonly _projectName: string; + private readonly _cacheName: string; + private readonly _cacheClient: CacheClient; + private readonly _config: TigrisClientConfig; + + constructor( + projectName: string, + cacheName: string, + cacheClient: CacheClient, + config: TigrisClientConfig + ) { + this._projectName = projectName; + this._cacheName = cacheName; + this._cacheClient = cacheClient; + this._config = config; + } + + /** + * returns cache name + */ + public getCacheName(): string { + return this._cacheName; + } + + /** + * Sets the key with value. It will override the value if already exists + * @param key - key to set + * @param value - value for the key + * @param options - optionally set params. + * @example + * ``` + * const c1 = tigris.GetCache("c1); + * const setResp = await c1.set("k1", "v1"); + * console.log(setResp.status); + * ``` + */ + public set( + key: string, + value: string | number | boolean | object, + options?: CacheSetOptions + ): Promise { + return new Promise((resolve, reject) => { + const req = new ProtoSetRequest() + .setProject(this._projectName) + .setName(this._cacheName) + .setKey(key) + .setValue(new TextEncoder().encode(Utility.objToJsonString(value as object))); + + if (options !== undefined && options.ex !== undefined) { + req.setEx(options.ex); + } + if (options !== undefined && options.px !== undefined) { + req.setPx(options.px); + } + if (options !== undefined && options.nx !== undefined) { + req.setNx(options.nx); + } + if (options !== undefined && options.xx !== undefined) { + req.setXx(options.xx); + } + + this._cacheClient.set(req, (error, response) => { + if (error) { + reject(error); + } else { + resolve(new CacheSetResponse(response.getMessage())); + } + }); + }); + } + + /** + * Sets the key with value. And returns the old value (if exists) + * + * @param key - key to set + * @param value - value for the key + * @example + * ``` + * const c1 = tigris.GetCache("c1); + * const getSetResp = await c1.getSet("k1", "v1"); + * console.log(getSetResp.old_value); + * ``` + */ + public getSet( + key: string, + value: string | number | boolean | object + ): Promise { + return new Promise((resolve, reject) => { + const req = new ProtoGetSetRequest() + .setProject(this._projectName) + .setName(this._cacheName) + .setKey(key) + .setValue(new TextEncoder().encode(Utility.objToJsonString(value as object))); + + this._cacheClient.getSet(req, (error, response) => { + if (error) { + reject(error); + } else { + if (response.getOldValue() !== undefined && response.getOldValue_asU8().length > 0) { + resolve( + new CacheGetSetResponse( + response.getMessage(), + Utility._base64DecodeToObject(response.getOldValue_asB64(), this._config) + ) + ); + } else { + resolve(new CacheGetSetResponse(response.getMessage())); + } + } + }); + }); + } + + /** + * Get the value for the key, errors if the key doesn't exist or expired + * + * @param key - key to retrieve value for + * @example + * ``` + * const c1 = tigris.GetCache("c1); + * const getResp = await c1.get("k1"); + * console.log(getResp.value); + * ``` + */ + public get(key: string): Promise { + return new Promise((resolve, reject) => { + this._cacheClient.get( + new ProtoGetRequest().setProject(this._projectName).setName(this._cacheName).setKey(key), + (error, response) => { + if (error) { + reject(error); + } else { + resolve( + new CacheGetResponse( + Utility._base64DecodeToObject(response.getValue_asB64(), this._config) + ) + ); + } + } + ); + }); + } + + /** + * Deletes a key from cache + * + * @param key - key to delete + * @example + * ``` + * const c1 = tigris.GetCache("c1); + * const delResp = await c1.del("k1"); + * console.log(delResp.status); + * ``` + */ + public del(key: string): Promise { + return new Promise((resolve, reject) => { + this._cacheClient.del( + new ProtoDelRequest().setProject(this._projectName).setName(this._cacheName).setKey(key), + (error, response) => { + if (error) { + reject(error); + } else { + resolve(new CacheDelResponse(response.getStatus(), response.getMessage())); + } + } + ); + }); + } + + /** + * returns an array of keys, complying the pattern + * @param pattern - optional argument to filter keys + * @example + * ``` + * const c1 = tigris.GetCache("c1); + * const keysCursor = await c1.keys(); + * for await (const keys of keysCursor) { + * console.log(keys); + * } + * ``` + */ + public keys(pattern?: string): CacheKeysCursor { + const req = new ProtoKeysRequest().setProject(this._projectName).setName(this._cacheName); + if (pattern !== undefined) { + req.setPattern(pattern); + } + this._cacheClient.keys(req); + const initializer = new CacheKeysCursorInitializer(this._cacheClient, req); + return new CacheKeysCursor(initializer); + } +} diff --git a/src/collection.ts b/src/collection.ts index 0f32160..c1266d8 100644 --- a/src/collection.ts +++ b/src/collection.ts @@ -8,238 +8,121 @@ import { ReplaceRequest as ProtoReplaceRequest, SearchResponse as ProtoSearchResponse, UpdateRequest as ProtoUpdateRequest, + SearchRequest as ProtoSearchRequest, + CountRequest as ProtoCountRequest, + DescribeCollectionRequest as ProtoDescribeCollectionRequest, } from "./proto/server/v1/api_pb"; import { Session } from "./session"; import { - DeleteRequestOptions, + CollectionDescription, + DeleteQuery, + DeleteQueryOptions, DeleteResponse, DMLMetadata, + ExplainResponse, Filter, - ReadFields, - ReadRequestOptions, - SelectorFilterOperator, - SimpleUpdateField, - StreamEvent, + FindQuery, + FindQueryOptions, + IndexDescription, + ReadType, TigrisCollectionType, - UpdateFields, - UpdateRequestOptions, + UpdateQuery, + UpdateQueryOptions, UpdateResponse, } from "./types"; import { Utility } from "./utility"; -import { SearchRequest, SearchRequestOptions, SearchResult } from "./search/types"; import { TigrisClientConfig } from "./tigris"; +import { MissingArgumentError } from "./error"; import { Cursor, ReadCursorInitializer } from "./consumables/cursor"; - -/** - * Callback to receive events from server - */ -export interface EventsCallback { - /** - * Receives a message from server. Can be called many times but is never called after - * {@link onError} or {@link onEnd} are called. - * - * @param event - */ - onNext(event: StreamEvent): void; - - /** - * Receives a notification of successful stream completion. - * - *

May only be called once and if called it must be the last method called. In particular, - * if an exception is thrown by an implementation of {@link onEnd} no further calls to any - * method are allowed. - */ - onEnd(): void; - - /** - * Receives terminating error from the stream. - * @param error - */ - onError(error: Error): void; -} +import { SearchIterator, SearchIteratorInitializer } from "./consumables/search-iterator"; +import { SearchQuery } from "./search"; +import { SearchResult } from "./search"; +import { DecoratorMetaStorage } from "./decorators/metadata/decorator-meta-storage"; +import { getDecoratorMetaStorage } from "./globals"; interface ICollection { readonly collectionName: string; readonly db: string; } -export abstract class ReadOnlyCollection implements ICollection { +/** + * The **Collection** class represents Tigris collection allowing insert/find/update/delete/search + * operations. + * @public + */ +export class Collection implements ICollection { readonly collectionName: string; readonly db: string; + readonly branch: string; readonly grpcClient: TigrisClient; readonly config: TigrisClientConfig; + private readonly _metadataStorage: DecoratorMetaStorage; + private readonly _collectionCreatedAtFieldNames: string[]; - protected constructor( + constructor( collectionName: string, db: string, + branch: string, grpcClient: TigrisClient, config: TigrisClientConfig ) { this.collectionName = collectionName; this.db = db; + this.branch = branch; this.grpcClient = grpcClient; + this._metadataStorage = getDecoratorMetaStorage(); this.config = config; - } - /** - * Performs a read query on collection and returns a cursor that can be used to iterate over - * query results. - * - * @param filter - Optional filter. If unspecified, then all documents will match the filter - * @param readFields - Optional field projection param allows returning only specific document fields in result - * @param tx - Optional session information for transaction context - * @param options - Optional settings for the find query - */ - findMany( - filter?: Filter, - readFields?: ReadFields, - tx?: Session, - options?: ReadRequestOptions - ): Cursor { - // find all - if (filter === undefined) { - filter = { op: SelectorFilterOperator.NONE }; - } - - const readRequest = new ProtoReadRequest() - .setDb(this.db) - .setCollection(this.collectionName) - .setFilter(Utility.stringToUint8Array(Utility.filterToString(filter))); - - if (readFields) { - readRequest.setFields(Utility.stringToUint8Array(Utility.readFieldString(readFields))); - } - - if (options !== undefined) { - readRequest.setOptions(Utility._readRequestOptionsToProtoReadRequestOptions(options)); - } - - const initializer = new ReadCursorInitializer(this.grpcClient, readRequest, tx); - return new Cursor(initializer, this.config); - } - - /** - * Performs a query to find a single document in collection. Returns the document if found, else - * null. - * - * @param filter - Query to match the document - * @param readFields - Optional field projection param allows returning only specific document fields in result - * @param tx - Optional session information for transaction context - * @param options - Optional settings for the find query - */ - findOne( - filter: Filter, - readFields?: ReadFields, - tx?: Session, - options?: ReadRequestOptions - ): Promise { - return new Promise((resolve, reject) => { - if (options === undefined) { - options = new ReadRequestOptions(1); - } else { - options.limit = 1; - } - - const cursor = this.findMany(filter, readFields, tx, options); - const iteratorResult = cursor[Symbol.asyncIterator]().next(); - if (iteratorResult !== undefined) { - iteratorResult - .then( - (r) => resolve(r.value), - (error) => reject(error) - ) - .catch(reject); - } else { - /* eslint unicorn/no-useless-undefined: ["error", {"checkArguments": false}]*/ - resolve(undefined); - } - }); + this._collectionCreatedAtFieldNames = ((): string[] => { + const collectionTarget = this._metadataStorage.collections.get(this.collectionName)?.target; + const collectionFields = this._metadataStorage.getCollectionFieldsByTarget(collectionTarget); + return collectionFields + .filter((field) => { + return field?.schemaFieldOptions?.timestamp === "createdAt"; + }) + .map((f) => f.name); + })(); } - /** - * Search for documents in a collection. Easily perform sophisticated queries and refine - * results using filters with advanced features like faceting and ordering. - * - * @param request - Search query to execute - * @param options - Optional settings for search - */ - search(request: SearchRequest, options?: SearchRequestOptions): Promise> { - return new Promise>((resolve, reject) => { - const searchRequest = Utility.createProtoSearchRequest( - this.db, - this.collectionName, - request, - // note: explicit page number is required to signal manual pagination - Utility.createSearchRequestOptions(options) - ); - const stream: grpc.ClientReadableStream = - this.grpcClient.search(searchRequest); + describe(): Promise { + return new Promise((resolve, reject) => { + const req = new ProtoDescribeCollectionRequest() + .setProject(this.db) + .setBranch(this.branch) + .setCollection(this.collectionName); - stream.on("data", (searchResponse: ProtoSearchResponse) => { - const searchResult: SearchResult = SearchResult.from(searchResponse, this.config); - resolve(searchResult); + this.grpcClient.describeCollection(req, (error, resp) => { + if (error) { + return reject(error); + } + const schema = Buffer.from(resp.getSchema_asB64(), "base64").toString(); + const desc = new CollectionDescription( + this.collectionName, + resp.getMetadata(), + schema, + resp.toObject().indexesList as IndexDescription[] + ); + + resolve(desc); }); - stream.on("error", (error) => reject(error)); - stream.on("end", () => resolve(SearchResult.empty)); }); } - /** - * Search for documents in a collection. Easily perform sophisticated queries and refine - * results using filters with advanced features like faceting and ordering. - * - * @param request - Search query to execute - * @param options - Optional settings for search - */ - async *searchStream( - request: SearchRequest, - options?: SearchRequestOptions - ): AsyncIterableIterator> { - const searchRequest = Utility.createProtoSearchRequest( - this.db, - this.collectionName, - request, - options - ); - const stream: grpc.ClientReadableStream = - this.grpcClient.search(searchRequest); - - for await (const searchResponse of stream) { - const searchResult: SearchResult = SearchResult.from(searchResponse, this.config); - yield searchResult; - } - return; - } -} - -/** - * The **Collection** class represents Tigris collection allowing insert/find/update/delete/search - * and events operations. - */ -export class Collection extends ReadOnlyCollection { - constructor( - collectionName: string, - db: string, - grpcClient: TigrisClient, - config: TigrisClientConfig - ) { - super(collectionName, db, grpcClient, config); - } - /** * Inserts multiple documents in Tigris collection. * * @param docs - Array of documents to insert - * @param tx - Optional session information for transaction context + * @param tx - Session information for transaction context */ insertMany(docs: Array, tx?: Session): Promise> { + const encoder = new TextEncoder(); return new Promise>((resolve, reject) => { - const docsArray = new Array(); - for (const doc of docs) { - docsArray.push(new TextEncoder().encode(Utility.objToJsonString(doc))); - } + const docsArray: Array = docs.map((doc) => + encoder.encode(Utility.objToJsonString(doc)) + ); const protoRequest = new ProtoInsertRequest() - .setDb(this.db) + .setProject(this.db) + .setBranch(this.branch) .setCollection(this.collectionName) .setDocumentsList(docsArray); @@ -247,21 +130,20 @@ export class Collection extends ReadOnlyCollecti protoRequest, Utility.txToMetadata(tx), (error: grpc.ServiceError, response: server_v1_api_pb.InsertResponse): void => { - if (error !== undefined && error !== null) { + if (error) { reject(error); } else { - let docIndex = 0; - const clonedDocs: T[] = Object.assign([], docs); - - for (const value of response.getKeysList_asU8()) { - const keyValueJsonObj: object = Utility.jsonStringToObj( - Utility.uint8ArrayToString(value), - this.config + let clonedDocs: Array; + clonedDocs = this.setDocsMetadata(docs, response.getKeysList_asU8()); + if (response.getMetadata().hasCreatedAt()) { + const createdAt = new Date( + response.getMetadata()?.getCreatedAt()?.getSeconds() * 1000 + ); + clonedDocs = this.setCreatedAtForDocsIfNotExists( + clonedDocs, + createdAt, + this._collectionCreatedAtFieldNames ); - for (const fieldName of Object.keys(keyValueJsonObj)) { - Reflect.set(clonedDocs[docIndex], fieldName, keyValueJsonObj[fieldName]); - docIndex++; - } } resolve(clonedDocs); } @@ -274,12 +156,11 @@ export class Collection extends ReadOnlyCollecti * Inserts a single document in Tigris collection. * * @param doc - Document to insert - * @param tx - Optional session information for transaction context + * @param tx - Session information for transaction context */ insertOne(doc: T, tx?: Session): Promise { return new Promise((resolve, reject) => { - const docArr: Array = new Array(); - docArr.push(doc); + const docArr: Array = [doc]; this.insertMany(docArr, tx) .then((docs) => { resolve(docs[0]); @@ -294,16 +175,16 @@ export class Collection extends ReadOnlyCollecti * Insert new or replace existing documents in collection. * * @param docs - Array of documents to insert or replace - * @param tx - Optional session information for transaction context + * @param tx - Session information for transaction context */ insertOrReplaceMany(docs: Array, tx?: Session): Promise> { return new Promise>((resolve, reject) => { - const docsArray = new Array(); - for (const doc of docs) { - docsArray.push(new TextEncoder().encode(Utility.objToJsonString(doc))); - } + const docsArray: Array = docs.map((doc) => + new TextEncoder().encode(Utility.objToJsonString(doc)) + ); const protoRequest = new ProtoReplaceRequest() - .setDb(this.db) + .setProject(this.db) + .setBranch(this.branch) .setCollection(this.collectionName) .setDocumentsList(docsArray); @@ -311,21 +192,10 @@ export class Collection extends ReadOnlyCollecti protoRequest, Utility.txToMetadata(tx), (error: grpc.ServiceError, response: server_v1_api_pb.ReplaceResponse): void => { - if (error !== undefined && error !== null) { + if (error) { reject(error); } else { - let docIndex = 0; - const clonedDocs: T[] = Object.assign([], docs); - for (const value of response.getKeysList_asU8()) { - const keyValueJsonObj: object = Utility.jsonStringToObj( - Utility.uint8ArrayToString(value), - this.config - ); - for (const fieldName of Object.keys(keyValueJsonObj)) { - Reflect.set(clonedDocs[docIndex], fieldName, keyValueJsonObj[fieldName]); - docIndex++; - } - } + const clonedDocs = this.setDocsMetadata(docs, response.getKeysList_asU8()); resolve(clonedDocs); } } @@ -337,44 +207,74 @@ export class Collection extends ReadOnlyCollecti * Insert new or replace an existing document in collection. * * @param doc - Document to insert or replace - * @param tx - Optional session information for transaction context + * @param tx - Session information for transaction context */ - insertOrReplaceOne(doc: T, tx?: Session): Promise { - return new Promise((resolve, reject) => { - const docArr: Array = new Array(); - docArr.push(doc); - this.insertOrReplaceMany(docArr, tx) - .then((docs) => resolve(docs[0])) - .catch((error) => reject(error)); - }); + async insertOrReplaceOne(doc: T, tx?: Session): Promise { + const docs = await this.insertOrReplaceMany([doc], tx); + return docs[0]; } /** - * Deletes documents in collection matching the filter + * Update multiple documents in a collection + * + * @param query - Filter to match documents and the update operations. Update + * will be applied to matching documents only. + * @returns {@link UpdateResponse} * - * @param filter - Query to match documents to delete - * @param tx - Optional session information for transaction context - * @param options - Optional settings for delete + * @example To update **language** of all books published by "Marcel Proust" + * ``` + * const updatePromise = db.getCollection(Book).updateMany({ + * filter: { author: "Marcel Proust" }, + * fields: { language: "French" } + * }); + * + * updatePromise + * .then((resp: UpdateResponse) => console.log(resp)); + * .catch( // catch the error) + * .finally( // finally do something); + * ``` */ - deleteMany( - filter: Filter, - tx?: Session, - options?: DeleteRequestOptions - ): Promise { - return new Promise((resolve, reject) => { - if (!filter) { - reject(new Error("No filter specified")); - } - const deleteRequest = new ProtoDeleteRequest() - .setDb(this.db) + updateMany(query: UpdateQuery): Promise; + + /** + * Update multiple documents in a collection in transactional context + * + * @param query - Filter to match documents and the update operations. Update + * will be applied to matching documents only. + * @param tx - Session information for transaction context + * @returns {@link UpdateResponse} + * + * @example To update **language** of all books published by "Marcel Proust" + * ``` + * const updatePromise = db.getCollection(Book).updateMany({ + * filter: { author: "Marcel Proust" }, + * fields: { language: "French" } + * }, tx); + * + * updatePromise + * .then((resp: UpdateResponse) => console.log(resp)); + * .catch( // catch the error) + * .finally( // finally do something); + * ``` + */ + updateMany(query: UpdateQuery, tx: Session): Promise; + + updateMany(query: UpdateQuery, tx?: Session): Promise { + return new Promise((resolve, reject) => { + const updateRequest = new ProtoUpdateRequest() + .setProject(this.db) + .setBranch(this.branch) .setCollection(this.collectionName) - .setFilter(Utility.stringToUint8Array(Utility.filterToString(filter))); + .setFilter(Utility.stringToUint8Array(Utility.filterToString(query.filter))) + .setFields(Utility.stringToUint8Array(Utility.updateFieldsString(query.fields))); - if (options !== undefined) { - deleteRequest.setOptions(Utility._deleteRequestOptionsToProtoDeleteRequestOptions(options)); + if (query.options !== undefined) { + updateRequest.setOptions( + Utility._updateRequestOptionsToProtoUpdateRequestOptions(query.options) + ); } - this.grpcClient.delete(deleteRequest, Utility.txToMetadata(tx), (error, response) => { + this.grpcClient.update(updateRequest, Utility.txToMetadata(tx), (error, response) => { if (error) { reject(error); } else { @@ -382,59 +282,127 @@ export class Collection extends ReadOnlyCollecti response.getMetadata().getCreatedAt(), response.getMetadata().getUpdatedAt() ); - resolve(new DeleteResponse(response.getStatus(), metadata)); + resolve(new UpdateResponse(response.getModifiedCount(), metadata)); } }); }); } /** - * Deletes a single document in collection matching the filter + * Update a single document in collection + * + * @param query - Filter to match the document and the update operations. Update + * will be applied to matching documents only. + * @returns {@link UpdateResponse} * - * @param filter - Query to match documents to delete - * @param tx - Optional session information for transaction context - * @param options - Optional settings for delete + * @example To update **language** of a book published by "Marcel Proust" + * ``` + * const updatePromise = db.getCollection(Book).updateOne({ + * filter: { author: "Marcel Proust" }, + * fields: { language: "French" } + * }); + * + * updatePromise + * .then((resp: UpdateResponse) => console.log(resp)); + * .catch( // catch the error) + * .finally( // finally do something); + * ``` */ - deleteOne( - filter: Filter, - tx?: Session, - options?: DeleteRequestOptions - ): Promise { - if (options === undefined) { - options = new DeleteRequestOptions(1); + updateOne(query: UpdateQuery): Promise; + + /** + * Update a single document in a collection in transactional context + * + * @param query - Filter to match the document and update operations. Update + * will be applied to a single matching document only. + * @param tx - Session information for transaction context + * @returns {@link UpdateResponse} + * + * @example To update **language** of a book published by "Marcel Proust" + * ``` + * const updatePromise = db.getCollection(Book).updateOne({ + * filter: { author: "Marcel Proust" }, + * fields: { language: "French" } + * }, tx); + * + * updatePromise + * .then((resp: UpdateResponse) => console.log(resp)); + * .catch( // catch the error) + * .finally( // finally do something); + * ``` + */ + updateOne(query: UpdateQuery, tx: Session): Promise; + + updateOne(query: UpdateQuery, tx?: Session): Promise { + if (query.options === undefined) { + query.options = new UpdateQueryOptions(1); } else { - options.limit = 1; + query.options.limit = 1; } - - return this.deleteMany(filter, tx, options); + return this.updateMany(query, tx); } /** - * Update multiple documents in collection + * Delete documents from collection matching the query + * + * @param query - Filter to match documents and other deletion options + * @returns {@link DeleteResponse} + * + * @example * - * @param filter - Query to match documents to apply update - * @param fields - Document fields to update and update operation - * @param tx - Optional session information for transaction context - * @param options - Optional settings for search + * ``` + * const deletionPromise = db.getCollection(Book).deleteMany({ + * filter: { author: "Marcel Proust" } + * }); + * + * deletionPromise + * .then((resp: DeleteResponse) => console.log(resp)); + * .catch( // catch the error) + * .finally( // finally do something); + * ``` */ - updateMany( - filter: Filter, - fields: UpdateFields | SimpleUpdateField, - tx?: Session, - options?: UpdateRequestOptions - ): Promise { - return new Promise((resolve, reject) => { - const updateRequest = new ProtoUpdateRequest() - .setDb(this.db) + deleteMany(query: DeleteQuery): Promise; + + /** + * Delete documents from collection in transactional context + * + * @param query - Filter to match documents and other deletion options + * @param tx - Session information for transaction context + * @returns {@link DeleteResponse} + * + * @example + * + * ``` + * const deletionPromise = db.getCollection(Book).deleteMany({ + * filter: { author: "Marcel Proust" } + * }, tx); + * + * deletionPromise + * .then((resp: DeleteResponse) => console.log(resp)); + * .catch( // catch the error) + * .finally( // finally do something); + * ``` + */ + deleteMany(query: DeleteQuery, tx: Session): Promise; + + deleteMany(query: DeleteQuery, tx?: Session): Promise { + return new Promise((resolve, reject) => { + if (typeof query?.filter === "undefined") { + reject(new MissingArgumentError("filter")); + } + const deleteRequest = new ProtoDeleteRequest() + .setProject(this.db) + .setBranch(this.branch) .setCollection(this.collectionName) - .setFilter(Utility.stringToUint8Array(Utility.filterToString(filter))) - .setFields(Utility.stringToUint8Array(Utility.updateFieldsString(fields))); + .setFilter(Utility.stringToUint8Array(Utility.filterToString(query.filter))); - if (options !== undefined) { - updateRequest.setOptions(Utility._updateRequestOptionsToProtoUpdateRequestOptions(options)); + if (query.options) { + deleteRequest.setOptions( + Utility._deleteRequestOptionsToProtoDeleteRequestOptions(query.options) + ); } - this.grpcClient.update(updateRequest, Utility.txToMetadata(tx), (error, response) => { + this.grpcClient.delete(deleteRequest, Utility.txToMetadata(tx), (error, response) => { if (error) { reject(error); } else { @@ -442,31 +410,464 @@ export class Collection extends ReadOnlyCollecti response.getMetadata().getCreatedAt(), response.getMetadata().getUpdatedAt() ); - resolve(new UpdateResponse(response.getStatus(), response.getModifiedCount(), metadata)); + resolve(new DeleteResponse(metadata)); + } + }); + }); + } + + /** + * Delete a single document from collection matching the query + * + * @param query - Filter to match documents and other deletion options + * @returns {@link DeleteResponse} + * + * @example + * + * ``` + * const deletionPromise = db.getCollection(Book).deleteOne({ + * filter: { author: "Marcel Proust" } + * }); + * + * deletionPromise + * .then((resp: DeleteResponse) => console.log(resp)); + * .catch( // catch the error) + * .finally( // finally do something); + * ``` + */ + deleteOne(query: DeleteQuery): Promise; + + /** + * Delete a single document from collection in transactional context + * + * @param query - Filter to match documents and other deletion options + * @param tx - Session information for transaction context + * @returns {@link DeleteResponse} + * + * @example + * + * ``` + * const deletionPromise = db.getCollection(Book).deleteOne({ + * filter: { author: "Marcel Proust" } + * }, tx); + * + * deletionPromise + * .then((resp: DeleteResponse) => console.log(resp)); + * .catch( // catch the error) + * .finally( // finally do something); + * ``` + */ + deleteOne(query: DeleteQuery, tx: Session): Promise; + + deleteOne(query: DeleteQuery, tx?: Session): Promise { + if (query.options === undefined) { + query.options = new DeleteQueryOptions(1); + } else { + query.options.limit = 1; + } + + return this.deleteMany(query, tx); + } + + /** + * Read all the documents from a collection. + * + * @returns - {@link Cursor} to iterate over documents + * + * @example + * ``` + * const cursor = db.getCollection(Book).findMany(); + * + * for await (const document of cursor) { + * console.log(document); + * } + * ``` + */ + findMany(): Cursor; + + /** + * Reads all the documents from a collection in transactional context. + * + * @param tx - Session information for Transaction + * @returns - {@link Cursor} to iterate over documents + * + * @example + * ``` + * const cursor = db.getCollection(Book).findMany(tx); + * + * for await (const document of cursor) { + * console.log(document); + * } + * ``` + */ + findMany(tx: Session): Cursor; + + /** + * Performs a read query on collection and returns a cursor that can be used to iterate over + * query results. + * + * @param query - Filter, field projection and other parameters + * @returns - {@link Cursor} to iterate over documents + * + * @example + * ``` + * const cursor = db.getCollection(Book).findMany({ + * filter: { author: "Marcel Proust" }, + * readFields: { include: ["id", "title"] } + * }); + * + * for await (const document of cursor) { + * console.log(document); + * } + * ``` + */ + findMany(query: FindQuery): Cursor; + + /** + * Performs a read query on collection in transactional context and returns a + * cursor that can be used to iterate over query results. + * + * @param query - Filter, field projection and other parameters + * @param tx - Session information for Transaction + * @returns - {@link Cursor} to iterate over documents + * + * @example + * ``` + * const cursor = db.getCollection(Book).findMany({ + * filter: { author: "Marcel Proust" }, + * readFields: { include: ["id", "title"] } + * }, tx); + * + * for await (const document of cursor) { + * console.log(document); + * } + * ``` + */ + findMany(query: FindQuery, tx: Session): Cursor; + + findMany(txOrQuery?: Session | FindQuery, tx?: Session): Cursor { + let query: FindQuery; + if (typeof txOrQuery !== "undefined") { + if (this.isTxSession(txOrQuery)) { + tx = txOrQuery as Session; + } else { + query = txOrQuery as FindQuery; + } + } + + const findAll: Filter = {}; + + if (!query) { + query = { filter: findAll }; + } else if (!query.filter) { + query.filter = findAll; + } + const readRequest = new ProtoReadRequest() + .setProject(this.db) + .setBranch(this.branch) + .setCollection(this.collectionName) + .setFilter(Utility.stringToUint8Array(Utility.filterToString(query.filter))); + + if (query.readFields) { + readRequest.setFields( + Utility.stringToUint8Array(Utility.readFieldString(query.readFields)) + ); + } + + if (query.sort) { + readRequest.setSort(Utility.stringToUint8Array(Utility._sortOrderingToString(query.sort))); + } + + if (query.options) { + readRequest.setOptions(Utility._readRequestOptionsToProtoReadRequestOptions(query.options)); + } + + const initializer = new ReadCursorInitializer(this.grpcClient, readRequest, tx); + return new Cursor(initializer, this.config); + } + + /** + * Returns a explain response on how Tigris would process a query + * + * @returns - The explain response + * + * @example + * ``` + * const explain = await db.getCollection(Book).explain({"author": "Brandon Sanderson"}); + * console.log(`Read Type: ${explain.readType}, Key Ranges: ${explain.KeyRange}, field: ${explain.field}`) + * + * ``` + */ + explain(query: FindQuery): Promise { + const readRequest = new ProtoReadRequest() + .setProject(this.db) + .setBranch(this.branch) + .setCollection(this.collectionName) + .setFilter(Utility.stringToUint8Array(Utility.filterToString(query.filter))); + return new Promise((resolve, reject) => { + this.grpcClient.explain(readRequest, (err, resp) => { + if (err) { + return reject(err); + } + + const explainResp = resp.toObject(); + explainResp.readType = + resp.getReadType() === "secondary index" + ? ("secondary index" as ReadType) + : ("primary index" as ReadType); + + resolve(explainResp as ExplainResponse); + }); + }); + } + + /** + * Count the number of documents in a collection + * @returns - the number of documents in a collection + * + * @example + * ``` + * const countPromise = db.getCollection(Book).count(); + * + * countPromise + * .then(count: number) => console.log(count); + * .catch( // catch the error) + * .finally( // finally do something) + * ``` + */ + count(filter?: Filter): Promise { + if (!filter) { + filter = {}; + } + const countRequest = new ProtoCountRequest() + .setProject(this.db) + .setCollection(this.collectionName) + .setBranch(this.branch) + .setFilter(Utility.stringToUint8Array(Utility.filterToString(filter))); + + return new Promise((resolve, reject) => { + this.grpcClient.count(countRequest, (err, response) => { + if (err) { + return reject(err); } + resolve(response.getCount()); }); }); } /** - * Updates a single document in collection + * Read a single document from collection. + * + * @returns - The document if found else **undefined** * - * @param filter - Query to match document to apply update - * @param fields - Document fields to update and update operation - * @param tx - Optional session information for transaction context - * @param options - Optional settings for search + * @example + * ``` + * const documentPromise = db.getCollection(Book).findOne(); + * + * documentPromise + * .then((doc: Book | undefined) => console.log(doc)); + * .catch( // catch the error) + * .finally( // finally do something); + * ``` */ - updateOne( - filter: Filter, - fields: UpdateFields | SimpleUpdateField, - tx?: Session, - options?: UpdateRequestOptions - ): Promise { - if (options === undefined) { - options = new UpdateRequestOptions(1); + findOne(): Promise; + + /** + * Read a single document from collection in transactional context + * + * @param tx - Session information for Transaction + * @returns - The document if found else **undefined** + * + * @example + * ``` + * const documentPromise = db.getCollection(Book).findOne(tx); + * + * documentPromise + * .then((doc: Book | undefined) => console.log(doc)); + * .catch( // catch the error) + * .finally( // finally do something); + * ``` + */ + findOne(tx: Session): Promise; + + /** + * Performs a read query on the collection and returns a single document matching + * the query. + * + * @param query - Filter, field projection and other parameters + * @returns - The document if found else **undefined** + * + * @example + * ``` + * const documentPromise = db.getCollection(Book).findOne({ + * filter: { author: "Marcel Proust" }, + * readFields: { include: ["id", "title"] } + * }); + * + * documentPromise + * .then((doc: Book | undefined) => console.log(doc)); + * .catch( // catch the error) + * .finally( // finally do something); + * ``` + */ + findOne(query: FindQuery): Promise; + + /** + * Performs a read query on the collection in transactional context and returns + * a single document matching the query. + * + * @param query - Filter, field projection and other parameters + * @param tx - Session information for Transaction + * @returns - The document if found else **undefined** + * + * @example + * ``` + * const documentPromise = db.getCollection(Book).findOne({ + * filter: { author: "Marcel Proust" }, + * readFields: { include: ["id", "title"] } + * }, tx); + * + * documentPromise + * .then((doc: Book | undefined) => console.log(doc)); + * .catch( // catch the error) + * .finally( // finally do something); + * ``` + */ + findOne(query: FindQuery, tx: Session): Promise; + + async findOne(txOrQuery?: Session | FindQuery, tx?: Session): Promise { + let query: FindQuery; + if (typeof txOrQuery !== "undefined") { + if (this.isTxSession(txOrQuery)) { + tx = txOrQuery as Session; + } else { + query = txOrQuery as FindQuery; + } + } + + const findOnlyOne: FindQueryOptions = new FindQueryOptions(1); + + if (!query) { + query = { options: findOnlyOne }; + } else if (!query.options) { + query.options = findOnlyOne; } else { - options.limit = 1; + query.options.limit = findOnlyOne.limit; } - return this.updateMany(filter, fields, tx, options); + + const cursor = this.findMany(query, tx); + const iteratorResult = await cursor[Symbol.asyncIterator]().next(); + + return iteratorResult?.value; + } + + /** + * Search for documents in a collection. Easily perform sophisticated queries and refine + * results using filters with advanced features like faceting and ordering. + * + * @param query - Search query to execute + * @returns {@link SearchIterator} - To iterate over pages of {@link SearchResult} + * + * @example + * ``` + * const iterator = db.getCollection(Book).search(query); + * + * for await (const resultPage of iterator) { + * console.log(resultPage.hits); + * console.log(resultPage.facets); + * } + * ``` + */ + search(query: SearchQuery): SearchIterator; + + /** + * Search for documents in a collection. Easily perform sophisticated queries and refine + * results using filters with advanced features like faceting and ordering. + * + * @param query - Search query to execute + * @param page - Page number to retrieve. Page number `1` fetches the first page of search results. + * @returns - Single page of results wrapped in a Promise + * + * @example To retrieve page number 5 of matched documents + * ``` + * const resultPromise = db.getCollection(Book).search(query, 5); + * + * resultPromise + * .then((res: SearchResult) => console.log(res.hits)) + * .catch( // catch the error) + * .finally( // finally do something); + * + * ``` + */ + search(query: SearchQuery, page: number): Promise>; + + search(query: SearchQuery, page?: number): SearchIterator | Promise> { + const searchRequest = new ProtoSearchRequest() + .setProject(this.db) + .setBranch(this.branch) + .setCollection(this.collectionName); + + Utility.protoSearchRequestFromQuery(query, searchRequest, page); + + // return a iterator if no explicit page number is specified + if (typeof page === "undefined") { + const initializer = new SearchIteratorInitializer(this.grpcClient, searchRequest); + return new SearchIterator(initializer, this.config); + } else { + return new Promise>((resolve, reject) => { + const stream: grpc.ClientReadableStream = + this.grpcClient.search(searchRequest); + + stream.on("data", (searchResponse: ProtoSearchResponse) => { + const searchResult: SearchResult = SearchResult.from(searchResponse, this.config); + resolve(searchResult); + }); + stream.on("error", (error) => reject(error)); + stream.on("end", () => resolve(SearchResult.empty)); + }); + } + } + + private isTxSession(txOrQuery: Session | unknown): txOrQuery is Session { + const mayBeTx = txOrQuery as Session; + return "id" in mayBeTx && mayBeTx instanceof Session; + } + + private setDocsMetadata(docs: Array, keys: Array): Array { + let docIndex = 0; + const clonedDocs: T[] = Object.assign([], docs); + + for (const value of keys) { + const keyValueJsonObj: object = Utility.jsonStringToObj( + Utility.uint8ArrayToString(value), + this.config + ); + for (const fieldName of Object.keys(keyValueJsonObj)) { + Reflect.set(clonedDocs[docIndex], fieldName, keyValueJsonObj[fieldName]); + } + docIndex++; + } + + return clonedDocs; + } + + private setCreatedAtForDocsIfNotExists( + docs: Array, + createdAt: Date, + collectionCreatedAtFieldNames: string[] + ): Array { + const clonedDocs: T[] = Object.assign([], docs); + let docIndex = 0; + + for (const doc of docs) { + collectionCreatedAtFieldNames.map((fieldName) => { + if (!Reflect.has(doc, fieldName)) { + Reflect.set(clonedDocs[docIndex], fieldName, createdAt); + } + }); + docIndex++; + } + + return clonedDocs; } } diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..706b927 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,8 @@ +export enum Status { + Created = "created", + Updated = "updated", + Deleted = "deleted", + Dropped = "dropped", + Ok = "ok", + Set = "set", +} diff --git a/src/consumables/cursor.ts b/src/consumables/cursor.ts index b878a66..55c8b2c 100644 --- a/src/consumables/cursor.ts +++ b/src/consumables/cursor.ts @@ -1,10 +1,12 @@ -import { AbstractCursor, Initializer } from "./abstract-cursor"; +import { IterableStream, Initializer } from "./iterable-stream"; import { ReadRequest, ReadResponse } from "../proto/server/v1/api_pb"; import { TigrisClient } from "../proto/server/v1/api_grpc_pb"; import { Session } from "../session"; import { Utility } from "../utility"; import { ClientReadableStream } from "@grpc/grpc-js"; import { TigrisClientConfig } from "../tigris"; +import { KeysRequest, KeysResponse } from "../proto/server/v1/cache_pb"; +import { CacheClient } from "../proto/server/v1/cache_grpc_pb"; /** @internal */ export class ReadCursorInitializer implements Initializer { @@ -23,10 +25,25 @@ export class ReadCursorInitializer implements Initializer { } } +/** @internal */ +export class CacheKeysCursorInitializer implements Initializer { + private readonly _client: CacheClient; + private readonly _request: KeysRequest; + + constructor(client: CacheClient, request: KeysRequest) { + this._client = client; + this._request = request; + } + + init(): ClientReadableStream { + return this._client.keys(this._request); + } +} + /** * Cursor to supplement find() queries */ -export class Cursor extends AbstractCursor { +export class Cursor extends IterableStream { /** @internal */ private readonly _config: TigrisClientConfig; @@ -36,7 +53,23 @@ export class Cursor extends AbstractCursor { } /** @override */ - _transform(message: ReadResponse): T { + protected _transform(message: ReadResponse): T { return Utility.jsonStringToObj(Utility._base64Decode(message.getData_asB64()), this._config); } } + +/** + * Cursor to supplement keys() call for cache + */ +export class CacheKeysCursor extends IterableStream { + /** @internal */ + + constructor(initializer: CacheKeysCursorInitializer) { + super(initializer); + } + + /** @override */ + protected _transform(message: KeysResponse): string[] { + return message.getKeysList(); + } +} diff --git a/src/consumables/abstract-cursor.ts b/src/consumables/iterable-stream.ts similarity index 87% rename from src/consumables/abstract-cursor.ts rename to src/consumables/iterable-stream.ts index 868bc32..fbdef9b 100644 --- a/src/consumables/abstract-cursor.ts +++ b/src/consumables/iterable-stream.ts @@ -1,7 +1,7 @@ import * as proto from "google-protobuf"; import { ClientReadableStream } from "@grpc/grpc-js"; -import { TigrisCursorInUseError } from "../error"; -import { Readable } from "node:stream"; +import { CursorInUseError } from "../error"; +import { Readable } from "stream"; /** @internal */ export interface Initializer { @@ -15,7 +15,7 @@ const tReady = Symbol("ready"); /** @internal */ const tClosed = Symbol("closed"); -export abstract class AbstractCursor { +export abstract class IterableStream { /** @internal */ [tStream]: ClientReadableStream; /** @internal */ @@ -36,7 +36,7 @@ export abstract class AbstractCursor { /** @internal */ private _assertNotInUse() { if (this[tClosed]) { - throw new TigrisCursorInUseError(); + throw new CursorInUseError(); } this[tClosed] = true; } @@ -53,14 +53,16 @@ export abstract class AbstractCursor { /** * Returns a {@link Readable} stream of documents to iterate on * - * Usage: + * @example + * ``` * const cursor = myCollection.find(); * for await (const doc of cursor.stream()) { * console.log(doc); * } + *``` * * @throws {@link TigrisCursorInUseError} - if cursor is being consumed or has been consumed. - * @see {@link reset()} to re-use a cursor. + * @see {@link reset} to re-use a cursor. */ stream(): Readable { return Readable.from(this.next()); @@ -69,14 +71,16 @@ export abstract class AbstractCursor { /** * Returns an async iterator to iterate on documents * - * Usage: + * @example + * ``` * const cursor = myCollection.find(); * for await (const doc of cursor) { * console.log(doc); * } + *``` * * @throws {@link TigrisCursorInUseError} - if cursor is being consumed or has been consumed. - * @see {@link reset()} to re-use a cursor. + * @see {@link reset} to re-use a cursor. */ [Symbol.asyncIterator](): AsyncIterableIterator { return this.next()[Symbol.asyncIterator](); @@ -87,7 +91,7 @@ export abstract class AbstractCursor { * is enough memory to store the results. * * @throws {@link TigrisCursorInUseError} - if cursor is being consumed or has been consumed. - * @see {@link reset()} to re-use a cursor. + * @see {@link reset} to re-use a cursor. */ toArray(): Promise> { this._assertNotInUse(); @@ -117,7 +121,7 @@ export abstract class AbstractCursor { * This essentially sends a new query to server and allows the cursor to be re-used. A new * query to server is sent even if this cursor is not yet consumed. * - * Note: A cursor may yield different results after reset() + * Note: A cursor may yield different results after `reset()` */ reset(): void { this[tClosed] = false; diff --git a/src/consumables/search-iterator.ts b/src/consumables/search-iterator.ts new file mode 100644 index 0000000..af01179 --- /dev/null +++ b/src/consumables/search-iterator.ts @@ -0,0 +1,68 @@ +import { Initializer, IterableStream } from "./iterable-stream"; +import { + SearchRequest as ProtoSearchRequest, + SearchResponse as ProtoSearchResponse, +} from "../proto/server/v1/api_pb"; + +import { TigrisClient } from "../proto/server/v1/api_grpc_pb"; +import { ClientReadableStream } from "@grpc/grpc-js"; +import { TigrisClientConfig } from "../tigris"; +import { + SearchIndexResponse as ProtoSearchIndexResponse, + SearchIndexRequest as ProtoSearchIndexRequest, +} from "../proto/server/v1/search_pb"; +import { SearchClient } from "../proto/server/v1/search_grpc_pb"; +import { SearchResult } from "../search"; + +/** @internal */ +export class SearchIteratorInitializer implements Initializer { + private readonly _client: TigrisClient; + private readonly _request: ProtoSearchRequest; + + constructor(client: TigrisClient, request: ProtoSearchRequest) { + this._client = client; + this._request = request; + } + + init(): ClientReadableStream { + return this._client.search(this._request); + } +} + +/** @internal */ +export class SearchIndexIteratorInitializer implements Initializer { + private readonly _client: SearchClient; + private readonly _request: ProtoSearchIndexRequest; + + constructor(client: SearchClient, request: ProtoSearchIndexRequest) { + this._client = client; + this._request = request; + } + init(): ClientReadableStream { + return this._client.search(this._request); + } +} + +/** + * Iterator to supplement search() queries + */ +export class SearchIterator extends IterableStream< + SearchResult, + ProtoSearchResponse | ProtoSearchIndexResponse +> { + /** @internal */ + private readonly _config: TigrisClientConfig; + + constructor( + initializer: SearchIteratorInitializer | SearchIndexIteratorInitializer, + config: TigrisClientConfig + ) { + super(initializer); + this._config = config; + } + + /** @override */ + protected _transform(message: ProtoSearchResponse | ProtoSearchIndexResponse): SearchResult { + return SearchResult.from(message, this._config); + } +} diff --git a/src/consumables/utils.ts b/src/consumables/utils.ts deleted file mode 100644 index deea24f..0000000 --- a/src/consumables/utils.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { ClientReadableStream } from "@grpc/grpc-js"; -import { Readable } from "node:stream"; - -function _next( - stream: ClientReadableStream, - transform: (arg: TResp) => T -): AsyncIterableIterator { - const iter: () => AsyncIterableIterator = async function* () { - for await (const message of stream) { - yield transform(message); - } - return; - }; - - return iter(); -} - -// Utility to convert grpc response streams to Readable streams -export function clientReadableToStream( - stream: ClientReadableStream, - transform: (arg: TResp) => T -): Readable { - return Readable.from(_next(stream, transform)); -} diff --git a/src/db.ts b/src/db.ts index 7ebe56f..3436410 100644 --- a/src/db.ts +++ b/src/db.ts @@ -4,10 +4,11 @@ import { CollectionInfo, CollectionMetadata, CollectionOptions, - CollectionType, CommitTransactionResponse, + CreateBranchResponse, DatabaseDescription, DatabaseMetadata, + DeleteBranchResponse, DropCollectionResponse, TigrisCollectionType, TigrisSchema, @@ -18,7 +19,9 @@ import { BeginTransactionRequest as ProtoBeginTransactionRequest, BeginTransactionResponse, CollectionOptions as ProtoCollectionOptions, + CreateBranchRequest as ProtoCreateBranchRequest, CreateOrUpdateCollectionRequest as ProtoCreateOrUpdateCollectionRequest, + DeleteBranchRequest as ProtoDeleteBranchRequest, DescribeDatabaseRequest as ProtoDescribeDatabaseRequest, DropCollectionRequest as ProtoDropCollectionRequest, ListCollectionsRequest as ProtoListCollectionsRequest, @@ -27,67 +30,141 @@ import { Collection } from "./collection"; import { Session } from "./session"; import { Utility } from "./utility"; import { Metadata, ServiceError } from "@grpc/grpc-js"; -import { Topic } from "./topic"; import { TigrisClientConfig } from "./tigris"; +import { DecoratedSchemaProcessor } from "./schema/decorated-schema-processor"; import { Log } from "./utils/logger"; +import { DecoratorMetaStorage } from "./decorators/metadata/decorator-meta-storage"; +import { getDecoratorMetaStorage } from "./globals"; +import { CollectionNotFoundError, BranchNameRequiredError } from "./error"; +import { Status } from "@grpc/grpc-js/build/src/constants"; -/** - * Tigris Database - */ const SetCookie = "Set-Cookie"; const Cookie = "Cookie"; const BeginTransactionMethodName = "/tigrisdata.v1.Tigris/BeginTransaction"; +const DefaultBranch = "main"; +/** + * Tigris Database class to manage database branches, collections and execute + * transactions. + */ export class DB { - private readonly _db: string; + private readonly _name: string; + private readonly _branch: string; private readonly grpcClient: TigrisClient; private readonly config: TigrisClientConfig; + private readonly schemaProcessor: DecoratedSchemaProcessor; + private readonly _metadataStorage: DecoratorMetaStorage; + /** + * Create an instance of Tigris Database class. + * + * @example Recommended way to create instance using {@link TigrisClient.getDatabase} + * ``` + * const client = new TigrisClient(); + * const db = client.getDatabase(); + * ``` + */ constructor(db: string, grpcClient: TigrisClient, config: TigrisClientConfig) { - this._db = db; + this._name = db; this.grpcClient = grpcClient; this.config = config; + this.schemaProcessor = DecoratedSchemaProcessor.Instance; + this._metadataStorage = getDecoratorMetaStorage(); + this._branch = Utility.branchNameFromEnv(config.branch); + if (!this._branch) { + throw new BranchNameRequiredError(); + } } + /** + * Create a new collection if not exists. Else, apply schema changes, if any. + * + * @param cls - A Class decorated by {@link TigrisCollection} + * + * @example + * + * ``` + * @TigrisCollection("todoItems") + * class TodoItem { + * @PrimaryKey(TigrisDataTypes.INT32, { order: 1 }) + * id: number; + * + * @Field() + * text: string; + * + * @Field() + * completed: boolean; + * } + * + * await db.createOrUpdateCollection(TodoItem); + * ``` + */ + public createOrUpdateCollection( + cls: new () => TigrisCollectionType + ): Promise>; + + /** + * Create a new collection if not exists. Else, apply schema changes, if any. + * + * @param collectionName - Name of the Tigris Collection + * @param schema - Collection's data model + * + * @example + * + * ``` + * const TodoItemSchema: TigrisSchema = { + * id: { + * type: TigrisDataTypes.INT32, + * primary_key: { order: 1, autoGenerate: true } + * }, + * text: { type: TigrisDataTypes.STRING }, + * completed: { type: TigrisDataTypes.BOOLEAN } + * }; + * + * await db.createOrUpdateCollection("todoItems", TodoItemSchema); + * ``` + */ public createOrUpdateCollection( collectionName: string, schema: TigrisSchema - ): Promise> { - return this.createOrUpdate( - collectionName, - CollectionType.DOCUMENTS, - schema, - () => new Collection(collectionName, this._db, this.grpcClient, this.config) - ); - } + ): Promise>; - public createOrUpdateTopic( - topicName: string, - schema: TigrisSchema - ): Promise> { + public createOrUpdateCollection( + nameOrClass: string | TigrisCollectionType, + schema?: TigrisSchema + ) { + let collectionName: string; + if (typeof nameOrClass === "string") { + collectionName = nameOrClass as string; + } else { + const generatedColl = this.schemaProcessor.processCollection( + nameOrClass as new () => TigrisCollectionType + ); + collectionName = generatedColl.name; + schema = generatedColl.schema as TigrisSchema; + } return this.createOrUpdate( - topicName, - CollectionType.MESSAGES, + collectionName, schema, - () => new Topic(topicName, this._db, this.grpcClient, this.config) + () => new Collection(collectionName, this._name, this.branch, this.grpcClient, this.config) ); } private createOrUpdate( name: string, - type: CollectionType, schema: TigrisSchema, resolver: () => R ): Promise { return new Promise((resolve, reject) => { - const rawJSONSchema: string = Utility._toJSONSchema(name, type, schema); - Log.debug(rawJSONSchema); + const rawJSONSchema: string = Utility._collectionSchematoJSON(name, schema); const createOrUpdateCollectionRequest = new ProtoCreateOrUpdateCollectionRequest() - .setDb(this._db) + .setProject(this._name) + .setBranch(this.branch) .setCollection(name) .setOnlyCreate(false) .setSchema(Utility.stringToUint8Array(rawJSONSchema)); + Log.event(`Creating collection: '${name}' in project: '${this._name}'`); this.grpcClient.createOrUpdateCollection( createOrUpdateCollectionRequest, // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -104,7 +181,9 @@ export class DB { public listCollections(options?: CollectionOptions): Promise> { return new Promise>((resolve, reject) => { - const request = new ProtoListCollectionsRequest().setDb(this.db); + const request = new ProtoListCollectionsRequest() + .setProject(this.name) + .setBranch(this.branch); if (typeof options !== "undefined") { return request.setOptions(new ProtoCollectionOptions()); } @@ -124,25 +203,52 @@ export class DB { }); } - public dropCollection(collectionName: string): Promise { + /** + * Drops a {@link Collection} + * + * @param cls - A Class decorated by {@link TigrisCollection} + */ + public dropCollection(cls: new () => TigrisCollectionType): Promise; + /** + * Drops a {@link Collection} + * + * @param name - Collection name + */ + public dropCollection(name: string): Promise; + + public dropCollection( + nameOrClass: TigrisCollectionType | string + ): Promise { + const collectionName = this.resolveNameFromCollectionClass(nameOrClass); return new Promise((resolve, reject) => { this.grpcClient.dropCollection( - new ProtoDropCollectionRequest().setDb(this.db).setCollection(collectionName), + new ProtoDropCollectionRequest() + .setProject(this.name) + .setBranch(this.branch) + .setCollection(collectionName), (error, response) => { if (error) { reject(error); } else { - resolve(new DropCollectionResponse(response.getStatus(), response.getMessage())); + resolve(new DropCollectionResponse(response.getMessage())); } } ); }); } + public async dropAllCollections(): Promise[]> { + const collections = await this.listCollections(); + const dropPromises = collections.map((coll) => { + return this.dropCollection(coll.name); + }); + return Promise.allSettled(dropPromises); + } + public describe(): Promise { return new Promise((resolve, reject) => { this.grpcClient.describeDatabase( - new ProtoDescribeDatabaseRequest().setDb(this.db), + new ProtoDescribeDatabaseRequest().setProject(this.name).setBranch(this.branch), (error, response) => { if (error) { reject(error); @@ -159,9 +265,9 @@ export class DB { } resolve( new DatabaseDescription( - response.getDb(), new DatabaseMetadata(), - collectionsDescription + collectionsDescription, + response.getBranchesList() ) ); } @@ -170,12 +276,41 @@ export class DB { }); } - public getCollection(collectionName: string): Collection { - return new Collection(collectionName, this.db, this.grpcClient, this.config); + /** + * Gets a {@link Collection} object + * + * @param cls - A Class decorated by {@link TigrisCollection} + */ + public getCollection( + cls: new () => TigrisCollectionType + ): Collection; + + /** + * Gets a {@link Collection} object + * + * @param name - Collection name + */ + public getCollection(name: string): Collection; + + public getCollection(nameOrClass: T | string): Collection { + const collectionName = this.resolveNameFromCollectionClass(nameOrClass); + return new Collection(collectionName, this.name, this.branch, this.grpcClient, this.config); } - public getTopic(topicName: string): Topic { - return new Topic(topicName, this.db, this.grpcClient, this.config); + private resolveNameFromCollectionClass(nameOrClass: TigrisCollectionType | string) { + let collectionName: string; + if (typeof nameOrClass === "string") { + collectionName = nameOrClass; + } else { + const coll = this._metadataStorage.getCollectionByTarget( + nameOrClass as new () => TigrisCollectionType + ); + if (!coll) { + throw new CollectionNotFoundError(nameOrClass.toString()); + } + collectionName = coll.collectionName; + } + return collectionName; } public transact(fn: (tx: Session) => void): Promise { @@ -189,7 +324,7 @@ export class DB { // user code successful const commitResponse: CommitTransactionResponse = await session.commit(); if (commitResponse) { - resolve(new TransactionResponse("transaction successful")); + resolve(new TransactionResponse()); } } catch (error) { // failed to run user code @@ -205,7 +340,9 @@ export class DB { // eslint-disable-next-line @typescript-eslint/no-unused-vars public beginTransaction(_options?: TransactionOptions): Promise { return new Promise((resolve, reject) => { - const beginTxRequest = new ProtoBeginTransactionRequest().setDb(this._db); + const beginTxRequest = new ProtoBeginTransactionRequest() + .setProject(this._name) + .setBranch(this.branch); const cookie: Metadata = new Metadata(); const call = this.grpcClient.makeUnaryRequest( BeginTransactionMethodName, @@ -223,7 +360,8 @@ export class DB { response.getTxCtx().getId(), response.getTxCtx().getOrigin(), this.grpcClient, - this.db, + this.name, + this.branch, cookie ) ); @@ -238,7 +376,69 @@ export class DB { }); } - get db(): string { - return this._db; + public createBranch(name: string): Promise { + return new Promise((resolve, reject) => { + const req = new ProtoCreateBranchRequest().setProject(this.name).setBranch(name); + this.grpcClient.createBranch(req, (error, response) => { + if (error) { + reject(error); + return; + } + resolve(CreateBranchResponse.from(response)); + }); + }); + } + + public deleteBranch(name: string): Promise { + return new Promise((resolve, reject) => { + const req = new ProtoDeleteBranchRequest().setProject(this.name).setBranch(name); + this.grpcClient.deleteBranch(req, (error, response) => { + if (error) { + reject(error); + return; + } + resolve(DeleteBranchResponse.from(response)); + }); + }); + } + + /** + * Creates a database branch, if not existing already. + * + * @example + * ``` + * const client = new TigrisClient(); + * const db = client.getDatabase(); + * await db.initializeBranch(); + * ``` + * + * @throws {@link Promise.reject} - Error if branch cannot be created + */ + public async initializeBranch(): Promise { + if (!this.usingDefaultBranch) { + try { + await this.createBranch(this.branch); + Log.event(`Created database branch: '${this.branch}'`); + } catch (error) { + if ((error as ServiceError).code === Status.ALREADY_EXISTS) { + Log.event(`'${this.branch}' branch already exists`); + } else { + throw error; + } + } + } + Log.info(`Using database branch: '${this.branch}'`); + } + + get name(): string { + return this._name; + } + + get branch(): string { + return this._branch; + } + + get usingDefaultBranch(): boolean { + return this.branch === DefaultBranch; } } diff --git a/src/decorators/metadata/collection-metadata.ts b/src/decorators/metadata/collection-metadata.ts new file mode 100644 index 0000000..7f12b17 --- /dev/null +++ b/src/decorators/metadata/collection-metadata.ts @@ -0,0 +1,5 @@ +/**@internal*/ +export interface CollectionMetadata { + readonly collectionName: string; + readonly target: Function; +} diff --git a/src/decorators/metadata/decorator-meta-storage.ts b/src/decorators/metadata/decorator-meta-storage.ts new file mode 100644 index 0000000..950217a --- /dev/null +++ b/src/decorators/metadata/decorator-meta-storage.ts @@ -0,0 +1,54 @@ +import { CollectionMetadata } from "./collection-metadata"; +import { FieldMetadata } from "./field-metadata"; +import { PrimaryKeyMetadata } from "./primary-key-metadata"; +import { SearchIndexMetadata } from "./search-index-metadata"; +import { SearchFieldMetadata } from "./search-field-metadata"; + +/** + * Temporary storage for storing metadata processed by decorators. Classes can + * be loaded in any order, schema generation cannot start until all class metadata + * is available. + * + * @internal + */ +export class DecoratorMetaStorage { + readonly collections: Map = new Map(); + readonly collectionFields: Array = new Array(); + readonly primaryKeys: Array = new Array(); + readonly indexes: Array = new Array(); + readonly searchFields: Array = new Array(); + + getCollectionByTarget(target: Function): CollectionMetadata { + for (const collection of this.collections.values()) { + if (collection.target === target) { + return collection; + } + } + } + + getIndexByTarget(target: Function): SearchIndexMetadata { + for (const index of this.indexes.values()) { + if (index.target === target) { + return index; + } + } + } + + getCollectionFieldsByTarget(target: Function): Array { + return this.collectionFields.filter(function (field) { + return field.target === target; + }); + } + + getSearchFieldsByTarget(target: Function): Array { + return this.searchFields.filter(function (field) { + return field.target === target; + }); + } + + getPKsByTarget(target: Function): Array { + return this.primaryKeys.filter(function (pk) { + return pk.target === target; + }); + } +} diff --git a/src/decorators/metadata/field-metadata.ts b/src/decorators/metadata/field-metadata.ts new file mode 100644 index 0000000..9cc9174 --- /dev/null +++ b/src/decorators/metadata/field-metadata.ts @@ -0,0 +1,11 @@ +import { TigrisDataTypes, CollectionFieldOptions } from "../../types"; + +/**@internal*/ +export interface FieldMetadata { + readonly name: string; + readonly target: Function; + readonly type: TigrisDataTypes; + readonly embedType?: TigrisDataTypes | Function; + readonly arrayDepth?: number; + readonly schemaFieldOptions?: CollectionFieldOptions; +} diff --git a/src/decorators/metadata/primary-key-metadata.ts b/src/decorators/metadata/primary-key-metadata.ts new file mode 100644 index 0000000..9e58008 --- /dev/null +++ b/src/decorators/metadata/primary-key-metadata.ts @@ -0,0 +1,9 @@ +import { PrimaryKeyOptions, TigrisDataTypes } from "../../types"; + +/**@internal*/ +export interface PrimaryKeyMetadata { + readonly name: string; + readonly target: Function; + type: TigrisDataTypes; + readonly options: PrimaryKeyOptions; +} diff --git a/src/decorators/metadata/search-field-metadata.ts b/src/decorators/metadata/search-field-metadata.ts new file mode 100644 index 0000000..00d7e8e --- /dev/null +++ b/src/decorators/metadata/search-field-metadata.ts @@ -0,0 +1,12 @@ +import { TigrisDataTypes } from "../../types"; +import { SearchFieldOptions } from "../../search"; + +/**@internal*/ +export interface SearchFieldMetadata { + readonly name: string; + readonly target: Function; + readonly type: TigrisDataTypes; + readonly embedType?: TigrisDataTypes | Function; + readonly arrayDepth?: number; + readonly schemaFieldOptions?: SearchFieldOptions; +} diff --git a/src/decorators/metadata/search-index-metadata.ts b/src/decorators/metadata/search-index-metadata.ts new file mode 100644 index 0000000..9586da8 --- /dev/null +++ b/src/decorators/metadata/search-index-metadata.ts @@ -0,0 +1,5 @@ +/**@internal*/ +export interface SearchIndexMetadata { + readonly indexName: string; + readonly target: Function; +} diff --git a/src/decorators/options/embedded-field-options.ts b/src/decorators/options/embedded-field-options.ts new file mode 100644 index 0000000..789c4e9 --- /dev/null +++ b/src/decorators/options/embedded-field-options.ts @@ -0,0 +1,17 @@ +import { TigrisDataTypes } from "../../types"; + +/** + * Additional type information for Arrays and Objects schema fields + * @public + */ +export type EmbeddedFieldOptions = { + elements?: TigrisDataTypes | Function; + /** + * Optionally used to specify nested arrays (Array of arrays). + * + * - `Array` will have "depth" of 1 (default) + * - `Array>` will have "depth" of 2 + * - `Array>>` will have "depth" of 3 + */ + depth?: number; +}; diff --git a/src/decorators/tigris-collection.ts b/src/decorators/tigris-collection.ts new file mode 100644 index 0000000..ee27cbb --- /dev/null +++ b/src/decorators/tigris-collection.ts @@ -0,0 +1,15 @@ +import { getDecoratorMetaStorage } from "../globals"; + +/** + * TigrisCollection decorator is used to mark a class as a Collection's schema/data model. + * + * @param name - Name of collection + */ +export function TigrisCollection(name: string): ClassDecorator { + return function (target) { + getDecoratorMetaStorage().collections.set(name, { + collectionName: name, + target: target, + }); + }; +} diff --git a/src/decorators/tigris-field.ts b/src/decorators/tigris-field.ts new file mode 100644 index 0000000..48ea2d6 --- /dev/null +++ b/src/decorators/tigris-field.ts @@ -0,0 +1,136 @@ +import "reflect-metadata"; +import { CollectionFieldOptions, TigrisDataTypes } from "../types"; +import { EmbeddedFieldOptions } from "./options/embedded-field-options"; +import { + CannotInferFieldTypeError, + IncompleteArrayTypeDefError, + IncorrectVectorDefError, + ReflectionNotEnabled, +} from "../error"; +import { getDecoratorMetaStorage } from "../globals"; +import { FieldMetadata } from "./metadata/field-metadata"; +import { Log } from "../utils/logger"; +import { getTigrisTypeFromReflectedType, isEmbeddedOption } from "./utils"; + +/** + * Field decorator is used to mark a class property as Collection field. Only properties + * decorated with `@Field` will be used in Schema. + * + * Uses `Reflection` to determine the data type of schema Field. + */ +export function Field(): PropertyDecorator; +/** + * Field decorator is used to mark a class property as Collection field. Only properties + * decorated with `@Field` will be used in Schema. + * + * @param type - Schema field's data type + */ +export function Field(type: TigrisDataTypes): PropertyDecorator; +/** + * Field decorator is used to mark a class property as Collection field. Only properties + * decorated with `@Field` will be used in Schema. + * + * Uses `Reflection` to determine the data type of schema Field. + * + * @param options - `EmbeddedFieldOptions` are only applicable to Array and Object types + * of schema field. + */ +export function Field(options: EmbeddedFieldOptions & CollectionFieldOptions): PropertyDecorator; +/** + * Field decorator is used to mark a class property as Collection field. Only properties + * decorated with `@Field` will be used in Schema. + * + * Uses `Reflection` to determine the data type of schema Field. + * + * @param type - Schema field's data type + * @param options - `EmbeddedFieldOptions` are only applicable to Array and Object types + * of schema field. + */ +export function Field( + type: TigrisDataTypes, + options?: EmbeddedFieldOptions & CollectionFieldOptions +): PropertyDecorator; + +/** + * Field decorator is used to mark a class property as Collection field. Only properties + * decorated with `@Field` will be used in Schema. + */ +export function Field( + typeOrOptions?: TigrisDataTypes | (CollectionFieldOptions & EmbeddedFieldOptions), + options?: CollectionFieldOptions & EmbeddedFieldOptions +): PropertyDecorator { + return function (target, propertyName) { + propertyName = propertyName.toString(); + let propertyType: TigrisDataTypes | undefined; + let fieldOptions: CollectionFieldOptions; + let embedOptions: EmbeddedFieldOptions; + + if (typeof typeOrOptions === "string") { + propertyType = typeOrOptions; + } else if (typeof typeOrOptions === "object") { + if (isEmbeddedOption(typeOrOptions)) { + embedOptions = typeOrOptions as EmbeddedFieldOptions; + } + fieldOptions = typeOrOptions as CollectionFieldOptions; + } + + if (typeof options === "object") { + if (isEmbeddedOption(options)) { + embedOptions = options as EmbeddedFieldOptions; + } + fieldOptions = options as CollectionFieldOptions; + } + + // if type or options are not specified, infer using reflection + if (!propertyType) { + Log.info(`Using reflection to infer type of ${target.constructor.name}#${propertyName}`); + let reflectedType; + try { + reflectedType = + Reflect && Reflect.getMetadata + ? Reflect.getMetadata("design:type", target, propertyName) + : undefined; + propertyType = getTigrisTypeFromReflectedType(reflectedType.name); + } catch { + throw new ReflectionNotEnabled(target, propertyName); + } + + // if propertyType is Array, type of contents is required unless its a vector + if (propertyType === TigrisDataTypes.ARRAY) { + if (fieldOptions?.dimensions !== undefined) { + if (embedOptions?.elements && embedOptions?.elements !== TigrisDataTypes.NUMBER) { + throw new IncorrectVectorDefError(target, propertyName); + } + embedOptions = { elements: TigrisDataTypes.NUMBER }; + } else if (embedOptions?.elements === undefined) { + throw new IncompleteArrayTypeDefError(target, propertyName); + } + } + + // if propertyType is still undefined, it probably is a typed object + if (propertyType === undefined) { + propertyType = TigrisDataTypes.OBJECT; + embedOptions = { elements: reflectedType }; + } + } + + if (!propertyType) { + throw new CannotInferFieldTypeError(target, propertyName); + } + + // if propertyType is Array, subtype is required + if (propertyType === TigrisDataTypes.ARRAY && embedOptions?.elements === undefined) { + throw new IncompleteArrayTypeDefError(target, propertyName); + } + + getDecoratorMetaStorage().collectionFields.push({ + name: propertyName, + type: propertyType, + isArray: propertyType === TigrisDataTypes.ARRAY, + target: target.constructor, + embedType: embedOptions?.elements, + arrayDepth: embedOptions?.depth, + schemaFieldOptions: fieldOptions, + } as FieldMetadata); + }; +} diff --git a/src/decorators/tigris-primary-key.ts b/src/decorators/tigris-primary-key.ts new file mode 100644 index 0000000..faf395b --- /dev/null +++ b/src/decorators/tigris-primary-key.ts @@ -0,0 +1,73 @@ +import "reflect-metadata"; +import { PrimaryKeyOptions, TigrisDataTypes } from "../types"; +import { CannotInferFieldTypeError, ReflectionNotEnabled } from "../error"; +import { getDecoratorMetaStorage } from "../globals"; +import { PrimaryKeyMetadata } from "./metadata/primary-key-metadata"; +import { Log } from "../utils/logger"; + +/** + * PrimaryKey decorator is used to mark a class property as Primary Key in a collection. + * + * Uses `Reflection` to determine the data type of schema Field + * + * @param options - Additional properties + */ +export function PrimaryKey(options?: PrimaryKeyOptions): PropertyDecorator; +/** + * PrimaryKey decorator is used to mark a class property as Primary Key in a collection. + * + * Uses `Reflection` to determine the type of schema Field + * + * @param type - Schema field's data type + * @param options - Additional properties + */ +export function PrimaryKey(type: TigrisDataTypes, options?: PrimaryKeyOptions): PropertyDecorator; + +/** + * PrimaryKey decorator is used to mark a class property as Primary Key in a collection. + */ +export function PrimaryKey( + typeOrOptions: TigrisDataTypes | PrimaryKeyOptions, + options?: PrimaryKeyOptions +): PropertyDecorator { + return function (target, propertyName) { + propertyName = propertyName.toString(); + let propertyType: TigrisDataTypes; + + if (typeof typeOrOptions === "string") { + propertyType = typeOrOptions as TigrisDataTypes; + } else if (typeof typeOrOptions === "object") { + options = typeOrOptions as PrimaryKeyOptions; + } + + // infer type from reflection + if (!propertyType) { + Log.info(`Using reflection to infer type of ${target.constructor.name}#${propertyName}`); + try { + const reflectedType = + Reflect && Reflect.getMetadata + ? Reflect.getMetadata("design:type", target, propertyName) + : undefined; + propertyType = ReflectedTypeToTigrisType.get(reflectedType.name); + } catch { + throw new ReflectionNotEnabled(target, propertyName); + } + } + if (!propertyType) { + throw new CannotInferFieldTypeError(target, propertyName); + } + + getDecoratorMetaStorage().primaryKeys.push({ + name: propertyName, + type: propertyType, + target: target.constructor, + options: options, + } as PrimaryKeyMetadata); + }; +} + +const ReflectedTypeToTigrisType: Map = new Map([ + ["String", TigrisDataTypes.STRING], + ["Number", TigrisDataTypes.NUMBER], + ["BigInt", TigrisDataTypes.NUMBER_BIGINT], +]); diff --git a/src/decorators/tigris-search-field.ts b/src/decorators/tigris-search-field.ts new file mode 100644 index 0000000..48c16b2 --- /dev/null +++ b/src/decorators/tigris-search-field.ts @@ -0,0 +1,104 @@ +import "reflect-metadata"; +import { TigrisDataTypes } from "../types"; +import { EmbeddedFieldOptions } from "./options/embedded-field-options"; +import { SearchFieldOptions } from "../search"; +import { getTigrisTypeFromReflectedType, isEmbeddedOption } from "./utils"; +import { Log } from "../utils/logger"; +import { + CannotInferFieldTypeError, + IncompleteArrayTypeDefError, + IncorrectVectorDefError, + ReflectionNotEnabled, +} from "../error"; +import { getDecoratorMetaStorage } from "../globals"; +import { SearchFieldMetadata } from "./metadata/search-field-metadata"; + +export function SearchField(): PropertyDecorator; +export function SearchField(type: TigrisDataTypes): PropertyDecorator; +export function SearchField(options: EmbeddedFieldOptions & SearchFieldOptions): PropertyDecorator; +export function SearchField( + type: TigrisDataTypes, + options: EmbeddedFieldOptions & SearchFieldOptions +): PropertyDecorator; + +export function SearchField( + typeOrOptions?: TigrisDataTypes | (SearchFieldOptions & EmbeddedFieldOptions), + options?: SearchFieldOptions & EmbeddedFieldOptions +): PropertyDecorator { + return function (target, propertyName) { + propertyName = propertyName.toString(); + let propertyType: TigrisDataTypes | undefined; + let fieldOptions: SearchFieldOptions; + let embedOptions: EmbeddedFieldOptions; + + if (typeof typeOrOptions === "string") { + propertyType = typeOrOptions; + } else if (typeof typeOrOptions === "object") { + if (isEmbeddedOption(typeOrOptions)) { + embedOptions = typeOrOptions as EmbeddedFieldOptions; + } + fieldOptions = typeOrOptions as SearchFieldOptions; + } + + if (typeof options === "object") { + if (isEmbeddedOption(options)) { + embedOptions = options as EmbeddedFieldOptions; + } + fieldOptions = options as SearchFieldOptions; + } + + // if type or options are not specified, infer using reflection + if (!propertyType) { + Log.info(`Using reflection to infer type of ${target.constructor.name}#${propertyName}`); + let reflectedType; + try { + reflectedType = + Reflect && Reflect.getMetadata + ? Reflect.getMetadata("design:type", target, propertyName) + : undefined; + propertyType = getTigrisTypeFromReflectedType(reflectedType.name); + } catch { + throw new ReflectionNotEnabled(target, propertyName); + } + + // if propertyType is Array, type of contents is required unless its a vector + if (propertyType === TigrisDataTypes.ARRAY) { + if (fieldOptions?.dimensions !== undefined) { + if (embedOptions?.elements && embedOptions?.elements !== TigrisDataTypes.NUMBER) { + throw new IncorrectVectorDefError(target, propertyName); + } + embedOptions = { elements: TigrisDataTypes.NUMBER }; + } else if (embedOptions?.elements === undefined) { + throw new IncompleteArrayTypeDefError(target, propertyName); + } + } + + // if propertyType is still undefined, it probably is a typed object + if (propertyType === undefined) { + propertyType = TigrisDataTypes.OBJECT; + embedOptions = { elements: reflectedType }; + } + } + + if (!propertyType) { + throw new CannotInferFieldTypeError(target, propertyName); + } + + // if propertyType is Array, subtype is required + if (propertyType === TigrisDataTypes.ARRAY && embedOptions?.elements === undefined) { + throw new IncompleteArrayTypeDefError(target, propertyName); + } + const defaultFieldOption: SearchFieldOptions = { searchIndex: true }; + fieldOptions = { ...defaultFieldOption, ...fieldOptions }; + + getDecoratorMetaStorage().searchFields.push({ + name: propertyName, + type: propertyType, + isArray: propertyType === TigrisDataTypes.ARRAY, + target: target.constructor, + embedType: embedOptions?.elements, + arrayDepth: embedOptions?.depth, + schemaFieldOptions: fieldOptions, + } as SearchFieldMetadata); + }; +} diff --git a/src/decorators/tigris-search-index.ts b/src/decorators/tigris-search-index.ts new file mode 100644 index 0000000..08d1d6f --- /dev/null +++ b/src/decorators/tigris-search-index.ts @@ -0,0 +1,15 @@ +import { getDecoratorMetaStorage } from "../globals"; + +/** + * TigrisSearchIndex decorator is used to mark a class as a schema/data model for Search Index. + * + * @param name - Name of Index + */ +export function TigrisSearchIndex(name: string): ClassDecorator { + return function (target) { + getDecoratorMetaStorage().indexes.push({ + indexName: name, + target: target, + }); + }; +} diff --git a/src/decorators/utils.ts b/src/decorators/utils.ts new file mode 100644 index 0000000..7518009 --- /dev/null +++ b/src/decorators/utils.ts @@ -0,0 +1,30 @@ +import { TigrisDataTypes, CollectionFieldOptions } from "../types"; +import { EmbeddedFieldOptions } from "./options/embedded-field-options"; + +export function getTigrisTypeFromReflectedType(reflectedType: string): TigrisDataTypes | undefined { + switch (reflectedType) { + case "String": + return TigrisDataTypes.STRING; + case "Boolean": + return TigrisDataTypes.BOOLEAN; + case "Object": + return TigrisDataTypes.OBJECT; + case "Array": + case "Set": + return TigrisDataTypes.ARRAY; + case "Number": + return TigrisDataTypes.NUMBER; + case "BigInt": + return TigrisDataTypes.NUMBER_BIGINT; + case "Date": + return TigrisDataTypes.DATE_TIME; + default: + return undefined; + } +} + +export function isEmbeddedOption( + options: CollectionFieldOptions | EmbeddedFieldOptions +): options is EmbeddedFieldOptions { + return (options as EmbeddedFieldOptions).elements !== undefined; +} diff --git a/src/error.ts b/src/error.ts index ceb33ef..e2eb099 100644 --- a/src/error.ts +++ b/src/error.ts @@ -2,6 +2,8 @@ * Generic TigrisError */ export class TigrisError extends Error { + readonly errMsg = this.name + ": " + this.message; + constructor(message: string) { super(message); } @@ -16,41 +18,117 @@ export class TigrisError extends Error { * used * * @public - * @category Error */ -export class TigrisCursorInUseError extends TigrisError { +export class CursorInUseError extends TigrisError { constructor(message = "Cursor is already in use or used. Please reset()") { super(message); } +} + +export class ReflectionNotEnabled extends TigrisError { + constructor(object: Object, propertyName: string) { + super( + `Cannot infer property "type" for ${object.constructor.name}#${propertyName} using Reflection. + Ensure that "experimentalDecorators" and "emitDecoratorMetadata" options are set to true in + "tsconfig.json" and "reflect-metadata" npm package is added to dependencies in "package.json". + Alternatively, specify the property's "field type" manually.` + ); + } override get name(): string { - return "TigrisCursorInUseError"; + return "ReflectionNotEnabled"; } } -/** - * An error thrown when path is invalid or not found - * - * @public - * @category Error - */ -export class TigrisFileNotFoundError extends TigrisError { - constructor(message) { - super(message); +export class MissingArgumentError extends TigrisError { + constructor(propertyName: string) { + super(`'${propertyName}' is required and cannot be 'undefined'`); } override get name(): string { - return "TigrisFileNotFoundError"; + return "MissingArgumentError"; } } -export class TigrisMoreThanOneSchemaDefined extends TigrisError { - constructor(fileName, foundSchemas) { +export class CannotInferFieldTypeError extends TigrisError { + constructor(object: Object, propertyName: string) { + super(`Field type for '${object.constructor.name}#${propertyName}' cannot be determined`); + } + + override get name(): string { + return "CannotInferFieldTypeError"; + } +} + +export class IncompleteArrayTypeDefError extends TigrisError { + constructor(object: Object, propertyName: string) { super( - `${foundSchemas} TigrisSchema detected in file ${fileName}, should only have 1 TigrisSchema exported` + `Missing "EmbeddedFieldOptions". Array's item type for '${object.constructor.name}#${propertyName}' cannot be determined` ); } override get name(): string { - return "TigrisMoreThanOneSchemaDefined"; + return "IncompleteArrayTypeDefError"; + } +} + +export class IncorrectVectorDefError extends TigrisError { + constructor(object: Object, propertyName: string) { + super(`'${propertyName}' in '${object.constructor.name}' defines "dimensions" field option identifying it as a Vector data type. + The primitive data type for Vector can only be a 'number[]'`); + } + override get name(): string { + return "IncorrectVectorDefError"; + } +} +export class MissingPrimaryKeyOrderInSchemaDefinitionError extends TigrisError { + constructor(propertyName: string) { + super(`Missing 'order' value for '${propertyName}' primary key`); + } + + override get name(): string { + return "MissingPrimaryKeyOrderInSchemaDefinitionError"; + } +} + +export class DuplicatePrimaryKeyOrderError extends TigrisError { + constructor(order: string, propertyName: string) { + super(`Primary Key order '${order}' already exists for '${propertyName}'`); + } + + override get name(): string { + return "DuplicatePrimaryKeyOrderError"; + } +} + +export class IncompletePrimaryKeyOrderError extends TigrisError { + constructor(name: string, collectionName: string) { + super( + `Missing 'order' value in "PrimaryKeyOptions" for variable '${name}' in ${collectionName} collection` + ); + } + + override get name(): string { + return "IncompletePrimaryKeyOrderError"; + } +} + +export class CollectionNotFoundError extends TigrisError { + constructor(name: string) { + super(`Collection not found : '${name}'`); + } + + override get name(): string { + return "CollectionNotFoundError"; + } +} + +export class BranchNameRequiredError extends TigrisError { + constructor() { + super(`Database branch name is required. Include a branch name in client config or specify one in + environment file as 'TIGRIS_DB_BRANCH=your_branch_name'`); + } + + override get name(): string { + return "BranchNameRequiredError"; } } diff --git a/src/globals.ts b/src/globals.ts new file mode 100644 index 0000000..c19ef8b --- /dev/null +++ b/src/globals.ts @@ -0,0 +1,9 @@ +import { DecoratorMetaStorage } from "./decorators/metadata/decorator-meta-storage"; + +export function getDecoratorMetaStorage(): DecoratorMetaStorage { + if (!global.annotationCache) { + global.annotationCache = new DecoratorMetaStorage(); + } + + return global.annotationCache; +} diff --git a/src/index.ts b/src/index.ts index c9793ed..616cf55 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,4 +2,11 @@ export * from "./collection"; export * from "./db"; export * from "./session"; export * from "./tigris"; -export * from "./topic"; +export * from "./types"; +export * from "./constants"; +export { Field } from "./decorators/tigris-field"; +export { PrimaryKey } from "./decorators/tigris-primary-key"; +export { TigrisCollection } from "./decorators/tigris-collection"; +export { EmbeddedFieldOptions } from "./decorators/options/embedded-field-options"; +export { Cursor } from "./consumables/cursor"; +export * from "./search/index"; diff --git a/src/schema/decorated-schema-processor.ts b/src/schema/decorated-schema-processor.ts new file mode 100644 index 0000000..8a6f6d0 --- /dev/null +++ b/src/schema/decorated-schema-processor.ts @@ -0,0 +1,310 @@ +import { DecoratorMetaStorage } from "../decorators/metadata/decorator-meta-storage"; +import { getDecoratorMetaStorage } from "../globals"; +import { + CollectionFieldOptions, + TigrisCollectionType, + TigrisDataTypes, + TigrisSchema, +} from "../types"; +import { SearchFieldOptions, TigrisIndexSchema, TigrisIndexType } from "../search"; +import { SearchFieldMetadata } from "../decorators/metadata/search-field-metadata"; +import { FieldMetadata } from "../decorators/metadata/field-metadata"; +import { PrimaryKeyMetadata } from "../decorators/metadata/primary-key-metadata"; +import { IncompletePrimaryKeyOrderError } from "../error"; + +export type CollectionSchema = { + name: string; + schema: TigrisSchema; +}; + +export type IndexSchema = { + name: string; + schema: TigrisIndexSchema; +}; + +/** @internal */ +export class DecoratedSchemaProcessor { + private static _instance: DecoratedSchemaProcessor; + private readonly storage: DecoratorMetaStorage; + + private constructor() { + this.storage = getDecoratorMetaStorage(); + } + + static get Instance(): DecoratedSchemaProcessor { + if (!DecoratedSchemaProcessor._instance) { + DecoratedSchemaProcessor._instance = new DecoratedSchemaProcessor(); + } + return DecoratedSchemaProcessor._instance; + } + + processIndex(cls: new () => TigrisIndexType): IndexSchema { + const index = this.storage.getIndexByTarget(cls); + const schema = this.buildTigrisSchema(index.target, false); + return { + name: index.indexName, + schema: schema as TigrisIndexSchema, + }; + } + + processCollection(cls: new () => TigrisCollectionType): CollectionSchema { + const collection = this.storage.getCollectionByTarget(cls); + const schema = this.buildTigrisSchema(collection.target, true); + this.addPrimaryKeys(schema, collection.target); + return { + name: collection.collectionName, + schema: schema as TigrisSchema, + }; + } + + private buildTigrisSchema( + from: Function, + forCollection: boolean, + parentFieldType?: TigrisDataTypes + ): TigrisSchema | TigrisIndexSchema { + const schema = {}; + // get all top level fields matching this target + const fields = this.getSchemaFields(from, forCollection); + + // process each field + for (const field of fields) { + const key = field.name; + if (!(key in schema)) { + schema[key] = { type: field.type }; + } + + let arrayItems: Object, arrayDepth: number; + + switch (field.type) { + case TigrisDataTypes.ARRAY: + arrayItems = + typeof field.embedType === "function" + ? { + type: this.buildTigrisSchema( + field.embedType as Function, + forCollection, + parentFieldType ?? field.type + ), + } + : { type: field.embedType as TigrisDataTypes }; + arrayDepth = field.arrayDepth && field.arrayDepth > 1 ? field.arrayDepth : 1; + schema[key] = this.buildNestedArray(arrayItems, arrayDepth); + break; + case TigrisDataTypes.OBJECT: + if (typeof field.embedType === "function") { + const embedSchema = this.buildTigrisSchema( + field.embedType as Function, + forCollection, + parentFieldType ?? field.type + ); + // generate embedded schema as its a class + if (Object.keys(embedSchema).length > 0) { + schema[key] = { + type: this.buildTigrisSchema( + field.embedType as Function, + forCollection, + parentFieldType ?? field.type + ), + }; + } + } + break; + case TigrisDataTypes.STRING: + if (field.schemaFieldOptions && "maxLength" in field.schemaFieldOptions) { + schema[key].maxLength = field.schemaFieldOptions.maxLength; + } + break; + } + + // process any field optionals + if (field.schemaFieldOptions) { + // set value for field, if any + for (const opKey of schemaOptions) { + if (schemaOptionSupported(field.schemaFieldOptions, field.type, parentFieldType, opKey)) { + schema[key][opKey.attrName] = field.schemaFieldOptions[opKey.attrName]; + } + } + } + } + return forCollection + ? (schema as TigrisSchema) + : (schema as TigrisIndexSchema); + } + + private buildNestedArray(items, depth: number) { + let head: Object, prev: Object, next: Object; + while (depth > 0) { + if (!head) { + next = {}; + head = next; + } + next["type"] = TigrisDataTypes.ARRAY; + next["items"] = {}; + prev = next; + next = next["items"]; + depth -= 1; + } + prev["items"] = items; + return head; + } + + private addPrimaryKeys( + targetSchema: TigrisSchema, + collectionClass: Function + ) { + const primaryKeysMetadata: PrimaryKeyMetadata[] = this.storage.getPKsByTarget(collectionClass); + this.validatePrimaryKeysOrder(primaryKeysMetadata, collectionClass); + for (const pk of primaryKeysMetadata) { + if (!(pk.name in targetSchema)) { + targetSchema[pk.name] = { + type: pk.type, + }; + } + targetSchema[pk.name]["primary_key"] = { + order: pk.options?.order ?? 1, + autoGenerate: pk.options?.autoGenerate === true, + }; + } + } + + private validatePrimaryKeysOrder(primaryKeys: PrimaryKeyMetadata[], collectionClass: Function) { + if (primaryKeys.length > 1) { + for (const pk of primaryKeys) { + if (!pk?.options?.order) { + throw new IncompletePrimaryKeyOrderError(pk.name, collectionClass.name); + } + } + } + } + + private getSchemaFields( + from: Function, + forCollection: boolean + ): (SearchFieldMetadata | FieldMetadata)[] { + const searchFields: (SearchFieldMetadata | FieldMetadata)[] = + this.storage.getSearchFieldsByTarget(from); + + if (!forCollection) { + return searchFields; + } + + const fields = []; + + // create a lookup for search fields + const searchFieldsLookup = []; + for (const field of searchFields) { + searchFieldsLookup[field.name] = field; + } + + // process the collection fields + const visitedFields = new Set(); + const collectionFields = this.storage.getCollectionFieldsByTarget(from); + for (const cf of collectionFields) { + let fieldOption = cf.schemaFieldOptions; + + // if a search field is defined for this field, merge its options + const searchField = searchFieldsLookup[cf.name]; + if (searchField) { + fieldOption = { ...cf.schemaFieldOptions, ...searchField.schemaFieldOptions }; + } + + fields.push({ + ...cf, + schemaFieldOptions: fieldOption, + }); + + visitedFields.add(cf.name); + } + + // process the additional fields that are tagged as search fields + for (const sf of searchFields) { + if (!visitedFields.has(sf.name)) { + fields.push(sf); + } + } + + return fields; + } +} + +interface SchemaFieldOptions { + attrName: string; + doesNotApplyTo: Set; + doesNotApplyToParent: Set; +} + +const schemaOptions: SchemaFieldOptions[] = [ + { + attrName: "default", + doesNotApplyTo: new Set(), + doesNotApplyToParent: new Set(), + }, + { + attrName: "timestamp", + doesNotApplyTo: new Set([TigrisDataTypes.OBJECT]), + doesNotApplyToParent: new Set(), + }, + { + attrName: "searchIndex", + doesNotApplyTo: new Set([TigrisDataTypes.OBJECT]), + doesNotApplyToParent: new Set([TigrisDataTypes.ARRAY]), + }, + { + attrName: "sort", + doesNotApplyTo: new Set([TigrisDataTypes.OBJECT]), + doesNotApplyToParent: new Set([TigrisDataTypes.ARRAY]), + }, + { + attrName: "facet", + doesNotApplyTo: new Set([TigrisDataTypes.OBJECT]), + doesNotApplyToParent: new Set([TigrisDataTypes.ARRAY]), + }, + { + attrName: "dimensions", + doesNotApplyTo: new Set([TigrisDataTypes.OBJECT, TigrisDataTypes.NUMBER]), + doesNotApplyToParent: new Set([TigrisDataTypes.ARRAY]), + }, + { + attrName: "id", + doesNotApplyTo: new Set([ + TigrisDataTypes.OBJECT, + TigrisDataTypes.ARRAY, + TigrisDataTypes.NUMBER, + TigrisDataTypes.BOOLEAN, + TigrisDataTypes.NUMBER_BIGINT, + TigrisDataTypes.INT64, + TigrisDataTypes.INT32, + TigrisDataTypes.DATE_TIME, + ]), + doesNotApplyToParent: new Set([TigrisDataTypes.ARRAY]), + }, + { + attrName: "index", + doesNotApplyTo: new Set([TigrisDataTypes.OBJECT, TigrisDataTypes.ARRAY]), + doesNotApplyToParent: new Set([TigrisDataTypes.ARRAY, TigrisDataTypes.OBJECT]), + }, +]; + +// searchIndex, sort and facet tags cannot be defined on top level object +// and can only be defined on the fields of the object +// { "field1": { "type": "object", "properties": { "name": { "type": "string" } }, "searchIndex": true } - not supported +// { "field1": { "type": "object", "properties": { "name": { "type": "string", "searchIndex": true } } } - supported +// searchIndex, sort and facet tags cannot be defined within a nested array +// { "field1": { "type": "array", "items": { "type": "object", "properties": { "name": { "type": "string", "searchIndex": true } } } } - not supported +// { "field1": { "type": "array", "items": { "type": "object", "properties": { "name": { "type": "string" } } }, "searchIndex": true } - supported +function schemaOptionSupported( + fieldOptions: SearchFieldOptions | CollectionFieldOptions, + fieldType: TigrisDataTypes, + fieldParentType: TigrisDataTypes, + attr: SchemaFieldOptions +): boolean { + if ( + attr.attrName in fieldOptions && + !attr.doesNotApplyTo.has(fieldType) && + (!fieldParentType || !attr.doesNotApplyToParent.has(fieldParentType)) + ) { + return true; + } + + return false; +} diff --git a/src/search/index.ts b/src/search/index.ts new file mode 100644 index 0000000..715747d --- /dev/null +++ b/src/search/index.ts @@ -0,0 +1,8 @@ +export * from "./types"; +export * from "./query"; +export * from "./result"; +export * from "./search-index"; +export { SearchIterator } from "../consumables/search-iterator"; +export { SearchField } from "../decorators/tigris-search-field"; +export { Search } from "./search"; +export { TigrisSearchIndex } from "../decorators/tigris-search-index"; diff --git a/src/search/query.ts b/src/search/query.ts new file mode 100644 index 0000000..ee3f0e6 --- /dev/null +++ b/src/search/query.ts @@ -0,0 +1,119 @@ +import { DocumentPaths, Filter, SortOrder, TigrisCollectionType } from "../types"; +import { TigrisIndexType } from "./types"; + +export const MATCH_ALL_QUERY_STRING = ""; + +/** + * Search query builder + */ +export interface SearchQuery { + /** + * Text to match + */ + q?: string; + /** + * Fields to project search query on + */ + searchFields?: Array>; + /** + * Filter to further refine the search results + */ + filter?: Filter; + /** + * Facet fields to categorically arrange indexed terms + */ + facets?: FacetFieldsQuery; + /** + * Perform a nearest neighbor search to find closest documents + */ + vectorQuery?: VectorQuery; + /** + * Sort the search results in indicated order + */ + sort?: SortOrder; + /** + * Group by single or multiple fields in the index + */ + groupBy?: Array; + /** + * Document fields to include when returning search results + */ + includeFields?: Array>; + /** + * Document fields to exclude when returning search results + */ + excludeFields?: Array>; + /** + * Maximum number of search hits (matched documents) to fetch per page + */ + hitsPerPage?: number; + /** + * Other parameters for search query + */ + options?: SearchQueryOptions; +} + +/** + * Options for search query + */ +export interface SearchQueryOptions { + /** + * String comparison rules for filtering. E.g. - Case insensitive text match + */ + collation?: Collation; +} + +export type FacetFieldsQuery = FacetFieldOptions | FacetFields; + +/** + * Map of collection field names and faceting options to include facet results in search response + */ +export type FacetFieldOptions = { + [K in DocumentPaths]?: FacetQueryOptions; +}; + +/** + * Array of field names to include facet results for in search response + */ +export type FacetFields = Array>; + +/** + * Information to build facets in search results + * + */ +export type FacetQueryOptions = { + /** + * Maximum number of facets to include in results + * default - 10 + */ + size: number; + /** + * Type of facets to build + */ + type?: "value"; +}; + +export enum Case { + /** + * Case insensitive collation case + */ + CaseInsensitive = "ci", +} + +/** + * A collation allows you to specify string comparison rules. Default is case-sensitive. + */ +export type Collation = { + case: Case; +}; + +/** + * A VectorQuery allows you to perform nearest neighbor search. + */ +export type VectorQuery = { + /** + * Document field to query against and the vector value to find nearest neighbors. + * The field must be of 'Vector' type. + */ + [key: string]: Array; +}; diff --git a/src/search/result.ts b/src/search/result.ts new file mode 100644 index 0000000..5dac9d3 --- /dev/null +++ b/src/search/result.ts @@ -0,0 +1,386 @@ +import { + FacetCount as ProtoFacetCount, + FacetStats as ProtoFacetStats, + Page as ProtoSearchPage, + SearchFacet as ProtoSearchFacet, + SearchHit as ProtoSearchHit, + SearchHitMeta as ProtoSearchHitMeta, + SearchMetadata as ProtoSearchMetadata, + SearchResponse as ProtoSearchResponse, + Match as ProtoMatch, + GroupedSearchHits, +} from "../proto/server/v1/api_pb"; +import { SearchIndexResponse as ProtoSearchIndexResponse } from "../proto/server/v1/search_pb"; +import { TigrisClientConfig } from "../tigris"; +import { TigrisCollectionType } from "../types"; +import { Utility } from "../utility"; + +export type Facets = { [key: string]: FacetCountDistribution }; +export type GroupedHits = { groupKeys: string[]; hits: Array> }; + +/** + * Outcome of executing search query + * @typeParam T - type of Tigris collection + */ +export class SearchResult { + /** + * Array of matched documents + * @readonly + */ + readonly hits: ReadonlyArray>; + /** + * Distribution of facets for fields included in facet query + * @readonly + */ + readonly facets: Facets; + /** + * Metadata associated with {@link SearchResult} + * @readonly + * @defaultValue undefined + */ + readonly meta: SearchMeta; + /** + * Array of matched documents when group_by is used in the search request. + * @readonly + * @defaultValue [] + */ + readonly groupedHits: GroupedHits[]; + + constructor( + hits: Array>, + facets: Facets, + meta: SearchMeta, + groupedHits: GroupedHits[] + ) { + this.hits = hits; + this.facets = facets; + this.meta = meta; + this.groupedHits = groupedHits; + } + + static get empty(): SearchResult { + return new SearchResult([], {}, SearchMeta.default, []); + } + + static from( + resp: ProtoSearchResponse | ProtoSearchIndexResponse, + config: TigrisClientConfig + ): SearchResult { + const _meta = + typeof resp?.getMeta() !== "undefined" ? SearchMeta.from(resp.getMeta()) : SearchMeta.default; + const _hits: Array> = resp + .getHitsList() + .map((h: ProtoSearchHit) => IndexedDoc.from(h, config)); + const _facets: Facets = {}; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for (const [k, _] of resp.getFacetsMap().toArray()) { + _facets[k] = FacetCountDistribution.from(resp.getFacetsMap().get(k)); + } + const _groupedHits = resp.getGroupList().map((g: GroupedSearchHits) => { + return { + groupKeys: g.getGroupKeysList(), + hits: g.getHitsList().map((h: ProtoSearchHit) => IndexedDoc.from(h, config)), + }; + }); + + return new SearchResult(_hits, _facets, _meta, _groupedHits); + } +} + +/** + * Matched document and relevance metadata for a search query + * @typeParam T - type of Tigris collection + */ +export class IndexedDoc { + /** + * Deserialized collection/search index document + * @readonly + */ + readonly document: T; + /** + * Relevance metadata for the matched document + * @readonly + */ + readonly meta: DocMeta; + + constructor(document: T, meta: DocMeta) { + this.document = document; + this.meta = meta; + } + + static from(resp: ProtoSearchHit, config: TigrisClientConfig): IndexedDoc { + const docAsB64 = resp.getData_asB64(); + if (!docAsB64) { + return new IndexedDoc(undefined, undefined); + } + const document = Utility.jsonStringToObj(Utility._base64Decode(docAsB64), config); + const meta = resp.hasMetadata() ? DocMeta.from(resp.getMetadata()) : undefined; + return new IndexedDoc(document, meta); + } +} + +/** + * Relevance metadata for a matched document + */ +export class DocMeta { + /** + * Time at which document was inserted/replaced to a precision of milliseconds + * @readonly + */ + readonly createdAt: Date; + /** + * Time at which document was updated to a precision of milliseconds + * @readonly + */ + readonly updatedAt: Date; + /** + * Metadata for matched fields and relevant score + * @readonly + */ + readonly textMatch: TextMatchInfo; + + constructor(createdAt: Date, updatedAt: Date, textMatch: TextMatchInfo) { + this.createdAt = createdAt; + this.updatedAt = updatedAt; + this.textMatch = textMatch; + } + + static from(resp: ProtoSearchHitMeta): DocMeta { + const _createdAt = + typeof resp?.getCreatedAt()?.getSeconds() !== "undefined" + ? new Date(resp.getCreatedAt().getSeconds() * 1000) + : undefined; + const _updatedAt = + typeof resp?.getUpdatedAt()?.getSeconds() !== "undefined" + ? new Date(resp.getUpdatedAt().getSeconds() * 1000) + : undefined; + const _textMatch = + typeof resp?.getMatch() !== "undefined" ? TextMatchInfo.from(resp.getMatch()) : undefined; + + return new DocMeta(_createdAt, _updatedAt, _textMatch); + } +} + +/** + * Information about the matched document + */ +export class TextMatchInfo { + readonly fields: ReadonlyArray; + readonly score: string; + readonly vectorDistance?: number; + + constructor(fields: ReadonlyArray, score: string, vectorDistance?: number) { + this.fields = fields; + this.score = score; + if (vectorDistance) { + this.vectorDistance = vectorDistance; + } + } + + static from(resp: ProtoMatch): TextMatchInfo { + const matchFields: Array = resp.getFieldsList().map((f) => f.getName()); + return new TextMatchInfo(matchFields, resp.getScore(), resp.getVectorDistance()); + } +} + +/** + * Distribution of values in a faceted field + */ +class FacetCountDistribution { + /** + * List of field values and their aggregated counts + * @readonly + */ + readonly counts: ReadonlyArray; + + /** + * Summary of faceted field + * @readonly + */ + readonly stats: FacetStats; + + constructor(counts: ReadonlyArray, stats: FacetStats) { + this.counts = counts; + this.stats = stats; + } + + static from(resp: ProtoSearchFacet): FacetCountDistribution { + const stats = + typeof resp?.getStats() !== "undefined" ? FacetStats.from(resp.getStats()) : undefined; + const counts = resp.getCountsList().map((c) => FacetCount.from(c)); + return new FacetCountDistribution(counts, stats); + } +} + +/** + * Aggregate count of values in a faceted field + */ +export class FacetCount { + /** + * Field's attribute value + * @readonly + */ + readonly value: string; + /** + * Count of field values in the search results + * @readonly + */ + readonly count: number; + + constructor(value: string, count: number) { + this.value = value; + this.count = count; + } + + static from(resp: ProtoFacetCount): FacetCount { + return new FacetCount(resp.getValue(), resp.getCount()); + } +} + +/** + * Summary of field values in a faceted field + */ +export class FacetStats { + /** + * Only for numeric fields. Average of values in a numeric field + * + * @defaultValue `0` + * @readonly + */ + readonly avg: number; + + /** + * Count of values in a faceted field + * @readonly + */ + readonly count: number; + + /** + * Only for numeric fields. Maximum value in a numeric field. + * + * @defaultValue `0` + * @readonly + */ + readonly max: number; + + /** + * Only for numeric fields. Minimum value in a numeric field. + * + * @defaultValue `0` + * @readonly + */ + readonly min: number; + + /** + * Only for numeric fields. Sum of numeric values in the field. + * + * @defaultValue `0` + * @readonly + */ + readonly sum: number; + + constructor(avg: number, count: number, max: number, min: number, sum: number) { + this.avg = avg; + this.count = count; + this.max = max; + this.min = min; + this.sum = sum; + } + + static from(resp: ProtoFacetStats): FacetStats { + return new FacetStats( + resp?.getAvg() ?? 0, + resp?.getCount() ?? 0, + resp?.getMax() ?? 0, + resp?.getMin() ?? 0, + resp?.getSum() ?? 0 + ); + } +} + +/** + * Metadata associated with search results + */ +export class SearchMeta { + /** + * Total number of matched hits for search query + * @readonly + */ + readonly found: number; + + /** + * Total number of pages of search results + * @readonly + */ + readonly totalPages: number; + + /** + * Current page information + * @readonly + */ + readonly page: Page; + + /** + * List of document fields matching the given input + * @readonly + */ + readonly matchedFields: ReadonlyArray; + + constructor(found: number, totalPages: number, page: Page, matchedFields: Array) { + this.found = found; + this.totalPages = totalPages; + this.page = page; + this.matchedFields = matchedFields; + } + + static from(resp: ProtoSearchMetadata): SearchMeta { + const found = resp?.getFound() ?? 0; + const totalPages = resp?.getTotalPages() ?? 0; + const page = typeof resp?.getPage() !== "undefined" ? Page.from(resp.getPage()) : undefined; + return new SearchMeta(found, totalPages, page, resp.getMatchedFieldsList()); + } + + /** + * @returns default metadata to construct empty/default response + * @readonly + */ + static get default(): SearchMeta { + return new SearchMeta(0, 1, Page.default, []); + } +} + +/** + * Pagination metadata associated with search results + */ +export class Page { + /** + * Current page number for the paginated search results + * @readonly + */ + readonly current; + + /** + * Maximum number of search results included per page + * @readonly + */ + readonly size; + + constructor(current, size) { + this.current = current; + this.size = size; + } + + static from(resp: ProtoSearchPage): Page { + const current = resp?.getCurrent() ?? 0; + const size = resp?.getSize() ?? 0; + return new Page(current, size); + } + + /** + * @returns the pre-defined page number and size to construct a default response + * @readonly + */ + static get default(): Page { + return new Page(1, 20); + } +} diff --git a/src/search/search-index.ts b/src/search/search-index.ts new file mode 100644 index 0000000..7721d27 --- /dev/null +++ b/src/search/search-index.ts @@ -0,0 +1,248 @@ +import { DocStatus, TigrisIndexType } from "./types"; +import { SearchClient } from "../proto/server/v1/search_grpc_pb"; +import { TigrisClientConfig } from "../tigris"; +import { + CreateDocumentRequest as ProtoCreateDocumentRequest, + CreateOrReplaceDocumentRequest as ProtoReplaceRequest, + DeleteByQueryRequest as ProtoDeleteByQueryRequest, + DeleteDocumentRequest as ProtoDeleteDocumentRequest, + GetDocumentRequest as ProtoGetDocumentRequest, + SearchIndexRequest as ProtoSearchIndexRequest, + SearchIndexResponse as ProtoSearchIndexResponse, + UpdateDocumentRequest as ProtoUpdateDocumentRequest, +} from "../proto/server/v1/search_pb"; +import { Utility } from "../utility"; +import { Filter } from "../types"; +import { SearchIndexIteratorInitializer, SearchIterator } from "../consumables/search-iterator"; +import * as grpc from "@grpc/grpc-js"; + +import { SearchQuery } from "./query"; +import { IndexedDoc, SearchResult } from "./result"; + +export class SearchIndex { + private readonly grpcClient: SearchClient; + private readonly name: string; + private readonly config: TigrisClientConfig; + + constructor(client, name, config) { + this.grpcClient = client; + this.name = name; + this.config = config; + } + + createMany(docs: Array): Promise> { + return new Promise>((resolve, reject) => { + const createRequest = new ProtoCreateDocumentRequest() + .setProject(this.config.projectName) + .setIndex(this.name); + for (const doc of docs) { + const encodedDoc = this.encodedDoc(doc); + createRequest.addDocuments(encodedDoc); + } + this.grpcClient.create(createRequest, (error, response) => { + if (error) { + reject(error); + return; + } + const status: Array = response.getStatusList().map((d) => DocStatus.from(d)); + resolve(status); + }); + }); + } + + createOne(doc: T): Promise { + return new Promise((resolve, reject) => { + this.createMany([doc]) + .then((docStatuses) => resolve(docStatuses[0])) + .catch((error) => reject(error)); + }); + } + + deleteMany(ids: Array): Promise> { + return new Promise>((resolve, reject) => { + const delRequest = new ProtoDeleteDocumentRequest() + .setProject(this.config.projectName) + .setIndex(this.name) + .setIdsList(ids); + this.grpcClient.delete(delRequest, (error, response) => { + if (error) { + reject(error); + return; + } + resolve(response.getStatusList().map((d) => DocStatus.from(d))); + }); + }); + } + + deleteByQuery(filter: Filter): Promise { + return new Promise((resolve, reject) => { + const delRequest = new ProtoDeleteByQueryRequest() + .setProject(this.config.projectName) + .setIndex(this.name) + .setFilter(Utility.stringToUint8Array(Utility.filterToString(filter))); + + this.grpcClient.deleteByQuery(delRequest, (error, response) => { + if (error) { + reject(error); + return; + } + resolve(response.getCount()); + }); + }); + } + + deleteOne(id: string): Promise { + return new Promise((resolve, reject) => { + this.deleteMany([id]) + .then((docStatuses) => resolve(docStatuses[0])) + .catch((error) => reject(error)); + }); + } + + createOrReplaceMany(docs: Array): Promise> { + return new Promise>((resolve, reject) => { + const replaceRequest = new ProtoReplaceRequest() + .setProject(this.config.projectName) + .setIndex(this.name); + + for (const doc of docs) replaceRequest.addDocuments(this.encodedDoc(doc)); + + this.grpcClient.createOrReplace(replaceRequest, (error, response) => { + if (error) { + reject(error); + return; + } + resolve(response.getStatusList().map((d) => DocStatus.from(d))); + }); + }); + } + + createOrReplaceOne(doc: T): Promise { + return new Promise((resolve, reject) => { + this.createOrReplaceMany([doc]) + .then((docStatuses) => resolve(docStatuses[0])) + .catch((error) => reject(error)); + }); + } + + getMany(ids: Array): Promise>> { + return new Promise>>((resolve, reject) => { + const getRequest = new ProtoGetDocumentRequest() + .setProject(this.config.projectName) + .setIndex(this.name) + .setIdsList(ids); + this.grpcClient.get(getRequest, (error, response) => { + if (error) { + reject(error); + return; + } + const docs: IndexedDoc[] = response.getDocumentsList().map((d) => { + return IndexedDoc.from(d, this.config); + }); + resolve(docs); + }); + }); + } + + getOne(id: string): Promise> { + return new Promise>((resolve, reject) => { + this.getMany([id]) + .then((docs) => resolve(docs[0])) + .catch((error) => reject(error)); + }); + } + + updateMany(docs: Array): Promise> { + return new Promise>((resolve, reject) => { + const updateRequest = new ProtoUpdateDocumentRequest() + .setProject(this.config.projectName) + .setIndex(this.name); + for (const doc of docs) updateRequest.addDocuments(this.encodedDoc(doc)); + + this.grpcClient.update(updateRequest, (error, response) => { + if (error) { + reject(error); + } + resolve(response.getStatusList().map((d) => DocStatus.from(d))); + }); + }); + } + + updateOne(doc: T): Promise { + return new Promise((resolve, reject) => { + this.updateMany([doc]) + .then((docStatuses) => resolve(docStatuses[0])) + .catch((error) => reject(error)); + }); + } + + /** + * Search for documents in an Index. Easily perform sophisticated queries and refine + * results using filters with advanced features like faceting and ordering. + * + * @param query - Search query to execute + * @returns {@link SearchIterator} - To iterate over pages of {@link SearchResult} + * + * @example + * ``` + * const iterator = client.getIndex("books").search(query); + * + * for await (const resultPage of iterator) { + * console.log(resultPage.hits); + * console.log(resultPage.facets); + * } + * ``` + */ + search(query: SearchQuery): SearchIterator; + + /** + * Search for documents in a collection. Easily perform sophisticated queries and refine + * results using filters with advanced features like faceting and ordering. + * + * @param query - Search query to execute + * @param page - Page number to retrieve. Page number `1` fetches the first page of search results. + * @returns - Single page of results wrapped in a Promise + * + * @example To retrieve page number 5 of matched documents + * ``` + * const resultPromise = client.getIndex("books").search(query, 5); + * + * resultPromise + * .then((res: SearchResult) => console.log(res.hits)) + * .catch( // catch the error) + * .finally( // finally do something); + * + * ``` + */ + search(query: SearchQuery, page: number): Promise>; + + search(query: SearchQuery, page?: number): SearchIterator | Promise> { + const searchRequest = new ProtoSearchIndexRequest() + .setProject(this.config.projectName) + .setIndex(this.name); + + Utility.protoSearchRequestFromQuery(query, searchRequest, page); + + // return a iterator if no explicit page number is specified + if (typeof page === "undefined") { + const initializer = new SearchIndexIteratorInitializer(this.grpcClient, searchRequest); + return new SearchIterator(initializer, this.config); + } else { + return new Promise>((resolve, reject) => { + const stream: grpc.ClientReadableStream = + this.grpcClient.search(searchRequest); + + stream.on("data", (searchResponse: ProtoSearchIndexResponse) => { + const searchResult: SearchResult = SearchResult.from(searchResponse, this.config); + resolve(searchResult); + }); + stream.on("error", (error) => reject(error)); + stream.on("end", () => resolve(SearchResult.empty)); + }); + } + } + + private encodedDoc(doc: T): Uint8Array { + return Utility.stringToUint8Array(Utility.objToJsonString(doc)); + } +} diff --git a/src/search/search.ts b/src/search/search.ts new file mode 100644 index 0000000..9d4911f --- /dev/null +++ b/src/search/search.ts @@ -0,0 +1,126 @@ +import { SearchClient } from "../proto/server/v1/search_grpc_pb"; +import { TigrisClientConfig } from "../tigris"; +import { DeleteIndexResponse, IndexInfo, TigrisIndexSchema, TigrisIndexType } from "./types"; +import { SearchIndex } from "./search-index"; +import { Utility } from "../utility"; +import { + CreateOrUpdateIndexRequest as ProtoCreateIndexRequest, + DeleteIndexRequest as ProtoDeleteIndexRequest, + GetIndexRequest as ProtoGetIndexRequest, + ListIndexesRequest as ProtoListIndexesRequest, +} from "../proto/server/v1/search_pb"; +import { DecoratedSchemaProcessor } from "../schema/decorated-schema-processor"; + +export class Search { + private readonly client: SearchClient; + private readonly config: TigrisClientConfig; + private readonly schemaProcessor: DecoratedSchemaProcessor; + + constructor(client: SearchClient, config: TigrisClientConfig) { + this.client = client; + this.config = config; + this.schemaProcessor = DecoratedSchemaProcessor.Instance; + } + + public createOrUpdateIndex( + cls: new () => TigrisIndexType + ): Promise>; + + public createOrUpdateIndex( + name: string, + schemaOrClass: TigrisIndexSchema | (new () => TigrisIndexType) + ): Promise>; + + public createOrUpdateIndex( + nameOrClass: string | TigrisIndexType, + schemaOrClass?: TigrisIndexSchema | TigrisIndexType + ): Promise> { + let indexName: string; + let mayBeClass: new () => TigrisIndexType; + let schema: TigrisIndexSchema; + + if (typeof nameOrClass === "string") { + indexName = nameOrClass as string; + if (typeof schemaOrClass === "function") { + mayBeClass = schemaOrClass as new () => TigrisIndexType; + } else { + schema = schemaOrClass as TigrisIndexSchema; + } + } else { + // only single class argument is passed + mayBeClass = nameOrClass as new () => TigrisIndexType; + } + + if (mayBeClass && !schema) { + const generatedIndex = this.schemaProcessor.processIndex(mayBeClass); + schema = generatedIndex.schema as TigrisIndexSchema; + // if indexName is not provided, use the one from model class + indexName = indexName ?? generatedIndex.name; + } + + const rawJSONSchema: string = Utility._indexSchematoJSON(indexName, schema); + const createOrUpdateIndexRequest = new ProtoCreateIndexRequest() + .setProject(this.projectName) + .setName(indexName) + .setSchema(Utility.stringToUint8Array(rawJSONSchema)); + return new Promise>((resolve, reject) => { + this.client.createOrUpdateIndex(createOrUpdateIndexRequest, (error, response) => { + if (error) { + reject(error); + return; + } + console.log(`Created index: ${response.getMessage()}`); + resolve(new SearchIndex(this.client, indexName, this.config)); + }); + }); + } + + public listIndexes(): Promise> { + // TODO: Set filter on request + const listIndexRequest = new ProtoListIndexesRequest().setProject(this.projectName); + return new Promise>((resolve, reject) => { + this.client.listIndexes(listIndexRequest, (error, response) => { + if (error) { + reject(error); + return; + } + resolve(response.getIndexesList().map((i) => IndexInfo.from(i))); + }); + }); + } + + public getIndex(name: string): Promise> { + const getIndexRequest = new ProtoGetIndexRequest().setProject(this.projectName).setName(name); + return new Promise>((resolve, reject) => { + this.client.getIndex(getIndexRequest, (error, response) => { + if (error) { + reject(error); + return; + } + if (response.hasIndex()) { + resolve(new SearchIndex(this.client, name, this.config)); + } + }); + }); + } + + public deleteIndex(name: string): Promise { + const deleteIndexRequest = new ProtoDeleteIndexRequest() + .setProject(this.projectName) + .setName(name); + + return new Promise((resolve, reject) => { + this.client.deleteIndex(deleteIndexRequest, (error, response) => { + if (error) { + reject(error); + return; + } + resolve(DeleteIndexResponse.from(response)); + }); + }); + } + + public get projectName(): string { + return this.config.projectName; + } +} diff --git a/src/search/types.ts b/src/search/types.ts index d37f5b6..8323ff4 100644 --- a/src/search/types.ts +++ b/src/search/types.ts @@ -1,518 +1,85 @@ -import { Filter, TigrisCollectionType } from "../types"; -import { - FacetCount as ProtoFacetCount, - FacetStats as ProtoFacetStats, - Page as ProtoSearchPage, - SearchFacet as ProtoSearchFacet, - SearchHit as ProtoSearchHit, - SearchHitMeta as ProtoSearchHitMeta, - SearchMetadata as ProtoSearchMetadata, - SearchResponse as ProtoSearchResponse, -} from "../proto/server/v1/api_pb"; -import { Utility } from "../utility"; -import { TigrisClientConfig } from "../tigris"; - -export const MATCH_ALL_QUERY_STRING = ""; - -/** - * Search request params - */ -export type SearchRequest = { - /** - * Text to query - */ - q: string; - /** - * Fields to project search query on - */ - searchFields?: Array; - /** - * Filter to further refine the search results - */ - filter?: Filter; - /** - * Facet fields to categorically arrange indexed terms - */ - facets?: FacetFieldsQuery; - /** - * Sort the search results in indicated order - */ - sort?: Ordering; - /** - * Document fields to include when returning search results - */ - includeFields?: Array; - /** - * Document fields to exclude when returning search results - */ - excludeFields?: Array; -}; - -/** - * Pagination and Collation options for search request - */ -export type SearchRequestOptions = { - /** - * Page number to fetch search results for - */ - page?: number; - /** - * Number of search results to fetch per page - */ - perPage?: number; - /** - * Allows case-insensitive filtering - */ - collation?: Collation; -}; - -export type FacetFieldsQuery = FacetFieldOptions | FacetFields; - -/** - * Map of collection field names and faceting options to include facet results in search response - */ -export type FacetFieldOptions = { - [key: string]: FacetQueryOptions; -}; - -/** - * Array of field names to include facet results for in search response - */ -export type FacetFields = Array; - -/** - * Information to build facets in search results - * Use `Utility.createFacetQueryOptions()` to generate using defaults - * - * @see {@link Utility.createFacetQueryOptions} - */ -export type FacetQueryOptions = { - /** - * Maximum number of facets to include in results - */ - size: number; - /** - * Type of facets to build - */ - type: FacetQueryFieldType; -}; - -export enum FacetQueryFieldType { - VALUE = "value", -} +import { TigrisArrayItem, TigrisDataTypes, TigrisResponse } from "../types"; -/** - * List of fields and their corresponding sort orders to order the search results. - */ -export type Ordering = Array; - -/** - * Collection field name and sort order - */ -export type SortField = { - field: string; - order: SortOrder; +import { Utility } from "../utility"; +import { + DeleteIndexResponse as ProtoDeleteIndexResponse, + DocStatus as ProtoDocStatus, + IndexInfo as ProtoIndexInfo, +} from "../proto/server/v1/search_pb"; +import { Status } from "../constants"; +import { TigrisError } from "../error"; + +export type SearchFieldOptions = { + searchIndex?: boolean; + sort?: boolean; + facet?: boolean; + dimensions?: number; + id?: boolean; }; -export enum SortOrder { - /** - * Ascending order - */ - ASC = "$asc", - - /** - * Descending order - */ - DESC = "$desc", -} - -export enum Case { - /** - * Case insensitive collation case - */ - CaseInsensitive = "ci", -} - -/** - * A collation allows you to specify string comparison rules. Default is case-sensitive. - */ -export type Collation = { - case: Case; +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface TigrisIndexType {} +export type TigrisIndexSchema = { + [K in keyof T]: { + type: TigrisDataTypes | TigrisIndexSchema; + items?: TigrisArrayItem; + } & SearchFieldOptions; }; -/** - * Outcome of executing search query - * @typeParam T - type of Tigris collection - */ -export class SearchResult { - private readonly _hits: ReadonlyArray>; - private readonly _facets: ReadonlyMap; - private readonly _meta: SearchMeta | undefined; +export class IndexInfo { + private readonly _name: string; + private readonly _schema: object; - constructor( - hits: Array>, - facets: Map, - meta: SearchMeta | undefined - ) { - this._hits = hits; - this._facets = facets; - this._meta = meta; + constructor(name, schema) { + this._name = name; + this._schema = schema; } - static get empty(): SearchResult { - return new SearchResult([], new Map(), undefined); + static from(info: ProtoIndexInfo): IndexInfo { + const schema = info.getSchema() + ? JSON.parse(Utility._base64Decode(info.getSchema_asB64())) + : {}; + return new this(info.getName(), schema); } - /** - * @returns matched documents as immutable list - * @readonly - */ - get hits(): ReadonlyArray> { - return this._hits; + get name(): string { + return this._name; } - /** - * @returns distribution of facets for fields included in facet query - * @readonly - */ - get facets(): ReadonlyMap { - return this._facets; - } - - /** - * @returns metadata associated with {@link SearchResult} - * @readonly - * @defaultValue undefined - */ - get meta(): SearchMeta | undefined { - return this._meta; - } - - static from(resp: ProtoSearchResponse, config: TigrisClientConfig): SearchResult { - const _meta = - typeof resp?.getMeta() !== "undefined" ? SearchMeta.from(resp.getMeta()) : undefined; - const _hits: Array> = resp.getHitsList().map((h) => Hit.from(h, config)); - const _facets: Map = new Map( - resp - .getFacetsMap() - .toArray() - .map( - // eslint-disable-next-line @typescript-eslint/no-unused-vars - ([k, _]) => [k, FacetCountDistribution.from(resp.getFacetsMap().get(k))] - ) - ); - return new SearchResult(_hits, _facets, _meta); + get schema(): object { + return this._schema; } } -/** - * Matched document and relevance metadata for a search query - * @typeParam T - type of Tigris collection - */ -export class Hit { - private readonly _document: T; - private readonly _meta: HitMeta | undefined; - - constructor(document: T, meta: HitMeta | undefined) { - this._document = document; - this._meta = meta; - } - - /** - * @returns json deserialized collection document - * @readonly - */ - get document(): T { - return this._document; +export class DeleteIndexResponse implements TigrisResponse { + status: Status = Status.Deleted; + private readonly _message: string; + constructor(message: string) { + this._message = message; } - /** - * @returns relevance metadata for the matched document - * @readonly - */ - get meta(): HitMeta | undefined { - return this._meta; + get message(): string { + return this._message; } - static from(resp: ProtoSearchHit, config: TigrisClientConfig): Hit { - const document = Utility.jsonStringToObj( - Utility._base64Decode(resp.getData_asB64()), - config - ); - const meta = resp.hasMetadata() ? HitMeta.from(resp.getMetadata()) : undefined; - return new Hit(document, meta); + static from(resp: ProtoDeleteIndexResponse): DeleteIndexResponse { + return new this(resp.getMessage()); } } -/** - * Relevance metadata for a matched document - */ -export class HitMeta { - private readonly _createdAt: Date | undefined; - private readonly _updatedAt: Date | undefined; - - constructor(createdAt: Date | undefined, updatedAt: Date | undefined) { - this._createdAt = createdAt; - this._updatedAt = updatedAt; - } - - /** - * @returns time at which document was inserted/replaced to a precision of milliseconds - * @readonly - */ - get createdAt(): Date | undefined { - return this._createdAt; - } - - /** - * @returns time at which document was updated to a precision of milliseconds - * @readonly - */ - get updatedAt(): Date | undefined { - return this._updatedAt; - } - - static from(resp: ProtoSearchHitMeta): HitMeta { - const _createdAt = - typeof resp?.getCreatedAt()?.getSeconds() !== "undefined" - ? new Date(resp.getCreatedAt().getSeconds() * 1000) - : undefined; - const _updatedAt = - typeof resp?.getUpdatedAt()?.getSeconds() !== "undefined" - ? new Date(resp.getUpdatedAt().getSeconds() * 1000) - : undefined; - - return new HitMeta(_createdAt, _updatedAt); - } -} - -/** - * Distribution of values in a faceted field - */ -export class FacetCountDistribution { - private readonly _counts: ReadonlyArray; - private readonly _stats: FacetStats | undefined; - - constructor(counts: ReadonlyArray, stats: FacetStats | undefined) { - this._counts = counts; - this._stats = stats; - } - - /** - * @returns list of field values and their aggregated counts - * @readonly - */ - get counts(): ReadonlyArray { - return this._counts; - } - - /** - * @returns summary of faceted field - * @readonly - */ - get stats(): FacetStats | undefined { - return this._stats; - } - - static from(resp: ProtoSearchFacet): FacetCountDistribution { - const stats = - typeof resp?.getStats() !== "undefined" ? FacetStats.from(resp.getStats()) : undefined; - const counts = resp.getCountsList().map((c) => FacetCount.from(c)); - return new FacetCountDistribution(counts, stats); - } -} - -/** - * Aggregate count of values in a faceted field - */ -export class FacetCount { - private readonly _value: string; - private readonly _count: number; - - constructor(value: string, count: number) { - this._value = value; - this._count = count; - } - - /** - * @returns field's attribute value - * @readonly - */ - get value(): string { - return this._value; - } - - /** - * @returns count of field values in the search results - * @readonly - */ - get count(): number { - return this._count; - } - - static from(resp: ProtoFacetCount): FacetCount { - return new FacetCount(resp.getValue(), resp.getCount()); - } -} - -/** - * Summary of field values in a faceted field - */ -export class FacetStats { - private readonly _avg: number; - private readonly _count: number; - private readonly _max: number; - private readonly _min: number; - private readonly _sum: number; - - constructor(avg: number, count: number, max: number, min: number, sum: number) { - this._avg = avg; - this._count = count; - this._max = max; - this._min = min; - this._sum = sum; - } - - /** - * Only for numeric fields. Average of values in a numeric field - * - * @returns average of values in a numeric field - * @defaultValue `0` - * @readonly - */ - get avg(): number { - return this._avg; - } - - /** - * @returns Count of values in a faceted field - * @readonly - */ - get count(): number { - return this._count; - } - - /** - * Only for numeric fields. Maximum value in a numeric field - * - * @returns maximum value in a numeric field - * @defaultValue `0` - * @readonly - */ - get max(): number { - return this._max; - } - - /** - * Only for numeric fields. Minimum value in a numeric field - * - * @returns minimum value in a numeric field - * @defaultValue `0` - * @readonly - */ - get min(): number { - return this._min; - } - - /** - * Only for numeric fields. Sum of numeric values in the field - * - * @returns sum of numeric values in the field - * @defaultValue `0` - * @readonly - */ - get sum(): number { - return this._sum; - } - - static from(resp: ProtoFacetStats): FacetStats { - return new FacetStats( - resp?.getAvg() ?? 0, - resp?.getCount() ?? 0, - resp?.getMax() ?? 0, - resp?.getMin() ?? 0, - resp?.getSum() ?? 0 - ); - } -} - -/** - * Metadata associated with search results - */ -export class SearchMeta { - private readonly _found: number; - private readonly _totalPages: number; - private readonly _page: Page; - - constructor(found: number, totalPages: number, page: Page) { - this._found = found; - this._totalPages = totalPages; - this._page = page; - } - - /** - * @returns total number of matched hits for search query - * @readonly - */ - get found(): number { - return this._found; - } - - /** - * @returns total number of pages of search results - * @readonly - */ - get totalPages(): number { - return this._totalPages; - } - - /** - * @returns current page information - * @readonly - */ - get page(): Page { - return this._page; - } - - static from(resp: ProtoSearchMetadata): SearchMeta { - const found = resp?.getFound() ?? 0; - const totalPages = resp?.getTotalPages() ?? 0; - const page = typeof resp?.getPage() !== "undefined" ? Page.from(resp.getPage()) : undefined; - return new SearchMeta(found, totalPages, page); - } -} - -/** - * Pagination metadata associated with search results - */ -export class Page { - private readonly _current; - private readonly _size; - - constructor(current, size) { - this._current = current; - this._size = size; - } - - /** - * @returns current page number for the paginated search results - * @readonly - */ - get current() { - return this._current; - } +export class DocStatus { + readonly id: string; + readonly error?: TigrisError; - /** - * @returns maximum number of search results included per page - * @readonly - */ - get size() { - return this._size; + constructor(id: string, error: TigrisError) { + this.id = id; + this.error = error; } - static from(resp: ProtoSearchPage): Page { - const current = resp?.getCurrent() ?? 0; - const size = resp?.getSize() ?? 0; - return new Page(current, size); + static from(protoStatus: ProtoDocStatus): DocStatus { + const err = protoStatus.hasError() + ? new TigrisError(protoStatus.getError().getMessage()) + : undefined; + return new this(protoStatus.getId(), err); } } diff --git a/src/session.ts b/src/session.ts index d007456..9451e04 100644 --- a/src/session.ts +++ b/src/session.ts @@ -12,6 +12,7 @@ export class Session { private readonly _origin: string; private readonly grpcClient: TigrisClient; private readonly db: string; + private readonly branch: string; private readonly _additionalMetadata: Metadata; constructor( @@ -19,12 +20,14 @@ export class Session { origin: string, grpcClient: TigrisClient, db: string, + branch: string, additionalMetadata: Metadata ) { this._id = id; this._origin = origin; this.grpcClient = grpcClient; this.db = db; + this.branch = branch; this._additionalMetadata = additionalMetadata; } @@ -42,7 +45,9 @@ export class Session { public commit(): Promise { return new Promise((resolve, reject) => { - const request = new ProtoCommitTransactionRequest().setDb(this.db); + const request = new ProtoCommitTransactionRequest() + .setProject(this.db) + .setBranch(this.branch); this.grpcClient.commitTransaction(request, Utility.txToMetadata(this), (error, response) => { if (error) { reject(error); @@ -55,7 +60,9 @@ export class Session { public rollback(): Promise { return new Promise((resolve, reject) => { - const request = new ProtoRollbackTransactionRequest().setDb(this.db); + const request = new ProtoRollbackTransactionRequest() + .setProject(this.db) + .setBranch(this.branch); this.grpcClient.rollbackTransaction( request, Utility.txToMetadata(this), diff --git a/src/tigris.ts b/src/tigris.ts index c06fb75..2b0e2e8 100644 --- a/src/tigris.ts +++ b/src/tigris.ts @@ -2,25 +2,16 @@ import { TigrisClient } from "./proto/server/v1/api_grpc_pb"; import { ObservabilityClient } from "./proto/server/v1/observability_grpc_pb"; import { HealthAPIClient } from "./proto/server/v1/health_grpc_pb"; import * as grpc from "@grpc/grpc-js"; -import { ChannelCredentials, Metadata, status } from "@grpc/grpc-js"; -import { - CreateDatabaseRequest as ProtoCreateDatabaseRequest, - DatabaseOptions as ProtoDatabaseOptions, - DropDatabaseRequest as ProtoDropDatabaseRequest, - ListDatabasesRequest as ProtoListDatabasesRequest, -} from "./proto/server/v1/api_pb"; +import { ChannelCredentials, Metadata } from "@grpc/grpc-js"; import { GetInfoRequest as ProtoGetInfoRequest } from "./proto/server/v1/observability_pb"; import { HealthCheckInput as ProtoHealthCheckInput } from "./proto/server/v1/health_pb"; -import path from "node:path"; -import appRootPath from "app-root-path"; -import * as dotenv from "dotenv"; import { - DatabaseInfo, - DatabaseMetadata, - DatabaseOptions, - DropDatabaseResponse, + CacheMetadata, + DeleteCacheResponse, + ListCachesResponse, ServerMetadata, + TigrisCollectionType, } from "./types"; import { @@ -31,14 +22,29 @@ import { import { DB } from "./db"; import { AuthClient } from "./proto/server/v1/auth_grpc_pb"; import { Utility } from "./utility"; -import { loadTigrisManifest, TigrisManifest } from "./utils/manifest-loader"; import { Log } from "./utils/logger"; +import { DecoratorMetaStorage } from "./decorators/metadata/decorator-meta-storage"; +import { getDecoratorMetaStorage } from "./globals"; +import { Cache } from "./cache"; +import { CacheClient } from "./proto/server/v1/cache_grpc_pb"; +import { + CreateCacheRequest as ProtoCreateCacheRequest, + DeleteCacheRequest as ProtoDeleteCacheRequest, + ListCachesRequest as ProtoListCachesRequest, +} from "./proto/server/v1/cache_pb"; + +import { Status } from "@grpc/grpc-js/build/src/constants"; +import { initializeEnvironment } from "./utils/env-loader"; + +import { SearchClient } from "./proto/server/v1/search_grpc_pb"; +import { Search } from "./search/search"; const AuthorizationHeaderName = "authorization"; const AuthorizationBearer = "Bearer "; export interface TigrisClientConfig { serverUrl?: string; + projectName?: string; /** * Use clientId/clientSecret to authenticate production services. * Obtains at console.preview.tigrisdata.cloud in `Applications Keys` section @@ -63,6 +69,11 @@ export interface TigrisClientConfig { * Controls the ping interval, if not specified defaults to 300_000ms (i.e. 5 min) */ pingIntervalMs?: number; + + /** + * Database branch name + */ + branch?: string; } class TokenSupplier { @@ -136,31 +147,42 @@ const DEST_NAME_KEY = "destination-name"; export class Tigris { private readonly grpcClient: TigrisClient; private readonly observabilityClient: ObservabilityClient; + private readonly cacheClient: CacheClient; + private readonly searchClient: SearchClient; private readonly healthAPIClient: HealthAPIClient; private readonly _config: TigrisClientConfig; + private readonly _metadataStorage: DecoratorMetaStorage; private readonly _ping: () => void; private readonly pingId: NodeJS.Timeout | number | string | undefined; /** + * Create Tigris client * - * @param {TigrisClientConfig} config configuration + * @param config - {@link TigrisClientConfig} configuration */ constructor(config?: TigrisClientConfig) { - dotenv.config(); + initializeEnvironment(); if (typeof config === "undefined") { config = {}; } if (config.serverUrl === undefined) { config.serverUrl = DEFAULT_URL; - - if ("TIGRIS_URI" in process.env) { + if (process.env.TIGRIS_URI?.trim().length > 0) { config.serverUrl = process.env.TIGRIS_URI; } - if ("TIGRIS_URL" in process.env) { + if (process.env.TIGRIS_URL?.trim().length > 0) { config.serverUrl = process.env.TIGRIS_URL; } } + if (config.projectName === undefined) { + if (!("TIGRIS_PROJECT" in process.env)) { + throw new Error("Unable to resolve TIGRIS_PROJECT environment variable"); + } + + config.projectName = process.env.TIGRIS_PROJECT; + } + if (config.serverUrl.startsWith("https://")) { config.serverUrl = config.serverUrl.replace("https://", ""); } @@ -196,6 +218,8 @@ export class Tigris { const insecureCreds: ChannelCredentials = grpc.credentials.createInsecure(); this.grpcClient = new TigrisClient(config.serverUrl, insecureCreds); this.observabilityClient = new ObservabilityClient(config.serverUrl, insecureCreds); + this.cacheClient = new CacheClient(config.serverUrl, insecureCreds); + this.searchClient = new SearchClient(config.serverUrl, insecureCreds); this.healthAPIClient = new HealthAPIClient(config.serverUrl, insecureCreds); } else if (config.clientId === undefined || config.clientSecret === undefined) { throw new Error("Both `clientId` and `clientSecret` are required"); @@ -220,6 +244,8 @@ export class Tigris { ); this.grpcClient = new TigrisClient(config.serverUrl, channelCreds); this.observabilityClient = new ObservabilityClient(config.serverUrl, channelCreds); + this.cacheClient = new CacheClient(config.serverUrl, channelCreds); + this.searchClient = new SearchClient(config.serverUrl, channelCreds); this.healthAPIClient = new HealthAPIClient(config.serverUrl, channelCreds); this._ping = () => { this.healthAPIClient.health(new ProtoHealthCheckInput(), (error, response) => { @@ -242,67 +268,80 @@ export class Tigris { }); } } + this._metadataStorage = getDecoratorMetaStorage(); Log.info(`Using Tigris at: ${config.serverUrl}`); } + public getDatabase(): DB { + return new DB(this._config.projectName, this.grpcClient, this._config); + } + /** - * Lists the databases - * @return {Promise>} a promise of an array of - * DatabaseInfo + * Creates the cache for this project, if the cache doesn't already exist + * @param name - cache identifier */ - public listDatabases(): Promise> { - return new Promise>((resolve, reject) => { - this.grpcClient.listDatabases(new ProtoListDatabasesRequest(), (error, response) => { - if (error) { - reject(error); - } else { - const result = response - .getDatabasesList() - .map( - (protoDatabaseInfo) => - new DatabaseInfo(protoDatabaseInfo.getDb(), new DatabaseMetadata()) - ); - resolve(result); + public createCacheIfNotExists(name: string): Promise { + return new Promise((resolve, reject) => { + this.cacheClient.createCache( + new ProtoCreateCacheRequest().setProject(this._config.projectName).setName(name), + // eslint-disable-next-line @typescript-eslint/no-unused-vars + (error, response) => { + if (error && error.code != Status.ALREADY_EXISTS) { + reject(error); + } else { + resolve(new Cache(this._config.projectName, name, this.cacheClient, this._config)); + } } - }); + ); }); } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public createDatabaseIfNotExists(db: string, _options?: DatabaseOptions): Promise { - return new Promise((resolve, reject) => { - this.grpcClient.createDatabase( - new ProtoCreateDatabaseRequest().setDb(db).setOptions(new ProtoDatabaseOptions()), - // eslint-disable-next-line @typescript-eslint/no-unused-vars - (error, _response) => { - if (error && error.code != status.ALREADY_EXISTS) { + /** + * Deletes the entire cache from this project. + * @param name - cache identifier + */ + public deleteCache(name: string): Promise { + return new Promise((resolve, reject) => { + this.cacheClient.deleteCache( + new ProtoDeleteCacheRequest().setProject(this._config.projectName).setName(name), + (error, response) => { + if (error) { reject(error); } else { - resolve(new DB(db, this.grpcClient, this._config)); + resolve(new DeleteCacheResponse(response.getMessage())); } } ); }); } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public dropDatabase(db: string, _options?: DatabaseOptions): Promise { - return new Promise((resolve, reject) => { - this.grpcClient.dropDatabase( - new ProtoDropDatabaseRequest().setDb(db).setOptions(new ProtoDatabaseOptions()), + /** + * Lists all the caches for this project + */ + public listCaches(): Promise { + return new Promise((resolve, reject) => { + this.cacheClient.listCaches( + new ProtoListCachesRequest().setProject(this._config.projectName), (error, response) => { if (error) { reject(error); } else { - resolve(new DropDatabaseResponse(response.getStatus(), response.getMessage())); + const cachesMetadata: CacheMetadata[] = new Array(); + for (const value of response.getCachesList()) + cachesMetadata.push(new CacheMetadata(value.getName())); + resolve(new ListCachesResponse(cachesMetadata)); } } ); }); } - public getDatabase(db: string): DB { - return new DB(db, this.grpcClient, this._config); + public getCache(cacheName: string): Cache { + return new Cache(this._config.projectName, cacheName, this.cacheClient, this._config); + } + + public getSearch(): Search { + return new Search(this.searchClient, this._config); } public getServerMetadata(): Promise { @@ -318,33 +357,36 @@ export class Tigris { } /** - * Automatically provision Databases and Collections based on the directories - * and {@link TigrisSchema} definitions in file system + * Automatically create Project and create or update Collections. + * Collection classes decorated with {@link TigrisCollection} decorator will be + * created if not already existing. If Collection already exists, schema changes + * will be applied, if any. + * + * @param collections - Array of Collection classes + * + * @example + * ``` + * @TigrisCollection("todoItems") + * class TodoItem { + * @PrimaryKey(TigrisDataTypes.INT32, { order: 1 }) + * id: number; + * + * @Field() + * text: string; + * } * - * @param schemaPath - Directory location in file system. Recommended to - * provide an absolute path, else loader will try to access application's root - * path which may not be accurate. + * await db.registerSchemas([TodoItem]); + * ``` */ - public async registerSchemas(schemaPath: string) { - if (!path.isAbsolute(schemaPath)) { - schemaPath = path.join(appRootPath.toString(), schemaPath); - } - const manifest: TigrisManifest = loadTigrisManifest(schemaPath); - - for (const dbManifest of manifest) { - // create DB - const tigrisDb = await this.createDatabaseIfNotExists(dbManifest.dbName); - Log.event(`Created database: ${dbManifest.dbName}`); - - for (const coll of dbManifest.collections) { - // Create a collection - const collection = await tigrisDb.createOrUpdateCollection( - coll.collectionName, - coll.schema - ); - Log.event( - `Created collection: ${collection.collectionName} from schema: ${coll.schemaName} in db: ${dbManifest.dbName}` - ); + public async registerSchemas(collections: Array) { + const tigrisDb = this.getDatabase(); + + for (const coll of collections) { + const found = this._metadataStorage.getCollectionByTarget(coll as Function); + if (!found) { + Log.error(`No such collection defined: '${coll.toString()}'`); + } else { + await tigrisDb.createOrUpdateCollection(found.target.prototype.constructor); } } } diff --git a/src/topic.ts b/src/topic.ts deleted file mode 100644 index a41ce43..0000000 --- a/src/topic.ts +++ /dev/null @@ -1,309 +0,0 @@ -import * as grpc from "@grpc/grpc-js"; -import { TigrisClient } from "./proto/server/v1/api_grpc_pb"; -import * as server_v1_api_pb from "./proto/server/v1/api_pb"; -import { - PublishRequest as ProtoPublishRequest, - PublishRequestOptions as ProtoPublishRequestOptions, - SubscribeRequest as ProtoSubscribeRequest, - SubscribeRequestOptions as ProtoSubscribeRequestOptions, - SubscribeResponse as ProtoSubscribeResponse, -} from "./proto/server/v1/api_pb"; -import { Filter, PublishOptions, SubscribeOptions, TigrisTopicType } from "./types"; -import { Utility } from "./utility"; -import { TigrisClientConfig } from "./tigris"; -import { Readable } from "node:stream"; -import { clientReadableToStream } from "./consumables/utils"; -import { ReadOnlyCollection } from "./collection"; - -/** - * Callback to receive events for a topic from server - */ -export interface SubscribeCallback { - /** - * Receives a message from server. Can be called many times but is never called after - * {@link onError} or {@link onEnd} are called. - * - * @param message - */ - onNext(message: T): void; - - /** - * Receives a notification of successful stream completion. - * - *

May only be called once and if called it must be the last method called. In particular, - * if an exception is thrown by an implementation of {@link onEnd} no further calls to any - * method are allowed. - */ - onEnd(): void; - - /** - * Receives terminating error from the stream. - * @param err - */ - onError(err: Error): void; -} - -/** - * The **Topic** class represents a events stream in Tigris. - */ -export class Topic extends ReadOnlyCollection { - private readonly _topicName: string; - - constructor(topicName: string, db: string, grpcClient: TigrisClient, config: TigrisClientConfig) { - super(topicName, db, grpcClient, config); - this._topicName = topicName; - } - - /** - * Name of this topic - */ - get topicName(): string { - return this._topicName; - } - - /** - * Publish multiple events to the topic - * - * @param messages - Array of events to publish - * @param {PublishOptions} options - Optional publishing options - * - * @example Publish messages to topic - *``` - * const tigris = new Tigris(config); - * const topic = tigris.getDatabase("my_db").getTopic("my_topic"); - * const messages = [new Message(1), new Message(2)]; - * topic.publishMany(messages) - * .then(result => console.log(result)) - * .catch(err => console.log(err)); - * ``` - * @returns Promise of published messages - */ - publishMany(messages: Array, options?: PublishOptions): Promise> { - return new Promise>((resolve, reject) => { - const messagesUintArray = new Array(); - const textEncoder = new TextEncoder(); - for (const message of messages) { - messagesUintArray.push(textEncoder.encode(Utility.objToJsonString(message))); - } - - const protoRequest = new ProtoPublishRequest() - .setDb(this.db) - .setCollection(this._topicName) - .setMessagesList(messagesUintArray); - - if (options) { - protoRequest.setOptions(new ProtoPublishRequestOptions().setPartition(options.partition)); - } - - this.grpcClient.publish( - protoRequest, - (error: grpc.ServiceError, response: server_v1_api_pb.PublishResponse): void => { - if (error !== undefined && error !== null) { - reject(error); - } else { - let messageIndex = 0; - const clonedMessages: T[] = Object.assign([], messages); - - for (const value of response.getKeysList_asU8()) { - const keyValueJsonObj: object = Utility.jsonStringToObj( - Utility.uint8ArrayToString(value), - this.config - ); - for (const fieldName of Object.keys(keyValueJsonObj)) { - Reflect.set(clonedMessages[messageIndex], fieldName, keyValueJsonObj[fieldName]); - messageIndex++; - } - } - resolve(clonedMessages); - } - } - ); - }); - } - - /** - * Publish a single message to topic - * - * @example Publish a message to topic - *``` - * const tigris = new Tigris(config); - * const topic = tigris.getDatabase("my_db").getTopic("my_topic"); - * topic.publish(new Message(1)) - * .then(result => console.log(result)) - * .catch(err => console.log(err)); - *``` - - * @param message - Message to publish - * @param {PublishOptions} options - Optional publishing options - * - * @returns Promise of the published message - */ - publish(message: T, options?: PublishOptions): Promise { - return new Promise((resolve, reject) => { - const messageArr: Array = new Array(); - messageArr.push(message); - this.publishMany(messageArr, options) - .then((messages) => { - resolve(messages[0]); - }) - .catch((error) => { - reject(error); - }); - }); - } - - /** - * Subscribe to listen for messages in a topic. Users can consume messages in one of two ways: - * 1. By providing an optional {@link SubscribeCallback} as param - * 2. By consuming {@link Readable} stream when no callback is provided - * - * @example Subscribe using callback - *``` - * const tigris = new Tigris(config); - * const topic = tigris.getDatabase("my_db").getTopic("my_topic"); - * - * topic.subscribe({ - * onNext(message: T) { - * console.log(message); - * }, - * onError(err: Error) { - * console.log(err); - * }, - * onEnd() { - * console.log("All messages consumed"); - * } - * }); - *``` - * - * @example Subscribe using {@link Readable} stream if callback is omitted - *``` - * const tigris = new Tigris(config); - * const topic = tigris.getDatabase("my_db").getTopic("my_topic"); - * const stream = topic.subscribe() as Readable; - * - * stream.on("data", (message: T) => console.log(message)); - * stream.on("error", (err: Error) => console.log(err)); - * stream.on("end", () => console.log("All messages consumed")); - *``` - * - * @param {SubscribeCallback} callback - Optional callback to consume messages - * @param {SubscribeOptions} options - Optional subscription options - * - * @returns {Readable} if no callback is provided, else nothing is returned - */ - subscribe(callback?: SubscribeCallback, options?: SubscribeOptions): Readable | void { - return this.subscribeWithFilter(undefined, callback, options); - } - - /** - * Subscribe to listen for messages in a topic that match given filter. Users can consume - * messages in one of two ways: - * 1. By providing an optional {@link SubscribeCallback} as param - * 2. By consuming {@link Readable} stream when no callback is provided - * - * @example Subscribe using callback - *``` - * const tigris = new Tigris(config); - * const topic = tigris.getDatabase("my_db").getTopic("my_topic"); - * const balanceLessThanThreshold = { - * op: SelectorFilterOperator.LT, - * fields: { - * balance: 200 - * } - * }; - * - * topic.subscribeWithFilter( - * balanceLessThanThreshold, - * { - * onNext(message: T) { - * console.log(message); - * }, - * onError(err: Error) { - * console.log(err); - * }, - * onEnd() { - * console.log("All messages consumed"); - * } - * } - * ); - *``` - * - * @example Subscribe using {@link Readable} stream if callback is omitted - *``` - * const tigris = new Tigris(config); - * const topic = tigris.getDatabase("my_db").getTopic("my_topic"); - * const balanceLessThanThreshold = { - * op: SelectorFilterOperator.LT, - * fields: { - * balance: 200 - * } - * }; - * const stream = topic.subscribe(balanceLessThanThreshold) as Readable; - * - * stream.on("data", (message: T) => console.log(message)); - * stream.on("error", (err: Error) => console.log(err)); - * stream.on("end", () => console.log("All messages consumed")); - *``` - * - * @param {Filter} filter - Subscription will only return messages that match this query - * @param {SubscribeCallback} callback - Optional callback to consume messages - * @param {SubscribeOptions} options - Optional subscription options - * - * @returns {Readable} if no callback is provided, else nothing is returned - */ - subscribeWithFilter( - filter: Filter, - callback?: SubscribeCallback, - options?: SubscribeOptions - ): Readable | void { - const subscribeRequest = new ProtoSubscribeRequest() - .setDb(this.db) - .setCollection(this._topicName); - - if (filter !== undefined) { - subscribeRequest.setFilter(Utility.stringToUint8Array(Utility.filterToString(filter))); - } - - if (options) { - subscribeRequest.setOptions( - new ProtoSubscribeRequestOptions().setPartitionsList(options.partitions) - ); - } - - const transform: (arg: ProtoSubscribeResponse) => T = (resp: ProtoSubscribeResponse) => { - return Utility.jsonStringToObj( - Utility._base64Decode(resp.getMessage_asB64()), - this.config - ); - }; - - const stream: grpc.ClientReadableStream = - this.grpcClient.subscribe(subscribeRequest); - - if (callback !== undefined) { - stream.on("data", (subscribeResponse: ProtoSubscribeResponse) => { - callback.onNext(transform(subscribeResponse)); - }); - - stream.on("error", (error) => callback.onError(error)); - stream.on("end", () => callback.onEnd()); - } else { - return clientReadableToStream(stream, transform); - } - } - - subscribeToPartitions( - partitions: Array, - callback?: SubscribeCallback - ): Readable | void { - return this.subscribeWithFilterToPartitions(undefined, partitions, callback); - } - - subscribeWithFilterToPartitions( - filter: Filter, - partitions: Array, - callback?: SubscribeCallback - ): Readable | void { - return this.subscribeWithFilter(filter, callback, new SubscribeOptions(partitions)); - } -} diff --git a/src/types.ts b/src/types.ts index 4803d8d..52432e2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,11 @@ -import { Collation } from "./search/types"; +/* eslint-disable @typescript-eslint/no-empty-interface */ +import { + CreateBranchResponse as ProtoCreateBranchResponse, + DeleteBranchResponse as ProtoDeleteBranchResponse, +} from "./proto/server/v1/api_pb"; +import { Status } from "./constants"; +import { Collation } from "./search/query"; +import { SearchFieldOptions } from "./search"; export class DatabaseInfo { private readonly _name: string; @@ -44,79 +51,113 @@ export class DatabaseOptions {} export class CollectionOptions {} -export class DropDatabaseResponse { - private readonly _status: string; +export interface TigrisResponse { + status: Status; + message?: string; +} + +export class CreateBranchResponse implements TigrisResponse { + status: Status = Status.Created; private readonly _message: string; - constructor(status: string, message: string) { - this._status = status; + constructor(message: string) { this._message = message; } - get status(): string { - return this._status; + get message(): string { + return this._message; + } + + static from(response: ProtoCreateBranchResponse): CreateBranchResponse { + return new this(response.getMessage()); + } +} + +export class DeleteBranchResponse implements TigrisResponse { + status: Status = Status.Deleted; + private readonly _message: string; + constructor(message: string) { + this._message = message; } get message(): string { return this._message; } + + static from(response: ProtoDeleteBranchResponse): DeleteBranchResponse { + return new this(response.getMessage()); + } } -export class DropCollectionResponse { - private readonly _status: string; +export class DropCollectionResponse implements TigrisResponse { + status: Status = Status.Dropped; private readonly _message: string; - constructor(status: string, message: string) { - this._status = status; + constructor(message: string) { this._message = message; } - get status(): string { - return this._status; - } - get message(): string { return this._message; } } export class DatabaseDescription { - private readonly _db: string; private readonly _metadata: DatabaseMetadata; - private readonly _collectionsDescription: Array; + private readonly _collectionsDescription: ReadonlyArray; + private readonly _branches: ReadonlyArray; constructor( - db: string, metadata: DatabaseMetadata, - collectionsDescription: Array + collectionsDescription: Array, + branches: Array ) { - this._db = db; this._metadata = metadata; this._collectionsDescription = collectionsDescription; - } - - get db(): string { - return this._db; + this._branches = branches; } get metadata(): DatabaseMetadata { return this._metadata; } - get collectionsDescription(): Array { + get collectionsDescription(): ReadonlyArray { return this._collectionsDescription; } + + get branches(): ReadonlyArray { + return this._branches; + } } +type IndexState = "INDEX WRITE MODE" | "INDEX ACTIVE"; + +export type IndexField = { + name: string; +}; + +export type IndexDescription = { + name: string; + state: IndexState; + fields?: IndexField[]; +}; + export class CollectionDescription { private readonly _collection: string; private readonly _metadata: CollectionMetadata; private readonly _schema: string; + private readonly _indexDescriptions?: IndexDescription[]; - constructor(collection: string, metadata: CollectionMetadata, schema: string) { + constructor( + collection: string, + metadata: CollectionMetadata, + schema: string, + indexDescriptions?: IndexDescription[] + ) { this._collection = collection; this._metadata = metadata; this._schema = schema; + this._indexDescriptions = indexDescriptions; } get collection(): string { @@ -130,17 +171,13 @@ export class CollectionDescription { get schema(): string { return this._schema; } -} - -export class TigrisResponse { - private readonly _status: string; - constructor(status: string) { - this._status = status; - } + get indexDescriptions(): IndexDescription[] { + if (!this._indexDescriptions) { + return []; + } - get status(): string { - return this._status; + return this._indexDescriptions; } } @@ -162,11 +199,15 @@ export class DMLMetadata { } } -export class DMLResponse extends TigrisResponse { +export interface DMLResponse { + metadata: DMLMetadata; +} + +export class DeleteResponse implements TigrisResponse, DMLResponse { + status: Status = Status.Deleted; private readonly _metadata: DMLMetadata; - constructor(status: string, metadata: DMLMetadata) { - super(status); + constructor(metadata: DMLMetadata) { this._metadata = metadata; } @@ -175,27 +216,28 @@ export class DMLResponse extends TigrisResponse { } } -export class DeleteResponse extends DMLResponse { - constructor(status: string, metadata: DMLMetadata) { - super(status, metadata); - } -} - -export class UpdateResponse extends DMLResponse { +export class UpdateResponse implements TigrisResponse, DMLResponse { + status: Status = Status.Updated; + private readonly _metadata: DMLMetadata; private readonly _modifiedCount: number; - constructor(status: string, modifiedCount: number, metadata: DMLMetadata) { - super(status, metadata); + + constructor(modifiedCount: number, metadata: DMLMetadata) { this._modifiedCount = modifiedCount; + this._metadata = metadata; } get modifiedCount(): number { return this._modifiedCount; } + + get metadata(): DMLMetadata { + return this._metadata; + } } export class WriteOptions {} -export class DeleteRequestOptions { +export class DeleteQueryOptions { private _collation: Collation; private _limit: number; @@ -221,7 +263,7 @@ export class DeleteRequestOptions { } } -export class UpdateRequestOptions { +export class UpdateQueryOptions { private _collation: Collation; private _limit: number; @@ -247,7 +289,7 @@ export class UpdateRequestOptions { } } -export class ReadRequestOptions { +export class FindQueryOptions { static DEFAULT_LIMIT = 100; static DEFAULT_SKIP = 0; @@ -260,8 +302,8 @@ export class ReadRequestOptions { constructor(limit: number, skip: number); constructor(limit?: number, skip?: number, offset?: string); constructor(limit?: number, skip?: number, offset?: string, collation?: Collation) { - this._limit = limit ?? ReadRequestOptions.DEFAULT_LIMIT; - this._skip = skip ?? ReadRequestOptions.DEFAULT_SKIP; + this._limit = limit ?? FindQueryOptions.DEFAULT_LIMIT; + this._skip = skip ?? FindQueryOptions.DEFAULT_SKIP; this._offset = offset; this._collation = collation; } @@ -301,89 +343,134 @@ export class ReadRequestOptions { export class TransactionOptions {} -export class StreamEvent { - private readonly _txId: string; - private readonly _collection: string; - private readonly _op: string; - private readonly _data: T; - private readonly _last: boolean; +export class CommitTransactionResponse implements TigrisResponse { + status: Status = Status.Ok; + private readonly _message: string; - constructor(txId: string, collection: string, op: string, data: T, last: boolean) { - this._txId = txId; - this._collection = collection; - this._op = op; - this._data = data; - this._last = last; + constructor(message: string) { + this._message = message; } - get txId(): string { - return this._txId; + get message(): string { + return this._message; } +} - get collection(): string { - return this._collection; +export class RollbackTransactionResponse implements TigrisResponse { + status: Status = Status.Ok; + private readonly _message: string; + + constructor(message: string) { + this._message = message; } - get op(): string { - return this._op; + get message(): string { + return this._message; } +} + +export class TransactionResponse implements TigrisResponse { + status: Status = Status.Ok; +} + +export class CacheMetadata { + private readonly _name: string; - get data(): T { - return this._data; + constructor(name: string) { + this._name = name; } - get last(): boolean { - return this._last; + get name(): string { + return this._name; } } -export class CommitTransactionResponse extends TigrisResponse { - constructor(status: string) { - super(status); +export class ListCachesResponse { + private readonly _caches: CacheMetadata[]; + + constructor(caches: CacheMetadata[]) { + this._caches = caches; } -} -export class RollbackTransactionResponse extends TigrisResponse { - public constructor(status: string) { - super(status); + get caches(): CacheMetadata[] { + return this._caches; } } -export class TransactionResponse extends TigrisResponse { - constructor(status: string) { - super(status); +export class DeleteCacheResponse implements TigrisResponse { + status: Status = Status.Deleted; + private readonly _message: string; + + constructor(message: string) { + this._message = message; + } + + get message(): string { + return this._message; } } -export class PublishOptions { - private _partition: number; +export class CacheSetResponse implements TigrisResponse { + status: Status = Status.Set; + private readonly _message: string; + + constructor(message: string) { + this._message = message; + } - constructor(partition: number) { - this._partition = partition; + get message(): string { + return this._message; } +} + +export class CacheGetSetResponse extends CacheSetResponse { + private readonly _old_value: object; - get partition(): number { - return this._partition; + constructor(message: string, old_value?: object) { + super(message); + if (old_value !== undefined) { + this._old_value = old_value; + } } - set partition(value: number) { - this._partition = value; + get old_value(): object { + return this._old_value; } } -export class SubscribeOptions { - private _partitions: Array; +export class CacheDelResponse implements TigrisResponse { + status: Status = Status.Deleted; + private readonly _message: string; + + constructor(status: string, message: string) { + this._message = message; + } - constructor(partitions: Array) { - this._partitions = partitions; + get message(): string { + return this._message; } +} + +export interface CacheSetOptions { + // optional ttl in seconds + ex?: number; + // optional ttl in ms + px?: number; + // only set if key doesn't exist + nx?: boolean; + // only set if key exists + xx?: boolean; +} + +export class CacheGetResponse { + private readonly _value: object; - get partitions(): Array { - return this._partitions; + constructor(value: object) { + this._value = value; } - set partitions(value: Array) { - this._partitions = value; + get value(): object { + return this._value; } } @@ -399,57 +486,146 @@ export class ServerMetadata { } } -// Marker interface -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface TigrisCollectionType {} +// Marker interfaces +export interface TigrisCollectionType { + // TODO: add a discriminator here +} -// Marker interface -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface TigrisTopicType extends TigrisCollectionType {} +export type NumericType = number | bigint; +export type FieldTypes = string | boolean | NumericType | BigInteger | Date | object; -export enum CollectionType { - DOCUMENTS = "documents", - MESSAGES = "messages", -} +export type ReadFields = { + include?: Array>; + exclude?: Array>; +}; -export enum LogicalOperator { - AND = "$and", - OR = "$or", -} +type DocumentFields = Partial<{ + [K in Paths]: V; +}>; + +export type UpdateFields = + | { + $set?: DocumentFields; + $unset?: Partial>[]; + $increment?: DocumentFields; + $decrement?: DocumentFields; + $multiply?: DocumentFields; + $divide?: DocumentFields; + } + | DocumentFields; + +/** + * List of fields and their corresponding sort order to order the search results. + */ +export type SortOrder = SortField | Array>; + +/** + * Collection field name and sort order + */ +export type SortField = { + field: DocumentPaths; + order: "$asc" | "$desc"; +}; + +/** + * Group by fields + */ +export type GroupByField = { + fields: Array; +}; + +/** + * Query builder for reading documents from a collection + * @public + */ +export interface FindQuery { + /** + * Filter to match the documents. Query will match all documents without a filter. + */ + filter?: Filter; + + /** + * Field projection to allow returning only specific document fields. By default + * all document fields are returned. + */ + readFields?: ReadFields; + + /** + * Sort the query results as per indicated order + */ + sort?: SortOrder; -export enum SelectorFilterOperator { - EQ = "$eq", - LT = "$lt", - LTE = "$lte", - GT = "$gt", - GTE = "$gte", - NONE = "$none", + /** + * Optional params + */ + options?: FindQueryOptions; } -export enum UpdateFieldsOperator { - SET = "$set", +/** + * Query builder for deleting documents from a collection + * @public + */ +export interface DeleteQuery { + /** + * Filter to match the documents + */ + filter: Filter; + + /** + * Optional params + */ + options?: DeleteQueryOptions; } -export type FieldTypes = string | number | boolean | bigint | BigInteger; +/** + * Query builder for updating documents in a collection + * @public + */ +export interface UpdateQuery { + /** + * Filter to match the documents + */ + filter: Filter; -export type LogicalFilter = { - op: LogicalOperator; - selectorFilters?: Array | Selector>; - logicalFilters?: Array>; -}; + /** + * Document fields to update and the update operation + */ + fields: UpdateFields; -export type ReadFields = { - include?: Array; - exclude?: Array; -}; + /** + * Optional params + */ + options?: UpdateQueryOptions; +} -export type UpdateFields = { - op: UpdateFieldsOperator; - fields: SimpleUpdateField; -}; -export type SimpleUpdateField = { - [key: string]: FieldTypes | undefined; -}; +export type ReadType = "primary index" | "secondary index"; +/** + * Explain Response + * @public + */ +export interface ExplainResponse { + /** + * Filter used to match the documents + */ + filter: string; + /** + * Sets whether the query read from the primary index or a secondary index + */ + readType: ReadType; + /** + * The field used to read from the secondary index + */ + field?: string; + /** + * The key range used to query the secondary index + */ + keyRange?: string[]; + + /** + * Sort field + */ + sort?: string; +} export enum TigrisDataTypes { STRING = "string", @@ -474,20 +650,48 @@ export enum TigrisDataTypes { OBJECT = "object", } -export type TigrisSchema = { - [K in keyof T]: { - type: TigrisDataTypes | TigrisSchema; - primary_key?: TigrisPrimaryKey; - items?: TigrisArrayItem; - }; +/** + * DB generated values for the schema fields + */ +export enum GeneratedField { + NOW = "now()", + CUID = "cuid()", + UUID = "uuid()", +} + +export type AutoTimestamp = "createdAt" | "updatedAt"; + +export type CollectionFieldOptions = { + /** + * Max length for "string" type of fields + */ + maxLength?: number; + /** + * Default value for the schema field + */ + default?: GeneratedField | FieldTypes | Array | Record; + + /** + * Let DB generate values for `Date` type of fields + */ + timestamp?: AutoTimestamp; + /** + * Dimensions for a vector field + */ + dimensions?: number; + /** + * Create a secondary index on the field + */ + index?: boolean; }; -export type TigrisTopicSchema = { +export type TigrisSchema = { [K in keyof T]: { - type: TigrisDataTypes | TigrisTopicSchema; - key?: TigrisPartitionKey; + type: TigrisDataTypes | TigrisSchema; + primary_key?: PrimaryKeyOptions; items?: TigrisArrayItem; - }; + } & CollectionFieldOptions & + SearchFieldOptions; }; export type TigrisArrayItem = { @@ -495,35 +699,29 @@ export type TigrisArrayItem = { items?: TigrisArrayItem | TigrisDataTypes; }; -export type TigrisPrimaryKey = { - order: number; +export type PrimaryKeyOptions = { + order?: number; autoGenerate?: boolean; }; -export type TigrisPartitionKey = { - order: number; -}; - /** -Generates all possible paths for type parameter T. By recursively iterating over its keys. While - iterating the keys it makes the keys available in string form and in non string form both. For - example - - interface IUser { - name: string; - id: number - address: Address; - } - - interface Address { - city: string - state: string - } - - and Paths will make these keys available - name, id, address (object type) and also in the string form - "name", "id", "address.city", "address.state" - + * Generates all possible paths for type parameter T. By recursively iterating over its keys. While + * iterating the keys it makes the keys available in string form and in non string form both. For + * @example + * ``` + * interface IUser { + * name: string; + * id: number; + * address: Address; + * } + * + * interface Address { + * city: string + * state: string + * } + * ``` + * and Paths will make these keys available name, id, address (object type) and also in the + * string form "name", "id", "address.city", "address.state" */ type Paths = { [K in keyof T]: T[K] extends object @@ -538,6 +736,7 @@ type Paths = { /** * This type helps to infer the type of the path that Paths (above) has generated. */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars type PathType = P extends keyof T ? T[P] : P extends `${infer L}.${infer R}` @@ -546,13 +745,43 @@ type PathType = P extends keyof T : never : never; +// eslint-disable-next-line @typescript-eslint/no-unused-vars export type Selector = Partial<{ - [K in Paths]: Partial>; + [K in string]: unknown; }>; -export type SelectorFilter = Partial<{ - op?: SelectorFilterOperator; - fields: Selector; -}>; +/** + * Compute all possible property combinations + */ +type normalTypes = PropertyKey | BigInt | Date | boolean | Array; +export type DocumentPaths = T extends normalTypes + ? Cache + : { + [P in keyof T]: P extends string + ? Cache extends "" + ? DocumentPaths + : Cache | DocumentPaths + : `${Cache}${P & string}`; + }[keyof T]; + +export type SelectorOperator = + | "$eq" + | "$gt" + | "$gte" + | "$lt" + | "$lte" + | "$not" + | "$regex" + | "$contains" + | "$none"; +export type LogicalOperator = "$or" | "$and"; + +export type SelectorFilter = { + [K in DocumentPaths]?: PathType | { [P in SelectorOperator]?: PathType }; +}; + +export type LogicalFilter = { + [P in LogicalOperator]?: Array>; +}; -export type Filter = SelectorFilter | LogicalFilter | Selector; +export type Filter = SelectorFilter | LogicalFilter; diff --git a/src/utility.ts b/src/utility.ts index 8a57fc2..6e44675 100644 --- a/src/utility.ts +++ b/src/utility.ts @@ -3,35 +3,17 @@ import json_bigint from "json-bigint"; import { Session } from "./session"; import { - CollectionType, - DeleteRequestOptions, - LogicalFilter, - LogicalOperator, + DeleteQueryOptions, + Filter, + FindQueryOptions, + GroupByField, ReadFields, - ReadRequestOptions, - Selector, - SelectorFilter, - SelectorFilterOperator, - SimpleUpdateField, - TigrisCollectionType, + SortOrder, TigrisDataTypes, TigrisSchema, - TigrisTopicSchema, UpdateFields, - UpdateFieldsOperator, - UpdateRequestOptions, + UpdateQueryOptions, } from "./types"; -import * as fs from "node:fs"; -import { - Case, - FacetFieldsQuery, - FacetQueryFieldType, - FacetQueryOptions, - MATCH_ALL_QUERY_STRING, - Ordering, - SearchRequest, - SearchRequestOptions, -} from "./search/types"; import { Collation as ProtoCollation, DeleteRequestOptions as ProtoDeleteRequestOptions, @@ -40,6 +22,19 @@ import { UpdateRequestOptions as ProtoUpdateRequestOptions, } from "./proto/server/v1/api_pb"; import { TigrisClientConfig } from "./tigris"; +import { + FacetFieldsQuery, + FacetQueryOptions, + MATCH_ALL_QUERY_STRING, + SearchQuery, + TigrisIndexSchema, + VectorQuery, +} from "./search"; +import { SearchIndexRequest as ProtoSearchIndexRequest } from "./proto/server/v1/search_pb"; +import { + DuplicatePrimaryKeyOrderError, + MissingPrimaryKeyOrderInSchemaDefinitionError, +} from "./error"; export const Utility = { stringToUint8Array(input: string): Uint8Array { @@ -50,115 +45,79 @@ export const Utility = { return new TextDecoder().decode(input); }, - filterToString(filter: SelectorFilter | LogicalFilter | Selector): string { - if ( - Object.prototype.hasOwnProperty.call(filter, "op") && - (filter["op"] === LogicalOperator.AND || filter["op"] === LogicalOperator.OR) - ) { - // LogicalFilter - return Utility._logicalFilterToString(filter as LogicalFilter); - // eslint-disable-next-line no-prototype-builtins - } else if (filter.hasOwnProperty("op")) { - // SelectorFilter - return Utility._selectorFilterToString(filter as SelectorFilter); + /** @see tests for usage */ + branchNameFromEnv(given?: string): string | undefined { + const maybeBranchName = typeof given !== "undefined" ? given : process.env.TIGRIS_DB_BRANCH; + if (typeof maybeBranchName === "undefined") { + return undefined; + } + const isTemplate = Utility.getTemplatedVar(maybeBranchName); + if (isTemplate) { + return isTemplate.extracted in process.env + ? maybeBranchName.replace( + isTemplate.matched, + this.nerfGitBranchName(process.env[isTemplate.extracted]) + ) + : undefined; } else { - // Selector (default operator $eq) - return Utility.objToJsonString(filter); + return this.nerfGitBranchName(maybeBranchName); } }, - _getRandomInt(upperBound: number): number { - return Math.floor(Math.random() * upperBound); - }, - _selectorFilterToString(filter: SelectorFilter): string { - switch (filter.op) { - case SelectorFilterOperator.NONE: - // filter nothing - return "{}"; - case SelectorFilterOperator.EQ: - case SelectorFilterOperator.LT: - case SelectorFilterOperator.LTE: - case SelectorFilterOperator.GT: - case SelectorFilterOperator.GTE: - return Utility.objToJsonString( - Utility._selectorFilterToFlatJSONObj(filter.op, filter.fields) - ); - default: - return ""; - } - }, - - _selectorFilterToFlatJSONObj(op: SelectorFilterOperator, fields: object): object { - switch (op) { - case SelectorFilterOperator.NONE: - return {}; - case SelectorFilterOperator.EQ: - return Utility._flattenObj(fields); - case SelectorFilterOperator.LT: - case SelectorFilterOperator.LTE: - case SelectorFilterOperator.GT: - case SelectorFilterOperator.GTE: { - const flattenedFields = Utility._flattenObj(fields); - for (const key in flattenedFields) { - flattenedFields[key] = { [op]: flattenedFields[key] }; - } - return flattenedFields; - } - default: - return Utility._flattenObj(fields); - } + + /** @see {@link branchNameFromEnv} tests for usage */ + getTemplatedVar(input: string): { matched: string; extracted: string } { + const output = input.match(/\${(.*?)}/); + return output ? { matched: output[0], extracted: output[1] } : undefined; }, - _logicalFilterToString(filter: LogicalFilter): string { - return this.objToJsonString(Utility._logicalFilterToJSONObj(filter)); + /** @see tests for usage */ + nerfGitBranchName(original: string) { + // only replace '/', '#', ' ' to avoid malformed urls + return original.replace(/[ #/]/g, "_"); }, - _logicalFilterToJSONObj(filter: LogicalFilter): object { - const result = {}; - const innerFilters = []; - result[filter.op] = innerFilters; - if (filter.selectorFilters) { - for (const value of filter.selectorFilters) { - // eslint-disable-next-line no-prototype-builtins - if (value.hasOwnProperty("op")) { - const v = value as SelectorFilter; - innerFilters.push(Utility._selectorFilterToFlatJSONObj(v.op, v.fields)); - } else { - const v = value as Selector; - innerFilters.push(Utility._selectorFilterToFlatJSONObj(SelectorFilterOperator.EQ, v)); - } + filterToString(filter: Filter): string { + for (const key of Object.keys(filter)) { + if (filter[key].constructor.name === "Date") { + filter[key] = (filter[key] as Date).toJSON(); } } - if (filter.logicalFilters) { - for (const value of filter.logicalFilters) - innerFilters.push(Utility._logicalFilterToJSONObj(value)); - } - return result; + return Utility.objToJsonString(filter); }, - - readFieldString(readFields: ReadFields): string { + _getRandomInt(upperBound: number): number { + return Math.floor(Math.random() * upperBound); + }, + readFieldString(readFields: ReadFields): string { const include = readFields.include?.reduce((acc, field) => ({ ...acc, [field]: true }), {}); const exclude = readFields.exclude?.reduce((acc, field) => ({ ...acc, [field]: false }), {}); return this.objToJsonString({ ...include, ...exclude }); }, - updateFieldsString(updateFields: UpdateFields | SimpleUpdateField) { + updateFieldsString(updateFields: UpdateFields) { // UpdateFields - // eslint-disable-next-line no-prototype-builtins - if (updateFields.hasOwnProperty("op")) { - const { op, fields } = updateFields as UpdateFields; - - return this.objToJsonString({ - [op]: fields, - }); - } else { - // SimpleUpdateField - return Utility.updateFieldsString({ - op: UpdateFieldsOperator.SET, - fields: updateFields as SimpleUpdateField, - }); + const updateBuilder: object = {}; + for (const [key, value] of Object.entries(updateFields)) { + switch (key) { + case "$set": + case "$unset": + case "$divide": + case "$increment": + case "$decrement": + case "$multiply": + updateBuilder[key] = value; + break; + default: + // by default everything else is a "$set" update + if (!("$set" in updateBuilder)) { + updateBuilder["$set"] = {}; + } + updateBuilder["$set"][key] = value; + } } + return this.objToJsonString(updateBuilder); }, + // eslint-disable-next-line @typescript-eslint/ban-types objToJsonString(obj: object): string { const JSONbigNative = json_bigint({ useNativeBigInt: true }); @@ -174,8 +133,8 @@ export const Utility = { * JSON serde mechanism - you might want to continue using it as `string`. * * - * @param json string representation of JSON object - * @param config Tigris client config instance + * @param json - string representation of JSON object + * @param config - Tigris client config instance */ jsonStringToObj(json: string, config: TigrisClientConfig): T { const JSONbigNative = json_bigint({ useNativeBigInt: true }); @@ -183,10 +142,18 @@ export const Utility = { // convert bigint to string based on configuration if (typeof v === "bigint" && (config.supportBigInt === undefined || !config.supportBigInt)) { return v.toString(); + } else if (typeof v === "string" && this._isISODateRegex(v)) { + return new Date(v); } + return v; }); }, + _isISODateRegex(value: string) { + const isoDateRegex = + /(\d{4}-[01]\d-[0-3]\dT[0-2](?:\d:[0-5]){2}\d\.\d+([+-][0-2]\d:[0-5]\d|Z))|(\d{4}-[01]\d-[0-3]\dT[0-2](?:\d:[0-5]){2}\d([+-][0-2]\d:[0-5]\d|Z))|(\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d([+-][0-2]\d:[0-5]\d|Z))/; + return isoDateRegex.test(value); + }, txToMetadata(tx: Session): Metadata { const metadata = new Metadata(); if (tx !== undefined && tx !== null) { @@ -225,12 +192,16 @@ export const Utility = { if (!ob.hasOwnProperty(key)) continue; if (typeof ob[key] == "object" && ob[key] !== null) { - const flatObject = Utility._flattenObj(ob[key]); - for (const x in flatObject) { - // eslint-disable-next-line no-prototype-builtins - if (!flatObject.hasOwnProperty(x)) continue; - - toReturn[key + "." + x] = flatObject[x]; + const value = ob[key]; + if (value.constructor.name === "Date") { + toReturn[key] = (value as Date).toJSON(); + } else { + const flatObject = Utility._flattenObj(value); + for (const x in flatObject) { + // eslint-disable-next-line no-prototype-builtins + if (!flatObject.hasOwnProperty(x)) continue; + toReturn[key + "." + x] = flatObject[x]; + } } } else { toReturn[key] = ob[key]; @@ -239,11 +210,13 @@ export const Utility = { return toReturn; }, - _toJSONSchema( - collectionName: string, - collectionType: CollectionType, - schema: TigrisSchema | TigrisTopicSchema - ): string { + _indexSchematoJSON(indexName: string, schema: TigrisIndexSchema): string { + const root = { title: indexName, type: "object" }; + root["properties"] = this._getSchemaProperties(schema, {}, {}); + return Utility.objToJsonString(root); + }, + + _collectionSchematoJSON(collectionName: string, schema: TigrisSchema): string { const root = {}; const pkeyMap = {}; const keyMap = {}; @@ -251,12 +224,7 @@ export const Utility = { root["additionalProperties"] = false; root["type"] = "object"; root["properties"] = this._getSchemaProperties(schema, pkeyMap, keyMap); - root["collection_type"] = collectionType; - if (collectionType === "documents") { - Utility._postProcessDocumentSchema(root, pkeyMap); - } else if (collectionType === "messages") { - Utility._postProcessMessageSchema(root, keyMap); - } + Utility._postProcessDocumentSchema(root, pkeyMap); return Utility.objToJsonString(root); }, /* @@ -267,36 +235,18 @@ export const Utility = { */ _postProcessDocumentSchema(result: object, pkeyMap: object): object { if (Object.keys(pkeyMap).length === 0) { - // if no pkeys was used defined. add implicit pkey - result["properties"]["id"] = { - type: "string", - format: "uuid", - }; - result["primary_key"] = ["id"]; - } else { - result["primary_key"] = []; - // add primary_key in order - for (let i = 1; i <= Object.keys(pkeyMap).length; i++) { - result["primary_key"].push(pkeyMap[i.toString()]); - } + return result; } - return result; - }, - - _postProcessMessageSchema(result: object, keyMap: object): object { - const len = Object.keys(keyMap).length; - if (len > 0) { - result["key"] = []; - // add key in order - for (let i = 1; i <= len; i++) { - result["key"].push(keyMap[i.toString()]); - } + result["primary_key"] = []; + // add primary_key in order + for (let i = 1; i <= Object.keys(pkeyMap).length; i++) { + result["primary_key"].push(pkeyMap[i.toString()]); } return result; }, _getSchemaProperties( - schema: TigrisSchema | TigrisTopicSchema, + schema: TigrisSchema | TigrisIndexSchema, pkeyMap: object, keyMap: object ): object { @@ -315,6 +265,9 @@ export const Utility = { pkeyMap, keyMap ); + } else if (schema[property].type === TigrisDataTypes.OBJECT) { + thisProperty["type"] = "object"; + thisProperty["properties"] = {}; } else if ( schema[property].type != TigrisDataTypes.ARRAY.valueOf() && typeof schema[property].type != "object" @@ -327,27 +280,93 @@ export const Utility = { // flat property could be a primary key if (schema[property].primary_key) { - pkeyMap[schema[property].primary_key["order"]] = property; + if (!schema[property].primary_key["order"]) { + /** + * if the order doesn't exists then default to 1. + * Check if order 1 already exists, if true then throw MissingPrimaryKeyOrderInSchemaDefinitionError + */ + if (pkeyMap["1"]) { + throw new MissingPrimaryKeyOrderInSchemaDefinitionError(property.toString()); + } + pkeyMap["1"] = property; + } else { + // validate duplicate order for primary key + if (pkeyMap[schema[property].primary_key["order"]]) { + throw new DuplicatePrimaryKeyOrderError( + schema[property].primary_key["order"], + pkeyMap[schema[property].primary_key["order"]] + ); + } + pkeyMap[schema[property].primary_key["order"]] = property; + } // autogenerate? if (schema[property].primary_key["autoGenerate"]) { thisProperty["autoGenerate"] = true; } } + // TODO: Add default_sort_by field + // flat property could be a partition key if (schema[property].key) { keyMap[schema[property].key["order"]] = property; } + // property is string and has "maxLength" optional attribute + if ( + thisProperty["type"] == TigrisDataTypes.STRING.valueOf() && + thisProperty["format"] === undefined && + schema[property].maxLength + ) { + thisProperty["maxLength"] = schema[property].maxLength as number; + } + // array type? } else if (schema[property].type === TigrisDataTypes.ARRAY.valueOf()) { thisProperty = this._getArrayBlock(schema[property], pkeyMap, keyMap); } + properties[property] = thisProperty; + + // 'default' values for schema fields, if any + if ("default" in schema[property]) { + switch (schema[property].default) { + case undefined: + // eslint-disable-next-line unicorn/no-null + thisProperty["default"] = null; + break; + default: + thisProperty["default"] = schema[property].default; + } + } + + // whether secondary index is enabled for this field + if ("index" in schema[property]) { + thisProperty["index"] = schema[property]["index"]; + } + + // indexing optionals + if ("searchIndex" in schema[property]) { + thisProperty["searchIndex"] = schema[property]["searchIndex"]; + } + if ("sort" in schema[property]) { + thisProperty["sort"] = schema[property]["sort"]; + } + if ("facet" in schema[property]) { + thisProperty["facet"] = schema[property]["facet"]; + } + if ("id" in schema[property]) { + thisProperty["id"] = schema[property]["id"]; + } + + // 'timestamp' values for schema fields + if ("timestamp" in schema[property]) { + thisProperty[schema[property].timestamp] = true; + } } return properties; }, - _readRequestOptionsToProtoReadRequestOptions(input: ReadRequestOptions): ProtoReadRequestOptions { + _readRequestOptionsToProtoReadRequestOptions(input: FindQueryOptions): ProtoReadRequestOptions { const result: ProtoReadRequestOptions = new ProtoReadRequestOptions(); if (input !== undefined) { if (input.skip !== undefined) { @@ -369,7 +388,7 @@ export const Utility = { return result; }, _deleteRequestOptionsToProtoDeleteRequestOptions( - input: DeleteRequestOptions + input: DeleteQueryOptions ): ProtoDeleteRequestOptions { const result: ProtoDeleteRequestOptions = new ProtoDeleteRequestOptions(); if (input !== undefined) { @@ -383,7 +402,7 @@ export const Utility = { return result; }, _updateRequestOptionsToProtoUpdateRequestOptions( - input: UpdateRequestOptions + input: UpdateQueryOptions ): ProtoUpdateRequestOptions { const result: ProtoUpdateRequestOptions = new ProtoUpdateRequestOptions(); if (input !== undefined) { @@ -403,25 +422,16 @@ export const Utility = { ): object { const arrayBlock = {}; arrayBlock["type"] = "array"; - arrayBlock["items"] = {}; - // array of array? - if (arraySchema["items"]["type"] === TigrisDataTypes.ARRAY.valueOf()) { - arrayBlock["items"] = this._getArrayBlock(arraySchema["items"], pkeyMap, keyMap); - // array of custom type? - } else if (typeof arraySchema["items"]["type"] === "object") { - arrayBlock["items"]["type"] = "object"; - arrayBlock["items"]["properties"] = this._getSchemaProperties( - arraySchema["items"]["type"], + if (typeof arraySchema === "object" && "dimensions" in arraySchema) { + arrayBlock["dimensions"] = arraySchema["dimensions"]; + arrayBlock["format"] = "vector"; + } else { + arrayBlock["items"] = {}; + arrayBlock["items"] = this._getSchemaProperties( + { _$arrayItemPlaceholder: arraySchema["items"] }, pkeyMap, keyMap - ); - // within array: single flat property? - } else { - arrayBlock["items"]["type"] = this._getType(arraySchema["items"]["type"] as TigrisDataTypes); - const format = this._getFormat(arraySchema["items"]["type"] as TigrisDataTypes); - if (format) { - arrayBlock["items"]["format"] = format; - } + )["_$arrayItemPlaceholder"]; } return arrayBlock; }, @@ -463,105 +473,127 @@ export const Utility = { return undefined; }, - _readTestDataFile(path: string): string { - return Utility.objToJsonString( - Utility.jsonStringToObj(fs.readFileSync("src/__tests__/data/" + path, "utf8"), { - serverUrl: "test", - }) - ); - }, - _base64Encode(input: string): string { - return Buffer.from(input, "binary").toString("base64"); + return Buffer.from(input, "utf8").toString("base64"); }, _base64Decode(b64String: string): string { - return Buffer.from(b64String, "base64").toString("binary"); + return Buffer.from(b64String, "base64").toString("utf8"); }, - createFacetQueryOptions(options?: Partial): FacetQueryOptions { - const defaults = { size: 10, type: FacetQueryFieldType.VALUE }; - return { ...defaults, ...options }; + _base64DecodeToObject(b64String: string, config: TigrisClientConfig): object { + return this.jsonStringToObj(Buffer.from(b64String, "base64").toString("utf8"), config); }, - createSearchRequestOptions(options?: Partial): SearchRequestOptions { - const defaults = { page: 1, perPage: 20, collation: { case: Case.CaseInsensitive } }; + defaultFacetingOptions(options?: Partial): FacetQueryOptions { + const defaults: FacetQueryOptions = { size: 10, type: "value" }; return { ...defaults, ...options }; }, - facetQueryToString(facets: FacetFieldsQuery): string { + facetQueryToString(facets: FacetFieldsQuery): string { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const optionsMap: any = {}; if (Array.isArray(facets)) { - const optionsMap = {}; for (const f of facets) { - optionsMap[f] = this.createFacetQueryOptions(); + optionsMap[f] = this.defaultFacetingOptions(); } - return this.objToJsonString(optionsMap); - } else { - return this.objToJsonString(facets); + } else if (typeof facets === "object") { + for (const f in facets) { + optionsMap[f] = this.defaultFacetingOptions(facets[f]); + } + } + return this.objToJsonString(optionsMap); + }, + + _vectorQueryToString(q: VectorQuery): string { + if (typeof q === "undefined") { + return ""; } + return this.objToJsonString(q); }, - sortOrderingToString(ordering: Ordering): string { - if (ordering === undefined || ordering.length === 0) { + _sortOrderingToString(ordering: SortOrder): string { + if (typeof ordering === "undefined") { return "[]"; } const sortOrders = []; + if (!Array.isArray(ordering)) { + ordering = [ordering]; + } for (const o of ordering) { sortOrders.push({ [o.field]: o.order }); } return this.objToJsonString(sortOrders); }, - createProtoSearchRequest( - dbName: string, - collectionName: string, - request: SearchRequest, - options?: SearchRequestOptions - ): ProtoSearchRequest { - const searchRequest = new ProtoSearchRequest() - .setDb(dbName) - .setCollection(collectionName) - .setQ(request.q ?? MATCH_ALL_QUERY_STRING); + _groupByToString(fields: string[]): string { + const groupBy: GroupByField = { + fields: [], + }; + + if (typeof fields === "undefined") { + return this.objToJsonString(groupBy); + } + + groupBy.fields = [...fields]; + + return this.objToJsonString(groupBy); + }, + + protoSearchRequestFromQuery( + query: SearchQuery, + searchRequest: ProtoSearchRequest | ProtoSearchIndexRequest, + page?: number + ) { + searchRequest.setQ(query.q ?? MATCH_ALL_QUERY_STRING); - if (request.searchFields !== undefined) { - searchRequest.setSearchFieldsList(request.searchFields); + if (query.searchFields !== undefined) { + searchRequest.setSearchFieldsList(query.searchFields); } - if (request.filter !== undefined) { - searchRequest.setFilter(Utility.stringToUint8Array(Utility.filterToString(request.filter))); + if (query.filter !== undefined) { + searchRequest.setFilter(Utility.stringToUint8Array(Utility.filterToString(query.filter))); } - if (request.facets !== undefined) { - searchRequest.setFacet( - Utility.stringToUint8Array(Utility.facetQueryToString(request.facets)) + if (query.facets !== undefined) { + searchRequest.setFacet(Utility.stringToUint8Array(Utility.facetQueryToString(query.facets))); + } + + if (query.vectorQuery !== undefined) { + searchRequest.setVector( + Utility.stringToUint8Array(Utility._vectorQueryToString(query.vectorQuery)) ); } - if (request.sort !== undefined) { - searchRequest.setSort(Utility.stringToUint8Array(Utility.sortOrderingToString(request.sort))); + if (query.sort !== undefined) { + searchRequest.setSort( + Utility.stringToUint8Array(Utility._sortOrderingToString(query.sort)) + ); } - if (request.includeFields !== undefined) { - searchRequest.setIncludeFieldsList(request.includeFields); + if (query.groupBy !== undefined) { + searchRequest.setGroupBy(Utility.stringToUint8Array(Utility._groupByToString(query.groupBy))); } - if (request.excludeFields !== undefined) { - searchRequest.setExcludeFieldsList(request.excludeFields); + if (query.includeFields !== undefined) { + searchRequest.setIncludeFieldsList(query.includeFields); } - if (options !== undefined) { - if (options.page !== undefined) { - searchRequest.setPage(options.page); - } - if (options.perPage !== undefined) { - searchRequest.setPageSize(options.perPage); - } - if (options.collation !== undefined) { - searchRequest.setCollation(new ProtoCollation().setCase(options.collation.case)); - } + if (query.excludeFields !== undefined) { + searchRequest.setExcludeFieldsList(query.excludeFields); } - return searchRequest; + if (query.hitsPerPage !== undefined) { + searchRequest.setPageSize(query.hitsPerPage); + } + + if (query.options?.collation !== undefined) { + searchRequest.setCollation(new ProtoCollation().setCase(query.options.collation.case)); + } + + if (page !== undefined) { + searchRequest.setPage(page); + } }, }; diff --git a/src/utils/env-loader.ts b/src/utils/env-loader.ts new file mode 100644 index 0000000..7356054 --- /dev/null +++ b/src/utils/env-loader.ts @@ -0,0 +1,67 @@ +import appRootPath from "app-root-path"; +import * as dotenv from "dotenv"; +import path from "path"; +import * as fs from "fs"; +import { Log } from "./logger"; + +/** + * Uses dotenv() to initialize `.env` config files based on the **NODE_ENV** + * environment variable in order -: + * 1. `.env.${NODE_ENV}.local` + * 2. `.env.local` + * 3. `.env.${NODE_ENV}` + * 4. `.env` + * + * @example If `NODE_ENV = production` + * ``` + * export NODE_ENV=production + * + * // will load following 4 config files in order: + * .env.production.local + * .env.local + * .env.production + * .env + * ``` + */ +export function initializeEnvironment() { + const envFiles = getEnvFiles(appRootPath.toString()); + for (const f of envFiles) { + dotenv.config({ path: f }); + } +} + +function getEnvFiles(dir: string) { + const nodeEnv = process.env.NODE_ENV; + const dotEnvFiles: Array = []; + switch (nodeEnv) { + case "test": + dotEnvFiles.push(`.env.${nodeEnv}.local`); + break; + case "development": + case "production": + dotEnvFiles.push(`.env.${nodeEnv}.local`, ".env.local"); + break; + } + dotEnvFiles.push(`.env.${nodeEnv}`, ".env"); + + const envFilePaths = []; + for (const envFile of dotEnvFiles) { + const envFilePath = path.join(dir, envFile); + try { + const stats = fs.statSync(envFilePath); + + // make sure to only attempt to read files + if (!stats.isFile()) { + continue; + } + + envFilePaths.push(envFilePath); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (error: any) { + if (error.code !== "ENOENT") { + Log.error(`Failed to read env from '${envFile}'`, error.message); + } + } + } + return envFilePaths; +} diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 0d95cff..d28d263 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -1,6 +1,7 @@ import chalk from "chalk"; class Logger { + private static _instance: Logger; private prefixes = { debug: chalk.green("debug") + " -", info: chalk.cyan("info") + " -", @@ -9,6 +10,16 @@ class Logger { event: chalk.magenta("event") + " -", }; + // eslint-disable-next-line @typescript-eslint/no-empty-function + private constructor() {} + + static get Instance(): Logger { + if (!Logger._instance) { + Logger._instance = new Logger(); + } + return this._instance; + } + public debug(...message: unknown[]) { console.log(this.prefixes.debug, ...message); } @@ -30,4 +41,4 @@ class Logger { } } -export const Log = new Logger(); +export const Log = Logger.Instance; diff --git a/src/utils/manifest-loader.ts b/src/utils/manifest-loader.ts deleted file mode 100644 index 0c81db3..0000000 --- a/src/utils/manifest-loader.ts +++ /dev/null @@ -1,113 +0,0 @@ -import path from "node:path"; -import fs from "node:fs"; -import { Log } from "./logger"; -import { TigrisSchema } from "../types"; -import { TigrisFileNotFoundError, TigrisMoreThanOneSchemaDefined } from "../error"; - -const COLL_FILE_SUFFIX = ".ts"; - -type CollectionManifest = { - collectionName: string; - schemaName: string; - schema: TigrisSchema; -}; - -type DatabaseManifest = { - dbName: string; - collections: Array; -}; - -/** - * Array of databases and collections in each database - */ -export type TigrisManifest = Array; - -/** - * Loads the databases and schema definitions from file system that can be used - * to create databases and collections - * - * @return TigrisManifest - */ -export function loadTigrisManifest(schemasPath: string): TigrisManifest { - Log.event(`Scanning ${schemasPath} for Tigris schema definitions`); - - if (!fs.existsSync(schemasPath)) { - Log.error(`Invalid path for Tigris schema: ${schemasPath}`); - throw new TigrisFileNotFoundError(`Directory not found: ${schemasPath}`); - } - - const tigrisFileManifest: TigrisManifest = new Array(); - - // load manifest from file structure - for (const schemaPathEntry of fs.readdirSync(schemasPath)) { - const dbDirPath = path.join(schemasPath, schemaPathEntry); - if (fs.lstatSync(dbDirPath).isDirectory()) { - Log.info(`Found DB definition ${schemaPathEntry}`); - const dbManifest: DatabaseManifest = { - dbName: schemaPathEntry, - collections: new Array(), - }; - - for (const dbPathEntry of fs.readdirSync(dbDirPath)) { - if (dbPathEntry.endsWith(COLL_FILE_SUFFIX)) { - const collFilePath = path.join(dbDirPath, dbPathEntry); - if (fs.lstatSync(collFilePath).isFile()) { - Log.info(`Found Schema file ${dbPathEntry} in ${schemaPathEntry}`); - const collName = dbPathEntry.slice( - 0, - Math.max(0, dbPathEntry.length - COLL_FILE_SUFFIX.length) - ); - // eslint-disable-next-line @typescript-eslint/no-var-requires,unicorn/prefer-module - const schemaFile = require(collFilePath); - const detectedSchemas = new Map>(); - // read schemas in that file - for (const [key, value] of Object.entries(schemaFile)) { - if (canBeSchema(value)) { - detectedSchemas.set(key, value as TigrisSchema); - } - } - if (detectedSchemas.size > 1) { - throw new TigrisMoreThanOneSchemaDefined(dbPathEntry, detectedSchemas.size); - } - for (const [name, def] of detectedSchemas) { - dbManifest.collections.push({ - collectionName: collName, - schema: def, - schemaName: name, - }); - Log.info(`Found schema definition: ${name}`); - } - } - } - } - if (dbManifest.collections.length === 0) { - Log.warn(`No valid schema definition found in ${schemaPathEntry}`); - } - tigrisFileManifest.push(dbManifest); - } - } - - Log.debug(`Generated Tigris Manifest: ${JSON.stringify(tigrisFileManifest)}`); - return tigrisFileManifest; -} - -/** - * Validate if given input can be a valid {@link TigrisSchema} type. This is - * not a comprehensive validation, it happens on server. - * - * @param maybeSchema - */ -export function canBeSchema(maybeSchema: unknown): boolean { - if (maybeSchema === null || typeof maybeSchema !== "object") { - return false; - } - for (const value of Object.values(maybeSchema)) { - if (value === null || typeof value !== "object") { - return false; - } - if (!Object.prototype.hasOwnProperty.call(value, "type")) { - return false; - } - } - return true; -} diff --git a/tsconfig.json b/tsconfig.json index 3ebf75e..f6c569a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { "experimentalDecorators": true, + "emitDecoratorMetadata": true, "target": "es6", "module": "commonjs", "declaration": true,