Compare commits

..
Author SHA1 Message Date
William Schurman 39093c3498 feat: add entity write validation 2020-06-23 20:43:20 -07:00
174 changed files with 4917 additions and 15543 deletions

No files matched your search

+1 -3
View File
@@ -9,9 +9,7 @@
], ],
"rules": { "rules": {
"no-restricted-imports": ["error",{ "paths": [".", "..", "../.."] }], "no-restricted-imports": ["error",{ "paths": [".", "..", "../.."] }],
"tsdoc/syntax": "warn", "tsdoc/syntax": "warn"
"no-console": "warn",
"handle-callback-err": "off"
}, },
"overrides": [ "overrides": [
{ {
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- uses: actions/setup-node@v1 - uses: actions/setup-node@v1
with: with:
node-version: 12.x node-version: 10.x
- name: Cache Node.js modules - name: Cache Node.js modules
uses: actions/cache@v1 uses: actions/cache@v1
with: with:
+1 -3
View File
@@ -10,11 +10,9 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
with:
ref: ${{ github.event.pull_request.head.sha }}
- uses: actions/setup-node@v1 - uses: actions/setup-node@v1
with: with:
node-version: 12.x node-version: 10.x
- name: Cache Node.js modules - name: Cache Node.js modules
uses: actions/cache@v1 uses: actions/cache@v1
with: with:
+3 -328
View File
@@ -1,331 +1,6 @@
# Change Log # Changelog
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
### Features
* Upgrade knex to 1.x in `@expo/entity-database-adapter-knex` ([#150](https://github.com/expo/entity/issues/150)) ([e1b0ee5](https://github.com/expo/entity/commit/e1b0ee528175646239d88aa71ffce73983eeb52f))
# [0.21.0](https://github.com/expo/entity/compare/v0.20.0...v0.21.0) (2022-01-03)
### Features
* add pre-commit callbacks on EntityQueryContext ([#147](https://github.com/expo/entity/issues/147)) ([f1d9847](https://github.com/expo/entity/commit/f1d9847210f5775de964509e44ce493e44482b21))
# [0.20.0](https://github.com/expo/entity/compare/v0.19.0...v0.20.0) (2021-12-29)
### Bug Fixes
* separate post-commit invalidation callbacks from trigger and user callbacks ([#144](https://github.com/expo/entity/issues/144)) ([efa131e](https://github.com/expo/entity/commit/efa131ef29532accf0e51b6d867ffe15a27b4c90))
### Features
* add cascading deletion info ([#145](https://github.com/expo/entity/issues/145)) ([3727191](https://github.com/expo/entity/commit/372719176742d3ead6b7cbca29cb66b09b3fe09c))
# [0.19.0](https://github.com/expo/entity/compare/v0.18.0...v0.19.0) (2021-10-28)
### Features
* add authorization metrics ([#142](https://github.com/expo/entity/issues/142)) ([7cf54d8](https://github.com/expo/entity/commit/7cf54d83c52e104159a3e2e675cdda2d29afe204))
* add IP support ([#138](https://github.com/expo/entity/issues/138)) ([1405a91](https://github.com/expo/entity/commit/1405a9126ee3a56c3e2a38bf3b697283744f5b5d))
# [0.18.0](https://github.com/expo/entity/compare/v0.17.0...v0.18.0) (2021-10-14)
### Features
* enable noImplicitOverride tsc setting ([#135](https://github.com/expo/entity/issues/135)) ([4263cb9](https://github.com/expo/entity/commit/4263cb9b8b69fe4fb68c74ec1c7aba04508a1555))
* expose describeFieldTestCase for use in other packages ([#139](https://github.com/expo/entity/issues/139)) ([1220477](https://github.com/expo/entity/commit/1220477aa331349b482ad015b44f76fa2bda7b90))
* upgrade TypeScript to 4.4 ([#134](https://github.com/expo/entity/issues/134)) ([7612392](https://github.com/expo/entity/commit/7612392dbfd9778d25c465c1626c372d75a6d05a))
* use better QueryContext types for transactional triggers and validators ([#136](https://github.com/expo/entity/issues/136)) ([5c7efe1](https://github.com/expo/entity/commit/5c7efe1678424ff907687e9327175bdc2538c3e0))
# [0.17.0](https://github.com/expo/entity/compare/v0.16.0...v0.17.0) (2021-08-16)
### Features
* Add IntField and FloatField, deprecate NumberField ([#131](https://github.com/expo/entity/issues/131)) ([2f2d963](https://github.com/expo/entity/commit/2f2d963cf92c6575d78f8eeca344a4aadcc9b8d9))
# [0.16.0](https://github.com/expo/entity/compare/v0.15.0...v0.16.0) (2021-07-07)
### Features
* allow null field values in loadManyByFieldEqualityConjunctionAsync ([#130](https://github.com/expo/entity/issues/130)) ([2f37dc6](https://github.com/expo/entity/commit/2f37dc6a37d368b12b3c8375e04f0777fd448797))
# [0.15.0](https://github.com/expo/entity/compare/v0.14.1...v0.15.0) (2021-05-26)
### Bug Fixes
* upgrade ioredis, knex, and pg ([#125](https://github.com/expo/entity/issues/125)) ([7c43edf](https://github.com/expo/entity/commit/7c43edf3de37e8af13e2a99bd44f03f54803d9b9))
* upgrade some other packages to fix dependabot alerts ([#126](https://github.com/expo/entity/issues/126)) ([226bffe](https://github.com/expo/entity/commit/226bffe0a95d7b8a3e9e9d46aebdb0edf1eeec1a))
* use column name instead of field name for redis cache key ([#124](https://github.com/expo/entity/issues/124)) ([d78f452](https://github.com/expo/entity/commit/d78f452bb3ac5527069813a03f5b2264375bd8ab))
### Features
* secondary cache loader ([#123](https://github.com/expo/entity/issues/123)) ([4cba01e](https://github.com/expo/entity/commit/4cba01eb259c87d60b3026ce776e46f781363690))
## [0.14.1](https://github.com/expo/entity/compare/v0.14.0...v0.14.1) (2021-03-24)
### Bug Fixes
* typescript typedef of EntityMutationInfo ([#121](https://github.com/expo/entity/issues/121)) ([8e8f0dc](https://github.com/expo/entity/commit/8e8f0dc8636e343e29a6012d155e4111477a8989))
# [0.14.0](https://github.com/expo/entity/compare/v0.13.0...v0.14.0) (2021-03-24)
### Bug Fixes
* mutation validator and trigger cyclic import structure ([#118](https://github.com/expo/entity/issues/118)) ([b11dbc0](https://github.com/expo/entity/commit/b11dbc087ba5ad13c748032698dd73279b7960b0))
### Features
* add mutation type to validator and trigger execute functions ([#119](https://github.com/expo/entity/issues/119)) ([ef40fa5](https://github.com/expo/entity/commit/ef40fa5e9cdf53e25e78a9423c698e6d1c9737af))
# [0.13.0](https://github.com/expo/entity/compare/v0.12.0...v0.13.0) (2021-02-12)
### Bug Fixes
* allow undefined value for all fields ([#114](https://github.com/expo/entity/issues/114)) ([022e0e9](https://github.com/expo/entity/commit/022e0e9b88a5e84e0c1bc6c8810c850beb05f0cf))
### Features
* add basic field type runtime validators ([#113](https://github.com/expo/entity/issues/113)) ([6c4d4b0](https://github.com/expo/entity/commit/6c4d4b03b5404dd776a12c5eb6f0af77a8c964f8))
# [0.12.0](https://github.com/expo/entity/compare/v0.11.0...v0.12.0) (2021-01-22)
### Bug Fixes
* move runInTransaction to ViewerContext ([#108](https://github.com/expo/entity/issues/108)) ([b7309e1](https://github.com/expo/entity/commit/b7309e18a2dd780b64cd306d566de1f12bf8df3a))
* remove cache and database adaptor flavor enums ([#109](https://github.com/expo/entity/issues/109)) ([72a77f8](https://github.com/expo/entity/commit/72a77f8b6893a4b76d3b1cce16659bdf3ce473ee))
### Features
* better typing on filterMap for use with predicates ([#106](https://github.com/expo/entity/issues/106)) ([01b3bfe](https://github.com/expo/entity/commit/01b3bfef7c6da55e7d89d8cccb52a0050187aee8))
# [0.11.0](https://github.com/expo/entity/compare/v0.10.0...v0.11.0) (2020-12-24)
### Bug Fixes
* add type generic default in test utils ([#104](https://github.com/expo/entity/issues/104)) ([fe2917e](https://github.com/expo/entity/commit/fe2917e6b5af808f166aa354d476e168c41e6d21))
* maps utils invariants for collections containing undefined values ([#103](https://github.com/expo/entity/issues/103)) ([29c1a43](https://github.com/expo/entity/commit/29c1a434ab6cd8d30e9f0e7c742065c8d46d8d8a))
* remove any cast from EntityLoader ([#100](https://github.com/expo/entity/issues/100)) ([b8e07f9](https://github.com/expo/entity/commit/b8e07f9ddc4077768980c000d61f5ddcc824e2e3))
### Features
* better cache adapter error handling ([#102](https://github.com/expo/entity/issues/102)) ([15546aa](https://github.com/expo/entity/commit/15546aad97734ab9ac67f1c012485f096a0e94f1))
* better database adapter error handling ([#101](https://github.com/expo/entity/issues/101)) ([5208aee](https://github.com/expo/entity/commit/5208aeedb0192f0460ba3d575770a04175f5c3aa))
# [0.10.0](https://github.com/expo/entity/compare/v0.9.1...v0.10.0) (2020-11-17)
### Bug Fixes
* fix null handling in load paths (try 2) ([#96](https://github.com/expo/entity/issues/96)) ([d960329](https://github.com/expo/entity/commit/d9603298e0cc5c3c2b0cca959876e9195555fb57))
## [0.9.1](https://github.com/expo/entity/compare/v0.9.0...v0.9.1) (2020-11-06)
### Bug Fixes
* edge deletion behavior cyclic import structure again ([#93](https://github.com/expo/entity/issues/93)) ([012c05d](https://github.com/expo/entity/commit/012c05d62d2ecd559eb331c6ca358052bfe66dc8))
# [0.9.0](https://github.com/expo/entity/compare/v0.8.1...v0.9.0) (2020-11-05)
### Bug Fixes
* edge deletion behavior cyclic import structure ([#89](https://github.com/expo/entity/issues/89)) ([cca8fc1](https://github.com/expo/entity/commit/cca8fc19870057ebc7033081b59fab4a142fbf1a))
### Features
* add ability to invalidate an entity manually ([#91](https://github.com/expo/entity/issues/91)) ([d1a6f13](https://github.com/expo/entity/commit/d1a6f1339648942f12b3fb605eb329aefe5cdd63))
## [0.8.1](https://github.com/expo/entity/compare/v0.8.0...v0.8.1) (2020-10-09)
**Note:** Version bump only for package root
# [0.8.0](https://github.com/expo/entity/compare/v0.7.1...v0.8.0) (2020-10-09)
### Features
* add entityClassName to metrics adapter events ([#83](https://github.com/expo/entity/issues/83)) ([f4cdc01](https://github.com/expo/entity/commit/f4cdc01c6cb8868c24ce3da2274f0901687e05bd))
## [0.7.1](https://github.com/expo/entity/compare/v0.7.0...v0.7.1) (2020-10-01)
### Bug Fixes
* correct type of runInTransactionAsync ([#81](https://github.com/expo/entity/issues/81)) ([f98f7d1](https://github.com/expo/entity/commit/f98f7d14b6149a496a7a3e97dd63d89b2842cc26))
# [0.7.0](https://github.com/expo/entity/compare/v0.6.0...v0.7.0) (2020-10-01)
### Bug Fixes
* fix incorrect loader types for field selecitons ([#72](https://github.com/expo/entity/issues/72)) ([cd2df8d](https://github.com/expo/entity/commit/cd2df8da4b81bcd4769e950d3124c18886df2816))
* fix index export for EntityMutationTrigger and EntityMutationValidator ([#73](https://github.com/expo/entity/issues/73)) ([7e829f3](https://github.com/expo/entity/commit/7e829f30fde0bd343cb8a584ccda5530cf61ec33))
* move cache invalidation to after transaction commit ([#77](https://github.com/expo/entity/issues/77)) ([dbc3c81](https://github.com/expo/entity/commit/dbc3c81b359a668b5134189ce6de49b5893bb5e7))
* upgrade packages ([#78](https://github.com/expo/entity/issues/78)) ([9891e74](https://github.com/expo/entity/commit/9891e7469467a28589f529c8d87b10fc2232d3ff))
# [0.6.0](https://github.com/expo/entity/compare/v0.5.2...v0.6.0) (2020-07-23)
### Bug Fixes
* ensure transitive deletion triggers are run for CASCADE_DELETE_INVALIDATE_CACHE ([#66](https://github.com/expo/entity/issues/66)) ([5744c51](https://github.com/expo/entity/commit/5744c51e6b3178bd9f229f3f465b683bb4474c8a))
* separate out EntityMutationValidator ([#68](https://github.com/expo/entity/issues/68)) ([547a1ef](https://github.com/expo/entity/commit/547a1efcecd17cc085702d1a3e9888ce5b644b13))
* update setField to correct typing ([#62](https://github.com/expo/entity/issues/62)) ([7a77afc](https://github.com/expo/entity/commit/7a77afc83ea732c9b062a5d6865eff9d3131d015))
### Features
* entity mutation validators ([#67](https://github.com/expo/entity/issues/67)) ([fc4377d](https://github.com/expo/entity/commit/fc4377d8839da07417b88afc138f73556383d896))
* mutation triggers ([#65](https://github.com/expo/entity/issues/65)) ([fd6060c](https://github.com/expo/entity/commit/fd6060cc844f60635b3ce4c400c4877f2df8fa44))
## [0.5.2](https://github.com/expo/entity/compare/v0.5.1...v0.5.2) (2020-07-02)
### Bug Fixes
* fix stub cache key generation for global cache ([#59](https://github.com/expo/entity/issues/59)) ([7c180e8](https://github.com/expo/entity/commit/7c180e84d9a8cdfd9380fac0eab2d02a96ec18f3))
## [0.5.1](https://github.com/expo/entity/compare/v0.5.0...v0.5.1) (2020-07-01)
### Bug Fixes
* correct some typing issues and exports ([#57](https://github.com/expo/entity/issues/57)) ([e91cfba](https://github.com/expo/entity/commit/e91cfba99321c2c4da2078aafd8627cbf0ceca50))
# 0.5.0 (2020-07-01)
### Bug Fixes
* support number ID field generation in StubDatabaseAdapter ([#43](https://github.com/expo/entity/issues/43)) ([60cc0fb](https://github.com/expo/entity/commit/60cc0fbda460c3ada1f5244a2780f90382fe3ebd))
* upgrade Apollo server for security advisory ([#38](https://github.com/expo/entity/issues/38)) ([d25c1e1](https://github.com/expo/entity/commit/d25c1e142d52385725593f4f155d1795697ab490))
### Features
* add two fan out methods to association loader ([#52](https://github.com/expo/entity/issues/52)) ([89cfb3d](https://github.com/expo/entity/commit/89cfb3d5a01d4da90f64acc52ae1b839ba348a35))
* support entity fields subset of db fields ([#49](https://github.com/expo/entity/issues/49)) ([4e40b2e](https://github.com/expo/entity/commit/4e40b2e521407e521d236978ec3b3b56db3990be))
# 0.4.0 (2020-06-03)
## v0.4.0 (2020-06-03)
* All Packages * All Packages
* Documentation Improvements * Documentation Improvements
@@ -341,7 +16,7 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline
* `@expo/entity-example` * `@expo/entity-example`
* Initial Version * Initial Version
# 0.3.0 (2020-04-28) ## v0.3.0 (2020-04-28)
* `@expo/entity` * `@expo/entity`
* Initial Version * Initial Version
-15
View File
@@ -78,21 +78,6 @@ Entity is not limited in where it can or should be used, but was designed for us
Note: The entity framework instance should not be shared across multiple requests since it contains a unique memoized [Dataloader](https://github.com/graphql/dataloader#class-dataloader). A long-lived instance is prone to data synchronization issues, especially when the application is scaled horizontally and multiple shared caches would exist for the same data. Note: The entity framework instance should not be shared across multiple requests since it contains a unique memoized [Dataloader](https://github.com/graphql/dataloader#class-dataloader). A long-lived instance is prone to data synchronization issues, especially when the application is scaled horizontally and multiple shared caches would exist for the same data.
## Releasing
To release a new version, let's say `v1.0.0` for example:
1. `git checkout -b bump-v1.0.0`
1. `yarn lerna version minor -- --no-push --conventional-commits`
1. `git push -u origin bump-v1.0.0`
1. Create a PR from the `bump-v1.0.0` branch, wait for tests, and commit the PR using GitHub interface.
1. `git checkout master && git pull`
1. `git tag -d v1.0.0`
1. `git tag v1.0.0`
1. `git push origin v1.0.0`
1. In GitHub release interface, create a new release from the tag, copy changelog changes to release description.
1. `yarn clean && yarn tsc`
1. In each public subpackage, run `npm publish` and enter 2FA token.
## License ## License
The Entity source code is made available under the [MIT license](LICENSE). The Entity source code is made available under the [MIT license](LICENSE).
+1 -1
View File
@@ -4,5 +4,5 @@
], ],
"npmClient": "yarn", "npmClient": "yarn",
"useWorkspaces": true, "useWorkspaces": true,
"version": "0.22.0" "version": "0.4.0"
} }
+24 -29
View File
@@ -1,14 +1,11 @@
{ {
"name": "root", "name": "root",
"private": true, "private": true,
"workspaces": [ "workspaces": ["packages/*"],
"packages/*"
],
"scripts": { "scripts": {
"tsc": "lerna run tsc", "tsc": "lerna run tsc",
"clean": "lerna run clean", "clean": "lerna run clean",
"lint": "lerna run lint", "lint": "lerna run lint",
"lint-fix": "lerna run lint-fix",
"test": "lerna run test", "test": "lerna run test",
"integration": "./resources/run-with-docker lerna run integration-no-setup", "integration": "./resources/run-with-docker lerna run integration-no-setup",
"prepublish": "lerna link && lerna run clean", "prepublish": "lerna link && lerna run clean",
@@ -17,35 +14,33 @@
}, },
"devDependencies": { "devDependencies": {
"@expo/entity": "file:packages/entity", "@expo/entity": "file:packages/entity",
"@expo/results": "^1.0.0", "@expo/results": "^0.3.0",
"@types/invariant": "^2.2.33", "@types/invariant": "^2.2.32",
"@types/ioredis": "^4.26.4", "@types/ioredis": "^4.14.9",
"@types/jest": "^26.0.10", "@types/jest": "^25.2.1",
"@types/jsbn": "^1.2.29", "@types/lodash": "^4.14.150",
"@types/node": "^14.6.0", "@types/node": "^13.13.4",
"@types/uuid": "^8.3.0", "@types/uuid": "^7.0.3",
"@typescript-eslint/eslint-plugin": "^4.14.0", "@typescript-eslint/eslint-plugin": "^2.30.0",
"@typescript-eslint/parser": "^4.14.0", "@typescript-eslint/parser": "^2.30.0",
"barrelsby": "^2.2.0", "barrelsby": "^2.2.0",
"eslint": "^7.7.0", "eslint": "^6.8.0",
"eslint-config-universe": "^7.0.0", "eslint-config-universe": "^3.0.0",
"eslint-plugin-tsdoc": "^0.2.11", "eslint-plugin-tsdoc": "^0.2.4",
"jest": "^26.6.3", "jest": "^24.9.0",
"lerna": "^4.0.0", "lerna": "^3.20.2",
"nullthrows": "^1.1.1", "pg": "^8.0.3",
"pg": "^8.6.0", "prettier": "^2.0.5",
"prettier": "^2.3.2", "ts-jest": "~24.2.0",
"ts-jest": "~26.4.4", "ts-mockito": "^2.5.0",
"ts-mockito": "^2.6.1", "typedoc": "^0.17.6",
"typedoc": "^0.21.9", "typedoc-plugin-lerna-packages": "^0.3.0",
"typescript": "^4.4.2", "typescript": "^3.9.2",
"uuid": "^8.3.0" "uuid": "^7.0.3"
}, },
"dependencies": { "dependencies": {
"@expo/entity": "file:packages/entity", "@expo/entity": "file:packages/entity",
"@expo/entity-cache-adapter-redis": "file:packages/entity-cache-adapter-redis", "@expo/entity-cache-adapter-redis": "file:packages/entity-cache-adapter-redis",
"@expo/entity-database-adapter-knex": "file:packages/entity-database-adapter-knex", "@expo/entity-database-adapter-knex": "file:packages/entity-database-adapter-knex"
"@expo/entity-secondary-cache-redis": "file:packages/entity-secondary-cache-redis",
"@expo/entity-ip-address-field": "file:packages/entity-ip-address-field"
} }
} }
@@ -1,217 +0,0 @@
# Change Log
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.21.0](https://github.com/expo/entity/compare/v0.20.0...v0.21.0) (2022-01-03)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.20.0](https://github.com/expo/entity/compare/v0.19.0...v0.20.0) (2021-12-29)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.19.0](https://github.com/expo/entity/compare/v0.18.0...v0.19.0) (2021-10-28)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.18.0](https://github.com/expo/entity/compare/v0.17.0...v0.18.0) (2021-10-14)
### Features
* enable noImplicitOverride tsc setting ([#135](https://github.com/expo/entity/issues/135)) ([4263cb9](https://github.com/expo/entity/commit/4263cb9b8b69fe4fb68c74ec1c7aba04508a1555))
* upgrade TypeScript to 4.4 ([#134](https://github.com/expo/entity/issues/134)) ([7612392](https://github.com/expo/entity/commit/7612392dbfd9778d25c465c1626c372d75a6d05a))
# [0.17.0](https://github.com/expo/entity/compare/v0.16.0...v0.17.0) (2021-08-16)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.16.0](https://github.com/expo/entity/compare/v0.15.0...v0.16.0) (2021-07-07)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.15.0](https://github.com/expo/entity/compare/v0.14.1...v0.15.0) (2021-05-26)
### Bug Fixes
* upgrade ioredis, knex, and pg ([#125](https://github.com/expo/entity/issues/125)) ([7c43edf](https://github.com/expo/entity/commit/7c43edf3de37e8af13e2a99bd44f03f54803d9b9))
* use column name instead of field name for redis cache key ([#124](https://github.com/expo/entity/issues/124)) ([d78f452](https://github.com/expo/entity/commit/d78f452bb3ac5527069813a03f5b2264375bd8ab))
### Features
* secondary cache loader ([#123](https://github.com/expo/entity/issues/123)) ([4cba01e](https://github.com/expo/entity/commit/4cba01eb259c87d60b3026ce776e46f781363690))
## [0.14.1](https://github.com/expo/entity/compare/v0.14.0...v0.14.1) (2021-03-24)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.14.0](https://github.com/expo/entity/compare/v0.13.0...v0.14.0) (2021-03-24)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.13.0](https://github.com/expo/entity/compare/v0.12.0...v0.13.0) (2021-02-12)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.12.0](https://github.com/expo/entity/compare/v0.11.0...v0.12.0) (2021-01-22)
### Bug Fixes
* remove cache and database adaptor flavor enums ([#109](https://github.com/expo/entity/issues/109)) ([72a77f8](https://github.com/expo/entity/commit/72a77f8b6893a4b76d3b1cce16659bdf3ce473ee))
# [0.11.0](https://github.com/expo/entity/compare/v0.10.0...v0.11.0) (2020-12-24)
### Features
* better cache adapter error handling ([#102](https://github.com/expo/entity/issues/102)) ([15546aa](https://github.com/expo/entity/commit/15546aad97734ab9ac67f1c012485f096a0e94f1))
# [0.10.0](https://github.com/expo/entity/compare/v0.9.1...v0.10.0) (2020-11-17)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
## [0.9.1](https://github.com/expo/entity/compare/v0.9.0...v0.9.1) (2020-11-06)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.9.0](https://github.com/expo/entity/compare/v0.8.1...v0.9.0) (2020-11-05)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
## [0.8.1](https://github.com/expo/entity/compare/v0.8.0...v0.8.1) (2020-10-09)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.8.0](https://github.com/expo/entity/compare/v0.7.1...v0.8.0) (2020-10-09)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
## [0.7.1](https://github.com/expo/entity/compare/v0.7.0...v0.7.1) (2020-10-01)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.7.0](https://github.com/expo/entity/compare/v0.6.0...v0.7.0) (2020-10-01)
### Bug Fixes
* upgrade packages ([#78](https://github.com/expo/entity/issues/78)) ([9891e74](https://github.com/expo/entity/commit/9891e7469467a28589f529c8d87b10fc2232d3ff))
# [0.6.0](https://github.com/expo/entity/compare/v0.5.2...v0.6.0) (2020-07-23)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
## [0.5.2](https://github.com/expo/entity/compare/v0.5.1...v0.5.2) (2020-07-02)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
## [0.5.1](https://github.com/expo/entity/compare/v0.5.0...v0.5.1) (2020-07-01)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# 0.5.0 (2020-07-01)
### Features
* support entity fields subset of db fields ([#49](https://github.com/expo/entity/issues/49)) ([4e40b2e](https://github.com/expo/entity/commit/4e40b2e521407e521d236978ec3b3b56db3990be))
@@ -12,7 +12,7 @@ During `EntityCompanionProvider` instantiation:
import Redis from 'ioredis'; import Redis from 'ioredis';
const redisCacheAdapterContext = { const redisCacheAdapterContext = {
redisClient: new Redis(new URL(process.env['REDIS_URL']!).toString()), redisClient: new Redis(new URL(process.env.REDIS_URL!).toString()),
makeKeyFn(...parts: string[]): string { makeKeyFn(...parts: string[]): string {
const delimiter = ':'; const delimiter = ':';
const escapedParts = parts.map((part) => const escapedParts = parts.map((part) =>
@@ -35,7 +35,7 @@ export const createDefaultEntityCompanionProvider = (
... ...
}, },
{ {
['redis']: { [CacheAdapterFlavor.REDIS]: {
cacheAdapterProvider: new RedisCacheAdapterProvider(redisCacheAdapterContext), cacheAdapterProvider: new RedisCacheAdapterProvider(redisCacheAdapterContext),
}, },
} }
@@ -1,6 +1,6 @@
{ {
"name": "@expo/entity-cache-adapter-redis", "name": "@expo/entity-cache-adapter-redis",
"version": "0.22.0", "version": "0.4.0",
"description": "Redis cache adapter for @expo/entity", "description": "Redis cache adapter for @expo/entity",
"files": [ "files": [
"build", "build",
@@ -11,15 +11,14 @@
"scripts": { "scripts": {
"tsc": "tsc", "tsc": "tsc",
"clean": "rm -rf build coverage coverage-integration", "clean": "rm -rf build coverage coverage-integration",
"lint": "eslint src", "lint": "eslint src --ext '.ts'",
"lint-fix": "eslint src --fix", "test": "jest --rootDir --config ../../resources/jest.config.js",
"test": "jest --rootDir . --config ../../resources/jest.config.js",
"integration": "../../resources/run-with-docker yarn integration-no-setup", "integration": "../../resources/run-with-docker yarn integration-no-setup",
"integration-no-setup": "jest --config ../../resources/jest-integration.config.js --rootDir . --runInBand --passWithNoTests", "integration-no-setup": "jest --config ../../resources/jest-integration.config.js --rootDir --runInBand --passWithNoTests",
"barrelsby": "barrelsby --directory src --location top --exclude tests__ --singleQuotes --exportDefault --delete" "barrelsby": "barrelsby --directory src --location top --exclude tests__ --singleQuotes --exportDefault --delete"
}, },
"engines": { "engines": {
"node": ">=12" "node": ">=8"
}, },
"keywords": [ "keywords": [
"entity" "entity"
@@ -30,9 +29,9 @@
"@expo/entity": "*" "@expo/entity": "*"
}, },
"dependencies": { "dependencies": {
"ioredis": "^4.27.3" "ioredis": "^4.16.1"
}, },
"devDependencies": { "devDependencies": {
"@expo/entity": "^0.22.0" "@expo/entity": "^0.4.0"
} }
} }
@@ -1,107 +0,0 @@
import { CacheLoadResult, CacheStatus } from '@expo/entity';
import { Redis } from 'ioredis';
import wrapNativeRedisCallAsync from './errors/wrapNativeRedisCallAsync';
// Sentinel value we store in Redis to negatively cache a database miss.
// The sentinel value is distinct from any (positively) cached value.
const DOES_NOT_EXIST_REDIS = '';
export interface GenericRedisCacheContext {
/**
* Instance of ioredis.Redis
*/
redisClient: Redis;
/**
* TTL for caching database hits. Successive entity loads within this TTL
* will be read from cache (unless invalidated).
*/
ttlSecondsPositive: number;
/**
* TTL for negatively caching database misses. Successive entity loads within
* this TTL will be assumed not present in the database (unless invalidated).
*/
ttlSecondsNegative: number;
}
export default class GenericRedisCacher {
constructor(private readonly context: GenericRedisCacheContext) {}
public async loadManyAsync(
keys: readonly string[]
): Promise<ReadonlyMap<string, CacheLoadResult>> {
if (keys.length === 0) {
return new Map();
}
const redisResults = await wrapNativeRedisCallAsync(() =>
this.context.redisClient.mget(...keys)
);
const results = new Map<string, CacheLoadResult>();
for (let i = 0; i < keys.length; i++) {
const key = keys[i]!;
const redisResult = redisResults[i];
if (redisResult === DOES_NOT_EXIST_REDIS) {
results.set(key, {
status: CacheStatus.NEGATIVE,
});
} else if (redisResult) {
results.set(key, {
status: CacheStatus.HIT,
item: JSON.parse(redisResult),
});
} else {
results.set(key, {
status: CacheStatus.MISS,
});
}
}
return results;
}
public async cacheManyAsync(objectMap: ReadonlyMap<string, object>): Promise<void> {
if (objectMap.size === 0) {
return;
}
let redisTransaction = this.context.redisClient.multi();
objectMap.forEach((object, key) => {
redisTransaction = redisTransaction.set(
key,
JSON.stringify(object),
'EX',
this.context.ttlSecondsPositive
);
});
await wrapNativeRedisCallAsync(() => redisTransaction.exec());
}
public async cacheDBMissesAsync(keys: string[]): Promise<void> {
if (keys.length === 0) {
return;
}
let redisTransaction = this.context.redisClient.multi();
keys.forEach((key) => {
redisTransaction = redisTransaction.set(
key,
DOES_NOT_EXIST_REDIS,
'EX',
this.context.ttlSecondsNegative
);
});
await wrapNativeRedisCallAsync(() => redisTransaction.exec());
}
public async invalidateManyAsync(keys: string[]): Promise<void> {
if (keys.length === 0) {
return;
}
await wrapNativeRedisCallAsync(() => this.context.redisClient.del(...keys));
}
}
@@ -1,19 +1,18 @@
import { import {
EntityCacheAdapter, EntityCacheAdapter,
EntityConfiguration, EntityConfiguration,
CacheLoadResult,
FieldTransformerMap, FieldTransformerMap,
mapKeys, CacheLoadResult,
transformCacheObjectToFields,
CacheStatus, CacheStatus,
transformFieldsToCacheObject,
} from '@expo/entity'; } from '@expo/entity';
import invariant from 'invariant';
import { Redis } from 'ioredis'; import { Redis } from 'ioredis';
import GenericRedisCacher from './GenericRedisCacher';
import { redisTransformerMap } from './RedisCommon'; import { redisTransformerMap } from './RedisCommon';
// Sentinel value we store in Redis to negatively cache a database miss.
// The sentinel value is distinct from any (positively) cached value.
const DOES_NOT_EXIST_REDIS = '';
export interface RedisCacheAdapterContext { export interface RedisCacheAdapterContext {
/** /**
* Instance of ioredis.Redis * Instance of ioredis.Redis
@@ -52,112 +51,114 @@ export interface RedisCacheAdapterContext {
} }
export default class RedisCacheAdapter<TFields> extends EntityCacheAdapter<TFields> { export default class RedisCacheAdapter<TFields> extends EntityCacheAdapter<TFields> {
/**
* Transformer definitions for field types. Used to modify values as they are read from or written to
* the cache. Override in concrete subclasses to change transformation behavior.
* If a field type is not present in the map, then fields of that type will not be transformed.
*/
private readonly redisFieldTransformer: FieldTransformerMap = redisTransformerMap;
private readonly genericRedisCacher: GenericRedisCacher;
constructor( constructor(
entityConfiguration: EntityConfiguration<TFields>, entityConfiguration: EntityConfiguration<TFields>,
private readonly context: RedisCacheAdapterContext private readonly context: RedisCacheAdapterContext
) { ) {
super(entityConfiguration); super(entityConfiguration);
}
this.genericRedisCacher = new GenericRedisCacher({ public getFieldTransformerMap(): FieldTransformerMap {
redisClient: context.redisClient, return redisTransformerMap;
ttlSecondsNegative: context.ttlSecondsNegative,
ttlSecondsPositive: context.ttlSecondsPositive,
});
} }
public async loadManyAsync<N extends keyof TFields>( public async loadManyAsync<N extends keyof TFields>(
fieldName: N, fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[] fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult>> { ): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult>> {
const redisCacheKeyToFieldValueMapping = new Map( if (fieldValues.length === 0) {
fieldValues.map((fieldValue) => [this.makeCacheKey(fieldName, fieldValue), fieldValue]) return new Map();
);
const rawCacheResults = await this.genericRedisCacher.loadManyAsync(
Array.from(redisCacheKeyToFieldValueMapping.keys())
);
const cacheResults = new Map<string, CacheLoadResult>();
for (const [redisCacheKey, rawCacheResult] of rawCacheResults) {
if (rawCacheResult.status === CacheStatus.HIT) {
cacheResults.set(redisCacheKey, {
status: CacheStatus.HIT,
item: transformCacheObjectToFields(
this.entityConfiguration,
this.redisFieldTransformer,
rawCacheResult.item
),
});
} else {
cacheResults.set(redisCacheKey, rawCacheResult);
}
} }
return mapKeys(cacheResults, (redisCacheKey) => { const redisKeys = fieldValues.map((fieldValue) => this.makeCacheKey(fieldName, fieldValue));
const fieldValue = redisCacheKeyToFieldValueMapping.get(redisCacheKey); const redisResults = await this.context.redisClient.mget(...redisKeys);
invariant(
fieldValue !== undefined, const results = new Map<NonNullable<TFields[N]>, CacheLoadResult>();
'Unspecified cache key %s returned from generic Redis cacher', for (let i = 0; i < fieldValues.length; i++) {
redisCacheKey const fieldValue = fieldValues[i];
); const redisResult = redisResults[i];
return fieldValue;
if (redisResult === DOES_NOT_EXIST_REDIS) {
results.set(fieldValue, {
status: CacheStatus.NEGATIVE,
}); });
} else if (redisResult) {
results.set(fieldValue, {
status: CacheStatus.HIT,
item: JSON.parse(redisResult),
});
} else {
results.set(fieldValue, {
status: CacheStatus.MISS,
});
}
}
return results;
} }
public async cacheManyAsync<N extends keyof TFields>( public async cacheManyAsync<N extends keyof TFields>(
fieldName: N, fieldName: N,
objectMap: ReadonlyMap<NonNullable<TFields[N]>, object> objectMap: ReadonlyMap<NonNullable<TFields[N]>, object>
): Promise<void> { ): Promise<void> {
const cacheObjectMap = new Map<NonNullable<TFields[N]>, object>(); if (objectMap.size === 0) {
for (const [fieldValue, object] of objectMap) { return;
cacheObjectMap.set(
fieldValue,
transformFieldsToCacheObject(
this.entityConfiguration,
this.redisFieldTransformer,
object as Readonly<TFields>
)
);
} }
await this.genericRedisCacher.cacheManyAsync(
mapKeys(cacheObjectMap, (fieldValue) => this.makeCacheKey(fieldName, fieldValue)) let redisTransaction = this.context.redisClient.multi();
objectMap.forEach((object, fieldValue) => {
const redisKey = this.makeCacheKey(fieldName, fieldValue);
redisTransaction = redisTransaction.set(
redisKey,
JSON.stringify(object),
'EX',
this.context.ttlSecondsPositive
); );
});
await redisTransaction.exec();
} }
public async cacheDBMissesAsync<N extends keyof TFields>( public async cacheDBMissesAsync<N extends keyof TFields>(
fieldName: N, fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[] fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<void> { ): Promise<void> {
await this.genericRedisCacher.cacheDBMissesAsync( if (fieldValues.length === 0) {
fieldValues.map((fieldValue) => this.makeCacheKey(fieldName, fieldValue)) return;
}
let redisTransaction = this.context.redisClient.multi();
fieldValues.forEach((fieldValue) => {
const redisKey = this.makeCacheKey(fieldName, fieldValue);
redisTransaction = redisTransaction.set(
redisKey,
DOES_NOT_EXIST_REDIS,
'EX',
this.context.ttlSecondsNegative
); );
});
await redisTransaction.exec();
} }
public async invalidateManyAsync<N extends keyof TFields>( public async invalidateManyAsync<N extends keyof TFields>(
fieldName: N, fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[] fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<void> { ): Promise<void> {
await this.genericRedisCacher.invalidateManyAsync( if (fieldValues.length === 0) {
fieldValues.map((fieldValue) => this.makeCacheKey(fieldName, fieldValue)) return;
); }
const redisKeys = fieldValues.map((fieldValue) => this.makeCacheKey(fieldName, fieldValue));
await this.context.redisClient.del(...redisKeys);
} }
private makeCacheKey<N extends keyof TFields>( private makeCacheKey<N extends keyof TFields>(
fieldName: N, fieldName: N,
fieldValue: NonNullable<TFields[N]> fieldValue: NonNullable<TFields[N]>
): string { ): string {
const columnName = this.entityConfiguration.entityToDBFieldsKeyMapping.get(fieldName);
invariant(columnName, `database field mapping missing for ${fieldName}`);
return this.context.makeKeyFn( return this.context.makeKeyFn(
this.context.cacheKeyPrefix, this.context.cacheKeyPrefix,
this.entityConfiguration.tableName, this.entityConfiguration.tableName,
`v2.${this.entityConfiguration.cacheKeyVersion}`, `v${this.entityConfiguration.cacheKeyVersion}`,
columnName, fieldName as string,
String(fieldValue) String(fieldValue)
); );
} }
@@ -15,7 +15,7 @@ describe(RedisCacheAdapter, () => {
beforeAll(() => { beforeAll(() => {
redisCacheAdapterContext = { redisCacheAdapterContext = {
redisClient: new Redis(new URL(process.env['REDIS_URL']!).toString()), redisClient: new Redis(new URL(process.env.REDIS_URL!).toString()),
makeKeyFn(...parts: string[]): string { makeKeyFn(...parts: string[]): string {
const delimiter = ':'; const delimiter = ':';
const escapedParts = parts.map((part) => const escapedParts = parts.map((part) =>
@@ -38,23 +38,19 @@ describe(RedisCacheAdapter, () => {
}); });
it('has correct caching behavior', async () => { it('has correct caching behavior', async () => {
const viewerContext = new TestViewerContext( const vc1 = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext) createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
); );
const cacheAdapter = viewerContext.entityCompanionProvider.getCompanionForEntity( const cacheAdapter = vc1.entityCompanionProvider.getCompanionForEntity(
RedisTestEntity, RedisTestEntity,
RedisTestEntity.getCompanionDefinition() RedisTestEntity.getCompanionDefinition()
)['tableDataCoordinator']['cacheAdapter']; )['cacheAdapter'];
const cacheKeyMaker = cacheAdapter['makeCacheKey'].bind(cacheAdapter); const cacheKeyMaker = cacheAdapter['makeCacheKey'].bind(cacheAdapter);
const entity1Created = await RedisTestEntity.creator(viewerContext) // creating an entity should put it in cache (loader loads it after creation)
.setField('name', 'blah') const entity1 = await enforceAsyncResult(
.enforceCreateAsync(); RedisTestEntity.creator(vc1).setField('name', 'blah').createAsync()
);
// loading an entity should put it in cache
const entity1 = await RedisTestEntity.loader(viewerContext)
.enforcing()
.loadByIDAsync(entity1Created.getID());
const cachedJSON = await redisCacheAdapterContext.redisClient.get( const cachedJSON = await redisCacheAdapterContext.redisClient.get(
cacheKeyMaker('id', entity1.getID()) cacheKeyMaker('id', entity1.getID())
@@ -68,9 +64,7 @@ describe(RedisCacheAdapter, () => {
// simulate non existent db fetch, should write negative result ('') to cache // simulate non existent db fetch, should write negative result ('') to cache
const nonExistentId = uuidv4(); const nonExistentId = uuidv4();
const entityNonExistentResult = await RedisTestEntity.loader(viewerContext).loadByIDAsync( const entityNonExistentResult = await RedisTestEntity.loader(vc1).loadByIDAsync(nonExistentId);
nonExistentId
);
expect(entityNonExistentResult.ok).toBe(false); expect(entityNonExistentResult.ok).toBe(false);
const nonExistentCachedValue = await redisCacheAdapterContext.redisClient.get( const nonExistentCachedValue = await redisCacheAdapterContext.redisClient.get(
@@ -79,13 +73,11 @@ describe(RedisCacheAdapter, () => {
expect(nonExistentCachedValue).toEqual(''); expect(nonExistentCachedValue).toEqual('');
// load again through entities framework to ensure it reads negative result // load again through entities framework to ensure it reads negative result
const entityNonExistentResult2 = await RedisTestEntity.loader(viewerContext).loadByIDAsync( const entityNonExistentResult2 = await RedisTestEntity.loader(vc1).loadByIDAsync(nonExistentId);
nonExistentId
);
expect(entityNonExistentResult2.ok).toBe(false); expect(entityNonExistentResult2.ok).toBe(false);
// invalidate from cache to ensure it invalidates correctly // invalidate from cache to ensure it invalidates correctly
await RedisTestEntity.loader(viewerContext).invalidateFieldsAsync(entity1.getAllFields()); await RedisTestEntity.loader(vc1).invalidateFieldsAsync(entity1.getAllFields());
const cachedValueNull = await redisCacheAdapterContext.redisClient.get( const cachedValueNull = await redisCacheAdapterContext.redisClient.get(
cacheKeyMaker('id', entity1.getID()) cacheKeyMaker('id', entity1.getID())
); );
@@ -93,18 +85,16 @@ describe(RedisCacheAdapter, () => {
}); });
it('caches and restores date fields', async () => { it('caches and restores date fields', async () => {
const viewerContext = new TestViewerContext( const vc1 = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext) createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
); );
const date = new Date(); const date = new Date();
const entity1 = await enforceAsyncResult( const entity1 = await enforceAsyncResult(
RedisTestEntity.creator(viewerContext).setField('dateField', date).createAsync() RedisTestEntity.creator(vc1).setField('dateField', date).createAsync()
); );
expect(entity1.getField('dateField')).toEqual(date); expect(entity1.getField('dateField')).toEqual(date);
const entity2 = await RedisTestEntity.loader(viewerContext) const entity2 = await RedisTestEntity.loader(vc1).enforcing().loadByIDAsync(entity1.getID());
.enforcing()
.loadByIDAsync(entity1.getID());
expect(entity2.getField('dateField')).toEqual(date); expect(entity2.getField('dateField')).toEqual(date);
// simulate new request // simulate new request
@@ -114,26 +104,4 @@ describe(RedisCacheAdapter, () => {
const entity3 = await RedisTestEntity.loader(vc2).enforcing().loadByIDAsync(entity1.getID()); const entity3 = await RedisTestEntity.loader(vc2).enforcing().loadByIDAsync(entity1.getID());
expect(entity3.getField('dateField')).toEqual(date); expect(entity3.getField('dateField')).toEqual(date);
}); });
it('caches and restores empty string field keys', async () => {
const viewerContext = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
);
const entity1 = await enforceAsyncResult(
RedisTestEntity.creator(viewerContext).setField('name', '').createAsync()
);
const entity2 = await RedisTestEntity.loader(viewerContext)
.enforcing()
.loadByFieldEqualingAsync('name', '');
expect(entity2?.getID()).toEqual(entity1.getID());
// simulate new request
const vc2 = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
);
const entity3 = await RedisTestEntity.loader(vc2)
.enforcing()
.loadByFieldEqualingAsync('name', '');
expect(entity3?.getID()).toEqual(entity1.getID());
});
}); });
@@ -1,46 +0,0 @@
import { EntityCacheAdapterTransientError, ViewerContext } from '@expo/entity';
import Redis from 'ioredis';
import { URL } from 'url';
import RedisCacheAdapter, { RedisCacheAdapterContext } from '../RedisCacheAdapter';
import RedisTestEntity from '../testfixtures/RedisTestEntity';
import { createRedisIntegrationTestEntityCompanionProvider } from '../testfixtures/createRedisIntegrationTestEntityCompanionProvider';
class TestViewerContext extends ViewerContext {}
describe(RedisCacheAdapter, () => {
let redisCacheAdapterContext: RedisCacheAdapterContext;
beforeAll(() => {
redisCacheAdapterContext = {
redisClient: new Redis(new URL(process.env['REDIS_URL']!).toString()),
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`)
);
return escapedParts.join(delimiter);
},
cacheKeyPrefix: 'test-',
cacheKeyVersion: 1,
ttlSecondsPositive: 86400, // 1 day
ttlSecondsNegative: 600, // 10 minutes
};
});
beforeEach(async () => {
await redisCacheAdapterContext.redisClient.flushdb();
});
it('throws when redis is disconnected', async () => {
redisCacheAdapterContext.redisClient.disconnect();
const vc1 = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
);
await expect(
RedisTestEntity.creator(vc1).setField('name', 'blah').enforceCreateAsync()
).rejects.toThrow(EntityCacheAdapterTransientError);
});
});
@@ -1,4 +1,4 @@
import { CacheStatus, UUIDField, EntityConfiguration, DateField } from '@expo/entity'; import { CacheStatus, UUIDField, EntityConfiguration } from '@expo/entity';
import { Redis, Pipeline } from 'ioredis'; import { Redis, Pipeline } from 'ioredis';
import { mock, when, instance, anything, verify } from 'ts-mockito'; import { mock, when, instance, anything, verify } from 'ts-mockito';
@@ -6,7 +6,6 @@ import RedisCacheAdapter from '../RedisCacheAdapter';
type BlahFields = { type BlahFields = {
id: string; id: string;
date: Date | null;
}; };
const entityConfiguration = new EntityConfiguration<BlahFields>({ const entityConfiguration = new EntityConfiguration<BlahFields>({
@@ -14,10 +13,7 @@ const entityConfiguration = new EntityConfiguration<BlahFields>({
tableName: 'blah', tableName: 'blah',
schema: { schema: {
id: new UUIDField({ columnName: 'id', cache: true }), id: new UUIDField({ columnName: 'id', cache: true }),
date: new DateField({ columnName: 'date' }),
}, },
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
}); });
describe(RedisCacheAdapter, () => { describe(RedisCacheAdapter, () => {
@@ -64,40 +60,6 @@ describe(RedisCacheAdapter, () => {
const results = await cacheAdapter.loadManyAsync('id', []); const results = await cacheAdapter.loadManyAsync('id', []);
expect(results).toEqual(new Map()); expect(results).toEqual(new Map());
}); });
it('transforms a cache object to an Entity object', async () => {
const redisResults = new Map();
const mockRedisClient = mock<Redis>();
// need to have one anything() for each element of spread, in this case 1
when(mockRedisClient.mget(anything())).thenCall(async (...keys) =>
keys.map((k) => redisResults.get(k) ?? null)
);
const cacheAdapter = new RedisCacheAdapter(entityConfiguration, {
redisClient: instance(mockRedisClient),
makeKeyFn: (...parts) => parts.join(':'),
cacheKeyVersion: 1,
cacheKeyPrefix: 'hello-',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
});
const date = new Date();
redisResults.set(
cacheAdapter['makeCacheKey']('id', 'wat'),
JSON.stringify({ id: 'wat', date: date.toISOString() })
);
const results = await cacheAdapter.loadManyAsync('id', ['wat']);
expect(results.get('wat')).toMatchObject({
status: CacheStatus.HIT,
item: { id: 'wat', date },
});
expect(results.size).toBe(1);
});
}); });
describe('cacheManyAsync', () => { describe('cacheManyAsync', () => {
@@ -134,41 +96,6 @@ describe(RedisCacheAdapter, () => {
ttl: 1, ttl: 1,
}); });
}); });
it('transforms an Entity object to a cache object', async () => {
const redisResults = new Map();
const mockPipeline = mock<Pipeline>();
when(mockPipeline.set(anything(), anything(), anything(), anything())).thenCall(
(key, value, code, ttl) => {
redisResults.set(key, { value, code, ttl });
return pipeline;
}
);
when(mockPipeline.exec()).thenResolve({} as any);
const pipeline = instance(mockPipeline);
const mockRedisClient = mock<Redis>();
when(mockRedisClient.multi()).thenReturn(pipeline);
const cacheAdapter = new RedisCacheAdapter(entityConfiguration, {
redisClient: instance(mockRedisClient),
makeKeyFn: (...parts) => parts.join(':'),
cacheKeyVersion: 1,
cacheKeyPrefix: 'hello-',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
});
const date = new Date();
await cacheAdapter.cacheManyAsync('id', new Map([['wat', { id: 'wat', date }]]));
const cacheKey = cacheAdapter['makeCacheKey']('id', 'wat');
expect(redisResults.get(cacheKey)).toMatchObject({
value: JSON.stringify({ id: 'wat', date: date.toISOString() }),
code: 'EX',
ttl: 1,
});
});
}); });
describe('cacheDBMissesAsync', () => { describe('cacheDBMissesAsync', () => {
@@ -1,39 +0,0 @@
import { EntityCacheAdapterTransientError } from '@expo/entity';
import wrapNativeRedisCallAsync from '../wrapNativeRedisCallAsync';
describe(wrapNativeRedisCallAsync, () => {
it('rethrows literals', async () => {
const throwingFn = async (): Promise<void> => {
// eslint-disable-next-line no-throw-literal,@typescript-eslint/no-throw-literal
throw 'hello';
};
let capturedThrownThing: any;
try {
await wrapNativeRedisCallAsync(throwingFn);
} catch (e) {
capturedThrownThing = e;
}
expect(capturedThrownThing).not.toBeInstanceOf(Error);
expect(capturedThrownThing).toEqual('hello');
});
it('wraps errors with stacks', async () => {
const throwingFn = async (): Promise<void> => {
const e = new Error('hello');
e.stack = 'world';
throw e;
};
let capturedThrownThing: any;
try {
await wrapNativeRedisCallAsync(throwingFn);
} catch (e) {
capturedThrownThing = e;
}
expect(capturedThrownThing).toBeInstanceOf(EntityCacheAdapterTransientError);
expect(capturedThrownThing.message).toEqual('hello');
expect(capturedThrownThing.stack).toEqual('world');
});
});
@@ -1,17 +0,0 @@
import { EntityCacheAdapterTransientError } from '@expo/entity';
export default async function wrapNativeRedisCallAsync<T>(fn: () => Promise<T>): Promise<T> {
try {
return await fn();
} catch (e) {
if (!(e instanceof Error)) {
throw e;
}
const error = new EntityCacheAdapterTransientError(e.message, e);
if (e.stack) {
error.stack = e.stack;
}
throw error;
}
}
@@ -1,12 +1,8 @@
/* eslint-disable tsdoc/syntax */
/** /**
* @packageDocumentation * Automatically generated by barrelsby.
* @module @expo/entity-cache-adapter-redis
*/ */
export { default as RedisCacheAdapter } from './RedisCacheAdapter'; export { default as RedisCacheAdapter } from './RedisCacheAdapter';
export * from './RedisCacheAdapter'; export * from './RedisCacheAdapter';
export { default as RedisCacheAdapterProvider } from './RedisCacheAdapterProvider'; export { default as RedisCacheAdapterProvider } from './RedisCacheAdapterProvider';
export * from './RedisCommon'; export * from './RedisCommon';
export { default as GenericRedisCacher } from './GenericRedisCacher';
export * from './GenericRedisCacher';
@@ -6,6 +6,8 @@ import {
DateField, DateField,
StringField, StringField,
EntityConfiguration, EntityConfiguration,
DatabaseAdapterFlavor,
CacheAdapterFlavor,
EntityCompanionDefinition, EntityCompanionDefinition,
Entity, Entity,
} from '@expo/entity'; } from '@expo/entity';
@@ -28,13 +30,13 @@ export default class RedisTestEntity extends Entity<RedisTestEntityFields, strin
} }
} }
export class RedisTestEntityPrivacyPolicy extends EntityPrivacyPolicy< class RedisTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
RedisTestEntityFields, RedisTestEntityFields,
string, string,
ViewerContext, ViewerContext,
RedisTestEntity RedisTestEntity
> { > {
protected override readonly createRules = [ protected readonly createRules = [
new AlwaysAllowPrivacyPolicyRule< new AlwaysAllowPrivacyPolicyRule<
RedisTestEntityFields, RedisTestEntityFields,
string, string,
@@ -42,7 +44,7 @@ export class RedisTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
RedisTestEntity RedisTestEntity
>(), >(),
]; ];
protected override readonly readRules = [ protected readonly readRules = [
new AlwaysAllowPrivacyPolicyRule< new AlwaysAllowPrivacyPolicyRule<
RedisTestEntityFields, RedisTestEntityFields,
string, string,
@@ -50,7 +52,7 @@ export class RedisTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
RedisTestEntity RedisTestEntity
>(), >(),
]; ];
protected override readonly updateRules = [ protected readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule< new AlwaysAllowPrivacyPolicyRule<
RedisTestEntityFields, RedisTestEntityFields,
string, string,
@@ -58,7 +60,7 @@ export class RedisTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
RedisTestEntity RedisTestEntity
>(), >(),
]; ];
protected override readonly deleteRules = [ protected readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule< new AlwaysAllowPrivacyPolicyRule<
RedisTestEntityFields, RedisTestEntityFields,
string, string,
@@ -78,18 +80,17 @@ export const redisTestEntityConfiguration = new EntityConfiguration<RedisTestEnt
}), }),
name: new StringField({ name: new StringField({
columnName: 'name', columnName: 'name',
cache: true,
}), }),
dateField: new DateField({ dateField: new DateField({
columnName: 'date_field', columnName: 'date_field',
}), }),
}, },
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
}); });
const redisTestEntityCompanionDefinition = new EntityCompanionDefinition({ const redisTestEntityCompanionDefinition = {
entityClass: RedisTestEntity, entityClass: RedisTestEntity,
entityConfiguration: redisTestEntityConfiguration, entityConfiguration: redisTestEntityConfiguration,
databaseAdaptorFlavor: DatabaseAdapterFlavor.POSTGRES,
cacheAdaptorFlavor: CacheAdapterFlavor.REDIS,
privacyPolicyClass: RedisTestEntityPrivacyPolicy, privacyPolicyClass: RedisTestEntityPrivacyPolicy,
}); };
@@ -2,6 +2,8 @@ import {
NoOpEntityMetricsAdapter, NoOpEntityMetricsAdapter,
IEntityMetricsAdapter, IEntityMetricsAdapter,
EntityCompanionProvider, EntityCompanionProvider,
CacheAdapterFlavor,
DatabaseAdapterFlavor,
StubQueryContextProvider, StubQueryContextProvider,
StubDatabaseAdapterProvider, StubDatabaseAdapterProvider,
} from '@expo/entity'; } from '@expo/entity';
@@ -15,22 +17,16 @@ export const createRedisIntegrationTestEntityCompanionProvider = (
): EntityCompanionProvider => { ): EntityCompanionProvider => {
return new EntityCompanionProvider( return new EntityCompanionProvider(
metricsAdapter, metricsAdapter,
new Map([
[
'postgres',
{ {
[DatabaseAdapterFlavor.POSTGRES]: {
adapterProvider: new StubDatabaseAdapterProvider(), adapterProvider: new StubDatabaseAdapterProvider(),
queryContextProvider: StubQueryContextProvider, queryContextProvider: StubQueryContextProvider,
}, },
], },
]),
new Map([
[
'redis',
{ {
[CacheAdapterFlavor.REDIS]: {
cacheAdapterProvider: new RedisCacheAdapterProvider(redisCacheAdapterContext), cacheAdapterProvider: new RedisCacheAdapterProvider(redisCacheAdapterContext),
}, },
], }
])
); );
}; };
@@ -1,256 +0,0 @@
# Change Log
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
### Features
* Upgrade knex to 1.x in `@expo/entity-database-adapter-knex` ([#150](https://github.com/expo/entity/issues/150)) ([e1b0ee5](https://github.com/expo/entity/commit/e1b0ee528175646239d88aa71ffce73983eeb52f))
# [0.21.0](https://github.com/expo/entity/compare/v0.20.0...v0.21.0) (2022-01-03)
### Features
* add pre-commit callbacks on EntityQueryContext ([#147](https://github.com/expo/entity/issues/147)) ([f1d9847](https://github.com/expo/entity/commit/f1d9847210f5775de964509e44ce493e44482b21))
# [0.20.0](https://github.com/expo/entity/compare/v0.19.0...v0.20.0) (2021-12-29)
### Features
* add cascading deletion info ([#145](https://github.com/expo/entity/issues/145)) ([3727191](https://github.com/expo/entity/commit/372719176742d3ead6b7cbca29cb66b09b3fe09c))
# [0.19.0](https://github.com/expo/entity/compare/v0.18.0...v0.19.0) (2021-10-28)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
# [0.18.0](https://github.com/expo/entity/compare/v0.17.0...v0.18.0) (2021-10-14)
### Features
* enable noImplicitOverride tsc setting ([#135](https://github.com/expo/entity/issues/135)) ([4263cb9](https://github.com/expo/entity/commit/4263cb9b8b69fe4fb68c74ec1c7aba04508a1555))
* upgrade TypeScript to 4.4 ([#134](https://github.com/expo/entity/issues/134)) ([7612392](https://github.com/expo/entity/commit/7612392dbfd9778d25c465c1626c372d75a6d05a))
# [0.17.0](https://github.com/expo/entity/compare/v0.16.0...v0.17.0) (2021-08-16)
### Features
* Add IntField and FloatField, deprecate NumberField ([#131](https://github.com/expo/entity/issues/131)) ([2f2d963](https://github.com/expo/entity/commit/2f2d963cf92c6575d78f8eeca344a4aadcc9b8d9))
# [0.16.0](https://github.com/expo/entity/compare/v0.15.0...v0.16.0) (2021-07-07)
### Features
* allow null field values in loadManyByFieldEqualityConjunctionAsync ([#130](https://github.com/expo/entity/issues/130)) ([2f37dc6](https://github.com/expo/entity/commit/2f37dc6a37d368b12b3c8375e04f0777fd448797))
# [0.15.0](https://github.com/expo/entity/compare/v0.14.1...v0.15.0) (2021-05-26)
### Bug Fixes
* upgrade ioredis, knex, and pg ([#125](https://github.com/expo/entity/issues/125)) ([7c43edf](https://github.com/expo/entity/commit/7c43edf3de37e8af13e2a99bd44f03f54803d9b9))
### Features
* secondary cache loader ([#123](https://github.com/expo/entity/issues/123)) ([4cba01e](https://github.com/expo/entity/commit/4cba01eb259c87d60b3026ce776e46f781363690))
## [0.14.1](https://github.com/expo/entity/compare/v0.14.0...v0.14.1) (2021-03-24)
### Bug Fixes
* typescript typedef of EntityMutationInfo ([#121](https://github.com/expo/entity/issues/121)) ([8e8f0dc](https://github.com/expo/entity/commit/8e8f0dc8636e343e29a6012d155e4111477a8989))
# [0.14.0](https://github.com/expo/entity/compare/v0.13.0...v0.14.0) (2021-03-24)
### Bug Fixes
* mutation validator and trigger cyclic import structure ([#118](https://github.com/expo/entity/issues/118)) ([b11dbc0](https://github.com/expo/entity/commit/b11dbc087ba5ad13c748032698dd73279b7960b0))
# [0.13.0](https://github.com/expo/entity/compare/v0.12.0...v0.13.0) (2021-02-12)
### Features
* add basic field type runtime validators ([#113](https://github.com/expo/entity/issues/113)) ([6c4d4b0](https://github.com/expo/entity/commit/6c4d4b03b5404dd776a12c5eb6f0af77a8c964f8))
# [0.12.0](https://github.com/expo/entity/compare/v0.11.0...v0.12.0) (2021-01-22)
### Bug Fixes
* move runInTransaction to ViewerContext ([#108](https://github.com/expo/entity/issues/108)) ([b7309e1](https://github.com/expo/entity/commit/b7309e18a2dd780b64cd306d566de1f12bf8df3a))
* remove cache and database adaptor flavor enums ([#109](https://github.com/expo/entity/issues/109)) ([72a77f8](https://github.com/expo/entity/commit/72a77f8b6893a4b76d3b1cce16659bdf3ce473ee))
# [0.11.0](https://github.com/expo/entity/compare/v0.10.0...v0.11.0) (2020-12-24)
### Features
* better cache adapter error handling ([#102](https://github.com/expo/entity/issues/102)) ([15546aa](https://github.com/expo/entity/commit/15546aad97734ab9ac67f1c012485f096a0e94f1))
* better database adapter error handling ([#101](https://github.com/expo/entity/issues/101)) ([5208aee](https://github.com/expo/entity/commit/5208aeedb0192f0460ba3d575770a04175f5c3aa))
# [0.10.0](https://github.com/expo/entity/compare/v0.9.1...v0.10.0) (2020-11-17)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
## [0.9.1](https://github.com/expo/entity/compare/v0.9.0...v0.9.1) (2020-11-06)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
# [0.9.0](https://github.com/expo/entity/compare/v0.8.1...v0.9.0) (2020-11-05)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
## [0.8.1](https://github.com/expo/entity/compare/v0.8.0...v0.8.1) (2020-10-09)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
# [0.8.0](https://github.com/expo/entity/compare/v0.7.1...v0.8.0) (2020-10-09)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
## [0.7.1](https://github.com/expo/entity/compare/v0.7.0...v0.7.1) (2020-10-01)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
# [0.7.0](https://github.com/expo/entity/compare/v0.6.0...v0.7.0) (2020-10-01)
### Bug Fixes
* fix index export for EntityMutationTrigger and EntityMutationValidator ([#73](https://github.com/expo/entity/issues/73)) ([7e829f3](https://github.com/expo/entity/commit/7e829f30fde0bd343cb8a584ccda5530cf61ec33))
* move cache invalidation to after transaction commit ([#77](https://github.com/expo/entity/issues/77)) ([dbc3c81](https://github.com/expo/entity/commit/dbc3c81b359a668b5134189ce6de49b5893bb5e7))
* upgrade packages ([#78](https://github.com/expo/entity/issues/78)) ([9891e74](https://github.com/expo/entity/commit/9891e7469467a28589f529c8d87b10fc2232d3ff))
# [0.6.0](https://github.com/expo/entity/compare/v0.5.2...v0.6.0) (2020-07-23)
### Bug Fixes
* separate out EntityMutationValidator ([#68](https://github.com/expo/entity/issues/68)) ([547a1ef](https://github.com/expo/entity/commit/547a1efcecd17cc085702d1a3e9888ce5b644b13))
### Features
* entity mutation validators ([#67](https://github.com/expo/entity/issues/67)) ([fc4377d](https://github.com/expo/entity/commit/fc4377d8839da07417b88afc138f73556383d896))
* mutation triggers ([#65](https://github.com/expo/entity/issues/65)) ([fd6060c](https://github.com/expo/entity/commit/fd6060cc844f60635b3ce4c400c4877f2df8fa44))
## [0.5.2](https://github.com/expo/entity/compare/v0.5.1...v0.5.2) (2020-07-02)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
## [0.5.1](https://github.com/expo/entity/compare/v0.5.0...v0.5.1) (2020-07-01)
### Bug Fixes
* correct some typing issues and exports ([#57](https://github.com/expo/entity/issues/57)) ([e91cfba](https://github.com/expo/entity/commit/e91cfba99321c2c4da2078aafd8627cbf0ceca50))
# 0.5.0 (2020-07-01)
### Features
* support entity fields subset of db fields ([#49](https://github.com/expo/entity/issues/49)) ([4e40b2e](https://github.com/expo/entity/commit/4e40b2e521407e521d236978ec3b3b56db3990be))
@@ -9,16 +9,16 @@
During `EntityCompanionProvider` instantiation: During `EntityCompanionProvider` instantiation:
```typescript ```typescript
import { knex, Knex } from 'knex'; import Knex from 'knex';
const knexInstance = knex({ const knexInstance = Knex({
client: 'pg', client: 'pg',
connection: { connection: {
user: process.env['PGUSER'], user: process.env.PGUSER,
password: process.env['PGPASSWORD'], password: process.env.PGPASSWORD,
host: process.env['PGHOST'], host: process.env.PGHOST,
port: parseInt(nullthrows(process.env['PGPORT']), 10), port: parseInt(process.env.PGPORT!, 10),
database: process.env['PGDATABASE'], database: process.env.PGDATABASE,
}, },
}); });
@@ -29,7 +29,7 @@ export const createDefaultEntityCompanionProvider = (
metricsAdapter, metricsAdapter,
{ {
// add the knex database adapter flavor // add the knex database adapter flavor
['postgres']: { [DatabaseAdapterFlavor.POSTGRES]: {
adapter: PostgresEntityDatabaseAdapter, adapter: PostgresEntityDatabaseAdapter,
queryContextProvider: new PostgresEntityQueryContextProvider(knexInstance), queryContextProvider: new PostgresEntityQueryContextProvider(knexInstance),
}, },
@@ -1,6 +1,6 @@
{ {
"name": "@expo/entity-database-adapter-knex", "name": "@expo/entity-database-adapter-knex",
"version": "0.22.0", "version": "0.4.0",
"description": "Knex database adapter for @expo/entity", "description": "Knex database adapter for @expo/entity",
"files": [ "files": [
"build", "build",
@@ -11,15 +11,14 @@
"scripts": { "scripts": {
"tsc": "tsc", "tsc": "tsc",
"clean": "rm -rf build coverage coverage-integration", "clean": "rm -rf build coverage coverage-integration",
"lint": "eslint src", "lint": "eslint src --ext '.ts'",
"lint-fix": "eslint src --fix", "test": "jest --rootDir --config ../../resources/jest.config.js --passWithNoTests",
"test": "jest --rootDir . --config ../../resources/jest.config.js --passWithNoTests",
"integration": "../../resources/run-with-docker yarn integration-no-setup", "integration": "../../resources/run-with-docker yarn integration-no-setup",
"integration-no-setup": "jest --config ../../resources/jest-integration.config.js --rootDir . --runInBand --passWithNoTests", "integration-no-setup": "jest --config ../../resources/jest-integration.config.js --rootDir --runInBand --passWithNoTests",
"barrelsby": "barrelsby --directory src --location top --exclude tests__ --singleQuotes --exportDefault --delete" "barrelsby": "barrelsby --directory src --location top --exclude tests__ --singleQuotes --exportDefault --delete"
}, },
"engines": { "engines": {
"node": ">=12" "node": ">=8"
}, },
"keywords": [ "keywords": [
"entity" "entity"
@@ -30,10 +29,9 @@
"@expo/entity": "*" "@expo/entity": "*"
}, },
"dependencies": { "dependencies": {
"knex": "^1.0.2" "knex": "^0.20.11"
}, },
"devDependencies": { "devDependencies": {
"@expo/entity": "^0.22.0", "@expo/entity": "^0.4.0"
"pg": "^8.7.1"
} }
} }
@@ -8,9 +8,7 @@ import {
TableFieldSingleValueEqualityCondition, TableFieldSingleValueEqualityCondition,
TableFieldMultiValueEqualityCondition, TableFieldMultiValueEqualityCondition,
} from '@expo/entity'; } from '@expo/entity';
import { Knex } from 'knex'; import Knex from 'knex';
import wrapNativePostgresCallAsync from './errors/wrapNativePostgresCallAsync';
export default class PostgresEntityDatabaseAdapter<TFields> extends EntityDatabaseAdapter<TFields> { export default class PostgresEntityDatabaseAdapter<TFields> extends EntityDatabaseAdapter<TFields> {
protected getFieldTransformerMap(): FieldTransformerMap { protected getFieldTransformerMap(): FieldTransformerMap {
@@ -45,12 +43,10 @@ export default class PostgresEntityDatabaseAdapter<TFields> extends EntityDataba
tableField: string, tableField: string,
tableValues: readonly any[] tableValues: readonly any[]
): Promise<object[]> { ): Promise<object[]> {
return await wrapNativePostgresCallAsync(() => return await queryInterface
queryInterface
.select() .select()
.from(tableName) .from(tableName)
.whereRaw('?? = ANY(?)', [tableField, tableValues as any[]]) .whereRaw('?? = ANY(?)', [tableField, tableValues as any[]]);
);
} }
private applyQueryModifiersToQuery( private applyQueryModifiersToQuery(
@@ -89,39 +85,20 @@ export default class PostgresEntityDatabaseAdapter<TFields> extends EntityDataba
if (tableFieldSingleValueEqualityOperands.length > 0) { if (tableFieldSingleValueEqualityOperands.length > 0) {
const whereObject: { [key: string]: any } = {}; const whereObject: { [key: string]: any } = {};
const nonNullTableFieldSingleValueEqualityOperands = for (const { tableField, tableValue } of tableFieldSingleValueEqualityOperands) {
tableFieldSingleValueEqualityOperands.filter(({ tableValue }) => tableValue !== null);
const nullTableFieldSingleValueEqualityOperands =
tableFieldSingleValueEqualityOperands.filter(({ tableValue }) => tableValue === null);
if (nonNullTableFieldSingleValueEqualityOperands.length > 0) {
for (const { tableField, tableValue } of nonNullTableFieldSingleValueEqualityOperands) {
whereObject[tableField] = tableValue; whereObject[tableField] = tableValue;
} }
query = query.where(whereObject); query = query.where(whereObject);
} }
if (nullTableFieldSingleValueEqualityOperands.length > 0) {
for (const { tableField } of nullTableFieldSingleValueEqualityOperands) {
query = query.whereNull(tableField);
}
}
}
if (tableFieldMultiValueEqualityOperands.length > 0) { if (tableFieldMultiValueEqualityOperands.length > 0) {
for (const { tableField, tableValues } of tableFieldMultiValueEqualityOperands) { for (const { tableField, tableValues } of tableFieldMultiValueEqualityOperands) {
const nonNullTableValues = tableValues.filter((tableValue) => tableValue !== null); query = query.whereRaw('?? = ANY(?)', [tableField, tableValues]);
query = query.where((builder) => {
builder.whereRaw('?? = ANY(?)', [tableField, [...nonNullTableValues]]);
// there was at least one null, allow null in this equality clause
if (nonNullTableValues.length !== tableValues.length) {
builder.orWhereNull(tableField);
}
});
} }
} }
query = this.applyQueryModifiersToQuery(query, querySelectionModifiers); query = this.applyQueryModifiersToQuery(query, querySelectionModifiers);
return await wrapNativePostgresCallAsync(() => query); return await query;
} }
protected async fetchManyByRawWhereClauseInternalAsync( protected async fetchManyByRawWhereClauseInternalAsync(
@@ -136,7 +113,7 @@ export default class PostgresEntityDatabaseAdapter<TFields> extends EntityDataba
.from(tableName) .from(tableName)
.whereRaw(rawWhereClause, bindings as any); .whereRaw(rawWhereClause, bindings as any);
query = this.applyQueryModifiersToQuery(query, querySelectionModifiers); query = this.applyQueryModifiersToQuery(query, querySelectionModifiers);
return await wrapNativePostgresCallAsync(() => query); return await query;
} }
protected async insertInternalAsync( protected async insertInternalAsync(
@@ -144,9 +121,7 @@ export default class PostgresEntityDatabaseAdapter<TFields> extends EntityDataba
tableName: string, tableName: string,
object: object object: object
): Promise<object[]> { ): Promise<object[]> {
return await wrapNativePostgresCallAsync(() => return await queryInterface.insert(object).into(tableName).returning('*');
queryInterface.insert(object).into(tableName).returning('*')
);
} }
protected async updateInternalAsync( protected async updateInternalAsync(
@@ -156,9 +131,11 @@ export default class PostgresEntityDatabaseAdapter<TFields> extends EntityDataba
id: any, id: any,
object: object object: object
): Promise<object[]> { ): Promise<object[]> {
return await wrapNativePostgresCallAsync(() => return await queryInterface
queryInterface.update(object).into(tableName).where(tableIdField, id).returning('*') .update(object)
); .into(tableName)
.where(tableIdField, id)
.returning('*');
} }
protected async deleteInternalAsync( protected async deleteInternalAsync(
@@ -167,8 +144,6 @@ export default class PostgresEntityDatabaseAdapter<TFields> extends EntityDataba
tableIdField: string, tableIdField: string,
id: any id: any
): Promise<number> { ): Promise<number> {
return await wrapNativePostgresCallAsync(() => return await queryInterface.into(tableName).where(tableIdField, id).del();
queryInterface.into(tableName).where(tableIdField, id).del()
);
} }
} }
@@ -7,8 +7,7 @@ import {
import PostgresEntityDatabaseAdapter from './PostgresEntityDatabaseAdapter'; import PostgresEntityDatabaseAdapter from './PostgresEntityDatabaseAdapter';
export default class PostgresEntityDatabaseAdapterProvider export default class PostgresEntityDatabaseAdapterProvider
implements IEntityDatabaseAdapterProvider implements IEntityDatabaseAdapterProvider {
{
getDatabaseAdapter<TFields>( getDatabaseAdapter<TFields>(
entityConfiguration: EntityConfiguration<TFields> entityConfiguration: EntityConfiguration<TFields>
): EntityDatabaseAdapter<TFields> { ): EntityDatabaseAdapter<TFields> {
@@ -1,21 +1,25 @@
import { EntityQueryContextProvider } from '@expo/entity'; import {
import { Knex } from 'knex'; IEntityQueryContextProvider,
EntityNonTransactionalQueryContext,
EntityTransactionalQueryContext,
} from '@expo/entity';
import Knex from 'knex';
/** /**
* Query context provider for knex (postgres). * Query context provider for knex (postgres).
*/ */
export default class PostgresEntityQueryContextProvider extends EntityQueryContextProvider { export default class PostgresEntityQueryContextProvider implements IEntityQueryContextProvider {
constructor(private readonly knexInstance: Knex) { constructor(private readonly knexInstance: Knex) {}
super();
getRegularEntityQueryContext(): EntityNonTransactionalQueryContext {
return new EntityNonTransactionalQueryContext(this.knexInstance);
} }
protected getQueryInterface(): any { async runInTransactionAsync<T>(
return this.knexInstance; transactionScope: (queryContext: EntityTransactionalQueryContext) => Promise<T>
} ): Promise<T> {
return await this.knexInstance.transaction(async (trx) => {
protected createTransactionRunner<T>(): ( return await transactionScope(new EntityTransactionalQueryContext(trx));
transactionScope: (trx: any) => Promise<T> });
) => Promise<T> {
return (transactionScope) => this.knexInstance.transaction(transactionScope);
} }
} }
@@ -5,26 +5,23 @@ import {
ViewerContext, ViewerContext,
} from '@expo/entity'; } from '@expo/entity';
import { enforceAsyncResult } from '@expo/results'; import { enforceAsyncResult } from '@expo/results';
import { knex, Knex } from 'knex'; import Knex from 'knex';
import nullthrows from 'nullthrows';
import PostgresTestEntity from '../testfixtures/PostgresTestEntity'; import PostgresTestEntity from '../testfixtures/PostgresTestEntity';
import PostgresTriggerTestEntity from '../testfixtures/PostgresTriggerTestEntity';
import PostgresValidatorTestEntity from '../testfixtures/PostgresValidatorTestEntity';
import { createKnexIntegrationTestEntityCompanionProvider } from '../testfixtures/createKnexIntegrationTestEntityCompanionProvider'; import { createKnexIntegrationTestEntityCompanionProvider } from '../testfixtures/createKnexIntegrationTestEntityCompanionProvider';
describe('postgres entity integration', () => { describe('postgres entity integration', () => {
let knexInstance: Knex; let knexInstance: Knex;
beforeAll(() => { beforeAll(() => {
knexInstance = knex({ knexInstance = Knex({
client: 'pg', client: 'pg',
connection: { connection: {
user: nullthrows(process.env['PGUSER']), user: process.env.PGUSER,
password: nullthrows(process.env['PGPASSWORD']), password: process.env.PGPASSWORD,
host: 'localhost', host: 'localhost',
port: parseInt(nullthrows(process.env['PGPORT']), 10), port: parseInt(process.env.PGPORT!, 10),
database: nullthrows(process.env['PGDATABASE']), database: process.env.PGDATABASE,
}, },
}); });
}); });
@@ -35,7 +32,7 @@ describe('postgres entity integration', () => {
afterAll(async () => { afterAll(async () => {
await PostgresTestEntity.dropPostgresTable(knexInstance); await PostgresTestEntity.dropPostgresTable(knexInstance);
await knexInstance.destroy(); knexInstance.destroy();
}); });
it('supports parallel partial updates', async () => { it('supports parallel partial updates', async () => {
@@ -95,7 +92,7 @@ describe('postgres entity integration', () => {
const errorToThrow = new Error('Intentional error'); const errorToThrow = new Error('Intentional error');
await expect( await expect(
vc1.runInTransactionForDatabaseAdaptorFlavorAsync('postgres', async (queryContext) => { PostgresTestEntity.runInTransactionAsync(vc1, async (queryContext) => {
// put another in the DB that will be rolled back due to error thrown // put another in the DB that will be rolled back due to error thrown
await enforceAsyncResult( await enforceAsyncResult(
PostgresTestEntity.creator(vc1, queryContext).setField('name', 'hello').createAsync() PostgresTestEntity.creator(vc1, queryContext).setField('name', 'hello').createAsync()
@@ -232,59 +229,6 @@ describe('postgres entity integration', () => {
expect(results).toHaveLength(2); expect(results).toHaveLength(2);
expect(results.map((e) => e.getField('name'))).toEqual(['b', 'a']); expect(results.map((e) => e.getField('name'))).toEqual(['b', 'a']);
}); });
it('supports null field values', async () => {
const vc1 = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance));
await enforceAsyncResult(
PostgresTestEntity.creator(vc1)
.setField('name', 'a')
.setField('hasADog', true)
.createAsync()
);
await enforceAsyncResult(
PostgresTestEntity.creator(vc1)
.setField('name', 'b')
.setField('hasADog', true)
.createAsync()
);
await enforceAsyncResult(
PostgresTestEntity.creator(vc1)
.setField('name', null)
.setField('hasADog', true)
.createAsync()
);
await enforceAsyncResult(
PostgresTestEntity.creator(vc1)
.setField('name', null)
.setField('hasADog', false)
.createAsync()
);
const results = await PostgresTestEntity.loader(vc1)
.enforcing()
.loadManyByFieldEqualityConjunctionAsync([{ fieldName: 'name', fieldValue: null }]);
expect(results).toHaveLength(2);
expect(results[0]!.getField('name')).toBeNull();
const results2 = await PostgresTestEntity.loader(vc1)
.enforcing()
.loadManyByFieldEqualityConjunctionAsync(
[
{ fieldName: 'name', fieldValues: ['a', null] },
{ fieldName: 'hasADog', fieldValue: true },
],
{
orderBy: [
{
fieldName: 'name',
order: OrderByOrdering.DESCENDING,
},
],
}
);
expect(results2).toHaveLength(2);
expect(results2.map((e) => e.getField('name'))).toEqual([null, 'a']);
});
}); });
describe('raw where clause loading', () => { describe('raw where clause loading', () => {
@@ -381,256 +325,4 @@ describe('postgres entity integration', () => {
expect(resultsMultipleOrderBy.map((e) => e.getField('name'))).toEqual(['c', 'b', 'a']); expect(resultsMultipleOrderBy.map((e) => e.getField('name'))).toEqual(['c', 'b', 'a']);
}); });
}); });
describe('trigger transaction behavior', () => {
describe('create', () => {
it('rolls back transaction when trigger throws except afterCommit', async () => {
const vc1 = new ViewerContext(
createKnexIntegrationTestEntityCompanionProvider(knexInstance)
);
await expect(
PostgresTriggerTestEntity.creator(vc1)
.setField('name', 'beforeCreate')
.enforceCreateAsync()
).rejects.toThrowError('name cannot have value beforeCreate');
await expect(
PostgresTriggerTestEntity.loader(vc1)
.enforcing()
.loadByFieldEqualingAsync('name', 'beforeCreate')
).resolves.toBeNull();
await expect(
PostgresTriggerTestEntity.creator(vc1)
.setField('name', 'afterCreate')
.enforceCreateAsync()
).rejects.toThrowError('name cannot have value afterCreate');
await expect(
PostgresTriggerTestEntity.loader(vc1)
.enforcing()
.loadByFieldEqualingAsync('name', 'afterCreate')
).resolves.toBeNull();
await expect(
PostgresTriggerTestEntity.creator(vc1).setField('name', 'beforeAll').enforceCreateAsync()
).rejects.toThrowError('name cannot have value beforeAll');
await expect(
PostgresTriggerTestEntity.loader(vc1)
.enforcing()
.loadByFieldEqualingAsync('name', 'beforeAll')
).resolves.toBeNull();
await expect(
PostgresTriggerTestEntity.creator(vc1).setField('name', 'afterAll').enforceCreateAsync()
).rejects.toThrowError('name cannot have value afterAll');
await expect(
PostgresTriggerTestEntity.loader(vc1)
.enforcing()
.loadByFieldEqualingAsync('name', 'afterAll')
).resolves.toBeNull();
await expect(
PostgresTriggerTestEntity.creator(vc1)
.setField('name', 'afterCommit')
.enforceCreateAsync()
).rejects.toThrowError('name cannot have value afterCommit');
await expect(
PostgresTriggerTestEntity.loader(vc1)
.enforcing()
.loadByFieldEqualingAsync('name', 'afterCommit')
).resolves.not.toBeNull();
});
});
describe('update', () => {
it('rolls back transaction when trigger throws except afterCommit', async () => {
const vc1 = new ViewerContext(
createKnexIntegrationTestEntityCompanionProvider(knexInstance)
);
const entity = await PostgresTriggerTestEntity.creator(vc1)
.setField('name', 'blah')
.enforceCreateAsync();
await expect(
PostgresTriggerTestEntity.updater(entity)
.setField('name', 'beforeUpdate')
.enforceUpdateAsync()
).rejects.toThrowError('name cannot have value beforeUpdate');
await expect(
PostgresTriggerTestEntity.loader(vc1)
.enforcing()
.loadByFieldEqualingAsync('name', 'beforeUpdate')
).resolves.toBeNull();
await expect(
PostgresTriggerTestEntity.updater(entity)
.setField('name', 'afterUpdate')
.enforceUpdateAsync()
).rejects.toThrowError('name cannot have value afterUpdate');
await expect(
PostgresTriggerTestEntity.loader(vc1)
.enforcing()
.loadByFieldEqualingAsync('name', 'afterUpdate')
).resolves.toBeNull();
await expect(
PostgresTriggerTestEntity.updater(entity)
.setField('name', 'beforeAll')
.enforceUpdateAsync()
).rejects.toThrowError('name cannot have value beforeAll');
await expect(
PostgresTriggerTestEntity.loader(vc1)
.enforcing()
.loadByFieldEqualingAsync('name', 'beforeAll')
).resolves.toBeNull();
await expect(
PostgresTriggerTestEntity.updater(entity)
.setField('name', 'afterAll')
.enforceUpdateAsync()
).rejects.toThrowError('name cannot have value afterAll');
await expect(
PostgresTriggerTestEntity.loader(vc1)
.enforcing()
.loadByFieldEqualingAsync('name', 'afterAll')
).resolves.toBeNull();
await expect(
PostgresTriggerTestEntity.updater(entity)
.setField('name', 'afterCommit')
.enforceUpdateAsync()
).rejects.toThrowError('name cannot have value afterCommit');
await expect(
PostgresTriggerTestEntity.loader(vc1)
.enforcing()
.loadByFieldEqualingAsync('name', 'afterCommit')
).resolves.not.toBeNull();
});
});
describe('delete', () => {
it('rolls back transaction when trigger throws except afterCommit', async () => {
const vc1 = new ViewerContext(
createKnexIntegrationTestEntityCompanionProvider(knexInstance)
);
const entityBeforeDelete = await PostgresTriggerTestEntity.creator(vc1)
.setField('name', 'beforeDelete')
.enforceCreateAsync();
await expect(
PostgresTriggerTestEntity.enforceDeleteAsync(entityBeforeDelete)
).rejects.toThrowError('name cannot have value beforeDelete');
await expect(
PostgresTriggerTestEntity.loader(vc1)
.enforcing()
.loadByFieldEqualingAsync('name', 'beforeDelete')
).resolves.not.toBeNull();
const entityAfterDelete = await PostgresTriggerTestEntity.creator(vc1)
.setField('name', 'afterDelete')
.enforceCreateAsync();
await expect(
PostgresTriggerTestEntity.enforceDeleteAsync(entityAfterDelete)
).rejects.toThrowError('name cannot have value afterDelete');
await expect(
PostgresTriggerTestEntity.loader(vc1)
.enforcing()
.loadByFieldEqualingAsync('name', 'afterDelete')
).resolves.not.toBeNull();
});
});
describe('validation transaction behavior', () => {
describe('create', () => {
it('rolls back transaction when trigger throws ', async () => {
const vc1 = new ViewerContext(
createKnexIntegrationTestEntityCompanionProvider(knexInstance)
);
await expect(
PostgresValidatorTestEntity.creator(vc1)
.setField('name', 'beforeCreateAndBeforeUpdate')
.enforceCreateAsync()
).rejects.toThrowError('name cannot have value beforeCreateAndBeforeUpdate');
await expect(
PostgresValidatorTestEntity.loader(vc1)
.enforcing()
.loadByFieldEqualingAsync('name', 'beforeCreateAndBeforeUpdate')
).resolves.toBeNull();
});
});
describe('update', () => {
it('rolls back transaction when trigger throws ', async () => {
const vc1 = new ViewerContext(
createKnexIntegrationTestEntityCompanionProvider(knexInstance)
);
const entity = await PostgresValidatorTestEntity.creator(vc1)
.setField('name', 'blah')
.enforceCreateAsync();
await expect(
PostgresValidatorTestEntity.updater(entity)
.setField('name', 'beforeCreateAndBeforeUpdate')
.enforceUpdateAsync()
).rejects.toThrowError('name cannot have value beforeCreateAndBeforeUpdate');
await expect(
PostgresValidatorTestEntity.loader(vc1)
.enforcing()
.loadByFieldEqualingAsync('name', 'beforeCreateAndBeforeUpdate')
).resolves.toBeNull();
});
});
describe('delete', () => {
it('validation should not run on a delete mutation', async () => {
const vc1 = new ViewerContext(
createKnexIntegrationTestEntityCompanionProvider(knexInstance)
);
const entityToDelete = await PostgresValidatorTestEntity.creator(vc1)
.setField('name', 'shouldBeDeleted')
.enforceCreateAsync();
await PostgresValidatorTestEntity.enforceDeleteAsync(entityToDelete);
await expect(
PostgresValidatorTestEntity.loader(vc1)
.enforcing()
.loadByFieldEqualingAsync('name', 'shouldBeDeleted')
).resolves.toBeNull();
});
});
});
});
describe('queryContext callback behavior', () => {
it('calls callbacks correctly', async () => {
const vc1 = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance));
let preCommitCallCount = 0;
let postCommitCallCount = 0;
await vc1.runInTransactionForDatabaseAdaptorFlavorAsync('postgres', async (queryContext) => {
queryContext.appendPostCommitCallback(async () => {
postCommitCallCount++;
});
queryContext.appendPreCommitCallback(async () => {
preCommitCallCount++;
}, 0);
});
await expect(
vc1.runInTransactionForDatabaseAdaptorFlavorAsync('postgres', async (queryContext) => {
queryContext.appendPostCommitCallback(async () => {
postCommitCallCount++;
});
queryContext.appendPreCommitCallback(async () => {
preCommitCallCount++;
throw Error('wat');
}, 0);
})
).rejects.toThrowError('wat');
expect(preCommitCallCount).toBe(2);
expect(postCommitCallCount).toBe(1);
});
});
}); });
@@ -1,7 +1,6 @@
import { ViewerContext } from '@expo/entity'; import { ViewerContext } from '@expo/entity';
import { enforceAsyncResult } from '@expo/results'; import { enforceAsyncResult } from '@expo/results';
import { knex, Knex } from 'knex'; import Knex from 'knex';
import nullthrows from 'nullthrows';
import InvalidTestEntity from '../testfixtures/InvalidTestEntity'; import InvalidTestEntity from '../testfixtures/InvalidTestEntity';
import { createKnexIntegrationTestEntityCompanionProvider } from '../testfixtures/createKnexIntegrationTestEntityCompanionProvider'; import { createKnexIntegrationTestEntityCompanionProvider } from '../testfixtures/createKnexIntegrationTestEntityCompanionProvider';
@@ -10,14 +9,14 @@ describe('postgres entity integration', () => {
let knexInstance: Knex; let knexInstance: Knex;
beforeAll(() => { beforeAll(() => {
knexInstance = knex({ knexInstance = Knex({
client: 'pg', client: 'pg',
connection: { connection: {
user: nullthrows(process.env['PGUSER']), user: process.env.PGUSER,
password: nullthrows(process.env['PGPASSWORD']), password: process.env.PGPASSWORD,
host: 'localhost', host: 'localhost',
port: parseInt(nullthrows(process.env['PGPORT']), 10), port: parseInt(process.env.PGPORT!, 10),
database: nullthrows(process.env['PGDATABASE']), database: process.env.PGDATABASE,
}, },
}); });
}); });
@@ -28,7 +27,7 @@ describe('postgres entity integration', () => {
afterAll(async () => { afterAll(async () => {
await InvalidTestEntity.dropPostgresTable(knexInstance); await InvalidTestEntity.dropPostgresTable(knexInstance);
await knexInstance.destroy(); knexInstance.destroy();
}); });
describe('invalid operation result counts', () => { describe('invalid operation result counts', () => {
@@ -44,6 +43,9 @@ describe('postgres entity integration', () => {
await expect(InvalidTestEntity.deleteAsync(entity1)).rejects.toThrowError( await expect(InvalidTestEntity.deleteAsync(entity1)).rejects.toThrowError(
'Excessive deletions from database adapter delete' 'Excessive deletions from database adapter delete'
); );
await expect(InvalidTestEntity.deleteAsync(entity1)).rejects.toThrowError(
'No deletions from database adapter delete'
);
}); });
it('throws after update of multiple rows', async () => { it('throws after update of multiple rows', async () => {
@@ -1,171 +0,0 @@
import {
ViewerContext,
EntityDatabaseAdapterCheckConstraintError,
EntityDatabaseAdapterExclusionConstraintError,
EntityDatabaseAdapterForeignKeyConstraintError,
EntityDatabaseAdapterNotNullConstraintError,
EntityDatabaseAdapterTransientError,
EntityDatabaseAdapterUniqueConstraintError,
EntityDatabaseAdapterUnknownError,
} from '@expo/entity';
import { knex, Knex } from 'knex';
import nullthrows from 'nullthrows';
import ErrorsTestEntity from '../testfixtures/ErrorsTestEntity';
import { createKnexIntegrationTestEntityCompanionProvider } from '../testfixtures/createKnexIntegrationTestEntityCompanionProvider';
describe('postgres errors', () => {
let knexInstance: Knex;
beforeAll(() => {
knexInstance = knex({
client: 'pg',
connection: {
user: nullthrows(process.env['PGUSER']),
password: nullthrows(process.env['PGPASSWORD']),
host: 'localhost',
port: parseInt(nullthrows(process.env['PGPORT']), 10),
database: nullthrows(process.env['PGDATABASE']),
},
});
});
beforeEach(async () => {
await ErrorsTestEntity.createOrTruncatePostgresTable(knexInstance);
});
afterAll(async () => {
await ErrorsTestEntity.dropPostgresTable(knexInstance);
await knexInstance.destroy();
});
it('throws EntityDatabaseAdapterTransientError on Knex timeout', async () => {
const vc = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance));
await ErrorsTestEntity.creator(vc)
.setField('id', 1)
.setField('fieldNonNull', 'hello')
.enforceCreateAsync();
const shortTimeoutKnexInstance = knex({
client: 'pg',
connection: {
user: nullthrows(process.env['PGUSER']),
password: nullthrows(process.env['PGPASSWORD']),
host: 'localhost',
port: parseInt(nullthrows(process.env['PGPORT']), 10),
database: nullthrows(process.env['PGDATABASE']),
},
acquireConnectionTimeout: 1,
});
const vc2 = new ViewerContext(
createKnexIntegrationTestEntityCompanionProvider(shortTimeoutKnexInstance)
);
await expect(ErrorsTestEntity.loader(vc2).enforcing().loadByIDAsync(1)).rejects.toThrow(
EntityDatabaseAdapterTransientError
);
await shortTimeoutKnexInstance.destroy();
});
it('throws EntityDatabaseAdapterNotNullConstraintError when not null is violated', async () => {
const vc = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance));
await expect(
ErrorsTestEntity.creator(vc)
.setField('id', 1)
.setField('fieldNonNull', null as any)
.enforceCreateAsync()
).rejects.toThrow(EntityDatabaseAdapterNotNullConstraintError);
});
it('throws EntityDatabaseAdapterForeignKeyConstraintError when foreign key is violated', async () => {
const vc = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance));
await expect(
ErrorsTestEntity.creator(vc)
.setField('id', 1)
.setField('fieldNonNull', 'hello')
.setField('fieldForeignKey', 2)
.enforceCreateAsync()
).rejects.toThrow(EntityDatabaseAdapterForeignKeyConstraintError);
});
it('throws EntityDatabaseAdapterUniqueConstraintError when primary key unique constraint is violated', async () => {
const vc = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance));
await ErrorsTestEntity.creator(vc)
.setField('id', 1)
.setField('fieldNonNull', 'hello')
.enforceCreateAsync();
await expect(
ErrorsTestEntity.creator(vc)
.setField('id', 1)
.setField('fieldNonNull', 'hello')
.enforceCreateAsync()
).rejects.toThrow(EntityDatabaseAdapterUniqueConstraintError);
});
it('throws EntityDatabaseAdapterUniqueConstraintError when unique constraint is violated', async () => {
const vc = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance));
await ErrorsTestEntity.creator(vc)
.setField('id', 2)
.setField('fieldNonNull', 'hello')
.setField('fieldUnique', 'hello')
.enforceCreateAsync();
await expect(
ErrorsTestEntity.creator(vc)
.setField('id', 1)
.setField('fieldNonNull', 'hello')
.setField('fieldUnique', 'hello')
.enforceCreateAsync()
).rejects.toThrow(EntityDatabaseAdapterUniqueConstraintError);
});
it('throws EntityDatabaseAdapterCheckConstraintError when check constraint is violated', async () => {
const vc = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance));
await expect(
ErrorsTestEntity.creator(vc)
.setField('id', 1)
.setField('fieldNonNull', 'hello')
.setField('checkLessThan5', 2)
.enforceCreateAsync()
).resolves.toBeTruthy();
await expect(
ErrorsTestEntity.creator(vc)
.setField('id', 2)
.setField('fieldNonNull', 'hello')
.setField('checkLessThan5', 10)
.enforceCreateAsync()
).rejects.toThrow(EntityDatabaseAdapterCheckConstraintError);
});
it('throws EntityDatabaseAdapterExclusionConstraintError when exclusion constraint is violated', async () => {
const vc = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance));
await expect(
ErrorsTestEntity.creator(vc)
.setField('id', 1)
.setField('fieldNonNull', 'hello')
.setField('fieldExclusion', 'what')
.enforceCreateAsync()
).resolves.toBeTruthy();
await expect(
ErrorsTestEntity.creator(vc)
.setField('id', 2)
.setField('fieldNonNull', 'hello')
.setField('fieldExclusion', 'what')
.enforceCreateAsync()
).rejects.toThrow(EntityDatabaseAdapterExclusionConstraintError);
});
it('throws EntityDatabaseAdapterUnknownError otherwise', async () => {
const vc = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance));
await expect(
ErrorsTestEntity.creator(vc)
.setField('id', 1)
.setField('fieldNonNull', 'hello')
.setField('nonExistentColumn', 'what')
.enforceCreateAsync()
).rejects.toThrow(EntityDatabaseAdapterUnknownError);
});
});
@@ -1,19 +0,0 @@
import wrapNativePostgresCallAsync from '../wrapNativePostgresCallAsync';
describe(wrapNativePostgresCallAsync, () => {
it('rethrows literals', async () => {
const throwingFn = async (): Promise<void> => {
// eslint-disable-next-line no-throw-literal,@typescript-eslint/no-throw-literal
throw 'hello';
};
let capturedThrownThing: any;
try {
await wrapNativePostgresCallAsync(throwingFn);
} catch (e) {
capturedThrownThing = e;
}
expect(capturedThrownThing).not.toBeInstanceOf(Error);
expect(capturedThrownThing).toEqual('hello');
});
});
@@ -1,55 +0,0 @@
import {
EntityDatabaseAdapterError,
EntityDatabaseAdapterCheckConstraintError,
EntityDatabaseAdapterExclusionConstraintError,
EntityDatabaseAdapterForeignKeyConstraintError,
EntityDatabaseAdapterNotNullConstraintError,
EntityDatabaseAdapterTransientError,
EntityDatabaseAdapterUniqueConstraintError,
EntityDatabaseAdapterUnknownError,
} from '@expo/entity';
import { knex } from 'knex';
function wrapNativePostgresError(
error: Error & { code?: string }
): EntityDatabaseAdapterError & Error {
const ret = translatePostgresError(error);
if (error.stack) {
ret.stack = error.stack;
}
return ret;
}
function translatePostgresError(
error: Error & { code?: string }
): EntityDatabaseAdapterError & Error {
if (error instanceof knex.KnexTimeoutError) {
return new EntityDatabaseAdapterTransientError(error.message, error);
}
switch (error.code) {
case '23502':
return new EntityDatabaseAdapterNotNullConstraintError(error.message, error);
case '23503':
return new EntityDatabaseAdapterForeignKeyConstraintError(error.message, error);
case '23505':
return new EntityDatabaseAdapterUniqueConstraintError(error.message, error);
case '23514':
return new EntityDatabaseAdapterCheckConstraintError(error.message, error);
case '23P01':
return new EntityDatabaseAdapterExclusionConstraintError(error.message, error);
default:
return new EntityDatabaseAdapterUnknownError(error.message, error);
}
}
export default async function wrapNativePostgresCallAsync<T>(fn: () => Promise<T>): Promise<T> {
try {
return await fn();
} catch (e) {
if (!(e instanceof Error)) {
throw e;
}
throw wrapNativePostgresError(e);
}
}
@@ -1,9 +1,2 @@
/* eslint-disable tsdoc/syntax */
/**
* @packageDocumentation
* @module @expo/entity-database-adapter-knex
*/
export { default as PostgresEntityDatabaseAdapter } from './PostgresEntityDatabaseAdapter'; export { default as PostgresEntityDatabaseAdapter } from './PostgresEntityDatabaseAdapter';
export { default as PostgresEntityDatabaseAdapterProvider } from './PostgresEntityDatabaseAdapterProvider';
export { default as PostgresEntityQueryContextProvider } from './PostgresEntityQueryContextProvider'; export { default as PostgresEntityQueryContextProvider } from './PostgresEntityQueryContextProvider';
@@ -1,177 +0,0 @@
import {
AlwaysAllowPrivacyPolicyRule,
EntityPrivacyPolicy,
ViewerContext,
StringField,
EntityConfiguration,
EntityCompanionDefinition,
Entity,
IntField,
} from '@expo/entity';
import { Knex } from 'knex';
type ErrorsTestEntityFields = {
id: number;
fieldNonNull: string;
fieldForeignKey: number | null;
fieldUnique: string | null;
checkLessThan5: number | null;
fieldExclusion: string | null;
nonExistentColumn: string | null;
};
const foreignTableName = 'foreign_table';
export default class ErrorsTestEntity extends Entity<
ErrorsTestEntityFields,
number,
ViewerContext
> {
static getCompanionDefinition(): EntityCompanionDefinition<
ErrorsTestEntityFields,
number,
ViewerContext,
ErrorsTestEntity,
ErrorsTestEntityPrivacyPolicy
> {
return errorsTestEntityCompanionDefinition;
}
public static async createOrTruncatePostgresTable(knex: Knex): Promise<void> {
await knex.raw('CREATE EXTENSION IF NOT EXISTS "btree_gist"'); // for gist exclusion on varchar
const tableName = this.getCompanionDefinition().entityConfiguration.tableName;
const hasForeignTable = await knex.schema.hasTable(foreignTableName);
if (!hasForeignTable) {
await knex.schema.createTable(foreignTableName, (table) => {
table.integer('id').primary();
});
}
const hasTable = await knex.schema.hasTable(tableName);
if (!hasTable) {
await knex.schema.createTable(tableName, (table) => {
table.integer('id').primary();
table.string('field_non_null').notNullable();
table.integer('field_foreign_key').references(`${foreignTableName}.id`);
table.string('field_unique').unique();
table.integer('check_less_than_5');
table.string('field_exclusion');
});
await knex.raw(
`
ALTER TABLE :tableName:
ADD CONSTRAINT check_less_than_5_check_constraint
CHECK (check_less_than_5 IS NULL OR check_less_than_5 < 5);`,
{
tableName,
}
);
await knex.raw(
`
ALTER TABLE :tableName:
ADD CONSTRAINT field_exclusion_exclusion_constraint
EXCLUDE USING gist (field_exclusion WITH =);
`,
{
tableName,
}
);
}
await knex(tableName).delete();
await knex(foreignTableName).delete();
}
public static async dropPostgresTable(knex: Knex): Promise<void> {
const tableName = this.getCompanionDefinition().entityConfiguration.tableName;
const hasTable = await knex.schema.hasTable(tableName);
if (hasTable) {
await knex.schema.dropTable(tableName);
}
const hasForeignTable = await knex.schema.hasTable(foreignTableName);
if (hasForeignTable) {
await knex.schema.dropTable(foreignTableName);
}
}
}
class ErrorsTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
ErrorsTestEntityFields,
number,
ViewerContext,
ErrorsTestEntity
> {
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<
ErrorsTestEntityFields,
number,
ViewerContext,
ErrorsTestEntity
>(),
];
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<
ErrorsTestEntityFields,
number,
ViewerContext,
ErrorsTestEntity
>(),
];
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<
ErrorsTestEntityFields,
number,
ViewerContext,
ErrorsTestEntity
>(),
];
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<
ErrorsTestEntityFields,
number,
ViewerContext,
ErrorsTestEntity
>(),
];
}
export const ErrorsTestEntityConfiguration = new EntityConfiguration<ErrorsTestEntityFields>({
idField: 'id',
tableName: 'postgres_test_entities',
schema: {
id: new IntField({
columnName: 'id',
}),
fieldNonNull: new StringField({
columnName: 'field_non_null',
}),
fieldForeignKey: new IntField({
columnName: 'field_foreign_key',
}),
fieldUnique: new StringField({
columnName: 'field_unique',
}),
checkLessThan5: new IntField({
columnName: 'check_less_than_5',
}),
fieldExclusion: new StringField({
columnName: 'field_exclusion',
}),
nonExistentColumn: new StringField({
columnName: 'non_existent_column',
}),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
});
const errorsTestEntityCompanionDefinition = new EntityCompanionDefinition({
entityClass: ErrorsTestEntity,
entityConfiguration: ErrorsTestEntityConfiguration,
privacyPolicyClass: ErrorsTestEntityPrivacyPolicy,
});
@@ -4,11 +4,13 @@ import {
ViewerContext, ViewerContext,
StringField, StringField,
EntityConfiguration, EntityConfiguration,
DatabaseAdapterFlavor,
CacheAdapterFlavor,
EntityCompanionDefinition, EntityCompanionDefinition,
Entity, Entity,
IntField, NumberField,
} from '@expo/entity'; } from '@expo/entity';
import { Knex } from 'knex'; import Knex from 'knex';
type InvalidTestEntityFields = { type InvalidTestEntityFields = {
id: number; id: number;
@@ -57,7 +59,7 @@ class InvalidTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
ViewerContext, ViewerContext,
InvalidTestEntity InvalidTestEntity
> { > {
protected override readonly createRules = [ protected readonly createRules = [
new AlwaysAllowPrivacyPolicyRule< new AlwaysAllowPrivacyPolicyRule<
InvalidTestEntityFields, InvalidTestEntityFields,
number, number,
@@ -65,7 +67,7 @@ class InvalidTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
InvalidTestEntity InvalidTestEntity
>(), >(),
]; ];
protected override readonly readRules = [ protected readonly readRules = [
new AlwaysAllowPrivacyPolicyRule< new AlwaysAllowPrivacyPolicyRule<
InvalidTestEntityFields, InvalidTestEntityFields,
number, number,
@@ -73,7 +75,7 @@ class InvalidTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
InvalidTestEntity InvalidTestEntity
>(), >(),
]; ];
protected override readonly updateRules = [ protected readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule< new AlwaysAllowPrivacyPolicyRule<
InvalidTestEntityFields, InvalidTestEntityFields,
number, number,
@@ -81,7 +83,7 @@ class InvalidTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
InvalidTestEntity InvalidTestEntity
>(), >(),
]; ];
protected override readonly deleteRules = [ protected readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule< new AlwaysAllowPrivacyPolicyRule<
InvalidTestEntityFields, InvalidTestEntityFields,
number, number,
@@ -95,19 +97,19 @@ export const invalidTestEntityConfiguration = new EntityConfiguration<InvalidTes
idField: 'id', idField: 'id',
tableName: 'postgres_test_entities', tableName: 'postgres_test_entities',
schema: { schema: {
id: new IntField({ id: new NumberField({
columnName: 'id', columnName: 'id',
}), }),
name: new StringField({ name: new StringField({
columnName: 'name', columnName: 'name',
}), }),
}, },
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
}); });
const invalidTestEntityCompanionDefinition = new EntityCompanionDefinition({ const invalidTestEntityCompanionDefinition = {
entityClass: InvalidTestEntity, entityClass: InvalidTestEntity,
entityConfiguration: invalidTestEntityConfiguration, entityConfiguration: invalidTestEntityConfiguration,
databaseAdaptorFlavor: DatabaseAdapterFlavor.POSTGRES,
cacheAdaptorFlavor: CacheAdapterFlavor.REDIS,
privacyPolicyClass: InvalidTestEntityPrivacyPolicy, privacyPolicyClass: InvalidTestEntityPrivacyPolicy,
}); };
@@ -11,10 +11,12 @@ import {
DateField, DateField,
MaybeJSONArrayField, MaybeJSONArrayField,
EntityConfiguration, EntityConfiguration,
DatabaseAdapterFlavor,
CacheAdapterFlavor,
EntityCompanionDefinition, EntityCompanionDefinition,
Entity, Entity,
} from '@expo/entity'; } from '@expo/entity';
import { Knex } from 'knex'; import Knex from 'knex';
type PostgresTestEntityFields = { type PostgresTestEntityFields = {
id: string; id: string;
@@ -81,7 +83,7 @@ class PostgresTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
ViewerContext, ViewerContext,
PostgresTestEntity PostgresTestEntity
> { > {
protected override readonly createRules = [ protected readonly createRules = [
new AlwaysAllowPrivacyPolicyRule< new AlwaysAllowPrivacyPolicyRule<
PostgresTestEntityFields, PostgresTestEntityFields,
string, string,
@@ -89,7 +91,7 @@ class PostgresTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
PostgresTestEntity PostgresTestEntity
>(), >(),
]; ];
protected override readonly readRules = [ protected readonly readRules = [
new AlwaysAllowPrivacyPolicyRule< new AlwaysAllowPrivacyPolicyRule<
PostgresTestEntityFields, PostgresTestEntityFields,
string, string,
@@ -97,7 +99,7 @@ class PostgresTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
PostgresTestEntity PostgresTestEntity
>(), >(),
]; ];
protected override readonly updateRules = [ protected readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule< new AlwaysAllowPrivacyPolicyRule<
PostgresTestEntityFields, PostgresTestEntityFields,
string, string,
@@ -105,7 +107,7 @@ class PostgresTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
PostgresTestEntity PostgresTestEntity
>(), >(),
]; ];
protected override readonly deleteRules = [ protected readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule< new AlwaysAllowPrivacyPolicyRule<
PostgresTestEntityFields, PostgresTestEntityFields,
string, string,
@@ -148,12 +150,12 @@ export const postgresTestEntityConfiguration = new EntityConfiguration<PostgresT
columnName: 'maybe_json_array_field', columnName: 'maybe_json_array_field',
}), }),
}, },
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
}); });
const postgresTestEntityCompanionDefinition = new EntityCompanionDefinition({ const postgresTestEntityCompanionDefinition = {
entityClass: PostgresTestEntity, entityClass: PostgresTestEntity,
entityConfiguration: postgresTestEntityConfiguration, entityConfiguration: postgresTestEntityConfiguration,
databaseAdaptorFlavor: DatabaseAdapterFlavor.POSTGRES,
cacheAdaptorFlavor: CacheAdapterFlavor.REDIS,
privacyPolicyClass: PostgresTestEntityPrivacyPolicy, privacyPolicyClass: PostgresTestEntityPrivacyPolicy,
}); };
@@ -1,179 +0,0 @@
import {
AlwaysAllowPrivacyPolicyRule,
EntityPrivacyPolicy,
ViewerContext,
UUIDField,
StringField,
EntityConfiguration,
EntityCompanionDefinition,
Entity,
EntityMutationTrigger,
EntityQueryContext,
EntityNonTransactionalMutationTrigger,
EntityTriggerMutationInfo,
} from '@expo/entity';
import { Knex } from 'knex';
type PostgresTriggerTestEntityFields = {
id: string;
name: string | null;
};
export default class PostgresTriggerTestEntity extends Entity<
PostgresTriggerTestEntityFields,
string,
ViewerContext
> {
static getCompanionDefinition(): EntityCompanionDefinition<
PostgresTriggerTestEntityFields,
string,
ViewerContext,
PostgresTriggerTestEntity,
PostgresTriggerTestEntityPrivacyPolicy
> {
return postgresTestEntityCompanionDefinition;
}
public static async createOrTruncatePostgresTable(knex: Knex): Promise<void> {
await knex.raw('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"'); // for uuid_generate_v4()
const tableName = this.getCompanionDefinition().entityConfiguration.tableName;
const hasTable = await knex.schema.hasTable(tableName);
if (!hasTable) {
await knex.schema.createTable(tableName, (table) => {
table.uuid('id').defaultTo(knex.raw('uuid_generate_v4()')).primary();
table.string('name');
});
}
await knex.into(tableName).truncate();
}
public static async dropPostgresTable(knex: Knex): Promise<void> {
const tableName = this.getCompanionDefinition().entityConfiguration.tableName;
const hasTable = await knex.schema.hasTable(tableName);
if (hasTable) {
await knex.schema.dropTable(tableName);
}
}
}
class PostgresTriggerTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
PostgresTriggerTestEntityFields,
string,
ViewerContext,
PostgresTriggerTestEntity
> {
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<
PostgresTriggerTestEntityFields,
string,
ViewerContext,
PostgresTriggerTestEntity
>(),
];
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<
PostgresTriggerTestEntityFields,
string,
ViewerContext,
PostgresTriggerTestEntity
>(),
];
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<
PostgresTriggerTestEntityFields,
string,
ViewerContext,
PostgresTriggerTestEntity
>(),
];
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<
PostgresTriggerTestEntityFields,
string,
ViewerContext,
PostgresTriggerTestEntity
>(),
];
}
class ThrowConditionallyTrigger extends EntityMutationTrigger<
PostgresTriggerTestEntityFields,
string,
ViewerContext,
PostgresTriggerTestEntity
> {
constructor(private fieldName: keyof PostgresTriggerTestEntityFields, private badValue: string) {
super();
}
async executeAsync(
_viewerContext: ViewerContext,
_queryContext: EntityQueryContext,
entity: PostgresTriggerTestEntity,
_mutationInfo: EntityTriggerMutationInfo<
PostgresTriggerTestEntityFields,
string,
ViewerContext,
PostgresTriggerTestEntity
>
): Promise<void> {
if (entity.getField(this.fieldName) === this.badValue) {
throw new Error(`${this.fieldName} cannot have value ${this.badValue}`);
}
}
}
class ThrowConditionallyNonTransactionalTrigger extends EntityNonTransactionalMutationTrigger<
PostgresTriggerTestEntityFields,
string,
ViewerContext,
PostgresTriggerTestEntity
> {
constructor(private fieldName: keyof PostgresTriggerTestEntityFields, private badValue: string) {
super();
}
async executeAsync(
_viewerContext: ViewerContext,
entity: PostgresTriggerTestEntity
): Promise<void> {
if (entity.getField(this.fieldName) === this.badValue) {
throw new Error(`${this.fieldName} cannot have value ${this.badValue}`);
}
}
}
export const postgresTestEntityConfiguration =
new EntityConfiguration<PostgresTriggerTestEntityFields>({
idField: 'id',
tableName: 'postgres_test_entities',
schema: {
id: new UUIDField({
columnName: 'id',
cache: true,
}),
name: new StringField({
columnName: 'name',
}),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
});
const postgresTestEntityCompanionDefinition = new EntityCompanionDefinition({
entityClass: PostgresTriggerTestEntity,
entityConfiguration: postgresTestEntityConfiguration,
privacyPolicyClass: PostgresTriggerTestEntityPrivacyPolicy,
mutationTriggers: () => ({
beforeCreate: [new ThrowConditionallyTrigger('name', 'beforeCreate')],
afterCreate: [new ThrowConditionallyTrigger('name', 'afterCreate')],
beforeUpdate: [new ThrowConditionallyTrigger('name', 'beforeUpdate')],
afterUpdate: [new ThrowConditionallyTrigger('name', 'afterUpdate')],
beforeDelete: [new ThrowConditionallyTrigger('name', 'beforeDelete')],
afterDelete: [new ThrowConditionallyTrigger('name', 'afterDelete')],
beforeAll: [new ThrowConditionallyTrigger('name', 'beforeAll')],
afterAll: [new ThrowConditionallyTrigger('name', 'afterAll')],
afterCommit: [new ThrowConditionallyNonTransactionalTrigger('name', 'afterCommit')],
}),
});
@@ -1,151 +0,0 @@
import {
AlwaysAllowPrivacyPolicyRule,
EntityPrivacyPolicy,
ViewerContext,
UUIDField,
StringField,
EntityConfiguration,
EntityCompanionDefinition,
Entity,
EntityMutationTrigger,
EntityQueryContext,
EntityValidatorMutationInfo,
} from '@expo/entity';
import { Knex } from 'knex';
type PostgresValidatorTestEntityFields = {
id: string;
name: string | null;
};
export default class PostgresValidatorTestEntity extends Entity<
PostgresValidatorTestEntityFields,
string,
ViewerContext
> {
static getCompanionDefinition(): EntityCompanionDefinition<
PostgresValidatorTestEntityFields,
string,
ViewerContext,
PostgresValidatorTestEntity,
PostgresValidatorTestEntityPrivacyPolicy
> {
return postgresTestEntityCompanionDefinition;
}
public static async createOrTruncatePostgresTable(knex: Knex): Promise<void> {
await knex.raw('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"'); // for uuid_generate_v4()
const tableName = this.getCompanionDefinition().entityConfiguration.tableName;
const hasTable = await knex.schema.hasTable(tableName);
if (!hasTable) {
await knex.schema.createTable(tableName, (table) => {
table.uuid('id').defaultTo(knex.raw('uuid_generate_v4()')).primary();
table.string('name');
});
}
await knex.into(tableName).truncate();
}
public static async dropPostgresTable(knex: Knex): Promise<void> {
const tableName = this.getCompanionDefinition().entityConfiguration.tableName;
const hasTable = await knex.schema.hasTable(tableName);
if (hasTable) {
await knex.schema.dropTable(tableName);
}
}
}
class PostgresValidatorTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
PostgresValidatorTestEntityFields,
string,
ViewerContext,
PostgresValidatorTestEntity
> {
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<
PostgresValidatorTestEntityFields,
string,
ViewerContext,
PostgresValidatorTestEntity
>(),
];
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<
PostgresValidatorTestEntityFields,
string,
ViewerContext,
PostgresValidatorTestEntity
>(),
];
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<
PostgresValidatorTestEntityFields,
string,
ViewerContext,
PostgresValidatorTestEntity
>(),
];
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<
PostgresValidatorTestEntityFields,
string,
ViewerContext,
PostgresValidatorTestEntity
>(),
];
}
class ThrowConditionallyTrigger extends EntityMutationTrigger<
PostgresValidatorTestEntityFields,
string,
ViewerContext,
PostgresValidatorTestEntity
> {
constructor(
private fieldName: keyof PostgresValidatorTestEntityFields,
private badValue: string
) {
super();
}
async executeAsync(
_viewerContext: ViewerContext,
_queryContext: EntityQueryContext,
entity: PostgresValidatorTestEntity,
_mutationInfo: EntityValidatorMutationInfo<
PostgresValidatorTestEntityFields,
string,
ViewerContext,
PostgresValidatorTestEntity
>
): Promise<void> {
if (entity.getField(this.fieldName) === this.badValue) {
throw new Error(`${this.fieldName} cannot have value ${this.badValue}`);
}
}
}
export const postgresTestEntityConfiguration =
new EntityConfiguration<PostgresValidatorTestEntityFields>({
idField: 'id',
tableName: 'postgres_test_entities',
schema: {
id: new UUIDField({
columnName: 'id',
cache: true,
}),
name: new StringField({
columnName: 'name',
}),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
});
const postgresTestEntityCompanionDefinition = new EntityCompanionDefinition({
entityClass: PostgresValidatorTestEntity,
entityConfiguration: postgresTestEntityConfiguration,
privacyPolicyClass: PostgresValidatorTestEntityPrivacyPolicy,
mutationValidators: () => [new ThrowConditionallyTrigger('name', 'beforeCreateAndBeforeUpdate')],
});
@@ -2,9 +2,11 @@ import {
NoOpEntityMetricsAdapter, NoOpEntityMetricsAdapter,
IEntityMetricsAdapter, IEntityMetricsAdapter,
EntityCompanionProvider, EntityCompanionProvider,
InMemoryFullCacheStubCacheAdapterProvider, CacheAdapterFlavor,
DatabaseAdapterFlavor,
NoCacheStubCacheAdapterProvider,
} from '@expo/entity'; } from '@expo/entity';
import { Knex } from 'knex'; import Knex from 'knex';
import PostgresEntityDatabaseAdapterProvider from '../PostgresEntityDatabaseAdapterProvider'; import PostgresEntityDatabaseAdapterProvider from '../PostgresEntityDatabaseAdapterProvider';
import PostgresEntityQueryContextProvider from '../PostgresEntityQueryContextProvider'; import PostgresEntityQueryContextProvider from '../PostgresEntityQueryContextProvider';
@@ -15,22 +17,16 @@ export const createKnexIntegrationTestEntityCompanionProvider = (
): EntityCompanionProvider => { ): EntityCompanionProvider => {
return new EntityCompanionProvider( return new EntityCompanionProvider(
metricsAdapter, metricsAdapter,
new Map([
[
'postgres',
{ {
[DatabaseAdapterFlavor.POSTGRES]: {
adapterProvider: new PostgresEntityDatabaseAdapterProvider(), adapterProvider: new PostgresEntityDatabaseAdapterProvider(),
queryContextProvider: new PostgresEntityQueryContextProvider(knex), queryContextProvider: new PostgresEntityQueryContextProvider(knex),
}, },
],
]),
new Map([
[
'redis',
{
cacheAdapterProvider: new InMemoryFullCacheStubCacheAdapterProvider(),
}, },
], {
]) [CacheAdapterFlavor.REDIS]: {
cacheAdapterProvider: new NoCacheStubCacheAdapterProvider(),
},
}
); );
}; };
-220
View File
@@ -1,220 +0,0 @@
# Change Log
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
**Note:** Version bump only for package @expo/entity-example
# [0.21.0](https://github.com/expo/entity/compare/v0.20.0...v0.21.0) (2022-01-03)
**Note:** Version bump only for package @expo/entity-example
# [0.20.0](https://github.com/expo/entity/compare/v0.19.0...v0.20.0) (2021-12-29)
**Note:** Version bump only for package @expo/entity-example
# [0.19.0](https://github.com/expo/entity/compare/v0.18.0...v0.19.0) (2021-10-28)
**Note:** Version bump only for package @expo/entity-example
# [0.18.0](https://github.com/expo/entity/compare/v0.17.0...v0.18.0) (2021-10-14)
### Features
* enable noImplicitOverride tsc setting ([#135](https://github.com/expo/entity/issues/135)) ([4263cb9](https://github.com/expo/entity/commit/4263cb9b8b69fe4fb68c74ec1c7aba04508a1555))
* upgrade TypeScript to 4.4 ([#134](https://github.com/expo/entity/issues/134)) ([7612392](https://github.com/expo/entity/commit/7612392dbfd9778d25c465c1626c372d75a6d05a))
# [0.17.0](https://github.com/expo/entity/compare/v0.16.0...v0.17.0) (2021-08-16)
**Note:** Version bump only for package @expo/entity-example
# [0.16.0](https://github.com/expo/entity/compare/v0.15.0...v0.16.0) (2021-07-07)
**Note:** Version bump only for package @expo/entity-example
# [0.15.0](https://github.com/expo/entity/compare/v0.14.1...v0.15.0) (2021-05-26)
### Features
* secondary cache loader ([#123](https://github.com/expo/entity/issues/123)) ([4cba01e](https://github.com/expo/entity/commit/4cba01eb259c87d60b3026ce776e46f781363690))
## [0.14.1](https://github.com/expo/entity/compare/v0.14.0...v0.14.1) (2021-03-24)
**Note:** Version bump only for package @expo/entity-example
# [0.14.0](https://github.com/expo/entity/compare/v0.13.0...v0.14.0) (2021-03-24)
**Note:** Version bump only for package @expo/entity-example
# [0.13.0](https://github.com/expo/entity/compare/v0.12.0...v0.13.0) (2021-02-12)
### Features
* add basic field type runtime validators ([#113](https://github.com/expo/entity/issues/113)) ([6c4d4b0](https://github.com/expo/entity/commit/6c4d4b03b5404dd776a12c5eb6f0af77a8c964f8))
# [0.12.0](https://github.com/expo/entity/compare/v0.11.0...v0.12.0) (2021-01-22)
### Bug Fixes
* remove cache and database adaptor flavor enums ([#109](https://github.com/expo/entity/issues/109)) ([72a77f8](https://github.com/expo/entity/commit/72a77f8b6893a4b76d3b1cce16659bdf3ce473ee))
# [0.11.0](https://github.com/expo/entity/compare/v0.10.0...v0.11.0) (2020-12-24)
### Bug Fixes
* remove any cast from EntityLoader ([#100](https://github.com/expo/entity/issues/100)) ([b8e07f9](https://github.com/expo/entity/commit/b8e07f9ddc4077768980c000d61f5ddcc824e2e3))
# [0.10.0](https://github.com/expo/entity/compare/v0.9.1...v0.10.0) (2020-11-17)
**Note:** Version bump only for package @expo/entity-example
## [0.9.1](https://github.com/expo/entity/compare/v0.9.0...v0.9.1) (2020-11-06)
**Note:** Version bump only for package @expo/entity-example
# [0.9.0](https://github.com/expo/entity/compare/v0.8.1...v0.9.0) (2020-11-05)
**Note:** Version bump only for package @expo/entity-example
## [0.8.1](https://github.com/expo/entity/compare/v0.8.0...v0.8.1) (2020-10-09)
**Note:** Version bump only for package @expo/entity-example
# [0.8.0](https://github.com/expo/entity/compare/v0.7.1...v0.8.0) (2020-10-09)
**Note:** Version bump only for package @expo/entity-example
## [0.7.1](https://github.com/expo/entity/compare/v0.7.0...v0.7.1) (2020-10-01)
**Note:** Version bump only for package @expo/entity-example
# [0.7.0](https://github.com/expo/entity/compare/v0.6.0...v0.7.0) (2020-10-01)
### Bug Fixes
* move cache invalidation to after transaction commit ([#77](https://github.com/expo/entity/issues/77)) ([dbc3c81](https://github.com/expo/entity/commit/dbc3c81b359a668b5134189ce6de49b5893bb5e7))
* upgrade packages ([#78](https://github.com/expo/entity/issues/78)) ([9891e74](https://github.com/expo/entity/commit/9891e7469467a28589f529c8d87b10fc2232d3ff))
# [0.6.0](https://github.com/expo/entity/compare/v0.5.2...v0.6.0) (2020-07-23)
**Note:** Version bump only for package @expo/entity-example
## [0.5.2](https://github.com/expo/entity/compare/v0.5.1...v0.5.2) (2020-07-02)
**Note:** Version bump only for package @expo/entity-example
## [0.5.1](https://github.com/expo/entity/compare/v0.5.0...v0.5.1) (2020-07-01)
**Note:** Version bump only for package @expo/entity-example
# 0.5.0 (2020-07-01)
### Bug Fixes
* upgrade Apollo server for security advisory ([#38](https://github.com/expo/entity/issues/38)) ([d25c1e1](https://github.com/expo/entity/commit/d25c1e142d52385725593f4f155d1795697ab490))
### Features
* support entity fields subset of db fields ([#49](https://github.com/expo/entity/issues/49)) ([4e40b2e](https://github.com/expo/entity/commit/4e40b2e521407e521d236978ec3b3b56db3990be))
+15 -16
View File
@@ -1,21 +1,20 @@
{ {
"name": "@expo/entity-example", "name": "@expo/entity-example",
"private": true, "private": true,
"version": "0.22.0", "version": "0.4.0",
"description": "An example integration of the @expo/entity framework", "description": "An example integration of the @expo/entity framework",
"scripts": { "scripts": {
"tsc": "tsc", "tsc": "tsc",
"clean": "rm -rf build coverage coverage-integration", "clean": "rm -rf build coverage coverage-integration",
"lint": "eslint src", "lint": "eslint src --ext '.ts'",
"lint-fix": "eslint src --fix", "test": "jest --rootDir --config ../../resources/jest.config.js",
"test": "jest --rootDir . --config ../../resources/jest.config.js",
"integration": "../../resources/run-with-docker yarn integration-no-setup", "integration": "../../resources/run-with-docker yarn integration-no-setup",
"integration-no-setup": "jest --config ../../resources/jest-integration.config.js --rootDir . --runInBand --passWithNoTests", "integration-no-setup": "jest --config ../../resources/jest-integration.config.js --rootDir --runInBand --passWithNoTests",
"barrelsby": "barrelsby --directory src --location top --exclude tests__ --singleQuotes --exportDefault --delete", "barrelsby": "barrelsby --directory src --location top --exclude tests__ --singleQuotes --exportDefault --delete",
"start": "tsnd --transpile-only --inspect --respawn --no-notify --async-stack-traces --throw-deprecation -- src/index.ts" "start": "tsnd --transpile-only --inspect --respawn --no-notify --async-stack-traces --throw-deprecation -- src/index.ts"
}, },
"engines": { "engines": {
"node": ">=12" "node": ">=8"
}, },
"keywords": [ "keywords": [
"entity" "entity"
@@ -23,18 +22,18 @@
"author": "Expo", "author": "Expo",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@expo/entity": "^0.22.0", "@expo/entity": "^0.4.0",
"apollo-server-koa": "^2.16.1", "apollo-server-koa": "^2.14.2",
"graphql": "^15.3.0", "graphql": "^15.0.0",
"koa": "^2.13.0", "koa": "^2.11.0",
"koa-body": "^4.2.0", "koa-body": "^4.1.1",
"koa-router": "^9.4.0" "koa-router": "^8.0.8"
}, },
"devDependencies": { "devDependencies": {
"@types/koa": "^2.11.4", "@types/koa": "^2.11.3",
"@types/koa-router": "^7.4.1", "@types/koa-router": "^7.4.0",
"@types/supertest": "^2.0.10", "@types/supertest": "^2.0.9",
"supertest": "^4.0.2", "supertest": "^4.0.2",
"ts-node-dev": "^1.0.0-pre.60" "ts-node-dev": "^1.0.0-pre.44"
} }
} }
@@ -1,5 +1,4 @@
import { createUnitTestEntityCompanionProvider } from '@expo/entity'; import { createUnitTestEntityCompanionProvider } from '@expo/entity';
import { v4 as uuidv4 } from 'uuid';
import NoteEntity from '../entities/NoteEntity'; import NoteEntity from '../entities/NoteEntity';
import { UserViewerContext } from '../viewerContexts'; import { UserViewerContext } from '../viewerContexts';
@@ -7,18 +6,17 @@ import { UserViewerContext } from '../viewerContexts';
describe(NoteEntity, () => { describe(NoteEntity, () => {
test('demonstrate usage of business logic test utilities', async () => { test('demonstrate usage of business logic test utilities', async () => {
const companionProvider = createUnitTestEntityCompanionProvider(); const companionProvider = createUnitTestEntityCompanionProvider();
const userId = uuidv4(); const viewerContext = new UserViewerContext(companionProvider, '4');
const viewerContext = new UserViewerContext(companionProvider, userId);
const createdEntityResult = await NoteEntity.creator(viewerContext) const createdEntityResult = await NoteEntity.creator(viewerContext)
.setField('userID', userId) .setField('userID', '4')
.setField('body', 'image') .setField('body', 'image')
.setField('title', 'page') .setField('title', 'page')
.createAsync(); .createAsync();
expect(createdEntityResult.ok).toBe(true); expect(createdEntityResult.ok).toBe(true);
const createdEntityResultImpersonate = await NoteEntity.creator(viewerContext) const createdEntityResultImpersonate = await NoteEntity.creator(viewerContext)
.setField('userID', uuidv4()) // a different user .setField('userID', '5')
.setField('body', 'image') .setField('body', 'image')
.setField('title', 'page') .setField('title', 'page')
.createAsync(); .createAsync();
@@ -1,6 +1,5 @@
import http from 'http'; import http from 'http';
import request from 'supertest'; import request from 'supertest';
import { v4 as uuidv4 } from 'uuid';
import app from '../app'; import app from '../app';
@@ -8,15 +7,14 @@ describe('graphql', () => {
it('allows CRUD of user notes', async () => { it('allows CRUD of user notes', async () => {
const server = request(http.createServer(app.callback())); const server = request(http.createServer(app.callback()));
const userId = uuidv4(); const responseEmpty = await server.post('/graphql').set('totally-secure-user-id', '4').send({
const responseEmpty = await server.post('/graphql').set('totally-secure-user-id', userId).send({
query: `query { me { notes { id } } }`, query: `query { me { notes { id } } }`,
}); });
expect(responseEmpty.body.data.me.notes).toEqual([]); expect(responseEmpty.body.data.me.notes).toEqual([]);
const createResponse1 = await server const createResponse1 = await server
.post('/graphql') .post('/graphql')
.set('totally-secure-user-id', userId) .set('totally-secure-user-id', '4')
.send({ .send({
query: `mutation($note: NoteInput!) { addNote(note: $note) { id, title, body } }`, query: `mutation($note: NoteInput!) { addNote(note: $note) { id, title, body } }`,
variables: { variables: {
@@ -33,7 +31,7 @@ describe('graphql', () => {
const createResponse2 = await server const createResponse2 = await server
.post('/graphql') .post('/graphql')
.set('totally-secure-user-id', userId) .set('totally-secure-user-id', '4')
.send({ .send({
query: `mutation($note: NoteInput!) { addNote(note: $note) { id, title, body } }`, query: `mutation($note: NoteInput!) { addNote(note: $note) { id, title, body } }`,
variables: { variables: {
@@ -44,17 +42,14 @@ describe('graphql', () => {
}, },
}); });
const responseLength2 = await server const responseLength2 = await server.post('/graphql').set('totally-secure-user-id', '4').send({
.post('/graphql')
.set('totally-secure-user-id', userId)
.send({
query: `query { me { notes { id } } }`, query: `query { me { notes { id } } }`,
}); });
expect(responseLength2.body.data.me.notes).toHaveLength(2); expect(responseLength2.body.data.me.notes).toHaveLength(2);
const responseGetSingle = await server const responseGetSingle = await server
.post('/graphql') .post('/graphql')
.set('totally-secure-user-id', userId) .set('totally-secure-user-id', '4')
.send({ .send({
query: `query($id: ID!) { noteByID(id: $id) { id, title, body } }`, query: `query($id: ID!) { noteByID(id: $id) { id, title, body } }`,
variables: { variables: {
@@ -69,7 +64,7 @@ describe('graphql', () => {
const responseUpdateSingle = await server const responseUpdateSingle = await server
.post('/graphql') .post('/graphql')
.set('totally-secure-user-id', userId) .set('totally-secure-user-id', '4')
.send({ .send({
query: `mutation($id: ID!, $note: NoteInput!) { updateNote(id: $id, note: $note) { id, title, body } }`, query: `mutation($id: ID!, $note: NoteInput!) { updateNote(id: $id, note: $note) { id, title, body } }`,
variables: { variables: {
@@ -89,7 +84,7 @@ describe('graphql', () => {
const responseDeleteSingle = await server const responseDeleteSingle = await server
.post('/graphql') .post('/graphql')
.set('totally-secure-user-id', userId) .set('totally-secure-user-id', '4')
.send({ .send({
query: `mutation($id: ID!) { deleteNote(id: $id) { id, title, body } }`, query: `mutation($id: ID!) { deleteNote(id: $id) { id, title, body } }`,
variables: { variables: {
@@ -107,10 +102,7 @@ describe('graphql', () => {
body: 'boarding', body: 'boarding',
}); });
const responseLength1 = await server const responseLength1 = await server.post('/graphql').set('totally-secure-user-id', '4').send({
.post('/graphql')
.set('totally-secure-user-id', userId)
.send({
query: `query { me { notes { id } } }`, query: `query { me { notes { id } } }`,
}); });
expect(responseLength1.body.data.me.notes).toHaveLength(1); expect(responseLength1.body.data.me.notes).toHaveLength(1);
@@ -135,11 +127,9 @@ describe('graphql', () => {
it('disallows cross-user note impersonation', async () => { it('disallows cross-user note impersonation', async () => {
const server = request(http.createServer(app.callback())); const server = request(http.createServer(app.callback()));
const userId = uuidv4();
const userId2 = uuidv4();
const createResponse1 = await server const createResponse1 = await server
.post('/graphql') .post('/graphql')
.set('totally-secure-user-id', userId) .set('totally-secure-user-id', '4')
.send({ .send({
query: `mutation($note: NoteInput!) { addNote(note: $note) { id, title, body } }`, query: `mutation($note: NoteInput!) { addNote(note: $note) { id, title, body } }`,
variables: { variables: {
@@ -153,7 +143,7 @@ describe('graphql', () => {
// notes are public // notes are public
const responseGetSingle = await server const responseGetSingle = await server
.post('/graphql') .post('/graphql')
.set('totally-secure-user-id', userId2) .set('totally-secure-user-id', '5')
.send({ .send({
query: `query($id: ID!) { noteByID(id: $id) { id, title, body } }`, query: `query($id: ID!) { noteByID(id: $id) { id, title, body } }`,
variables: { variables: {
@@ -169,7 +159,7 @@ describe('graphql', () => {
// but can only be updated by the owner // but can only be updated by the owner
const responseUpdateSingle = await server const responseUpdateSingle = await server
.post('/graphql') .post('/graphql')
.set('totally-secure-user-id', userId2) .set('totally-secure-user-id', '5')
.send({ .send({
query: `mutation($id: ID!, $note: NoteInput!) { updateNote(id: $id, note: $note) { id, title, body } }`, query: `mutation($id: ID!, $note: NoteInput!) { updateNote(id: $id, note: $note) { id, title, body } }`,
variables: { variables: {
@@ -1,6 +1,5 @@
import http from 'http'; import http from 'http';
import request from 'supertest'; import request from 'supertest';
import { v4 as uuidv4 } from 'uuid';
import app from '../app'; import app from '../app';
@@ -12,74 +11,59 @@ describe('notesRouter', () => {
it('allows CRUD of user notes', async () => { it('allows CRUD of user notes', async () => {
const server = request(http.createServer(app.callback())); const server = request(http.createServer(app.callback()));
const responseEmpty = await server.get('/notes/').set('totally-secure-user-id', '4').send();
const userId = uuidv4();
const responseEmpty = await server.get('/notes/').set('totally-secure-user-id', userId).send();
expect(responseEmpty.body.notes).toEqual([]); expect(responseEmpty.body.notes).toEqual([]);
const createResponse1 = await server const createResponse1 = await server.post('/notes/').set('totally-secure-user-id', '4').send({
.post('/notes/')
.set('totally-secure-user-id', userId)
.send({
title: 'track', title: 'track',
body: 'language', body: 'language',
}); });
expect(createResponse1.body.note).toMatchObject({ expect(createResponse1.body.note).toMatchObject({
userID: userId, userID: '4',
title: 'track', title: 'track',
body: 'language', body: 'language',
}); });
const createResponse2 = await server const createResponse2 = await server.post('/notes/').set('totally-secure-user-id', '4').send({
.post('/notes/')
.set('totally-secure-user-id', userId)
.send({
title: 'nine', title: 'nine',
body: 'lotion', body: 'lotion',
}); });
const responseLength2 = await server const responseLength2 = await server.get('/notes/').set('totally-secure-user-id', '4').send();
.get('/notes/')
.set('totally-secure-user-id', userId)
.send();
expect(responseLength2.body.notes).toHaveLength(2); expect(responseLength2.body.notes).toHaveLength(2);
const responseGetSingle = await server const responseGetSingle = await server
.get(`/notes/${createResponse2.body.note.id}`) .get(`/notes/${createResponse2.body.note.id}`)
.set('totally-secure-user-id', userId) .set('totally-secure-user-id', '4')
.send(); .send();
expect(responseGetSingle.body.note).toMatchObject({ expect(responseGetSingle.body.note).toMatchObject({
id: createResponse2.body.note.id, id: createResponse2.body.note.id,
userID: userId, userID: '4',
title: 'nine', title: 'nine',
body: 'lotion', body: 'lotion',
}); });
const responseUpdateSingle = await server const responseUpdateSingle = await server
.put(`/notes/${createResponse2.body.note.id}`) .put(`/notes/${createResponse2.body.note.id}`)
.set('totally-secure-user-id', userId) .set('totally-secure-user-id', '4')
.send({ .send({
title: 'wave', title: 'wave',
body: 'boarding', body: 'boarding',
}); });
expect(responseUpdateSingle.body.note).toMatchObject({ expect(responseUpdateSingle.body.note).toMatchObject({
id: responseGetSingle.body.note.id, id: responseGetSingle.body.note.id,
userID: userId, userID: '4',
title: 'wave', title: 'wave',
body: 'boarding', body: 'boarding',
}); });
const responseDeleteSingle = await server const responseDeleteSingle = await server
.delete(`/notes/${createResponse2.body.note.id}`) .delete(`/notes/${createResponse2.body.note.id}`)
.set('totally-secure-user-id', userId) .set('totally-secure-user-id', '4')
.send(); .send();
expect(responseDeleteSingle.body.status).toEqual('ok'); expect(responseDeleteSingle.body.status).toEqual('ok');
const responseLength1 = await server const responseLength1 = await server.get('/notes/').set('totally-secure-user-id', '4').send();
.get('/notes/')
.set('totally-secure-user-id', userId)
.send();
expect(responseLength1.body.notes).toHaveLength(1); expect(responseLength1.body.notes).toHaveLength(1);
}); });
@@ -95,12 +79,7 @@ describe('notesRouter', () => {
it('disallows cross-user note impersonation', async () => { it('disallows cross-user note impersonation', async () => {
const server = request(http.createServer(app.callback())); const server = request(http.createServer(app.callback()));
const userId = uuidv4(); const createResponse1 = await server.post('/notes/').set('totally-secure-user-id', '4').send({
const userId2 = uuidv4();
const createResponse1 = await server
.post('/notes/')
.set('totally-secure-user-id', userId)
.send({
title: 'track', title: 'track',
body: 'language', body: 'language',
}); });
@@ -109,14 +88,14 @@ describe('notesRouter', () => {
// notes are public // notes are public
const responseGetSingleOtherUser = await server const responseGetSingleOtherUser = await server
.get(`/notes/${createResponse1.body.note.id}`) .get(`/notes/${createResponse1.body.note.id}`)
.set('totally-secure-user-id', userId2) .set('totally-secure-user-id', '5')
.send(); .send();
expect(responseGetSingleOtherUser.ok).toBe(true); expect(responseGetSingleOtherUser.ok).toBe(true);
// but can only be updated by the owner // but can only be updated by the owner
const responseUpdateSingle = await server const responseUpdateSingle = await server
.put(`/notes/${createResponse1.body.note.id}`) .put(`/notes/${createResponse1.body.note.id}`)
.set('totally-secure-user-id', userId2) .set('totally-secure-user-id', '5')
.send({ .send({
title: 'wave', title: 'wave',
body: 'boarding', body: 'boarding',
@@ -58,7 +58,7 @@ class InMemoryDatabaseAdapter<T> extends EntityDatabaseAdapter<T> {
return 0; return 0;
} }
const currentOrderBy = orderBys[0]!; const currentOrderBy = orderBys[0];
const aField = objectA[currentOrderBy.columnName]; const aField = objectA[currentOrderBy.columnName];
const bField = objectB[currentOrderBy.columnName]; const bField = objectB[currentOrderBy.columnName];
switch (currentOrderBy.order) { switch (currentOrderBy.order) {
@@ -161,7 +161,7 @@ class InMemoryDatabaseAdapter<T> extends EntityDatabaseAdapter<T> {
...dbObjects[objectIndex], ...dbObjects[objectIndex],
...object, ...object,
}; };
return [dbObjects[objectIndex]!]; return [dbObjects[objectIndex]];
} }
protected async deleteInternalAsync( protected async deleteInternalAsync(
@@ -1,13 +1,18 @@
import { EntityQueryContextProvider } from '@expo/entity'; import {
IEntityQueryContextProvider,
EntityQueryContext,
EntityNonTransactionalQueryContext,
EntityTransactionalQueryContext,
} from '@expo/entity';
export default class InMemoryQueryContextProvider extends EntityQueryContextProvider { export default class InMemoryQueryContextProvider implements IEntityQueryContextProvider {
protected getQueryInterface(): any { getRegularEntityQueryContext(): EntityQueryContext {
return {}; return new EntityNonTransactionalQueryContext({});
} }
protected createTransactionRunner<T>(): ( async runInTransactionAsync<T>(
transactionScope: (queryInterface: any) => Promise<T> transactionScope: (queryContext: EntityQueryContext) => Promise<T>
) => Promise<T> { ): Promise<T> {
return (transactionScope) => Promise.resolve(transactionScope({})); return await transactionScope(new EntityTransactionalQueryContext({}));
} }
} }
+2 -2
View File
@@ -2,14 +2,14 @@ import { EntityCompanionProvider } from '@expo/entity';
import { ApolloServer } from 'apollo-server-koa'; import { ApolloServer } from 'apollo-server-koa';
import Koa from 'koa'; import Koa from 'koa';
import koaBody from 'koa-body'; import koaBody from 'koa-body';
import KoaRouter, { RouterContext } from 'koa-router'; import KoaRouter from 'koa-router';
import { entityCompanionMiddleware, viewerContextMiddleware } from './middleware'; import { entityCompanionMiddleware, viewerContextMiddleware } from './middleware';
import notesRouter from './routers/notesRouter'; import notesRouter from './routers/notesRouter';
import { typeDefs, resolvers } from './schema'; import { typeDefs, resolvers } from './schema';
import { ExampleViewerContext } from './viewerContexts'; import { ExampleViewerContext } from './viewerContexts';
export type ExampleContext = RouterContext<ExampleState>; export type ExampleContext = Koa.ParameterizedContext<ExampleState>;
/** /**
* Koa provides a per-request state container to place custom properties. * Koa provides a per-request state container to place custom properties.
+8 -12
View File
@@ -3,6 +3,8 @@ import {
NoOpEntityMetricsAdapter, NoOpEntityMetricsAdapter,
EntityCompanionProvider, EntityCompanionProvider,
InMemoryFullCacheStubCacheAdapterProvider, InMemoryFullCacheStubCacheAdapterProvider,
CacheAdapterFlavor,
DatabaseAdapterFlavor,
} from '@expo/entity'; } from '@expo/entity';
import { InMemoryDatabaseAdapterProvider } from './adapters/InMemoryDatabaseAdapter'; import { InMemoryDatabaseAdapterProvider } from './adapters/InMemoryDatabaseAdapter';
@@ -19,26 +21,20 @@ export const createEntityCompanionProvider = (
): EntityCompanionProvider => { ): EntityCompanionProvider => {
return new EntityCompanionProvider( return new EntityCompanionProvider(
metricsAdapter, metricsAdapter,
new Map([ {
[
'postgres',
// An in-memory DB is used for demonstration purposes, but generally this would be // An in-memory DB is used for demonstration purposes, but generally this would be
// instantiated with PostgresEntityDatabaseAdapter and PostgresEntityQueryContextProvider // instantiated with PostgresEntityDatabaseAdapter and PostgresEntityQueryContextProvider
{ [DatabaseAdapterFlavor.POSTGRES]: {
adapterProvider: new InMemoryDatabaseAdapterProvider(), adapterProvider: new InMemoryDatabaseAdapterProvider(),
queryContextProvider: new InMemoryQueryContextProvider(), queryContextProvider: new InMemoryQueryContextProvider(),
}, },
], },
]), {
new Map([
[
'redis',
// An in-memory cache is used for demonstration purposes, but generally this would be // An in-memory cache is used for demonstration purposes, but generally this would be
// instantiated with a RedisCacheAdapterProvider // instantiated with a RedisCacheAdapterProvider
{ [CacheAdapterFlavor.REDIS]: {
cacheAdapterProvider: new InMemoryFullCacheStubCacheAdapterProvider(), cacheAdapterProvider: new InMemoryFullCacheStubCacheAdapterProvider(),
}, },
], }
])
); );
}; };
@@ -23,9 +23,8 @@ import { ExampleViewerContext } from '../viewerContexts';
*/ */
export default class AllowIfUserOwnerPrivacyRule< export default class AllowIfUserOwnerPrivacyRule<
TFields, TFields,
TID extends NonNullable<TFields[TSelectedFields]>, TID,
TEntity extends ReadonlyEntity<TFields, TID, ExampleViewerContext>, TEntity extends ReadonlyEntity<TFields, TID, ExampleViewerContext>
TSelectedFields extends keyof TFields = keyof TFields
> extends PrivacyPolicyRule<TFields, TID, ExampleViewerContext, TEntity> { > extends PrivacyPolicyRule<TFields, TID, ExampleViewerContext, TEntity> {
constructor(private readonly entityOwnerField: keyof TFields) { constructor(private readonly entityOwnerField: keyof TFields) {
super(); super();
@@ -3,6 +3,8 @@ import {
EntityCompanionDefinition, EntityCompanionDefinition,
EntityConfiguration, EntityConfiguration,
UUIDField, UUIDField,
DatabaseAdapterFlavor,
CacheAdapterFlavor,
StringField, StringField,
} from '@expo/entity'; } from '@expo/entity';
@@ -36,7 +38,7 @@ export default class NoteEntity extends Entity<NoteFields, string, ExampleViewer
* of entity. In some languages, this would be representable as "abstract" static members * of entity. In some languages, this would be representable as "abstract" static members
* of the class itself, but TypeScript disallows static generic and abstract methods. * of the class itself, but TypeScript disallows static generic and abstract methods.
*/ */
export const noteEntityCompanion = new EntityCompanionDefinition({ export const noteEntityCompanion = {
entityClass: NoteEntity, entityClass: NoteEntity,
entityConfiguration: new EntityConfiguration<NoteFields>({ entityConfiguration: new EntityConfiguration<NoteFields>({
idField: 'id', idField: 'id',
@@ -55,8 +57,8 @@ export const noteEntityCompanion = new EntityCompanionDefinition({
columnName: 'body', columnName: 'body',
}), }),
}, },
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
}), }),
databaseAdaptorFlavor: DatabaseAdapterFlavor.POSTGRES,
cacheAdaptorFlavor: CacheAdapterFlavor.REDIS,
privacyPolicyClass: NotePrivacyPolicy, privacyPolicyClass: NotePrivacyPolicy,
}); };
@@ -13,16 +13,16 @@ export default class NotePrivacyPolicy extends EntityPrivacyPolicy<
ExampleViewerContext, ExampleViewerContext,
NoteEntity NoteEntity
> { > {
protected override readonly readRules = [ protected readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<NoteFields, string, ExampleViewerContext, NoteEntity>(), new AlwaysAllowPrivacyPolicyRule<NoteFields, string, ExampleViewerContext, NoteEntity>(),
]; ];
protected override readonly createRules = [ protected readonly createRules = [
new AllowIfUserOwnerPrivacyRule<NoteFields, string, NoteEntity>('userID'), new AllowIfUserOwnerPrivacyRule<NoteFields, string, NoteEntity>('userID'),
]; ];
protected override readonly updateRules = [ protected readonly updateRules = [
new AllowIfUserOwnerPrivacyRule<NoteFields, string, NoteEntity>('userID'), new AllowIfUserOwnerPrivacyRule<NoteFields, string, NoteEntity>('userID'),
]; ];
protected override readonly deleteRules = [ protected readonly deleteRules = [
new AllowIfUserOwnerPrivacyRule<NoteFields, string, NoteEntity>('userID'), new AllowIfUserOwnerPrivacyRule<NoteFields, string, NoteEntity>('userID'),
]; ];
} }
@@ -1,192 +0,0 @@
# Change Log
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
**Note:** Version bump only for package @expo/entity-full-integration-tests
# [0.21.0](https://github.com/expo/entity/compare/v0.20.0...v0.21.0) (2022-01-03)
**Note:** Version bump only for package @expo/entity-full-integration-tests
# [0.20.0](https://github.com/expo/entity/compare/v0.19.0...v0.20.0) (2021-12-29)
**Note:** Version bump only for package @expo/entity-full-integration-tests
# [0.19.0](https://github.com/expo/entity/compare/v0.18.0...v0.19.0) (2021-10-28)
**Note:** Version bump only for package @expo/entity-full-integration-tests
# [0.18.0](https://github.com/expo/entity/compare/v0.17.0...v0.18.0) (2021-10-14)
### Features
* enable noImplicitOverride tsc setting ([#135](https://github.com/expo/entity/issues/135)) ([4263cb9](https://github.com/expo/entity/commit/4263cb9b8b69fe4fb68c74ec1c7aba04508a1555))
* upgrade TypeScript to 4.4 ([#134](https://github.com/expo/entity/issues/134)) ([7612392](https://github.com/expo/entity/commit/7612392dbfd9778d25c465c1626c372d75a6d05a))
# [0.17.0](https://github.com/expo/entity/compare/v0.16.0...v0.17.0) (2021-08-16)
**Note:** Version bump only for package @expo/entity-full-integration-tests
# [0.16.0](https://github.com/expo/entity/compare/v0.15.0...v0.16.0) (2021-07-07)
**Note:** Version bump only for package @expo/entity-full-integration-tests
# [0.15.0](https://github.com/expo/entity/compare/v0.14.1...v0.15.0) (2021-05-26)
### Bug Fixes
* upgrade ioredis, knex, and pg ([#125](https://github.com/expo/entity/issues/125)) ([7c43edf](https://github.com/expo/entity/commit/7c43edf3de37e8af13e2a99bd44f03f54803d9b9))
### Features
* secondary cache loader ([#123](https://github.com/expo/entity/issues/123)) ([4cba01e](https://github.com/expo/entity/commit/4cba01eb259c87d60b3026ce776e46f781363690))
## [0.14.1](https://github.com/expo/entity/compare/v0.14.0...v0.14.1) (2021-03-24)
**Note:** Version bump only for package @expo/entity-full-integration-tests
# [0.14.0](https://github.com/expo/entity/compare/v0.13.0...v0.14.0) (2021-03-24)
**Note:** Version bump only for package @expo/entity-full-integration-tests
# [0.13.0](https://github.com/expo/entity/compare/v0.12.0...v0.13.0) (2021-02-12)
### Features
* add basic field type runtime validators ([#113](https://github.com/expo/entity/issues/113)) ([6c4d4b0](https://github.com/expo/entity/commit/6c4d4b03b5404dd776a12c5eb6f0af77a8c964f8))
# [0.12.0](https://github.com/expo/entity/compare/v0.11.0...v0.12.0) (2021-01-22)
### Bug Fixes
* move runInTransaction to ViewerContext ([#108](https://github.com/expo/entity/issues/108)) ([b7309e1](https://github.com/expo/entity/commit/b7309e18a2dd780b64cd306d566de1f12bf8df3a))
* remove cache and database adaptor flavor enums ([#109](https://github.com/expo/entity/issues/109)) ([72a77f8](https://github.com/expo/entity/commit/72a77f8b6893a4b76d3b1cce16659bdf3ce473ee))
# [0.11.0](https://github.com/expo/entity/compare/v0.10.0...v0.11.0) (2020-12-24)
### Bug Fixes
* remove any cast from EntityLoader ([#100](https://github.com/expo/entity/issues/100)) ([b8e07f9](https://github.com/expo/entity/commit/b8e07f9ddc4077768980c000d61f5ddcc824e2e3))
# [0.10.0](https://github.com/expo/entity/compare/v0.9.1...v0.10.0) (2020-11-17)
**Note:** Version bump only for package @expo/entity-full-integration-tests
## [0.9.1](https://github.com/expo/entity/compare/v0.9.0...v0.9.1) (2020-11-06)
### Bug Fixes
* edge deletion behavior cyclic import structure again ([#93](https://github.com/expo/entity/issues/93)) ([012c05d](https://github.com/expo/entity/commit/012c05d62d2ecd559eb331c6ca358052bfe66dc8))
# [0.9.0](https://github.com/expo/entity/compare/v0.8.1...v0.9.0) (2020-11-05)
### Bug Fixes
* edge deletion behavior cyclic import structure ([#89](https://github.com/expo/entity/issues/89)) ([cca8fc1](https://github.com/expo/entity/commit/cca8fc19870057ebc7033081b59fab4a142fbf1a))
## [0.8.1](https://github.com/expo/entity/compare/v0.8.0...v0.8.1) (2020-10-09)
**Note:** Version bump only for package @expo/entity-full-integration-tests
# [0.8.0](https://github.com/expo/entity/compare/v0.7.1...v0.8.0) (2020-10-09)
**Note:** Version bump only for package @expo/entity-full-integration-tests
## [0.7.1](https://github.com/expo/entity/compare/v0.7.0...v0.7.1) (2020-10-01)
**Note:** Version bump only for package @expo/entity-full-integration-tests
# [0.7.0](https://github.com/expo/entity/compare/v0.6.0...v0.7.0) (2020-10-01)
### Bug Fixes
* move cache invalidation to after transaction commit ([#77](https://github.com/expo/entity/issues/77)) ([dbc3c81](https://github.com/expo/entity/commit/dbc3c81b359a668b5134189ce6de49b5893bb5e7))
* upgrade packages ([#78](https://github.com/expo/entity/issues/78)) ([9891e74](https://github.com/expo/entity/commit/9891e7469467a28589f529c8d87b10fc2232d3ff))
@@ -1,5 +0,0 @@
# `@expo/entity-full-integration-tests`
Full integration tests for testing combinations of database and cache adapters.
[Documentation](https://expo.github.io/entity/modules/_expo_entity_full_integration_tests.html)
@@ -1,34 +0,0 @@
{
"name": "@expo/entity-full-integration-tests",
"private": true,
"version": "0.22.0",
"description": "Full redis and knex integration tests for the entity framework",
"scripts": {
"tsc": "tsc",
"clean": "rm -rf build coverage coverage-integration",
"lint": "eslint src",
"lint-fix": "eslint src --fix",
"test": "jest --rootDir . --config ../../resources/jest.config.js --passWithNoTests",
"integration": "../../resources/run-with-docker yarn integration-no-setup",
"integration-no-setup": "jest --config ../../resources/jest-integration.config.js --rootDir . --runInBand --passWithNoTests",
"barrelsby": "barrelsby --directory src --location top --exclude tests__ --singleQuotes --exportDefault --delete"
},
"engines": {
"node": ">=12"
},
"keywords": [
"entity"
],
"author": "Expo",
"license": "MIT",
"peerDependencies": {
"@expo/entity": "*",
"@expo/entity-cache-adapter-redis": "*",
"@expo/entity-database-adapter-knex": "*"
},
"devDependencies": {
"@expo/entity": "^0.22.0",
"@expo/entity-cache-adapter-redis": "^0.22.0",
"@expo/entity-database-adapter-knex": "^0.22.0"
}
}
@@ -1,216 +0,0 @@
import {
EntityPrivacyPolicy,
ViewerContext,
AlwaysAllowPrivacyPolicyRule,
Entity,
EntityCompanionDefinition,
EntityConfiguration,
UUIDField,
StringField,
} from '@expo/entity';
import { RedisCacheAdapterContext } from '@expo/entity-cache-adapter-redis';
import Redis from 'ioredis';
import { knex, Knex } from 'knex';
import nullthrows from 'nullthrows';
import { URL } from 'url';
import { createFullIntegrationTestEntityCompanionProvider } from '../testfixtures/createFullIntegrationTestEntityCompanionProvider';
interface TestFields {
id: string;
other_string: string;
third_string: string;
}
class TestEntityPrivacyPolicy extends EntityPrivacyPolicy<
TestFields,
string,
ViewerContext,
TestEntity
> {
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<TestFields, string, ViewerContext, TestEntity>(),
];
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<TestFields, string, ViewerContext, TestEntity>(),
];
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<TestFields, string, ViewerContext, TestEntity>(),
];
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<TestFields, string, ViewerContext, TestEntity>(),
];
}
class TestEntity extends Entity<TestFields, string, ViewerContext> {
static getCompanionDefinition(): EntityCompanionDefinition<
TestFields,
string,
ViewerContext,
TestEntity,
TestEntityPrivacyPolicy
> {
return testEntityCompanion;
}
}
const testEntityConfiguration = new EntityConfiguration<TestFields>({
idField: 'id',
tableName: 'testentities',
schema: {
id: new UUIDField({
columnName: 'id',
cache: true,
}),
other_string: new StringField({
columnName: 'other_string',
cache: true,
}),
third_string: new StringField({
columnName: 'third_string',
}),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
});
const testEntityCompanion = new EntityCompanionDefinition({
entityClass: TestEntity,
entityConfiguration: testEntityConfiguration,
privacyPolicyClass: TestEntityPrivacyPolicy,
});
async function createOrTruncatePostgresTables(knex: Knex): Promise<void> {
await knex.raw('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"'); // for uuid_generate_v4()
await knex.schema.createTable('testentities', (table) => {
table.uuid('id').defaultTo(knex.raw('uuid_generate_v4()')).primary();
table.string('other_string').notNullable();
table.string('third_string').notNullable();
});
await knex.into('testentities').truncate();
}
async function dropPostgresTable(knex: Knex): Promise<void> {
if (await knex.schema.hasTable('children')) {
await knex.schema.dropTable('children');
}
if (await knex.schema.hasTable('parents')) {
await knex.schema.dropTable('parents');
}
}
describe('Entity cache inconsistency', () => {
let knexInstance: Knex;
let redisCacheAdapterContext: RedisCacheAdapterContext;
beforeAll(() => {
knexInstance = knex({
client: 'pg',
connection: {
user: nullthrows(process.env['PGUSER']),
password: nullthrows(process.env['PGPASSWORD']),
host: 'localhost',
port: parseInt(nullthrows(process.env['PGPORT']), 10),
database: nullthrows(process.env['PGDATABASE']),
},
});
redisCacheAdapterContext = {
redisClient: new Redis(new URL(process.env['REDIS_URL']!).toString()),
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`)
);
return escapedParts.join(delimiter);
},
cacheKeyPrefix: 'test-',
cacheKeyVersion: 1,
ttlSecondsPositive: 86400, // 1 day
ttlSecondsNegative: 600, // 10 minutes
};
});
beforeEach(async () => {
await createOrTruncatePostgresTables(knexInstance);
await redisCacheAdapterContext.redisClient.flushdb();
});
afterAll(async () => {
await dropPostgresTable(knexInstance);
await knexInstance.destroy();
redisCacheAdapterContext.redisClient.disconnect();
});
test('lots of updates in long-ish running transactions', async () => {
const viewerContext = new ViewerContext(
createFullIntegrationTestEntityCompanionProvider(knexInstance, redisCacheAdapterContext)
);
const entity1 = await TestEntity.creator(viewerContext)
.setField('other_string', 'hello')
.setField('third_string', 'initial')
.enforceCreateAsync();
// put entities in cache and dataloader
await TestEntity.loader(viewerContext).enforcing().loadByIDAsync(entity1.getID());
await TestEntity.loader(viewerContext)
.enforcing()
.loadByFieldEqualingAsync('other_string', 'hello');
let openBarrier1: () => void;
const barrier1 = new Promise<void>((resolve) => {
openBarrier1 = resolve;
});
let openBarrier2: () => void;
const barrier2 = new Promise<void>((resolve) => {
openBarrier2 = resolve;
});
await Promise.all([
// do a load after the transaction below updates the entity but before transaction commits to ensure
// that the cache is cleared after the transaction commits rather than in the middle where the changes
// may not be visible by other requests (ViewerContexts) which would cache the incorrect
// value during the read-through cache.
(async () => {
await barrier1;
const viewerContextInternal = new ViewerContext(
createFullIntegrationTestEntityCompanionProvider(knexInstance, redisCacheAdapterContext)
);
await TestEntity.loader(viewerContextInternal).enforcing().loadByIDAsync(entity1.getID());
openBarrier2!();
})(),
viewerContext.runInTransactionForDatabaseAdaptorFlavorAsync(
'postgres',
async (queryContext) => {
await TestEntity.updater(entity1, queryContext)
.setField('third_string', 'updated')
.enforceUpdateAsync();
openBarrier1();
// wait for to ensure the transaction isn't committed until after load above occurs
await barrier2;
}
),
]);
// ensure cache consistency
const viewerContextLast = new ViewerContext(
createFullIntegrationTestEntityCompanionProvider(knexInstance, redisCacheAdapterContext)
);
const loadedById = await TestEntity.loader(viewerContextLast)
.enforcing()
.loadByIDAsync(entity1.getID());
const loadedByField = await TestEntity.loader(viewerContextLast)
.enforcing()
.loadByFieldEqualingAsync('other_string', 'hello');
expect(loadedById.getField('third_string')).toEqual('updated');
expect(loadedByField!.getField('third_string')).toEqual('updated');
});
});
@@ -1,109 +0,0 @@
import { ViewerContext } from '@expo/entity';
import { RedisCacheAdapterContext } from '@expo/entity-cache-adapter-redis';
import Redis from 'ioredis';
import { knex, Knex } from 'knex';
import nullthrows from 'nullthrows';
import { URL } from 'url';
import { createFullIntegrationTestEntityCompanionProvider } from '../testfixtures/createFullIntegrationTestEntityCompanionProvider';
import ChildEntity from './entities/ChildEntity';
import ParentEntity from './entities/ParentEntity';
async function createOrTruncatePostgresTables(knex: Knex): Promise<void> {
await knex.raw('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"'); // for uuid_generate_v4()
await knex.schema.createTable('parents', (table) => {
table.uuid('id').defaultTo(knex.raw('uuid_generate_v4()')).primary();
});
await knex.into('parents').truncate();
await knex.schema.createTable('children', (table) => {
table.uuid('id').defaultTo(knex.raw('uuid_generate_v4()')).primary();
table.uuid('parent_id').references('id').inTable('parents').onDelete('cascade').unique();
});
await knex.into('children').truncate();
}
async function dropPostgresTable(knex: Knex): Promise<void> {
if (await knex.schema.hasTable('children')) {
await knex.schema.dropTable('children');
}
if (await knex.schema.hasTable('parents')) {
await knex.schema.dropTable('parents');
}
}
describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
let knexInstance: Knex;
let redisCacheAdapterContext: RedisCacheAdapterContext;
beforeAll(() => {
knexInstance = knex({
client: 'pg',
connection: {
user: nullthrows(process.env['PGUSER']),
password: nullthrows(process.env['PGPASSWORD']),
host: 'localhost',
port: parseInt(nullthrows(process.env['PGPORT']), 10),
database: nullthrows(process.env['PGDATABASE']),
},
});
redisCacheAdapterContext = {
redisClient: new Redis(new URL(process.env['REDIS_URL']!).toString()),
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`)
);
return escapedParts.join(delimiter);
},
cacheKeyPrefix: 'test-',
cacheKeyVersion: 1,
ttlSecondsPositive: 86400, // 1 day
ttlSecondsNegative: 600, // 10 minutes
};
});
beforeEach(async () => {
await createOrTruncatePostgresTables(knexInstance);
await redisCacheAdapterContext.redisClient.flushdb();
});
afterAll(async () => {
await dropPostgresTable(knexInstance);
await knexInstance.destroy();
redisCacheAdapterContext.redisClient.disconnect();
});
describe('EntityEdgeDeletionBehavior.INVALIDATE_CACHE', () => {
it('invalidates the cache', async () => {
const viewerContext = new ViewerContext(
createFullIntegrationTestEntityCompanionProvider(knexInstance, redisCacheAdapterContext)
);
const parent = await ParentEntity.creator(viewerContext).enforceCreateAsync();
const child = await ChildEntity.creator(viewerContext)
.setField('parent_id', parent.getID())
.enforceCreateAsync();
await expect(
ParentEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(parent.getID())
).resolves.not.toBeNull();
await expect(
ChildEntity.loader(viewerContext)
.enforcing()
.loadByFieldEqualingAsync('parent_id', parent.getID())
).resolves.not.toBeNull();
await ParentEntity.enforceDeleteAsync(parent);
await expect(
ParentEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(parent.getID())
).resolves.toBeNull();
await expect(
ChildEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(child.getID())
).resolves.toBeNull();
});
});
});
@@ -1,242 +0,0 @@
import {
EntityPrivacyPolicy,
ViewerContext,
AlwaysAllowPrivacyPolicyRule,
Entity,
EntityCompanionDefinition,
EntityConfiguration,
UUIDField,
EntityEdgeDeletionBehavior,
} from '@expo/entity';
import { RedisCacheAdapterContext } from '@expo/entity-cache-adapter-redis';
import Redis from 'ioredis';
import { knex, Knex } from 'knex';
import nullthrows from 'nullthrows';
import { URL } from 'url';
import { v4 as uuidv4 } from 'uuid';
import { createFullIntegrationTestEntityCompanionProvider } from '../testfixtures/createFullIntegrationTestEntityCompanionProvider';
interface CategoryFields {
id: string;
parent_other_id: string;
}
interface OtherFields {
id: string;
parent_category_id: string;
}
class PrivacyPolicy extends EntityPrivacyPolicy<any, string, ViewerContext, any> {
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, ViewerContext, any>(),
];
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, ViewerContext, any>(),
];
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, ViewerContext, any>(),
];
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, ViewerContext, any>(),
];
}
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
const makeEntityClasses = async (knex: Knex, edgeDeletionBehavior: EntityEdgeDeletionBehavior) => {
const categoriesTableName = uuidv4();
const othersTableName = uuidv4();
class CategoryEntity extends Entity<CategoryFields, string, ViewerContext> {
static getCompanionDefinition(): EntityCompanionDefinition<
CategoryFields,
string,
ViewerContext,
CategoryEntity,
PrivacyPolicy
> {
return categoryEntityCompanion;
}
}
class OtherEntity extends Entity<OtherFields, string, ViewerContext> {
static getCompanionDefinition(): EntityCompanionDefinition<
OtherFields,
string,
ViewerContext,
OtherEntity,
PrivacyPolicy
> {
return otherEntityCompanion;
}
}
const categoryEntityConfiguration = new EntityConfiguration<CategoryFields>({
idField: 'id',
tableName: categoriesTableName,
getInboundEdges: () => [OtherEntity],
schema: {
id: new UUIDField({
columnName: 'id',
cache: true,
}),
parent_other_id: new UUIDField({
columnName: 'parent_other_id',
cache: true,
association: {
getAssociatedEntityClass: () => OtherEntity,
edgeDeletionBehavior,
},
}),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
});
const categoryEntityCompanion = new EntityCompanionDefinition({
entityClass: CategoryEntity,
entityConfiguration: categoryEntityConfiguration,
privacyPolicyClass: PrivacyPolicy,
});
const otherEntityConfiguration = new EntityConfiguration<OtherFields>({
idField: 'id',
tableName: othersTableName,
getInboundEdges: () => [CategoryEntity],
schema: {
id: new UUIDField({
columnName: 'id',
cache: true,
}),
parent_category_id: new UUIDField({
columnName: 'parent_category_id',
cache: true,
association: {
getAssociatedEntityClass: () => CategoryEntity,
edgeDeletionBehavior,
},
}),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
});
const otherEntityCompanion = new EntityCompanionDefinition({
entityClass: OtherEntity,
entityConfiguration: otherEntityConfiguration,
privacyPolicyClass: PrivacyPolicy,
});
await knex.raw('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"'); // for uuid_generate_v4()
await knex.schema.createTable(categoriesTableName, (table) => {
table.uuid('id').defaultTo(knex.raw('uuid_generate_v4()')).primary();
});
await knex.schema.createTable(othersTableName, (table) => {
table.uuid('id').defaultTo(knex.raw('uuid_generate_v4()')).primary();
if (edgeDeletionBehavior === EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE) {
table
.uuid('parent_category_id')
.references('id')
.inTable(categoriesTableName)
.unique()
.onDelete('cascade');
} else {
table.uuid('parent_category_id').unique();
}
});
await knex.schema.alterTable(categoriesTableName, (table) => {
if (edgeDeletionBehavior === EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE) {
table
.uuid('parent_other_id')
.references('id')
.inTable(othersTableName)
.unique()
.onDelete('cascade');
} else {
table.uuid('parent_other_id').unique();
}
});
return { CategoryEntity, OtherEntity };
};
describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
let knexInstance: Knex;
let redisCacheAdapterContext: RedisCacheAdapterContext;
beforeAll(() => {
knexInstance = knex({
client: 'pg',
connection: {
user: nullthrows(process.env['PGUSER']),
password: nullthrows(process.env['PGPASSWORD']),
host: 'localhost',
port: parseInt(nullthrows(process.env['PGPORT']), 10),
database: nullthrows(process.env['PGDATABASE']),
},
});
redisCacheAdapterContext = {
redisClient: new Redis(new URL(process.env['REDIS_URL']!).toString()),
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`)
);
return escapedParts.join(delimiter);
},
cacheKeyPrefix: 'test-',
cacheKeyVersion: 1,
ttlSecondsPositive: 86400, // 1 day
ttlSecondsNegative: 600, // 10 minutes
};
});
afterAll(async () => {
await knexInstance.destroy();
redisCacheAdapterContext.redisClient.disconnect();
});
it.each([
EntityEdgeDeletionBehavior.CASCADE_DELETE,
EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE,
EntityEdgeDeletionBehavior.SET_NULL,
])('behavior: %p', async (edgeDeletionBehavior) => {
const { CategoryEntity, OtherEntity } = await makeEntityClasses(
knexInstance,
edgeDeletionBehavior
);
const viewerContext = new ViewerContext(
createFullIntegrationTestEntityCompanionProvider(knexInstance, redisCacheAdapterContext)
);
const category1 = await CategoryEntity.creator(viewerContext).enforceCreateAsync();
const other1 = await OtherEntity.creator(viewerContext)
.setField('parent_category_id', category1.getID())
.enforceCreateAsync();
await CategoryEntity.updater(category1)
.setField('parent_other_id', other1.getID())
.enforceUpdateAsync();
await CategoryEntity.enforceDeleteAsync(category1);
if (edgeDeletionBehavior === EntityEdgeDeletionBehavior.SET_NULL) {
await expect(
CategoryEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(category1.getID())
).resolves.toBeNull();
const otherLoaded = await OtherEntity.loader(viewerContext)
.enforcing()
.loadByIDNullableAsync(other1.getID());
expect(otherLoaded?.getField('parent_category_id')).toBeNull();
} else {
await expect(
CategoryEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(category1.getID())
).resolves.toBeNull();
await expect(
OtherEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(other1.getID())
).resolves.toBeNull();
}
});
});
@@ -1,71 +0,0 @@
import {
AlwaysAllowPrivacyPolicyRule,
Entity,
EntityCompanionDefinition,
EntityConfiguration,
EntityEdgeDeletionBehavior,
EntityPrivacyPolicy,
UUIDField,
ViewerContext,
} from '@expo/entity';
import ParentEntity from './ParentEntity';
interface ChildFields {
id: string;
parent_id: string;
}
class TestEntityPrivacyPolicy extends EntityPrivacyPolicy<any, string, ViewerContext, any, any> {
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, ViewerContext, any, any>(),
];
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, ViewerContext, any, any>(),
];
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, ViewerContext, any, any>(),
];
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, ViewerContext, any, any>(),
];
}
export default class ChildEntity extends Entity<ChildFields, string, ViewerContext> {
static getCompanionDefinition(): EntityCompanionDefinition<
ChildFields,
string,
ViewerContext,
ChildEntity,
TestEntityPrivacyPolicy
> {
return childEntityCompanion;
}
}
const childEntityConfiguration = new EntityConfiguration<ChildFields>({
idField: 'id',
tableName: 'children',
schema: {
id: new UUIDField({
columnName: 'id',
cache: true,
}),
parent_id: new UUIDField({
columnName: 'parent_id',
cache: true,
association: {
getAssociatedEntityClass: () => ParentEntity,
edgeDeletionBehavior: EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE,
},
}),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
});
const childEntityCompanion = new EntityCompanionDefinition({
entityClass: ChildEntity,
entityConfiguration: childEntityConfiguration,
privacyPolicyClass: TestEntityPrivacyPolicy,
});
@@ -1,62 +0,0 @@
import {
AlwaysAllowPrivacyPolicyRule,
Entity,
EntityCompanionDefinition,
EntityConfiguration,
EntityPrivacyPolicy,
UUIDField,
ViewerContext,
} from '@expo/entity';
import ChildEntity from './ChildEntity';
interface ParentFields {
id: string;
}
class TestEntityPrivacyPolicy extends EntityPrivacyPolicy<any, string, ViewerContext, any, any> {
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, ViewerContext, any, any>(),
];
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, ViewerContext, any, any>(),
];
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, ViewerContext, any, any>(),
];
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, ViewerContext, any, any>(),
];
}
export default class ParentEntity extends Entity<ParentFields, string, ViewerContext> {
static getCompanionDefinition(): EntityCompanionDefinition<
ParentFields,
string,
ViewerContext,
ParentEntity,
TestEntityPrivacyPolicy
> {
return parentEntityCompanion;
}
}
const parentEntityConfiguration = new EntityConfiguration<ParentFields>({
idField: 'id',
tableName: 'parents',
getInboundEdges: () => [ChildEntity],
schema: {
id: new UUIDField({
columnName: 'id',
cache: true,
}),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
});
const parentEntityCompanion = new EntityCompanionDefinition({
entityClass: ParentEntity,
entityConfiguration: parentEntityConfiguration,
privacyPolicyClass: TestEntityPrivacyPolicy,
});
@@ -1,41 +0,0 @@
import {
NoOpEntityMetricsAdapter,
IEntityMetricsAdapter,
EntityCompanionProvider,
} from '@expo/entity';
import {
RedisCacheAdapterContext,
RedisCacheAdapterProvider,
} from '@expo/entity-cache-adapter-redis';
import {
PostgresEntityDatabaseAdapterProvider,
PostgresEntityQueryContextProvider,
} from '@expo/entity-database-adapter-knex';
import { Knex } from 'knex';
export const createFullIntegrationTestEntityCompanionProvider = (
knex: Knex,
redisCacheAdapterContext: RedisCacheAdapterContext,
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter()
): EntityCompanionProvider => {
return new EntityCompanionProvider(
metricsAdapter,
new Map([
[
'postgres',
{
adapterProvider: new PostgresEntityDatabaseAdapterProvider(),
queryContextProvider: new PostgresEntityQueryContextProvider(knex),
},
],
]),
new Map([
[
'redis',
{
cacheAdapterProvider: new RedisCacheAdapterProvider(redisCacheAdapterContext),
},
],
])
);
};
@@ -1,7 +0,0 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"noEmit": true,
},
"include": ["src"]
}
@@ -1,50 +0,0 @@
# Change Log
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
**Note:** Version bump only for package @expo/entity-ip-address-field
# [0.21.0](https://github.com/expo/entity/compare/v0.20.0...v0.21.0) (2022-01-03)
**Note:** Version bump only for package @expo/entity-ip-address-field
# [0.20.0](https://github.com/expo/entity/compare/v0.19.0...v0.20.0) (2021-12-29)
**Note:** Version bump only for package @expo/entity-ip-address-field
# [0.19.0](https://github.com/expo/entity/compare/v0.18.0...v0.19.0) (2021-10-28)
### Features
* add IP support ([#138](https://github.com/expo/entity/issues/138)) ([1405a91](https://github.com/expo/entity/commit/1405a9126ee3a56c3e2a38bf3b697283744f5b5d))
# [0.18.0](https://github.com/expo/entity/compare/v0.17.0...v0.18.0) (2021-10-14)
### Features
* lands @expo/entity-ip-address-field ([#138](https://github.com/expo/entity/pull/138))
* enable noImplicitOverride tsc setting ([#135](https://github.com/expo/entity/issues/135)) ([4263cb9](https://github.com/expo/entity/commit/4263cb9b8b69fe4fb68c74ec1c7aba04508a1555))
* expose describeFieldTestCase for use in other packages ([#139](https://github.com/expo/entity/issues/139)) ([1220477](https://github.com/expo/entity/commit/1220477aa331349b482ad015b44f76fa2bda7b90))
* upgrade TypeScript to 4.4 ([#134](https://github.com/expo/entity/issues/134)) ([7612392](https://github.com/expo/entity/commit/7612392dbfd9778d25c465c1626c372d75a6d05a))
* use better QueryContext types for transactional triggers and validators ([#136](https://github.com/expo/entity/issues/136)) ([5c7efe1](https://github.com/expo/entity/commit/5c7efe1678424ff907687e9327175bdc2538c3e0))
@@ -1,3 +0,0 @@
# @expo/entity-ip-address-field
A package containing the IP address EntityField definitions for @expo/entity. Supports addresses with either IPv4 or IPv6 syntax.
@@ -1,37 +0,0 @@
{
"name": "@expo/entity-ip-address-field",
"version": "0.22.0",
"description": "IP address EntityField definitions for @expo/entity",
"files": [
"build",
"src"
],
"main": "build/index.js",
"types": "build/index.d.ts",
"scripts": {
"tsc": "tsc",
"clean": "rm -rf build coverage coverage-integration",
"lint": "eslint src",
"lint-fix": "eslint src --fix",
"test": "jest --rootDir . --config ../../resources/jest.config.js",
"barrelsby": "barrelsby --directory src --location top --exclude tests__ --singleQuotes --delete"
},
"engines": {
"node": ">=12"
},
"keywords": [
"entity"
],
"author": "Expo",
"license": "MIT",
"dependencies": {
"ip-address": "^8.1.0"
},
"devDependencies": {
"@expo/entity": "^0.22.0",
"@types/jsbn": "^1.2.29"
},
"peerDependencies": {
"@expo/entity": "*"
}
}
@@ -1,8 +0,0 @@
import { EntityFieldDefinition } from '@expo/entity';
import { Address4, Address6 } from 'ip-address';
export class IPAddressField extends EntityFieldDefinition<string> {
protected validateInputValueInternal(value: string): boolean {
return Address4.isValid(value) || Address6.isValid(value);
}
}
@@ -1,16 +0,0 @@
import { describeFieldTestCase } from '@expo/entity';
import { IPAddressField } from '../EntityFields';
describeFieldTestCase(
new IPAddressField({ columnName: 'wat' }),
[
'192.168.1.1',
'198.24.10.0/24',
'10.0.0.0',
'0:1:2:3:4:5:6:7',
'1:2:3:4:5:6:7:8/64',
'fedc:ba98:7654:3210:fedc:ba98:7654:3210',
],
['', 'abs', '0.0.0.x/0', '123.21.23', '1:2:3:4:5:6', '198.10/8']
);
@@ -1,7 +0,0 @@
/* eslint-disable tsdoc/syntax */
/**
* @packageDocumentation
* @module @expo/entity-ip-address-field
*/
export * from './EntityFields';
@@ -1,7 +0,0 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "build",
},
"include": ["src"]
}
@@ -1,71 +0,0 @@
# Change Log
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
**Note:** Version bump only for package @expo/entity-secondary-cache-redis
# [0.21.0](https://github.com/expo/entity/compare/v0.20.0...v0.21.0) (2022-01-03)
**Note:** Version bump only for package @expo/entity-secondary-cache-redis
# [0.20.0](https://github.com/expo/entity/compare/v0.19.0...v0.20.0) (2021-12-29)
**Note:** Version bump only for package @expo/entity-secondary-cache-redis
# [0.19.0](https://github.com/expo/entity/compare/v0.18.0...v0.19.0) (2021-10-28)
**Note:** Version bump only for package @expo/entity-secondary-cache-redis
# [0.18.0](https://github.com/expo/entity/compare/v0.17.0...v0.18.0) (2021-10-14)
### Features
* enable noImplicitOverride tsc setting ([#135](https://github.com/expo/entity/issues/135)) ([4263cb9](https://github.com/expo/entity/commit/4263cb9b8b69fe4fb68c74ec1c7aba04508a1555))
* upgrade TypeScript to 4.4 ([#134](https://github.com/expo/entity/issues/134)) ([7612392](https://github.com/expo/entity/commit/7612392dbfd9778d25c465c1626c372d75a6d05a))
# [0.17.0](https://github.com/expo/entity/compare/v0.16.0...v0.17.0) (2021-08-16)
**Note:** Version bump only for package @expo/entity-secondary-cache-redis
# [0.16.0](https://github.com/expo/entity/compare/v0.15.0...v0.16.0) (2021-07-07)
**Note:** Version bump only for package @expo/entity-secondary-cache-redis
# [0.15.0](https://github.com/expo/entity/compare/v0.14.1...v0.15.0) (2021-05-26)
### Features
* secondary cache loader ([#123](https://github.com/expo/entity/issues/123)) ([4cba01e](https://github.com/expo/entity/commit/4cba01eb259c87d60b3026ce776e46f781363690))
@@ -1,25 +0,0 @@
# `@expo/entity-secondary-cache-redis`
[ioredis](https://github.com/luin/ioredis) secondary cache for `@expo/entity`.
[Documentation](https://expo.github.io/entity/modules/_expo_secondary_cache_redis.html)
## Usage
1. Create a concrete implementation of abstract class `EntitySecondaryCacheLoader`, in this example `TestEntitySecondaryCacheLoader`. The underlying data can come from anywhere, but an entity is constructed from the data and then authorized for the viewer.
2. Create an instance of your `EntitySecondaryCacheLoader`, passing in a `RedisSecondaryEntityCache`.
```typescript
const secondaryCacheLoader = new TestSecondaryRedisCacheLoader(
new RedisSecondaryEntityCache(
redisTestEntityConfiguration,
redisCacheAdapterContext,
(loadParams) => `${loadParams.id}`
),
RedisTestEntity.loader(viewerContext)
);
```
3. Load entities through it:
```typescript
const loadParams = { id: createdEntity.getID() };
const results = await secondaryCacheLoader.loadManyAsync([loadParams]);
```
@@ -1,41 +0,0 @@
{
"name": "@expo/entity-secondary-cache-redis",
"version": "0.22.0",
"description": "Redis secondary cache for @expo/entity",
"files": [
"build",
"src"
],
"main": "build/index.js",
"types": "build/index.d.ts",
"scripts": {
"tsc": "tsc",
"clean": "rm -rf build coverage coverage-integration",
"lint": "eslint src",
"lint-fix": "eslint src --fix",
"test": "jest --rootDir . --config ../../resources/jest.config.js --passWithNoTests",
"integration": "../../resources/run-with-docker yarn integration-no-setup",
"integration-no-setup": "jest --config ../../resources/jest-integration.config.js --rootDir . --runInBand --passWithNoTests",
"barrelsby": "barrelsby --directory src --location top --exclude tests__ --singleQuotes --exportDefault --delete"
},
"engines": {
"node": ">=12"
},
"keywords": [
"entity"
],
"author": "Expo",
"license": "MIT",
"peerDependencies": {
"@expo/entity": "*",
"@expo/entity-cache-adapter-redis": "*"
},
"dependencies": {
"ioredis": "^4.17.3"
},
"devDependencies": {
"@expo/entity": "^0.22.0",
"@expo/entity-cache-adapter-redis": "^0.22.0",
"nullthrows": "^1.1.1"
}
}
@@ -1,136 +0,0 @@
import {
CacheStatus,
EntityConfiguration,
filterMap,
ISecondaryEntityCache,
transformCacheObjectToFields,
transformFieldsToCacheObject,
zipToMap,
} from '@expo/entity';
import {
GenericRedisCacheContext,
GenericRedisCacher,
redisTransformerMap,
} from '@expo/entity-cache-adapter-redis';
import invariant from 'invariant';
/**
* A custom secondary read-through entity cache is a way to add a custom second layer of caching for a particular
* single entity load. One common way this may be used is to add a second layer of caching in a hot path that makes
* a call to {@link EntityLoader.loadManyByFieldEqualityConjunctionAsync} is guaranteed to return at most one entity.
*/
export default class RedisSecondaryEntityCache<TFields, TLoadParams>
implements ISecondaryEntityCache<TFields, TLoadParams>
{
private readonly genericRedisCacher: GenericRedisCacher;
constructor(
private readonly entityConfiguration: EntityConfiguration<TFields>,
genericRedisCacheContext: GenericRedisCacheContext,
private readonly constructRedisKey: (params: Readonly<TLoadParams>) => string
) {
this.genericRedisCacher = new GenericRedisCacher(genericRedisCacheContext);
}
/**
* Read-through cache function. Steps:
*
* 1. Check for cached objects
* 2. Query the fetcher for loadParams not in the cache
* 3. Cache the results from the fetcher
* 4. Negatively cache anything missing from the fetcher
* 5. Return the full set of data for the query.
*
* @param loadParamsArray - array of loadParams to load from the cache
* @param fetcher - closure used to provide underlying data source objects for loadParams
* @returns map from loadParams to the entity field object
*/
public async loadManyThroughAsync(
loadParamsArray: readonly Readonly<TLoadParams>[],
fetcher: (
fetcherLoadParamsArray: readonly Readonly<TLoadParams>[]
) => Promise<ReadonlyMap<Readonly<TLoadParams>, Readonly<TFields> | null>>
): Promise<ReadonlyMap<Readonly<TLoadParams>, Readonly<TFields> | null>> {
const redisKeys = loadParamsArray.map(this.constructRedisKey);
const redisKeyToLoadParamsMap = zipToMap(redisKeys, loadParamsArray);
const cacheLoadResults = await this.genericRedisCacher.loadManyAsync(redisKeys);
invariant(
cacheLoadResults.size === loadParamsArray.length,
`${this.constructor.name} loadMany should return a result for each key`
);
const redisKeysToFetch = Array.from(
filterMap(
cacheLoadResults,
(cacheLoadResult) => cacheLoadResult.status === CacheStatus.MISS
).keys()
);
// put transformed cache hits in result map
const results: Map<Readonly<TLoadParams>, Readonly<TFields> | null> = new Map();
cacheLoadResults.forEach((cacheLoadResult, redisKey) => {
if (cacheLoadResult.status === CacheStatus.HIT) {
const loadParams = redisKeyToLoadParamsMap.get(redisKey);
invariant(loadParams !== undefined, 'load params should be in redis key map');
results.set(
loadParams,
transformCacheObjectToFields(
this.entityConfiguration,
redisTransformerMap,
cacheLoadResult.item
)
);
}
});
// fetch any misses from DB, add DB objects to results, cache DB results, inform cache of any missing DB results
if (redisKeysToFetch.length > 0) {
const loadParamsToFetch = redisKeysToFetch.map((redisKey) => {
const loadParams = redisKeyToLoadParamsMap.get(redisKey);
invariant(loadParams !== undefined, 'load params should be in redis key map');
return loadParams;
});
const fetchResults = await fetcher(loadParamsToFetch);
const fetchMisses = loadParamsToFetch.filter((loadParams) => {
// all values of fetchResults should be field objects or undefined
return !fetchResults.get(loadParams);
});
for (const fetchMiss of fetchMisses) {
results.set(fetchMiss, null);
}
const objectsToCache: Map<string, object> = new Map();
for (const [loadParams, object] of fetchResults.entries()) {
if (object) {
objectsToCache.set(
this.constructRedisKey(loadParams),
transformFieldsToCacheObject(this.entityConfiguration, redisTransformerMap, object)
);
results.set(loadParams, object);
}
}
await Promise.all([
this.genericRedisCacher.cacheManyAsync(objectsToCache),
this.genericRedisCacher.cacheDBMissesAsync(fetchMisses.map(this.constructRedisKey)),
]);
}
return results;
}
/**
* Invalidate the cache for objects cached by constructRedisKey(loadParams).
*
* @param loadParamsArray - load params to invalidate
*/
public async invalidateManyAsync(
loadParamsArray: readonly Readonly<TLoadParams>[]
): Promise<void> {
await this.genericRedisCacher.invalidateManyAsync(loadParamsArray.map(this.constructRedisKey));
}
}
@@ -1,139 +0,0 @@
import { EntitySecondaryCacheLoader, mapMapAsync, ViewerContext } from '@expo/entity';
import { RedisCacheAdapterContext } from '@expo/entity-cache-adapter-redis';
import Redis from 'ioredis';
import nullthrows from 'nullthrows';
import { URL } from 'url';
import RedisSecondaryEntityCache from '../RedisSecondaryEntityCache';
import RedisTestEntity, {
redisTestEntityConfiguration,
RedisTestEntityFields,
RedisTestEntityPrivacyPolicy,
} from '../testfixtures/RedisTestEntity';
import { createRedisIntegrationTestEntityCompanionProvider } from '../testfixtures/createRedisIntegrationTestEntityCompanionProvider';
class TestViewerContext extends ViewerContext {}
type TestLoadParams = { id: string };
const FAKE_ID = 'fake';
class TestSecondaryRedisCacheLoader extends EntitySecondaryCacheLoader<
TestLoadParams,
RedisTestEntityFields,
string,
TestViewerContext,
RedisTestEntity,
RedisTestEntityPrivacyPolicy
> {
public databaseLoadCount = 0;
protected async fetchObjectsFromDatabaseAsync(
loadParamsArray: readonly Readonly<TestLoadParams>[]
): Promise<ReadonlyMap<Readonly<TestLoadParams>, Readonly<RedisTestEntityFields> | null>> {
this.databaseLoadCount += loadParamsArray.length;
const emptyMap = new Map(loadParamsArray.map((p) => [p, null]));
return await mapMapAsync(emptyMap, async (_value, loadParams) => {
if (loadParams.id === FAKE_ID) {
return null;
}
return nullthrows(
(
await this.entityLoader
.enforcing()
.loadManyByFieldEqualityConjunctionAsync([
{ fieldName: 'id', fieldValue: loadParams.id },
])
)[0]
).getAllFields();
});
}
}
describe(RedisSecondaryEntityCache, () => {
let redisCacheAdapterContext: RedisCacheAdapterContext;
beforeAll(() => {
redisCacheAdapterContext = {
redisClient: new Redis(new URL(process.env['REDIS_URL']!).toString()),
makeKeyFn(..._parts: string[]): string {
throw new Error('should not be used by this test');
},
cacheKeyPrefix: 'test-',
cacheKeyVersion: 1,
ttlSecondsPositive: 86400, // 1 day
ttlSecondsNegative: 600, // 10 minutes
};
});
beforeEach(async () => {
await redisCacheAdapterContext.redisClient.flushdb();
});
afterAll(async () => {
redisCacheAdapterContext.redisClient.disconnect();
});
it('Loads through secondary loader, caches, and invalidates', async () => {
const viewerContext = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
);
const createdEntity = await RedisTestEntity.creator(viewerContext)
.setField('name', 'wat')
.enforceCreateAsync();
const secondaryCacheLoader = new TestSecondaryRedisCacheLoader(
new RedisSecondaryEntityCache(
redisTestEntityConfiguration,
redisCacheAdapterContext,
(loadParams) => `test-key-${loadParams.id}`
),
RedisTestEntity.loader(viewerContext)
);
const loadParams = { id: createdEntity.getID() };
const results = await secondaryCacheLoader.loadManyAsync([loadParams]);
expect(nullthrows(results.get(loadParams)).enforceValue().getID()).toEqual(
createdEntity.getID()
);
expect(secondaryCacheLoader.databaseLoadCount).toEqual(1);
const results2 = await secondaryCacheLoader.loadManyAsync([loadParams]);
expect(nullthrows(results2.get(loadParams)).enforceValue().getID()).toEqual(
createdEntity.getID()
);
expect(secondaryCacheLoader.databaseLoadCount).toEqual(1);
await secondaryCacheLoader.invalidateManyAsync([loadParams]);
const results3 = await secondaryCacheLoader.loadManyAsync([loadParams]);
expect(nullthrows(results3.get(loadParams)).enforceValue().getID()).toEqual(
createdEntity.getID()
);
expect(secondaryCacheLoader.databaseLoadCount).toEqual(2);
});
it('correctly handles uncached and unfetchable load params', async () => {
const viewerContext = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
);
const secondaryCacheLoader = new TestSecondaryRedisCacheLoader(
new RedisSecondaryEntityCache(
redisTestEntityConfiguration,
redisCacheAdapterContext,
(loadParams) => `test-key-${loadParams.id}`
),
RedisTestEntity.loader(viewerContext)
);
const loadParams = { id: FAKE_ID };
const results = await secondaryCacheLoader.loadManyAsync([loadParams]);
expect(results.size).toBe(1);
expect(results.get(loadParams)).toBe(null);
});
});
@@ -1,7 +0,0 @@
/* eslint-disable tsdoc/syntax */
/**
* @packageDocumentation
* @module @expo/entity-secondary-cache-redis
*/
export { default as RedisSecondaryEntityCache } from './RedisSecondaryEntityCache';
@@ -1,88 +0,0 @@
import {
AlwaysAllowPrivacyPolicyRule,
EntityPrivacyPolicy,
ViewerContext,
UUIDField,
StringField,
EntityConfiguration,
EntityCompanionDefinition,
Entity,
} from '@expo/entity';
export type RedisTestEntityFields = {
id: string;
name: string;
};
export default class RedisTestEntity extends Entity<RedisTestEntityFields, string, ViewerContext> {
static getCompanionDefinition(): EntityCompanionDefinition<
RedisTestEntityFields,
string,
ViewerContext,
RedisTestEntity,
RedisTestEntityPrivacyPolicy
> {
return redisTestEntityCompanionDefinition;
}
}
export class RedisTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
RedisTestEntityFields,
string,
ViewerContext,
RedisTestEntity
> {
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<
RedisTestEntityFields,
string,
ViewerContext,
RedisTestEntity
>(),
];
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<
RedisTestEntityFields,
string,
ViewerContext,
RedisTestEntity
>(),
];
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<
RedisTestEntityFields,
string,
ViewerContext,
RedisTestEntity
>(),
];
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<
RedisTestEntityFields,
string,
ViewerContext,
RedisTestEntity
>(),
];
}
export const redisTestEntityConfiguration = new EntityConfiguration<RedisTestEntityFields>({
idField: 'id',
tableName: 'redis_test_entities',
schema: {
id: new UUIDField({
columnName: 'id',
}),
name: new StringField({
columnName: 'name',
}),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
});
const redisTestEntityCompanionDefinition = new EntityCompanionDefinition({
entityClass: RedisTestEntity,
entityConfiguration: redisTestEntityConfiguration,
privacyPolicyClass: RedisTestEntityPrivacyPolicy,
});
@@ -1,37 +0,0 @@
import {
NoOpEntityMetricsAdapter,
IEntityMetricsAdapter,
EntityCompanionProvider,
StubQueryContextProvider,
StubDatabaseAdapterProvider,
} from '@expo/entity';
import {
RedisCacheAdapterContext,
RedisCacheAdapterProvider,
} from '@expo/entity-cache-adapter-redis';
export const createRedisIntegrationTestEntityCompanionProvider = (
redisCacheAdapterContext: RedisCacheAdapterContext,
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter()
): EntityCompanionProvider => {
return new EntityCompanionProvider(
metricsAdapter,
new Map([
[
'postgres',
{
adapterProvider: new StubDatabaseAdapterProvider(),
queryContextProvider: StubQueryContextProvider,
},
],
]),
new Map([
[
'redis',
{
cacheAdapterProvider: new RedisCacheAdapterProvider(redisCacheAdapterContext),
},
],
])
);
};
@@ -1,7 +0,0 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "build",
},
"include": ["src"]
}
-312
View File
@@ -1,312 +0,0 @@
# Change Log
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
**Note:** Version bump only for package @expo/entity
# [0.21.0](https://github.com/expo/entity/compare/v0.20.0...v0.21.0) (2022-01-03)
### Features
* add pre-commit callbacks on EntityQueryContext ([#147](https://github.com/expo/entity/issues/147)) ([f1d9847](https://github.com/expo/entity/commit/f1d9847210f5775de964509e44ce493e44482b21))
# [0.20.0](https://github.com/expo/entity/compare/v0.19.0...v0.20.0) (2021-12-29)
### Bug Fixes
* separate post-commit invalidation callbacks from trigger and user callbacks ([#144](https://github.com/expo/entity/issues/144)) ([efa131e](https://github.com/expo/entity/commit/efa131ef29532accf0e51b6d867ffe15a27b4c90))
### Features
* add cascading deletion info ([#145](https://github.com/expo/entity/issues/145)) ([3727191](https://github.com/expo/entity/commit/372719176742d3ead6b7cbca29cb66b09b3fe09c))
# [0.19.0](https://github.com/expo/entity/compare/v0.18.0...v0.19.0) (2021-10-28)
### Features
* add authorization metrics ([#142](https://github.com/expo/entity/issues/142)) ([7cf54d8](https://github.com/expo/entity/commit/7cf54d83c52e104159a3e2e675cdda2d29afe204))
# [0.18.0](https://github.com/expo/entity/compare/v0.17.0...v0.18.0) (2021-10-14)
### Features
* enable noImplicitOverride tsc setting ([#135](https://github.com/expo/entity/issues/135)) ([4263cb9](https://github.com/expo/entity/commit/4263cb9b8b69fe4fb68c74ec1c7aba04508a1555))
* expose describeFieldTestCase for use in other packages ([#139](https://github.com/expo/entity/issues/139)) ([1220477](https://github.com/expo/entity/commit/1220477aa331349b482ad015b44f76fa2bda7b90))
* upgrade TypeScript to 4.4 ([#134](https://github.com/expo/entity/issues/134)) ([7612392](https://github.com/expo/entity/commit/7612392dbfd9778d25c465c1626c372d75a6d05a))
* use better QueryContext types for transactional triggers and validators ([#136](https://github.com/expo/entity/issues/136)) ([5c7efe1](https://github.com/expo/entity/commit/5c7efe1678424ff907687e9327175bdc2538c3e0))
# [0.17.0](https://github.com/expo/entity/compare/v0.16.0...v0.17.0) (2021-08-16)
### Features
* Add IntField and FloatField, deprecate NumberField ([#131](https://github.com/expo/entity/issues/131)) ([2f2d963](https://github.com/expo/entity/commit/2f2d963cf92c6575d78f8eeca344a4aadcc9b8d9))
# [0.16.0](https://github.com/expo/entity/compare/v0.15.0...v0.16.0) (2021-07-07)
### Features
* allow null field values in loadManyByFieldEqualityConjunctionAsync ([#130](https://github.com/expo/entity/issues/130)) ([2f37dc6](https://github.com/expo/entity/commit/2f37dc6a37d368b12b3c8375e04f0777fd448797))
# [0.15.0](https://github.com/expo/entity/compare/v0.14.1...v0.15.0) (2021-05-26)
### Features
* secondary cache loader ([#123](https://github.com/expo/entity/issues/123)) ([4cba01e](https://github.com/expo/entity/commit/4cba01eb259c87d60b3026ce776e46f781363690))
## [0.14.1](https://github.com/expo/entity/compare/v0.14.0...v0.14.1) (2021-03-24)
### Bug Fixes
* typescript typedef of EntityMutationInfo ([#121](https://github.com/expo/entity/issues/121)) ([8e8f0dc](https://github.com/expo/entity/commit/8e8f0dc8636e343e29a6012d155e4111477a8989))
# [0.14.0](https://github.com/expo/entity/compare/v0.13.0...v0.14.0) (2021-03-24)
### Bug Fixes
* mutation validator and trigger cyclic import structure ([#118](https://github.com/expo/entity/issues/118)) ([b11dbc0](https://github.com/expo/entity/commit/b11dbc087ba5ad13c748032698dd73279b7960b0))
### Features
* add mutation type to validator and trigger execute functions ([#119](https://github.com/expo/entity/issues/119)) ([ef40fa5](https://github.com/expo/entity/commit/ef40fa5e9cdf53e25e78a9423c698e6d1c9737af))
# [0.13.0](https://github.com/expo/entity/compare/v0.12.0...v0.13.0) (2021-02-12)
### Bug Fixes
* allow undefined value for all fields ([#114](https://github.com/expo/entity/issues/114)) ([022e0e9](https://github.com/expo/entity/commit/022e0e9b88a5e84e0c1bc6c8810c850beb05f0cf))
### Features
* add basic field type runtime validators ([#113](https://github.com/expo/entity/issues/113)) ([6c4d4b0](https://github.com/expo/entity/commit/6c4d4b03b5404dd776a12c5eb6f0af77a8c964f8))
# [0.12.0](https://github.com/expo/entity/compare/v0.11.0...v0.12.0) (2021-01-22)
### Bug Fixes
* move runInTransaction to ViewerContext ([#108](https://github.com/expo/entity/issues/108)) ([b7309e1](https://github.com/expo/entity/commit/b7309e18a2dd780b64cd306d566de1f12bf8df3a))
* remove cache and database adaptor flavor enums ([#109](https://github.com/expo/entity/issues/109)) ([72a77f8](https://github.com/expo/entity/commit/72a77f8b6893a4b76d3b1cce16659bdf3ce473ee))
### Features
* better typing on filterMap for use with predicates ([#106](https://github.com/expo/entity/issues/106)) ([01b3bfe](https://github.com/expo/entity/commit/01b3bfef7c6da55e7d89d8cccb52a0050187aee8))
# [0.11.0](https://github.com/expo/entity/compare/v0.10.0...v0.11.0) (2020-12-24)
### Bug Fixes
* add type generic default in test utils ([#104](https://github.com/expo/entity/issues/104)) ([fe2917e](https://github.com/expo/entity/commit/fe2917e6b5af808f166aa354d476e168c41e6d21))
* maps utils invariants for collections containing undefined values ([#103](https://github.com/expo/entity/issues/103)) ([29c1a43](https://github.com/expo/entity/commit/29c1a434ab6cd8d30e9f0e7c742065c8d46d8d8a))
* remove any cast from EntityLoader ([#100](https://github.com/expo/entity/issues/100)) ([b8e07f9](https://github.com/expo/entity/commit/b8e07f9ddc4077768980c000d61f5ddcc824e2e3))
### Features
* better cache adapter error handling ([#102](https://github.com/expo/entity/issues/102)) ([15546aa](https://github.com/expo/entity/commit/15546aad97734ab9ac67f1c012485f096a0e94f1))
* better database adapter error handling ([#101](https://github.com/expo/entity/issues/101)) ([5208aee](https://github.com/expo/entity/commit/5208aeedb0192f0460ba3d575770a04175f5c3aa))
# [0.10.0](https://github.com/expo/entity/compare/v0.9.1...v0.10.0) (2020-11-17)
### Bug Fixes
* fix null handling in load paths (try 2) ([#96](https://github.com/expo/entity/issues/96)) ([d960329](https://github.com/expo/entity/commit/d9603298e0cc5c3c2b0cca959876e9195555fb57))
## [0.9.1](https://github.com/expo/entity/compare/v0.9.0...v0.9.1) (2020-11-06)
### Bug Fixes
* edge deletion behavior cyclic import structure again ([#93](https://github.com/expo/entity/issues/93)) ([012c05d](https://github.com/expo/entity/commit/012c05d62d2ecd559eb331c6ca358052bfe66dc8))
# [0.9.0](https://github.com/expo/entity/compare/v0.8.1...v0.9.0) (2020-11-05)
### Bug Fixes
* edge deletion behavior cyclic import structure ([#89](https://github.com/expo/entity/issues/89)) ([cca8fc1](https://github.com/expo/entity/commit/cca8fc19870057ebc7033081b59fab4a142fbf1a))
### Features
* add ability to invalidate an entity manually ([#91](https://github.com/expo/entity/issues/91)) ([d1a6f13](https://github.com/expo/entity/commit/d1a6f1339648942f12b3fb605eb329aefe5cdd63))
## [0.8.1](https://github.com/expo/entity/compare/v0.8.0...v0.8.1) (2020-10-09)
**Note:** Version bump only for package @expo/entity
# [0.8.0](https://github.com/expo/entity/compare/v0.7.1...v0.8.0) (2020-10-09)
### Features
* add entityClassName to metrics adapter events ([#83](https://github.com/expo/entity/issues/83)) ([f4cdc01](https://github.com/expo/entity/commit/f4cdc01c6cb8868c24ce3da2274f0901687e05bd))
## [0.7.1](https://github.com/expo/entity/compare/v0.7.0...v0.7.1) (2020-10-01)
### Bug Fixes
* correct type of runInTransactionAsync ([#81](https://github.com/expo/entity/issues/81)) ([f98f7d1](https://github.com/expo/entity/commit/f98f7d14b6149a496a7a3e97dd63d89b2842cc26))
# [0.7.0](https://github.com/expo/entity/compare/v0.6.0...v0.7.0) (2020-10-01)
### Bug Fixes
* fix incorrect loader types for field selecitons ([#72](https://github.com/expo/entity/issues/72)) ([cd2df8d](https://github.com/expo/entity/commit/cd2df8da4b81bcd4769e950d3124c18886df2816))
* fix index export for EntityMutationTrigger and EntityMutationValidator ([#73](https://github.com/expo/entity/issues/73)) ([7e829f3](https://github.com/expo/entity/commit/7e829f30fde0bd343cb8a584ccda5530cf61ec33))
* move cache invalidation to after transaction commit ([#77](https://github.com/expo/entity/issues/77)) ([dbc3c81](https://github.com/expo/entity/commit/dbc3c81b359a668b5134189ce6de49b5893bb5e7))
* upgrade packages ([#78](https://github.com/expo/entity/issues/78)) ([9891e74](https://github.com/expo/entity/commit/9891e7469467a28589f529c8d87b10fc2232d3ff))
# [0.6.0](https://github.com/expo/entity/compare/v0.5.2...v0.6.0) (2020-07-23)
### Bug Fixes
* ensure transitive deletion triggers are run for CASCADE_DELETE_INVALIDATE_CACHE ([#66](https://github.com/expo/entity/issues/66)) ([5744c51](https://github.com/expo/entity/commit/5744c51e6b3178bd9f229f3f465b683bb4474c8a))
* separate out EntityMutationValidator ([#68](https://github.com/expo/entity/issues/68)) ([547a1ef](https://github.com/expo/entity/commit/547a1efcecd17cc085702d1a3e9888ce5b644b13))
* update setField to correct typing ([#62](https://github.com/expo/entity/issues/62)) ([7a77afc](https://github.com/expo/entity/commit/7a77afc83ea732c9b062a5d6865eff9d3131d015))
### Features
* entity mutation validators ([#67](https://github.com/expo/entity/issues/67)) ([fc4377d](https://github.com/expo/entity/commit/fc4377d8839da07417b88afc138f73556383d896))
* mutation triggers ([#65](https://github.com/expo/entity/issues/65)) ([fd6060c](https://github.com/expo/entity/commit/fd6060cc844f60635b3ce4c400c4877f2df8fa44))
## [0.5.2](https://github.com/expo/entity/compare/v0.5.1...v0.5.2) (2020-07-02)
### Bug Fixes
* fix stub cache key generation for global cache ([#59](https://github.com/expo/entity/issues/59)) ([7c180e8](https://github.com/expo/entity/commit/7c180e84d9a8cdfd9380fac0eab2d02a96ec18f3))
## [0.5.1](https://github.com/expo/entity/compare/v0.5.0...v0.5.1) (2020-07-01)
### Bug Fixes
* correct some typing issues and exports ([#57](https://github.com/expo/entity/issues/57)) ([e91cfba](https://github.com/expo/entity/commit/e91cfba99321c2c4da2078aafd8627cbf0ceca50))
# 0.5.0 (2020-07-01)
### Bug Fixes
* support number ID field generation in StubDatabaseAdapter ([#43](https://github.com/expo/entity/issues/43)) ([60cc0fb](https://github.com/expo/entity/commit/60cc0fbda460c3ada1f5244a2780f90382fe3ebd))
### Features
* add two fan out methods to association loader ([#52](https://github.com/expo/entity/issues/52)) ([89cfb3d](https://github.com/expo/entity/commit/89cfb3d5a01d4da90f64acc52ae1b839ba348a35))
* support entity fields subset of db fields ([#49](https://github.com/expo/entity/issues/49)) ([4e40b2e](https://github.com/expo/entity/commit/4e40b2e521407e521d236978ec3b3b56db3990be))
+8 -8
View File
@@ -1,6 +1,6 @@
{ {
"name": "@expo/entity", "name": "@expo/entity",
"version": "0.22.0", "version": "0.4.0",
"description": "A privacy-first data model", "description": "A privacy-first data model",
"files": [ "files": [
"build", "build",
@@ -11,15 +11,14 @@
"scripts": { "scripts": {
"tsc": "tsc", "tsc": "tsc",
"clean": "rm -rf build coverage coverage-integration", "clean": "rm -rf build coverage coverage-integration",
"lint": "eslint src", "lint": "eslint src --ext '.ts'",
"lint-fix": "eslint src --fix", "test": "jest --rootDir --config ../../resources/jest.config.js",
"test": "jest --rootDir . --config ../../resources/jest.config.js",
"integration": "../../resources/run-with-docker yarn integration-no-setup", "integration": "../../resources/run-with-docker yarn integration-no-setup",
"integration-no-setup": "jest --config ../../resources/jest-integration.config.js --rootDir . --runInBand --passWithNoTests", "integration-no-setup": "jest --config ../../resources/jest-integration.config.js --rootDir --runInBand --passWithNoTests",
"barrelsby": "barrelsby --directory src --location top --exclude tests__ --singleQuotes --exportDefault --delete" "barrelsby": "barrelsby --directory src --location top --exclude tests__ --singleQuotes --exportDefault --delete"
}, },
"engines": { "engines": {
"node": ">=12" "node": ">=8"
}, },
"keywords": [ "keywords": [
"entity" "entity"
@@ -27,10 +26,11 @@
"author": "Expo", "author": "Expo",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@expo/results": "^1.0.0", "@expo/results": "^0.3.0",
"dataloader": "^2.0.0", "dataloader": "^2.0.0",
"es6-error": "^4.1.1", "es6-error": "^4.1.1",
"invariant": "^2.2.4", "invariant": "^2.2.4",
"uuid": "^8.3.0" "lodash": "^4.17.15",
"uuid": "^7.0.3"
} }
} }
+8 -16
View File
@@ -10,18 +10,11 @@ import { mapMap } from './utils/collections/maps';
* if the loads are not successful. * if the loads are not successful.
*/ */
export default class EnforcingEntityLoader< export default class EnforcingEntityLoader<
TFields,
TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
TPrivacyPolicy extends EntityPrivacyPolicy<
TFields, TFields,
TID, TID,
TViewerContext, TViewerContext extends ViewerContext,
TEntity, TEntity extends ReadonlyEntity<TFields, TID, TViewerContext>,
TSelectedFields TPrivacyPolicy extends EntityPrivacyPolicy<TFields, TID, TViewerContext, TEntity>
>,
TSelectedFields extends keyof TFields
> { > {
constructor( constructor(
private readonly entityLoader: EntityLoader< private readonly entityLoader: EntityLoader<
@@ -29,8 +22,7 @@ export default class EnforcingEntityLoader<
TID, TID,
TViewerContext, TViewerContext,
TEntity, TEntity,
TPrivacyPolicy, TPrivacyPolicy
TSelectedFields
> >
) {} ) {}
@@ -38,7 +30,7 @@ export default class EnforcingEntityLoader<
* Enforcing version of entity loader method by the same name. * Enforcing version of entity loader method by the same name.
* @throws {@link EntityNotAuthorizedError} when viewer is not authorized to view one or more of the returned entities * @throws {@link EntityNotAuthorizedError} when viewer is not authorized to view one or more of the returned entities
*/ */
async loadManyByFieldEqualingManyAsync<N extends keyof Pick<TFields, TSelectedFields>>( async loadManyByFieldEqualingManyAsync<N extends keyof TFields>(
fieldName: N, fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[] fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<ReadonlyMap<NonNullable<TFields[N]>, readonly TEntity[]>> { ): Promise<ReadonlyMap<NonNullable<TFields[N]>, readonly TEntity[]>> {
@@ -55,7 +47,7 @@ export default class EnforcingEntityLoader<
* Enforcing version of entity loader method by the same name. * Enforcing version of entity loader method by the same name.
* @throws {@link EntityNotAuthorizedError} when viewer is not authorized to view one or more of the returned entities * @throws {@link EntityNotAuthorizedError} when viewer is not authorized to view one or more of the returned entities
*/ */
async loadManyByFieldEqualingAsync<N extends keyof Pick<TFields, TSelectedFields>>( async loadManyByFieldEqualingAsync<N extends keyof TFields>(
fieldName: N, fieldName: N,
fieldValue: NonNullable<TFields[N]> fieldValue: NonNullable<TFields[N]>
): Promise<readonly TEntity[]> { ): Promise<readonly TEntity[]> {
@@ -71,7 +63,7 @@ export default class EnforcingEntityLoader<
* @throws {@link EntityNotAuthorizedError} when viewer is not authorized to view the returned entity * @throws {@link EntityNotAuthorizedError} when viewer is not authorized to view the returned entity
* @throws when multiple entities are found matching the condition * @throws when multiple entities are found matching the condition
*/ */
async loadByFieldEqualingAsync<N extends keyof Pick<TFields, TSelectedFields>>( async loadByFieldEqualingAsync<N extends keyof TFields>(
uniqueFieldName: N, uniqueFieldName: N,
fieldValue: NonNullable<TFields[N]> fieldValue: NonNullable<TFields[N]>
): Promise<TEntity | null> { ): Promise<TEntity | null> {
@@ -114,7 +106,7 @@ export default class EnforcingEntityLoader<
* Enforcing version of entity loader method by the same name. * Enforcing version of entity loader method by the same name.
* @throws {@link EntityNotAuthorizedError} when viewer is not authorized to view one or more of the returned entities * @throws {@link EntityNotAuthorizedError} when viewer is not authorized to view one or more of the returned entities
*/ */
async loadManyByFieldEqualityConjunctionAsync<N extends keyof Pick<TFields, TSelectedFields>>( async loadManyByFieldEqualityConjunctionAsync<N extends keyof TFields>(
fieldEqualityOperands: FieldEqualityCondition<TFields, N>[], fieldEqualityOperands: FieldEqualityCondition<TFields, N>[],
querySelectionModifiers: QuerySelectionModifiers<TFields> = {} querySelectionModifiers: QuerySelectionModifiers<TFields> = {}
): Promise<readonly TEntity[]> { ): Promise<readonly TEntity[]> {
+41 -142
View File
@@ -27,10 +27,9 @@ import ViewerContext from './ViewerContext';
*/ */
export default abstract class Entity< export default abstract class Entity<
TFields, TFields,
TID extends NonNullable<TFields[TSelectedFields]>, TID,
TViewerContext extends ViewerContext, TViewerContext extends ViewerContext
TSelectedFields extends keyof TFields = keyof TFields > extends ReadonlyEntity<TFields, TID, TViewerContext> {
> extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields> {
/** /**
* Vend mutator for creating a new entity in given query context. * Vend mutator for creating a new entity in given query context.
* @param viewerContext - viewer context of creating user * @param viewerContext - viewer context of creating user
@@ -39,33 +38,19 @@ export default abstract class Entity<
*/ */
static creator< static creator<
TMFields, TMFields,
TMID extends NonNullable<TMFields[TMSelectedFields]>, TMID,
TMViewerContext extends ViewerContext, TMViewerContext extends ViewerContext,
TMViewerContext2 extends TMViewerContext, TMViewerContext2 extends TMViewerContext,
TMEntity extends Entity<TMFields, TMID, TMViewerContext, TMSelectedFields>, TMEntity extends Entity<TMFields, TMID, TMViewerContext>,
TMPrivacyPolicy extends EntityPrivacyPolicy< TMPrivacyPolicy extends EntityPrivacyPolicy<TMFields, TMID, TMViewerContext, TMEntity>
TMFields,
TMID,
TMViewerContext,
TMEntity,
TMSelectedFields
>,
TMSelectedFields extends keyof TMFields = keyof TMFields
>( >(
this: IEntityClass< this: IEntityClass<TMFields, TMID, TMViewerContext, TMEntity, TMPrivacyPolicy>,
TMFields,
TMID,
TMViewerContext,
TMEntity,
TMPrivacyPolicy,
TMSelectedFields
>,
viewerContext: TMViewerContext2, viewerContext: TMViewerContext2,
queryContext: EntityQueryContext = viewerContext queryContext: EntityQueryContext = viewerContext
.getViewerScopedEntityCompanionForClass(this) .getViewerScopedEntityCompanionForClass(this)
.getQueryContextProvider() .getQueryContextProvider()
.getQueryContext() .getRegularEntityQueryContext()
): CreateMutator<TMFields, TMID, TMViewerContext, TMEntity, TMPrivacyPolicy, TMSelectedFields> { ): CreateMutator<TMFields, TMID, TMViewerContext, TMEntity, TMPrivacyPolicy> {
return viewerContext return viewerContext
.getViewerScopedEntityCompanionForClass(this) .getViewerScopedEntityCompanionForClass(this)
.getMutatorFactory() .getMutatorFactory()
@@ -80,33 +65,19 @@ export default abstract class Entity<
*/ */
static updater< static updater<
TMFields, TMFields,
TMID extends NonNullable<TMFields[TMSelectedFields]>, TMID,
TMViewerContext extends ViewerContext, TMViewerContext extends ViewerContext,
TMEntity extends Entity<TMFields, TMID, TMViewerContext, TMSelectedFields>, TMEntity extends Entity<TMFields, TMID, TMViewerContext>,
TMPrivacyPolicy extends EntityPrivacyPolicy< TMPrivacyPolicy extends EntityPrivacyPolicy<TMFields, TMID, TMViewerContext, TMEntity>
TMFields,
TMID,
TMViewerContext,
TMEntity,
TMSelectedFields
>,
TMSelectedFields extends keyof TMFields = keyof TMFields
>( >(
this: IEntityClass< this: IEntityClass<TMFields, TMID, TMViewerContext, TMEntity, TMPrivacyPolicy>,
TMFields,
TMID,
TMViewerContext,
TMEntity,
TMPrivacyPolicy,
TMSelectedFields
>,
existingEntity: TMEntity, existingEntity: TMEntity,
queryContext: EntityQueryContext = existingEntity queryContext: EntityQueryContext = existingEntity
.getViewerContext() .getViewerContext()
.getViewerScopedEntityCompanionForClass(this) .getViewerScopedEntityCompanionForClass(this)
.getQueryContextProvider() .getQueryContextProvider()
.getQueryContext() .getRegularEntityQueryContext()
): UpdateMutator<TMFields, TMID, TMViewerContext, TMEntity, TMPrivacyPolicy, TMSelectedFields> { ): UpdateMutator<TMFields, TMID, TMViewerContext, TMEntity, TMPrivacyPolicy> {
return existingEntity return existingEntity
.getViewerContext() .getViewerContext()
.getViewerScopedEntityCompanionForClass(this) .getViewerScopedEntityCompanionForClass(this)
@@ -121,32 +92,18 @@ export default abstract class Entity<
*/ */
static deleteAsync< static deleteAsync<
TMFields, TMFields,
TMID extends NonNullable<TMFields[TMSelectedFields]>, TMID,
TMViewerContext extends ViewerContext, TMViewerContext extends ViewerContext,
TMEntity extends Entity<TMFields, TMID, TMViewerContext, TMSelectedFields>, TMEntity extends Entity<TMFields, TMID, TMViewerContext>,
TMPrivacyPolicy extends EntityPrivacyPolicy< TMPrivacyPolicy extends EntityPrivacyPolicy<TMFields, TMID, TMViewerContext, TMEntity>
TMFields,
TMID,
TMViewerContext,
TMEntity,
TMSelectedFields
>,
TMSelectedFields extends keyof TMFields = keyof TMFields
>( >(
this: IEntityClass< this: IEntityClass<TMFields, TMID, TMViewerContext, TMEntity, TMPrivacyPolicy>,
TMFields,
TMID,
TMViewerContext,
TMEntity,
TMPrivacyPolicy,
TMSelectedFields
>,
existingEntity: TMEntity, existingEntity: TMEntity,
queryContext: EntityQueryContext = existingEntity queryContext: EntityQueryContext = existingEntity
.getViewerContext() .getViewerContext()
.getViewerScopedEntityCompanionForClass(this) .getViewerScopedEntityCompanionForClass(this)
.getQueryContextProvider() .getQueryContextProvider()
.getQueryContext() .getRegularEntityQueryContext()
): Promise<Result<void>> { ): Promise<Result<void>> {
return existingEntity return existingEntity
.getViewerContext() .getViewerContext()
@@ -163,32 +120,18 @@ export default abstract class Entity<
*/ */
static enforceDeleteAsync< static enforceDeleteAsync<
TMFields, TMFields,
TMID extends NonNullable<TMFields[TMSelectedFields]>, TMID,
TMViewerContext extends ViewerContext, TMViewerContext extends ViewerContext,
TMEntity extends Entity<TMFields, TMID, TMViewerContext, TMSelectedFields>, TMEntity extends Entity<TMFields, TMID, TMViewerContext>,
TMPrivacyPolicy extends EntityPrivacyPolicy< TMPrivacyPolicy extends EntityPrivacyPolicy<TMFields, TMID, TMViewerContext, TMEntity>
TMFields,
TMID,
TMViewerContext,
TMEntity,
TMSelectedFields
>,
TMSelectedFields extends keyof TMFields = keyof TMFields
>( >(
this: IEntityClass< this: IEntityClass<TMFields, TMID, TMViewerContext, TMEntity, TMPrivacyPolicy>,
TMFields,
TMID,
TMViewerContext,
TMEntity,
TMPrivacyPolicy,
TMSelectedFields
>,
existingEntity: TMEntity, existingEntity: TMEntity,
queryContext: EntityQueryContext = existingEntity queryContext: EntityQueryContext = existingEntity
.getViewerContext() .getViewerContext()
.getViewerScopedEntityCompanionForClass(this) .getViewerScopedEntityCompanionForClass(this)
.getQueryContextProvider() .getQueryContextProvider()
.getQueryContext() .getRegularEntityQueryContext()
): Promise<void> { ): Promise<void> {
return existingEntity return existingEntity
.getViewerContext() .getViewerContext()
@@ -216,43 +159,25 @@ export default abstract class Entity<
*/ */
static async canViewerUpdateAsync< static async canViewerUpdateAsync<
TMFields, TMFields,
TMID extends NonNullable<TMFields[TMSelectedFields]>, TMID,
TMViewerContext extends ViewerContext, TMViewerContext extends ViewerContext,
TMEntity extends Entity<TMFields, TMID, TMViewerContext, TMSelectedFields>, TMEntity extends Entity<TMFields, TMID, TMViewerContext>,
TMPrivacyPolicy extends EntityPrivacyPolicy< TMPrivacyPolicy extends EntityPrivacyPolicy<TMFields, TMID, TMViewerContext, TMEntity>
TMFields,
TMID,
TMViewerContext,
TMEntity,
TMSelectedFields
>,
TMSelectedFields extends keyof TMFields = keyof TMFields
>( >(
this: IEntityClass< this: IEntityClass<TMFields, TMID, TMViewerContext, TMEntity, TMPrivacyPolicy>,
TMFields,
TMID,
TMViewerContext,
TMEntity,
TMPrivacyPolicy,
TMSelectedFields
>,
existingEntity: TMEntity, existingEntity: TMEntity,
queryContext: EntityQueryContext = existingEntity queryContext: EntityQueryContext = existingEntity
.getViewerContext() .getViewerContext()
.getViewerScopedEntityCompanionForClass(this) .getViewerScopedEntityCompanionForClass(this)
.getQueryContextProvider() .getQueryContextProvider()
.getQueryContext() .getRegularEntityQueryContext()
): Promise<boolean> { ): Promise<boolean> {
const companion = existingEntity
.getViewerContext()
.getViewerScopedEntityCompanionForClass(this);
const privacyPolicy = new (this.getCompanionDefinition().privacyPolicyClass)(); const privacyPolicy = new (this.getCompanionDefinition().privacyPolicyClass)();
const evaluationResult = await asyncResult( const evaluationResult = await asyncResult(
privacyPolicy.authorizeUpdateAsync( privacyPolicy.authorizeUpdateAsync(
existingEntity.getViewerContext(), existingEntity.getViewerContext(),
queryContext, queryContext,
existingEntity, existingEntity
companion.getMetricsAdapter()
) )
); );
return evaluationResult.ok; return evaluationResult.ok;
@@ -269,43 +194,25 @@ export default abstract class Entity<
*/ */
static async canViewerDeleteAsync< static async canViewerDeleteAsync<
TMFields, TMFields,
TMID extends NonNullable<TMFields[TMSelectedFields]>, TMID,
TMViewerContext extends ViewerContext, TMViewerContext extends ViewerContext,
TMEntity extends Entity<TMFields, TMID, TMViewerContext, TMSelectedFields>, TMEntity extends Entity<TMFields, TMID, TMViewerContext>,
TMPrivacyPolicy extends EntityPrivacyPolicy< TMPrivacyPolicy extends EntityPrivacyPolicy<TMFields, TMID, TMViewerContext, TMEntity>
TMFields,
TMID,
TMViewerContext,
TMEntity,
TMSelectedFields
>,
TMSelectedFields extends keyof TMFields = keyof TMFields
>( >(
this: IEntityClass< this: IEntityClass<TMFields, TMID, TMViewerContext, TMEntity, TMPrivacyPolicy>,
TMFields,
TMID,
TMViewerContext,
TMEntity,
TMPrivacyPolicy,
TMSelectedFields
>,
existingEntity: TMEntity, existingEntity: TMEntity,
queryContext: EntityQueryContext = existingEntity queryContext: EntityQueryContext = existingEntity
.getViewerContext() .getViewerContext()
.getViewerScopedEntityCompanionForClass(this) .getViewerScopedEntityCompanionForClass(this)
.getQueryContextProvider() .getQueryContextProvider()
.getQueryContext() .getRegularEntityQueryContext()
): Promise<boolean> { ): Promise<boolean> {
const companion = existingEntity
.getViewerContext()
.getViewerScopedEntityCompanionForClass(this);
const privacyPolicy = new (this.getCompanionDefinition().privacyPolicyClass)(); const privacyPolicy = new (this.getCompanionDefinition().privacyPolicyClass)();
const evaluationResult = await asyncResult( const evaluationResult = await asyncResult(
privacyPolicy.authorizeDeleteAsync( privacyPolicy.authorizeDeleteAsync(
existingEntity.getViewerContext(), existingEntity.getViewerContext(),
queryContext, queryContext,
existingEntity, existingEntity
companion.getMetricsAdapter()
) )
); );
return evaluationResult.ok; return evaluationResult.ok;
@@ -316,18 +223,11 @@ export default abstract class Entity<
* An interface to pass in constructor (class) of an Entity as a function argument. * An interface to pass in constructor (class) of an Entity as a function argument.
*/ */
export interface IEntityClass< export interface IEntityClass<
TFields,
TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
TPrivacyPolicy extends EntityPrivacyPolicy<
TFields, TFields,
TID, TID,
TViewerContext, TViewerContext extends ViewerContext,
TEntity, TEntity extends ReadonlyEntity<TFields, TID, TViewerContext>,
TSelectedFields TPrivacyPolicy extends EntityPrivacyPolicy<TFields, TID, TViewerContext, TEntity>
>,
TSelectedFields extends keyof TFields = keyof TFields
> { > {
new (viewerContext: TViewerContext, obj: Readonly<TFields>): TEntity; new (viewerContext: TViewerContext, obj: Readonly<TFields>): TEntity;
getCompanionDefinition(): EntityCompanionDefinition< getCompanionDefinition(): EntityCompanionDefinition<
@@ -335,7 +235,6 @@ export interface IEntityClass<
TID, TID,
TViewerContext, TViewerContext,
TEntity, TEntity,
TPrivacyPolicy, TPrivacyPolicy
TSelectedFields
>; >;
} }
+54 -268
View File
@@ -8,10 +8,9 @@ import ViewerContext from './ViewerContext';
export default class EntityAssociationLoader< export default class EntityAssociationLoader<
TFields, TFields,
TID extends NonNullable<TFields[TSelectedFields]>, TID,
TViewerContext extends ViewerContext, TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>, TEntity extends ReadonlyEntity<TFields, TID, TViewerContext>
TSelectedFields extends keyof TFields
> { > {
constructor(private readonly entity: TEntity) {} constructor(private readonly entity: TEntity) {}
@@ -23,111 +22,37 @@ export default class EntityAssociationLoader<
* @param queryContext - query context in which to perform the load * @param queryContext - query context in which to perform the load
*/ */
async loadAssociatedEntityAsync< async loadAssociatedEntityAsync<
TIdentifyingField extends keyof Pick<TFields, TSelectedFields>,
TAssociatedFields,
TAssociatedID extends NonNullable<TAssociatedFields[TAssociatedSelectedFields]>,
TAssociatedEntity extends ReadonlyEntity<
TAssociatedFields, TAssociatedFields,
TAssociatedID, TAssociatedID,
TViewerContext, TAssociatedEntity extends ReadonlyEntity<TAssociatedFields, TAssociatedID, TViewerContext>,
TAssociatedSelectedFields
>,
TAssociatedPrivacyPolicy extends EntityPrivacyPolicy< TAssociatedPrivacyPolicy extends EntityPrivacyPolicy<
TAssociatedFields, TAssociatedFields,
TAssociatedID, TAssociatedID,
TViewerContext, TViewerContext,
TAssociatedEntity, TAssociatedEntity
TAssociatedSelectedFields >
>,
TAssociatedSelectedFields extends keyof TAssociatedFields = keyof TAssociatedFields
>( >(
fieldIdentifyingAssociatedEntity: TIdentifyingField, fieldIdentifyingAssociatedEntity: keyof TFields,
associatedEntityClass: IEntityClass< associatedEntityClass: IEntityClass<
TAssociatedFields, TAssociatedFields,
TAssociatedID, TAssociatedID,
TViewerContext, TViewerContext,
TAssociatedEntity, TAssociatedEntity,
TAssociatedPrivacyPolicy, TAssociatedPrivacyPolicy
TAssociatedSelectedFields
>, >,
queryContext: EntityQueryContext = this.entity queryContext: EntityQueryContext = this.entity
.getViewerContext() .getViewerContext()
.getViewerScopedEntityCompanionForClass(associatedEntityClass) .getViewerScopedEntityCompanionForClass(associatedEntityClass)
.getQueryContextProvider() .getQueryContextProvider()
.getQueryContext() .getRegularEntityQueryContext()
): Promise< ): Promise<Result<TAssociatedEntity>> {
Result<null extends TFields[TIdentifyingField] ? TAssociatedEntity | null : TAssociatedEntity>
> {
const associatedEntityID = this.entity.getField(fieldIdentifyingAssociatedEntity); const associatedEntityID = this.entity.getField(fieldIdentifyingAssociatedEntity);
if (!associatedEntityID) {
return result(null) as Result<
null extends TFields[TIdentifyingField] ? TAssociatedEntity | null : TAssociatedEntity
>;
}
const loader = this.entity const loader = this.entity
.getViewerContext() .getViewerContext()
.getViewerScopedEntityCompanionForClass(associatedEntityClass) .getViewerScopedEntityCompanionForClass(associatedEntityClass)
.getLoaderFactory() .getLoaderFactory()
.forLoad(queryContext); .forLoad(queryContext);
return await loader.loadByIDAsync((associatedEntityID as unknown) as TAssociatedID);
return (await loader.loadByIDAsync(associatedEntityID as unknown as TAssociatedID)) as Result<
null extends TFields[TIdentifyingField] ? TAssociatedEntity | null : TAssociatedEntity
>;
}
/**
* Load many entities associated with this entity, often referred to as entites belonging
* to this entity. In a relational database, the field in the foreign entity is a
* foreign key to the ID of this entity. Also commonly referred to as a has many relationship,
* where this entity has many associated entities.
* @param associatedEntityClass - class of the associated entities
* @param associatedEntityFieldContainingThisID - field of associated entity which contains the ID of this entity
* @param queryContext - query context in which to perform the load
*/
async loadManyAssociatedEntitiesAsync<
TAssociatedFields,
TAssociatedID extends NonNullable<TAssociatedFields[TAssociatedSelectedFields]>,
TAssociatedEntity extends ReadonlyEntity<
TAssociatedFields,
TAssociatedID,
TViewerContext,
TAssociatedSelectedFields
>,
TAssociatedPrivacyPolicy extends EntityPrivacyPolicy<
TAssociatedFields,
TAssociatedID,
TViewerContext,
TAssociatedEntity,
TAssociatedSelectedFields
>,
TAssociatedSelectedFields extends keyof TAssociatedFields = keyof TAssociatedFields
>(
associatedEntityClass: IEntityClass<
TAssociatedFields,
TAssociatedID,
TViewerContext,
TAssociatedEntity,
TAssociatedPrivacyPolicy,
TAssociatedSelectedFields
>,
associatedEntityFieldContainingThisID: keyof Pick<TAssociatedFields, TAssociatedSelectedFields>,
queryContext: EntityQueryContext = this.entity
.getViewerContext()
.getViewerScopedEntityCompanionForClass(associatedEntityClass)
.getQueryContextProvider()
.getQueryContext()
): Promise<readonly Result<TAssociatedEntity>[]> {
const thisID = this.entity.getID();
const loader = this.entity
.getViewerContext()
.getViewerScopedEntityCompanionForClass(associatedEntityClass)
.getLoaderFactory()
.forLoad(queryContext);
return await loader.loadManyByFieldEqualingAsync(
associatedEntityFieldContainingThisID,
thisID as any
);
} }
/** /**
@@ -139,43 +64,32 @@ export default class EntityAssociationLoader<
* @param queryContext - query context in which to perform the load * @param queryContext - query context in which to perform the load
*/ */
async loadAssociatedEntityByFieldEqualingAsync< async loadAssociatedEntityByFieldEqualingAsync<
TAssociatedFields,
TAssociatedID extends NonNullable<TAssociatedFields[TAssociatedSelectedFields]>,
TAssociatedEntity extends ReadonlyEntity<
TAssociatedFields, TAssociatedFields,
TAssociatedID, TAssociatedID,
TViewerContext, TAssociatedEntity extends ReadonlyEntity<TAssociatedFields, TAssociatedID, TViewerContext>,
TAssociatedSelectedFields
>,
TAssociatedPrivacyPolicy extends EntityPrivacyPolicy< TAssociatedPrivacyPolicy extends EntityPrivacyPolicy<
TAssociatedFields, TAssociatedFields,
TAssociatedID, TAssociatedID,
TViewerContext, TViewerContext,
TAssociatedEntity, TAssociatedEntity
TAssociatedSelectedFields >
>,
TAssociatedSelectedFields extends keyof TAssociatedFields = keyof TAssociatedFields
>( >(
fieldIdentifyingAssociatedEntity: keyof Pick<TFields, TSelectedFields>, fieldIdentifyingAssociatedEntity: keyof TFields,
associatedEntityClass: IEntityClass< associatedEntityClass: IEntityClass<
TAssociatedFields, TAssociatedFields,
TAssociatedID, TAssociatedID,
TViewerContext, TViewerContext,
TAssociatedEntity, TAssociatedEntity,
TAssociatedPrivacyPolicy, TAssociatedPrivacyPolicy
TAssociatedSelectedFields
>, >,
associatedEntityLookupByField: keyof Pick<TAssociatedFields, TAssociatedSelectedFields>, associatedEntityLookupByField: keyof TAssociatedFields,
queryContext: EntityQueryContext = this.entity queryContext: EntityQueryContext = this.entity
.getViewerContext() .getViewerContext()
.getViewerScopedEntityCompanionForClass(associatedEntityClass) .getViewerScopedEntityCompanionForClass(associatedEntityClass)
.getQueryContextProvider() .getQueryContextProvider()
.getQueryContext() .getRegularEntityQueryContext()
): Promise<Result<TAssociatedEntity> | null> { ): Promise<Result<TAssociatedEntity> | null> {
const associatedFieldValue = this.entity.getField(fieldIdentifyingAssociatedEntity); const associatedFieldValue = this.entity.getField(fieldIdentifyingAssociatedEntity);
if (!associatedFieldValue) {
return null;
}
const loader = this.entity const loader = this.entity
.getViewerContext() .getViewerContext()
.getViewerScopedEntityCompanionForClass(associatedEntityClass) .getViewerScopedEntityCompanionForClass(associatedEntityClass)
@@ -187,64 +101,6 @@ export default class EntityAssociationLoader<
); );
} }
/**
* Load many associated entities identified by a field value of this entity. In a relational database,
* the field in this entity refers to a field of the associated entity.
* @param fieldIdentifyingAssociatedEntity - field of this entity containing the value with which to look up associated entities
* @param associatedEntityClass - class of the associated entities
* @param associatedEntityLookupByField - field of associated entities with which to look up the associated entities
* @param queryContext - query context in which to perform the load
*/
async loadManyAssociatedEntitiesByFieldEqualingAsync<
TAssociatedFields,
TAssociatedID extends NonNullable<TAssociatedFields[TAssociatedSelectedFields]>,
TAssociatedEntity extends ReadonlyEntity<
TAssociatedFields,
TAssociatedID,
TViewerContext,
TAssociatedSelectedFields
>,
TAssociatedPrivacyPolicy extends EntityPrivacyPolicy<
TAssociatedFields,
TAssociatedID,
TViewerContext,
TAssociatedEntity,
TAssociatedSelectedFields
>,
TAssociatedSelectedFields extends keyof TAssociatedFields = keyof TAssociatedFields
>(
fieldIdentifyingAssociatedEntity: keyof Pick<TFields, TSelectedFields>,
associatedEntityClass: IEntityClass<
TAssociatedFields,
TAssociatedID,
TViewerContext,
TAssociatedEntity,
TAssociatedPrivacyPolicy,
TAssociatedSelectedFields
>,
associatedEntityLookupByField: keyof Pick<TAssociatedFields, TAssociatedSelectedFields>,
queryContext: EntityQueryContext = this.entity
.getViewerContext()
.getViewerScopedEntityCompanionForClass(associatedEntityClass)
.getQueryContextProvider()
.getQueryContext()
): Promise<readonly Result<TAssociatedEntity>[]> {
const associatedFieldValue = this.entity.getField(fieldIdentifyingAssociatedEntity);
if (!associatedFieldValue) {
return [];
}
const loader = this.entity
.getViewerContext()
.getViewerScopedEntityCompanionForClass(associatedEntityClass)
.getLoaderFactory()
.forLoad(queryContext);
return await loader.loadManyByFieldEqualingAsync(
associatedEntityLookupByField,
associatedFieldValue as any
);
}
/** /**
* Load an associated entity by folding a sequence of {@link EntityLoadThroughDirective}. At each * Load an associated entity by folding a sequence of {@link EntityLoadThroughDirective}. At each
* fold step, load an associated entity identified by a field value of the current fold value. * fold step, load an associated entity identified by a field value of the current fold value.
@@ -252,29 +108,15 @@ export default class EntityAssociationLoader<
* @param queryContext - query context in which to perform the loads * @param queryContext - query context in which to perform the loads
*/ */
async loadAssociatedEntityThroughAsync< async loadAssociatedEntityThroughAsync<
TFields2, TEntityRoot extends ReadonlyEntity<TFields, TID, TViewerContext>,
TID2 extends NonNullable<TFields2[TSelectedFields2]>, TPrivacyPolicyRoot extends EntityPrivacyPolicy<TFields, TID, TViewerContext, TEntityRoot>,
TEntity2 extends ReadonlyEntity<TFields2, TID2, TViewerContext, TSelectedFields2>,
TPrivacyPolicy2 extends EntityPrivacyPolicy<
TFields2, TFields2,
TID2, TID2,
TViewerContext, TEntity2 extends ReadonlyEntity<TFields2, TID2, TViewerContext>,
TEntity2, TPrivacyPolicy2 extends EntityPrivacyPolicy<TFields2, TID2, TViewerContext, TEntity2>
TSelectedFields2
>,
TSelectedFields2 extends keyof TFields2 = keyof TFields2
>( >(
loadDirectives: [ loadDirectives: [
EntityLoadThroughDirective< EntityLoadThroughDirective<TViewerContext, TFields, TFields2, TID2, TEntity2, TPrivacyPolicy2>
TViewerContext,
TFields,
TFields2,
TID2,
TEntity2,
TPrivacyPolicy2,
TSelectedFields,
TSelectedFields2
>
], ],
queryContext?: EntityQueryContext queryContext?: EntityQueryContext
): Promise<Result<TEntity2> | null>; ): Promise<Result<TEntity2> | null>;
@@ -286,28 +128,16 @@ export default class EntityAssociationLoader<
* @param queryContext - query context in which to perform the loads * @param queryContext - query context in which to perform the loads
*/ */
async loadAssociatedEntityThroughAsync< async loadAssociatedEntityThroughAsync<
TFields2, TEntityRoot extends ReadonlyEntity<TFields, TID, TViewerContext>,
TID2 extends NonNullable<TFields2[TSelectedFields2]>, TPrivacyPolicyRoot extends EntityPrivacyPolicy<TFields, TID, TViewerContext, TEntityRoot>,
TEntity2 extends ReadonlyEntity<TFields2, TID2, TViewerContext, TSelectedFields2>,
TPrivacyPolicy2 extends EntityPrivacyPolicy<
TFields2, TFields2,
TID2, TID2,
TViewerContext, TEntity2 extends ReadonlyEntity<TFields2, TID2, TViewerContext>,
TEntity2, TPrivacyPolicy2 extends EntityPrivacyPolicy<TFields2, TID2, TViewerContext, TEntity2>,
TSelectedFields2
>,
TFields3,
TID3 extends NonNullable<TFields3[TSelectedFields3]>,
TEntity3 extends ReadonlyEntity<TFields3, TID3, TViewerContext, TSelectedFields3>,
TPrivacyPolicy3 extends EntityPrivacyPolicy<
TFields3, TFields3,
TID3, TID3,
TViewerContext, TEntity3 extends ReadonlyEntity<TFields3, TID3, TViewerContext>,
TEntity3, TPrivacyPolicy3 extends EntityPrivacyPolicy<TFields3, TID3, TViewerContext, TEntity3>
TSelectedFields3
>,
TSelectedFields2 extends keyof TFields2 = keyof TFields2,
TSelectedFields3 extends keyof TFields3 = keyof TFields3
>( >(
loadDirectives: [ loadDirectives: [
EntityLoadThroughDirective< EntityLoadThroughDirective<
@@ -316,9 +146,7 @@ export default class EntityAssociationLoader<
TFields2, TFields2,
TID2, TID2,
TEntity2, TEntity2,
TPrivacyPolicy2, TPrivacyPolicy2
TSelectedFields,
TSelectedFields2
>, >,
EntityLoadThroughDirective< EntityLoadThroughDirective<
TViewerContext, TViewerContext,
@@ -326,9 +154,7 @@ export default class EntityAssociationLoader<
TFields3, TFields3,
TID3, TID3,
TEntity3, TEntity3,
TPrivacyPolicy3, TPrivacyPolicy3
TSelectedFields2,
TSelectedFields3
> >
], ],
queryContext?: EntityQueryContext queryContext?: EntityQueryContext
@@ -341,39 +167,20 @@ export default class EntityAssociationLoader<
* @param queryContext - query context in which to perform the loads * @param queryContext - query context in which to perform the loads
*/ */
async loadAssociatedEntityThroughAsync< async loadAssociatedEntityThroughAsync<
TFields2, TEntityRoot extends ReadonlyEntity<TFields, TID, TViewerContext>,
TID2 extends NonNullable<TFields2[TSelectedFields2]>, TPrivacyPolicyRoot extends EntityPrivacyPolicy<TFields, TID, TViewerContext, TEntityRoot>,
TEntity2 extends ReadonlyEntity<TFields2, TID2, TViewerContext, TSelectedFields2>,
TPrivacyPolicy2 extends EntityPrivacyPolicy<
TFields2, TFields2,
TID2, TID2,
TViewerContext, TEntity2 extends ReadonlyEntity<TFields2, TID2, TViewerContext>,
TEntity2, TPrivacyPolicy2 extends EntityPrivacyPolicy<TFields2, TID2, TViewerContext, TEntity2>,
TSelectedFields2
>,
TFields3,
TID3 extends NonNullable<TFields3[TSelectedFields3]>,
TEntity3 extends ReadonlyEntity<TFields3, TID3, TViewerContext, TSelectedFields3>,
TPrivacyPolicy3 extends EntityPrivacyPolicy<
TFields3, TFields3,
TID3, TID3,
TViewerContext, TEntity3 extends ReadonlyEntity<TFields3, TID3, TViewerContext>,
TEntity3, TPrivacyPolicy3 extends EntityPrivacyPolicy<TFields3, TID3, TViewerContext, TEntity3>,
TSelectedFields3
>,
TFields4,
TID4 extends NonNullable<TFields4[TSelectedFields4]>,
TEntity4 extends ReadonlyEntity<TFields4, TID4, TViewerContext, TSelectedFields4>,
TPrivacyPolicy4 extends EntityPrivacyPolicy<
TFields4, TFields4,
TID4, TID4,
TViewerContext, TEntity4 extends ReadonlyEntity<TFields4, TID4, TViewerContext>,
TEntity4, TPrivacyPolicy4 extends EntityPrivacyPolicy<TFields4, TID4, TViewerContext, TEntity4>
TSelectedFields4
>,
TSelectedFields2 extends keyof TFields2 = keyof TFields2,
TSelectedFields3 extends keyof TFields3 = keyof TFields3,
TSelectedFields4 extends keyof TFields4 = keyof TFields4
>( >(
loadDirective: [ loadDirective: [
EntityLoadThroughDirective< EntityLoadThroughDirective<
@@ -382,9 +189,7 @@ export default class EntityAssociationLoader<
TFields2, TFields2,
TID2, TID2,
TEntity2, TEntity2,
TPrivacyPolicy2, TPrivacyPolicy2
TSelectedFields,
TSelectedFields2
>, >,
EntityLoadThroughDirective< EntityLoadThroughDirective<
TViewerContext, TViewerContext,
@@ -392,9 +197,7 @@ export default class EntityAssociationLoader<
TFields3, TFields3,
TID3, TID3,
TEntity3, TEntity3,
TPrivacyPolicy3, TPrivacyPolicy3
TSelectedFields2,
TSelectedFields3
>, >,
EntityLoadThroughDirective< EntityLoadThroughDirective<
TViewerContext, TViewerContext,
@@ -402,9 +205,7 @@ export default class EntityAssociationLoader<
TFields4, TFields4,
TID4, TID4,
TEntity4, TEntity4,
TPrivacyPolicy4, TPrivacyPolicy4
TSelectedFields3,
TSelectedFields4
> >
], ],
queryContext?: EntityQueryContext queryContext?: EntityQueryContext
@@ -417,22 +218,22 @@ export default class EntityAssociationLoader<
* @param queryContext - query context in which to perform the loads * @param queryContext - query context in which to perform the loads
*/ */
async loadAssociatedEntityThroughAsync( async loadAssociatedEntityThroughAsync(
loadDirectives: EntityLoadThroughDirective<TViewerContext, any, any, any, any, any, any, any>[], loadDirectives: EntityLoadThroughDirective<TViewerContext, any, any, any, any, any>[],
queryContext?: EntityQueryContext queryContext?: EntityQueryContext
): Promise<Result<ReadonlyEntity<any, any, any, any>> | null>; ): Promise<Result<ReadonlyEntity<any, any, any>> | null>;
async loadAssociatedEntityThroughAsync( async loadAssociatedEntityThroughAsync(
loadDirectives: EntityLoadThroughDirective<TViewerContext, any, any, any, any, any, any, any>[], loadDirectives: EntityLoadThroughDirective<TViewerContext, any, any, any, any, any>[],
queryContext?: EntityQueryContext queryContext?: EntityQueryContext
): Promise<Result<ReadonlyEntity<any, any, any, any>> | null> { ): Promise<Result<ReadonlyEntity<any, any, any>> | null> {
let currentEntity: ReadonlyEntity<any, any, any, any> = this.entity; let currentEntity: ReadonlyEntity<any, any, any> = this.entity;
for (const loadDirective of loadDirectives) { for (const loadDirective of loadDirectives) {
const { const {
associatedEntityClass, associatedEntityClass,
fieldIdentifyingAssociatedEntity, fieldIdentifyingAssociatedEntity,
associatedEntityLookupByField, associatedEntityLookupByField,
} = loadDirective; } = loadDirective;
let associatedEntityResult: Result<ReadonlyEntity<any, any, any, any>> | null; let associatedEntityResult: Result<ReadonlyEntity<any, any, any>> | null;
if (associatedEntityLookupByField) { if (associatedEntityLookupByField) {
associatedEntityResult = await currentEntity associatedEntityResult = await currentEntity
.associationLoader() .associationLoader()
@@ -443,19 +244,13 @@ export default class EntityAssociationLoader<
queryContext queryContext
); );
} else { } else {
const associatedEntityResultLocal = await currentEntity associatedEntityResult = await currentEntity
.associationLoader() .associationLoader()
.loadAssociatedEntityAsync( .loadAssociatedEntityAsync(
fieldIdentifyingAssociatedEntity, fieldIdentifyingAssociatedEntity,
associatedEntityClass, associatedEntityClass,
queryContext queryContext
); );
if (associatedEntityResultLocal.ok && associatedEntityResultLocal.value === null) {
associatedEntityResult = null;
} else {
associatedEntityResult = associatedEntityResultLocal;
}
} }
if (!associatedEntityResult) { if (!associatedEntityResult) {
@@ -477,23 +272,15 @@ export default class EntityAssociationLoader<
export interface EntityLoadThroughDirective< export interface EntityLoadThroughDirective<
TViewerContext extends ViewerContext, TViewerContext extends ViewerContext,
TFields, TFields,
TAssociatedFields,
TAssociatedID extends NonNullable<TAssociatedFields[TAssociatedSelectedFields]>,
TAssociatedEntity extends ReadonlyEntity<
TAssociatedFields, TAssociatedFields,
TAssociatedID, TAssociatedID,
TViewerContext, TAssociatedEntity extends ReadonlyEntity<TAssociatedFields, TAssociatedID, TViewerContext>,
TAssociatedSelectedFields
>,
TAssociatedPrivacyPolicy extends EntityPrivacyPolicy< TAssociatedPrivacyPolicy extends EntityPrivacyPolicy<
TAssociatedFields, TAssociatedFields,
TAssociatedID, TAssociatedID,
TViewerContext, TViewerContext,
TAssociatedEntity, TAssociatedEntity
TAssociatedSelectedFields >
>,
TSelectedFields extends keyof TFields = keyof TFields,
TAssociatedSelectedFields extends keyof TAssociatedFields = keyof TAssociatedFields
> { > {
/** /**
* Class of entity to load at this step. * Class of entity to load at this step.
@@ -503,18 +290,17 @@ export interface EntityLoadThroughDirective<
TAssociatedID, TAssociatedID,
TViewerContext, TViewerContext,
TAssociatedEntity, TAssociatedEntity,
TAssociatedPrivacyPolicy, TAssociatedPrivacyPolicy
TAssociatedSelectedFields
>; >;
/** /**
* Field of the current entity with which to load an instance of associatedEntityClass. * Field of the current entity with which to load an instance of associatedEntityClass.
*/ */
fieldIdentifyingAssociatedEntity: keyof Pick<TFields, TSelectedFields>; fieldIdentifyingAssociatedEntity: keyof TFields;
/** /**
* Field by which to load the instance of associatedEntityClass. If not provided, the * Field by which to load the instance of associatedEntityClass. If not provided, the
* associatedEntityClass instance is fetched by its ID. * associatedEntityClass instance is fetched by its ID.
*/ */
associatedEntityLookupByField?: keyof Pick<TAssociatedFields, TAssociatedSelectedFields>; associatedEntityLookupByField?: keyof TAssociatedFields;
} }
@@ -1,4 +1,5 @@
import EntityConfiguration from './EntityConfiguration'; import EntityConfiguration from './EntityConfiguration';
import { FieldTransformerMap } from './internal/EntityFieldTransformationUtils';
import { CacheLoadResult } from './internal/ReadThroughEntityCache'; import { CacheLoadResult } from './internal/ReadThroughEntityCache';
/** /**
@@ -8,6 +9,13 @@ import { CacheLoadResult } from './internal/ReadThroughEntityCache';
export default abstract class EntityCacheAdapter<TFields> { export default abstract class EntityCacheAdapter<TFields> {
constructor(protected readonly entityConfiguration: EntityConfiguration<TFields>) {} constructor(protected readonly entityConfiguration: EntityConfiguration<TFields>) {}
/**
* Transformer definitions for field types. Used to modify values as they are read from or written to
* the cache. Override in concrete subclasses to change transformation behavior.
* If a field type is not present in the map, then fields of that type will not be transformed.
*/
public abstract getFieldTransformerMap(): FieldTransformerMap;
/** /**
* Load many objects from cache. * Load many objects from cache.
* @param fieldName - object field being queried * @param fieldName - object field being queried
+43 -87
View File
@@ -1,13 +1,17 @@
import { IEntityClass } from './Entity'; import { IEntityClass } from './Entity';
import EntityCacheAdapter from './EntityCacheAdapter';
import EntityConfiguration from './EntityConfiguration';
import EntityDatabaseAdapter from './EntityDatabaseAdapter';
import EntityLoaderFactory from './EntityLoaderFactory'; import EntityLoaderFactory from './EntityLoaderFactory';
import EntityMutationTriggerConfiguration from './EntityMutationTriggerConfiguration';
import EntityMutationValidator from './EntityMutationValidator';
import EntityMutatorFactory from './EntityMutatorFactory'; import EntityMutatorFactory from './EntityMutatorFactory';
import EntityPrivacyPolicy from './EntityPrivacyPolicy'; import EntityPrivacyPolicy from './EntityPrivacyPolicy';
import EntityQueryContextProvider from './EntityQueryContextProvider'; import IEntityCacheAdapterProvider from './IEntityCacheAdapterProvider';
import IEntityDatabaseAdapterProvider from './IEntityDatabaseAdapterProvider';
import IEntityQueryContextProvider from './IEntityQueryContextProvider';
import ReadonlyEntity from './ReadonlyEntity'; import ReadonlyEntity from './ReadonlyEntity';
import ViewerContext from './ViewerContext'; import ViewerContext from './ViewerContext';
import EntityTableDataCoordinator from './internal/EntityTableDataCoordinator'; import EntityDataManager from './internal/EntityDataManager';
import ReadThroughEntityCache from './internal/ReadThroughEntityCache';
import IEntityMetricsAdapter from './metrics/IEntityMetricsAdapter'; import IEntityMetricsAdapter from './metrics/IEntityMetricsAdapter';
export interface IPrivacyPolicyClass<TPrivacyPolicy> { export interface IPrivacyPolicyClass<TPrivacyPolicy> {
@@ -18,123 +22,75 @@ export interface IPrivacyPolicyClass<TPrivacyPolicy> {
* Composition root responsible for orchestrating setup of Entity mutators and loaders. * Composition root responsible for orchestrating setup of Entity mutators and loaders.
*/ */
export default class EntityCompanion< export default class EntityCompanion<
TFields,
TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
TPrivacyPolicy extends EntityPrivacyPolicy<
TFields, TFields,
TID, TID,
TViewerContext, TViewerContext extends ViewerContext,
TEntity, TEntity extends ReadonlyEntity<TFields, TID, TViewerContext>,
TSelectedFields TPrivacyPolicy extends EntityPrivacyPolicy<TFields, TID, TViewerContext, TEntity>
>,
TSelectedFields extends keyof TFields
> { > {
// defined as properties so that they can be accessed from tests
private readonly databaseAdapter: EntityDatabaseAdapter<TFields>;
private readonly cacheAdapter: EntityCacheAdapter<TFields>;
private readonly entityLoaderFactory: EntityLoaderFactory< private readonly entityLoaderFactory: EntityLoaderFactory<
TFields, TFields,
TID, TID,
TViewerContext, TViewerContext,
TEntity, TEntity,
TPrivacyPolicy, TPrivacyPolicy
TSelectedFields
>; >;
private readonly entityMutatorFactory: EntityMutatorFactory< private readonly entityMutatorFactory: EntityMutatorFactory<
TFields, TFields,
TID, TID,
TViewerContext, TViewerContext,
TEntity, TEntity,
TPrivacyPolicy, TPrivacyPolicy
TSelectedFields
>; >;
constructor( constructor(
entityClass: IEntityClass< entityClass: IEntityClass<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy>,
TFields, entityConfiguration: EntityConfiguration<TFields>,
TID, databaseAdapterProvider: IEntityDatabaseAdapterProvider,
TViewerContext, cacheAdapterProvider: IEntityCacheAdapterProvider,
TEntity,
TPrivacyPolicy,
TSelectedFields
>,
private readonly tableDataCoordinator: EntityTableDataCoordinator<TFields>,
PrivacyPolicyClass: IPrivacyPolicyClass<TPrivacyPolicy>, PrivacyPolicyClass: IPrivacyPolicyClass<TPrivacyPolicy>,
mutationValidators: EntityMutationValidator< private readonly queryContextProvider: IEntityQueryContextProvider,
TFields,
TID, metricsAdapter: IEntityMetricsAdapter
TViewerContext,
TEntity,
TSelectedFields
>[],
mutationTriggers: EntityMutationTriggerConfiguration<
TFields,
TID,
TViewerContext,
TEntity,
TSelectedFields
>,
private readonly metricsAdapter: IEntityMetricsAdapter
) { ) {
const privacyPolicy = new PrivacyPolicyClass(); this.databaseAdapter = databaseAdapterProvider.getDatabaseAdapter(entityConfiguration);
this.entityLoaderFactory = new EntityLoaderFactory< this.cacheAdapter = cacheAdapterProvider.getCacheAdapter(entityConfiguration);
TFields, const dataManager = new EntityDataManager(
TID, this.databaseAdapter,
TViewerContext, new ReadThroughEntityCache(entityConfiguration, this.cacheAdapter),
TEntity, queryContextProvider,
TPrivacyPolicy,
TSelectedFields
>(
tableDataCoordinator.entityConfiguration,
entityClass,
privacyPolicy,
tableDataCoordinator.dataManager,
metricsAdapter metricsAdapter
); );
const privacyPolicy = new PrivacyPolicyClass();
this.entityLoaderFactory = new EntityLoaderFactory(
entityConfiguration,
entityClass,
privacyPolicy,
dataManager
);
this.entityMutatorFactory = new EntityMutatorFactory( this.entityMutatorFactory = new EntityMutatorFactory(
tableDataCoordinator.entityConfiguration, entityConfiguration,
entityClass, entityClass,
privacyPolicy, privacyPolicy,
mutationValidators,
mutationTriggers,
this.entityLoaderFactory, this.entityLoaderFactory,
tableDataCoordinator.databaseAdapter, this.databaseAdapter,
metricsAdapter metricsAdapter
); );
} }
getLoaderFactory(): EntityLoaderFactory< getLoaderFactory(): EntityLoaderFactory<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy> {
TFields,
TID,
TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
> {
return this.entityLoaderFactory; return this.entityLoaderFactory;
} }
getMutatorFactory(): EntityMutatorFactory< getMutatorFactory(): EntityMutatorFactory<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy> {
TFields,
TID,
TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
> {
return this.entityMutatorFactory; return this.entityMutatorFactory;
} }
/** getQueryContextProvider(): IEntityQueryContextProvider {
* Get the query context provider for this entity. return this.queryContextProvider;
*/
getQueryContextProvider(): EntityQueryContextProvider {
return this.tableDataCoordinator.getQueryContextProvider();
}
/**
* Get the {@link IEntityMetricsAdapter} for this companion.
*/
getMetricsAdapter(): IEntityMetricsAdapter {
return this.metricsAdapter;
} }
} }
+46 -188
View File
@@ -1,31 +1,37 @@
import invariant from 'invariant';
import { IEntityClass } from './Entity'; import { IEntityClass } from './Entity';
import EntityCompanion, { IPrivacyPolicyClass } from './EntityCompanion'; import EntityCompanion, { IPrivacyPolicyClass } from './EntityCompanion';
import EntityConfiguration from './EntityConfiguration'; import EntityConfiguration from './EntityConfiguration';
import EntityMutationTriggerConfiguration from './EntityMutationTriggerConfiguration';
import EntityMutationValidator from './EntityMutationValidator';
import EntityPrivacyPolicy from './EntityPrivacyPolicy'; import EntityPrivacyPolicy from './EntityPrivacyPolicy';
import EntityQueryContextProvider from './EntityQueryContextProvider';
import IEntityCacheAdapterProvider from './IEntityCacheAdapterProvider'; import IEntityCacheAdapterProvider from './IEntityCacheAdapterProvider';
import IEntityDatabaseAdapterProvider from './IEntityDatabaseAdapterProvider'; import IEntityDatabaseAdapterProvider from './IEntityDatabaseAdapterProvider';
import IEntityQueryContextProvider from './IEntityQueryContextProvider';
import ReadonlyEntity from './ReadonlyEntity'; import ReadonlyEntity from './ReadonlyEntity';
import ViewerContext from './ViewerContext'; import ViewerContext from './ViewerContext';
import EntityTableDataCoordinator from './internal/EntityTableDataCoordinator';
import IEntityMetricsAdapter from './metrics/IEntityMetricsAdapter'; import IEntityMetricsAdapter from './metrics/IEntityMetricsAdapter';
import { computeIfAbsent } from './utils/collections/maps'; import { computeIfAbsent } from './utils/collections/maps';
/** /**
* Backing database and transaction type for an entity. The definitions and implementations * Backing database and transaction type for an entity. The definitions and implementations
* are provided by injection in the root EntityCompanionProvider to allow for mocking and sharing. * are provided by injection in the root EntityCompanionProvider to allow for mocking and sharing.
*
* Note: this enum will likely be modified soon to be more flexible.
*/ */
export type DatabaseAdapterFlavor = string; export enum DatabaseAdapterFlavor {
/**
* Knex postgres adapter, using postgres table and postgres transactions.
*/
POSTGRES = 'postgres',
}
/** /**
* Cache system for an entity. The definitions and implementations are provided by injection * Cache system for an entity. The definitions and implementations are provided by injection
* in the root EntityCompanionProvider to allow for mocking and sharing. * in the root EntityCompanionProvider to allow for mocking and sharing.
*
* Note: this enum will likely be modified soon to be more flexible.
*/ */
export type CacheAdapterFlavor = string; export enum CacheAdapterFlavor {
REDIS = 'redis',
}
/** /**
* Defines a set interfaces for a entity database adapter flavor. An entity that uses a flavor * Defines a set interfaces for a entity database adapter flavor. An entity that uses a flavor
@@ -34,7 +40,7 @@ export type CacheAdapterFlavor = string;
*/ */
export interface DatabaseAdapterFlavorDefinition { export interface DatabaseAdapterFlavorDefinition {
adapterProvider: IEntityDatabaseAdapterProvider; adapterProvider: IEntityDatabaseAdapterProvider;
queryContextProvider: EntityQueryContextProvider; queryContextProvider: IEntityQueryContextProvider;
} }
/** /**
@@ -49,107 +55,18 @@ export interface CacheAdapterFlavorDefinition {
* Definition for constructing a companion for an entity. Defines the core set of objects * Definition for constructing a companion for an entity. Defines the core set of objects
* used to power the entity framework for a particular type of entity. * used to power the entity framework for a particular type of entity.
*/ */
export class EntityCompanionDefinition< export interface EntityCompanionDefinition<
TFields, TFields,
TID extends NonNullable<TFields[TSelectedFields]>, TID,
TViewerContext extends ViewerContext, TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>, TEntity extends ReadonlyEntity<TFields, TID, TViewerContext>,
TPrivacyPolicy extends EntityPrivacyPolicy< TPrivacyPolicy extends EntityPrivacyPolicy<TFields, TID, TViewerContext, TEntity>
TFields,
TID,
TViewerContext,
TEntity,
TSelectedFields
>,
TSelectedFields extends keyof TFields = keyof TFields
> { > {
readonly entityClass: IEntityClass< entityClass: IEntityClass<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy>;
TFields,
TID,
TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
>;
readonly entityConfiguration: EntityConfiguration<TFields>;
readonly privacyPolicyClass: IPrivacyPolicyClass<TPrivacyPolicy>;
readonly mutationValidators: () => EntityMutationValidator<
TFields,
TID,
TViewerContext,
TEntity,
TSelectedFields
>[];
readonly mutationTriggers: () => EntityMutationTriggerConfiguration<
TFields,
TID,
TViewerContext,
TEntity,
TSelectedFields
>;
readonly entitySelectedFields: TSelectedFields[];
constructor({
entityClass,
entityConfiguration,
privacyPolicyClass,
mutationValidators = () => [],
mutationTriggers = () => ({}),
entitySelectedFields = Array.from(entityConfiguration.schema.keys()) as TSelectedFields[],
}: {
/**
* The concrete Entity class for which this is the definition.
*/
entityClass: IEntityClass<
TFields,
TID,
TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
>;
/**
* The {@link EntityConfiguration} for this entity.
*/
entityConfiguration: EntityConfiguration<TFields>; entityConfiguration: EntityConfiguration<TFields>;
/** databaseAdaptorFlavor: DatabaseAdapterFlavor;
* The {@link EntityPrivacyPolicy} class for this entity. cacheAdaptorFlavor: CacheAdapterFlavor;
*/
privacyPolicyClass: IPrivacyPolicyClass<TPrivacyPolicy>; privacyPolicyClass: IPrivacyPolicyClass<TPrivacyPolicy>;
/**
* An optional list of {@link EntityMutationValidator} for this entity.
*/
mutationValidators?: () => EntityMutationValidator<
TFields,
TID,
TViewerContext,
TEntity,
TSelectedFields
>[];
/**
* An optional list of {@link EntityMutationTrigger} for this entity.
*/
mutationTriggers?: () => EntityMutationTriggerConfiguration<
TFields,
TID,
TViewerContext,
TEntity,
TSelectedFields
>;
/**
* An optional subset of fields defined in the {@link EntityConfiguration} which belong to this entity.
* For use when multiple types of entities are backed by a single table ({@link EntityConfiguration}) yet
* only expose a subset of the fields.
*/
entitySelectedFields?: TSelectedFields[];
}) {
this.entityClass = entityClass;
this.entityConfiguration = entityConfiguration;
this.privacyPolicyClass = privacyPolicyClass;
this.mutationValidators = mutationValidators;
this.mutationTriggers = mutationTriggers;
this.entitySelectedFields = entitySelectedFields;
}
} }
/** /**
@@ -162,10 +79,10 @@ export class EntityCompanionDefinition<
* {@link EntityCompanion} for each type of {@link Entity}. * {@link EntityCompanion} for each type of {@link Entity}.
*/ */
export default class EntityCompanionProvider { export default class EntityCompanionProvider {
private readonly companionMap: Map<string, EntityCompanion<any, any, any, any, any, any>> = private readonly entityCompanionMap: Map<
new Map(); string,
private readonly tableDataCoordinatorMap: Map<string, EntityTableDataCoordinator<any>> = EntityCompanion<any, any, any, any, any>
new Map(); > = new Map();
/** /**
* Instantiate an Entity framework. * Instantiate an Entity framework.
@@ -174,12 +91,9 @@ export default class EntityCompanionProvider {
* @param cacheAdapterFlavors - Cache adapter configurations for this instance * @param cacheAdapterFlavors - Cache adapter configurations for this instance
*/ */
constructor( constructor(
public readonly metricsAdapter: IEntityMetricsAdapter, private metricsAdapter: IEntityMetricsAdapter,
private databaseAdapterFlavors: ReadonlyMap< private databaseAdapterFlavors: Record<DatabaseAdapterFlavor, DatabaseAdapterFlavorDefinition>,
DatabaseAdapterFlavor, private cacheAdapterFlavors: Record<CacheAdapterFlavor, CacheAdapterFlavorDefinition>
DatabaseAdapterFlavorDefinition
>,
private cacheAdapterFlavors: ReadonlyMap<CacheAdapterFlavor, CacheAdapterFlavorDefinition>
) {} ) {}
/** /**
@@ -191,92 +105,36 @@ export default class EntityCompanionProvider {
*/ */
getCompanionForEntity< getCompanionForEntity<
TFields, TFields,
TID extends NonNullable<TFields[TSelectedFields]>, TID,
TViewerContext extends ViewerContext, TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>, TEntity extends ReadonlyEntity<TFields, TID, TViewerContext>,
TPrivacyPolicy extends EntityPrivacyPolicy< TPrivacyPolicy extends EntityPrivacyPolicy<TFields, TID, TViewerContext, TEntity>
TFields,
TID,
TViewerContext,
TEntity,
TSelectedFields
>,
TSelectedFields extends keyof TFields
>( >(
entityClass: IEntityClass< entityClass: IEntityClass<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy>,
TFields,
TID,
TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
>,
entityCompanionDefinition: EntityCompanionDefinition< entityCompanionDefinition: EntityCompanionDefinition<
TFields, TFields,
TID, TID,
TViewerContext, TViewerContext,
TEntity, TEntity,
TPrivacyPolicy, TPrivacyPolicy
TSelectedFields
> >
): EntityCompanion<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy, TSelectedFields> { ): EntityCompanion<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy> {
const tableDataCoordinator = this.getTableDataCoordinatorForEntity( return computeIfAbsent(this.entityCompanionMap, entityClass.name, () => {
entityCompanionDefinition.entityConfiguration, const entityDatabaseAdapterFlavor = this.databaseAdapterFlavors[
entityClass.name entityCompanionDefinition.databaseAdaptorFlavor
); ];
return computeIfAbsent(this.companionMap, entityClass.name, () => { const entityCacheAdapterFlavor = this.cacheAdapterFlavors[
entityCompanionDefinition.cacheAdaptorFlavor
];
return new EntityCompanion( return new EntityCompanion(
entityCompanionDefinition.entityClass, entityCompanionDefinition.entityClass,
tableDataCoordinator, entityCompanionDefinition.entityConfiguration,
entityDatabaseAdapterFlavor.adapterProvider,
entityCacheAdapterFlavor.cacheAdapterProvider,
entityCompanionDefinition.privacyPolicyClass, entityCompanionDefinition.privacyPolicyClass,
entityCompanionDefinition.mutationValidators(), entityDatabaseAdapterFlavor.queryContextProvider,
entityCompanionDefinition.mutationTriggers(),
this.metricsAdapter this.metricsAdapter
); );
}); });
} }
getQueryContextProviderForDatabaseAdaptorFlavor(
databaseAdapterFlavor: DatabaseAdapterFlavor
): EntityQueryContextProvider {
const entityDatabaseAdapterFlavor = this.databaseAdapterFlavors.get(databaseAdapterFlavor);
invariant(
entityDatabaseAdapterFlavor,
`No database adaptor configuration found for flavor: ${databaseAdapterFlavor}`
);
return entityDatabaseAdapterFlavor.queryContextProvider;
}
private getTableDataCoordinatorForEntity<TFields>(
entityConfiguration: EntityConfiguration<TFields>,
entityClassName: string
): EntityTableDataCoordinator<TFields> {
return computeIfAbsent(this.tableDataCoordinatorMap, entityConfiguration.tableName, () => {
const entityDatabaseAdapterFlavor = this.databaseAdapterFlavors.get(
entityConfiguration.databaseAdapterFlavor
);
invariant(
entityDatabaseAdapterFlavor,
`No database adaptor configuration found for flavor: ${entityConfiguration.databaseAdapterFlavor}`
);
const entityCacheAdapterFlavor = this.cacheAdapterFlavors.get(
entityConfiguration.cacheAdapterFlavor
);
invariant(
entityCacheAdapterFlavor,
`No cache adaptor configuration found for flavor: ${entityConfiguration.cacheAdapterFlavor}`
);
return new EntityTableDataCoordinator(
entityConfiguration,
entityDatabaseAdapterFlavor.adapterProvider,
entityCacheAdapterFlavor.cacheAdapterProvider,
entityDatabaseAdapterFlavor.queryContextProvider,
this.metricsAdapter,
entityClassName
);
});
}
} }
+2 -46
View File
@@ -1,11 +1,9 @@
import { IEntityClass } from './Entity'; import { EntityFieldDefinition } from './EntityFields';
import { DatabaseAdapterFlavor, CacheAdapterFlavor } from './EntityCompanionProvider';
import { EntityFieldDefinition } from './EntityFieldDefinition';
import { mapMap, invertMap, reduceMap } from './utils/collections/maps'; import { mapMap, invertMap, reduceMap } from './utils/collections/maps';
/** /**
* The data storage configuration for a type of Entity. Contains information relating to IDs, * The data storage configuration for a type of Entity. Contains information relating to IDs,
* cachable fields, field mappings, and types of cache and database adapter. * cachable fields, and field mappings.
*/ */
export default class EntityConfiguration<TFields> { export default class EntityConfiguration<TFields> {
readonly idField: keyof TFields; readonly idField: keyof TFields;
@@ -13,66 +11,24 @@ export default class EntityConfiguration<TFields> {
readonly cacheableKeys: ReadonlySet<keyof TFields>; readonly cacheableKeys: ReadonlySet<keyof TFields>;
readonly cacheKeyVersion: number; readonly cacheKeyVersion: number;
readonly getInboundEdges: () => IEntityClass<any, any, any, any, any, any>[];
readonly schema: ReadonlyMap<keyof TFields, EntityFieldDefinition<any>>; readonly schema: ReadonlyMap<keyof TFields, EntityFieldDefinition<any>>;
readonly entityToDBFieldsKeyMapping: ReadonlyMap<keyof TFields, string>; readonly entityToDBFieldsKeyMapping: ReadonlyMap<keyof TFields, string>;
readonly dbToEntityFieldsKeyMapping: ReadonlyMap<string, keyof TFields>; readonly dbToEntityFieldsKeyMapping: ReadonlyMap<string, keyof TFields>;
readonly databaseAdapterFlavor: DatabaseAdapterFlavor;
readonly cacheAdapterFlavor: CacheAdapterFlavor;
constructor({ constructor({
idField, idField,
tableName, tableName,
schema, schema,
getInboundEdges = () => [],
cacheKeyVersion = 0, cacheKeyVersion = 0,
databaseAdapterFlavor,
cacheAdapterFlavor,
}: { }: {
/**
* The field used to identify this entity. Must be a unique field in the table.
*/
idField: keyof TFields; idField: keyof TFields;
/**
* The name of the table where entities of this type are stored.
*/
tableName: string; tableName: string;
/**
* Map from each entity field to an {@link EntityFieldDefinition} specifying information about the field.
*/
schema: Record<keyof TFields, EntityFieldDefinition<any>>; schema: Record<keyof TFields, EntityFieldDefinition<any>>;
/**
* List of other entity types that reference this type in {@link EntityFieldDefinition} associations.
*/
getInboundEdges?: () => IEntityClass<any, any, any, any, any, any>[];
/**
* Cache key version for this entity type. Should be bumped when a field is added to, removed from, or changed
* in this entity and the underlying database table.
*/
cacheKeyVersion?: number; cacheKeyVersion?: number;
/**
* Backing database and transaction type for this entity.
*/
databaseAdapterFlavor: DatabaseAdapterFlavor;
/**
* Cache system for this entity.
*/
cacheAdapterFlavor: CacheAdapterFlavor;
}) { }) {
this.idField = idField; this.idField = idField;
this.tableName = tableName; this.tableName = tableName;
this.cacheKeyVersion = cacheKeyVersion; this.cacheKeyVersion = cacheKeyVersion;
this.databaseAdapterFlavor = databaseAdapterFlavor;
this.cacheAdapterFlavor = cacheAdapterFlavor;
this.getInboundEdges = getInboundEdges;
// external schema is a Record to typecheck that all fields have FieldDefinitions, // external schema is a Record to typecheck that all fields have FieldDefinitions,
// but internally the most useful representation is a map for lookups // but internally the most useful representation is a map for lookups
+13 -14
View File
@@ -14,17 +14,14 @@ interface SingleValueFieldEqualityCondition<TFields, N extends keyof TFields = k
interface MultiValueFieldEqualityCondition<TFields, N extends keyof TFields = keyof TFields> { interface MultiValueFieldEqualityCondition<TFields, N extends keyof TFields = keyof TFields> {
fieldName: N; fieldName: N;
fieldValues: readonly TFields[N][]; fieldValues: TFields[N][];
} }
export type FieldEqualityCondition<TFields, N extends keyof TFields = keyof TFields> = export type FieldEqualityCondition<TFields, N extends keyof TFields = keyof TFields> =
| SingleValueFieldEqualityCondition<TFields, N> | SingleValueFieldEqualityCondition<TFields, N>
| MultiValueFieldEqualityCondition<TFields, N>; | MultiValueFieldEqualityCondition<TFields, N>;
export function isSingleValueFieldEqualityCondition< function isSingleValueFieldEqualityCondition<TFields, N extends keyof TFields = keyof TFields>(
TFields,
N extends keyof TFields = keyof TFields
>(
condition: FieldEqualityCondition<TFields, N> condition: FieldEqualityCondition<TFields, N>
): condition is SingleValueFieldEqualityCondition<TFields, N> { ): condition is SingleValueFieldEqualityCondition<TFields, N> {
return (condition as SingleValueFieldEqualityCondition<TFields, N>).fieldValue !== undefined; return (condition as SingleValueFieldEqualityCondition<TFields, N>).fieldValue !== undefined;
@@ -37,7 +34,7 @@ export interface TableFieldSingleValueEqualityCondition {
export interface TableFieldMultiValueEqualityCondition { export interface TableFieldMultiValueEqualityCondition {
tableField: string; tableField: string;
tableValues: readonly any[]; tableValues: any[];
} }
export enum OrderByOrdering { export enum OrderByOrdering {
@@ -69,14 +66,12 @@ export interface QuerySelectionModifiers<TFields> {
} }
export interface TableQuerySelectionModifiers { export interface TableQuerySelectionModifiers {
orderBy: orderBy?: {
| {
columnName: string; columnName: string;
order: OrderByOrdering; order: OrderByOrdering;
}[] }[];
| undefined; offset?: number;
offset: number | undefined; limit?: number;
limit: number | undefined;
} }
/** /**
@@ -265,7 +260,7 @@ export default abstract class EntityDatabaseAdapter<TFields> {
return transformDatabaseObjectToFields( return transformDatabaseObjectToFields(
this.entityConfiguration, this.entityConfiguration,
this.fieldTransformerMap, this.fieldTransformerMap,
results[0]! results[0]
); );
} }
@@ -317,7 +312,7 @@ export default abstract class EntityDatabaseAdapter<TFields> {
return transformDatabaseObjectToFields( return transformDatabaseObjectToFields(
this.entityConfiguration, this.entityConfiguration,
this.fieldTransformerMap, this.fieldTransformerMap,
results[0]! results[0]
); );
} }
@@ -353,6 +348,10 @@ export default abstract class EntityDatabaseAdapter<TFields> {
throw new Error( throw new Error(
`Excessive deletions from database adapter delete: ${this.entityConfiguration.tableName}(id = ${id})` `Excessive deletions from database adapter delete: ${this.entityConfiguration.tableName}(id = ${id})`
); );
} else if (numDeleted === 0) {
throw new Error(
`No deletions from database adapter delete: ${this.entityConfiguration.tableName}(id = ${id})`
);
} }
} }
+46
View File
@@ -0,0 +1,46 @@
import ES6Error from 'es6-error';
import { IEntityClass } from './Entity';
import EntityPrivacyPolicy, { EntityAuthorizationAction } from './EntityPrivacyPolicy';
import ReadonlyEntity from './ReadonlyEntity';
import ViewerContext from './ViewerContext';
export abstract class EntityError extends ES6Error {}
export class EntityNotFoundError<
TFields,
TID,
TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext>,
TPrivacyPolicy extends EntityPrivacyPolicy<TFields, TID, TViewerContext, TEntity>,
N extends keyof TFields
> extends EntityError {
constructor(
entityClass: IEntityClass<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy>,
fieldName: N,
fieldValue: TFields[N]
) {
super(`Entity not found: ${entityClass.name} (${fieldName} = ${fieldValue})`);
}
}
export class EntityNotAuthorizedError<
TFields,
TID,
TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext>
> extends EntityError {
public readonly entityClassName: string;
constructor(
entity: TEntity,
viewerContext: TViewerContext,
action: EntityAuthorizationAction,
ruleIndex: number
) {
super(
`Entity not authorized: ${entity} (viewer = ${viewerContext}, action = ${EntityAuthorizationAction[action]}, ruleIndex = ${ruleIndex})`
);
this.entityClassName = entity.constructor.name;
}
}
@@ -1,124 +0,0 @@
import { IEntityClass } from './Entity';
import EntityPrivacyPolicy from './EntityPrivacyPolicy';
import ReadonlyEntity from './ReadonlyEntity';
import ViewerContext from './ViewerContext';
export enum EntityEdgeDeletionBehavior {
/**
* Default. Invalidate the cache for all entities that reference the entity
* being deleted through this field, and transitively run deletions on those entities.
* This is most useful when the database itself expresses foreign
* keys and cascading deletes or set nulls and the entity framework just needs to
* be kept consistent with the state of the database.
*/
CASCADE_DELETE_INVALIDATE_CACHE,
/**
* Delete all entities that reference the entity being deleted through this field. This is very similar
* to SQL `ON DELETE CASCADE` but is done in the Entity framework instead of at the underlying level.
* This will also invalidate the cached referencing entities.
*/
CASCADE_DELETE,
/**
* Set this field to null when the referenced entity is deleted. This is very similar
* to SQL `ON DELETE SET NULL` but is done in the Entity framework instead of at the underlying level.
* This will also invalidate the cached referencing entities.
*/
SET_NULL,
}
export interface EntityAssociationDefinition<
TViewerContext extends ViewerContext,
TAssociatedFields,
TAssociatedID extends NonNullable<TAssociatedFields[TAssociatedSelectedFields]>,
TAssociatedEntity extends ReadonlyEntity<
TAssociatedFields,
TAssociatedID,
TViewerContext,
TAssociatedSelectedFields
>,
TAssociatedPrivacyPolicy extends EntityPrivacyPolicy<
TAssociatedFields,
TAssociatedID,
TViewerContext,
TAssociatedEntity,
TAssociatedSelectedFields
>,
TAssociatedSelectedFields extends keyof TAssociatedFields = keyof TAssociatedFields
> {
/**
* Class of entity on the other end of this edge.
*/
getAssociatedEntityClass: () => IEntityClass<
TAssociatedFields,
TAssociatedID,
TViewerContext,
TAssociatedEntity,
TAssociatedPrivacyPolicy,
TAssociatedSelectedFields
>;
/**
* Field by which to load the instance of associatedEntityClass. If not provided, the
* associatedEntityClass instance is fetched by its ID.
*/
associatedEntityLookupByField?: keyof TAssociatedFields;
/**
* What action to perform on the current entity when the entity on the referencing end of
* this edge is deleted.
*
* @remarks
* The entity framework doesn't prescribe a one-size-fits-all solution for referential
* integrity; instead it exposes mechanisms that supports both database foreign key constraints
* and implicit entity-specified foreign keys. Choosing which approach to use often depends on
* application requirements, and sometimes even a mix-and-match is the right choice.
*
* - If referential integrity is critical to your application, database foreign key constraints
* combined with {@link EntityEdgeDeletionBehavior.CASACDE_DELETE_INVALIDATE_CACHE} are recommended.
* - If the database being used doesn't support foreign keys, then using the entity framework for referential
* integrity is recommended.
*/
edgeDeletionBehavior?: EntityEdgeDeletionBehavior;
}
export abstract class EntityFieldDefinition<T> {
readonly columnName: string;
readonly cache: boolean;
readonly association: EntityAssociationDefinition<any, any, any, any, any, any> | undefined;
/**
*
* @param columnName - Column name in the database.
* @param cache - Whether or not to cache loaded instances of the entity by this field. The column name is
* used to derive a cache key for the cache entry. If true, this column must be able uniquely
* identify the entity.
*/
constructor({
columnName,
cache = false,
association,
}: {
columnName: string;
cache?: boolean;
association?: EntityAssociationDefinition<any, any, any, any, any, any>;
}) {
this.columnName = columnName;
this.cache = cache;
this.association = association;
}
/**
* Validates input value for a field of this type. Null and undefined are considered valid by default. This is used for things like:
* - EntityLoader.loadByFieldValue - to ensure the value being loaded by is a valid value
* - EntityMutator.setField - to ensure the value being set is a valid value
*/
public validateInputValue(value: T | null | undefined): boolean {
if (value === null || value === undefined) {
return true;
}
return this.validateInputValueInternal(value);
}
protected abstract validateInputValueInternal(value: T): boolean;
}
+43 -57
View File
@@ -1,62 +1,48 @@
import { validate as validateUUID } from 'uuid'; export interface FieldValidator<T> {
/**
* Validation to apply for this field before an entity is created or updated.
* Only applies to fields changed during a mutation; any existing fields will
* not be re-validated in order to allow for changing of validator logic.
*/
write?: (value: T) => Promise<void>;
}
import { EntityFieldDefinition } from './EntityFieldDefinition'; export abstract class EntityFieldDefinition<T> {
readonly columnName: string;
readonly cache: boolean;
readonly validator: FieldValidator<T>;
export class StringField extends EntityFieldDefinition<string> { /**
protected validateInputValueInternal(value: string): boolean { *
return typeof value === 'string'; * @param columnName - Column name in the database.
} * @param cache - Whether or not to cache loaded instances of the entity by this field. The column name is
} * used to derive a cache key for the cache entry. If true, this column must be able uniquely
export class UUIDField extends StringField { * identify the entity.
protected override validateInputValueInternal(value: string): boolean { */
return validateUUID(value); constructor({
} columnName,
} cache = false,
export class DateField extends EntityFieldDefinition<Date> { validator = {},
protected validateInputValueInternal(value: Date): boolean { }: {
return value instanceof Date; columnName: string;
} cache?: boolean;
} validator?: FieldValidator<T>;
export class BooleanField extends EntityFieldDefinition<boolean> { }) {
protected validateInputValueInternal(value: boolean): boolean { this.columnName = columnName;
return typeof value === 'boolean'; this.cache = cache;
this.validator = validator;
} }
} }
export class IntField extends EntityFieldDefinition<number> { export class StringField extends EntityFieldDefinition<string> {}
protected validateInputValueInternal(value: number): boolean { export class UUIDField extends StringField {}
return typeof value === 'number' && Number.isInteger(value); export class DateField extends EntityFieldDefinition<Date> {}
} export class BooleanField extends EntityFieldDefinition<boolean> {}
} export class NumberField extends EntityFieldDefinition<number> {}
export class StringArrayField extends EntityFieldDefinition<string[]> {}
export class FloatField extends EntityFieldDefinition<number> { export class JSONObjectField<T extends object> extends EntityFieldDefinition<T> {}
protected validateInputValueInternal(value: number): boolean { export class EnumField<T> extends EntityFieldDefinition<T> {}
return typeof value === 'number'; export class JSONArrayField<T> extends EntityFieldDefinition<T[]> {}
} export class MaybeJSONArrayField<TArray, TNotArray> extends EntityFieldDefinition<
} TArray[] | TNotArray
> {}
export class StringArrayField extends EntityFieldDefinition<string[]> {
protected validateInputValueInternal(value: string[]): boolean {
return Array.isArray(value) && value.every((subValue) => typeof subValue === 'string');
}
}
export class JSONObjectField extends EntityFieldDefinition<object> {
protected validateInputValueInternal(value: object): boolean {
return typeof value === 'object' && !Array.isArray(value);
}
}
export class EnumField extends EntityFieldDefinition<string | number> {
protected validateInputValueInternal(value: string | number): boolean {
return typeof value === 'number' || typeof value === 'string';
}
}
export class JSONArrayField extends EntityFieldDefinition<any[]> {
protected validateInputValueInternal(value: any[]): boolean {
return Array.isArray(value);
}
}
export class MaybeJSONArrayField extends EntityFieldDefinition<any | any[]> {
protected validateInputValueInternal(_value: any): boolean {
return true;
}
}
+55 -151
View File
@@ -4,19 +4,13 @@ import invariant from 'invariant';
import EnforcingEntityLoader from './EnforcingEntityLoader'; import EnforcingEntityLoader from './EnforcingEntityLoader';
import { IEntityClass } from './Entity'; import { IEntityClass } from './Entity';
import EntityConfiguration from './EntityConfiguration'; import EntityConfiguration from './EntityConfiguration';
import { import { FieldEqualityCondition, QuerySelectionModifiers } from './EntityDatabaseAdapter';
FieldEqualityCondition, import { EntityNotFoundError } from './EntityErrors';
QuerySelectionModifiers,
isSingleValueFieldEqualityCondition,
} from './EntityDatabaseAdapter';
import EntityPrivacyPolicy from './EntityPrivacyPolicy'; import EntityPrivacyPolicy from './EntityPrivacyPolicy';
import { EntityQueryContext } from './EntityQueryContext'; import { EntityQueryContext } from './EntityQueryContext';
import ReadonlyEntity from './ReadonlyEntity'; import ReadonlyEntity from './ReadonlyEntity';
import ViewerContext from './ViewerContext'; import ViewerContext from './ViewerContext';
import EntityInvalidFieldValueError from './errors/EntityInvalidFieldValueError';
import EntityNotFoundError from './errors/EntityNotFoundError';
import EntityDataManager from './internal/EntityDataManager'; import EntityDataManager from './internal/EntityDataManager';
import IEntityMetricsAdapter from './metrics/IEntityMetricsAdapter';
import { mapMap, mapMapAsync } from './utils/collections/maps'; import { mapMap, mapMapAsync } from './utils/collections/maps';
/** /**
@@ -24,18 +18,11 @@ import { mapMap, mapMapAsync } from './utils/collections/maps';
* cached, and authorized against the entity's {@link EntityPrivacyPolicy}. * cached, and authorized against the entity's {@link EntityPrivacyPolicy}.
*/ */
export default class EntityLoader< export default class EntityLoader<
TFields,
TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
TPrivacyPolicy extends EntityPrivacyPolicy<
TFields, TFields,
TID, TID,
TViewerContext, TViewerContext extends ViewerContext,
TEntity, TEntity extends ReadonlyEntity<TFields, TID, TViewerContext>,
TSelectedFields TPrivacyPolicy extends EntityPrivacyPolicy<TFields, TID, TViewerContext, TEntity>
>,
TSelectedFields extends keyof TFields
> { > {
constructor( constructor(
private readonly viewerContext: TViewerContext, private readonly viewerContext: TViewerContext,
@@ -46,12 +33,10 @@ export default class EntityLoader<
TID, TID,
TViewerContext, TViewerContext,
TEntity, TEntity,
TPrivacyPolicy, TPrivacyPolicy
TSelectedFields
>, >,
private readonly privacyPolicy: TPrivacyPolicy, private readonly privacyPolicy: TPrivacyPolicy,
private readonly dataManager: EntityDataManager<TFields>, private readonly entityDataManager: EntityDataManager<TFields>
protected readonly metricsAdapter: IEntityMetricsAdapter
) {} ) {}
/** /**
@@ -59,14 +44,7 @@ export default class EntityLoader<
* guaranteed to be the values of successful results (or null for some loader methods), * guaranteed to be the values of successful results (or null for some loader methods),
* and will throw otherwise. * and will throw otherwise.
*/ */
enforcing(): EnforcingEntityLoader< enforcing(): EnforcingEntityLoader<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy> {
TFields,
TID,
TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
> {
return new EnforcingEntityLoader(this); return new EnforcingEntityLoader(this);
} }
@@ -77,19 +55,30 @@ export default class EntityLoader<
* @returns map from fieldValue to entity results that match the query for that fieldValue, * @returns map from fieldValue to entity results that match the query for that fieldValue,
* where result errors can be UnauthorizedError * where result errors can be UnauthorizedError
*/ */
async loadManyByFieldEqualingManyAsync<N extends keyof Pick<TFields, TSelectedFields>>( async loadManyByFieldEqualingManyAsync<N extends keyof TFields>(
fieldName: N, fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[] fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<ReadonlyMap<NonNullable<TFields[N]>, readonly Result<TEntity>[]>> { ): Promise<ReadonlyMap<NonNullable<TFields[N]>, readonly Result<TEntity>[]>> {
this.validateFieldValues(fieldName, fieldValues); const fieldValuesToObjects = await this.entityDataManager.loadManyByFieldEqualingAsync(
const fieldValuesToFieldObjects = await this.dataManager.loadManyByFieldEqualingAsync(
this.queryContext, this.queryContext,
fieldName, fieldName,
fieldValues fieldValues
); );
const fieldValuesToUncheckedEntities = mapMap(fieldValuesToObjects, (v) => {
return v.map((obj) => {
return new this.entityClass(this.viewerContext, obj);
});
});
return await this.constructAndAuthorizeEntitiesAsync(fieldValuesToFieldObjects); return await mapMapAsync(fieldValuesToUncheckedEntities, async (v) => {
return await Promise.all(
v.map((entity) =>
asyncResult(
this.privacyPolicy.authorizeReadAsync(this.viewerContext, this.queryContext, entity)
)
)
);
});
} }
/** /**
@@ -98,7 +87,7 @@ export default class EntityLoader<
* @param fieldValue - fieldName field value being queried * @param fieldValue - fieldName field value being queried
* @returns array of entity results that match the query for fieldValue, where result error can be UnauthorizedError * @returns array of entity results that match the query for fieldValue, where result error can be UnauthorizedError
*/ */
async loadManyByFieldEqualingAsync<N extends keyof Pick<TFields, TSelectedFields>>( async loadManyByFieldEqualingAsync<N extends keyof TFields>(
fieldName: N, fieldName: N,
fieldValue: NonNullable<TFields[N]> fieldValue: NonNullable<TFields[N]>
): Promise<readonly Result<TEntity>[]> { ): Promise<readonly Result<TEntity>[]> {
@@ -118,7 +107,7 @@ export default class EntityLoader<
* @returns entity result where uniqueFieldName equals fieldValue, or null if no entity matches the condition. * @returns entity result where uniqueFieldName equals fieldValue, or null if no entity matches the condition.
* @throws when multiple entities match the condition * @throws when multiple entities match the condition
*/ */
async loadByFieldEqualingAsync<N extends keyof Pick<TFields, TSelectedFields>>( async loadByFieldEqualingAsync<N extends keyof TFields>(
uniqueFieldName: N, uniqueFieldName: N,
fieldValue: NonNullable<TFields[N]> fieldValue: NonNullable<TFields[N]>
): Promise<Result<TEntity> | null> { ): Promise<Result<TEntity> | null> {
@@ -140,7 +129,7 @@ export default class EntityLoader<
const entityResult = entityResults.get(id); const entityResult = entityResults.get(id);
if (entityResult === undefined) { if (entityResult === undefined) {
return result( return result(
new EntityNotFoundError(this.entityClass, this.entityConfiguration.idField, id) new EntityNotFoundError(this.entityClass, this.entityConfiguration.idField, id as any)
); );
} }
return entityResult; return entityResult;
@@ -152,10 +141,7 @@ export default class EntityLoader<
* @returns entity result for matching ID, or null if no entity exists for ID. * @returns entity result for matching ID, or null if no entity exists for ID.
*/ */
async loadByIDNullableAsync(id: TID): Promise<Result<TEntity> | null> { async loadByIDNullableAsync(id: TID): Promise<Result<TEntity> | null> {
return await this.loadByFieldEqualingAsync( return await this.loadByFieldEqualingAsync(this.entityConfiguration.idField, id as any);
this.entityConfiguration.idField as TSelectedFields,
id
);
} }
/** /**
@@ -165,15 +151,17 @@ export default class EntityLoader<
* @returns map from ID to corresponding entity result, where result error can be UnauthorizedError or EntityNotFoundError. * @returns map from ID to corresponding entity result, where result error can be UnauthorizedError or EntityNotFoundError.
*/ */
async loadManyByIDsAsync(ids: readonly TID[]): Promise<ReadonlyMap<TID, Result<TEntity>>> { async loadManyByIDsAsync(ids: readonly TID[]): Promise<ReadonlyMap<TID, Result<TEntity>>> {
const entityResults = (await this.loadManyByFieldEqualingManyAsync( const entityResults = ((await this.loadManyByFieldEqualingManyAsync(
this.entityConfiguration.idField as TSelectedFields, this.entityConfiguration.idField,
ids ids as any
)) as ReadonlyMap<TID, readonly Result<TEntity>[]>; )) as any) as ReadonlyMap<TID, readonly Result<TEntity>[]>;
return mapMap(entityResults, (entityResultsForId, id) => { return mapMap(entityResults, (entityResultsForId, id) => {
const entityResult = entityResultsForId[0]; const entityResult = entityResultsForId[0];
return ( return (
entityResult ?? entityResult ??
result(new EntityNotFoundError(this.entityClass, this.entityConfiguration.idField, id)) result(
new EntityNotFoundError(this.entityClass, this.entityConfiguration.idField, id as any)
)
); );
}); });
} }
@@ -192,37 +180,25 @@ export default class EntityLoader<
* @param querySelectionModifiers - limit, offset, and orderBy for the query * @param querySelectionModifiers - limit, offset, and orderBy for the query
* @returns array of entity results that match the query, where result error can be UnauthorizedError * @returns array of entity results that match the query, where result error can be UnauthorizedError
*/ */
async loadManyByFieldEqualityConjunctionAsync<N extends keyof Pick<TFields, TSelectedFields>>( async loadManyByFieldEqualityConjunctionAsync<N extends keyof TFields>(
fieldEqualityOperands: FieldEqualityCondition<TFields, N>[], fieldEqualityOperands: FieldEqualityCondition<TFields, N>[],
querySelectionModifiers: QuerySelectionModifiers<TFields> = {} querySelectionModifiers: QuerySelectionModifiers<TFields> = {}
): Promise<readonly Result<TEntity>[]> { ): Promise<readonly Result<TEntity>[]> {
for (const fieldEqualityOperand of fieldEqualityOperands) { const fieldValuesArray = await this.entityDataManager.loadManyByFieldEqualityConjunctionAsync(
const fieldValues = isSingleValueFieldEqualityCondition(fieldEqualityOperand)
? [fieldEqualityOperand.fieldValue]
: fieldEqualityOperand.fieldValues;
this.validateFieldValues(fieldEqualityOperand.fieldName, fieldValues);
}
const fieldObjects = await this.dataManager.loadManyByFieldEqualityConjunctionAsync(
this.queryContext, this.queryContext,
fieldEqualityOperands, fieldEqualityOperands,
querySelectionModifiers querySelectionModifiers
); );
const uncheckedEntityResults = this.tryConstructEntities(fieldObjects); const uncheckedEntities = fieldValuesArray.map((obj) => {
return new this.entityClass(this.viewerContext, obj);
});
return await Promise.all( return await Promise.all(
uncheckedEntityResults.map(async (uncheckedEntityResult) => { uncheckedEntities.map(async (entity) =>
if (!uncheckedEntityResult.ok) { asyncResult(
return uncheckedEntityResult; this.privacyPolicy.authorizeReadAsync(this.viewerContext, this.queryContext, entity)
} )
return await asyncResult(
this.privacyPolicy.authorizeReadAsync(
this.viewerContext,
this.queryContext,
uncheckedEntityResult.value,
this.metricsAdapter
) )
);
})
); );
} }
@@ -256,27 +232,22 @@ export default class EntityLoader<
bindings: any[] | object, bindings: any[] | object,
querySelectionModifiers: QuerySelectionModifiers<TFields> = {} querySelectionModifiers: QuerySelectionModifiers<TFields> = {}
): Promise<readonly Result<TEntity>[]> { ): Promise<readonly Result<TEntity>[]> {
const fieldObjects = await this.dataManager.loadManyByRawWhereClauseAsync( const fieldValuesArray = await this.entityDataManager.loadManyByRawWhereClauseAsync(
this.queryContext, this.queryContext,
rawWhereClause, rawWhereClause,
bindings, bindings,
querySelectionModifiers querySelectionModifiers
); );
const uncheckedEntityResults = this.tryConstructEntities(fieldObjects); const uncheckedEntities = fieldValuesArray.map((obj) => {
return new this.entityClass(this.viewerContext, obj);
});
return await Promise.all( return await Promise.all(
uncheckedEntityResults.map(async (uncheckedEntityResult) => { uncheckedEntities.map(async (entity) =>
if (!uncheckedEntityResult.ok) { asyncResult(
return uncheckedEntityResult; this.privacyPolicy.authorizeReadAsync(this.viewerContext, this.queryContext, entity)
} )
return await asyncResult(
this.privacyPolicy.authorizeReadAsync(
this.viewerContext,
this.queryContext,
uncheckedEntityResult.value,
this.metricsAdapter
) )
);
})
); );
} }
@@ -285,73 +256,6 @@ export default class EntityLoader<
* @param objectFields - entity data object to be invalidated * @param objectFields - entity data object to be invalidated
*/ */
async invalidateFieldsAsync(objectFields: Readonly<TFields>): Promise<void> { async invalidateFieldsAsync(objectFields: Readonly<TFields>): Promise<void> {
await this.dataManager.invalidateObjectFieldsAsync(objectFields); await this.entityDataManager.invalidateObjectFieldsAsync(objectFields);
}
/**
* Invalidate all caches for an entity. One potential use case would be to keep the entity
* framework in sync with changes made to data outside of the framework.
* @param entity - entity to be invalidated
*/
async invalidateEntityAsync(entity: TEntity): Promise<void> {
await this.invalidateFieldsAsync(entity.getAllDatabaseFields());
}
private tryConstructEntities(fieldsObjects: readonly TFields[]): readonly Result<TEntity>[] {
return fieldsObjects.map((fieldsObject) => {
try {
return result(new this.entityClass(this.viewerContext, fieldsObject));
} catch (e) {
if (!(e instanceof Error)) {
throw e;
}
return result(e);
}
});
}
/**
* Construct and authorize entities from fields map, returning error results for entities that fail
* to construct or fail to authorize.
*
* @param map - map from an arbitrary key type to an array of entity field objects
*/
public async constructAndAuthorizeEntitiesAsync<K>(
map: ReadonlyMap<K, readonly Readonly<TFields>[]>
): Promise<ReadonlyMap<K, readonly Result<TEntity>[]>> {
const uncheckedEntityResultsMap = mapMap(map, (fieldObjects) =>
this.tryConstructEntities(fieldObjects)
);
return await mapMapAsync(uncheckedEntityResultsMap, async (uncheckedEntityResults) => {
return await Promise.all(
uncheckedEntityResults.map(async (uncheckedEntityResult) => {
if (!uncheckedEntityResult.ok) {
return uncheckedEntityResult;
}
return await asyncResult(
this.privacyPolicy.authorizeReadAsync(
this.viewerContext,
this.queryContext,
uncheckedEntityResult.value,
this.metricsAdapter
)
);
})
);
});
}
private validateFieldValues<N extends keyof Pick<TFields, TSelectedFields>>(
fieldName: N,
fieldValues: readonly TFields[N][]
): void {
const fieldDefinition = this.entityConfiguration.schema.get(fieldName);
invariant(fieldDefinition, `must have field definition for field = ${fieldName}`);
for (const fieldValue of fieldValues) {
const isInputValid = fieldDefinition.validateInputValue(fieldValue);
if (!isInputValid) {
throw new EntityInvalidFieldValueError(this.entityClass, fieldName, fieldValue);
}
}
} }
} }
+7 -18
View File
@@ -6,24 +6,16 @@ import { EntityQueryContext } from './EntityQueryContext';
import ReadonlyEntity from './ReadonlyEntity'; import ReadonlyEntity from './ReadonlyEntity';
import ViewerContext from './ViewerContext'; import ViewerContext from './ViewerContext';
import EntityDataManager from './internal/EntityDataManager'; import EntityDataManager from './internal/EntityDataManager';
import IEntityMetricsAdapter from './metrics/IEntityMetricsAdapter';
/** /**
* The primary entry point for loading entities. * The primary entry point for loading entities.
*/ */
export default class EntityLoaderFactory< export default class EntityLoaderFactory<
TFields,
TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
TPrivacyPolicy extends EntityPrivacyPolicy<
TFields, TFields,
TID, TID,
TViewerContext, TViewerContext extends ViewerContext,
TEntity, TEntity extends ReadonlyEntity<TFields, TID, TViewerContext>,
TSelectedFields TPrivacyPolicy extends EntityPrivacyPolicy<TFields, TID, TViewerContext, TEntity>
>,
TSelectedFields extends keyof TFields
> { > {
constructor( constructor(
private readonly entityConfiguration: EntityConfiguration<TFields>, private readonly entityConfiguration: EntityConfiguration<TFields>,
@@ -32,12 +24,10 @@ export default class EntityLoaderFactory<
TID, TID,
TViewerContext, TViewerContext,
TEntity, TEntity,
TPrivacyPolicy, TPrivacyPolicy
TSelectedFields
>, >,
private readonly privacyPolicyClass: TPrivacyPolicy, private readonly privacyPolicyClass: TPrivacyPolicy,
private readonly dataManager: EntityDataManager<TFields>, private readonly dataManager: EntityDataManager<TFields>
protected readonly metricsAdapter: IEntityMetricsAdapter
) {} ) {}
/** /**
@@ -48,15 +38,14 @@ export default class EntityLoaderFactory<
forLoad( forLoad(
viewerContext: TViewerContext, viewerContext: TViewerContext,
queryContext: EntityQueryContext queryContext: EntityQueryContext
): EntityLoader<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy, TSelectedFields> { ): EntityLoader<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy> {
return new EntityLoader( return new EntityLoader(
viewerContext, viewerContext,
queryContext, queryContext,
this.entityConfiguration, this.entityConfiguration,
this.entityClass, this.entityClass,
this.privacyPolicyClass, this.privacyPolicyClass,
this.dataManager, this.dataManager
this.metricsAdapter
); );
} }
} }
-47
View File
@@ -1,47 +0,0 @@
import Entity from './Entity';
import ViewerContext from './ViewerContext';
export enum EntityMutationType {
CREATE,
UPDATE,
DELETE,
}
export type EntityValidatorMutationInfo<
TFields,
TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext,
TEntity extends Entity<TFields, TID, TViewerContext, TSelectedFields>,
TSelectedFields extends keyof TFields = keyof TFields
> =
| {
type: EntityMutationType.CREATE;
}
| {
type: EntityMutationType.UPDATE;
previousValue: TEntity;
};
export type EntityMutationTriggerDeleteCascadeInfo<> = {
entity: Entity<any, any, any, any>;
cascadingDeleteCause: EntityMutationTriggerDeleteCascadeInfo | null;
};
export type EntityTriggerMutationInfo<
TFields,
TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext,
TEntity extends Entity<TFields, TID, TViewerContext, TSelectedFields>,
TSelectedFields extends keyof TFields = keyof TFields
> =
| {
type: EntityMutationType.CREATE;
}
| {
type: EntityMutationType.UPDATE;
previousValue: TEntity;
}
| {
type: EntityMutationType.DELETE;
cascadingDeleteCause: EntityMutationTriggerDeleteCascadeInfo | null;
};
@@ -1,103 +0,0 @@
import { EntityTriggerMutationInfo } from './EntityMutationInfo';
import { EntityTransactionalQueryContext } from './EntityQueryContext';
import ReadonlyEntity from './ReadonlyEntity';
import ViewerContext from './ViewerContext';
/**
* Interface to define trigger behavior for entities.
*/
export default interface EntityMutationTriggerConfiguration<
TFields,
TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
TSelectedFields extends keyof TFields = keyof TFields
> {
/**
* Trigger set that runs within the transaction but before the entity is created in the database.
*/
beforeCreate?: EntityMutationTrigger<TFields, TID, TViewerContext, TEntity, TSelectedFields>[];
/**
* Trigger set that runs within the transaction but after the entity is created in the database and cache is invalidated.
*/
afterCreate?: EntityMutationTrigger<TFields, TID, TViewerContext, TEntity, TSelectedFields>[];
/**
* Trigger set that runs within the transaction but before the entity is updated in the database.
*/
beforeUpdate?: EntityMutationTrigger<TFields, TID, TViewerContext, TEntity, TSelectedFields>[];
/**
* Trigger set that runs within the transaction but after the entity is updated in the database and cache is invalidated.
*/
afterUpdate?: EntityMutationTrigger<TFields, TID, TViewerContext, TEntity, TSelectedFields>[];
/**
* Trigger set that runs within the transaction but before the entity is deleted from the database.
*/
beforeDelete?: EntityMutationTrigger<TFields, TID, TViewerContext, TEntity, TSelectedFields>[];
/**
* Trigger set that runs within the transaction but after the entity is deleted from the database and cache is invalidated.
*/
afterDelete?: EntityMutationTrigger<TFields, TID, TViewerContext, TEntity, TSelectedFields>[];
/**
* Trigger set that runs within the transaction but before the entity is created, updated, or deleted.
*/
beforeAll?: EntityMutationTrigger<TFields, TID, TViewerContext, TEntity, TSelectedFields>[];
/**
* Trigger set that runs within the transaction but before the entity is created in, updated in, or deleted from
* the database and the cache is invalidated.
*/
afterAll?: EntityMutationTrigger<TFields, TID, TViewerContext, TEntity, TSelectedFields>[];
/**
* Trigger set that runs after committing the transaction unless one is supplied
* after any mutation (create, update, delete). If the call to the mutation is wrapped in a transaction,
* this too will be within the transaction.
*/
afterCommit?: EntityNonTransactionalMutationTrigger<
TFields,
TID,
TViewerContext,
TEntity,
TSelectedFields
>[];
}
/**
* A trigger is a way to specify entity mutation operation side-effects that run within the
* same transaction as the mutation itself. The one exception is afterCommit, which will run within
* the transaction if a transaction is supplied.
*/
export abstract class EntityMutationTrigger<
TFields,
TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
TSelectedFields extends keyof TFields = keyof TFields
> {
abstract executeAsync(
viewerContext: TViewerContext,
queryContext: EntityTransactionalQueryContext,
entity: TEntity,
mutationInfo: EntityTriggerMutationInfo<TFields, TID, TViewerContext, TEntity, TSelectedFields>
): Promise<void>;
}
/**
* A non-transactional trigger is like a {@link EntityMutationTrigger} but used for afterCommit triggers
* since they explicitly occur outside of the transaction.
*/
export abstract class EntityNonTransactionalMutationTrigger<
TFields,
TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
TSelectedFields extends keyof TFields = keyof TFields
> {
abstract executeAsync(
viewerContext: TViewerContext,
entity: TEntity,
mutationInfo: EntityTriggerMutationInfo<TFields, TID, TViewerContext, TEntity, TSelectedFields>
): Promise<void>;
}
@@ -1,29 +0,0 @@
import { EntityValidatorMutationInfo } from './EntityMutationInfo';
import { EntityTransactionalQueryContext } from './EntityQueryContext';
import ReadonlyEntity from './ReadonlyEntity';
import ViewerContext from './ViewerContext';
/**
* A validator is a way to specify entity mutation validation that runs within the
* same transaction as the mutation itself before creating or updating an entity.
*/
export default abstract class EntityMutationValidator<
TFields,
TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
TSelectedFields extends keyof TFields = keyof TFields
> {
abstract executeAsync(
viewerContext: TViewerContext,
queryContext: EntityTransactionalQueryContext,
entity: TEntity,
mutationInfo: EntityValidatorMutationInfo<
TFields,
TID,
TViewerContext,
TEntity,
TSelectedFields
>
): Promise<void>;
}
+80 -569
View File
@@ -1,45 +1,23 @@
import { Result, asyncResult, result, enforceAsyncResult } from '@expo/results'; import { Result, asyncResult, result, enforceAsyncResult } from '@expo/results';
import invariant from 'invariant'; import invariant from 'invariant';
import _ from 'lodash';
import Entity, { IEntityClass } from './Entity'; import Entity, { IEntityClass } from './Entity';
import { EntityCompanionDefinition } from './EntityCompanionProvider';
import EntityConfiguration from './EntityConfiguration'; import EntityConfiguration from './EntityConfiguration';
import EntityDatabaseAdapter from './EntityDatabaseAdapter'; import EntityDatabaseAdapter from './EntityDatabaseAdapter';
import { EntityEdgeDeletionBehavior } from './EntityFieldDefinition';
import EntityLoaderFactory from './EntityLoaderFactory'; import EntityLoaderFactory from './EntityLoaderFactory';
import {
EntityValidatorMutationInfo,
EntityMutationType,
EntityTriggerMutationInfo,
EntityMutationTriggerDeleteCascadeInfo,
} from './EntityMutationInfo';
import EntityMutationTriggerConfiguration, {
EntityMutationTrigger,
EntityNonTransactionalMutationTrigger,
} from './EntityMutationTriggerConfiguration';
import EntityMutationValidator from './EntityMutationValidator';
import EntityPrivacyPolicy from './EntityPrivacyPolicy'; import EntityPrivacyPolicy from './EntityPrivacyPolicy';
import { EntityQueryContext, EntityTransactionalQueryContext } from './EntityQueryContext'; import { EntityQueryContext } from './EntityQueryContext';
import ReadonlyEntity from './ReadonlyEntity';
import ViewerContext from './ViewerContext'; import ViewerContext from './ViewerContext';
import EntityInvalidFieldValueError from './errors/EntityInvalidFieldValueError';
import { timeAndLogMutationEventAsync } from './metrics/EntityMetricsUtils'; import { timeAndLogMutationEventAsync } from './metrics/EntityMetricsUtils';
import IEntityMetricsAdapter, { EntityMetricsMutationType } from './metrics/IEntityMetricsAdapter'; import IEntityMetricsAdapter, { EntityMetricsMutationType } from './metrics/IEntityMetricsAdapter';
import { mapMapAsync } from './utils/collections/maps';
abstract class BaseMutator< abstract class BaseMutator<
TFields,
TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext,
TEntity extends Entity<TFields, TID, TViewerContext, TSelectedFields>,
TPrivacyPolicy extends EntityPrivacyPolicy<
TFields, TFields,
TID, TID,
TViewerContext, TViewerContext extends ViewerContext,
TEntity, TEntity extends Entity<TFields, TID, TViewerContext>,
TSelectedFields TPrivacyPolicy extends EntityPrivacyPolicy<TFields, TID, TViewerContext, TEntity>
>,
TSelectedFields extends keyof TFields
> { > {
constructor( constructor(
protected readonly viewerContext: TViewerContext, protected readonly viewerContext: TViewerContext,
@@ -50,104 +28,37 @@ abstract class BaseMutator<
TID, TID,
TViewerContext, TViewerContext,
TEntity, TEntity,
TPrivacyPolicy, TPrivacyPolicy
TSelectedFields
>, >,
protected readonly privacyPolicy: TPrivacyPolicy, protected readonly privacyPolicy: TPrivacyPolicy,
protected readonly mutationValidators: EntityMutationValidator<
TFields,
TID,
TViewerContext,
TEntity,
TSelectedFields
>[],
protected readonly mutationTriggers: EntityMutationTriggerConfiguration<
TFields,
TID,
TViewerContext,
TEntity,
TSelectedFields
>,
protected readonly entityLoaderFactory: EntityLoaderFactory< protected readonly entityLoaderFactory: EntityLoaderFactory<
TFields, TFields,
TID, TID,
TViewerContext, TViewerContext,
TEntity, TEntity,
TPrivacyPolicy, TPrivacyPolicy
TSelectedFields
>, >,
protected readonly databaseAdapter: EntityDatabaseAdapter<TFields>, protected readonly databaseAdapter: EntityDatabaseAdapter<TFields>,
protected readonly metricsAdapter: IEntityMetricsAdapter protected readonly metricsAdapter: IEntityMetricsAdapter
) {} ) {}
protected validateFields(fields: Partial<TFields>): void { protected async validateFields(fields: Partial<TFields>): Promise<Result<Partial<TFields>>> {
for (const fieldName in fields) { const validatorResults = await asyncResult(
const fieldValue = fields[fieldName]; Promise.all(
const fieldDefinition = this.entityConfiguration.schema.get(fieldName); Object.entries(fields).map(async ([fieldName, fieldValue]) => {
const fieldDefinition = this.entityConfiguration.schema.get(fieldName as keyof TFields);
invariant(fieldDefinition, `must have field definition for field = ${fieldName}`); invariant(fieldDefinition, `must have field definition for field = ${fieldName}`);
const isInputValid = fieldDefinition.validateInputValue(fieldValue); const writeValidator = fieldDefinition.validator.write;
if (!isInputValid) { if (writeValidator) {
throw new EntityInvalidFieldValueError(this.entityClass, fieldName, fieldValue); await writeValidator(fieldValue);
} }
} })
}
protected async executeMutationValidatorsAsync(
validators: EntityMutationValidator<TFields, TID, TViewerContext, TEntity, TSelectedFields>[],
queryContext: EntityTransactionalQueryContext,
entity: TEntity,
mutationInfo: EntityValidatorMutationInfo<
TFields,
TID,
TViewerContext,
TEntity,
TSelectedFields
>
): Promise<void> {
await Promise.all(
validators.map((validator) =>
validator.executeAsync(this.viewerContext, queryContext, entity, mutationInfo)
) )
); );
if (!validatorResults.ok) {
return result(validatorResults.reason);
} }
return result(fields);
protected async executeMutationTriggersAsync(
triggers:
| EntityMutationTrigger<TFields, TID, TViewerContext, TEntity, TSelectedFields>[]
| undefined,
queryContext: EntityTransactionalQueryContext,
entity: TEntity,
mutationInfo: EntityTriggerMutationInfo<TFields, TID, TViewerContext, TEntity, TSelectedFields>
): Promise<void> {
if (!triggers) {
return;
}
await Promise.all(
triggers.map((trigger) =>
trigger.executeAsync(this.viewerContext, queryContext, entity, mutationInfo)
)
);
}
protected async executeNonTransactionalMutationTriggersAsync(
triggers:
| EntityNonTransactionalMutationTrigger<
TFields,
TID,
TViewerContext,
TEntity,
TSelectedFields
>[]
| undefined,
entity: TEntity,
mutationInfo: EntityTriggerMutationInfo<TFields, TID, TViewerContext, TEntity, TSelectedFields>
): Promise<void> {
if (!triggers) {
return;
}
await Promise.all(
triggers.map((trigger) => trigger.executeAsync(this.viewerContext, entity, mutationInfo))
);
} }
} }
@@ -155,19 +66,12 @@ abstract class BaseMutator<
* Mutator for creating a new entity. * Mutator for creating a new entity.
*/ */
export class CreateMutator< export class CreateMutator<
TFields,
TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext,
TEntity extends Entity<TFields, TID, TViewerContext, TSelectedFields>,
TPrivacyPolicy extends EntityPrivacyPolicy<
TFields, TFields,
TID, TID,
TViewerContext, TViewerContext extends ViewerContext,
TEntity, TEntity extends Entity<TFields, TID, TViewerContext>,
TSelectedFields TPrivacyPolicy extends EntityPrivacyPolicy<TFields, TID, TViewerContext, TEntity>
>, > extends BaseMutator<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy> {
TSelectedFields extends keyof TFields
> extends BaseMutator<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy, TSelectedFields> {
private readonly fieldsForEntity: Partial<TFields> = {}; private readonly fieldsForEntity: Partial<TFields> = {};
/** /**
@@ -175,7 +79,7 @@ export class CreateMutator<
* @param fieldName - entity field being updated * @param fieldName - entity field being updated
* @param value - value for entity field * @param value - value for entity field
*/ */
setField<K extends keyof Pick<TFields, TSelectedFields>>(fieldName: K, value: TFields[K]): this { setField<K extends keyof TFields>(fieldName: K, value: TFields[K]): this {
this.fieldsForEntity[fieldName] = value; this.fieldsForEntity[fieldName] = value;
return this; return this;
} }
@@ -188,9 +92,8 @@ export class CreateMutator<
async createAsync(): Promise<Result<TEntity>> { async createAsync(): Promise<Result<TEntity>> {
return await timeAndLogMutationEventAsync( return await timeAndLogMutationEventAsync(
this.metricsAdapter, this.metricsAdapter,
EntityMetricsMutationType.CREATE, EntityMetricsMutationType.CREATE
this.entityClass.name )(this.createInternalAsync());
)(this.createInTransactionAsync());
} }
/** /**
@@ -200,88 +103,38 @@ export class CreateMutator<
return await enforceAsyncResult(this.createAsync()); return await enforceAsyncResult(this.createAsync());
} }
private async createInTransactionAsync(): Promise<Result<TEntity>> { private async createInternalAsync(): Promise<Result<TEntity>> {
return await this.queryContext.runInTransactionIfNotInTransactionAsync((innerQueryContext) => const validatedFieldsResult = await this.validateFields(this.fieldsForEntity);
this.createInternalAsync(innerQueryContext) if (!validatedFieldsResult.ok) {
); return result(validatedFieldsResult.reason);
} }
private async createInternalAsync( const temporaryEntityForPrivacyCheck = new this.entityClass(this.viewerContext, ({
queryContext: EntityTransactionalQueryContext
): Promise<Result<TEntity>> {
this.validateFields(this.fieldsForEntity);
const temporaryEntityForPrivacyCheck = new this.entityClass(this.viewerContext, {
[this.entityConfiguration.idField]: '00000000-0000-0000-0000-000000000000', // zero UUID [this.entityConfiguration.idField]: '00000000-0000-0000-0000-000000000000', // zero UUID
...this.fieldsForEntity, ...validatedFieldsResult.value,
} as unknown as TFields); } as unknown) as TFields);
const authorizeCreateResult = await asyncResult( const authorizeCreateResult = await asyncResult(
this.privacyPolicy.authorizeCreateAsync( this.privacyPolicy.authorizeCreateAsync(
this.viewerContext, this.viewerContext,
queryContext, this.queryContext,
temporaryEntityForPrivacyCheck, temporaryEntityForPrivacyCheck
this.metricsAdapter
) )
); );
if (!authorizeCreateResult.ok) { if (!authorizeCreateResult.ok) {
return authorizeCreateResult; return authorizeCreateResult;
} }
await this.executeMutationValidatorsAsync( const insertResult = await this.databaseAdapter.insertAsync(
this.mutationValidators, this.queryContext,
queryContext, validatedFieldsResult.value
temporaryEntityForPrivacyCheck,
{ type: EntityMutationType.CREATE }
);
await this.executeMutationTriggersAsync(
this.mutationTriggers.beforeAll,
queryContext,
temporaryEntityForPrivacyCheck,
{ type: EntityMutationType.CREATE }
);
await this.executeMutationTriggersAsync(
this.mutationTriggers.beforeCreate,
queryContext,
temporaryEntityForPrivacyCheck,
{ type: EntityMutationType.CREATE }
); );
const insertResult = await this.databaseAdapter.insertAsync(queryContext, this.fieldsForEntity); const entityLoader = this.entityLoaderFactory.forLoad(this.viewerContext, this.queryContext);
await entityLoader.invalidateFieldsAsync(insertResult);
const entityLoader = this.entityLoaderFactory.forLoad(this.viewerContext, queryContext);
queryContext.appendPostCommitInvalidationCallback(
entityLoader.invalidateFieldsAsync.bind(entityLoader, insertResult)
);
const unauthorizedEntityAfterInsert = new this.entityClass(this.viewerContext, insertResult); const unauthorizedEntityAfterInsert = new this.entityClass(this.viewerContext, insertResult);
const newEntity = await entityLoader return await entityLoader.loadByIDAsync(unauthorizedEntityAfterInsert.getID());
.enforcing()
.loadByIDAsync(unauthorizedEntityAfterInsert.getID());
await this.executeMutationTriggersAsync(
this.mutationTriggers.afterCreate,
queryContext,
newEntity,
{ type: EntityMutationType.CREATE }
);
await this.executeMutationTriggersAsync(
this.mutationTriggers.afterAll,
queryContext,
newEntity,
{ type: EntityMutationType.CREATE }
);
queryContext.appendPostCommitCallback(
this.executeNonTransactionalMutationTriggersAsync.bind(
this,
this.mutationTriggers.afterCommit,
newEntity,
{ type: EntityMutationType.CREATE }
)
);
return result(newEntity);
} }
} }
@@ -289,20 +142,13 @@ export class CreateMutator<
* Mutator for updating an existing entity. * Mutator for updating an existing entity.
*/ */
export class UpdateMutator< export class UpdateMutator<
TFields,
TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext,
TEntity extends Entity<TFields, TID, TViewerContext, TSelectedFields>,
TPrivacyPolicy extends EntityPrivacyPolicy<
TFields, TFields,
TID, TID,
TViewerContext, TViewerContext extends ViewerContext,
TEntity, TEntity extends Entity<TFields, TID, TViewerContext>,
TSelectedFields TPrivacyPolicy extends EntityPrivacyPolicy<TFields, TID, TViewerContext, TEntity>
>, > extends BaseMutator<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy> {
TSelectedFields extends keyof TFields private readonly originalFieldsForEntity: Readonly<TFields>;
> extends BaseMutator<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy, TSelectedFields> {
private readonly originalEntity: TEntity;
private readonly fieldsForEntity: TFields; private readonly fieldsForEntity: TFields;
private readonly updatedFields: Partial<TFields> = {}; private readonly updatedFields: Partial<TFields> = {};
@@ -310,40 +156,12 @@ export class UpdateMutator<
viewerContext: TViewerContext, viewerContext: TViewerContext,
queryContext: EntityQueryContext, queryContext: EntityQueryContext,
entityConfiguration: EntityConfiguration<TFields>, entityConfiguration: EntityConfiguration<TFields>,
entityClass: IEntityClass< entityClass: IEntityClass<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy>,
TFields,
TID,
TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
>,
privacyPolicy: TPrivacyPolicy, privacyPolicy: TPrivacyPolicy,
mutationValidators: EntityMutationValidator< entityLoaderFactory: EntityLoaderFactory<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy>,
TFields,
TID,
TViewerContext,
TEntity,
TSelectedFields
>[],
mutationTriggers: EntityMutationTriggerConfiguration<
TFields,
TID,
TViewerContext,
TEntity,
TSelectedFields
>,
entityLoaderFactory: EntityLoaderFactory<
TFields,
TID,
TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
>,
databaseAdapter: EntityDatabaseAdapter<TFields>, databaseAdapter: EntityDatabaseAdapter<TFields>,
metricsAdapter: IEntityMetricsAdapter, metricsAdapter: IEntityMetricsAdapter,
originalEntity: TEntity fieldsForEntity: Readonly<TFields>
) { ) {
super( super(
viewerContext, viewerContext,
@@ -351,14 +169,12 @@ export class UpdateMutator<
entityConfiguration, entityConfiguration,
entityClass, entityClass,
privacyPolicy, privacyPolicy,
mutationValidators,
mutationTriggers,
entityLoaderFactory, entityLoaderFactory,
databaseAdapter, databaseAdapter,
metricsAdapter metricsAdapter
); );
this.originalEntity = originalEntity; this.originalFieldsForEntity = _.cloneDeep(fieldsForEntity);
this.fieldsForEntity = { ...originalEntity.getAllDatabaseFields() }; this.fieldsForEntity = _.cloneDeep(fieldsForEntity);
} }
/** /**
@@ -366,7 +182,7 @@ export class UpdateMutator<
* @param fieldName - entity field being updated * @param fieldName - entity field being updated
* @param value - value for entity field * @param value - value for entity field
*/ */
setField<K extends keyof Pick<TFields, TSelectedFields>>(fieldName: K, value: TFields[K]): this { setField<K extends keyof TFields>(fieldName: K, value: TFields[K]): this {
this.fieldsForEntity[fieldName] = value; this.fieldsForEntity[fieldName] = value;
this.updatedFields[fieldName] = value; this.updatedFields[fieldName] = value;
return this; return this;
@@ -381,9 +197,8 @@ export class UpdateMutator<
async updateAsync(): Promise<Result<TEntity>> { async updateAsync(): Promise<Result<TEntity>> {
return await timeAndLogMutationEventAsync( return await timeAndLogMutationEventAsync(
this.metricsAdapter, this.metricsAdapter,
EntityMetricsMutationType.UPDATE, EntityMetricsMutationType.UPDATE
this.entityClass.name )(this.updateInternalAsync());
)(this.updateInTransactionAsync());
} }
/** /**
@@ -393,96 +208,38 @@ export class UpdateMutator<
return await enforceAsyncResult(this.updateAsync()); return await enforceAsyncResult(this.updateAsync());
} }
private async updateInTransactionAsync(): Promise<Result<TEntity>> { private async updateInternalAsync(): Promise<Result<TEntity>> {
return await this.queryContext.runInTransactionIfNotInTransactionAsync((innerQueryContext) => const validatedUpdatedFieldsResult = await this.validateFields(this.updatedFields);
this.updateInternalAsync(innerQueryContext) if (!validatedUpdatedFieldsResult.ok) {
); return result(validatedUpdatedFieldsResult.reason);
} }
private async updateInternalAsync(
queryContext: EntityTransactionalQueryContext
): Promise<Result<TEntity>> {
this.validateFields(this.updatedFields);
const entityAboutToBeUpdated = new this.entityClass(this.viewerContext, this.fieldsForEntity); const entityAboutToBeUpdated = new this.entityClass(this.viewerContext, this.fieldsForEntity);
const authorizeUpdateResult = await asyncResult( const authorizeUpdateResult = await asyncResult(
this.privacyPolicy.authorizeUpdateAsync( this.privacyPolicy.authorizeUpdateAsync(
this.viewerContext, this.viewerContext,
queryContext, this.queryContext,
entityAboutToBeUpdated, entityAboutToBeUpdated
this.metricsAdapter
) )
); );
if (!authorizeUpdateResult.ok) { if (!authorizeUpdateResult.ok) {
return authorizeUpdateResult; return authorizeUpdateResult;
} }
await this.executeMutationValidatorsAsync(
this.mutationValidators,
queryContext,
entityAboutToBeUpdated,
{ type: EntityMutationType.UPDATE, previousValue: this.originalEntity }
);
await this.executeMutationTriggersAsync(
this.mutationTriggers.beforeAll,
queryContext,
entityAboutToBeUpdated,
{ type: EntityMutationType.UPDATE, previousValue: this.originalEntity }
);
await this.executeMutationTriggersAsync(
this.mutationTriggers.beforeUpdate,
queryContext,
entityAboutToBeUpdated,
{ type: EntityMutationType.UPDATE, previousValue: this.originalEntity }
);
const updateResult = await this.databaseAdapter.updateAsync( const updateResult = await this.databaseAdapter.updateAsync(
queryContext, this.queryContext,
this.entityConfiguration.idField, this.entityConfiguration.idField,
entityAboutToBeUpdated.getID(), entityAboutToBeUpdated.getID(),
this.updatedFields validatedUpdatedFieldsResult.value
); );
const entityLoader = this.entityLoaderFactory.forLoad(this.viewerContext, queryContext); const entityLoader = this.entityLoaderFactory.forLoad(this.viewerContext, this.queryContext);
queryContext.appendPostCommitInvalidationCallback( await entityLoader.invalidateFieldsAsync(this.originalFieldsForEntity);
entityLoader.invalidateFieldsAsync.bind( await entityLoader.invalidateFieldsAsync(this.fieldsForEntity);
entityLoader,
this.originalEntity.getAllDatabaseFields()
)
);
queryContext.appendPostCommitInvalidationCallback(
entityLoader.invalidateFieldsAsync.bind(entityLoader, this.fieldsForEntity)
);
const unauthorizedEntityAfterUpdate = new this.entityClass(this.viewerContext, updateResult); const unauthorizedEntityAfterUpdate = new this.entityClass(this.viewerContext, updateResult);
const updatedEntity = await entityLoader return await entityLoader.loadByIDAsync(unauthorizedEntityAfterUpdate.getID());
.enforcing()
.loadByIDAsync(unauthorizedEntityAfterUpdate.getID());
await this.executeMutationTriggersAsync(
this.mutationTriggers.afterUpdate,
queryContext,
updatedEntity,
{ type: EntityMutationType.UPDATE, previousValue: this.originalEntity }
);
await this.executeMutationTriggersAsync(
this.mutationTriggers.afterAll,
queryContext,
updatedEntity,
{ type: EntityMutationType.UPDATE, previousValue: this.originalEntity }
);
queryContext.appendPostCommitCallback(
this.executeNonTransactionalMutationTriggersAsync.bind(
this,
this.mutationTriggers.afterCommit,
updatedEntity,
{ type: EntityMutationType.UPDATE, previousValue: this.originalEntity }
)
);
return result(updatedEntity);
} }
} }
@@ -490,54 +247,19 @@ export class UpdateMutator<
* Mutator for deleting an existing entity. * Mutator for deleting an existing entity.
*/ */
export class DeleteMutator< export class DeleteMutator<
TFields,
TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext,
TEntity extends Entity<TFields, TID, TViewerContext, TSelectedFields>,
TPrivacyPolicy extends EntityPrivacyPolicy<
TFields, TFields,
TID, TID,
TViewerContext, TViewerContext extends ViewerContext,
TEntity, TEntity extends Entity<TFields, TID, TViewerContext>,
TSelectedFields TPrivacyPolicy extends EntityPrivacyPolicy<TFields, TID, TViewerContext, TEntity>
>, > extends BaseMutator<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy> {
TSelectedFields extends keyof TFields
> extends BaseMutator<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy, TSelectedFields> {
constructor( constructor(
viewerContext: TViewerContext, viewerContext: TViewerContext,
queryContext: EntityQueryContext, queryContext: EntityQueryContext,
entityConfiguration: EntityConfiguration<TFields>, entityConfiguration: EntityConfiguration<TFields>,
entityClass: IEntityClass< entityClass: IEntityClass<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy>,
TFields,
TID,
TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
>,
privacyPolicy: TPrivacyPolicy, privacyPolicy: TPrivacyPolicy,
mutationValidators: EntityMutationValidator< entityLoaderFactory: EntityLoaderFactory<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy>,
TFields,
TID,
TViewerContext,
TEntity,
TSelectedFields
>[],
mutationTriggers: EntityMutationTriggerConfiguration<
TFields,
TID,
TViewerContext,
TEntity,
TSelectedFields
>,
entityLoaderFactory: EntityLoaderFactory<
TFields,
TID,
TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
>,
databaseAdapter: EntityDatabaseAdapter<TFields>, databaseAdapter: EntityDatabaseAdapter<TFields>,
metricsAdapter: IEntityMetricsAdapter, metricsAdapter: IEntityMetricsAdapter,
private readonly entity: TEntity private readonly entity: TEntity
@@ -548,8 +270,6 @@ export class DeleteMutator<
entityConfiguration, entityConfiguration,
entityClass, entityClass,
privacyPolicy, privacyPolicy,
mutationValidators,
mutationTriggers,
entityLoaderFactory, entityLoaderFactory,
databaseAdapter, databaseAdapter,
metricsAdapter metricsAdapter
@@ -563,9 +283,8 @@ export class DeleteMutator<
async deleteAsync(): Promise<Result<void>> { async deleteAsync(): Promise<Result<void>> {
return await timeAndLogMutationEventAsync( return await timeAndLogMutationEventAsync(
this.metricsAdapter, this.metricsAdapter,
EntityMetricsMutationType.DELETE, EntityMetricsMutationType.DELETE
this.entityClass.name )(this.deleteInternalAsync());
)(this.deleteInTransactionAsync(new Set(), false, null));
} }
/** /**
@@ -575,229 +294,21 @@ export class DeleteMutator<
return await enforceAsyncResult(this.deleteAsync()); return await enforceAsyncResult(this.deleteAsync());
} }
private async deleteInTransactionAsync( private async deleteInternalAsync(): Promise<Result<void>> {
processedEntityIdentifiersFromTransitiveDeletions: Set<string>,
skipDatabaseDeletion: boolean,
cascadingDeleteCause: EntityMutationTriggerDeleteCascadeInfo | null
): Promise<Result<void>> {
return await this.queryContext.runInTransactionIfNotInTransactionAsync((innerQueryContext) =>
this.deleteInternalAsync(
innerQueryContext,
processedEntityIdentifiersFromTransitiveDeletions,
skipDatabaseDeletion,
cascadingDeleteCause
)
);
}
private async deleteInternalAsync(
queryContext: EntityTransactionalQueryContext,
processedEntityIdentifiersFromTransitiveDeletions: Set<string>,
skipDatabaseDeletion: boolean,
cascadingDeleteCause: EntityMutationTriggerDeleteCascadeInfo | null
): Promise<Result<void>> {
const authorizeDeleteResult = await asyncResult( const authorizeDeleteResult = await asyncResult(
this.privacyPolicy.authorizeDeleteAsync( this.privacyPolicy.authorizeDeleteAsync(this.viewerContext, this.queryContext, this.entity)
this.viewerContext,
queryContext,
this.entity,
this.metricsAdapter
)
); );
if (!authorizeDeleteResult.ok) { if (!authorizeDeleteResult.ok) {
return authorizeDeleteResult; return authorizeDeleteResult;
} }
await this.processEntityDeletionForInboundEdgesAsync( const id = this.entity.getID();
this.entity,
queryContext,
processedEntityIdentifiersFromTransitiveDeletions,
cascadingDeleteCause
);
await this.executeMutationTriggersAsync( await this.databaseAdapter.deleteAsync(this.queryContext, this.entityConfiguration.idField, id);
this.mutationTriggers.beforeAll,
queryContext,
this.entity,
{ type: EntityMutationType.DELETE, cascadingDeleteCause }
);
await this.executeMutationTriggersAsync(
this.mutationTriggers.beforeDelete,
queryContext,
this.entity,
{ type: EntityMutationType.DELETE, cascadingDeleteCause }
);
if (!skipDatabaseDeletion) { const entityLoader = this.entityLoaderFactory.forLoad(this.viewerContext, this.queryContext);
await this.databaseAdapter.deleteAsync( await entityLoader.invalidateFieldsAsync(this.entity.getAllFields());
queryContext,
this.entityConfiguration.idField,
this.entity.getID()
);
}
const entityLoader = this.entityLoaderFactory.forLoad(this.viewerContext, queryContext);
queryContext.appendPostCommitInvalidationCallback(
entityLoader.invalidateFieldsAsync.bind(entityLoader, this.entity.getAllDatabaseFields())
);
await this.executeMutationTriggersAsync(
this.mutationTriggers.afterDelete,
queryContext,
this.entity,
{ type: EntityMutationType.DELETE, cascadingDeleteCause }
);
await this.executeMutationTriggersAsync(
this.mutationTriggers.afterAll,
queryContext,
this.entity,
{ type: EntityMutationType.DELETE, cascadingDeleteCause }
);
queryContext.appendPostCommitCallback(
this.executeNonTransactionalMutationTriggersAsync.bind(
this,
this.mutationTriggers.afterCommit,
this.entity,
{ type: EntityMutationType.DELETE, cascadingDeleteCause }
)
);
return result(); return result();
} }
/**
* Finds all entities referencing the specified entity and either deletes them, nullifies
* their references to the specified entity, or invalidates the cache depending on the
* {@link OnDeleteBehavior} of the field referencing the specified entity.
*
* @remarks
* This works by doing reverse fan-out queries:
* 1. Load all entity configurations of entity types that reference this type of entity
* 2. For each entity configuration, find all fields that contain edges to this type of entity
* 3. For each edge field, load all entities with an edge from target entity to this entity via that field
* 4. Perform desired OnDeleteBehavior for entities
*
* @param entity - entity to find all references to
*/
private async processEntityDeletionForInboundEdgesAsync(
entity: TEntity,
queryContext: EntityTransactionalQueryContext,
processedEntityIdentifiers: Set<string>,
cascadingDeleteCause: EntityMutationTriggerDeleteCascadeInfo | null
): Promise<void> {
// prevent infinite reference cycles by keeping track of entities already processed
if (processedEntityIdentifiers.has(entity.getUniqueIdentifier())) {
return;
}
processedEntityIdentifiers.add(entity.getUniqueIdentifier());
const companionDefinition = (
entity.constructor as any
).getCompanionDefinition() as EntityCompanionDefinition<
TFields,
TID,
TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
>;
const entityConfiguration = companionDefinition.entityConfiguration;
const inboundEdges = entityConfiguration.getInboundEdges();
await Promise.all(
inboundEdges.map(async (entityClass) => {
return await mapMapAsync(
entityClass.getCompanionDefinition().entityConfiguration.schema,
async (fieldDefinition, fieldName) => {
const association = fieldDefinition.association;
if (!association) {
return;
}
const associatedConfiguration = association
.getAssociatedEntityClass()
.getCompanionDefinition().entityConfiguration;
if (associatedConfiguration !== entityConfiguration) {
return;
}
const associatedEntityLookupByField = association.associatedEntityLookupByField;
const loaderFactory = entity
.getViewerContext()
.getViewerScopedEntityCompanionForClass(entityClass)
.getLoaderFactory();
const mutatorFactory = entity
.getViewerContext()
.getViewerScopedEntityCompanionForClass(entityClass)
.getMutatorFactory();
let inboundReferenceEntities: readonly ReadonlyEntity<any, any, any, any>[];
if (associatedEntityLookupByField) {
inboundReferenceEntities = await loaderFactory
.forLoad(queryContext)
.enforcing()
.loadManyByFieldEqualingAsync(
fieldName,
entity.getField(associatedEntityLookupByField as any)
);
} else {
inboundReferenceEntities = await loaderFactory
.forLoad(queryContext)
.enforcing()
.loadManyByFieldEqualingAsync(fieldName, entity.getID());
}
switch (association.edgeDeletionBehavior) {
case undefined:
case EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE: {
await Promise.all(
inboundReferenceEntities.map((inboundReferenceEntity) =>
mutatorFactory
.forDelete(inboundReferenceEntity, queryContext)
.deleteInTransactionAsync(
processedEntityIdentifiers,
/* skipDatabaseDeletion */ true, // deletion is handled by DB
{
entity,
cascadingDeleteCause,
}
)
)
);
break;
}
case EntityEdgeDeletionBehavior.SET_NULL: {
await Promise.all(
inboundReferenceEntities.map((inboundReferenceEntity) =>
mutatorFactory
.forUpdate(inboundReferenceEntity, queryContext)
.setField(fieldName, null)
.enforceUpdateAsync()
)
);
break;
}
case EntityEdgeDeletionBehavior.CASCADE_DELETE: {
await Promise.all(
inboundReferenceEntities.map((inboundReferenceEntity) =>
mutatorFactory
.forDelete(inboundReferenceEntity, queryContext)
.deleteInTransactionAsync(
processedEntityIdentifiers,
/* skipDatabaseDeletion */ false,
{
entity,
cascadingDeleteCause,
}
)
)
);
}
}
}
);
})
);
}
} }
+9 -40
View File
@@ -2,8 +2,6 @@ import Entity, { IEntityClass } from './Entity';
import EntityConfiguration from './EntityConfiguration'; import EntityConfiguration from './EntityConfiguration';
import EntityDatabaseAdapter from './EntityDatabaseAdapter'; import EntityDatabaseAdapter from './EntityDatabaseAdapter';
import EntityLoaderFactory from './EntityLoaderFactory'; import EntityLoaderFactory from './EntityLoaderFactory';
import EntityMutationTriggerConfiguration from './EntityMutationTriggerConfiguration';
import EntityMutationValidator from './EntityMutationValidator';
import { CreateMutator, UpdateMutator, DeleteMutator } from './EntityMutator'; import { CreateMutator, UpdateMutator, DeleteMutator } from './EntityMutator';
import EntityPrivacyPolicy from './EntityPrivacyPolicy'; import EntityPrivacyPolicy from './EntityPrivacyPolicy';
import { EntityQueryContext } from './EntityQueryContext'; import { EntityQueryContext } from './EntityQueryContext';
@@ -14,18 +12,11 @@ import IEntityMetricsAdapter from './metrics/IEntityMetricsAdapter';
* The primary interface for creating, mutating, and deleting entities. * The primary interface for creating, mutating, and deleting entities.
*/ */
export default class EntityMutatorFactory< export default class EntityMutatorFactory<
TFields,
TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext,
TEntity extends Entity<TFields, TID, TViewerContext, TSelectedFields>,
TPrivacyPolicy extends EntityPrivacyPolicy<
TFields, TFields,
TID, TID,
TViewerContext, TViewerContext extends ViewerContext,
TEntity, TEntity extends Entity<TFields, TID, TViewerContext>,
TSelectedFields TPrivacyPolicy extends EntityPrivacyPolicy<TFields, TID, TViewerContext, TEntity>
>,
TSelectedFields extends keyof TFields = keyof TFields
> { > {
constructor( constructor(
private readonly entityConfiguration: EntityConfiguration<TFields>, private readonly entityConfiguration: EntityConfiguration<TFields>,
@@ -34,31 +25,15 @@ export default class EntityMutatorFactory<
TID, TID,
TViewerContext, TViewerContext,
TEntity, TEntity,
TPrivacyPolicy, TPrivacyPolicy
TSelectedFields
>, >,
private readonly privacyPolicy: TPrivacyPolicy, private readonly privacyPolicy: TPrivacyPolicy,
private readonly mutationValidators: EntityMutationValidator<
TFields,
TID,
TViewerContext,
TEntity,
TSelectedFields
>[],
private readonly mutationTriggers: EntityMutationTriggerConfiguration<
TFields,
TID,
TViewerContext,
TEntity,
TSelectedFields
>,
private readonly entityLoaderFactory: EntityLoaderFactory< private readonly entityLoaderFactory: EntityLoaderFactory<
TFields, TFields,
TID, TID,
TViewerContext, TViewerContext,
TEntity, TEntity,
TPrivacyPolicy, TPrivacyPolicy
TSelectedFields
>, >,
private readonly databaseAdapter: EntityDatabaseAdapter<TFields>, private readonly databaseAdapter: EntityDatabaseAdapter<TFields>,
private readonly metricsAdapter: IEntityMetricsAdapter private readonly metricsAdapter: IEntityMetricsAdapter
@@ -73,15 +48,13 @@ export default class EntityMutatorFactory<
forCreate( forCreate(
viewerContext: TViewerContext, viewerContext: TViewerContext,
queryContext: EntityQueryContext queryContext: EntityQueryContext
): CreateMutator<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy, TSelectedFields> { ): CreateMutator<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy> {
return new CreateMutator( return new CreateMutator(
viewerContext, viewerContext,
queryContext, queryContext,
this.entityConfiguration, this.entityConfiguration,
this.entityClass, this.entityClass,
this.privacyPolicy, this.privacyPolicy,
this.mutationValidators,
this.mutationTriggers,
this.entityLoaderFactory, this.entityLoaderFactory,
this.databaseAdapter, this.databaseAdapter,
this.metricsAdapter this.metricsAdapter
@@ -97,19 +70,17 @@ export default class EntityMutatorFactory<
forUpdate( forUpdate(
existingEntity: TEntity, existingEntity: TEntity,
queryContext: EntityQueryContext queryContext: EntityQueryContext
): UpdateMutator<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy, TSelectedFields> { ): UpdateMutator<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy> {
return new UpdateMutator( return new UpdateMutator(
existingEntity.getViewerContext(), existingEntity.getViewerContext(),
queryContext, queryContext,
this.entityConfiguration, this.entityConfiguration,
this.entityClass, this.entityClass,
this.privacyPolicy, this.privacyPolicy,
this.mutationValidators,
this.mutationTriggers,
this.entityLoaderFactory, this.entityLoaderFactory,
this.databaseAdapter, this.databaseAdapter,
this.metricsAdapter, this.metricsAdapter,
existingEntity existingEntity.getAllFields()
); );
} }
@@ -121,15 +92,13 @@ export default class EntityMutatorFactory<
forDelete( forDelete(
existingEntity: TEntity, existingEntity: TEntity,
queryContext: EntityQueryContext queryContext: EntityQueryContext
): DeleteMutator<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy, TSelectedFields> { ): DeleteMutator<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy> {
return new DeleteMutator( return new DeleteMutator(
existingEntity.getViewerContext(), existingEntity.getViewerContext(),
queryContext, queryContext,
this.entityConfiguration, this.entityConfiguration,
this.entityClass, this.entityClass,
this.privacyPolicy, this.privacyPolicy,
this.mutationValidators,
this.mutationTriggers,
this.entityLoaderFactory, this.entityLoaderFactory,
this.databaseAdapter, this.databaseAdapter,
this.metricsAdapter, this.metricsAdapter,
+29 -108
View File
@@ -1,10 +1,7 @@
import { EntityNotAuthorizedError } from './EntityErrors';
import { EntityQueryContext } from './EntityQueryContext'; import { EntityQueryContext } from './EntityQueryContext';
import ReadonlyEntity from './ReadonlyEntity'; import ReadonlyEntity from './ReadonlyEntity';
import ViewerContext from './ViewerContext'; import ViewerContext from './ViewerContext';
import EntityNotAuthorizedError from './errors/EntityNotAuthorizedError';
import IEntityMetricsAdapter, {
EntityMetricsAuthorizationResult,
} from './metrics/IEntityMetricsAdapter';
import PrivacyPolicyRule, { RuleEvaluationResult } from './rules/PrivacyPolicyRule'; import PrivacyPolicyRule, { RuleEvaluationResult } from './rules/PrivacyPolicyRule';
export enum EntityPrivacyPolicyEvaluationMode { export enum EntityPrivacyPolicyEvaluationMode {
@@ -15,25 +12,20 @@ export enum EntityPrivacyPolicyEvaluationMode {
export type EntityPrivacyPolicyEvaluator< export type EntityPrivacyPolicyEvaluator<
TFields, TFields,
TID extends NonNullable<TFields[TSelectedFields]>, TID,
TViewerContext extends ViewerContext, TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>, TEntity extends ReadonlyEntity<TFields, TID, TViewerContext>
TSelectedFields extends keyof TFields = keyof TFields
> = > =
| { | {
mode: EntityPrivacyPolicyEvaluationMode.ENFORCE; mode: EntityPrivacyPolicyEvaluationMode.ENFORCE;
} }
| { | {
mode: EntityPrivacyPolicyEvaluationMode.DRY_RUN; mode: EntityPrivacyPolicyEvaluationMode.DRY_RUN;
denyHandler: ( denyHandler: (error: EntityNotAuthorizedError<TFields, TID, TViewerContext, TEntity>) => void;
error: EntityNotAuthorizedError<TFields, TID, TViewerContext, TEntity, TSelectedFields>
) => void;
} }
| { | {
mode: EntityPrivacyPolicyEvaluationMode.ENFORCE_AND_LOG; mode: EntityPrivacyPolicyEvaluationMode.ENFORCE_AND_LOG;
denyHandler: ( denyHandler: (error: EntityNotAuthorizedError<TFields, TID, TViewerContext, TEntity>) => void;
error: EntityNotAuthorizedError<TFields, TID, TViewerContext, TEntity, TSelectedFields>
) => void;
}; };
export enum EntityAuthorizationAction { export enum EntityAuthorizationAction {
@@ -66,38 +58,33 @@ export enum EntityAuthorizationAction {
*/ */
export default abstract class EntityPrivacyPolicy< export default abstract class EntityPrivacyPolicy<
TFields, TFields,
TID extends NonNullable<TFields[TSelectedFields]>, TID,
TViewerContext extends ViewerContext, TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>, TEntity extends ReadonlyEntity<TFields, TID, TViewerContext>
TSelectedFields extends keyof TFields = keyof TFields
> { > {
protected readonly createRules: readonly PrivacyPolicyRule< protected readonly createRules: readonly PrivacyPolicyRule<
TFields, TFields,
TID, TID,
TViewerContext, TViewerContext,
TEntity, TEntity
TSelectedFields
>[] = []; >[] = [];
protected readonly readRules: readonly PrivacyPolicyRule< protected readonly readRules: readonly PrivacyPolicyRule<
TFields, TFields,
TID, TID,
TViewerContext, TViewerContext,
TEntity, TEntity
TSelectedFields
>[] = []; >[] = [];
protected readonly updateRules: readonly PrivacyPolicyRule< protected readonly updateRules: readonly PrivacyPolicyRule<
TFields, TFields,
TID, TID,
TViewerContext, TViewerContext,
TEntity, TEntity
TSelectedFields
>[] = []; >[] = [];
protected readonly deleteRules: readonly PrivacyPolicyRule< protected readonly deleteRules: readonly PrivacyPolicyRule<
TFields, TFields,
TID, TID,
TViewerContext, TViewerContext,
TEntity, TEntity
TSelectedFields
>[] = []; >[] = [];
/** /**
@@ -110,7 +97,7 @@ export default abstract class EntityPrivacyPolicy<
*/ */
protected getPrivacyPolicyEvaluator( protected getPrivacyPolicyEvaluator(
_viewerContext: TViewerContext _viewerContext: TViewerContext
): EntityPrivacyPolicyEvaluator<TFields, TID, TViewerContext, TEntity, TSelectedFields> { ): EntityPrivacyPolicyEvaluator<TFields, TID, TViewerContext, TEntity> {
return { return {
mode: EntityPrivacyPolicyEvaluationMode.ENFORCE, mode: EntityPrivacyPolicyEvaluationMode.ENFORCE,
}; };
@@ -127,16 +114,14 @@ export default abstract class EntityPrivacyPolicy<
async authorizeCreateAsync( async authorizeCreateAsync(
viewerContext: TViewerContext, viewerContext: TViewerContext,
queryContext: EntityQueryContext, queryContext: EntityQueryContext,
entity: TEntity, entity: TEntity
metricsAdapter: IEntityMetricsAdapter
): Promise<TEntity> { ): Promise<TEntity> {
return await this.authorizeForRulesetAsync( return await this.authorizeForRulesetAsync(
this.createRules, this.createRules,
viewerContext, viewerContext,
queryContext, queryContext,
entity, entity,
EntityAuthorizationAction.CREATE, EntityAuthorizationAction.CREATE
metricsAdapter
); );
} }
@@ -151,16 +136,14 @@ export default abstract class EntityPrivacyPolicy<
async authorizeReadAsync( async authorizeReadAsync(
viewerContext: TViewerContext, viewerContext: TViewerContext,
queryContext: EntityQueryContext, queryContext: EntityQueryContext,
entity: TEntity, entity: TEntity
metricsAdapter: IEntityMetricsAdapter
): Promise<TEntity> { ): Promise<TEntity> {
return await this.authorizeForRulesetAsync( return await this.authorizeForRulesetAsync(
this.readRules, this.readRules,
viewerContext, viewerContext,
queryContext, queryContext,
entity, entity,
EntityAuthorizationAction.READ, EntityAuthorizationAction.READ
metricsAdapter
); );
} }
@@ -175,16 +158,14 @@ export default abstract class EntityPrivacyPolicy<
async authorizeUpdateAsync( async authorizeUpdateAsync(
viewerContext: TViewerContext, viewerContext: TViewerContext,
queryContext: EntityQueryContext, queryContext: EntityQueryContext,
entity: TEntity, entity: TEntity
metricsAdapter: IEntityMetricsAdapter
): Promise<TEntity> { ): Promise<TEntity> {
return await this.authorizeForRulesetAsync( return await this.authorizeForRulesetAsync(
this.updateRules, this.updateRules,
viewerContext, viewerContext,
queryContext, queryContext,
entity, entity,
EntityAuthorizationAction.UPDATE, EntityAuthorizationAction.UPDATE
metricsAdapter
); );
} }
@@ -199,137 +180,82 @@ export default abstract class EntityPrivacyPolicy<
async authorizeDeleteAsync( async authorizeDeleteAsync(
viewerContext: TViewerContext, viewerContext: TViewerContext,
queryContext: EntityQueryContext, queryContext: EntityQueryContext,
entity: TEntity, entity: TEntity
metricsAdapter: IEntityMetricsAdapter
): Promise<TEntity> { ): Promise<TEntity> {
return await this.authorizeForRulesetAsync( return await this.authorizeForRulesetAsync(
this.deleteRules, this.deleteRules,
viewerContext, viewerContext,
queryContext, queryContext,
entity, entity,
EntityAuthorizationAction.DELETE, EntityAuthorizationAction.DELETE
metricsAdapter
); );
} }
private async authorizeForRulesetAsync( private async authorizeForRulesetAsync(
ruleset: readonly PrivacyPolicyRule<TFields, TID, TViewerContext, TEntity, TSelectedFields>[], ruleset: readonly PrivacyPolicyRule<TFields, TID, TViewerContext, TEntity>[],
viewerContext: TViewerContext, viewerContext: TViewerContext,
queryContext: EntityQueryContext, queryContext: EntityQueryContext,
entity: TEntity, entity: TEntity,
action: EntityAuthorizationAction, action: EntityAuthorizationAction
metricsAdapter: IEntityMetricsAdapter
): Promise<TEntity> { ): Promise<TEntity> {
const privacyPolicyEvaluator = this.getPrivacyPolicyEvaluator(viewerContext); const privacyPolicyEvaluator = this.getPrivacyPolicyEvaluator(viewerContext);
switch (privacyPolicyEvaluator.mode) { switch (privacyPolicyEvaluator.mode) {
case EntityPrivacyPolicyEvaluationMode.ENFORCE: case EntityPrivacyPolicyEvaluationMode.ENFORCE:
try { return await this.authorizeForRulesetInnerAsync(
const result = await this.authorizeForRulesetInnerAsync(
ruleset, ruleset,
viewerContext, viewerContext,
queryContext, queryContext,
entity, entity,
action action
); );
metricsAdapter.logAuthorizationEvent({
entityClassName: entity.constructor.name,
action,
evaluationResult: EntityMetricsAuthorizationResult.ALLOW,
privacyPolicyEvaluationMode: privacyPolicyEvaluator.mode,
});
return result;
} catch (e) {
if (!(e instanceof EntityNotAuthorizedError)) {
throw e;
}
metricsAdapter.logAuthorizationEvent({
entityClassName: entity.constructor.name,
action,
evaluationResult: EntityMetricsAuthorizationResult.DENY,
privacyPolicyEvaluationMode: privacyPolicyEvaluator.mode,
});
throw e;
}
case EntityPrivacyPolicyEvaluationMode.ENFORCE_AND_LOG: case EntityPrivacyPolicyEvaluationMode.ENFORCE_AND_LOG:
try { try {
const result = await this.authorizeForRulesetInnerAsync( return await this.authorizeForRulesetInnerAsync(
ruleset, ruleset,
viewerContext, viewerContext,
queryContext, queryContext,
entity, entity,
action action
); );
metricsAdapter.logAuthorizationEvent({
entityClassName: entity.constructor.name,
action,
evaluationResult: EntityMetricsAuthorizationResult.ALLOW,
privacyPolicyEvaluationMode: privacyPolicyEvaluator.mode,
});
return result;
} catch (e) { } catch (e) {
if (!(e instanceof EntityNotAuthorizedError)) { if (!(e instanceof EntityNotAuthorizedError)) {
throw e; throw e;
} }
privacyPolicyEvaluator.denyHandler(e); privacyPolicyEvaluator.denyHandler(e);
metricsAdapter.logAuthorizationEvent({
entityClassName: entity.constructor.name,
action,
evaluationResult: EntityMetricsAuthorizationResult.DENY,
privacyPolicyEvaluationMode: privacyPolicyEvaluator.mode,
});
throw e; throw e;
} }
case EntityPrivacyPolicyEvaluationMode.DRY_RUN: case EntityPrivacyPolicyEvaluationMode.DRY_RUN:
try { try {
const result = await this.authorizeForRulesetInnerAsync( return await this.authorizeForRulesetInnerAsync(
ruleset, ruleset,
viewerContext, viewerContext,
queryContext, queryContext,
entity, entity,
action action
); );
metricsAdapter.logAuthorizationEvent({
entityClassName: entity.constructor.name,
action,
evaluationResult: EntityMetricsAuthorizationResult.ALLOW,
privacyPolicyEvaluationMode: privacyPolicyEvaluator.mode,
});
return result;
} catch (e) { } catch (e) {
if (!(e instanceof EntityNotAuthorizedError)) { if (!(e instanceof EntityNotAuthorizedError)) {
throw e; throw e;
} }
privacyPolicyEvaluator.denyHandler(e); privacyPolicyEvaluator.denyHandler(e);
metricsAdapter.logAuthorizationEvent({
entityClassName: entity.constructor.name,
action,
evaluationResult: EntityMetricsAuthorizationResult.DENY,
privacyPolicyEvaluationMode: privacyPolicyEvaluator.mode,
});
return entity; return entity;
} }
} }
} }
private async authorizeForRulesetInnerAsync( private async authorizeForRulesetInnerAsync(
ruleset: readonly PrivacyPolicyRule<TFields, TID, TViewerContext, TEntity, TSelectedFields>[], ruleset: readonly PrivacyPolicyRule<TFields, TID, TViewerContext, TEntity>[],
viewerContext: TViewerContext, viewerContext: TViewerContext,
queryContext: EntityQueryContext, queryContext: EntityQueryContext,
entity: TEntity, entity: TEntity,
action: EntityAuthorizationAction action: EntityAuthorizationAction
): Promise<TEntity> { ): Promise<TEntity> {
for (let i = 0; i < ruleset.length; i++) { for (let i = 0; i < ruleset.length; i++) {
const rule = ruleset[i]!; const rule = ruleset[i];
const ruleEvaluationResult = await rule.evaluateAsync(viewerContext, queryContext, entity); const ruleEvaluationResult = await rule.evaluateAsync(viewerContext, queryContext, entity);
switch (ruleEvaluationResult) { switch (ruleEvaluationResult) {
case RuleEvaluationResult.DENY: case RuleEvaluationResult.DENY:
throw new EntityNotAuthorizedError< throw new EntityNotAuthorizedError(entity, viewerContext, action, i);
TFields,
TID,
TViewerContext,
TEntity,
TSelectedFields
>(entity, viewerContext, action, i);
case RuleEvaluationResult.SKIP: case RuleEvaluationResult.SKIP:
continue; continue;
case RuleEvaluationResult.ALLOW: case RuleEvaluationResult.ALLOW:
@@ -339,11 +265,6 @@ export default abstract class EntityPrivacyPolicy<
} }
} }
throw new EntityNotAuthorizedError<TFields, TID, TViewerContext, TEntity, TSelectedFields>( throw new EntityNotAuthorizedError(entity, viewerContext, action, -1);
entity,
viewerContext,
action,
-1
);
} }
} }
+4 -97
View File
@@ -1,13 +1,3 @@
import assert from 'assert';
import EntityQueryContextProvider from './EntityQueryContextProvider';
export type PostCommitCallback = (...args: any) => Promise<any>;
export type PreCommitCallback = (
queryContext: EntityTransactionalQueryContext,
...args: any
) => Promise<any>;
/** /**
* Entity framework representation of transactional and non-transactional database * Entity framework representation of transactional and non-transactional database
* query execution units. * query execution units.
@@ -23,99 +13,16 @@ export abstract class EntityQueryContext {
getQueryInterface(): any { getQueryInterface(): any {
return this.queryInterface; return this.queryInterface;
} }
abstract runInTransactionIfNotInTransactionAsync<T>(
transactionScope: (queryContext: EntityTransactionalQueryContext) => Promise<T>
): Promise<T>;
}
export class EntityNonTransactionalQueryContext extends EntityQueryContext {
constructor(
queryInterface: any,
private readonly entityQueryContextProvider: EntityQueryContextProvider
) {
super(queryInterface);
}
isInTransaction(): boolean {
return false;
}
async runInTransactionIfNotInTransactionAsync<T>(
transactionScope: (queryContext: EntityTransactionalQueryContext) => Promise<T>
): Promise<T> {
return await this.entityQueryContextProvider.runInTransactionAsync(transactionScope);
}
} }
export class EntityTransactionalQueryContext extends EntityQueryContext { export class EntityTransactionalQueryContext extends EntityQueryContext {
private readonly postCommitInvalidationCallbacks: PostCommitCallback[] = [];
private readonly postCommitCallbacks: PostCommitCallback[] = [];
private readonly preCommitCallbacks: { callback: PreCommitCallback; order: number }[] = [];
/**
* Schedule a pre-commit callback. These will be run within the transaction right before it is
* committed, and will be run in the order specified. Ordering of callbacks scheduled with the
* same value for the order parameter is undefined within that ordering group.
* @param callback - callback to schedule
* @param order - order in which this should be run relative to other scheduled pre-commit callbacks,
* with higher numbers running later than lower numbers.
*/
public appendPreCommitCallback(callback: PreCommitCallback, order: number): void {
assert(
order >= Number.MIN_SAFE_INTEGER && order <= Number.MAX_SAFE_INTEGER,
`Invalid order specified: ${order}`
);
this.preCommitCallbacks.push({ callback, order });
}
/**
* Schedule a post-commit cache invalidation callback. These are run before normal
* post-commit callbacks in order to have cache consistency in normal post-commit callbacks.
* @param callback - callback to schedule
*/
public appendPostCommitInvalidationCallback(callback: PostCommitCallback): void {
this.postCommitInvalidationCallbacks.push(callback);
}
/**
* Schedule a post-commit callback. These will be run after the transaction has
* been committed.
* @param callback - callback to schedule
*/
public appendPostCommitCallback(callback: PostCommitCallback): void {
this.postCommitCallbacks.push(callback);
}
public async runPreCommitCallbacksAsync(): Promise<void> {
const callbacks = [...this.preCommitCallbacks]
.sort((a, b) => a.order - b.order)
.map((c) => c.callback);
this.preCommitCallbacks.length = 0;
for (const callback of callbacks) {
await callback(this);
}
}
public async runPostCommitCallbacksAsync(): Promise<void> {
const invalidationCallbacks = [...this.postCommitInvalidationCallbacks];
this.postCommitInvalidationCallbacks.length = 0;
await Promise.all(invalidationCallbacks.map((callback) => callback()));
const callbacks = [...this.postCommitCallbacks];
this.postCommitCallbacks.length = 0;
await Promise.all(callbacks.map((callback) => callback()));
}
isInTransaction(): boolean { isInTransaction(): boolean {
return true; return true;
} }
}
async runInTransactionIfNotInTransactionAsync<T>( export class EntityNonTransactionalQueryContext extends EntityQueryContext {
transactionScope: (queryContext: EntityTransactionalQueryContext) => Promise<T> isInTransaction(): boolean {
): Promise<T> { return false;
return await transactionScope(this);
} }
} }
@@ -1,47 +0,0 @@
import {
EntityTransactionalQueryContext,
EntityNonTransactionalQueryContext,
} from './EntityQueryContext';
/**
* A query context provider vends transactional and non-transactional query contexts.
*/
export default abstract class EntityQueryContextProvider {
/**
* Vend a regular (non-transactional) entity query context.
*/
public getQueryContext(): EntityNonTransactionalQueryContext {
return new EntityNonTransactionalQueryContext(this.getQueryInterface(), this);
}
/**
* Get the query interface for constructing a query context.
*/
protected abstract getQueryInterface(): any;
/**
* Vend a transaction runner for use in {@link runInTransactionAsync}.
*/
protected abstract createTransactionRunner<T>(): (
transactionScope: (queryInterface: any) => Promise<T>
) => Promise<T>;
/**
* Start a transaction and execute the provided transaction-scoped closure within the transaction.
* @param transactionScope - async callback to execute within the transaction
*/
async runInTransactionAsync<T>(
transactionScope: (queryContext: EntityTransactionalQueryContext) => Promise<T>
): Promise<T> {
const [returnedValue, queryContext] = await this.createTransactionRunner<
[T, EntityTransactionalQueryContext]
>()(async (queryInterface) => {
const queryContext = new EntityTransactionalQueryContext(queryInterface);
const result = await transactionScope(queryContext);
await queryContext.runPreCommitCallbacksAsync();
return [result, queryContext];
});
await queryContext.runPostCommitCallbacksAsync();
return returnedValue;
}
}
@@ -1,112 +0,0 @@
import { Result } from '@expo/results';
import EntityLoader from './EntityLoader';
import EntityPrivacyPolicy from './EntityPrivacyPolicy';
import ReadonlyEntity from './ReadonlyEntity';
import ViewerContext from './ViewerContext';
import { mapMap } from './utils/collections/maps';
/**
* An interface that knows how to load many objects from a cache by load params and invalidate
* those same load params.
*/
export interface ISecondaryEntityCache<TFields, TLoadParams> {
/**
* Read-through cache function.
* @param loadParamsArray - array of loadParams to load from the cache
* @param fetcher - closure used to provide underlying data source objects for loadParams
* @returns map from loadParams to the entity field object
*/
loadManyThroughAsync(
loadParamsArray: readonly Readonly<TLoadParams>[],
fetcher: (
fetcherLoadParamsArray: readonly Readonly<TLoadParams>[]
) => Promise<ReadonlyMap<Readonly<TLoadParams>, Readonly<TFields> | null>>
): Promise<ReadonlyMap<Readonly<TLoadParams>, Readonly<TFields> | null>>;
/**
* Invalidate the cache for objects cached by loadParams.
*
* @param loadParamsArray - array of load params objects to invalidate
*/
invalidateManyAsync(loadParamsArray: readonly Readonly<TLoadParams>[]): Promise<void>;
}
/**
* A secondary cache loader allows for arbitrary cache keying for load params, which are a set of params used to load
* a single entity field object.
*
* Note that this cache cannot be automatically invalidated like other entity caches so it must be manually invalidated
* when the underlying data of a cache key could be stale.
*
* This is most commonly used to further optimize hot paths that cannot make use of normal entity cache loading
* due to use of a non-unique-field-based {@link EntityLoader} method like `loadManyByFieldEqualityConjunctionAsync` or
* `loadManyByRawWhereClauseAsync`.
*/
export default abstract class EntitySecondaryCacheLoader<
TLoadParams,
TFields,
TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
TPrivacyPolicy extends EntityPrivacyPolicy<
TFields,
TID,
TViewerContext,
TEntity,
TSelectedFields
>,
TSelectedFields extends keyof TFields = keyof TFields
> {
constructor(
private readonly secondaryEntityCache: ISecondaryEntityCache<TFields, TLoadParams>,
protected readonly entityLoader: EntityLoader<
TFields,
TID,
TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
>
) {}
/**
* Load many by load params objects
*
* @param loadParamsArray - array of loadParams to load through the cache
*/
public async loadManyAsync(
loadParamsArray: readonly Readonly<TLoadParams>[]
): Promise<ReadonlyMap<Readonly<TLoadParams>, Result<TEntity> | null>> {
const loadParamsToFieldObjects = await this.secondaryEntityCache.loadManyThroughAsync(
loadParamsArray,
this.fetchObjectsFromDatabaseAsync.bind(this)
);
// convert value to and from array to reuse complex code
const entitiesMap = await this.entityLoader.constructAndAuthorizeEntitiesAsync(
mapMap(loadParamsToFieldObjects, (fieldObject) => (fieldObject ? [fieldObject] : []))
);
return mapMap(entitiesMap, (fieldObjects) => fieldObjects[0] ?? null);
}
/**
* Invalidate the cache for objects cached by loadParams.
*
* @param loadParamsArray - array of load params objects to invalidate
*/
public async invalidateManyAsync(
loadParamsArray: readonly Readonly<TLoadParams>[]
): Promise<void> {
await this.secondaryEntityCache.invalidateManyAsync(loadParamsArray);
}
/**
* Load through method called to fetch objects when load params objects are not in the cache
*
* @param loadParamsArray - array of load params objects to load
*/
protected abstract fetchObjectsFromDatabaseAsync(
loadParamsArray: readonly Readonly<TLoadParams>[]
): Promise<ReadonlyMap<Readonly<Readonly<TLoadParams>>, Readonly<TFields> | null>>;
}
Loaded 100 of 174 files, more files were not shown because too many files have changed in this diff. Show more