Compare commits

...
Author SHA1 Message Date
Quinlan Jung b61341057b [cache-adapter-local-memory] export classes 2022-03-09 17:53:23 -08:00
Quinlan Jung bc378b6c79 chore: better variable and method naming (#160) 2022-02-16 18:37:51 -08:00
Quinlan Jung fefa4100fc v0.24.0 (#159)
* v0.24.0

* changelog
2022-02-16 18:37:39 -08:00
Quinlan Jung 85d35752fa feat: add local memory cache package (#158)
* feat: add local memory cache package

* some pr feedback

* more tests
2022-02-15 18:44:20 -08:00
Quinlan Jung 09d48a617c chore: refactor cachers to use an abstract class and interface (#157)
* chore: refactor cachers to use an abstract class and interface

* fix lint

* changelog
2022-02-14 18:42:27 -08:00
Will Schurman 0adcf7238f [entity] Add docblocks for EntityFields and EntityFieldDefinition (#156) 2022-02-11 14:20:45 -08:00
Quinlan Jung 41bd0722c9 v0.23.0 (#155)
* v0.23.0

* update changelog
2022-02-09 13:59:40 -08:00
Quinlan Jung 03a1fce067 move responsibility of transformer to generic redis cacher (#153)
* will's commit

* integ tests

* fix codecov
2022-02-07 15:04:02 -08:00
James Ide a2c6dd7488 v0.22.0 (#151) 2022-02-03 18:52:25 -08:00
James Ide e1b0ee5281 feat: Upgrade knex to 1.x in @expo/entity-database-adapter-knex (#150)
Upgrades knex to 1.x internally. This changes the behavior of `returning` but not in a way that affects how the knex adapter uses it: `returning('*')` behaves the same as before.

This does, however, change the TypeScript declarations for knex, which changed from:
```
 Knex<TRecord extends {} = any, TResult = unknown[]>
```
to:
```
 Knex<TRecord extends {} = any, TResult = Record<string, any>[]>
```
2022-02-03 18:35:55 -08:00
Will Schurman 5d39fa928f v0.21.0 (#148) 2022-01-03 11:04:02 -08:00
Will Schurman f1d9847210 feat: add pre-commit callbacks on EntityQueryContext (#147) 2022-01-03 09:37:19 -08:00
Will Schurman 99657196f8 v0.20.0 (#146) 2021-12-29 10:50:58 -08:00
Will Schurman 3727191767 feat: add cascading deletion info (#145) 2021-12-29 10:39:22 -08:00
Will Schurman efa131ef29 fix: separate post-commit invalidation callbacks from trigger and user callbacks (#144) 2021-12-23 12:41:29 -08:00
Will Schurman c4df889d50 v0.19.0 (#143) 2021-10-28 14:35:19 -07:00
Will Schurman 7cf54d83c5 feat: add authorization metrics (#142) 2021-10-24 17:26:40 -07:00
kgc00 1405a9126e feat: add IP support (#138)
* Add @expo/entity-ip-address-field

- adds an entity field for IP addresses, validates both v4 and v6 type IPs
- ip address field functionality moved to its own package
- specified `@expo/entity` as a dev & peer dependency
2021-10-15 10:40:20 -07:00
Will Schurman 677d15174c v0.18.0 (#140) 2021-10-14 14:51:59 -07:00
Will Schurman 1220477aa3 feat: expose describeFieldTestCase for use in other packages (#139) 2021-10-14 14:41:26 -07:00
Will Schurman 5c7efe1678 feat: use better QueryContext types for transactional triggers and validators (#136) 2021-09-17 17:24:48 -07:00
Will Schurman 4263cb9b8b feat: enable noImplicitOverride tsc setting (#135) 2021-09-07 15:18:22 -07:00
Will Schurman 7612392dbf feat: upgrade TypeScript to 4.4 (#134) 2021-09-07 15:17:39 -07:00
Will Schurman 1201cc065c v0.17.0 (#133) 2021-08-16 11:52:43 -06:00
Hayden Daly 2f2d963cf9 feat: Add IntField and FloatField, deprecate NumberField (#131)
Why
To better represent Postgres' type system, deprecated the type NumberField and replaced with IntField and FloatField.

How
Added the new types to EntityFields.ts. To release, will need to bump minor version so breaking changes aren't propagated.

Test Plan
Update tests using the NumberField to use IntField.
2021-07-08 08:50:10 -07:00
James Ide 944f4d2e07 v0.16.0 (#132) 2021-07-06 21:33:03 -07:00
Will Schurman 2f37dc6a37 feat: allow null field values in loadManyByFieldEqualityConjunctionAsync (#130) 2021-07-01 10:58:34 -07:00
Will Schurman 5401be0ac8 docs: add better docblocks for EntityCompanionDefinition and EntityConfiguration (#129) 2021-06-11 13:08:11 -07:00
Will Schurman ee28fa0a87 v0.15.0 (#127) 2021-05-26 17:20:52 -06:00
Will Schurman 226bffe0a9 fix: upgrade some other packages to fix dependabot alerts (#126) 2021-05-26 15:00:08 -06:00
Will Schurman 7c43edf3de fix: upgrade ioredis, knex, and pg (#125) 2021-05-26 14:59:20 -06:00
Will Schurman 4cba01eb25 feat: secondary cache loader (#123) 2021-05-26 09:52:38 -06:00
Will Schurman d78f452bb3 fix: use column name instead of field name for redis cache key (#124) 2021-05-12 15:59:42 -07:00
Will Schurman eaab0b1f4c v0.14.1 (#122) 2021-03-24 15:31:19 -07:00
Will Schurman 8e8f0dc863 fix: typescript typedef of EntityMutationInfo (#121) 2021-03-24 15:28:10 -07:00
Will Schurman 1621a1c432 v0.14.0 (#120) 2021-03-24 15:05:39 -07:00
Will Schurman ef40fa5e9c feat: add mutation type to validator and trigger execute functions (#119) 2021-03-23 22:07:53 -07:00
Will Schurman b11dbc087b fix: mutation validator and trigger cyclic import structure (#118) 2021-03-23 13:08:00 -07:00
Will Schurman 3ce961d2f2 docs: upgrade typedoc (#117) 2021-02-19 15:01:01 -08:00
Will Schurman 2052838403 docs: fix docblock comment issue (#116) 2021-02-19 14:28:04 -08:00
Will Schurman 9e828f4e0b v0.13.0 (#115) 2021-02-12 09:40:01 -08:00
Will Schurman 022e0e9b88 fix: allow undefined value for all fields (#114) 2021-02-12 09:34:24 -08:00
Will Schurman 6c4d4b03b5 feat: add basic field type runtime validators (#113) 2021-02-08 09:36:29 -08:00
Will Schurman 5a8479f3d9 v0.12.0 (#112) 2021-01-22 09:28:24 -08:00
Will Schurman 045c575713 chore: upgrade more packages (#111) 2021-01-20 12:53:31 -08:00
Will Schurman a032141fbf chore: upgrade typescript to 4.1 (#110) 2021-01-20 12:37:15 -08:00
Will Schurman 72a77f8b68 fix: remove cache and database adaptor flavor enums (#109) 2021-01-20 11:49:45 -08:00
Will Schurman b7309e18a2 fix: move runInTransaction to ViewerContext (#108) 2021-01-20 10:07:13 -08:00
Will Schurman 01b3bfef7c feat: better typing on filterMap for use with predicates (#106) 2021-01-08 10:02:02 -08:00
Will Schurman 83547412a2 v0.11.0 (#105) 2020-12-23 16:49:06 -08:00
Will Schurman fe2917e6b5 fix: add type generic default in test utils (#104) 2020-12-23 16:18:57 -08:00
Will Schurman 29c1a434ab fix: maps utils invariants for collections containing undefined values (#103) 2020-12-23 14:01:20 -08:00
Will Schurman 15546aad97 feat: better cache adapter error handling (#102) 2020-12-23 13:47:22 -08:00
Will Schurman 5208aeedb0 feat: better database adapter error handling (#101) 2020-12-22 18:01:12 -08:00
Will Schurman b8e07f9ddc fix: remove any cast from EntityLoader (#100) 2020-12-21 16:28:28 -08:00
Will Schurman e3b2b235f8 v0.10.0 (#97) 2020-11-16 20:27:33 -08:00
Will Schurman d9603298e0 fix: fix null handling in load paths (try 2) (#96) 2020-11-16 20:03:32 -08:00
Will Schurman b22c734275 v0.9.1 (#94) 2020-11-06 09:11:55 -08:00
Will Schurman 012c05d62d fix: edge deletion behavior cyclic import structure again (#93) 2020-11-06 08:52:37 -08:00
Will Schurman ead9561cc7 v0.9.0 (#92) 2020-11-05 14:40:17 -08:00
Will Schurman d1a6f13396 feat: add ability to invalidate an entity manually (#91) 2020-11-05 12:38:14 -08:00
Will Schurman cca8fc1987 fix: edge deletion behavior cyclic import structure (#89) 2020-11-05 10:08:50 -08:00
Will Schurman 86aa5b291d v0.8.1 (#87) 2020-10-09 12:07:19 -07:00
Will Schurman 4026fc1a61 docs: update to add 2FA token note to release process 2020-10-09 10:59:58 -07:00
Will Schurman a263e74b8a fix!: add entity name to loader counter metric events (#86) 2020-10-09 10:48:00 -07:00
Will Schurman f737fa5b28 v0.8.0 (#85) 2020-10-09 10:11:22 -07:00
Will Schurman 884320ac53 docs: update readme one last time for release process 2020-10-09 10:06:34 -07:00
Will Schurman 40906202cf docs: update readme again for release process 2020-10-09 10:04:30 -07:00
Will Schurman 6cdd720c98 docs: update readme for release process (#84) 2020-10-09 09:58:16 -07:00
Will Schurman f4cdc01c6c feat: add entityClassName to metrics adapter events (#83) 2020-10-08 14:47:20 -07:00
Will Schurman d0cd8d88f2 v0.7.1 (#82) 2020-09-30 20:31:20 -07:00
Will Schurman f98f7d14b6 fix: correct type of runInTransactionAsync (#81) 2020-09-30 20:19:40 -07:00
Will Schurman f4b30cb704 v0.7.0 (#80) 2020-09-30 19:55:22 -07:00
Will Schurman 22aa907b11 docs: fix typedoc (#79) 2020-08-25 14:05:28 -07:00
Will Schurman 9891e74694 fix: upgrade packages (#78) 2020-08-25 13:38:00 -07:00
Will Schurman dbc3c81b35 fix: move cache invalidation to after transaction commit (#77) 2020-08-25 11:16:16 -07:00
Will Schurman 62bfd6c703 fix!: constuctor thrown exceptions for loadMany (#74) 2020-07-29 10:35:28 -07:00
Will Schurman 7e829f30fd fix: fix index export for EntityMutationTrigger and EntityMutationValidator (#73) 2020-07-28 15:09:45 -07:00
Will Schurman cd2df8da4b fix: fix incorrect loader types for field selecitons (#72) 2020-07-28 15:01:33 -07:00
Will Schurman b580a218c0 v0.6.0 (#69) 2020-07-23 14:48:27 -07:00
Will Schurman 547a1efcec fix: separate out EntityMutationValidator (#68) 2020-07-23 14:22:02 -07:00
Quinlan Jung fc4377d883 feat: entity mutation validators (#67)
* entity mutation validators

* pr feedback

* tests
2020-07-23 11:03:20 -07:00
Will Schurman 5744c51e6b fix: ensure transitive deletion triggers are run for CASCADE_DELETE_INVALIDATE_CACHE (#66) 2020-07-22 19:26:32 -07:00
Will Schurman fd6060cc84 feat: mutation triggers (#65) 2020-07-22 16:57:46 -07:00
Will Schurman a757867360 fix!: remove lodash dependency for vuln, require node 12 (#64) 2020-07-22 10:48:12 -07:00
Will Schurman 1224de79d5 feat!: add support for reference deletion and enclose mutations in transactions (#63) 2020-07-22 09:47:37 -07:00
Will Schurman 7a77afc83e fix: update setField to correct typing (#62) 2020-07-10 17:00:13 -07:00
Will Schurman 9c92d10b80 v0.5.2 (#60) 2020-07-01 22:04:27 -07:00
Will Schurman 7c180e84d9 fix: fix stub cache key generation for global cache (#59) 2020-07-01 22:00:55 -07:00
Will Schurman 1aa44e168e v0.5.1 (#58) 2020-07-01 16:32:35 -07:00
Will Schurman e91cfba993 fix: correct some typing issues and exports (#57) 2020-07-01 16:27:33 -07:00
Will Schurman 252d945917 Cut v0.5.0 (#56) 2020-07-01 11:43:46 -07:00
Will Schurman 4e40b2e521 feat: support entity fields subset of db fields (#49) 2020-07-01 11:23:55 -07:00
Will Schurman ccbdbc472a ci: fix checkout action to checkout branch at current state instead of merging into base branch (#55) 2020-07-01 10:45:01 -07:00
Will Schurman 89cfb3d5a0 feat: add two fan out methods to association loader (#52) 2020-06-30 09:26:05 -07:00
188 changed files with 16921 additions and 4999 deletions

No files matched your search

+3 -1
View File
@@ -9,7 +9,9 @@
], ],
"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: 10.x node-version: 12.x
- name: Cache Node.js modules - name: Cache Node.js modules
uses: actions/cache@v1 uses: actions/cache@v1
with: with:
+3 -1
View File
@@ -10,9 +10,11 @@ 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: 10.x node-version: 12.x
- name: Cache Node.js modules - name: Cache Node.js modules
uses: actions/cache@v1 uses: actions/cache@v1
with: with:
+352 -3
View File
@@ -1,6 +1,355 @@
# Changelog # Change Log
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
### Features
* add local memory cache package ([#158](https://github.com/expo/entity/issues/158)) ([85d3575](https://github.com/expo/entity/commit/85d35752fa0919a62efb776a762a6829e83ca7af))
### Chores
* Refactor cachers to use an abstract class and interface ([#157](https://github.com/expo/entity/pull/157))
# [0.23.0](https://github.com/expo/entity/compare/v0.22.0...v0.23.0) (2022-02-09)
### Breaking changes
* Move data transforming responsibilities to the CacheAdapter instead of the ReadThroughEntityCache. Affects any classes extending the EntityCacheAdapter and relying on the transformation behavior of the ReadThroughEntityCache. ([#153](https://github.com/expo/entity/pull/153))
# [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
@@ -16,7 +365,7 @@
* `@expo/entity-example` * `@expo/entity-example`
* Initial Version * Initial Version
## v0.3.0 (2020-04-28) # 0.3.0 (2020-04-28)
* `@expo/entity` * `@expo/entity`
* Initial Version * Initial Version
+15
View File
@@ -78,6 +78,21 @@ 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.4.0" "version": "0.24.0"
} }
+31 -24
View File
@@ -1,11 +1,14 @@
{ {
"name": "root", "name": "root",
"private": true, "private": true,
"workspaces": ["packages/*"], "workspaces": [
"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",
@@ -14,33 +17,37 @@
}, },
"devDependencies": { "devDependencies": {
"@expo/entity": "file:packages/entity", "@expo/entity": "file:packages/entity",
"@expo/results": "^0.3.0", "@expo/results": "^1.0.0",
"@types/invariant": "^2.2.32", "@types/lru-cache": "^5.1.1",
"@types/ioredis": "^4.14.9", "@types/invariant": "^2.2.33",
"@types/jest": "^25.2.1", "@types/ioredis": "^4.26.4",
"@types/lodash": "^4.14.150", "@types/jest": "^26.0.10",
"@types/node": "^13.13.4", "@types/jsbn": "^1.2.29",
"@types/uuid": "^7.0.3", "@types/node": "^14.6.0",
"@typescript-eslint/eslint-plugin": "^2.30.0", "@types/uuid": "^8.3.0",
"@typescript-eslint/parser": "^2.30.0", "@typescript-eslint/eslint-plugin": "^4.14.0",
"@typescript-eslint/parser": "^4.14.0",
"barrelsby": "^2.2.0", "barrelsby": "^2.2.0",
"eslint": "^6.8.0", "eslint": "^7.7.0",
"eslint-config-universe": "^3.0.0", "eslint-config-universe": "^7.0.0",
"eslint-plugin-tsdoc": "^0.2.4", "eslint-plugin-tsdoc": "^0.2.11",
"jest": "^24.9.0", "jest": "^26.6.3",
"lerna": "^3.20.2", "lerna": "^4.0.0",
"pg": "^8.0.3", "nullthrows": "^1.1.1",
"prettier": "^2.0.5", "pg": "^8.6.0",
"ts-jest": "~24.2.0", "prettier": "^2.3.2",
"ts-mockito": "^2.5.0", "ts-jest": "~26.4.4",
"typedoc": "^0.17.6", "ts-mockito": "^2.6.1",
"typedoc-plugin-lerna-packages": "^0.3.0", "typedoc": "^0.21.9",
"typescript": "^3.9.2", "typescript": "^4.4.2",
"uuid": "^7.0.3" "uuid": "^8.3.0"
}, },
"dependencies": { "dependencies": {
"@expo/entity": "file:packages/entity", "@expo/entity": "file:packages/entity",
"@expo/entity-cache-adapter-local-memory": "file:packages/entity-cache-adapter-local-memory",
"@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"
} }
} }
@@ -0,0 +1,11 @@
# Change Log
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
### Features
* add local memory cache package ([#158](https://github.com/expo/entity/issues/158)) ([85d3575](https://github.com/expo/entity/commit/85d35752fa0919a62efb776a762a6829e83ca7af))
@@ -0,0 +1,44 @@
# `@expo/entity-cache-adapter-local-memory`
Cross-request [LRU](https://github.com/isaacs/node-lru-cache) cache adapter for `@expo/entity`. Use
this cache with caution - it is nonstandard. The cache is shared between requests in the node process.
## Why NOT use this cache
Because this is an in-memory cache, cross-box invalidation is not possible. Do not use this cache
if you have the following use cases:
- The objects stored have high mutability
- Reading a stale object from the cache is not acceptable in your application
- Cross-box invalidation is not possible
## Typical use cases
If your application sees many requests fetching the same objects, you can save a trip to your cache
cluster and backing datastore by using this in-memory cache. Here are some good use cases:
- The objects stored are mostly immutable
- You have a low TTL setting in your cache
## Usage
During `EntityCompanionProvider` instantiation:
```typescript
export const createDefaultEntityCompanionProvider = (
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter()
): EntityCompanionProvider => {
return new EntityCompanionProvider(
metricsAdapter,
{
...
},
{
['local-memory']: {
cacheAdapterProvider: new LocalMemoryCacheAdapterProvider.getProvider(),
},
}
);
};
```
@@ -0,0 +1,38 @@
{
"name": "@expo/entity-cache-adapter-local-memory",
"version": "0.24.0",
"description": "Cross-request local memory cache adapter 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",
"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": "*"
},
"dependencies": {
"lru-cache": "^7.3.1"
},
"devDependencies": {
"@expo/entity": "^0.24.0"
}
}
@@ -0,0 +1,154 @@
import { CacheLoadResult, CacheStatus, IEntityGenericCacher } from '@expo/entity';
import LRUCache from 'lru-cache';
// Sentinel value we store in local memory to negatively cache a database miss.
// The sentinel value is distinct from any (positively) cached value.
export const DOES_NOT_EXIST_LOCAL_MEMORY_CACHE = Symbol('doesNotExist');
type LocalMemoryCacheValue<TFields> = Readonly<TFields> | typeof DOES_NOT_EXIST_LOCAL_MEMORY_CACHE;
export type LocalMemoryCache<TFields> = LRUCache<string, LocalMemoryCacheValue<TFields>>;
type LRUCacheOptionsV7<K, V> = {
/**
* the number of most recently used items to keep.
* note that we may store fewer items than this if maxSize is hit.
*/
max: number;
/**
* if you wish to track item size, you must provide a maxSize
* note that we still will only keep up to max *actual items*,
* so size tracking may cause fewer than max items to be stored.
* At the extreme, a single item of maxSize size will cause everything
* else in the cache to be dropped when it is added. Use with caution!
* Note also that size tracking can negatively impact performance,
* though for most cases, only minimally.
*/
maxSize?: number;
/**
* function to calculate size of items. useful if storing strings or
* buffers or other items where memory size depends on the object itself.
* also note that oversized items do NOT immediately get dropped from
* the cache, though they will cause faster turnover in the storage.
*/
sizeCalculation?: (value: V, key: K) => number;
/**
* function to call when the item is removed from the cache
* Note that using this can negatively impact performance.
*/
dispose?: (value: V, key: K) => void;
/**
* max time to live for items before they are considered stale
* note that stale items are NOT preemptively removed by default,
* and MAY live in the cache, contributing to its LRU max, long after
* they have expired.
* Also, as this cache is optimized for LRU/MRU operations, some of
* the staleness/TTL checks will reduce performance, as they will incur
* overhead by deleting items.
* Must be a positive integer in ms, defaults to 0, which means "no TTL"
*/
ttl?: number;
/**
* return stale items from cache.get() before disposing of them
* boolean, default false
*/
allowStale?: boolean;
/**
* update the age of items on cache.get(), renewing their TTL
* boolean, default false
*/
updateAgeOnGet?: boolean;
/**
* update the age of items on cache.has(), renewing their TTL
* boolean, default false
*/
updateAgeOnHas?: boolean;
/**
* update the "recently-used"-ness of items on cache.has()
* boolean, default false
*/
updateRecencyOnHas?: boolean;
};
export default class GenericLocalMemoryCacher<TFields> implements IEntityGenericCacher<TFields> {
constructor(private readonly localMemoryCache: LocalMemoryCache<TFields>) {}
static createLRUCache<TFields>(
options: { maxSize?: number; ttlSeconds?: number } = {}
): LocalMemoryCache<TFields> {
const DEFAULT_LRU_CACHE_MAX_AGE_SECONDS = 10;
const DEFAULT_LRU_CACHE_SIZE = 10000;
const maxAgeSeconds = options.ttlSeconds ?? DEFAULT_LRU_CACHE_MAX_AGE_SECONDS;
const lruCacheOptions: LRUCacheOptionsV7<string, TFields> = {
max: options.maxSize ?? DEFAULT_LRU_CACHE_SIZE,
sizeCalculation: (value: LocalMemoryCacheValue<TFields>) =>
value === DOES_NOT_EXIST_LOCAL_MEMORY_CACHE ? 0 : 1,
ttl: maxAgeSeconds * 1000, // convert to ms
};
return new LRUCache<string, LocalMemoryCacheValue<TFields>>(lruCacheOptions as any);
}
static createNoOpCache<TFields>(): LocalMemoryCache<TFields> {
return new LRUCache<string, LocalMemoryCacheValue<TFields>>({
max: 0,
maxAge: -1,
});
}
public async loadManyAsync(
keys: readonly string[]
): Promise<ReadonlyMap<string, CacheLoadResult<TFields>>> {
const cacheResults = new Map<string, CacheLoadResult<TFields>>();
for (const key of keys) {
const cacheResult = this.localMemoryCache.get(key);
if (cacheResult === DOES_NOT_EXIST_LOCAL_MEMORY_CACHE) {
cacheResults.set(key, {
status: CacheStatus.NEGATIVE,
});
} else if (cacheResult) {
cacheResults.set(key, {
status: CacheStatus.HIT,
item: cacheResult as unknown as TFields,
});
} else {
cacheResults.set(key, {
status: CacheStatus.MISS,
});
}
}
return cacheResults;
}
public async cacheManyAsync(objectMap: ReadonlyMap<string, Readonly<TFields>>): Promise<void> {
for (const [key, item] of objectMap) {
this.localMemoryCache.set(key, item);
}
}
public async cacheDBMissesAsync(keys: readonly string[]): Promise<void> {
for (const key of keys) {
this.localMemoryCache.set(key, DOES_NOT_EXIST_LOCAL_MEMORY_CACHE);
}
}
public async invalidateManyAsync(keys: readonly string[]): Promise<void> {
for (const key of keys) {
this.localMemoryCache.del(key);
}
}
public makeCacheKey(parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`)
);
return escapedParts.join(delimiter);
}
}
@@ -0,0 +1,80 @@
import { EntityCacheAdapter, CacheLoadResult, EntityConfiguration, mapKeys } from '@expo/entity';
import invariant from 'invariant';
import GenericLocalMemoryCacher, { LocalMemoryCache } from './GenericLocalMemoryCacher';
export default class LocalMemoryCacheAdapter<TFields> extends EntityCacheAdapter<TFields> {
private readonly genericLocalMemoryCacher: GenericLocalMemoryCacher<TFields>;
constructor(
entityConfiguration: EntityConfiguration<TFields>,
localMemoryCache: LocalMemoryCache<TFields>
) {
super(entityConfiguration);
this.genericLocalMemoryCacher = new GenericLocalMemoryCacher(localMemoryCache);
}
public async loadManyAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult<TFields>>> {
const localMemoryCacheKeyToFieldValueMapping = new Map(
fieldValues.map((fieldValue) => [this.makeCacheKey(fieldName, fieldValue), fieldValue])
);
const cacheResults = await this.genericLocalMemoryCacher.loadManyAsync(
Array.from(localMemoryCacheKeyToFieldValueMapping.keys())
);
return mapKeys(cacheResults, (cacheKey) => {
const fieldValue = localMemoryCacheKeyToFieldValueMapping.get(cacheKey);
invariant(
fieldValue !== undefined,
'Unspecified cache key %s returned from generic local memory cacher',
cacheKey
);
return fieldValue;
});
}
public async cacheManyAsync<N extends keyof TFields>(
fieldName: N,
objectMap: ReadonlyMap<NonNullable<TFields[N]>, Readonly<TFields>>
): Promise<void> {
await this.genericLocalMemoryCacher.cacheManyAsync(
mapKeys(objectMap, (fieldValue) => this.makeCacheKey(fieldName, fieldValue))
);
}
public async cacheDBMissesAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<void> {
await this.genericLocalMemoryCacher.cacheDBMissesAsync(
fieldValues.map((fieldValue) => this.makeCacheKey(fieldName, fieldValue))
);
}
public async invalidateManyAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<void> {
await this.genericLocalMemoryCacher.invalidateManyAsync(
fieldValues.map((fieldValue) => this.makeCacheKey(fieldName, fieldValue))
);
}
private makeCacheKey<N extends keyof TFields>(
fieldName: N,
fieldValue: NonNullable<TFields[N]>
): string {
const columnName = this.entityConfiguration.entityToDBFieldsKeyMapping.get(fieldName);
invariant(columnName, `database field mapping missing for ${fieldName}`);
const parts = [
this.entityConfiguration.tableName,
`${this.entityConfiguration.cacheKeyVersion}`,
columnName,
String(fieldValue),
];
return this.genericLocalMemoryCacher.makeCacheKey(parts);
}
}
@@ -0,0 +1,45 @@
import {
computeIfAbsent,
EntityCacheAdapter,
EntityConfiguration,
IEntityCacheAdapterProvider,
} from '@expo/entity';
import GenericLocalMemoryCacher, { LocalMemoryCache } from './GenericLocalMemoryCacher';
import LocalMemoryCacheAdapter from './LocalMemoryCacheAdapter';
export default class LocalMemoryCacheAdapterProvider implements IEntityCacheAdapterProvider {
// local memory cache adapters should be shared/reused across requests
static localMemoryCacheAdapterMap = new Map<string, LocalMemoryCacheAdapter<any>>();
static getNoOpProvider(): IEntityCacheAdapterProvider {
return new LocalMemoryCacheAdapterProvider(<TFields>() =>
GenericLocalMemoryCacher.createNoOpCache<TFields>()
);
}
static getProvider(
options: { maxSize?: number; ttlSeconds?: number } = {}
): IEntityCacheAdapterProvider {
return new LocalMemoryCacheAdapterProvider(<TFields>() =>
GenericLocalMemoryCacher.createLRUCache<TFields>(options)
);
}
private constructor(
private readonly localMemoryCacheCreator: <TFields>() => LocalMemoryCache<TFields>
) {}
public getCacheAdapter<TFields>(
entityConfiguration: EntityConfiguration<TFields>
): EntityCacheAdapter<TFields> {
return computeIfAbsent(
LocalMemoryCacheAdapterProvider.localMemoryCacheAdapterMap,
entityConfiguration.tableName,
() => {
const localMemoryCache = this.localMemoryCacheCreator<TFields>();
return new LocalMemoryCacheAdapter(entityConfiguration, localMemoryCache);
}
);
}
}
@@ -0,0 +1,180 @@
import { CacheStatus, ViewerContext } from '@expo/entity';
import { v4 as uuidv4 } from 'uuid';
import GenericLocalMemoryCacher from '../GenericLocalMemoryCacher';
import LocalMemoryCacheAdapter from '../LocalMemoryCacheAdapter';
import { LocalMemoryCacheAdapterProvider } from '../LocalMemoryCacheAdapterProvider';
import LocalMemoryTestEntity from '../testfixtures/LocalMemoryTestEntity';
import {
createLocalMemoryIntegrationTestEntityCompanionProvider,
createNoopLocalMemoryIntegrationTestEntityCompanionProvider,
} from '../testfixtures/createLocalMemoryIntegrationTestEntityCompanionProvider';
class TestViewerContext extends ViewerContext {}
describe(LocalMemoryCacheAdapter, () => {
beforeEach(async () => {
LocalMemoryCacheAdapterProvider.localMemoryCacheAdapterMap.clear();
});
it('has correct caching behavior', async () => {
const viewerContext = new TestViewerContext(
createLocalMemoryIntegrationTestEntityCompanionProvider()
);
const cacheAdapter = viewerContext.entityCompanionProvider.getCompanionForEntity(
LocalMemoryTestEntity,
LocalMemoryTestEntity.getCompanionDefinition()
)['tableDataCoordinator']['cacheAdapter'];
const cacheKeyMaker = cacheAdapter['makeCacheKey'].bind(cacheAdapter);
const date = new Date();
const entity1Created = await LocalMemoryTestEntity.creator(viewerContext)
.setField('name', 'blah')
.setField('dateField', date)
.enforceCreateAsync();
// loading an entity should put it in cache
const entity1 = await LocalMemoryTestEntity.loader(viewerContext)
.enforcing()
.loadByIDAsync(entity1Created.getID());
const entitySpecificGenericCacher =
LocalMemoryCacheAdapterProvider.localMemoryCacheAdapterMap.get(
LocalMemoryTestEntity.getCompanionDefinition().entityConfiguration.tableName
)!['genericLocalMemoryCacher'];
const cachedResult = await entitySpecificGenericCacher.loadManyAsync([
cacheKeyMaker('id', entity1.getID()),
]);
const cachedValue = cachedResult.get(cacheKeyMaker('id', entity1.getID()))!;
expect(cachedValue).toMatchObject({
status: CacheStatus.HIT,
item: {
id: entity1.getID(),
name: 'blah',
dateField: date,
},
});
// simulate non existent db fetch, should write negative result ('') to cache
const nonExistentId = uuidv4();
const entityNonExistentResult = await LocalMemoryTestEntity.loader(viewerContext).loadByIDAsync(
nonExistentId
);
expect(entityNonExistentResult.ok).toBe(false);
const nonExistentCachedResult = await entitySpecificGenericCacher.loadManyAsync([
cacheKeyMaker('id', nonExistentId),
]);
expect(nonExistentCachedResult.get(cacheKeyMaker('id', nonExistentId))).toMatchObject({
status: CacheStatus.NEGATIVE,
});
// load again through entities framework to ensure it reads negative result
const entityNonExistentResult2 = await LocalMemoryTestEntity.loader(
viewerContext
).loadByIDAsync(nonExistentId);
expect(entityNonExistentResult2.ok).toBe(false);
// invalidate from cache to ensure it invalidates correctly
await LocalMemoryTestEntity.loader(viewerContext).invalidateFieldsAsync(entity1.getAllFields());
const cachedResultMiss = await entitySpecificGenericCacher.loadManyAsync([
cacheKeyMaker('id', entity1.getID()),
]);
const cachedValueMiss = cachedResultMiss.get(cacheKeyMaker('id', entity1.getID()));
expect(cachedValueMiss).toMatchObject({ status: CacheStatus.MISS });
});
it('shares the cache between different requests', async () => {
const genericLocalMemoryCacherLoadManySpy = jest.spyOn(
GenericLocalMemoryCacher.prototype as unknown as any,
'loadManyAsync'
);
const viewerContext = new TestViewerContext(
createLocalMemoryIntegrationTestEntityCompanionProvider()
);
const date = new Date();
const entity1Created = await LocalMemoryTestEntity.creator(viewerContext)
.setField('name', 'blah')
.setField('dateField', date)
.enforceCreateAsync();
// loading an entity should put it in cache
await LocalMemoryTestEntity.loader(viewerContext)
.enforcing()
.loadByIDAsync(entity1Created.getID());
// load entity with a different request
const viewerContext2 = new TestViewerContext(
createLocalMemoryIntegrationTestEntityCompanionProvider()
);
const entity1WithVc2 = await LocalMemoryTestEntity.loader(viewerContext2)
.enforcing()
.loadByIDAsync(entity1Created.getID());
const cacheAdapter = viewerContext.entityCompanionProvider.getCompanionForEntity(
LocalMemoryTestEntity,
LocalMemoryTestEntity.getCompanionDefinition()
)['tableDataCoordinator']['cacheAdapter'];
const cacheKeyMaker = cacheAdapter['makeCacheKey'].bind(cacheAdapter);
expect(entity1WithVc2.getAllFields()).toMatchObject({
id: entity1WithVc2.getID(),
name: 'blah',
dateField: date,
});
expect(genericLocalMemoryCacherLoadManySpy).toBeCalledWith([
cacheKeyMaker('id', entity1WithVc2.getID()),
]);
expect(genericLocalMemoryCacherLoadManySpy).toBeCalledTimes(2);
});
it('respects the parameters of a noop cache', async () => {
const viewerContext = new TestViewerContext(
createNoopLocalMemoryIntegrationTestEntityCompanionProvider()
);
const cacheAdapter = viewerContext.entityCompanionProvider.getCompanionForEntity(
LocalMemoryTestEntity,
LocalMemoryTestEntity.getCompanionDefinition()
)['tableDataCoordinator']['cacheAdapter'];
const cacheKeyMaker = cacheAdapter['makeCacheKey'].bind(cacheAdapter);
const date = new Date();
const entity1Created = await LocalMemoryTestEntity.creator(viewerContext)
.setField('name', 'blah')
.setField('dateField', date)
.enforceCreateAsync();
// loading an entity will try to put it in cache but it's a noop cache, so it should be a miss
const entity1 = await LocalMemoryTestEntity.loader(viewerContext)
.enforcing()
.loadByIDAsync(entity1Created.getID());
const entitySpecificGenericCacher =
LocalMemoryCacheAdapterProvider.localMemoryCacheAdapterMap.get(
LocalMemoryTestEntity.getCompanionDefinition().entityConfiguration.tableName
)!['genericLocalMemoryCacher'];
const cachedResult = await entitySpecificGenericCacher.loadManyAsync([
cacheKeyMaker('id', entity1.getID()),
]);
const cachedValue = cachedResult.get(cacheKeyMaker('id', entity1.getID()))!;
expect(cachedValue).toMatchObject({
status: CacheStatus.MISS,
});
// a non existent db fetch should try to write negative result ('') but it's a noop cache, so it should be a miss
const nonExistentId = uuidv4();
const entityNonExistentResult = await LocalMemoryTestEntity.loader(viewerContext).loadByIDAsync(
nonExistentId
);
expect(entityNonExistentResult.ok).toBe(false);
const nonExistentCachedResult = await entitySpecificGenericCacher.loadManyAsync([
cacheKeyMaker('id', nonExistentId),
]);
expect(nonExistentCachedResult.get(cacheKeyMaker('id', nonExistentId))).toMatchObject({
status: CacheStatus.MISS,
});
});
});
@@ -0,0 +1,125 @@
import { CacheStatus, UUIDField, EntityConfiguration } from '@expo/entity';
import GenericLocalMemoryCacher, {
DOES_NOT_EXIST_LOCAL_MEMORY_CACHE,
} from '../GenericLocalMemoryCacher';
import LocalMemoryCacheAdapter from '../LocalMemoryCacheAdapter';
type BlahFields = {
id: string;
};
const entityConfiguration = new EntityConfiguration<BlahFields>({
idField: 'id',
tableName: 'blah',
schema: {
id: new UUIDField({ columnName: 'id', cache: true }),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'local-memory',
});
describe(LocalMemoryCacheAdapter, () => {
describe('loadManyAsync', () => {
it('returns appropriate cache results', async () => {
const cacheAdapter = new LocalMemoryCacheAdapter(
entityConfiguration,
GenericLocalMemoryCacher.createLRUCache({
maxSize: Number.MAX_SAFE_INTEGER,
ttlSeconds: Number.MAX_SAFE_INTEGER,
})
);
const cacheHits = new Map<string, Readonly<BlahFields>>([['test-id-1', { id: 'test-id-1' }]]);
await cacheAdapter.cacheManyAsync('id', cacheHits);
await cacheAdapter.cacheDBMissesAsync('id', ['test-id-2']);
const results = await cacheAdapter.loadManyAsync('id', [
'test-id-1',
'test-id-2',
'test-id-3',
]);
expect(results.get('test-id-1')).toMatchObject({
status: CacheStatus.HIT,
item: { id: 'test-id-1' },
});
expect(results.get('test-id-2')).toMatchObject({ status: CacheStatus.NEGATIVE });
expect(results.get('test-id-3')).toMatchObject({ status: CacheStatus.MISS });
expect(results.size).toBe(3);
});
it('returns empty map when passed empty array of fieldValues', async () => {
const cacheAdapter = new LocalMemoryCacheAdapter(
entityConfiguration,
GenericLocalMemoryCacher.createLRUCache({
maxSize: Number.MAX_SAFE_INTEGER,
ttlSeconds: Number.MAX_SAFE_INTEGER,
})
);
const results = await cacheAdapter.loadManyAsync('id', []);
expect(results).toEqual(new Map());
});
});
describe('cacheManyAsync', () => {
it('correctly caches all objects', async () => {
const localMemoryCache = GenericLocalMemoryCacher.createLRUCache<BlahFields>({
maxSize: Number.MAX_SAFE_INTEGER,
ttlSeconds: Number.MAX_SAFE_INTEGER,
});
const cacheAdapter = new LocalMemoryCacheAdapter(entityConfiguration, localMemoryCache);
await cacheAdapter.cacheManyAsync('id', new Map([['test-id-1', { id: 'test-id-1' }]]));
const cacheKey = cacheAdapter['makeCacheKey']('id', 'test-id-1');
expect(localMemoryCache.get(cacheKey)).toMatchObject({
id: 'test-id-1',
});
});
});
describe('cacheDBMissesAsync', () => {
it('correctly caches misses', async () => {
const localMemoryCache = GenericLocalMemoryCacher.createLRUCache<BlahFields>({
maxSize: Number.MAX_SAFE_INTEGER,
ttlSeconds: Number.MAX_SAFE_INTEGER,
});
const cacheAdapter = new LocalMemoryCacheAdapter(entityConfiguration, localMemoryCache);
await cacheAdapter.cacheDBMissesAsync('id', ['test-id-1']);
const cacheKey = cacheAdapter['makeCacheKey']('id', 'test-id-1');
expect(localMemoryCache.get(cacheKey)).toEqual(DOES_NOT_EXIST_LOCAL_MEMORY_CACHE);
});
});
describe('invalidateManyAsync', () => {
it('invalidates correctly', async () => {
const localMemoryCache = GenericLocalMemoryCacher.createLRUCache<BlahFields>({
maxSize: Number.MAX_SAFE_INTEGER,
ttlSeconds: Number.MAX_SAFE_INTEGER,
});
const cacheAdapter = new LocalMemoryCacheAdapter(entityConfiguration, localMemoryCache);
await cacheAdapter.cacheManyAsync('id', new Map([['test-id-1', { id: 'test-id-1' }]]));
await cacheAdapter.cacheDBMissesAsync('id', ['test-id-2']);
await cacheAdapter.invalidateManyAsync('id', ['test-id-1', 'test-id-2']);
const results = await cacheAdapter.loadManyAsync('id', ['test-id-1', 'test-id-2']);
expect(results.get('test-id-1')).toMatchObject({ status: CacheStatus.MISS });
expect(results.get('test-id-2')).toMatchObject({ status: CacheStatus.MISS });
});
it('returns when passed empty array of fieldValues', async () => {
const cacheAdapter = new LocalMemoryCacheAdapter(
entityConfiguration,
GenericLocalMemoryCacher.createLRUCache<BlahFields>({
maxSize: Number.MAX_SAFE_INTEGER,
ttlSeconds: Number.MAX_SAFE_INTEGER,
})
);
await cacheAdapter.invalidateManyAsync('id', []);
});
});
});
@@ -0,0 +1,10 @@
/* eslint-disable tsdoc/syntax */
/**
* @packageDocumentation
* @module @expo/entity-cache-adapter-local-memory
*/
export { default as LocalMemoryCacheAdapter } from './LocalMemoryCacheAdapter';
export { default as LocalMemoryCacheAdapterProvider } from './LocalMemoryCacheAdapterProvider';
export { default as GenericLocalMemoryCacher } from './GenericLocalMemoryCacher';
export * from './GenericLocalMemoryCacher';
@@ -0,0 +1,100 @@
import {
AlwaysAllowPrivacyPolicyRule,
EntityPrivacyPolicy,
ViewerContext,
UUIDField,
DateField,
StringField,
EntityConfiguration,
EntityCompanionDefinition,
Entity,
} from '@expo/entity';
export type LocalMemoryTestEntityFields = {
id: string;
name: string;
dateField: Date | null;
};
export default class LocalMemoryTestEntity extends Entity<
LocalMemoryTestEntityFields,
string,
ViewerContext
> {
static getCompanionDefinition(): EntityCompanionDefinition<
LocalMemoryTestEntityFields,
string,
ViewerContext,
LocalMemoryTestEntity,
LocalMemoryTestEntityPrivacyPolicy
> {
return localMemoryTestEntityCompanionDefinition;
}
}
export class LocalMemoryTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
LocalMemoryTestEntityFields,
string,
ViewerContext,
LocalMemoryTestEntity
> {
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<
LocalMemoryTestEntityFields,
string,
ViewerContext,
LocalMemoryTestEntity
>(),
];
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<
LocalMemoryTestEntityFields,
string,
ViewerContext,
LocalMemoryTestEntity
>(),
];
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<
LocalMemoryTestEntityFields,
string,
ViewerContext,
LocalMemoryTestEntity
>(),
];
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<
LocalMemoryTestEntityFields,
string,
ViewerContext,
LocalMemoryTestEntity
>(),
];
}
export const localMemoryTestEntityConfiguration =
new EntityConfiguration<LocalMemoryTestEntityFields>({
idField: 'id',
tableName: 'local_memory_test_entities',
schema: {
id: new UUIDField({
columnName: 'id',
cache: true,
}),
name: new StringField({
columnName: 'name',
cache: true,
}),
dateField: new DateField({
columnName: 'date_field',
}),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'local-memory',
});
const localMemoryTestEntityCompanionDefinition = new EntityCompanionDefinition({
entityClass: LocalMemoryTestEntity,
entityConfiguration: localMemoryTestEntityConfiguration,
privacyPolicyClass: LocalMemoryTestEntityPrivacyPolicy,
});
@@ -0,0 +1,48 @@
import {
NoOpEntityMetricsAdapter,
IEntityMetricsAdapter,
EntityCompanionProvider,
StubQueryContextProvider,
StubDatabaseAdapterProvider,
} from '@expo/entity';
import { LocalMemoryCacheAdapterProvider } from '../LocalMemoryCacheAdapterProvider';
export const createLocalMemoryIntegrationTestEntityCompanionProvider = (
localMemoryOptions: { maxSize?: number; ttlSeconds?: number } = {},
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter()
): EntityCompanionProvider => {
const localMemoryCacheAdapterProvider =
localMemoryOptions.maxSize === 0 && localMemoryOptions.ttlSeconds === 0
? LocalMemoryCacheAdapterProvider.getNoOpProvider()
: LocalMemoryCacheAdapterProvider.getProvider(localMemoryOptions);
return new EntityCompanionProvider(
metricsAdapter,
new Map([
[
'postgres',
{
adapterProvider: new StubDatabaseAdapterProvider(),
queryContextProvider: StubQueryContextProvider,
},
],
]),
new Map([
[
'local-memory',
{
cacheAdapterProvider: localMemoryCacheAdapterProvider,
},
],
])
);
};
export const createNoopLocalMemoryIntegrationTestEntityCompanionProvider = (
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter()
): EntityCompanionProvider => {
return createLocalMemoryIntegrationTestEntityCompanionProvider(
{ maxSize: 0, ttlSeconds: 0 },
metricsAdapter
);
};
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "build",
},
"include": ["src"]
}
@@ -0,0 +1,236 @@
# Change Log
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
### Chores
* Refactor cachers to use an abstract class and interface ([#157](https://github.com/expo/entity/pull/157))
# [0.23.0](https://github.com/expo/entity/compare/v0.22.0...v0.23.0) (2022-02-09)
### Chores
* Move data transforming responsibilities to the CacheAdapter instead of the ReadThroughEntityCache. ([#153](https://github.com/expo/entity/pull/153))
# [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 = (
... ...
}, },
{ {
[CacheAdapterFlavor.REDIS]: { ['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.4.0", "version": "0.24.0",
"description": "Redis cache adapter for @expo/entity", "description": "Redis cache adapter for @expo/entity",
"files": [ "files": [
"build", "build",
@@ -11,14 +11,15 @@
"scripts": { "scripts": {
"tsc": "tsc", "tsc": "tsc",
"clean": "rm -rf build coverage coverage-integration", "clean": "rm -rf build coverage coverage-integration",
"lint": "eslint src --ext '.ts'", "lint": "eslint src",
"test": "jest --rootDir --config ../../resources/jest.config.js", "lint-fix": "eslint src --fix",
"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": ">=8" "node": ">=12"
}, },
"keywords": [ "keywords": [
"entity" "entity"
@@ -29,9 +30,9 @@
"@expo/entity": "*" "@expo/entity": "*"
}, },
"dependencies": { "dependencies": {
"ioredis": "^4.16.1" "ioredis": "^4.27.3"
}, },
"devDependencies": { "devDependencies": {
"@expo/entity": "^0.4.0" "@expo/entity": "^0.24.0"
} }
} }
@@ -0,0 +1,124 @@
import {
CacheLoadResult,
CacheStatus,
EntityConfiguration,
transformCacheObjectToFields,
transformFieldsToCacheObject,
IEntityGenericCacher,
} from '@expo/entity';
import { Redis } from 'ioredis';
import { redisTransformerMap } from './RedisCommon';
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<TFields> implements IEntityGenericCacher<TFields> {
constructor(
private readonly context: GenericRedisCacheContext,
private readonly entityConfiguration: EntityConfiguration<TFields>
) {}
public async loadManyAsync(
keys: readonly string[]
): Promise<ReadonlyMap<string, CacheLoadResult<TFields>>> {
if (keys.length === 0) {
return new Map();
}
const redisResults = await wrapNativeRedisCallAsync(() =>
this.context.redisClient.mget(...keys)
);
const results = new Map<string, CacheLoadResult<TFields>>();
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: transformCacheObjectToFields(
this.entityConfiguration,
redisTransformerMap,
JSON.parse(redisResult)
),
});
} else {
results.set(key, {
status: CacheStatus.MISS,
});
}
}
return results;
}
public async cacheManyAsync(objectMap: ReadonlyMap<string, Readonly<TFields>>): Promise<void> {
if (objectMap.size === 0) {
return;
}
let redisTransaction = this.context.redisClient.multi();
objectMap.forEach((object, key) => {
redisTransaction = redisTransaction.set(
key,
JSON.stringify(
transformFieldsToCacheObject(this.entityConfiguration, redisTransformerMap, object)
),
'EX',
this.context.ttlSecondsPositive
);
});
await wrapNativeRedisCallAsync(() => redisTransaction.exec());
}
public async cacheDBMissesAsync(keys: readonly 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: readonly string[]): Promise<void> {
if (keys.length === 0) {
return;
}
await wrapNativeRedisCallAsync(() => this.context.redisClient.del(...keys));
}
}
@@ -1,17 +1,8 @@
import { import { EntityCacheAdapter, EntityConfiguration, CacheLoadResult, mapKeys } from '@expo/entity';
EntityCacheAdapter, import invariant from 'invariant';
EntityConfiguration,
FieldTransformerMap,
CacheLoadResult,
CacheStatus,
} from '@expo/entity';
import { Redis } from 'ioredis'; import { Redis } from 'ioredis';
import { redisTransformerMap } from './RedisCommon'; import GenericRedisCacher from './GenericRedisCacher';
// 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 {
/** /**
@@ -51,114 +42,83 @@ export interface RedisCacheAdapterContext {
} }
export default class RedisCacheAdapter<TFields> extends EntityCacheAdapter<TFields> { export default class RedisCacheAdapter<TFields> extends EntityCacheAdapter<TFields> {
private readonly genericRedisCacher: GenericRedisCacher<TFields>;
constructor( constructor(
entityConfiguration: EntityConfiguration<TFields>, entityConfiguration: EntityConfiguration<TFields>,
private readonly context: RedisCacheAdapterContext private readonly context: RedisCacheAdapterContext
) { ) {
super(entityConfiguration); super(entityConfiguration);
}
public getFieldTransformerMap(): FieldTransformerMap { this.genericRedisCacher = new GenericRedisCacher(
return redisTransformerMap; {
redisClient: context.redisClient,
ttlSecondsNegative: context.ttlSecondsNegative,
ttlSecondsPositive: context.ttlSecondsPositive,
},
entityConfiguration
);
} }
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<TFields>>> {
if (fieldValues.length === 0) { const redisCacheKeyToFieldValueMapping = new Map(
return new Map(); fieldValues.map((fieldValue) => [this.makeCacheKey(fieldName, fieldValue), fieldValue])
} );
const cacheResults = await this.genericRedisCacher.loadManyAsync(
Array.from(redisCacheKeyToFieldValueMapping.keys())
);
const redisKeys = fieldValues.map((fieldValue) => this.makeCacheKey(fieldName, fieldValue)); return mapKeys(cacheResults, (redisCacheKey) => {
const redisResults = await this.context.redisClient.mget(...redisKeys); const fieldValue = redisCacheKeyToFieldValueMapping.get(redisCacheKey);
invariant(
const results = new Map<NonNullable<TFields[N]>, CacheLoadResult>(); fieldValue !== undefined,
for (let i = 0; i < fieldValues.length; i++) { 'Unspecified cache key %s returned from generic Redis cacher',
const fieldValue = fieldValues[i]; redisCacheKey
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]>, Readonly<TFields>>
): Promise<void> { ): Promise<void> {
if (objectMap.size === 0) { await this.genericRedisCacher.cacheManyAsync(
return; mapKeys(objectMap, (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> {
if (fieldValues.length === 0) { await this.genericRedisCacher.cacheDBMissesAsync(
return; fieldValues.map((fieldValue) => this.makeCacheKey(fieldName, fieldValue))
} );
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> {
if (fieldValues.length === 0) { await this.genericRedisCacher.invalidateManyAsync(
return; fieldValues.map((fieldValue) => this.makeCacheKey(fieldName, fieldValue))
} );
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,
`v${this.entityConfiguration.cacheKeyVersion}`, `v2.${this.entityConfiguration.cacheKeyVersion}`,
fieldName as string, columnName,
String(fieldValue) String(fieldValue)
); );
} }
@@ -0,0 +1,125 @@
import { CacheStatus, ViewerContext } from '@expo/entity';
import Redis from 'ioredis';
import { URL } from 'url';
import GenericRedisCacher from '../GenericRedisCacher';
import { RedisCacheAdapterContext } from '../RedisCacheAdapter';
import RedisTestEntity, {
redisTestEntityConfiguration,
RedisTestEntityFields,
} from '../testfixtures/RedisTestEntity';
import { createRedisIntegrationTestEntityCompanionProvider } from '../testfixtures/createRedisIntegrationTestEntityCompanionProvider';
class TestViewerContext extends ViewerContext {}
describe(GenericRedisCacher, () => {
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();
});
afterAll(async () => {
redisCacheAdapterContext.redisClient.disconnect();
});
it('has correct caching and loading behavior', async () => {
const viewerContext = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
);
const genericRedisCacher = new GenericRedisCacher(
{
redisClient: redisCacheAdapterContext.redisClient,
ttlSecondsNegative: redisCacheAdapterContext.ttlSecondsNegative,
ttlSecondsPositive: redisCacheAdapterContext.ttlSecondsPositive,
},
redisTestEntityConfiguration
);
const date = new Date();
const entity1Created = await RedisTestEntity.creator(viewerContext)
.setField('name', 'blah')
.setField('dateField', date)
.enforceCreateAsync();
const testKey = `test-id-key-${entity1Created.getID()}`;
const objectMap = new Map<string, Readonly<RedisTestEntityFields>>([
[testKey, entity1Created.getAllFields()],
]);
await genericRedisCacher.cacheManyAsync(objectMap);
const cachedJSON = await redisCacheAdapterContext.redisClient.get(testKey);
const cachedValue = JSON.parse(cachedJSON!);
expect(cachedValue).toMatchObject({
id: entity1Created.getID(),
dateField: date.toISOString(),
});
const loadedObjectMap = await genericRedisCacher.loadManyAsync([testKey]);
const cacheLoadResult = loadedObjectMap.get(testKey)!;
expect(cacheLoadResult).toMatchObject({
status: CacheStatus.HIT,
item: entity1Created.getAllFields(),
});
expect(loadedObjectMap.size).toBe(1);
});
it('has correct negative caching behaviour', async () => {
const genericRedisCacher = new GenericRedisCacher(
{
redisClient: redisCacheAdapterContext.redisClient,
ttlSecondsNegative: redisCacheAdapterContext.ttlSecondsNegative,
ttlSecondsPositive: redisCacheAdapterContext.ttlSecondsPositive,
},
redisTestEntityConfiguration
);
const testKey = `test-id-key-non-existent-id`;
await genericRedisCacher.cacheDBMissesAsync([testKey]);
const loadedObjectMap = await genericRedisCacher.loadManyAsync([testKey]);
const cacheLoadResult = loadedObjectMap.get(testKey)!;
expect(cacheLoadResult.status).toBe(CacheStatus.NEGATIVE);
});
it('has correct invalidation behaviour', async () => {
const viewerContext = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
);
const genericRedisCacher = new GenericRedisCacher(
{
redisClient: redisCacheAdapterContext.redisClient,
ttlSecondsNegative: redisCacheAdapterContext.ttlSecondsNegative,
ttlSecondsPositive: redisCacheAdapterContext.ttlSecondsPositive,
},
redisTestEntityConfiguration
);
const date = new Date();
const entity1Created = await RedisTestEntity.creator(viewerContext)
.setField('name', 'blah')
.setField('dateField', date)
.enforceCreateAsync();
const testKey = `test-id-key-${entity1Created.getID()}`;
const objectMap = new Map<string, Readonly<RedisTestEntityFields>>([
[testKey, entity1Created.getAllFields()],
]);
await genericRedisCacher.cacheManyAsync(objectMap);
await genericRedisCacher.invalidateManyAsync([testKey]);
const loadedObjectMap = await genericRedisCacher.loadManyAsync([testKey]);
const cacheLoadResult = loadedObjectMap.get(testKey)!;
expect(cacheLoadResult.status).toBe(CacheStatus.MISS);
});
});
@@ -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,19 +38,23 @@ describe(RedisCacheAdapter, () => {
}); });
it('has correct caching behavior', async () => { it('has correct caching behavior', async () => {
const vc1 = new TestViewerContext( const viewerContext = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext) createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
); );
const cacheAdapter = vc1.entityCompanionProvider.getCompanionForEntity( const cacheAdapter = viewerContext.entityCompanionProvider.getCompanionForEntity(
RedisTestEntity, RedisTestEntity,
RedisTestEntity.getCompanionDefinition() RedisTestEntity.getCompanionDefinition()
)['cacheAdapter']; )['tableDataCoordinator']['cacheAdapter'];
const cacheKeyMaker = cacheAdapter['makeCacheKey'].bind(cacheAdapter); const cacheKeyMaker = cacheAdapter['makeCacheKey'].bind(cacheAdapter);
// creating an entity should put it in cache (loader loads it after creation) const entity1Created = await RedisTestEntity.creator(viewerContext)
const entity1 = await enforceAsyncResult( .setField('name', 'blah')
RedisTestEntity.creator(vc1).setField('name', 'blah').createAsync() .enforceCreateAsync();
);
// 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())
@@ -64,7 +68,9 @@ 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(vc1).loadByIDAsync(nonExistentId); const entityNonExistentResult = await RedisTestEntity.loader(viewerContext).loadByIDAsync(
nonExistentId
);
expect(entityNonExistentResult.ok).toBe(false); expect(entityNonExistentResult.ok).toBe(false);
const nonExistentCachedValue = await redisCacheAdapterContext.redisClient.get( const nonExistentCachedValue = await redisCacheAdapterContext.redisClient.get(
@@ -73,11 +79,13 @@ 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(vc1).loadByIDAsync(nonExistentId); const entityNonExistentResult2 = await RedisTestEntity.loader(viewerContext).loadByIDAsync(
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(vc1).invalidateFieldsAsync(entity1.getAllFields()); await RedisTestEntity.loader(viewerContext).invalidateFieldsAsync(entity1.getAllFields());
const cachedValueNull = await redisCacheAdapterContext.redisClient.get( const cachedValueNull = await redisCacheAdapterContext.redisClient.get(
cacheKeyMaker('id', entity1.getID()) cacheKeyMaker('id', entity1.getID())
); );
@@ -85,16 +93,18 @@ describe(RedisCacheAdapter, () => {
}); });
it('caches and restores date fields', async () => { it('caches and restores date fields', async () => {
const vc1 = new TestViewerContext( const viewerContext = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext) createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
); );
const date = new Date(); const date = new Date();
const entity1 = await enforceAsyncResult( const entity1 = await enforceAsyncResult(
RedisTestEntity.creator(vc1).setField('dateField', date).createAsync() RedisTestEntity.creator(viewerContext).setField('dateField', date).createAsync()
); );
expect(entity1.getField('dateField')).toEqual(date); expect(entity1.getField('dateField')).toEqual(date);
const entity2 = await RedisTestEntity.loader(vc1).enforcing().loadByIDAsync(entity1.getID()); const entity2 = await RedisTestEntity.loader(viewerContext)
.enforcing()
.loadByIDAsync(entity1.getID());
expect(entity2.getField('dateField')).toEqual(date); expect(entity2.getField('dateField')).toEqual(date);
// simulate new request // simulate new request
@@ -104,4 +114,26 @@ 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());
});
}); });
@@ -0,0 +1,46 @@
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);
});
});
@@ -14,6 +14,8 @@ const entityConfiguration = new EntityConfiguration<BlahFields>({
schema: { schema: {
id: new UUIDField({ columnName: 'id', cache: true }), id: new UUIDField({ columnName: 'id', cache: true }),
}, },
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
}); });
describe(RedisCacheAdapter, () => { describe(RedisCacheAdapter, () => {
@@ -0,0 +1,39 @@
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');
});
});
@@ -0,0 +1,17 @@
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,8 +1,12 @@
/* eslint-disable tsdoc/syntax */
/** /**
* Automatically generated by barrelsby. * @packageDocumentation
* @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,8 +6,6 @@ import {
DateField, DateField,
StringField, StringField,
EntityConfiguration, EntityConfiguration,
DatabaseAdapterFlavor,
CacheAdapterFlavor,
EntityCompanionDefinition, EntityCompanionDefinition,
Entity, Entity,
} from '@expo/entity'; } from '@expo/entity';
@@ -30,13 +28,13 @@ export default class RedisTestEntity extends Entity<RedisTestEntityFields, strin
} }
} }
class RedisTestEntityPrivacyPolicy extends EntityPrivacyPolicy< export class RedisTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
RedisTestEntityFields, RedisTestEntityFields,
string, string,
ViewerContext, ViewerContext,
RedisTestEntity RedisTestEntity
> { > {
protected readonly createRules = [ protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule< new AlwaysAllowPrivacyPolicyRule<
RedisTestEntityFields, RedisTestEntityFields,
string, string,
@@ -44,7 +42,7 @@ class RedisTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
RedisTestEntity RedisTestEntity
>(), >(),
]; ];
protected readonly readRules = [ protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule< new AlwaysAllowPrivacyPolicyRule<
RedisTestEntityFields, RedisTestEntityFields,
string, string,
@@ -52,7 +50,7 @@ class RedisTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
RedisTestEntity RedisTestEntity
>(), >(),
]; ];
protected readonly updateRules = [ protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule< new AlwaysAllowPrivacyPolicyRule<
RedisTestEntityFields, RedisTestEntityFields,
string, string,
@@ -60,7 +58,7 @@ class RedisTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
RedisTestEntity RedisTestEntity
>(), >(),
]; ];
protected readonly deleteRules = [ protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule< new AlwaysAllowPrivacyPolicyRule<
RedisTestEntityFields, RedisTestEntityFields,
string, string,
@@ -80,17 +78,18 @@ 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 = { const redisTestEntityCompanionDefinition = new EntityCompanionDefinition({
entityClass: RedisTestEntity, entityClass: RedisTestEntity,
entityConfiguration: redisTestEntityConfiguration, entityConfiguration: redisTestEntityConfiguration,
databaseAdaptorFlavor: DatabaseAdapterFlavor.POSTGRES,
cacheAdaptorFlavor: CacheAdapterFlavor.REDIS,
privacyPolicyClass: RedisTestEntityPrivacyPolicy, privacyPolicyClass: RedisTestEntityPrivacyPolicy,
}; });
@@ -2,8 +2,6 @@ import {
NoOpEntityMetricsAdapter, NoOpEntityMetricsAdapter,
IEntityMetricsAdapter, IEntityMetricsAdapter,
EntityCompanionProvider, EntityCompanionProvider,
CacheAdapterFlavor,
DatabaseAdapterFlavor,
StubQueryContextProvider, StubQueryContextProvider,
StubDatabaseAdapterProvider, StubDatabaseAdapterProvider,
} from '@expo/entity'; } from '@expo/entity';
@@ -17,16 +15,22 @@ export const createRedisIntegrationTestEntityCompanionProvider = (
): EntityCompanionProvider => { ): EntityCompanionProvider => {
return new EntityCompanionProvider( return new EntityCompanionProvider(
metricsAdapter, metricsAdapter,
{ new Map([
[DatabaseAdapterFlavor.POSTGRES]: { [
adapterProvider: new StubDatabaseAdapterProvider(), 'postgres',
queryContextProvider: StubQueryContextProvider, {
}, adapterProvider: new StubDatabaseAdapterProvider(),
}, queryContextProvider: StubQueryContextProvider,
{ },
[CacheAdapterFlavor.REDIS]: { ],
cacheAdapterProvider: new RedisCacheAdapterProvider(redisCacheAdapterContext), ]),
}, new Map([
} [
'redis',
{
cacheAdapterProvider: new RedisCacheAdapterProvider(redisCacheAdapterContext),
},
],
])
); );
}; };
@@ -0,0 +1,272 @@
# Change Log
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
# [0.23.0](https://github.com/expo/entity/compare/v0.22.0...v0.23.0) (2022-02-09)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
# [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 from 'knex'; import { knex, 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(process.env.PGPORT!, 10), port: parseInt(nullthrows(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
[DatabaseAdapterFlavor.POSTGRES]: { ['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.4.0", "version": "0.24.0",
"description": "Knex database adapter for @expo/entity", "description": "Knex database adapter for @expo/entity",
"files": [ "files": [
"build", "build",
@@ -11,14 +11,15 @@
"scripts": { "scripts": {
"tsc": "tsc", "tsc": "tsc",
"clean": "rm -rf build coverage coverage-integration", "clean": "rm -rf build coverage coverage-integration",
"lint": "eslint src --ext '.ts'", "lint": "eslint src",
"test": "jest --rootDir --config ../../resources/jest.config.js --passWithNoTests", "lint-fix": "eslint src --fix",
"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": ">=8" "node": ">=12"
}, },
"keywords": [ "keywords": [
"entity" "entity"
@@ -29,9 +30,10 @@
"@expo/entity": "*" "@expo/entity": "*"
}, },
"dependencies": { "dependencies": {
"knex": "^0.20.11" "knex": "^1.0.2"
}, },
"devDependencies": { "devDependencies": {
"@expo/entity": "^0.4.0" "@expo/entity": "^0.24.0",
"pg": "^8.7.1"
} }
} }
@@ -8,7 +8,9 @@ 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 {
@@ -43,10 +45,12 @@ export default class PostgresEntityDatabaseAdapter<TFields> extends EntityDataba
tableField: string, tableField: string,
tableValues: readonly any[] tableValues: readonly any[]
): Promise<object[]> { ): Promise<object[]> {
return await queryInterface return await wrapNativePostgresCallAsync(() =>
.select() queryInterface
.from(tableName) .select()
.whereRaw('?? = ANY(?)', [tableField, tableValues as any[]]); .from(tableName)
.whereRaw('?? = ANY(?)', [tableField, tableValues as any[]])
);
} }
private applyQueryModifiersToQuery( private applyQueryModifiersToQuery(
@@ -85,20 +89,39 @@ 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 } = {};
for (const { tableField, tableValue } of tableFieldSingleValueEqualityOperands) { const nonNullTableFieldSingleValueEqualityOperands =
whereObject[tableField] = tableValue; 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;
}
query = query.where(whereObject);
}
if (nullTableFieldSingleValueEqualityOperands.length > 0) {
for (const { tableField } of nullTableFieldSingleValueEqualityOperands) {
query = query.whereNull(tableField);
}
} }
query = query.where(whereObject);
} }
if (tableFieldMultiValueEqualityOperands.length > 0) { if (tableFieldMultiValueEqualityOperands.length > 0) {
for (const { tableField, tableValues } of tableFieldMultiValueEqualityOperands) { for (const { tableField, tableValues } of tableFieldMultiValueEqualityOperands) {
query = query.whereRaw('?? = ANY(?)', [tableField, tableValues]); const nonNullTableValues = tableValues.filter((tableValue) => tableValue !== null);
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 query; return await wrapNativePostgresCallAsync(() => query);
} }
protected async fetchManyByRawWhereClauseInternalAsync( protected async fetchManyByRawWhereClauseInternalAsync(
@@ -113,7 +136,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 query; return await wrapNativePostgresCallAsync(() => query);
} }
protected async insertInternalAsync( protected async insertInternalAsync(
@@ -121,7 +144,9 @@ export default class PostgresEntityDatabaseAdapter<TFields> extends EntityDataba
tableName: string, tableName: string,
object: object object: object
): Promise<object[]> { ): Promise<object[]> {
return await queryInterface.insert(object).into(tableName).returning('*'); return await wrapNativePostgresCallAsync(() =>
queryInterface.insert(object).into(tableName).returning('*')
);
} }
protected async updateInternalAsync( protected async updateInternalAsync(
@@ -131,11 +156,9 @@ export default class PostgresEntityDatabaseAdapter<TFields> extends EntityDataba
id: any, id: any,
object: object object: object
): Promise<object[]> { ): Promise<object[]> {
return await queryInterface return await wrapNativePostgresCallAsync(() =>
.update(object) queryInterface.update(object).into(tableName).where(tableIdField, id).returning('*')
.into(tableName) );
.where(tableIdField, id)
.returning('*');
} }
protected async deleteInternalAsync( protected async deleteInternalAsync(
@@ -144,6 +167,8 @@ export default class PostgresEntityDatabaseAdapter<TFields> extends EntityDataba
tableIdField: string, tableIdField: string,
id: any id: any
): Promise<number> { ): Promise<number> {
return await queryInterface.into(tableName).where(tableIdField, id).del(); return await wrapNativePostgresCallAsync(() =>
queryInterface.into(tableName).where(tableIdField, id).del()
);
} }
} }
@@ -7,7 +7,8 @@ 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,25 +1,21 @@
import { import { EntityQueryContextProvider } from '@expo/entity';
IEntityQueryContextProvider, import { Knex } from 'knex';
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 implements IEntityQueryContextProvider { export default class PostgresEntityQueryContextProvider extends EntityQueryContextProvider {
constructor(private readonly knexInstance: Knex) {} constructor(private readonly knexInstance: Knex) {
super();
getRegularEntityQueryContext(): EntityNonTransactionalQueryContext {
return new EntityNonTransactionalQueryContext(this.knexInstance);
} }
async runInTransactionAsync<T>( protected getQueryInterface(): any {
transactionScope: (queryContext: EntityTransactionalQueryContext) => Promise<T> return this.knexInstance;
): Promise<T> { }
return await this.knexInstance.transaction(async (trx) => {
return await transactionScope(new EntityTransactionalQueryContext(trx)); protected createTransactionRunner<T>(): (
}); transactionScope: (trx: any) => Promise<T>
) => Promise<T> {
return (transactionScope) => this.knexInstance.transaction(transactionScope);
} }
} }
@@ -5,23 +5,26 @@ import {
ViewerContext, ViewerContext,
} from '@expo/entity'; } from '@expo/entity';
import { enforceAsyncResult } from '@expo/results'; import { enforceAsyncResult } from '@expo/results';
import Knex from 'knex'; import { knex, 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: process.env.PGUSER, user: nullthrows(process.env['PGUSER']),
password: process.env.PGPASSWORD, password: nullthrows(process.env['PGPASSWORD']),
host: 'localhost', host: 'localhost',
port: parseInt(process.env.PGPORT!, 10), port: parseInt(nullthrows(process.env['PGPORT']), 10),
database: process.env.PGDATABASE, database: nullthrows(process.env['PGDATABASE']),
}, },
}); });
}); });
@@ -32,7 +35,7 @@ describe('postgres entity integration', () => {
afterAll(async () => { afterAll(async () => {
await PostgresTestEntity.dropPostgresTable(knexInstance); await PostgresTestEntity.dropPostgresTable(knexInstance);
knexInstance.destroy(); await knexInstance.destroy();
}); });
it('supports parallel partial updates', async () => { it('supports parallel partial updates', async () => {
@@ -92,7 +95,7 @@ describe('postgres entity integration', () => {
const errorToThrow = new Error('Intentional error'); const errorToThrow = new Error('Intentional error');
await expect( await expect(
PostgresTestEntity.runInTransactionAsync(vc1, async (queryContext) => { vc1.runInTransactionForDatabaseAdaptorFlavorAsync('postgres', 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()
@@ -229,6 +232,59 @@ 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', () => {
@@ -325,4 +381,256 @@ 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,6 +1,7 @@
import { ViewerContext } from '@expo/entity'; import { ViewerContext } from '@expo/entity';
import { enforceAsyncResult } from '@expo/results'; import { enforceAsyncResult } from '@expo/results';
import Knex from 'knex'; import { knex, 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';
@@ -9,14 +10,14 @@ describe('postgres entity integration', () => {
let knexInstance: Knex; let knexInstance: Knex;
beforeAll(() => { beforeAll(() => {
knexInstance = Knex({ knexInstance = knex({
client: 'pg', client: 'pg',
connection: { connection: {
user: process.env.PGUSER, user: nullthrows(process.env['PGUSER']),
password: process.env.PGPASSWORD, password: nullthrows(process.env['PGPASSWORD']),
host: 'localhost', host: 'localhost',
port: parseInt(process.env.PGPORT!, 10), port: parseInt(nullthrows(process.env['PGPORT']), 10),
database: process.env.PGDATABASE, database: nullthrows(process.env['PGDATABASE']),
}, },
}); });
}); });
@@ -27,7 +28,7 @@ describe('postgres entity integration', () => {
afterAll(async () => { afterAll(async () => {
await InvalidTestEntity.dropPostgresTable(knexInstance); await InvalidTestEntity.dropPostgresTable(knexInstance);
knexInstance.destroy(); await knexInstance.destroy();
}); });
describe('invalid operation result counts', () => { describe('invalid operation result counts', () => {
@@ -43,9 +44,6 @@ 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 () => {
@@ -0,0 +1,171 @@
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);
});
});
@@ -0,0 +1,19 @@
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');
});
});
@@ -0,0 +1,55 @@
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,2 +1,9 @@
/* 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';
@@ -0,0 +1,177 @@
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,13 +4,11 @@ import {
ViewerContext, ViewerContext,
StringField, StringField,
EntityConfiguration, EntityConfiguration,
DatabaseAdapterFlavor,
CacheAdapterFlavor,
EntityCompanionDefinition, EntityCompanionDefinition,
Entity, Entity,
NumberField, IntField,
} from '@expo/entity'; } from '@expo/entity';
import Knex from 'knex'; import { Knex } from 'knex';
type InvalidTestEntityFields = { type InvalidTestEntityFields = {
id: number; id: number;
@@ -59,7 +57,7 @@ class InvalidTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
ViewerContext, ViewerContext,
InvalidTestEntity InvalidTestEntity
> { > {
protected readonly createRules = [ protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule< new AlwaysAllowPrivacyPolicyRule<
InvalidTestEntityFields, InvalidTestEntityFields,
number, number,
@@ -67,7 +65,7 @@ class InvalidTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
InvalidTestEntity InvalidTestEntity
>(), >(),
]; ];
protected readonly readRules = [ protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule< new AlwaysAllowPrivacyPolicyRule<
InvalidTestEntityFields, InvalidTestEntityFields,
number, number,
@@ -75,7 +73,7 @@ class InvalidTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
InvalidTestEntity InvalidTestEntity
>(), >(),
]; ];
protected readonly updateRules = [ protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule< new AlwaysAllowPrivacyPolicyRule<
InvalidTestEntityFields, InvalidTestEntityFields,
number, number,
@@ -83,7 +81,7 @@ class InvalidTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
InvalidTestEntity InvalidTestEntity
>(), >(),
]; ];
protected readonly deleteRules = [ protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule< new AlwaysAllowPrivacyPolicyRule<
InvalidTestEntityFields, InvalidTestEntityFields,
number, number,
@@ -97,19 +95,19 @@ export const invalidTestEntityConfiguration = new EntityConfiguration<InvalidTes
idField: 'id', idField: 'id',
tableName: 'postgres_test_entities', tableName: 'postgres_test_entities',
schema: { schema: {
id: new NumberField({ id: new IntField({
columnName: 'id', columnName: 'id',
}), }),
name: new StringField({ name: new StringField({
columnName: 'name', columnName: 'name',
}), }),
}, },
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
}); });
const invalidTestEntityCompanionDefinition = { const invalidTestEntityCompanionDefinition = new EntityCompanionDefinition({
entityClass: InvalidTestEntity, entityClass: InvalidTestEntity,
entityConfiguration: invalidTestEntityConfiguration, entityConfiguration: invalidTestEntityConfiguration,
databaseAdaptorFlavor: DatabaseAdapterFlavor.POSTGRES,
cacheAdaptorFlavor: CacheAdapterFlavor.REDIS,
privacyPolicyClass: InvalidTestEntityPrivacyPolicy, privacyPolicyClass: InvalidTestEntityPrivacyPolicy,
}; });
@@ -11,12 +11,10 @@ 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;
@@ -83,7 +81,7 @@ class PostgresTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
ViewerContext, ViewerContext,
PostgresTestEntity PostgresTestEntity
> { > {
protected readonly createRules = [ protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule< new AlwaysAllowPrivacyPolicyRule<
PostgresTestEntityFields, PostgresTestEntityFields,
string, string,
@@ -91,7 +89,7 @@ class PostgresTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
PostgresTestEntity PostgresTestEntity
>(), >(),
]; ];
protected readonly readRules = [ protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule< new AlwaysAllowPrivacyPolicyRule<
PostgresTestEntityFields, PostgresTestEntityFields,
string, string,
@@ -99,7 +97,7 @@ class PostgresTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
PostgresTestEntity PostgresTestEntity
>(), >(),
]; ];
protected readonly updateRules = [ protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule< new AlwaysAllowPrivacyPolicyRule<
PostgresTestEntityFields, PostgresTestEntityFields,
string, string,
@@ -107,7 +105,7 @@ class PostgresTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
PostgresTestEntity PostgresTestEntity
>(), >(),
]; ];
protected readonly deleteRules = [ protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule< new AlwaysAllowPrivacyPolicyRule<
PostgresTestEntityFields, PostgresTestEntityFields,
string, string,
@@ -150,12 +148,12 @@ export const postgresTestEntityConfiguration = new EntityConfiguration<PostgresT
columnName: 'maybe_json_array_field', columnName: 'maybe_json_array_field',
}), }),
}, },
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
}); });
const postgresTestEntityCompanionDefinition = { const postgresTestEntityCompanionDefinition = new EntityCompanionDefinition({
entityClass: PostgresTestEntity, entityClass: PostgresTestEntity,
entityConfiguration: postgresTestEntityConfiguration, entityConfiguration: postgresTestEntityConfiguration,
databaseAdaptorFlavor: DatabaseAdapterFlavor.POSTGRES,
cacheAdaptorFlavor: CacheAdapterFlavor.REDIS,
privacyPolicyClass: PostgresTestEntityPrivacyPolicy, privacyPolicyClass: PostgresTestEntityPrivacyPolicy,
}; });
@@ -0,0 +1,179 @@
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')],
}),
});
@@ -0,0 +1,151 @@
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,11 +2,9 @@ import {
NoOpEntityMetricsAdapter, NoOpEntityMetricsAdapter,
IEntityMetricsAdapter, IEntityMetricsAdapter,
EntityCompanionProvider, EntityCompanionProvider,
CacheAdapterFlavor, InMemoryFullCacheStubCacheAdapterProvider,
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';
@@ -17,16 +15,22 @@ export const createKnexIntegrationTestEntityCompanionProvider = (
): EntityCompanionProvider => { ): EntityCompanionProvider => {
return new EntityCompanionProvider( return new EntityCompanionProvider(
metricsAdapter, metricsAdapter,
{ new Map([
[DatabaseAdapterFlavor.POSTGRES]: { [
adapterProvider: new PostgresEntityDatabaseAdapterProvider(), 'postgres',
queryContextProvider: new PostgresEntityQueryContextProvider(knex), {
}, adapterProvider: new PostgresEntityDatabaseAdapterProvider(),
}, queryContextProvider: new PostgresEntityQueryContextProvider(knex),
{ },
[CacheAdapterFlavor.REDIS]: { ],
cacheAdapterProvider: new NoCacheStubCacheAdapterProvider(), ]),
}, new Map([
} [
'redis',
{
cacheAdapterProvider: new InMemoryFullCacheStubCacheAdapterProvider(),
},
],
])
); );
}; };
+236
View File
@@ -0,0 +1,236 @@
# Change Log
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
**Note:** Version bump only for package @expo/entity-example
# [0.23.0](https://github.com/expo/entity/compare/v0.22.0...v0.23.0) (2022-02-09)
**Note:** Version bump only for package @expo/entity-example
# [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))
+16 -15
View File
@@ -1,20 +1,21 @@
{ {
"name": "@expo/entity-example", "name": "@expo/entity-example",
"private": true, "private": true,
"version": "0.4.0", "version": "0.24.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 --ext '.ts'", "lint": "eslint src",
"test": "jest --rootDir --config ../../resources/jest.config.js", "lint-fix": "eslint src --fix",
"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": ">=8" "node": ">=12"
}, },
"keywords": [ "keywords": [
"entity" "entity"
@@ -22,18 +23,18 @@
"author": "Expo", "author": "Expo",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@expo/entity": "^0.4.0", "@expo/entity": "^0.24.0",
"apollo-server-koa": "^2.14.2", "apollo-server-koa": "^2.16.1",
"graphql": "^15.0.0", "graphql": "^15.3.0",
"koa": "^2.11.0", "koa": "^2.13.0",
"koa-body": "^4.1.1", "koa-body": "^4.2.0",
"koa-router": "^8.0.8" "koa-router": "^9.4.0"
}, },
"devDependencies": { "devDependencies": {
"@types/koa": "^2.11.3", "@types/koa": "^2.11.4",
"@types/koa-router": "^7.4.0", "@types/koa-router": "^7.4.1",
"@types/supertest": "^2.0.9", "@types/supertest": "^2.0.10",
"supertest": "^4.0.2", "supertest": "^4.0.2",
"ts-node-dev": "^1.0.0-pre.44" "ts-node-dev": "^1.0.0-pre.60"
} }
} }
@@ -1,4 +1,5 @@
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';
@@ -6,17 +7,18 @@ 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 viewerContext = new UserViewerContext(companionProvider, '4'); const userId = uuidv4();
const viewerContext = new UserViewerContext(companionProvider, userId);
const createdEntityResult = await NoteEntity.creator(viewerContext) const createdEntityResult = await NoteEntity.creator(viewerContext)
.setField('userID', '4') .setField('userID', userId)
.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', '5') .setField('userID', uuidv4()) // a different user
.setField('body', 'image') .setField('body', 'image')
.setField('title', 'page') .setField('title', 'page')
.createAsync(); .createAsync();
@@ -1,5 +1,6 @@
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';
@@ -7,14 +8,15 @@ 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 responseEmpty = await server.post('/graphql').set('totally-secure-user-id', '4').send({ const userId = uuidv4();
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', '4') .set('totally-secure-user-id', userId)
.send({ .send({
query: `mutation($note: NoteInput!) { addNote(note: $note) { id, title, body } }`, query: `mutation($note: NoteInput!) { addNote(note: $note) { id, title, body } }`,
variables: { variables: {
@@ -31,7 +33,7 @@ describe('graphql', () => {
const createResponse2 = await server const createResponse2 = await server
.post('/graphql') .post('/graphql')
.set('totally-secure-user-id', '4') .set('totally-secure-user-id', userId)
.send({ .send({
query: `mutation($note: NoteInput!) { addNote(note: $note) { id, title, body } }`, query: `mutation($note: NoteInput!) { addNote(note: $note) { id, title, body } }`,
variables: { variables: {
@@ -42,14 +44,17 @@ describe('graphql', () => {
}, },
}); });
const responseLength2 = await server.post('/graphql').set('totally-secure-user-id', '4').send({ const responseLength2 = await server
query: `query { me { notes { id } } }`, .post('/graphql')
}); .set('totally-secure-user-id', userId)
.send({
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', '4') .set('totally-secure-user-id', userId)
.send({ .send({
query: `query($id: ID!) { noteByID(id: $id) { id, title, body } }`, query: `query($id: ID!) { noteByID(id: $id) { id, title, body } }`,
variables: { variables: {
@@ -64,7 +69,7 @@ describe('graphql', () => {
const responseUpdateSingle = await server const responseUpdateSingle = await server
.post('/graphql') .post('/graphql')
.set('totally-secure-user-id', '4') .set('totally-secure-user-id', userId)
.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: {
@@ -84,7 +89,7 @@ describe('graphql', () => {
const responseDeleteSingle = await server const responseDeleteSingle = await server
.post('/graphql') .post('/graphql')
.set('totally-secure-user-id', '4') .set('totally-secure-user-id', userId)
.send({ .send({
query: `mutation($id: ID!) { deleteNote(id: $id) { id, title, body } }`, query: `mutation($id: ID!) { deleteNote(id: $id) { id, title, body } }`,
variables: { variables: {
@@ -102,9 +107,12 @@ describe('graphql', () => {
body: 'boarding', body: 'boarding',
}); });
const responseLength1 = await server.post('/graphql').set('totally-secure-user-id', '4').send({ const responseLength1 = await server
query: `query { me { notes { id } } }`, .post('/graphql')
}); .set('totally-secure-user-id', userId)
.send({
query: `query { me { notes { id } } }`,
});
expect(responseLength1.body.data.me.notes).toHaveLength(1); expect(responseLength1.body.data.me.notes).toHaveLength(1);
}); });
@@ -127,9 +135,11 @@ 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', '4') .set('totally-secure-user-id', userId)
.send({ .send({
query: `mutation($note: NoteInput!) { addNote(note: $note) { id, title, body } }`, query: `mutation($note: NoteInput!) { addNote(note: $note) { id, title, body } }`,
variables: { variables: {
@@ -143,7 +153,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', '5') .set('totally-secure-user-id', userId2)
.send({ .send({
query: `query($id: ID!) { noteByID(id: $id) { id, title, body } }`, query: `query($id: ID!) { noteByID(id: $id) { id, title, body } }`,
variables: { variables: {
@@ -159,7 +169,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', '5') .set('totally-secure-user-id', userId2)
.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,5 +1,6 @@
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';
@@ -11,59 +12,74 @@ 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.post('/notes/').set('totally-secure-user-id', '4').send({ const createResponse1 = await server
title: 'track', .post('/notes/')
body: 'language', .set('totally-secure-user-id', userId)
}); .send({
title: 'track',
body: 'language',
});
expect(createResponse1.body.note).toMatchObject({ expect(createResponse1.body.note).toMatchObject({
userID: '4', userID: userId,
title: 'track', title: 'track',
body: 'language', body: 'language',
}); });
const createResponse2 = await server.post('/notes/').set('totally-secure-user-id', '4').send({ const createResponse2 = await server
title: 'nine', .post('/notes/')
body: 'lotion', .set('totally-secure-user-id', userId)
}); .send({
title: 'nine',
body: 'lotion',
});
const responseLength2 = await server.get('/notes/').set('totally-secure-user-id', '4').send(); const responseLength2 = await server
.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', '4') .set('totally-secure-user-id', userId)
.send(); .send();
expect(responseGetSingle.body.note).toMatchObject({ expect(responseGetSingle.body.note).toMatchObject({
id: createResponse2.body.note.id, id: createResponse2.body.note.id,
userID: '4', userID: userId,
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', '4') .set('totally-secure-user-id', userId)
.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: '4', userID: userId,
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', '4') .set('totally-secure-user-id', userId)
.send(); .send();
expect(responseDeleteSingle.body.status).toEqual('ok'); expect(responseDeleteSingle.body.status).toEqual('ok');
const responseLength1 = await server.get('/notes/').set('totally-secure-user-id', '4').send(); const responseLength1 = await server
.get('/notes/')
.set('totally-secure-user-id', userId)
.send();
expect(responseLength1.body.notes).toHaveLength(1); expect(responseLength1.body.notes).toHaveLength(1);
}); });
@@ -79,23 +95,28 @@ 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 createResponse1 = await server.post('/notes/').set('totally-secure-user-id', '4').send({ const userId = uuidv4();
title: 'track', const userId2 = uuidv4();
body: 'language', const createResponse1 = await server
}); .post('/notes/')
.set('totally-secure-user-id', userId)
.send({
title: 'track',
body: 'language',
});
expect(createResponse1.ok).toBe(true); expect(createResponse1.ok).toBe(true);
// 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', '5') .set('totally-secure-user-id', userId2)
.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', '5') .set('totally-secure-user-id', userId2)
.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,18 +1,13 @@
import { import { EntityQueryContextProvider } from '@expo/entity';
IEntityQueryContextProvider,
EntityQueryContext,
EntityNonTransactionalQueryContext,
EntityTransactionalQueryContext,
} from '@expo/entity';
export default class InMemoryQueryContextProvider implements IEntityQueryContextProvider { export default class InMemoryQueryContextProvider extends EntityQueryContextProvider {
getRegularEntityQueryContext(): EntityQueryContext { protected getQueryInterface(): any {
return new EntityNonTransactionalQueryContext({}); return {};
} }
async runInTransactionAsync<T>( protected createTransactionRunner<T>(): (
transactionScope: (queryContext: EntityQueryContext) => Promise<T> transactionScope: (queryInterface: any) => Promise<T>
): Promise<T> { ) => Promise<T> {
return await transactionScope(new EntityTransactionalQueryContext({})); return (transactionScope) => Promise.resolve(transactionScope({}));
} }
} }
+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 from 'koa-router'; import KoaRouter, { RouterContext } 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 = Koa.ParameterizedContext<ExampleState>; export type ExampleContext = RouterContext<ExampleState>;
/** /**
* Koa provides a per-request state container to place custom properties. * Koa provides a per-request state container to place custom properties.
+21 -17
View File
@@ -3,8 +3,6 @@ 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';
@@ -21,20 +19,26 @@ export const createEntityCompanionProvider = (
): EntityCompanionProvider => { ): EntityCompanionProvider => {
return new EntityCompanionProvider( return new EntityCompanionProvider(
metricsAdapter, metricsAdapter,
{ new Map([
// An in-memory DB is used for demonstration purposes, but generally this would be [
// instantiated with PostgresEntityDatabaseAdapter and PostgresEntityQueryContextProvider 'postgres',
[DatabaseAdapterFlavor.POSTGRES]: { // An in-memory DB is used for demonstration purposes, but generally this would be
adapterProvider: new InMemoryDatabaseAdapterProvider(), // instantiated with PostgresEntityDatabaseAdapter and PostgresEntityQueryContextProvider
queryContextProvider: new InMemoryQueryContextProvider(), {
}, adapterProvider: new InMemoryDatabaseAdapterProvider(),
}, queryContextProvider: new InMemoryQueryContextProvider(),
{ },
// An in-memory cache is used for demonstration purposes, but generally this would be ],
// instantiated with a RedisCacheAdapterProvider ]),
[CacheAdapterFlavor.REDIS]: { new Map([
cacheAdapterProvider: new InMemoryFullCacheStubCacheAdapterProvider(), [
}, 'redis',
} // An in-memory cache is used for demonstration purposes, but generally this would be
// instantiated with a RedisCacheAdapterProvider
{
cacheAdapterProvider: new InMemoryFullCacheStubCacheAdapterProvider(),
},
],
])
); );
}; };
@@ -23,8 +23,9 @@ import { ExampleViewerContext } from '../viewerContexts';
*/ */
export default class AllowIfUserOwnerPrivacyRule< export default class AllowIfUserOwnerPrivacyRule<
TFields, TFields,
TID, TID extends NonNullable<TFields[TSelectedFields]>,
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,8 +3,6 @@ import {
EntityCompanionDefinition, EntityCompanionDefinition,
EntityConfiguration, EntityConfiguration,
UUIDField, UUIDField,
DatabaseAdapterFlavor,
CacheAdapterFlavor,
StringField, StringField,
} from '@expo/entity'; } from '@expo/entity';
@@ -38,7 +36,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 = { export const noteEntityCompanion = new EntityCompanionDefinition({
entityClass: NoteEntity, entityClass: NoteEntity,
entityConfiguration: new EntityConfiguration<NoteFields>({ entityConfiguration: new EntityConfiguration<NoteFields>({
idField: 'id', idField: 'id',
@@ -57,8 +55,8 @@ export const noteEntityCompanion = {
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 readonly readRules = [ protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<NoteFields, string, ExampleViewerContext, NoteEntity>(), new AlwaysAllowPrivacyPolicyRule<NoteFields, string, ExampleViewerContext, NoteEntity>(),
]; ];
protected readonly createRules = [ protected override readonly createRules = [
new AllowIfUserOwnerPrivacyRule<NoteFields, string, NoteEntity>('userID'), new AllowIfUserOwnerPrivacyRule<NoteFields, string, NoteEntity>('userID'),
]; ];
protected readonly updateRules = [ protected override readonly updateRules = [
new AllowIfUserOwnerPrivacyRule<NoteFields, string, NoteEntity>('userID'), new AllowIfUserOwnerPrivacyRule<NoteFields, string, NoteEntity>('userID'),
]; ];
protected readonly deleteRules = [ protected override readonly deleteRules = [
new AllowIfUserOwnerPrivacyRule<NoteFields, string, NoteEntity>('userID'), new AllowIfUserOwnerPrivacyRule<NoteFields, string, NoteEntity>('userID'),
]; ];
} }
@@ -0,0 +1,208 @@
# Change Log
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
**Note:** Version bump only for package @expo/entity-full-integration-tests
# [0.23.0](https://github.com/expo/entity/compare/v0.22.0...v0.23.0) (2022-02-09)
**Note:** Version bump only for package @expo/entity-full-integration-tests
# [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))
@@ -0,0 +1,5 @@
# `@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)
@@ -0,0 +1,34 @@
{
"name": "@expo/entity-full-integration-tests",
"private": true,
"version": "0.24.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.24.0",
"@expo/entity-cache-adapter-redis": "^0.24.0",
"@expo/entity-database-adapter-knex": "^0.24.0"
}
}
@@ -0,0 +1,216 @@
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');
});
});
@@ -0,0 +1,109 @@
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();
});
});
});
@@ -0,0 +1,242 @@
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();
}
});
});
@@ -0,0 +1,71 @@
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,
});
@@ -0,0 +1,62 @@
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,
});
@@ -0,0 +1,41 @@
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),
},
],
])
);
};
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"noEmit": true,
},
"include": ["src"]
}
@@ -0,0 +1,66 @@
# Change Log
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
**Note:** Version bump only for package @expo/entity-ip-address-field
# [0.23.0](https://github.com/expo/entity/compare/v0.22.0...v0.23.0) (2022-02-09)
**Note:** Version bump only for package @expo/entity-ip-address-field
# [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))
@@ -0,0 +1,3 @@
# @expo/entity-ip-address-field
A package containing the IP address EntityField definitions for @expo/entity. Supports addresses with either IPv4 or IPv6 syntax.
@@ -0,0 +1,37 @@
{
"name": "@expo/entity-ip-address-field",
"version": "0.24.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.24.0",
"@types/jsbn": "^1.2.29"
},
"peerDependencies": {
"@expo/entity": "*"
}
}
@@ -0,0 +1,8 @@
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);
}
}
@@ -0,0 +1,16 @@
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']
);
@@ -0,0 +1,7 @@
/* eslint-disable tsdoc/syntax */
/**
* @packageDocumentation
* @module @expo/entity-ip-address-field
*/
export * from './EntityFields';
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "build",
},
"include": ["src"]
}
@@ -0,0 +1,89 @@
# Change Log
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
### Chores
* Refactor cachers to use an abstract class and interface ([#157](https://github.com/expo/entity/pull/157))
# [0.23.0](https://github.com/expo/entity/compare/v0.22.0...v0.23.0) (2022-02-09)
### Chores
* Move data transforming responsibilities to the CacheAdapter instead of the RedisSecondaryEntityCache. ([#153](https://github.com/expo/entity/pull/153))
# [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))
@@ -0,0 +1,25 @@
# `@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]);
```
@@ -0,0 +1,41 @@
{
"name": "@expo/entity-secondary-cache-redis",
"version": "0.24.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.24.0",
"@expo/entity-cache-adapter-redis": "^0.24.0",
"nullthrows": "^1.1.1"
}
}
@@ -0,0 +1,20 @@
import { EntityConfiguration, GenericSecondaryEntityCache } from '@expo/entity';
import { GenericRedisCacheContext, GenericRedisCacher } from '@expo/entity-cache-adapter-redis';
/**
* 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
> extends GenericSecondaryEntityCache<TFields, TLoadParams> {
constructor(
entityConfiguration: EntityConfiguration<TFields>,
genericRedisCacheContext: GenericRedisCacheContext,
constructRedisKey: (params: Readonly<TLoadParams>) => string
) {
super(new GenericRedisCacher(genericRedisCacheContext, entityConfiguration), constructRedisKey);
}
}
@@ -0,0 +1,139 @@
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);
});
});
@@ -0,0 +1,7 @@
/* eslint-disable tsdoc/syntax */
/**
* @packageDocumentation
* @module @expo/entity-secondary-cache-redis
*/
export { default as RedisSecondaryEntityCache } from './RedisSecondaryEntityCache';
@@ -0,0 +1,88 @@
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,
});
@@ -0,0 +1,37 @@
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),
},
],
])
);
};
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "build",
},
"include": ["src"]
}
+333
View File
@@ -0,0 +1,333 @@
# Change Log
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
**Note:** Version bump only for package @expo/entity
### Chores
* Refactor cachers to use an abstract class and interface ([#157](https://github.com/expo/entity/pull/157))
# [0.23.0](https://github.com/expo/entity/compare/v0.22.0...v0.23.0) (2022-02-09)
### Breaking changes
* Move data transforming responsibilities to the CacheAdapter instead of the ReadThroughEntityCache. Affects any classes extending the EntityCacheAdapter and relying on the transformation behavior of the ReadThroughEntityCache. ([#153](https://github.com/expo/entity/pull/153))
# [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.4.0", "version": "0.24.0",
"description": "A privacy-first data model", "description": "A privacy-first data model",
"files": [ "files": [
"build", "build",
@@ -11,14 +11,15 @@
"scripts": { "scripts": {
"tsc": "tsc", "tsc": "tsc",
"clean": "rm -rf build coverage coverage-integration", "clean": "rm -rf build coverage coverage-integration",
"lint": "eslint src --ext '.ts'", "lint": "eslint src",
"test": "jest --rootDir --config ../../resources/jest.config.js", "lint-fix": "eslint src --fix",
"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": ">=8" "node": ">=12"
}, },
"keywords": [ "keywords": [
"entity" "entity"
@@ -26,11 +27,10 @@
"author": "Expo", "author": "Expo",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@expo/results": "^0.3.0", "@expo/results": "^1.0.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",
"lodash": "^4.17.15", "uuid": "^8.3.0"
"uuid": "^7.0.3"
} }
} }
+16 -8
View File
@@ -11,10 +11,17 @@ import { mapMap } from './utils/collections/maps';
*/ */
export default class EnforcingEntityLoader< export default class EnforcingEntityLoader<
TFields, TFields,
TID, TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext, TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext>, TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
TPrivacyPolicy extends EntityPrivacyPolicy<TFields, TID, TViewerContext, TEntity> TPrivacyPolicy extends EntityPrivacyPolicy<
TFields,
TID,
TViewerContext,
TEntity,
TSelectedFields
>,
TSelectedFields extends keyof TFields
> { > {
constructor( constructor(
private readonly entityLoader: EntityLoader< private readonly entityLoader: EntityLoader<
@@ -22,7 +29,8 @@ export default class EnforcingEntityLoader<
TID, TID,
TViewerContext, TViewerContext,
TEntity, TEntity,
TPrivacyPolicy TPrivacyPolicy,
TSelectedFields
> >
) {} ) {}
@@ -30,7 +38,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 TFields>( async loadManyByFieldEqualingManyAsync<N extends keyof Pick<TFields, TSelectedFields>>(
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[]>> {
@@ -47,7 +55,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 TFields>( async loadManyByFieldEqualingAsync<N extends keyof Pick<TFields, TSelectedFields>>(
fieldName: N, fieldName: N,
fieldValue: NonNullable<TFields[N]> fieldValue: NonNullable<TFields[N]>
): Promise<readonly TEntity[]> { ): Promise<readonly TEntity[]> {
@@ -63,7 +71,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 TFields>( async loadByFieldEqualingAsync<N extends keyof Pick<TFields, TSelectedFields>>(
uniqueFieldName: N, uniqueFieldName: N,
fieldValue: NonNullable<TFields[N]> fieldValue: NonNullable<TFields[N]>
): Promise<TEntity | null> { ): Promise<TEntity | null> {
@@ -106,7 +114,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 TFields>( async loadManyByFieldEqualityConjunctionAsync<N extends keyof Pick<TFields, TSelectedFields>>(
fieldEqualityOperands: FieldEqualityCondition<TFields, N>[], fieldEqualityOperands: FieldEqualityCondition<TFields, N>[],
querySelectionModifiers: QuerySelectionModifiers<TFields> = {} querySelectionModifiers: QuerySelectionModifiers<TFields> = {}
): Promise<readonly TEntity[]> { ): Promise<readonly TEntity[]> {
+142 -41
View File
@@ -27,9 +27,10 @@ import ViewerContext from './ViewerContext';
*/ */
export default abstract class Entity< export default abstract class Entity<
TFields, TFields,
TID, TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext TViewerContext extends ViewerContext,
> extends ReadonlyEntity<TFields, TID, TViewerContext> { TSelectedFields extends keyof TFields = keyof TFields
> 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
@@ -38,19 +39,33 @@ export default abstract class Entity<
*/ */
static creator< static creator<
TMFields, TMFields,
TMID, TMID extends NonNullable<TMFields[TMSelectedFields]>,
TMViewerContext extends ViewerContext, TMViewerContext extends ViewerContext,
TMViewerContext2 extends TMViewerContext, TMViewerContext2 extends TMViewerContext,
TMEntity extends Entity<TMFields, TMID, TMViewerContext>, TMEntity extends Entity<TMFields, TMID, TMViewerContext, TMSelectedFields>,
TMPrivacyPolicy extends EntityPrivacyPolicy<TMFields, TMID, TMViewerContext, TMEntity> TMPrivacyPolicy extends EntityPrivacyPolicy<
TMFields,
TMID,
TMViewerContext,
TMEntity,
TMSelectedFields
>,
TMSelectedFields extends keyof TMFields = keyof TMFields
>( >(
this: IEntityClass<TMFields, TMID, TMViewerContext, TMEntity, TMPrivacyPolicy>, this: IEntityClass<
TMFields,
TMID,
TMViewerContext,
TMEntity,
TMPrivacyPolicy,
TMSelectedFields
>,
viewerContext: TMViewerContext2, viewerContext: TMViewerContext2,
queryContext: EntityQueryContext = viewerContext queryContext: EntityQueryContext = viewerContext
.getViewerScopedEntityCompanionForClass(this) .getViewerScopedEntityCompanionForClass(this)
.getQueryContextProvider() .getQueryContextProvider()
.getRegularEntityQueryContext() .getQueryContext()
): CreateMutator<TMFields, TMID, TMViewerContext, TMEntity, TMPrivacyPolicy> { ): CreateMutator<TMFields, TMID, TMViewerContext, TMEntity, TMPrivacyPolicy, TMSelectedFields> {
return viewerContext return viewerContext
.getViewerScopedEntityCompanionForClass(this) .getViewerScopedEntityCompanionForClass(this)
.getMutatorFactory() .getMutatorFactory()
@@ -65,19 +80,33 @@ export default abstract class Entity<
*/ */
static updater< static updater<
TMFields, TMFields,
TMID, TMID extends NonNullable<TMFields[TMSelectedFields]>,
TMViewerContext extends ViewerContext, TMViewerContext extends ViewerContext,
TMEntity extends Entity<TMFields, TMID, TMViewerContext>, TMEntity extends Entity<TMFields, TMID, TMViewerContext, TMSelectedFields>,
TMPrivacyPolicy extends EntityPrivacyPolicy<TMFields, TMID, TMViewerContext, TMEntity> TMPrivacyPolicy extends EntityPrivacyPolicy<
TMFields,
TMID,
TMViewerContext,
TMEntity,
TMSelectedFields
>,
TMSelectedFields extends keyof TMFields = keyof TMFields
>( >(
this: IEntityClass<TMFields, TMID, TMViewerContext, TMEntity, TMPrivacyPolicy>, this: IEntityClass<
TMFields,
TMID,
TMViewerContext,
TMEntity,
TMPrivacyPolicy,
TMSelectedFields
>,
existingEntity: TMEntity, existingEntity: TMEntity,
queryContext: EntityQueryContext = existingEntity queryContext: EntityQueryContext = existingEntity
.getViewerContext() .getViewerContext()
.getViewerScopedEntityCompanionForClass(this) .getViewerScopedEntityCompanionForClass(this)
.getQueryContextProvider() .getQueryContextProvider()
.getRegularEntityQueryContext() .getQueryContext()
): UpdateMutator<TMFields, TMID, TMViewerContext, TMEntity, TMPrivacyPolicy> { ): UpdateMutator<TMFields, TMID, TMViewerContext, TMEntity, TMPrivacyPolicy, TMSelectedFields> {
return existingEntity return existingEntity
.getViewerContext() .getViewerContext()
.getViewerScopedEntityCompanionForClass(this) .getViewerScopedEntityCompanionForClass(this)
@@ -92,18 +121,32 @@ export default abstract class Entity<
*/ */
static deleteAsync< static deleteAsync<
TMFields, TMFields,
TMID, TMID extends NonNullable<TMFields[TMSelectedFields]>,
TMViewerContext extends ViewerContext, TMViewerContext extends ViewerContext,
TMEntity extends Entity<TMFields, TMID, TMViewerContext>, TMEntity extends Entity<TMFields, TMID, TMViewerContext, TMSelectedFields>,
TMPrivacyPolicy extends EntityPrivacyPolicy<TMFields, TMID, TMViewerContext, TMEntity> TMPrivacyPolicy extends EntityPrivacyPolicy<
TMFields,
TMID,
TMViewerContext,
TMEntity,
TMSelectedFields
>,
TMSelectedFields extends keyof TMFields = keyof TMFields
>( >(
this: IEntityClass<TMFields, TMID, TMViewerContext, TMEntity, TMPrivacyPolicy>, this: IEntityClass<
TMFields,
TMID,
TMViewerContext,
TMEntity,
TMPrivacyPolicy,
TMSelectedFields
>,
existingEntity: TMEntity, existingEntity: TMEntity,
queryContext: EntityQueryContext = existingEntity queryContext: EntityQueryContext = existingEntity
.getViewerContext() .getViewerContext()
.getViewerScopedEntityCompanionForClass(this) .getViewerScopedEntityCompanionForClass(this)
.getQueryContextProvider() .getQueryContextProvider()
.getRegularEntityQueryContext() .getQueryContext()
): Promise<Result<void>> { ): Promise<Result<void>> {
return existingEntity return existingEntity
.getViewerContext() .getViewerContext()
@@ -120,18 +163,32 @@ export default abstract class Entity<
*/ */
static enforceDeleteAsync< static enforceDeleteAsync<
TMFields, TMFields,
TMID, TMID extends NonNullable<TMFields[TMSelectedFields]>,
TMViewerContext extends ViewerContext, TMViewerContext extends ViewerContext,
TMEntity extends Entity<TMFields, TMID, TMViewerContext>, TMEntity extends Entity<TMFields, TMID, TMViewerContext, TMSelectedFields>,
TMPrivacyPolicy extends EntityPrivacyPolicy<TMFields, TMID, TMViewerContext, TMEntity> TMPrivacyPolicy extends EntityPrivacyPolicy<
TMFields,
TMID,
TMViewerContext,
TMEntity,
TMSelectedFields
>,
TMSelectedFields extends keyof TMFields = keyof TMFields
>( >(
this: IEntityClass<TMFields, TMID, TMViewerContext, TMEntity, TMPrivacyPolicy>, this: IEntityClass<
TMFields,
TMID,
TMViewerContext,
TMEntity,
TMPrivacyPolicy,
TMSelectedFields
>,
existingEntity: TMEntity, existingEntity: TMEntity,
queryContext: EntityQueryContext = existingEntity queryContext: EntityQueryContext = existingEntity
.getViewerContext() .getViewerContext()
.getViewerScopedEntityCompanionForClass(this) .getViewerScopedEntityCompanionForClass(this)
.getQueryContextProvider() .getQueryContextProvider()
.getRegularEntityQueryContext() .getQueryContext()
): Promise<void> { ): Promise<void> {
return existingEntity return existingEntity
.getViewerContext() .getViewerContext()
@@ -159,25 +216,43 @@ export default abstract class Entity<
*/ */
static async canViewerUpdateAsync< static async canViewerUpdateAsync<
TMFields, TMFields,
TMID, TMID extends NonNullable<TMFields[TMSelectedFields]>,
TMViewerContext extends ViewerContext, TMViewerContext extends ViewerContext,
TMEntity extends Entity<TMFields, TMID, TMViewerContext>, TMEntity extends Entity<TMFields, TMID, TMViewerContext, TMSelectedFields>,
TMPrivacyPolicy extends EntityPrivacyPolicy<TMFields, TMID, TMViewerContext, TMEntity> TMPrivacyPolicy extends EntityPrivacyPolicy<
TMFields,
TMID,
TMViewerContext,
TMEntity,
TMSelectedFields
>,
TMSelectedFields extends keyof TMFields = keyof TMFields
>( >(
this: IEntityClass<TMFields, TMID, TMViewerContext, TMEntity, TMPrivacyPolicy>, this: IEntityClass<
TMFields,
TMID,
TMViewerContext,
TMEntity,
TMPrivacyPolicy,
TMSelectedFields
>,
existingEntity: TMEntity, existingEntity: TMEntity,
queryContext: EntityQueryContext = existingEntity queryContext: EntityQueryContext = existingEntity
.getViewerContext() .getViewerContext()
.getViewerScopedEntityCompanionForClass(this) .getViewerScopedEntityCompanionForClass(this)
.getQueryContextProvider() .getQueryContextProvider()
.getRegularEntityQueryContext() .getQueryContext()
): 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;
@@ -194,25 +269,43 @@ export default abstract class Entity<
*/ */
static async canViewerDeleteAsync< static async canViewerDeleteAsync<
TMFields, TMFields,
TMID, TMID extends NonNullable<TMFields[TMSelectedFields]>,
TMViewerContext extends ViewerContext, TMViewerContext extends ViewerContext,
TMEntity extends Entity<TMFields, TMID, TMViewerContext>, TMEntity extends Entity<TMFields, TMID, TMViewerContext, TMSelectedFields>,
TMPrivacyPolicy extends EntityPrivacyPolicy<TMFields, TMID, TMViewerContext, TMEntity> TMPrivacyPolicy extends EntityPrivacyPolicy<
TMFields,
TMID,
TMViewerContext,
TMEntity,
TMSelectedFields
>,
TMSelectedFields extends keyof TMFields = keyof TMFields
>( >(
this: IEntityClass<TMFields, TMID, TMViewerContext, TMEntity, TMPrivacyPolicy>, this: IEntityClass<
TMFields,
TMID,
TMViewerContext,
TMEntity,
TMPrivacyPolicy,
TMSelectedFields
>,
existingEntity: TMEntity, existingEntity: TMEntity,
queryContext: EntityQueryContext = existingEntity queryContext: EntityQueryContext = existingEntity
.getViewerContext() .getViewerContext()
.getViewerScopedEntityCompanionForClass(this) .getViewerScopedEntityCompanionForClass(this)
.getQueryContextProvider() .getQueryContextProvider()
.getRegularEntityQueryContext() .getQueryContext()
): 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;
@@ -224,10 +317,17 @@ export default abstract class Entity<
*/ */
export interface IEntityClass< export interface IEntityClass<
TFields, TFields,
TID, TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext, TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext>, TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
TPrivacyPolicy extends EntityPrivacyPolicy<TFields, TID, TViewerContext, TEntity> TPrivacyPolicy extends EntityPrivacyPolicy<
TFields,
TID,
TViewerContext,
TEntity,
TSelectedFields
>,
TSelectedFields extends keyof TFields = keyof TFields
> { > {
new (viewerContext: TViewerContext, obj: Readonly<TFields>): TEntity; new (viewerContext: TViewerContext, obj: Readonly<TFields>): TEntity;
getCompanionDefinition(): EntityCompanionDefinition< getCompanionDefinition(): EntityCompanionDefinition<
@@ -235,6 +335,7 @@ export interface IEntityClass<
TID, TID,
TViewerContext, TViewerContext,
TEntity, TEntity,
TPrivacyPolicy TPrivacyPolicy,
TSelectedFields
>; >;
} }
+277 -63
View File
@@ -8,9 +8,10 @@ import ViewerContext from './ViewerContext';
export default class EntityAssociationLoader< export default class EntityAssociationLoader<
TFields, TFields,
TID, TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext, TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext> TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
TSelectedFields extends keyof TFields
> { > {
constructor(private readonly entity: TEntity) {} constructor(private readonly entity: TEntity) {}
@@ -22,37 +23,111 @@ 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, TAssociatedFields,
TAssociatedID, TAssociatedID extends NonNullable<TAssociatedFields[TAssociatedSelectedFields]>,
TAssociatedEntity extends ReadonlyEntity<TAssociatedFields, 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 TFields, fieldIdentifyingAssociatedEntity: TIdentifyingField,
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()
.getRegularEntityQueryContext() .getQueryContext()
): Promise<Result<TAssociatedEntity>> { ): Promise<
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
);
} }
/** /**
@@ -65,31 +140,42 @@ export default class EntityAssociationLoader<
*/ */
async loadAssociatedEntityByFieldEqualingAsync< async loadAssociatedEntityByFieldEqualingAsync<
TAssociatedFields, TAssociatedFields,
TAssociatedID, TAssociatedID extends NonNullable<TAssociatedFields[TAssociatedSelectedFields]>,
TAssociatedEntity extends ReadonlyEntity<TAssociatedFields, 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 TFields, fieldIdentifyingAssociatedEntity: keyof Pick<TFields, TSelectedFields>,
associatedEntityClass: IEntityClass< associatedEntityClass: IEntityClass<
TAssociatedFields, TAssociatedFields,
TAssociatedID, TAssociatedID,
TViewerContext, TViewerContext,
TAssociatedEntity, TAssociatedEntity,
TAssociatedPrivacyPolicy TAssociatedPrivacyPolicy,
TAssociatedSelectedFields
>, >,
associatedEntityLookupByField: keyof TAssociatedFields, associatedEntityLookupByField: keyof Pick<TAssociatedFields, TAssociatedSelectedFields>,
queryContext: EntityQueryContext = this.entity queryContext: EntityQueryContext = this.entity
.getViewerContext() .getViewerContext()
.getViewerScopedEntityCompanionForClass(associatedEntityClass) .getViewerScopedEntityCompanionForClass(associatedEntityClass)
.getQueryContextProvider() .getQueryContextProvider()
.getRegularEntityQueryContext() .getQueryContext()
): 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)
@@ -101,6 +187,64 @@ 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.
@@ -108,15 +252,29 @@ 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<
TEntityRoot extends ReadonlyEntity<TFields, TID, TViewerContext>,
TPrivacyPolicyRoot extends EntityPrivacyPolicy<TFields, TID, TViewerContext, TEntityRoot>,
TFields2, TFields2,
TID2, TID2 extends NonNullable<TFields2[TSelectedFields2]>,
TEntity2 extends ReadonlyEntity<TFields2, TID2, TViewerContext>, TEntity2 extends ReadonlyEntity<TFields2, TID2, TViewerContext, TSelectedFields2>,
TPrivacyPolicy2 extends EntityPrivacyPolicy<TFields2, TID2, TViewerContext, TEntity2> TPrivacyPolicy2 extends EntityPrivacyPolicy<
TFields2,
TID2,
TViewerContext,
TEntity2,
TSelectedFields2
>,
TSelectedFields2 extends keyof TFields2 = keyof TFields2
>( >(
loadDirectives: [ loadDirectives: [
EntityLoadThroughDirective<TViewerContext, TFields, TFields2, TID2, TEntity2, TPrivacyPolicy2> EntityLoadThroughDirective<
TViewerContext,
TFields,
TFields2,
TID2,
TEntity2,
TPrivacyPolicy2,
TSelectedFields,
TSelectedFields2
>
], ],
queryContext?: EntityQueryContext queryContext?: EntityQueryContext
): Promise<Result<TEntity2> | null>; ): Promise<Result<TEntity2> | null>;
@@ -128,16 +286,28 @@ 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<
TEntityRoot extends ReadonlyEntity<TFields, TID, TViewerContext>,
TPrivacyPolicyRoot extends EntityPrivacyPolicy<TFields, TID, TViewerContext, TEntityRoot>,
TFields2, TFields2,
TID2, TID2 extends NonNullable<TFields2[TSelectedFields2]>,
TEntity2 extends ReadonlyEntity<TFields2, TID2, TViewerContext>, TEntity2 extends ReadonlyEntity<TFields2, TID2, TViewerContext, TSelectedFields2>,
TPrivacyPolicy2 extends EntityPrivacyPolicy<TFields2, TID2, TViewerContext, TEntity2>, TPrivacyPolicy2 extends EntityPrivacyPolicy<
TFields2,
TID2,
TViewerContext,
TEntity2,
TSelectedFields2
>,
TFields3, TFields3,
TID3, TID3 extends NonNullable<TFields3[TSelectedFields3]>,
TEntity3 extends ReadonlyEntity<TFields3, TID3, TViewerContext>, TEntity3 extends ReadonlyEntity<TFields3, TID3, TViewerContext, TSelectedFields3>,
TPrivacyPolicy3 extends EntityPrivacyPolicy<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<
@@ -146,7 +316,9 @@ export default class EntityAssociationLoader<
TFields2, TFields2,
TID2, TID2,
TEntity2, TEntity2,
TPrivacyPolicy2 TPrivacyPolicy2,
TSelectedFields,
TSelectedFields2
>, >,
EntityLoadThroughDirective< EntityLoadThroughDirective<
TViewerContext, TViewerContext,
@@ -154,7 +326,9 @@ export default class EntityAssociationLoader<
TFields3, TFields3,
TID3, TID3,
TEntity3, TEntity3,
TPrivacyPolicy3 TPrivacyPolicy3,
TSelectedFields2,
TSelectedFields3
> >
], ],
queryContext?: EntityQueryContext queryContext?: EntityQueryContext
@@ -167,20 +341,39 @@ 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<
TEntityRoot extends ReadonlyEntity<TFields, TID, TViewerContext>,
TPrivacyPolicyRoot extends EntityPrivacyPolicy<TFields, TID, TViewerContext, TEntityRoot>,
TFields2, TFields2,
TID2, TID2 extends NonNullable<TFields2[TSelectedFields2]>,
TEntity2 extends ReadonlyEntity<TFields2, TID2, TViewerContext>, TEntity2 extends ReadonlyEntity<TFields2, TID2, TViewerContext, TSelectedFields2>,
TPrivacyPolicy2 extends EntityPrivacyPolicy<TFields2, TID2, TViewerContext, TEntity2>, TPrivacyPolicy2 extends EntityPrivacyPolicy<
TFields2,
TID2,
TViewerContext,
TEntity2,
TSelectedFields2
>,
TFields3, TFields3,
TID3, TID3 extends NonNullable<TFields3[TSelectedFields3]>,
TEntity3 extends ReadonlyEntity<TFields3, TID3, TViewerContext>, TEntity3 extends ReadonlyEntity<TFields3, TID3, TViewerContext, TSelectedFields3>,
TPrivacyPolicy3 extends EntityPrivacyPolicy<TFields3, TID3, TViewerContext, TEntity3>, TPrivacyPolicy3 extends EntityPrivacyPolicy<
TFields3,
TID3,
TViewerContext,
TEntity3,
TSelectedFields3
>,
TFields4, TFields4,
TID4, TID4 extends NonNullable<TFields4[TSelectedFields4]>,
TEntity4 extends ReadonlyEntity<TFields4, TID4, TViewerContext>, TEntity4 extends ReadonlyEntity<TFields4, TID4, TViewerContext, TSelectedFields4>,
TPrivacyPolicy4 extends EntityPrivacyPolicy<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<
@@ -189,7 +382,9 @@ export default class EntityAssociationLoader<
TFields2, TFields2,
TID2, TID2,
TEntity2, TEntity2,
TPrivacyPolicy2 TPrivacyPolicy2,
TSelectedFields,
TSelectedFields2
>, >,
EntityLoadThroughDirective< EntityLoadThroughDirective<
TViewerContext, TViewerContext,
@@ -197,7 +392,9 @@ export default class EntityAssociationLoader<
TFields3, TFields3,
TID3, TID3,
TEntity3, TEntity3,
TPrivacyPolicy3 TPrivacyPolicy3,
TSelectedFields2,
TSelectedFields3
>, >,
EntityLoadThroughDirective< EntityLoadThroughDirective<
TViewerContext, TViewerContext,
@@ -205,7 +402,9 @@ export default class EntityAssociationLoader<
TFields4, TFields4,
TID4, TID4,
TEntity4, TEntity4,
TPrivacyPolicy4 TPrivacyPolicy4,
TSelectedFields3,
TSelectedFields4
> >
], ],
queryContext?: EntityQueryContext queryContext?: EntityQueryContext
@@ -218,22 +417,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>[], loadDirectives: EntityLoadThroughDirective<TViewerContext, any, any, any, any, any, any, any>[],
queryContext?: EntityQueryContext queryContext?: EntityQueryContext
): Promise<Result<ReadonlyEntity<any, any, any>> | null>; ): Promise<Result<ReadonlyEntity<any, any, any, any>> | null>;
async loadAssociatedEntityThroughAsync( async loadAssociatedEntityThroughAsync(
loadDirectives: EntityLoadThroughDirective<TViewerContext, any, any, any, any, any>[], loadDirectives: EntityLoadThroughDirective<TViewerContext, any, any, any, any, any, any, any>[],
queryContext?: EntityQueryContext queryContext?: EntityQueryContext
): Promise<Result<ReadonlyEntity<any, any, any>> | null> { ): Promise<Result<ReadonlyEntity<any, any, any, any>> | null> {
let currentEntity: ReadonlyEntity<any, any, any> = this.entity; let currentEntity: ReadonlyEntity<any, 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>> | null; let associatedEntityResult: Result<ReadonlyEntity<any, any, any, any>> | null;
if (associatedEntityLookupByField) { if (associatedEntityLookupByField) {
associatedEntityResult = await currentEntity associatedEntityResult = await currentEntity
.associationLoader() .associationLoader()
@@ -244,13 +443,19 @@ export default class EntityAssociationLoader<
queryContext queryContext
); );
} else { } else {
associatedEntityResult = await currentEntity const associatedEntityResultLocal = 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) {
@@ -273,14 +478,22 @@ export interface EntityLoadThroughDirective<
TViewerContext extends ViewerContext, TViewerContext extends ViewerContext,
TFields, TFields,
TAssociatedFields, TAssociatedFields,
TAssociatedID, TAssociatedID extends NonNullable<TAssociatedFields[TAssociatedSelectedFields]>,
TAssociatedEntity extends ReadonlyEntity<TAssociatedFields, 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.
@@ -290,17 +503,18 @@ 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 TFields; fieldIdentifyingAssociatedEntity: keyof Pick<TFields, TSelectedFields>;
/** /**
* 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 TAssociatedFields; associatedEntityLookupByField?: keyof Pick<TAssociatedFields, TAssociatedSelectedFields>;
} }
+2 -10
View File
@@ -1,5 +1,4 @@
import EntityConfiguration from './EntityConfiguration'; import EntityConfiguration from './EntityConfiguration';
import { FieldTransformerMap } from './internal/EntityFieldTransformationUtils';
import { CacheLoadResult } from './internal/ReadThroughEntityCache'; import { CacheLoadResult } from './internal/ReadThroughEntityCache';
/** /**
@@ -9,13 +8,6 @@ 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
@@ -25,7 +17,7 @@ export default abstract class EntityCacheAdapter<TFields> {
public abstract loadManyAsync<N extends keyof TFields>( public abstract 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<TFields>>>;
/** /**
* Cache many objects fetched from the DB. * Cache many objects fetched from the DB.
@@ -34,7 +26,7 @@ export default abstract class EntityCacheAdapter<TFields> {
*/ */
public abstract cacheManyAsync<N extends keyof TFields>( public abstract cacheManyAsync<N extends keyof TFields>(
fieldName: N, fieldName: N,
objectMap: ReadonlyMap<NonNullable<TFields[N]>, object> objectMap: ReadonlyMap<NonNullable<TFields[N]>, Readonly<TFields>>
): Promise<void>; ): Promise<void>;
/** /**
+87 -43
View File
@@ -1,17 +1,13 @@
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 IEntityCacheAdapterProvider from './IEntityCacheAdapterProvider'; import EntityQueryContextProvider from './EntityQueryContextProvider';
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 EntityDataManager from './internal/EntityDataManager'; import EntityTableDataCoordinator from './internal/EntityTableDataCoordinator';
import ReadThroughEntityCache from './internal/ReadThroughEntityCache';
import IEntityMetricsAdapter from './metrics/IEntityMetricsAdapter'; import IEntityMetricsAdapter from './metrics/IEntityMetricsAdapter';
export interface IPrivacyPolicyClass<TPrivacyPolicy> { export interface IPrivacyPolicyClass<TPrivacyPolicy> {
@@ -23,74 +19,122 @@ export interface IPrivacyPolicyClass<TPrivacyPolicy> {
*/ */
export default class EntityCompanion< export default class EntityCompanion<
TFields, TFields,
TID, TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext, TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext>, TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
TPrivacyPolicy extends EntityPrivacyPolicy<TFields, TID, TViewerContext, TEntity> TPrivacyPolicy extends EntityPrivacyPolicy<
TFields,
TID,
TViewerContext,
TEntity,
TSelectedFields
>,
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<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy>, entityClass: IEntityClass<
entityConfiguration: EntityConfiguration<TFields>, TFields,
databaseAdapterProvider: IEntityDatabaseAdapterProvider, TID,
cacheAdapterProvider: IEntityCacheAdapterProvider, TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
>,
private readonly tableDataCoordinator: EntityTableDataCoordinator<TFields>,
PrivacyPolicyClass: IPrivacyPolicyClass<TPrivacyPolicy>, PrivacyPolicyClass: IPrivacyPolicyClass<TPrivacyPolicy>,
private readonly queryContextProvider: IEntityQueryContextProvider, mutationValidators: EntityMutationValidator<
TFields,
metricsAdapter: IEntityMetricsAdapter TID,
TViewerContext,
TEntity,
TSelectedFields
>[],
mutationTriggers: EntityMutationTriggerConfiguration<
TFields,
TID,
TViewerContext,
TEntity,
TSelectedFields
>,
private readonly metricsAdapter: IEntityMetricsAdapter
) { ) {
this.databaseAdapter = databaseAdapterProvider.getDatabaseAdapter(entityConfiguration); const privacyPolicy = new PrivacyPolicyClass();
this.cacheAdapter = cacheAdapterProvider.getCacheAdapter(entityConfiguration); this.entityLoaderFactory = new EntityLoaderFactory<
const dataManager = new EntityDataManager( TFields,
this.databaseAdapter, TID,
new ReadThroughEntityCache(entityConfiguration, this.cacheAdapter), TViewerContext,
queryContextProvider, TEntity,
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(
entityConfiguration, tableDataCoordinator.entityConfiguration,
entityClass, entityClass,
privacyPolicy, privacyPolicy,
mutationValidators,
mutationTriggers,
this.entityLoaderFactory, this.entityLoaderFactory,
this.databaseAdapter, tableDataCoordinator.databaseAdapter,
metricsAdapter metricsAdapter
); );
} }
getLoaderFactory(): EntityLoaderFactory<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy> { getLoaderFactory(): EntityLoaderFactory<
TFields,
TID,
TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
> {
return this.entityLoaderFactory; return this.entityLoaderFactory;
} }
getMutatorFactory(): EntityMutatorFactory<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy> { getMutatorFactory(): EntityMutatorFactory<
TFields,
TID,
TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
> {
return this.entityMutatorFactory; return this.entityMutatorFactory;
} }
getQueryContextProvider(): IEntityQueryContextProvider { /**
return this.queryContextProvider; * Get the query context provider for this entity.
*/
getQueryContextProvider(): EntityQueryContextProvider {
return this.tableDataCoordinator.getQueryContextProvider();
}
/**
* Get the {@link IEntityMetricsAdapter} for this companion.
*/
getMetricsAdapter(): IEntityMetricsAdapter {
return this.metricsAdapter;
} }
} }
+190 -48
View File
@@ -1,37 +1,31 @@
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 enum DatabaseAdapterFlavor { export type DatabaseAdapterFlavor = string;
/**
* 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 enum CacheAdapterFlavor { export type CacheAdapterFlavor = string;
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
@@ -40,7 +34,7 @@ export enum CacheAdapterFlavor {
*/ */
export interface DatabaseAdapterFlavorDefinition { export interface DatabaseAdapterFlavorDefinition {
adapterProvider: IEntityDatabaseAdapterProvider; adapterProvider: IEntityDatabaseAdapterProvider;
queryContextProvider: IEntityQueryContextProvider; queryContextProvider: EntityQueryContextProvider;
} }
/** /**
@@ -55,18 +49,107 @@ 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 interface EntityCompanionDefinition< export class EntityCompanionDefinition<
TFields, TFields,
TID, TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext, TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext>, TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
TPrivacyPolicy extends EntityPrivacyPolicy<TFields, TID, TViewerContext, TEntity> TPrivacyPolicy extends EntityPrivacyPolicy<
TFields,
TID,
TViewerContext,
TEntity,
TSelectedFields
>,
TSelectedFields extends keyof TFields = keyof TFields
> { > {
entityClass: IEntityClass<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy>; readonly entityClass: IEntityClass<
entityConfiguration: EntityConfiguration<TFields>; TFields,
databaseAdaptorFlavor: DatabaseAdapterFlavor; TID,
cacheAdaptorFlavor: CacheAdapterFlavor; TViewerContext,
privacyPolicyClass: IPrivacyPolicyClass<TPrivacyPolicy>; 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>;
/**
* The {@link EntityPrivacyPolicy} class for this entity.
*/
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;
}
} }
/** /**
@@ -79,10 +162,10 @@ export interface 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 entityCompanionMap: Map< private readonly companionMap: Map<string, EntityCompanion<any, any, any, any, any, any>> =
string, new Map();
EntityCompanion<any, any, any, any, any> private readonly tableDataCoordinatorMap: Map<string, EntityTableDataCoordinator<any>> =
> = new Map(); new Map();
/** /**
* Instantiate an Entity framework. * Instantiate an Entity framework.
@@ -91,9 +174,12 @@ export default class EntityCompanionProvider {
* @param cacheAdapterFlavors - Cache adapter configurations for this instance * @param cacheAdapterFlavors - Cache adapter configurations for this instance
*/ */
constructor( constructor(
private metricsAdapter: IEntityMetricsAdapter, public readonly metricsAdapter: IEntityMetricsAdapter,
private databaseAdapterFlavors: Record<DatabaseAdapterFlavor, DatabaseAdapterFlavorDefinition>, private databaseAdapterFlavors: ReadonlyMap<
private cacheAdapterFlavors: Record<CacheAdapterFlavor, CacheAdapterFlavorDefinition> DatabaseAdapterFlavor,
DatabaseAdapterFlavorDefinition
>,
private cacheAdapterFlavors: ReadonlyMap<CacheAdapterFlavor, CacheAdapterFlavorDefinition>
) {} ) {}
/** /**
@@ -105,36 +191,92 @@ export default class EntityCompanionProvider {
*/ */
getCompanionForEntity< getCompanionForEntity<
TFields, TFields,
TID, TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext, TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext>, TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
TPrivacyPolicy extends EntityPrivacyPolicy<TFields, TID, TViewerContext, TEntity> TPrivacyPolicy extends EntityPrivacyPolicy<
TFields,
TID,
TViewerContext,
TEntity,
TSelectedFields
>,
TSelectedFields extends keyof TFields
>( >(
entityClass: IEntityClass<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy>, entityClass: IEntityClass<
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> { ): EntityCompanion<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy, TSelectedFields> {
return computeIfAbsent(this.entityCompanionMap, entityClass.name, () => { const tableDataCoordinator = this.getTableDataCoordinatorForEntity(
const entityDatabaseAdapterFlavor = this.databaseAdapterFlavors[ entityCompanionDefinition.entityConfiguration,
entityCompanionDefinition.databaseAdaptorFlavor entityClass.name
]; );
const entityCacheAdapterFlavor = this.cacheAdapterFlavors[ return computeIfAbsent(this.companionMap, entityClass.name, () => {
entityCompanionDefinition.cacheAdaptorFlavor
];
return new EntityCompanion( return new EntityCompanion(
entityCompanionDefinition.entityClass, entityCompanionDefinition.entityClass,
entityCompanionDefinition.entityConfiguration, tableDataCoordinator,
entityDatabaseAdapterFlavor.adapterProvider,
entityCacheAdapterFlavor.cacheAdapterProvider,
entityCompanionDefinition.privacyPolicyClass, entityCompanionDefinition.privacyPolicyClass,
entityDatabaseAdapterFlavor.queryContextProvider, entityCompanionDefinition.mutationValidators(),
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
);
});
}
} }
+50 -6
View File
@@ -1,9 +1,11 @@
import { EntityFieldDefinition } from './EntityFields'; import { IEntityClass } from './Entity';
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, and field mappings. * cachable fields, field mappings, and types of cache and database adapter.
*/ */
export default class EntityConfiguration<TFields> { export default class EntityConfiguration<TFields> {
readonly idField: keyof TFields; readonly idField: keyof TFields;
@@ -11,24 +13,66 @@ export default class EntityConfiguration<TFields> {
readonly cacheableKeys: ReadonlySet<keyof TFields>; readonly cacheableKeys: ReadonlySet<keyof TFields>;
readonly cacheKeyVersion: number; readonly cacheKeyVersion: number;
readonly schema: ReadonlyMap<keyof TFields, EntityFieldDefinition>; readonly getInboundEdges: () => IEntityClass<any, any, any, any, any, 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;
schema: Record<keyof TFields, EntityFieldDefinition>;
/**
* Map from each entity field to an {@link EntityFieldDefinition} specifying information about the field.
*/
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
@@ -43,7 +87,7 @@ export default class EntityConfiguration<TFields> {
} }
private static computeCacheableKeys<TFields>( private static computeCacheableKeys<TFields>(
schema: ReadonlyMap<keyof TFields, EntityFieldDefinition> schema: ReadonlyMap<keyof TFields, EntityFieldDefinition<any>>
): ReadonlySet<keyof TFields> { ): ReadonlySet<keyof TFields> {
return reduceMap( return reduceMap(
schema, schema,
@@ -58,7 +102,7 @@ export default class EntityConfiguration<TFields> {
} }
private static computeEntityToDBFieldsKeyMapping<TFields>( private static computeEntityToDBFieldsKeyMapping<TFields>(
schema: ReadonlyMap<keyof TFields, EntityFieldDefinition> schema: ReadonlyMap<keyof TFields, EntityFieldDefinition<any>>
): ReadonlyMap<keyof TFields, string> { ): ReadonlyMap<keyof TFields, string> {
return mapMap(schema, (v) => v.columnName); return mapMap(schema, (v) => v.columnName);
} }
+16 -15
View File
@@ -14,14 +14,17 @@ 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: TFields[N][]; fieldValues: readonly 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>;
function isSingleValueFieldEqualityCondition<TFields, N extends keyof TFields = keyof TFields>( export function isSingleValueFieldEqualityCondition<
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;
@@ -34,7 +37,7 @@ export interface TableFieldSingleValueEqualityCondition {
export interface TableFieldMultiValueEqualityCondition { export interface TableFieldMultiValueEqualityCondition {
tableField: string; tableField: string;
tableValues: any[]; tableValues: readonly any[];
} }
export enum OrderByOrdering { export enum OrderByOrdering {
@@ -66,12 +69,14 @@ export interface QuerySelectionModifiers<TFields> {
} }
export interface TableQuerySelectionModifiers { export interface TableQuerySelectionModifiers {
orderBy?: { orderBy:
columnName: string; | {
order: OrderByOrdering; columnName: string;
}[]; order: OrderByOrdering;
offset?: number; }[]
limit?: number; | undefined;
offset: number | undefined;
limit: number | undefined;
} }
/** /**
@@ -260,7 +265,7 @@ export default abstract class EntityDatabaseAdapter<TFields> {
return transformDatabaseObjectToFields( return transformDatabaseObjectToFields(
this.entityConfiguration, this.entityConfiguration,
this.fieldTransformerMap, this.fieldTransformerMap,
results[0] results[0]!
); );
} }
@@ -312,7 +317,7 @@ export default abstract class EntityDatabaseAdapter<TFields> {
return transformDatabaseObjectToFields( return transformDatabaseObjectToFields(
this.entityConfiguration, this.entityConfiguration,
this.fieldTransformerMap, this.fieldTransformerMap,
results[0] results[0]!
); );
} }
@@ -348,10 +353,6 @@ 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
@@ -1,46 +0,0 @@
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;
}
}
Loaded 100 of 188 files, more files were not shown because too many files have changed in this diff. Show more