Compare commits

...
Author SHA1 Message Date
Quinlan Jung a38e77e9af remove transformer test in read thru 2022-02-03 21:05:37 -08:00
Quinlan Jung 9ba1d430e0 [entity][entity-cache-adapter-redis] move transformer responsibility to cache adapter 2022-02-03 21:02:46 -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
156 changed files with 8863 additions and 4202 deletions

No files matched your search

+2 -1
View File
@@ -10,7 +10,8 @@
"rules": { "rules": {
"no-restricted-imports": ["error",{ "paths": [".", "..", "../.."] }], "no-restricted-imports": ["error",{ "paths": [".", "..", "../.."] }],
"tsdoc/syntax": "warn", "tsdoc/syntax": "warn",
"no-console": "warn" "no-console": "warn",
"handle-callback-err": "off"
}, },
"overrides": [ "overrides": [
{ {
+183
View File
@@ -3,6 +3,189 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
### Features
* Upgrade knex to 1.x in `@expo/entity-database-adapter-knex` ([#150](https://github.com/expo/entity/issues/150)) ([e1b0ee5](https://github.com/expo/entity/commit/e1b0ee528175646239d88aa71ffce73983eeb52f))
# [0.21.0](https://github.com/expo/entity/compare/v0.20.0...v0.21.0) (2022-01-03)
### Features
* add pre-commit callbacks on EntityQueryContext ([#147](https://github.com/expo/entity/issues/147)) ([f1d9847](https://github.com/expo/entity/commit/f1d9847210f5775de964509e44ce493e44482b21))
# [0.20.0](https://github.com/expo/entity/compare/v0.19.0...v0.20.0) (2021-12-29)
### Bug Fixes
* separate post-commit invalidation callbacks from trigger and user callbacks ([#144](https://github.com/expo/entity/issues/144)) ([efa131e](https://github.com/expo/entity/commit/efa131ef29532accf0e51b6d867ffe15a27b4c90))
### Features
* add cascading deletion info ([#145](https://github.com/expo/entity/issues/145)) ([3727191](https://github.com/expo/entity/commit/372719176742d3ead6b7cbca29cb66b09b3fe09c))
# [0.19.0](https://github.com/expo/entity/compare/v0.18.0...v0.19.0) (2021-10-28)
### Features
* add authorization metrics ([#142](https://github.com/expo/entity/issues/142)) ([7cf54d8](https://github.com/expo/entity/commit/7cf54d83c52e104159a3e2e675cdda2d29afe204))
* add IP support ([#138](https://github.com/expo/entity/issues/138)) ([1405a91](https://github.com/expo/entity/commit/1405a9126ee3a56c3e2a38bf3b697283744f5b5d))
# [0.18.0](https://github.com/expo/entity/compare/v0.17.0...v0.18.0) (2021-10-14)
### Features
* enable noImplicitOverride tsc setting ([#135](https://github.com/expo/entity/issues/135)) ([4263cb9](https://github.com/expo/entity/commit/4263cb9b8b69fe4fb68c74ec1c7aba04508a1555))
* expose describeFieldTestCase for use in other packages ([#139](https://github.com/expo/entity/issues/139)) ([1220477](https://github.com/expo/entity/commit/1220477aa331349b482ad015b44f76fa2bda7b90))
* upgrade TypeScript to 4.4 ([#134](https://github.com/expo/entity/issues/134)) ([7612392](https://github.com/expo/entity/commit/7612392dbfd9778d25c465c1626c372d75a6d05a))
* use better QueryContext types for transactional triggers and validators ([#136](https://github.com/expo/entity/issues/136)) ([5c7efe1](https://github.com/expo/entity/commit/5c7efe1678424ff907687e9327175bdc2538c3e0))
# [0.17.0](https://github.com/expo/entity/compare/v0.16.0...v0.17.0) (2021-08-16)
### Features
* Add IntField and FloatField, deprecate NumberField ([#131](https://github.com/expo/entity/issues/131)) ([2f2d963](https://github.com/expo/entity/commit/2f2d963cf92c6575d78f8eeca344a4aadcc9b8d9))
# [0.16.0](https://github.com/expo/entity/compare/v0.15.0...v0.16.0) (2021-07-07)
### Features
* allow null field values in loadManyByFieldEqualityConjunctionAsync ([#130](https://github.com/expo/entity/issues/130)) ([2f37dc6](https://github.com/expo/entity/commit/2f37dc6a37d368b12b3c8375e04f0777fd448797))
# [0.15.0](https://github.com/expo/entity/compare/v0.14.1...v0.15.0) (2021-05-26)
### Bug Fixes
* upgrade ioredis, knex, and pg ([#125](https://github.com/expo/entity/issues/125)) ([7c43edf](https://github.com/expo/entity/commit/7c43edf3de37e8af13e2a99bd44f03f54803d9b9))
* upgrade some other packages to fix dependabot alerts ([#126](https://github.com/expo/entity/issues/126)) ([226bffe](https://github.com/expo/entity/commit/226bffe0a95d7b8a3e9e9d46aebdb0edf1eeec1a))
* use column name instead of field name for redis cache key ([#124](https://github.com/expo/entity/issues/124)) ([d78f452](https://github.com/expo/entity/commit/d78f452bb3ac5527069813a03f5b2264375bd8ab))
### Features
* secondary cache loader ([#123](https://github.com/expo/entity/issues/123)) ([4cba01e](https://github.com/expo/entity/commit/4cba01eb259c87d60b3026ce776e46f781363690))
## [0.14.1](https://github.com/expo/entity/compare/v0.14.0...v0.14.1) (2021-03-24)
### Bug Fixes
* typescript typedef of EntityMutationInfo ([#121](https://github.com/expo/entity/issues/121)) ([8e8f0dc](https://github.com/expo/entity/commit/8e8f0dc8636e343e29a6012d155e4111477a8989))
# [0.14.0](https://github.com/expo/entity/compare/v0.13.0...v0.14.0) (2021-03-24)
### Bug Fixes
* mutation validator and trigger cyclic import structure ([#118](https://github.com/expo/entity/issues/118)) ([b11dbc0](https://github.com/expo/entity/commit/b11dbc087ba5ad13c748032698dd73279b7960b0))
### Features
* add mutation type to validator and trigger execute functions ([#119](https://github.com/expo/entity/issues/119)) ([ef40fa5](https://github.com/expo/entity/commit/ef40fa5e9cdf53e25e78a9423c698e6d1c9737af))
# [0.13.0](https://github.com/expo/entity/compare/v0.12.0...v0.13.0) (2021-02-12)
### Bug Fixes
* allow undefined value for all fields ([#114](https://github.com/expo/entity/issues/114)) ([022e0e9](https://github.com/expo/entity/commit/022e0e9b88a5e84e0c1bc6c8810c850beb05f0cf))
### Features
* add basic field type runtime validators ([#113](https://github.com/expo/entity/issues/113)) ([6c4d4b0](https://github.com/expo/entity/commit/6c4d4b03b5404dd776a12c5eb6f0af77a8c964f8))
# [0.12.0](https://github.com/expo/entity/compare/v0.11.0...v0.12.0) (2021-01-22)
### Bug Fixes
* move runInTransaction to ViewerContext ([#108](https://github.com/expo/entity/issues/108)) ([b7309e1](https://github.com/expo/entity/commit/b7309e18a2dd780b64cd306d566de1f12bf8df3a))
* remove cache and database adaptor flavor enums ([#109](https://github.com/expo/entity/issues/109)) ([72a77f8](https://github.com/expo/entity/commit/72a77f8b6893a4b76d3b1cce16659bdf3ce473ee))
### Features
* better typing on filterMap for use with predicates ([#106](https://github.com/expo/entity/issues/106)) ([01b3bfe](https://github.com/expo/entity/commit/01b3bfef7c6da55e7d89d8cccb52a0050187aee8))
# [0.11.0](https://github.com/expo/entity/compare/v0.10.0...v0.11.0) (2020-12-24)
### Bug Fixes
* add type generic default in test utils ([#104](https://github.com/expo/entity/issues/104)) ([fe2917e](https://github.com/expo/entity/commit/fe2917e6b5af808f166aa354d476e168c41e6d21))
* maps utils invariants for collections containing undefined values ([#103](https://github.com/expo/entity/issues/103)) ([29c1a43](https://github.com/expo/entity/commit/29c1a434ab6cd8d30e9f0e7c742065c8d46d8d8a))
* remove any cast from EntityLoader ([#100](https://github.com/expo/entity/issues/100)) ([b8e07f9](https://github.com/expo/entity/commit/b8e07f9ddc4077768980c000d61f5ddcc824e2e3))
### Features
* better cache adapter error handling ([#102](https://github.com/expo/entity/issues/102)) ([15546aa](https://github.com/expo/entity/commit/15546aad97734ab9ac67f1c012485f096a0e94f1))
* better database adapter error handling ([#101](https://github.com/expo/entity/issues/101)) ([5208aee](https://github.com/expo/entity/commit/5208aeedb0192f0460ba3d575770a04175f5c3aa))
# [0.10.0](https://github.com/expo/entity/compare/v0.9.1...v0.10.0) (2020-11-17) # [0.10.0](https://github.com/expo/entity/compare/v0.9.1...v0.10.0) (2020-11-17)
+1 -1
View File
@@ -4,5 +4,5 @@
], ],
"npmClient": "yarn", "npmClient": "yarn",
"useWorkspaces": true, "useWorkspaces": true,
"version": "0.10.0" "version": "0.22.0"
} }
+21 -15
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",
@@ -16,30 +19,33 @@
"@expo/entity": "file:packages/entity", "@expo/entity": "file:packages/entity",
"@expo/results": "^1.0.0", "@expo/results": "^1.0.0",
"@types/invariant": "^2.2.33", "@types/invariant": "^2.2.33",
"@types/ioredis": "^4.17.3", "@types/ioredis": "^4.26.4",
"@types/jest": "^26.0.10", "@types/jest": "^26.0.10",
"@types/jsbn": "^1.2.29",
"@types/node": "^14.6.0", "@types/node": "^14.6.0",
"@types/uuid": "^8.3.0", "@types/uuid": "^8.3.0",
"@typescript-eslint/eslint-plugin": "^3.10.1", "@typescript-eslint/eslint-plugin": "^4.14.0",
"@typescript-eslint/parser": "^3.10.1", "@typescript-eslint/parser": "^4.14.0",
"barrelsby": "^2.2.0", "barrelsby": "^2.2.0",
"eslint": "^7.7.0", "eslint": "^7.7.0",
"eslint-config-universe": "^4.0.0", "eslint-config-universe": "^7.0.0",
"eslint-plugin-tsdoc": "^0.2.6", "eslint-plugin-tsdoc": "^0.2.11",
"jest": "^26.4.2", "jest": "^26.6.3",
"lerna": "^3.22.1", "lerna": "^4.0.0",
"pg": "^8.3.2", "nullthrows": "^1.1.1",
"prettier": "^2.1.0", "pg": "^8.6.0",
"ts-jest": "~26.2.0", "prettier": "^2.3.2",
"ts-jest": "~26.4.4",
"ts-mockito": "^2.6.1", "ts-mockito": "^2.6.1",
"typedoc": "^0.17.6", "typedoc": "^0.21.9",
"typedoc-plugin-lerna-packages": "^0.3.0", "typescript": "^4.4.2",
"typescript": "^4.0.2",
"uuid": "^8.3.0" "uuid": "^8.3.0"
}, },
"dependencies": { "dependencies": {
"@expo/entity": "file:packages/entity", "@expo/entity": "file:packages/entity",
"@expo/entity-cache-adapter-redis": "file:packages/entity-cache-adapter-redis", "@expo/entity-cache-adapter-redis": "file:packages/entity-cache-adapter-redis",
"@expo/entity-database-adapter-knex": "file:packages/entity-database-adapter-knex" "@expo/entity-database-adapter-knex": "file:packages/entity-database-adapter-knex",
"@expo/entity-secondary-cache-redis": "file:packages/entity-secondary-cache-redis",
"@expo/entity-ip-address-field": "file:packages/entity-ip-address-field"
} }
} }
@@ -3,6 +3,129 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.21.0](https://github.com/expo/entity/compare/v0.20.0...v0.21.0) (2022-01-03)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.20.0](https://github.com/expo/entity/compare/v0.19.0...v0.20.0) (2021-12-29)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.19.0](https://github.com/expo/entity/compare/v0.18.0...v0.19.0) (2021-10-28)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.18.0](https://github.com/expo/entity/compare/v0.17.0...v0.18.0) (2021-10-14)
### Features
* enable noImplicitOverride tsc setting ([#135](https://github.com/expo/entity/issues/135)) ([4263cb9](https://github.com/expo/entity/commit/4263cb9b8b69fe4fb68c74ec1c7aba04508a1555))
* upgrade TypeScript to 4.4 ([#134](https://github.com/expo/entity/issues/134)) ([7612392](https://github.com/expo/entity/commit/7612392dbfd9778d25c465c1626c372d75a6d05a))
# [0.17.0](https://github.com/expo/entity/compare/v0.16.0...v0.17.0) (2021-08-16)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.16.0](https://github.com/expo/entity/compare/v0.15.0...v0.16.0) (2021-07-07)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.15.0](https://github.com/expo/entity/compare/v0.14.1...v0.15.0) (2021-05-26)
### Bug Fixes
* upgrade ioredis, knex, and pg ([#125](https://github.com/expo/entity/issues/125)) ([7c43edf](https://github.com/expo/entity/commit/7c43edf3de37e8af13e2a99bd44f03f54803d9b9))
* use column name instead of field name for redis cache key ([#124](https://github.com/expo/entity/issues/124)) ([d78f452](https://github.com/expo/entity/commit/d78f452bb3ac5527069813a03f5b2264375bd8ab))
### Features
* secondary cache loader ([#123](https://github.com/expo/entity/issues/123)) ([4cba01e](https://github.com/expo/entity/commit/4cba01eb259c87d60b3026ce776e46f781363690))
## [0.14.1](https://github.com/expo/entity/compare/v0.14.0...v0.14.1) (2021-03-24)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.14.0](https://github.com/expo/entity/compare/v0.13.0...v0.14.0) (2021-03-24)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.13.0](https://github.com/expo/entity/compare/v0.12.0...v0.13.0) (2021-02-12)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.12.0](https://github.com/expo/entity/compare/v0.11.0...v0.12.0) (2021-01-22)
### Bug Fixes
* remove cache and database adaptor flavor enums ([#109](https://github.com/expo/entity/issues/109)) ([72a77f8](https://github.com/expo/entity/commit/72a77f8b6893a4b76d3b1cce16659bdf3ce473ee))
# [0.11.0](https://github.com/expo/entity/compare/v0.10.0...v0.11.0) (2020-12-24)
### Features
* better cache adapter error handling ([#102](https://github.com/expo/entity/issues/102)) ([15546aa](https://github.com/expo/entity/commit/15546aad97734ab9ac67f1c012485f096a0e94f1))
# [0.10.0](https://github.com/expo/entity/compare/v0.9.1...v0.10.0) (2020-11-17) # [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 **Note:** Version bump only for package @expo/entity-cache-adapter-redis
@@ -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.10.0", "version": "0.22.0",
"description": "Redis cache adapter for @expo/entity", "description": "Redis cache adapter for @expo/entity",
"files": [ "files": [
"build", "build",
@@ -11,7 +11,8 @@
"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",
"lint-fix": "eslint src --fix",
"test": "jest --rootDir . --config ../../resources/jest.config.js", "test": "jest --rootDir . --config ../../resources/jest.config.js",
"integration": "../../resources/run-with-docker yarn integration-no-setup", "integration": "../../resources/run-with-docker yarn integration-no-setup",
"integration-no-setup": "jest --config ../../resources/jest-integration.config.js --rootDir . --runInBand --passWithNoTests", "integration-no-setup": "jest --config ../../resources/jest-integration.config.js --rootDir . --runInBand --passWithNoTests",
@@ -29,9 +30,9 @@
"@expo/entity": "*" "@expo/entity": "*"
}, },
"dependencies": { "dependencies": {
"ioredis": "^4.17.3" "ioredis": "^4.27.3"
}, },
"devDependencies": { "devDependencies": {
"@expo/entity": "^0.10.0" "@expo/entity": "^0.22.0"
} }
} }
@@ -0,0 +1,107 @@
import { CacheLoadResult, CacheStatus } from '@expo/entity';
import { Redis } from 'ioredis';
import wrapNativeRedisCallAsync from './errors/wrapNativeRedisCallAsync';
// Sentinel value we store in Redis to negatively cache a database miss.
// The sentinel value is distinct from any (positively) cached value.
const DOES_NOT_EXIST_REDIS = '';
export interface GenericRedisCacheContext {
/**
* Instance of ioredis.Redis
*/
redisClient: Redis;
/**
* TTL for caching database hits. Successive entity loads within this TTL
* will be read from cache (unless invalidated).
*/
ttlSecondsPositive: number;
/**
* TTL for negatively caching database misses. Successive entity loads within
* this TTL will be assumed not present in the database (unless invalidated).
*/
ttlSecondsNegative: number;
}
export default class GenericRedisCacher {
constructor(private readonly context: GenericRedisCacheContext) {}
public async loadManyAsync(
keys: readonly string[]
): Promise<ReadonlyMap<string, CacheLoadResult>> {
if (keys.length === 0) {
return new Map();
}
const redisResults = await wrapNativeRedisCallAsync(() =>
this.context.redisClient.mget(...keys)
);
const results = new Map<string, CacheLoadResult>();
for (let i = 0; i < keys.length; i++) {
const key = keys[i]!;
const redisResult = redisResults[i];
if (redisResult === DOES_NOT_EXIST_REDIS) {
results.set(key, {
status: CacheStatus.NEGATIVE,
});
} else if (redisResult) {
results.set(key, {
status: CacheStatus.HIT,
item: JSON.parse(redisResult),
});
} else {
results.set(key, {
status: CacheStatus.MISS,
});
}
}
return results;
}
public async cacheManyAsync(objectMap: ReadonlyMap<string, object>): Promise<void> {
if (objectMap.size === 0) {
return;
}
let redisTransaction = this.context.redisClient.multi();
objectMap.forEach((object, key) => {
redisTransaction = redisTransaction.set(
key,
JSON.stringify(object),
'EX',
this.context.ttlSecondsPositive
);
});
await wrapNativeRedisCallAsync(() => redisTransaction.exec());
}
public async cacheDBMissesAsync(keys: string[]): Promise<void> {
if (keys.length === 0) {
return;
}
let redisTransaction = this.context.redisClient.multi();
keys.forEach((key) => {
redisTransaction = redisTransaction.set(
key,
DOES_NOT_EXIST_REDIS,
'EX',
this.context.ttlSecondsNegative
);
});
await wrapNativeRedisCallAsync(() => redisTransaction.exec());
}
public async invalidateManyAsync(keys: string[]): Promise<void> {
if (keys.length === 0) {
return;
}
await wrapNativeRedisCallAsync(() => this.context.redisClient.del(...keys));
}
}
@@ -1,18 +1,19 @@
import { import {
EntityCacheAdapter, EntityCacheAdapter,
EntityConfiguration, EntityConfiguration,
FieldTransformerMap,
CacheLoadResult, CacheLoadResult,
FieldTransformerMap,
mapKeys,
transformCacheObjectToFields,
CacheStatus, CacheStatus,
transformFieldsToCacheObject,
} from '@expo/entity'; } from '@expo/entity';
import invariant from 'invariant';
import { Redis } from 'ioredis'; import { Redis } from 'ioredis';
import GenericRedisCacher from './GenericRedisCacher';
import { redisTransformerMap } from './RedisCommon'; import { redisTransformerMap } from './RedisCommon';
// Sentinel value we store in Redis to negatively cache a database miss.
// The sentinel value is distinct from any (positively) cached value.
const DOES_NOT_EXIST_REDIS = '';
export interface RedisCacheAdapterContext { export interface RedisCacheAdapterContext {
/** /**
* Instance of ioredis.Redis * Instance of ioredis.Redis
@@ -51,114 +52,112 @@ export interface RedisCacheAdapterContext {
} }
export default class RedisCacheAdapter<TFields> extends EntityCacheAdapter<TFields> { export default class RedisCacheAdapter<TFields> extends EntityCacheAdapter<TFields> {
/**
* Transformer definitions for field types. Used to modify values as they are read from or written to
* the cache. Override in concrete subclasses to change transformation behavior.
* If a field type is not present in the map, then fields of that type will not be transformed.
*/
private readonly redisFieldTransformer: FieldTransformerMap = redisTransformerMap;
private readonly genericRedisCacher: GenericRedisCacher;
constructor( constructor(
entityConfiguration: EntityConfiguration<TFields>, entityConfiguration: EntityConfiguration<TFields>,
private readonly context: RedisCacheAdapterContext private readonly context: RedisCacheAdapterContext
) { ) {
super(entityConfiguration); super(entityConfiguration);
}
public getFieldTransformerMap(): FieldTransformerMap { this.genericRedisCacher = new GenericRedisCacher({
return redisTransformerMap; redisClient: context.redisClient,
ttlSecondsNegative: context.ttlSecondsNegative,
ttlSecondsPositive: context.ttlSecondsPositive,
});
} }
public async loadManyAsync<N extends keyof TFields>( public async loadManyAsync<N extends keyof TFields>(
fieldName: N, fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[] fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult>> { ): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult>> {
if (fieldValues.length === 0) { const redisCacheKeyToFieldValueMapping = new Map(
return new Map(); fieldValues.map((fieldValue) => [this.makeCacheKey(fieldName, fieldValue), fieldValue])
} );
const rawCacheResults = await this.genericRedisCacher.loadManyAsync(
const redisKeys = fieldValues.map((fieldValue) => this.makeCacheKey(fieldName, fieldValue)); Array.from(redisCacheKeyToFieldValueMapping.keys())
const redisResults = await this.context.redisClient.mget(...redisKeys); );
const cacheResults = new Map<string, CacheLoadResult>();
const results = new Map<NonNullable<TFields[N]>, CacheLoadResult>(); for (const [redisCacheKey, rawCacheResult] of rawCacheResults) {
for (let i = 0; i < fieldValues.length; i++) { if (rawCacheResult.status === CacheStatus.HIT) {
const fieldValue = fieldValues[i]; cacheResults.set(redisCacheKey, {
const redisResult = redisResults[i];
if (redisResult === DOES_NOT_EXIST_REDIS) {
results.set(fieldValue, {
status: CacheStatus.NEGATIVE,
});
} else if (redisResult) {
results.set(fieldValue, {
status: CacheStatus.HIT, status: CacheStatus.HIT,
item: JSON.parse(redisResult), item: transformCacheObjectToFields(
this.entityConfiguration,
this.redisFieldTransformer,
rawCacheResult.item
),
}); });
} else { } else {
results.set(fieldValue, { cacheResults.set(redisCacheKey, rawCacheResult);
status: CacheStatus.MISS,
});
} }
} }
return results;
return mapKeys(cacheResults, (redisCacheKey) => {
const fieldValue = redisCacheKeyToFieldValueMapping.get(redisCacheKey);
invariant(
fieldValue !== undefined,
'Unspecified cache key %s returned from generic Redis cacher',
redisCacheKey
);
return fieldValue;
});
} }
public async cacheManyAsync<N extends keyof TFields>( public async cacheManyAsync<N extends keyof TFields>(
fieldName: N, fieldName: N,
objectMap: ReadonlyMap<NonNullable<TFields[N]>, object> objectMap: ReadonlyMap<NonNullable<TFields[N]>, object>
): Promise<void> { ): Promise<void> {
if (objectMap.size === 0) { const cacheObjectMap = new Map<NonNullable<TFields[N]>, object>();
return; for (const [fieldValue, object] of objectMap) {
} cacheObjectMap.set(
fieldValue,
let redisTransaction = this.context.redisClient.multi(); transformFieldsToCacheObject(
objectMap.forEach((object, fieldValue) => { this.entityConfiguration,
const redisKey = this.makeCacheKey(fieldName, fieldValue); this.redisFieldTransformer,
redisTransaction = redisTransaction.set( object as Readonly<TFields>
redisKey, )
JSON.stringify(object),
'EX',
this.context.ttlSecondsPositive
); );
}); }
await redisTransaction.exec(); await this.genericRedisCacher.cacheManyAsync(
mapKeys(cacheObjectMap, (fieldValue) => this.makeCacheKey(fieldName, fieldValue))
);
} }
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)
); );
} }
@@ -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,21 +38,21 @@ 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()
)['tableDataCoordinator']['cacheAdapter']; )['tableDataCoordinator']['cacheAdapter'];
const cacheKeyMaker = cacheAdapter['makeCacheKey'].bind(cacheAdapter); const cacheKeyMaker = cacheAdapter['makeCacheKey'].bind(cacheAdapter);
const entity1Created = await RedisTestEntity.creator(vc1) const entity1Created = await RedisTestEntity.creator(viewerContext)
.setField('name', 'blah') .setField('name', 'blah')
.enforceCreateAsync(); .enforceCreateAsync();
// loading an entity should put it in cache // loading an entity should put it in cache
const entity1 = await RedisTestEntity.loader(vc1) const entity1 = await RedisTestEntity.loader(viewerContext)
.enforcing() .enforcing()
.loadByIDAsync(entity1Created.getID()); .loadByIDAsync(entity1Created.getID());
@@ -68,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(
@@ -77,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())
); );
@@ -89,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
@@ -108,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);
});
});
@@ -1,10 +1,4 @@
import { import { CacheStatus, UUIDField, EntityConfiguration, DateField } from '@expo/entity';
CacheStatus,
UUIDField,
EntityConfiguration,
DatabaseAdapterFlavor,
CacheAdapterFlavor,
} from '@expo/entity';
import { Redis, Pipeline } from 'ioredis'; import { Redis, Pipeline } from 'ioredis';
import { mock, when, instance, anything, verify } from 'ts-mockito'; import { mock, when, instance, anything, verify } from 'ts-mockito';
@@ -12,6 +6,7 @@ import RedisCacheAdapter from '../RedisCacheAdapter';
type BlahFields = { type BlahFields = {
id: string; id: string;
date: Date | null;
}; };
const entityConfiguration = new EntityConfiguration<BlahFields>({ const entityConfiguration = new EntityConfiguration<BlahFields>({
@@ -19,9 +14,10 @@ const entityConfiguration = new EntityConfiguration<BlahFields>({
tableName: 'blah', tableName: 'blah',
schema: { schema: {
id: new UUIDField({ columnName: 'id', cache: true }), id: new UUIDField({ columnName: 'id', cache: true }),
date: new DateField({ columnName: 'date' }),
}, },
databaseAdapterFlavor: DatabaseAdapterFlavor.POSTGRES, databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: CacheAdapterFlavor.REDIS, cacheAdapterFlavor: 'redis',
}); });
describe(RedisCacheAdapter, () => { describe(RedisCacheAdapter, () => {
@@ -68,6 +64,40 @@ describe(RedisCacheAdapter, () => {
const results = await cacheAdapter.loadManyAsync('id', []); const results = await cacheAdapter.loadManyAsync('id', []);
expect(results).toEqual(new Map()); expect(results).toEqual(new Map());
}); });
it('transforms a cache object to an Entity object', async () => {
const redisResults = new Map();
const mockRedisClient = mock<Redis>();
// need to have one anything() for each element of spread, in this case 1
when(mockRedisClient.mget(anything())).thenCall(async (...keys) =>
keys.map((k) => redisResults.get(k) ?? null)
);
const cacheAdapter = new RedisCacheAdapter(entityConfiguration, {
redisClient: instance(mockRedisClient),
makeKeyFn: (...parts) => parts.join(':'),
cacheKeyVersion: 1,
cacheKeyPrefix: 'hello-',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
});
const date = new Date();
redisResults.set(
cacheAdapter['makeCacheKey']('id', 'wat'),
JSON.stringify({ id: 'wat', date: date.toISOString() })
);
const results = await cacheAdapter.loadManyAsync('id', ['wat']);
expect(results.get('wat')).toMatchObject({
status: CacheStatus.HIT,
item: { id: 'wat', date },
});
expect(results.size).toBe(1);
});
}); });
describe('cacheManyAsync', () => { describe('cacheManyAsync', () => {
@@ -104,6 +134,41 @@ describe(RedisCacheAdapter, () => {
ttl: 1, ttl: 1,
}); });
}); });
it('transforms an Entity object to a cache object', async () => {
const redisResults = new Map();
const mockPipeline = mock<Pipeline>();
when(mockPipeline.set(anything(), anything(), anything(), anything())).thenCall(
(key, value, code, ttl) => {
redisResults.set(key, { value, code, ttl });
return pipeline;
}
);
when(mockPipeline.exec()).thenResolve({} as any);
const pipeline = instance(mockPipeline);
const mockRedisClient = mock<Redis>();
when(mockRedisClient.multi()).thenReturn(pipeline);
const cacheAdapter = new RedisCacheAdapter(entityConfiguration, {
redisClient: instance(mockRedisClient),
makeKeyFn: (...parts) => parts.join(':'),
cacheKeyVersion: 1,
cacheKeyPrefix: 'hello-',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
});
const date = new Date();
await cacheAdapter.cacheManyAsync('id', new Map([['wat', { id: 'wat', date }]]));
const cacheKey = cacheAdapter['makeCacheKey']('id', 'wat');
expect(redisResults.get(cacheKey)).toMatchObject({
value: JSON.stringify({ id: 'wat', date: date.toISOString() }),
code: 'EX',
ttl: 1,
});
});
}); });
describe('cacheDBMissesAsync', () => { describe('cacheDBMissesAsync', () => {
@@ -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,13 +78,14 @@ 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: DatabaseAdapterFlavor.POSTGRES, databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: CacheAdapterFlavor.REDIS, cacheAdapterFlavor: 'redis',
}); });
const redisTestEntityCompanionDefinition = new EntityCompanionDefinition({ const redisTestEntityCompanionDefinition = new EntityCompanionDefinition({
@@ -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),
},
],
])
); );
}; };
@@ -3,6 +3,154 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
### Features
* Upgrade knex to 1.x in `@expo/entity-database-adapter-knex` ([#150](https://github.com/expo/entity/issues/150)) ([e1b0ee5](https://github.com/expo/entity/commit/e1b0ee528175646239d88aa71ffce73983eeb52f))
# [0.21.0](https://github.com/expo/entity/compare/v0.20.0...v0.21.0) (2022-01-03)
### Features
* add pre-commit callbacks on EntityQueryContext ([#147](https://github.com/expo/entity/issues/147)) ([f1d9847](https://github.com/expo/entity/commit/f1d9847210f5775de964509e44ce493e44482b21))
# [0.20.0](https://github.com/expo/entity/compare/v0.19.0...v0.20.0) (2021-12-29)
### Features
* add cascading deletion info ([#145](https://github.com/expo/entity/issues/145)) ([3727191](https://github.com/expo/entity/commit/372719176742d3ead6b7cbca29cb66b09b3fe09c))
# [0.19.0](https://github.com/expo/entity/compare/v0.18.0...v0.19.0) (2021-10-28)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
# [0.18.0](https://github.com/expo/entity/compare/v0.17.0...v0.18.0) (2021-10-14)
### Features
* enable noImplicitOverride tsc setting ([#135](https://github.com/expo/entity/issues/135)) ([4263cb9](https://github.com/expo/entity/commit/4263cb9b8b69fe4fb68c74ec1c7aba04508a1555))
* upgrade TypeScript to 4.4 ([#134](https://github.com/expo/entity/issues/134)) ([7612392](https://github.com/expo/entity/commit/7612392dbfd9778d25c465c1626c372d75a6d05a))
# [0.17.0](https://github.com/expo/entity/compare/v0.16.0...v0.17.0) (2021-08-16)
### Features
* Add IntField and FloatField, deprecate NumberField ([#131](https://github.com/expo/entity/issues/131)) ([2f2d963](https://github.com/expo/entity/commit/2f2d963cf92c6575d78f8eeca344a4aadcc9b8d9))
# [0.16.0](https://github.com/expo/entity/compare/v0.15.0...v0.16.0) (2021-07-07)
### Features
* allow null field values in loadManyByFieldEqualityConjunctionAsync ([#130](https://github.com/expo/entity/issues/130)) ([2f37dc6](https://github.com/expo/entity/commit/2f37dc6a37d368b12b3c8375e04f0777fd448797))
# [0.15.0](https://github.com/expo/entity/compare/v0.14.1...v0.15.0) (2021-05-26)
### Bug Fixes
* upgrade ioredis, knex, and pg ([#125](https://github.com/expo/entity/issues/125)) ([7c43edf](https://github.com/expo/entity/commit/7c43edf3de37e8af13e2a99bd44f03f54803d9b9))
### Features
* secondary cache loader ([#123](https://github.com/expo/entity/issues/123)) ([4cba01e](https://github.com/expo/entity/commit/4cba01eb259c87d60b3026ce776e46f781363690))
## [0.14.1](https://github.com/expo/entity/compare/v0.14.0...v0.14.1) (2021-03-24)
### Bug Fixes
* typescript typedef of EntityMutationInfo ([#121](https://github.com/expo/entity/issues/121)) ([8e8f0dc](https://github.com/expo/entity/commit/8e8f0dc8636e343e29a6012d155e4111477a8989))
# [0.14.0](https://github.com/expo/entity/compare/v0.13.0...v0.14.0) (2021-03-24)
### Bug Fixes
* mutation validator and trigger cyclic import structure ([#118](https://github.com/expo/entity/issues/118)) ([b11dbc0](https://github.com/expo/entity/commit/b11dbc087ba5ad13c748032698dd73279b7960b0))
# [0.13.0](https://github.com/expo/entity/compare/v0.12.0...v0.13.0) (2021-02-12)
### Features
* add basic field type runtime validators ([#113](https://github.com/expo/entity/issues/113)) ([6c4d4b0](https://github.com/expo/entity/commit/6c4d4b03b5404dd776a12c5eb6f0af77a8c964f8))
# [0.12.0](https://github.com/expo/entity/compare/v0.11.0...v0.12.0) (2021-01-22)
### Bug Fixes
* move runInTransaction to ViewerContext ([#108](https://github.com/expo/entity/issues/108)) ([b7309e1](https://github.com/expo/entity/commit/b7309e18a2dd780b64cd306d566de1f12bf8df3a))
* remove cache and database adaptor flavor enums ([#109](https://github.com/expo/entity/issues/109)) ([72a77f8](https://github.com/expo/entity/commit/72a77f8b6893a4b76d3b1cce16659bdf3ce473ee))
# [0.11.0](https://github.com/expo/entity/compare/v0.10.0...v0.11.0) (2020-12-24)
### Features
* better cache adapter error handling ([#102](https://github.com/expo/entity/issues/102)) ([15546aa](https://github.com/expo/entity/commit/15546aad97734ab9ac67f1c012485f096a0e94f1))
* better database adapter error handling ([#101](https://github.com/expo/entity/issues/101)) ([5208aee](https://github.com/expo/entity/commit/5208aeedb0192f0460ba3d575770a04175f5c3aa))
# [0.10.0](https://github.com/expo/entity/compare/v0.9.1...v0.10.0) (2020-11-17) # [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 **Note:** Version bump only for package @expo/entity-database-adapter-knex
@@ -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.10.0", "version": "0.22.0",
"description": "Knex database adapter for @expo/entity", "description": "Knex database adapter for @expo/entity",
"files": [ "files": [
"build", "build",
@@ -11,7 +11,8 @@
"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",
"lint-fix": "eslint src --fix",
"test": "jest --rootDir . --config ../../resources/jest.config.js --passWithNoTests", "test": "jest --rootDir . --config ../../resources/jest.config.js --passWithNoTests",
"integration": "../../resources/run-with-docker yarn integration-no-setup", "integration": "../../resources/run-with-docker yarn integration-no-setup",
"integration-no-setup": "jest --config ../../resources/jest-integration.config.js --rootDir . --runInBand --passWithNoTests", "integration-no-setup": "jest --config ../../resources/jest-integration.config.js --rootDir . --runInBand --passWithNoTests",
@@ -29,9 +30,10 @@
"@expo/entity": "*" "@expo/entity": "*"
}, },
"dependencies": { "dependencies": {
"knex": "^0.21.5" "knex": "^1.0.2"
}, },
"devDependencies": { "devDependencies": {
"@expo/entity": "^0.10.0" "@expo/entity": "^0.22.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,5 +1,5 @@
import { EntityQueryContextProvider } from '@expo/entity'; import { EntityQueryContextProvider } from '@expo/entity';
import Knex from 'knex'; import { Knex } from 'knex';
/** /**
* Query context provider for knex (postgres). * Query context provider for knex (postgres).
@@ -5,7 +5,8 @@ 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 PostgresTriggerTestEntity from '../testfixtures/PostgresTriggerTestEntity';
@@ -16,14 +17,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']),
}, },
}); });
}); });
@@ -94,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()
@@ -231,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', () => {
@@ -546,4 +600,37 @@ describe('postgres entity integration', () => {
}); });
}); });
}); });
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']),
}, },
}); });
}); });
@@ -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,3 +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 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,15 +95,15 @@ 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: DatabaseAdapterFlavor.POSTGRES, databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: CacheAdapterFlavor.REDIS, cacheAdapterFlavor: 'redis',
}); });
const invalidTestEntityCompanionDefinition = new EntityCompanionDefinition({ const invalidTestEntityCompanionDefinition = new EntityCompanionDefinition({
@@ -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,8 +148,8 @@ export const postgresTestEntityConfiguration = new EntityConfiguration<PostgresT
columnName: 'maybe_json_array_field', columnName: 'maybe_json_array_field',
}), }),
}, },
databaseAdapterFlavor: DatabaseAdapterFlavor.POSTGRES, databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: CacheAdapterFlavor.REDIS, cacheAdapterFlavor: 'redis',
}); });
const postgresTestEntityCompanionDefinition = new EntityCompanionDefinition({ const postgresTestEntityCompanionDefinition = new EntityCompanionDefinition({
@@ -5,15 +5,14 @@ import {
UUIDField, UUIDField,
StringField, StringField,
EntityConfiguration, EntityConfiguration,
DatabaseAdapterFlavor,
CacheAdapterFlavor,
EntityCompanionDefinition, EntityCompanionDefinition,
Entity, Entity,
EntityMutationTrigger, EntityMutationTrigger,
EntityQueryContext, EntityQueryContext,
EntityNonTransactionalMutationTrigger, EntityNonTransactionalMutationTrigger,
EntityTriggerMutationInfo,
} from '@expo/entity'; } from '@expo/entity';
import Knex from 'knex'; import { Knex } from 'knex';
type PostgresTriggerTestEntityFields = { type PostgresTriggerTestEntityFields = {
id: string; id: string;
@@ -64,7 +63,7 @@ class PostgresTriggerTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
ViewerContext, ViewerContext,
PostgresTriggerTestEntity PostgresTriggerTestEntity
> { > {
protected readonly createRules = [ protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule< new AlwaysAllowPrivacyPolicyRule<
PostgresTriggerTestEntityFields, PostgresTriggerTestEntityFields,
string, string,
@@ -72,7 +71,7 @@ class PostgresTriggerTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
PostgresTriggerTestEntity PostgresTriggerTestEntity
>(), >(),
]; ];
protected readonly readRules = [ protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule< new AlwaysAllowPrivacyPolicyRule<
PostgresTriggerTestEntityFields, PostgresTriggerTestEntityFields,
string, string,
@@ -80,7 +79,7 @@ class PostgresTriggerTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
PostgresTriggerTestEntity PostgresTriggerTestEntity
>(), >(),
]; ];
protected readonly updateRules = [ protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule< new AlwaysAllowPrivacyPolicyRule<
PostgresTriggerTestEntityFields, PostgresTriggerTestEntityFields,
string, string,
@@ -88,7 +87,7 @@ class PostgresTriggerTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
PostgresTriggerTestEntity PostgresTriggerTestEntity
>(), >(),
]; ];
protected readonly deleteRules = [ protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule< new AlwaysAllowPrivacyPolicyRule<
PostgresTriggerTestEntityFields, PostgresTriggerTestEntityFields,
string, string,
@@ -111,7 +110,13 @@ class ThrowConditionallyTrigger extends EntityMutationTrigger<
async executeAsync( async executeAsync(
_viewerContext: ViewerContext, _viewerContext: ViewerContext,
_queryContext: EntityQueryContext, _queryContext: EntityQueryContext,
entity: PostgresTriggerTestEntity entity: PostgresTriggerTestEntity,
_mutationInfo: EntityTriggerMutationInfo<
PostgresTriggerTestEntityFields,
string,
ViewerContext,
PostgresTriggerTestEntity
>
): Promise<void> { ): Promise<void> {
if (entity.getField(this.fieldName) === this.badValue) { if (entity.getField(this.fieldName) === this.badValue) {
throw new Error(`${this.fieldName} cannot have value ${this.badValue}`); throw new Error(`${this.fieldName} cannot have value ${this.badValue}`);
@@ -139,29 +144,28 @@ class ThrowConditionallyNonTransactionalTrigger extends EntityNonTransactionalMu
} }
} }
export const postgresTestEntityConfiguration = new EntityConfiguration< export const postgresTestEntityConfiguration =
PostgresTriggerTestEntityFields new EntityConfiguration<PostgresTriggerTestEntityFields>({
>({ idField: 'id',
idField: 'id', tableName: 'postgres_test_entities',
tableName: 'postgres_test_entities', schema: {
schema: { id: new UUIDField({
id: new UUIDField({ columnName: 'id',
columnName: 'id', cache: true,
cache: true, }),
}), name: new StringField({
name: new StringField({ columnName: 'name',
columnName: 'name', }),
}), },
}, databaseAdapterFlavor: 'postgres',
databaseAdapterFlavor: DatabaseAdapterFlavor.POSTGRES, cacheAdapterFlavor: 'redis',
cacheAdapterFlavor: CacheAdapterFlavor.REDIS, });
});
const postgresTestEntityCompanionDefinition = new EntityCompanionDefinition({ const postgresTestEntityCompanionDefinition = new EntityCompanionDefinition({
entityClass: PostgresTriggerTestEntity, entityClass: PostgresTriggerTestEntity,
entityConfiguration: postgresTestEntityConfiguration, entityConfiguration: postgresTestEntityConfiguration,
privacyPolicyClass: PostgresTriggerTestEntityPrivacyPolicy, privacyPolicyClass: PostgresTriggerTestEntityPrivacyPolicy,
mutationTriggers: { mutationTriggers: () => ({
beforeCreate: [new ThrowConditionallyTrigger('name', 'beforeCreate')], beforeCreate: [new ThrowConditionallyTrigger('name', 'beforeCreate')],
afterCreate: [new ThrowConditionallyTrigger('name', 'afterCreate')], afterCreate: [new ThrowConditionallyTrigger('name', 'afterCreate')],
beforeUpdate: [new ThrowConditionallyTrigger('name', 'beforeUpdate')], beforeUpdate: [new ThrowConditionallyTrigger('name', 'beforeUpdate')],
@@ -171,5 +175,5 @@ const postgresTestEntityCompanionDefinition = new EntityCompanionDefinition({
beforeAll: [new ThrowConditionallyTrigger('name', 'beforeAll')], beforeAll: [new ThrowConditionallyTrigger('name', 'beforeAll')],
afterAll: [new ThrowConditionallyTrigger('name', 'afterAll')], afterAll: [new ThrowConditionallyTrigger('name', 'afterAll')],
afterCommit: [new ThrowConditionallyNonTransactionalTrigger('name', 'afterCommit')], afterCommit: [new ThrowConditionallyNonTransactionalTrigger('name', 'afterCommit')],
}, }),
}); });
@@ -5,14 +5,13 @@ import {
UUIDField, UUIDField,
StringField, StringField,
EntityConfiguration, EntityConfiguration,
DatabaseAdapterFlavor,
CacheAdapterFlavor,
EntityCompanionDefinition, EntityCompanionDefinition,
Entity, Entity,
EntityMutationTrigger, EntityMutationTrigger,
EntityQueryContext, EntityQueryContext,
EntityValidatorMutationInfo,
} from '@expo/entity'; } from '@expo/entity';
import Knex from 'knex'; import { Knex } from 'knex';
type PostgresValidatorTestEntityFields = { type PostgresValidatorTestEntityFields = {
id: string; id: string;
@@ -63,7 +62,7 @@ class PostgresValidatorTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
ViewerContext, ViewerContext,
PostgresValidatorTestEntity PostgresValidatorTestEntity
> { > {
protected readonly createRules = [ protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule< new AlwaysAllowPrivacyPolicyRule<
PostgresValidatorTestEntityFields, PostgresValidatorTestEntityFields,
string, string,
@@ -71,7 +70,7 @@ class PostgresValidatorTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
PostgresValidatorTestEntity PostgresValidatorTestEntity
>(), >(),
]; ];
protected readonly readRules = [ protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule< new AlwaysAllowPrivacyPolicyRule<
PostgresValidatorTestEntityFields, PostgresValidatorTestEntityFields,
string, string,
@@ -79,7 +78,7 @@ class PostgresValidatorTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
PostgresValidatorTestEntity PostgresValidatorTestEntity
>(), >(),
]; ];
protected readonly updateRules = [ protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule< new AlwaysAllowPrivacyPolicyRule<
PostgresValidatorTestEntityFields, PostgresValidatorTestEntityFields,
string, string,
@@ -87,7 +86,7 @@ class PostgresValidatorTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
PostgresValidatorTestEntity PostgresValidatorTestEntity
>(), >(),
]; ];
protected readonly deleteRules = [ protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule< new AlwaysAllowPrivacyPolicyRule<
PostgresValidatorTestEntityFields, PostgresValidatorTestEntityFields,
string, string,
@@ -113,7 +112,13 @@ class ThrowConditionallyTrigger extends EntityMutationTrigger<
async executeAsync( async executeAsync(
_viewerContext: ViewerContext, _viewerContext: ViewerContext,
_queryContext: EntityQueryContext, _queryContext: EntityQueryContext,
entity: PostgresValidatorTestEntity entity: PostgresValidatorTestEntity,
_mutationInfo: EntityValidatorMutationInfo<
PostgresValidatorTestEntityFields,
string,
ViewerContext,
PostgresValidatorTestEntity
>
): Promise<void> { ): Promise<void> {
if (entity.getField(this.fieldName) === this.badValue) { if (entity.getField(this.fieldName) === this.badValue) {
throw new Error(`${this.fieldName} cannot have value ${this.badValue}`); throw new Error(`${this.fieldName} cannot have value ${this.badValue}`);
@@ -121,27 +126,26 @@ class ThrowConditionallyTrigger extends EntityMutationTrigger<
} }
} }
export const postgresTestEntityConfiguration = new EntityConfiguration< export const postgresTestEntityConfiguration =
PostgresValidatorTestEntityFields new EntityConfiguration<PostgresValidatorTestEntityFields>({
>({ idField: 'id',
idField: 'id', tableName: 'postgres_test_entities',
tableName: 'postgres_test_entities', schema: {
schema: { id: new UUIDField({
id: new UUIDField({ columnName: 'id',
columnName: 'id', cache: true,
cache: true, }),
}), name: new StringField({
name: new StringField({ columnName: 'name',
columnName: 'name', }),
}), },
}, databaseAdapterFlavor: 'postgres',
databaseAdapterFlavor: DatabaseAdapterFlavor.POSTGRES, cacheAdapterFlavor: 'redis',
cacheAdapterFlavor: CacheAdapterFlavor.REDIS, });
});
const postgresTestEntityCompanionDefinition = new EntityCompanionDefinition({ const postgresTestEntityCompanionDefinition = new EntityCompanionDefinition({
entityClass: PostgresValidatorTestEntity, entityClass: PostgresValidatorTestEntity,
entityConfiguration: postgresTestEntityConfiguration, entityConfiguration: postgresTestEntityConfiguration,
privacyPolicyClass: PostgresValidatorTestEntityPrivacyPolicy, privacyPolicyClass: PostgresValidatorTestEntityPrivacyPolicy,
mutationValidators: [new ThrowConditionallyTrigger('name', 'beforeCreateAndBeforeUpdate')], mutationValidators: () => [new ThrowConditionallyTrigger('name', 'beforeCreateAndBeforeUpdate')],
}); });
@@ -2,11 +2,9 @@ import {
NoOpEntityMetricsAdapter, NoOpEntityMetricsAdapter,
IEntityMetricsAdapter, IEntityMetricsAdapter,
EntityCompanionProvider, EntityCompanionProvider,
CacheAdapterFlavor,
DatabaseAdapterFlavor,
InMemoryFullCacheStubCacheAdapterProvider, InMemoryFullCacheStubCacheAdapterProvider,
} 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 InMemoryFullCacheStubCacheAdapterProvider(), ]),
}, new Map([
} [
'redis',
{
cacheAdapterProvider: new InMemoryFullCacheStubCacheAdapterProvider(),
},
],
])
); );
}; };
+120
View File
@@ -3,6 +3,126 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
**Note:** Version bump only for package @expo/entity-example
# [0.21.0](https://github.com/expo/entity/compare/v0.20.0...v0.21.0) (2022-01-03)
**Note:** Version bump only for package @expo/entity-example
# [0.20.0](https://github.com/expo/entity/compare/v0.19.0...v0.20.0) (2021-12-29)
**Note:** Version bump only for package @expo/entity-example
# [0.19.0](https://github.com/expo/entity/compare/v0.18.0...v0.19.0) (2021-10-28)
**Note:** Version bump only for package @expo/entity-example
# [0.18.0](https://github.com/expo/entity/compare/v0.17.0...v0.18.0) (2021-10-14)
### Features
* enable noImplicitOverride tsc setting ([#135](https://github.com/expo/entity/issues/135)) ([4263cb9](https://github.com/expo/entity/commit/4263cb9b8b69fe4fb68c74ec1c7aba04508a1555))
* upgrade TypeScript to 4.4 ([#134](https://github.com/expo/entity/issues/134)) ([7612392](https://github.com/expo/entity/commit/7612392dbfd9778d25c465c1626c372d75a6d05a))
# [0.17.0](https://github.com/expo/entity/compare/v0.16.0...v0.17.0) (2021-08-16)
**Note:** Version bump only for package @expo/entity-example
# [0.16.0](https://github.com/expo/entity/compare/v0.15.0...v0.16.0) (2021-07-07)
**Note:** Version bump only for package @expo/entity-example
# [0.15.0](https://github.com/expo/entity/compare/v0.14.1...v0.15.0) (2021-05-26)
### Features
* secondary cache loader ([#123](https://github.com/expo/entity/issues/123)) ([4cba01e](https://github.com/expo/entity/commit/4cba01eb259c87d60b3026ce776e46f781363690))
## [0.14.1](https://github.com/expo/entity/compare/v0.14.0...v0.14.1) (2021-03-24)
**Note:** Version bump only for package @expo/entity-example
# [0.14.0](https://github.com/expo/entity/compare/v0.13.0...v0.14.0) (2021-03-24)
**Note:** Version bump only for package @expo/entity-example
# [0.13.0](https://github.com/expo/entity/compare/v0.12.0...v0.13.0) (2021-02-12)
### Features
* add basic field type runtime validators ([#113](https://github.com/expo/entity/issues/113)) ([6c4d4b0](https://github.com/expo/entity/commit/6c4d4b03b5404dd776a12c5eb6f0af77a8c964f8))
# [0.12.0](https://github.com/expo/entity/compare/v0.11.0...v0.12.0) (2021-01-22)
### Bug Fixes
* remove cache and database adaptor flavor enums ([#109](https://github.com/expo/entity/issues/109)) ([72a77f8](https://github.com/expo/entity/commit/72a77f8b6893a4b76d3b1cce16659bdf3ce473ee))
# [0.11.0](https://github.com/expo/entity/compare/v0.10.0...v0.11.0) (2020-12-24)
### Bug Fixes
* remove any cast from EntityLoader ([#100](https://github.com/expo/entity/issues/100)) ([b8e07f9](https://github.com/expo/entity/commit/b8e07f9ddc4077768980c000d61f5ddcc824e2e3))
# [0.10.0](https://github.com/expo/entity/compare/v0.9.1...v0.10.0) (2020-11-17) # [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 **Note:** Version bump only for package @expo/entity-example
+4 -3
View File
@@ -1,12 +1,13 @@
{ {
"name": "@expo/entity-example", "name": "@expo/entity-example",
"private": true, "private": true,
"version": "0.10.0", "version": "0.22.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",
"lint-fix": "eslint src --fix",
"test": "jest --rootDir . --config ../../resources/jest.config.js", "test": "jest --rootDir . --config ../../resources/jest.config.js",
"integration": "../../resources/run-with-docker yarn integration-no-setup", "integration": "../../resources/run-with-docker yarn integration-no-setup",
"integration-no-setup": "jest --config ../../resources/jest-integration.config.js --rootDir . --runInBand --passWithNoTests", "integration-no-setup": "jest --config ../../resources/jest-integration.config.js --rootDir . --runInBand --passWithNoTests",
@@ -22,7 +23,7 @@
"author": "Expo", "author": "Expo",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@expo/entity": "^0.10.0", "@expo/entity": "^0.22.0",
"apollo-server-koa": "^2.16.1", "apollo-server-koa": "^2.16.1",
"graphql": "^15.3.0", "graphql": "^15.3.0",
"koa": "^2.13.0", "koa": "^2.13.0",
@@ -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(
+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';
@@ -57,8 +55,8 @@ export const noteEntityCompanion = new EntityCompanionDefinition({
columnName: 'body', columnName: 'body',
}), }),
}, },
databaseAdapterFlavor: DatabaseAdapterFlavor.POSTGRES, databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: CacheAdapterFlavor.REDIS, 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'),
]; ];
} }
@@ -3,6 +3,132 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
**Note:** Version bump only for package @expo/entity-full-integration-tests
# [0.21.0](https://github.com/expo/entity/compare/v0.20.0...v0.21.0) (2022-01-03)
**Note:** Version bump only for package @expo/entity-full-integration-tests
# [0.20.0](https://github.com/expo/entity/compare/v0.19.0...v0.20.0) (2021-12-29)
**Note:** Version bump only for package @expo/entity-full-integration-tests
# [0.19.0](https://github.com/expo/entity/compare/v0.18.0...v0.19.0) (2021-10-28)
**Note:** Version bump only for package @expo/entity-full-integration-tests
# [0.18.0](https://github.com/expo/entity/compare/v0.17.0...v0.18.0) (2021-10-14)
### Features
* enable noImplicitOverride tsc setting ([#135](https://github.com/expo/entity/issues/135)) ([4263cb9](https://github.com/expo/entity/commit/4263cb9b8b69fe4fb68c74ec1c7aba04508a1555))
* upgrade TypeScript to 4.4 ([#134](https://github.com/expo/entity/issues/134)) ([7612392](https://github.com/expo/entity/commit/7612392dbfd9778d25c465c1626c372d75a6d05a))
# [0.17.0](https://github.com/expo/entity/compare/v0.16.0...v0.17.0) (2021-08-16)
**Note:** Version bump only for package @expo/entity-full-integration-tests
# [0.16.0](https://github.com/expo/entity/compare/v0.15.0...v0.16.0) (2021-07-07)
**Note:** Version bump only for package @expo/entity-full-integration-tests
# [0.15.0](https://github.com/expo/entity/compare/v0.14.1...v0.15.0) (2021-05-26)
### Bug Fixes
* upgrade ioredis, knex, and pg ([#125](https://github.com/expo/entity/issues/125)) ([7c43edf](https://github.com/expo/entity/commit/7c43edf3de37e8af13e2a99bd44f03f54803d9b9))
### Features
* secondary cache loader ([#123](https://github.com/expo/entity/issues/123)) ([4cba01e](https://github.com/expo/entity/commit/4cba01eb259c87d60b3026ce776e46f781363690))
## [0.14.1](https://github.com/expo/entity/compare/v0.14.0...v0.14.1) (2021-03-24)
**Note:** Version bump only for package @expo/entity-full-integration-tests
# [0.14.0](https://github.com/expo/entity/compare/v0.13.0...v0.14.0) (2021-03-24)
**Note:** Version bump only for package @expo/entity-full-integration-tests
# [0.13.0](https://github.com/expo/entity/compare/v0.12.0...v0.13.0) (2021-02-12)
### Features
* add basic field type runtime validators ([#113](https://github.com/expo/entity/issues/113)) ([6c4d4b0](https://github.com/expo/entity/commit/6c4d4b03b5404dd776a12c5eb6f0af77a8c964f8))
# [0.12.0](https://github.com/expo/entity/compare/v0.11.0...v0.12.0) (2021-01-22)
### Bug Fixes
* move runInTransaction to ViewerContext ([#108](https://github.com/expo/entity/issues/108)) ([b7309e1](https://github.com/expo/entity/commit/b7309e18a2dd780b64cd306d566de1f12bf8df3a))
* remove cache and database adaptor flavor enums ([#109](https://github.com/expo/entity/issues/109)) ([72a77f8](https://github.com/expo/entity/commit/72a77f8b6893a4b76d3b1cce16659bdf3ce473ee))
# [0.11.0](https://github.com/expo/entity/compare/v0.10.0...v0.11.0) (2020-12-24)
### Bug Fixes
* remove any cast from EntityLoader ([#100](https://github.com/expo/entity/issues/100)) ([b8e07f9](https://github.com/expo/entity/commit/b8e07f9ddc4077768980c000d61f5ddcc824e2e3))
# [0.10.0](https://github.com/expo/entity/compare/v0.9.1...v0.10.0) (2020-11-17) # [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 **Note:** Version bump only for package @expo/entity-full-integration-tests
@@ -1,12 +1,13 @@
{ {
"name": "@expo/entity-full-integration-tests", "name": "@expo/entity-full-integration-tests",
"private": true, "private": true,
"version": "0.10.0", "version": "0.22.0",
"description": "Full redis and knex integration tests for the entity framework", "description": "Full redis and knex integration tests for the 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",
"lint-fix": "eslint src --fix",
"test": "jest --rootDir . --config ../../resources/jest.config.js --passWithNoTests", "test": "jest --rootDir . --config ../../resources/jest.config.js --passWithNoTests",
"integration": "../../resources/run-with-docker yarn integration-no-setup", "integration": "../../resources/run-with-docker yarn integration-no-setup",
"integration-no-setup": "jest --config ../../resources/jest-integration.config.js --rootDir . --runInBand --passWithNoTests", "integration-no-setup": "jest --config ../../resources/jest-integration.config.js --rootDir . --runInBand --passWithNoTests",
@@ -26,8 +27,8 @@
"@expo/entity-database-adapter-knex": "*" "@expo/entity-database-adapter-knex": "*"
}, },
"devDependencies": { "devDependencies": {
"@expo/entity": "^0.10.0", "@expo/entity": "^0.22.0",
"@expo/entity-cache-adapter-redis": "^0.10.0", "@expo/entity-cache-adapter-redis": "^0.22.0",
"@expo/entity-database-adapter-knex": "^0.10.0" "@expo/entity-database-adapter-knex": "^0.22.0"
} }
} }
@@ -5,13 +5,13 @@ import {
Entity, Entity,
EntityCompanionDefinition, EntityCompanionDefinition,
EntityConfiguration, EntityConfiguration,
DatabaseAdapterFlavor,
CacheAdapterFlavor,
UUIDField, UUIDField,
StringField,
} from '@expo/entity'; } from '@expo/entity';
import { RedisCacheAdapterContext } from '@expo/entity-cache-adapter-redis'; import { RedisCacheAdapterContext } from '@expo/entity-cache-adapter-redis';
import Redis from 'ioredis'; import Redis from 'ioredis';
import Knex from 'knex'; import { knex, Knex } from 'knex';
import nullthrows from 'nullthrows';
import { URL } from 'url'; import { URL } from 'url';
import { createFullIntegrationTestEntityCompanionProvider } from '../testfixtures/createFullIntegrationTestEntityCompanionProvider'; import { createFullIntegrationTestEntityCompanionProvider } from '../testfixtures/createFullIntegrationTestEntityCompanionProvider';
@@ -28,10 +28,18 @@ class TestEntityPrivacyPolicy extends EntityPrivacyPolicy<
ViewerContext, ViewerContext,
TestEntity TestEntity
> { > {
protected readonly readRules = [new AlwaysAllowPrivacyPolicyRule()]; protected override readonly readRules = [
protected readonly createRules = [new AlwaysAllowPrivacyPolicyRule()]; new AlwaysAllowPrivacyPolicyRule<TestFields, string, ViewerContext, TestEntity>(),
protected readonly updateRules = [new AlwaysAllowPrivacyPolicyRule()]; ];
protected readonly deleteRules = [new AlwaysAllowPrivacyPolicyRule()]; 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> { class TestEntity extends Entity<TestFields, string, ViewerContext> {
@@ -54,16 +62,16 @@ const testEntityConfiguration = new EntityConfiguration<TestFields>({
columnName: 'id', columnName: 'id',
cache: true, cache: true,
}), }),
other_string: new UUIDField({ other_string: new StringField({
columnName: 'other_string', columnName: 'other_string',
cache: true, cache: true,
}), }),
third_string: new UUIDField({ third_string: new StringField({
columnName: 'third_string', columnName: 'third_string',
}), }),
}, },
databaseAdapterFlavor: DatabaseAdapterFlavor.POSTGRES, databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: CacheAdapterFlavor.REDIS, cacheAdapterFlavor: 'redis',
}); });
const testEntityCompanion = new EntityCompanionDefinition({ const testEntityCompanion = new EntityCompanionDefinition({
@@ -97,18 +105,18 @@ describe('Entity cache inconsistency', () => {
let redisCacheAdapterContext: RedisCacheAdapterContext; let redisCacheAdapterContext: RedisCacheAdapterContext;
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']),
}, },
}); });
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) =>
@@ -151,12 +159,12 @@ describe('Entity cache inconsistency', () => {
.loadByFieldEqualingAsync('other_string', 'hello'); .loadByFieldEqualingAsync('other_string', 'hello');
let openBarrier1: () => void; let openBarrier1: () => void;
const barrier1 = new Promise((resolve) => { const barrier1 = new Promise<void>((resolve) => {
openBarrier1 = resolve; openBarrier1 = resolve;
}); });
let openBarrier2: () => void; let openBarrier2: () => void;
const barrier2 = new Promise((resolve) => { const barrier2 = new Promise<void>((resolve) => {
openBarrier2 = resolve; openBarrier2 = resolve;
}); });
@@ -175,16 +183,19 @@ describe('Entity cache inconsistency', () => {
openBarrier2!(); openBarrier2!();
})(), })(),
TestEntity.runInTransactionAsync(viewerContext, async (queryContext) => { viewerContext.runInTransactionForDatabaseAdaptorFlavorAsync(
await TestEntity.updater(entity1, queryContext) 'postgres',
.setField('third_string', 'updated') async (queryContext) => {
.enforceUpdateAsync(); await TestEntity.updater(entity1, queryContext)
.setField('third_string', 'updated')
.enforceUpdateAsync();
openBarrier1(); openBarrier1();
// wait for to ensure the transaction isn't committed until after load above occurs // wait for to ensure the transaction isn't committed until after load above occurs
await barrier2; await barrier2;
}), }
),
]); ]);
// ensure cache consistency // ensure cache consistency
@@ -1,7 +1,8 @@
import { ViewerContext } from '@expo/entity'; import { ViewerContext } from '@expo/entity';
import { RedisCacheAdapterContext } from '@expo/entity-cache-adapter-redis'; import { RedisCacheAdapterContext } from '@expo/entity-cache-adapter-redis';
import Redis from 'ioredis'; import Redis from 'ioredis';
import Knex from 'knex'; import { knex, Knex } from 'knex';
import nullthrows from 'nullthrows';
import { URL } from 'url'; import { URL } from 'url';
import { createFullIntegrationTestEntityCompanionProvider } from '../testfixtures/createFullIntegrationTestEntityCompanionProvider'; import { createFullIntegrationTestEntityCompanionProvider } from '../testfixtures/createFullIntegrationTestEntityCompanionProvider';
@@ -37,18 +38,18 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
let redisCacheAdapterContext: RedisCacheAdapterContext; let redisCacheAdapterContext: RedisCacheAdapterContext;
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']),
}, },
}); });
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) =>
@@ -5,14 +5,13 @@ import {
Entity, Entity,
EntityCompanionDefinition, EntityCompanionDefinition,
EntityConfiguration, EntityConfiguration,
DatabaseAdapterFlavor,
CacheAdapterFlavor,
UUIDField, UUIDField,
EntityEdgeDeletionBehavior, EntityEdgeDeletionBehavior,
} from '@expo/entity'; } from '@expo/entity';
import { RedisCacheAdapterContext } from '@expo/entity-cache-adapter-redis'; import { RedisCacheAdapterContext } from '@expo/entity-cache-adapter-redis';
import Redis from 'ioredis'; import Redis from 'ioredis';
import Knex from 'knex'; import { knex, Knex } from 'knex';
import nullthrows from 'nullthrows';
import { URL } from 'url'; import { URL } from 'url';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
@@ -29,10 +28,18 @@ interface OtherFields {
} }
class PrivacyPolicy extends EntityPrivacyPolicy<any, string, ViewerContext, any> { class PrivacyPolicy extends EntityPrivacyPolicy<any, string, ViewerContext, any> {
protected readonly readRules = [new AlwaysAllowPrivacyPolicyRule()]; protected override readonly readRules = [
protected readonly createRules = [new AlwaysAllowPrivacyPolicyRule()]; new AlwaysAllowPrivacyPolicyRule<any, string, ViewerContext, any>(),
protected readonly updateRules = [new AlwaysAllowPrivacyPolicyRule()]; ];
protected readonly deleteRules = [new AlwaysAllowPrivacyPolicyRule()]; 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 // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
@@ -82,8 +89,8 @@ const makeEntityClasses = async (knex: Knex, edgeDeletionBehavior: EntityEdgeDel
}, },
}), }),
}, },
databaseAdapterFlavor: DatabaseAdapterFlavor.POSTGRES, databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: CacheAdapterFlavor.REDIS, cacheAdapterFlavor: 'redis',
}); });
const categoryEntityCompanion = new EntityCompanionDefinition({ const categoryEntityCompanion = new EntityCompanionDefinition({
@@ -110,8 +117,8 @@ const makeEntityClasses = async (knex: Knex, edgeDeletionBehavior: EntityEdgeDel
}, },
}), }),
}, },
databaseAdapterFlavor: DatabaseAdapterFlavor.POSTGRES, databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: CacheAdapterFlavor.REDIS, cacheAdapterFlavor: 'redis',
}); });
const otherEntityCompanion = new EntityCompanionDefinition({ const otherEntityCompanion = new EntityCompanionDefinition({
@@ -160,18 +167,18 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
let redisCacheAdapterContext: RedisCacheAdapterContext; let redisCacheAdapterContext: RedisCacheAdapterContext;
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']),
}, },
}); });
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) =>
@@ -1,7 +1,5 @@
import { import {
AlwaysAllowPrivacyPolicyRule, AlwaysAllowPrivacyPolicyRule,
CacheAdapterFlavor,
DatabaseAdapterFlavor,
Entity, Entity,
EntityCompanionDefinition, EntityCompanionDefinition,
EntityConfiguration, EntityConfiguration,
@@ -19,10 +17,18 @@ interface ChildFields {
} }
class TestEntityPrivacyPolicy extends EntityPrivacyPolicy<any, string, ViewerContext, any, any> { class TestEntityPrivacyPolicy extends EntityPrivacyPolicy<any, string, ViewerContext, any, any> {
protected readonly readRules = [new AlwaysAllowPrivacyPolicyRule()]; protected override readonly readRules = [
protected readonly createRules = [new AlwaysAllowPrivacyPolicyRule()]; new AlwaysAllowPrivacyPolicyRule<any, string, ViewerContext, any, any>(),
protected readonly updateRules = [new AlwaysAllowPrivacyPolicyRule()]; ];
protected readonly deleteRules = [new AlwaysAllowPrivacyPolicyRule()]; 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> { export default class ChildEntity extends Entity<ChildFields, string, ViewerContext> {
@@ -54,8 +60,8 @@ const childEntityConfiguration = new EntityConfiguration<ChildFields>({
}, },
}), }),
}, },
databaseAdapterFlavor: DatabaseAdapterFlavor.POSTGRES, databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: CacheAdapterFlavor.REDIS, cacheAdapterFlavor: 'redis',
}); });
const childEntityCompanion = new EntityCompanionDefinition({ const childEntityCompanion = new EntityCompanionDefinition({
@@ -1,7 +1,5 @@
import { import {
AlwaysAllowPrivacyPolicyRule, AlwaysAllowPrivacyPolicyRule,
CacheAdapterFlavor,
DatabaseAdapterFlavor,
Entity, Entity,
EntityCompanionDefinition, EntityCompanionDefinition,
EntityConfiguration, EntityConfiguration,
@@ -17,10 +15,18 @@ interface ParentFields {
} }
class TestEntityPrivacyPolicy extends EntityPrivacyPolicy<any, string, ViewerContext, any, any> { class TestEntityPrivacyPolicy extends EntityPrivacyPolicy<any, string, ViewerContext, any, any> {
protected readonly readRules = [new AlwaysAllowPrivacyPolicyRule()]; protected override readonly readRules = [
protected readonly createRules = [new AlwaysAllowPrivacyPolicyRule()]; new AlwaysAllowPrivacyPolicyRule<any, string, ViewerContext, any, any>(),
protected readonly updateRules = [new AlwaysAllowPrivacyPolicyRule()]; ];
protected readonly deleteRules = [new AlwaysAllowPrivacyPolicyRule()]; 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> { export default class ParentEntity extends Entity<ParentFields, string, ViewerContext> {
@@ -45,8 +51,8 @@ const parentEntityConfiguration = new EntityConfiguration<ParentFields>({
cache: true, cache: true,
}), }),
}, },
databaseAdapterFlavor: DatabaseAdapterFlavor.POSTGRES, databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: CacheAdapterFlavor.REDIS, cacheAdapterFlavor: 'redis',
}); });
const parentEntityCompanion = new EntityCompanionDefinition({ const parentEntityCompanion = new EntityCompanionDefinition({
@@ -2,8 +2,6 @@ import {
NoOpEntityMetricsAdapter, NoOpEntityMetricsAdapter,
IEntityMetricsAdapter, IEntityMetricsAdapter,
EntityCompanionProvider, EntityCompanionProvider,
CacheAdapterFlavor,
DatabaseAdapterFlavor,
} from '@expo/entity'; } from '@expo/entity';
import { import {
RedisCacheAdapterContext, RedisCacheAdapterContext,
@@ -13,7 +11,7 @@ import {
PostgresEntityDatabaseAdapterProvider, PostgresEntityDatabaseAdapterProvider,
PostgresEntityQueryContextProvider, PostgresEntityQueryContextProvider,
} from '@expo/entity-database-adapter-knex'; } from '@expo/entity-database-adapter-knex';
import Knex from 'knex'; import { Knex } from 'knex';
export const createFullIntegrationTestEntityCompanionProvider = ( export const createFullIntegrationTestEntityCompanionProvider = (
knex: Knex, knex: Knex,
@@ -22,16 +20,22 @@ export const createFullIntegrationTestEntityCompanionProvider = (
): 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 RedisCacheAdapterProvider(redisCacheAdapterContext), ]),
}, new Map([
} [
'redis',
{
cacheAdapterProvider: new RedisCacheAdapterProvider(redisCacheAdapterContext),
},
],
])
); );
}; };
@@ -0,0 +1,50 @@
# Change Log
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
**Note:** Version bump only for package @expo/entity-ip-address-field
# [0.21.0](https://github.com/expo/entity/compare/v0.20.0...v0.21.0) (2022-01-03)
**Note:** Version bump only for package @expo/entity-ip-address-field
# [0.20.0](https://github.com/expo/entity/compare/v0.19.0...v0.20.0) (2021-12-29)
**Note:** Version bump only for package @expo/entity-ip-address-field
# [0.19.0](https://github.com/expo/entity/compare/v0.18.0...v0.19.0) (2021-10-28)
### Features
* add IP support ([#138](https://github.com/expo/entity/issues/138)) ([1405a91](https://github.com/expo/entity/commit/1405a9126ee3a56c3e2a38bf3b697283744f5b5d))
# [0.18.0](https://github.com/expo/entity/compare/v0.17.0...v0.18.0) (2021-10-14)
### Features
* lands @expo/entity-ip-address-field ([#138](https://github.com/expo/entity/pull/138))
* enable noImplicitOverride tsc setting ([#135](https://github.com/expo/entity/issues/135)) ([4263cb9](https://github.com/expo/entity/commit/4263cb9b8b69fe4fb68c74ec1c7aba04508a1555))
* expose describeFieldTestCase for use in other packages ([#139](https://github.com/expo/entity/issues/139)) ([1220477](https://github.com/expo/entity/commit/1220477aa331349b482ad015b44f76fa2bda7b90))
* upgrade TypeScript to 4.4 ([#134](https://github.com/expo/entity/issues/134)) ([7612392](https://github.com/expo/entity/commit/7612392dbfd9778d25c465c1626c372d75a6d05a))
* use better QueryContext types for transactional triggers and validators ([#136](https://github.com/expo/entity/issues/136)) ([5c7efe1](https://github.com/expo/entity/commit/5c7efe1678424ff907687e9327175bdc2538c3e0))
@@ -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.22.0",
"description": "IP address EntityField definitions for @expo/entity",
"files": [
"build",
"src"
],
"main": "build/index.js",
"types": "build/index.d.ts",
"scripts": {
"tsc": "tsc",
"clean": "rm -rf build coverage coverage-integration",
"lint": "eslint src",
"lint-fix": "eslint src --fix",
"test": "jest --rootDir . --config ../../resources/jest.config.js",
"barrelsby": "barrelsby --directory src --location top --exclude tests__ --singleQuotes --delete"
},
"engines": {
"node": ">=12"
},
"keywords": [
"entity"
],
"author": "Expo",
"license": "MIT",
"dependencies": {
"ip-address": "^8.1.0"
},
"devDependencies": {
"@expo/entity": "^0.22.0",
"@types/jsbn": "^1.2.29"
},
"peerDependencies": {
"@expo/entity": "*"
}
}
@@ -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,71 @@
# Change Log
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
**Note:** Version bump only for package @expo/entity-secondary-cache-redis
# [0.21.0](https://github.com/expo/entity/compare/v0.20.0...v0.21.0) (2022-01-03)
**Note:** Version bump only for package @expo/entity-secondary-cache-redis
# [0.20.0](https://github.com/expo/entity/compare/v0.19.0...v0.20.0) (2021-12-29)
**Note:** Version bump only for package @expo/entity-secondary-cache-redis
# [0.19.0](https://github.com/expo/entity/compare/v0.18.0...v0.19.0) (2021-10-28)
**Note:** Version bump only for package @expo/entity-secondary-cache-redis
# [0.18.0](https://github.com/expo/entity/compare/v0.17.0...v0.18.0) (2021-10-14)
### Features
* enable noImplicitOverride tsc setting ([#135](https://github.com/expo/entity/issues/135)) ([4263cb9](https://github.com/expo/entity/commit/4263cb9b8b69fe4fb68c74ec1c7aba04508a1555))
* upgrade TypeScript to 4.4 ([#134](https://github.com/expo/entity/issues/134)) ([7612392](https://github.com/expo/entity/commit/7612392dbfd9778d25c465c1626c372d75a6d05a))
# [0.17.0](https://github.com/expo/entity/compare/v0.16.0...v0.17.0) (2021-08-16)
**Note:** Version bump only for package @expo/entity-secondary-cache-redis
# [0.16.0](https://github.com/expo/entity/compare/v0.15.0...v0.16.0) (2021-07-07)
**Note:** Version bump only for package @expo/entity-secondary-cache-redis
# [0.15.0](https://github.com/expo/entity/compare/v0.14.1...v0.15.0) (2021-05-26)
### Features
* secondary cache loader ([#123](https://github.com/expo/entity/issues/123)) ([4cba01e](https://github.com/expo/entity/commit/4cba01eb259c87d60b3026ce776e46f781363690))
@@ -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.22.0",
"description": "Redis secondary cache for @expo/entity",
"files": [
"build",
"src"
],
"main": "build/index.js",
"types": "build/index.d.ts",
"scripts": {
"tsc": "tsc",
"clean": "rm -rf build coverage coverage-integration",
"lint": "eslint src",
"lint-fix": "eslint src --fix",
"test": "jest --rootDir . --config ../../resources/jest.config.js --passWithNoTests",
"integration": "../../resources/run-with-docker yarn integration-no-setup",
"integration-no-setup": "jest --config ../../resources/jest-integration.config.js --rootDir . --runInBand --passWithNoTests",
"barrelsby": "barrelsby --directory src --location top --exclude tests__ --singleQuotes --exportDefault --delete"
},
"engines": {
"node": ">=12"
},
"keywords": [
"entity"
],
"author": "Expo",
"license": "MIT",
"peerDependencies": {
"@expo/entity": "*",
"@expo/entity-cache-adapter-redis": "*"
},
"dependencies": {
"ioredis": "^4.17.3"
},
"devDependencies": {
"@expo/entity": "^0.22.0",
"@expo/entity-cache-adapter-redis": "^0.22.0",
"nullthrows": "^1.1.1"
}
}
@@ -0,0 +1,136 @@
import {
CacheStatus,
EntityConfiguration,
filterMap,
ISecondaryEntityCache,
transformCacheObjectToFields,
transformFieldsToCacheObject,
zipToMap,
} from '@expo/entity';
import {
GenericRedisCacheContext,
GenericRedisCacher,
redisTransformerMap,
} from '@expo/entity-cache-adapter-redis';
import invariant from 'invariant';
/**
* A custom secondary read-through entity cache is a way to add a custom second layer of caching for a particular
* single entity load. One common way this may be used is to add a second layer of caching in a hot path that makes
* a call to {@link EntityLoader.loadManyByFieldEqualityConjunctionAsync} is guaranteed to return at most one entity.
*/
export default class RedisSecondaryEntityCache<TFields, TLoadParams>
implements ISecondaryEntityCache<TFields, TLoadParams>
{
private readonly genericRedisCacher: GenericRedisCacher;
constructor(
private readonly entityConfiguration: EntityConfiguration<TFields>,
genericRedisCacheContext: GenericRedisCacheContext,
private readonly constructRedisKey: (params: Readonly<TLoadParams>) => string
) {
this.genericRedisCacher = new GenericRedisCacher(genericRedisCacheContext);
}
/**
* Read-through cache function. Steps:
*
* 1. Check for cached objects
* 2. Query the fetcher for loadParams not in the cache
* 3. Cache the results from the fetcher
* 4. Negatively cache anything missing from the fetcher
* 5. Return the full set of data for the query.
*
* @param loadParamsArray - array of loadParams to load from the cache
* @param fetcher - closure used to provide underlying data source objects for loadParams
* @returns map from loadParams to the entity field object
*/
public async loadManyThroughAsync(
loadParamsArray: readonly Readonly<TLoadParams>[],
fetcher: (
fetcherLoadParamsArray: readonly Readonly<TLoadParams>[]
) => Promise<ReadonlyMap<Readonly<TLoadParams>, Readonly<TFields> | null>>
): Promise<ReadonlyMap<Readonly<TLoadParams>, Readonly<TFields> | null>> {
const redisKeys = loadParamsArray.map(this.constructRedisKey);
const redisKeyToLoadParamsMap = zipToMap(redisKeys, loadParamsArray);
const cacheLoadResults = await this.genericRedisCacher.loadManyAsync(redisKeys);
invariant(
cacheLoadResults.size === loadParamsArray.length,
`${this.constructor.name} loadMany should return a result for each key`
);
const redisKeysToFetch = Array.from(
filterMap(
cacheLoadResults,
(cacheLoadResult) => cacheLoadResult.status === CacheStatus.MISS
).keys()
);
// put transformed cache hits in result map
const results: Map<Readonly<TLoadParams>, Readonly<TFields> | null> = new Map();
cacheLoadResults.forEach((cacheLoadResult, redisKey) => {
if (cacheLoadResult.status === CacheStatus.HIT) {
const loadParams = redisKeyToLoadParamsMap.get(redisKey);
invariant(loadParams !== undefined, 'load params should be in redis key map');
results.set(
loadParams,
transformCacheObjectToFields(
this.entityConfiguration,
redisTransformerMap,
cacheLoadResult.item
)
);
}
});
// fetch any misses from DB, add DB objects to results, cache DB results, inform cache of any missing DB results
if (redisKeysToFetch.length > 0) {
const loadParamsToFetch = redisKeysToFetch.map((redisKey) => {
const loadParams = redisKeyToLoadParamsMap.get(redisKey);
invariant(loadParams !== undefined, 'load params should be in redis key map');
return loadParams;
});
const fetchResults = await fetcher(loadParamsToFetch);
const fetchMisses = loadParamsToFetch.filter((loadParams) => {
// all values of fetchResults should be field objects or undefined
return !fetchResults.get(loadParams);
});
for (const fetchMiss of fetchMisses) {
results.set(fetchMiss, null);
}
const objectsToCache: Map<string, object> = new Map();
for (const [loadParams, object] of fetchResults.entries()) {
if (object) {
objectsToCache.set(
this.constructRedisKey(loadParams),
transformFieldsToCacheObject(this.entityConfiguration, redisTransformerMap, object)
);
results.set(loadParams, object);
}
}
await Promise.all([
this.genericRedisCacher.cacheManyAsync(objectsToCache),
this.genericRedisCacher.cacheDBMissesAsync(fetchMisses.map(this.constructRedisKey)),
]);
}
return results;
}
/**
* Invalidate the cache for objects cached by constructRedisKey(loadParams).
*
* @param loadParamsArray - load params to invalidate
*/
public async invalidateManyAsync(
loadParamsArray: readonly Readonly<TLoadParams>[]
): Promise<void> {
await this.genericRedisCacher.invalidateManyAsync(loadParamsArray.map(this.constructRedisKey));
}
}
@@ -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"]
}
+172
View File
@@ -3,6 +3,178 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
**Note:** Version bump only for package @expo/entity
# [0.21.0](https://github.com/expo/entity/compare/v0.20.0...v0.21.0) (2022-01-03)
### Features
* add pre-commit callbacks on EntityQueryContext ([#147](https://github.com/expo/entity/issues/147)) ([f1d9847](https://github.com/expo/entity/commit/f1d9847210f5775de964509e44ce493e44482b21))
# [0.20.0](https://github.com/expo/entity/compare/v0.19.0...v0.20.0) (2021-12-29)
### Bug Fixes
* separate post-commit invalidation callbacks from trigger and user callbacks ([#144](https://github.com/expo/entity/issues/144)) ([efa131e](https://github.com/expo/entity/commit/efa131ef29532accf0e51b6d867ffe15a27b4c90))
### Features
* add cascading deletion info ([#145](https://github.com/expo/entity/issues/145)) ([3727191](https://github.com/expo/entity/commit/372719176742d3ead6b7cbca29cb66b09b3fe09c))
# [0.19.0](https://github.com/expo/entity/compare/v0.18.0...v0.19.0) (2021-10-28)
### Features
* add authorization metrics ([#142](https://github.com/expo/entity/issues/142)) ([7cf54d8](https://github.com/expo/entity/commit/7cf54d83c52e104159a3e2e675cdda2d29afe204))
# [0.18.0](https://github.com/expo/entity/compare/v0.17.0...v0.18.0) (2021-10-14)
### Features
* enable noImplicitOverride tsc setting ([#135](https://github.com/expo/entity/issues/135)) ([4263cb9](https://github.com/expo/entity/commit/4263cb9b8b69fe4fb68c74ec1c7aba04508a1555))
* expose describeFieldTestCase for use in other packages ([#139](https://github.com/expo/entity/issues/139)) ([1220477](https://github.com/expo/entity/commit/1220477aa331349b482ad015b44f76fa2bda7b90))
* upgrade TypeScript to 4.4 ([#134](https://github.com/expo/entity/issues/134)) ([7612392](https://github.com/expo/entity/commit/7612392dbfd9778d25c465c1626c372d75a6d05a))
* use better QueryContext types for transactional triggers and validators ([#136](https://github.com/expo/entity/issues/136)) ([5c7efe1](https://github.com/expo/entity/commit/5c7efe1678424ff907687e9327175bdc2538c3e0))
# [0.17.0](https://github.com/expo/entity/compare/v0.16.0...v0.17.0) (2021-08-16)
### Features
* Add IntField and FloatField, deprecate NumberField ([#131](https://github.com/expo/entity/issues/131)) ([2f2d963](https://github.com/expo/entity/commit/2f2d963cf92c6575d78f8eeca344a4aadcc9b8d9))
# [0.16.0](https://github.com/expo/entity/compare/v0.15.0...v0.16.0) (2021-07-07)
### Features
* allow null field values in loadManyByFieldEqualityConjunctionAsync ([#130](https://github.com/expo/entity/issues/130)) ([2f37dc6](https://github.com/expo/entity/commit/2f37dc6a37d368b12b3c8375e04f0777fd448797))
# [0.15.0](https://github.com/expo/entity/compare/v0.14.1...v0.15.0) (2021-05-26)
### Features
* secondary cache loader ([#123](https://github.com/expo/entity/issues/123)) ([4cba01e](https://github.com/expo/entity/commit/4cba01eb259c87d60b3026ce776e46f781363690))
## [0.14.1](https://github.com/expo/entity/compare/v0.14.0...v0.14.1) (2021-03-24)
### Bug Fixes
* typescript typedef of EntityMutationInfo ([#121](https://github.com/expo/entity/issues/121)) ([8e8f0dc](https://github.com/expo/entity/commit/8e8f0dc8636e343e29a6012d155e4111477a8989))
# [0.14.0](https://github.com/expo/entity/compare/v0.13.0...v0.14.0) (2021-03-24)
### Bug Fixes
* mutation validator and trigger cyclic import structure ([#118](https://github.com/expo/entity/issues/118)) ([b11dbc0](https://github.com/expo/entity/commit/b11dbc087ba5ad13c748032698dd73279b7960b0))
### Features
* add mutation type to validator and trigger execute functions ([#119](https://github.com/expo/entity/issues/119)) ([ef40fa5](https://github.com/expo/entity/commit/ef40fa5e9cdf53e25e78a9423c698e6d1c9737af))
# [0.13.0](https://github.com/expo/entity/compare/v0.12.0...v0.13.0) (2021-02-12)
### Bug Fixes
* allow undefined value for all fields ([#114](https://github.com/expo/entity/issues/114)) ([022e0e9](https://github.com/expo/entity/commit/022e0e9b88a5e84e0c1bc6c8810c850beb05f0cf))
### Features
* add basic field type runtime validators ([#113](https://github.com/expo/entity/issues/113)) ([6c4d4b0](https://github.com/expo/entity/commit/6c4d4b03b5404dd776a12c5eb6f0af77a8c964f8))
# [0.12.0](https://github.com/expo/entity/compare/v0.11.0...v0.12.0) (2021-01-22)
### Bug Fixes
* move runInTransaction to ViewerContext ([#108](https://github.com/expo/entity/issues/108)) ([b7309e1](https://github.com/expo/entity/commit/b7309e18a2dd780b64cd306d566de1f12bf8df3a))
* remove cache and database adaptor flavor enums ([#109](https://github.com/expo/entity/issues/109)) ([72a77f8](https://github.com/expo/entity/commit/72a77f8b6893a4b76d3b1cce16659bdf3ce473ee))
### Features
* better typing on filterMap for use with predicates ([#106](https://github.com/expo/entity/issues/106)) ([01b3bfe](https://github.com/expo/entity/commit/01b3bfef7c6da55e7d89d8cccb52a0050187aee8))
# [0.11.0](https://github.com/expo/entity/compare/v0.10.0...v0.11.0) (2020-12-24)
### Bug Fixes
* add type generic default in test utils ([#104](https://github.com/expo/entity/issues/104)) ([fe2917e](https://github.com/expo/entity/commit/fe2917e6b5af808f166aa354d476e168c41e6d21))
* maps utils invariants for collections containing undefined values ([#103](https://github.com/expo/entity/issues/103)) ([29c1a43](https://github.com/expo/entity/commit/29c1a434ab6cd8d30e9f0e7c742065c8d46d8d8a))
* remove any cast from EntityLoader ([#100](https://github.com/expo/entity/issues/100)) ([b8e07f9](https://github.com/expo/entity/commit/b8e07f9ddc4077768980c000d61f5ddcc824e2e3))
### Features
* better cache adapter error handling ([#102](https://github.com/expo/entity/issues/102)) ([15546aa](https://github.com/expo/entity/commit/15546aad97734ab9ac67f1c012485f096a0e94f1))
* better database adapter error handling ([#101](https://github.com/expo/entity/issues/101)) ([5208aee](https://github.com/expo/entity/commit/5208aeedb0192f0460ba3d575770a04175f5c3aa))
# [0.10.0](https://github.com/expo/entity/compare/v0.9.1...v0.10.0) (2020-11-17) # [0.10.0](https://github.com/expo/entity/compare/v0.9.1...v0.10.0) (2020-11-17)
+3 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@expo/entity", "name": "@expo/entity",
"version": "0.10.0", "version": "0.22.0",
"description": "A privacy-first data model", "description": "A privacy-first data model",
"files": [ "files": [
"build", "build",
@@ -11,7 +11,8 @@
"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",
"lint-fix": "eslint src --fix",
"test": "jest --rootDir . --config ../../resources/jest.config.js", "test": "jest --rootDir . --config ../../resources/jest.config.js",
"integration": "../../resources/run-with-docker yarn integration-no-setup", "integration": "../../resources/run-with-docker yarn integration-no-setup",
"integration-no-setup": "jest --config ../../resources/jest-integration.config.js --rootDir . --runInBand --passWithNoTests", "integration-no-setup": "jest --config ../../resources/jest-integration.config.js --rootDir . --runInBand --passWithNoTests",
+1 -1
View File
@@ -11,7 +11,7 @@ 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, TSelectedFields>, TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
TPrivacyPolicy extends EntityPrivacyPolicy< TPrivacyPolicy extends EntityPrivacyPolicy<
+18 -10
View File
@@ -27,7 +27,7 @@ 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,
TSelectedFields extends keyof TFields = keyof TFields TSelectedFields extends keyof TFields = keyof TFields
> extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields> { > extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields> {
@@ -39,7 +39,7 @@ 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, TMSelectedFields>, TMEntity extends Entity<TMFields, TMID, TMViewerContext, TMSelectedFields>,
@@ -80,7 +80,7 @@ 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, TMSelectedFields>, TMEntity extends Entity<TMFields, TMID, TMViewerContext, TMSelectedFields>,
TMPrivacyPolicy extends EntityPrivacyPolicy< TMPrivacyPolicy extends EntityPrivacyPolicy<
@@ -121,7 +121,7 @@ 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, TMSelectedFields>, TMEntity extends Entity<TMFields, TMID, TMViewerContext, TMSelectedFields>,
TMPrivacyPolicy extends EntityPrivacyPolicy< TMPrivacyPolicy extends EntityPrivacyPolicy<
@@ -163,7 +163,7 @@ 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, TMSelectedFields>, TMEntity extends Entity<TMFields, TMID, TMViewerContext, TMSelectedFields>,
TMPrivacyPolicy extends EntityPrivacyPolicy< TMPrivacyPolicy extends EntityPrivacyPolicy<
@@ -216,7 +216,7 @@ 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, TMSelectedFields>, TMEntity extends Entity<TMFields, TMID, TMViewerContext, TMSelectedFields>,
TMPrivacyPolicy extends EntityPrivacyPolicy< TMPrivacyPolicy extends EntityPrivacyPolicy<
@@ -243,12 +243,16 @@ export default abstract class Entity<
.getQueryContextProvider() .getQueryContextProvider()
.getQueryContext() .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;
@@ -265,7 +269,7 @@ 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, TMSelectedFields>, TMEntity extends Entity<TMFields, TMID, TMViewerContext, TMSelectedFields>,
TMPrivacyPolicy extends EntityPrivacyPolicy< TMPrivacyPolicy extends EntityPrivacyPolicy<
@@ -292,12 +296,16 @@ export default abstract class Entity<
.getQueryContextProvider() .getQueryContextProvider()
.getQueryContext() .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;
@@ -309,7 +317,7 @@ 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, TSelectedFields>, TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
TPrivacyPolicy extends EntityPrivacyPolicy< TPrivacyPolicy extends EntityPrivacyPolicy<
+13 -13
View File
@@ -8,7 +8,7 @@ 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, TSelectedFields>, TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
TSelectedFields extends keyof TFields TSelectedFields extends keyof TFields
@@ -25,7 +25,7 @@ export default class EntityAssociationLoader<
async loadAssociatedEntityAsync< async loadAssociatedEntityAsync<
TIdentifyingField extends keyof Pick<TFields, TSelectedFields>, TIdentifyingField extends keyof Pick<TFields, TSelectedFields>,
TAssociatedFields, TAssociatedFields,
TAssociatedID, TAssociatedID extends NonNullable<TAssociatedFields[TAssociatedSelectedFields]>,
TAssociatedEntity extends ReadonlyEntity< TAssociatedEntity extends ReadonlyEntity<
TAssociatedFields, TAssociatedFields,
TAssociatedID, TAssociatedID,
@@ -71,7 +71,7 @@ export default class EntityAssociationLoader<
.getLoaderFactory() .getLoaderFactory()
.forLoad(queryContext); .forLoad(queryContext);
return (await loader.loadByIDAsync((associatedEntityID as unknown) as TAssociatedID)) as Result< return (await loader.loadByIDAsync(associatedEntityID as unknown as TAssociatedID)) as Result<
null extends TFields[TIdentifyingField] ? TAssociatedEntity | null : TAssociatedEntity null extends TFields[TIdentifyingField] ? TAssociatedEntity | null : TAssociatedEntity
>; >;
} }
@@ -87,7 +87,7 @@ export default class EntityAssociationLoader<
*/ */
async loadManyAssociatedEntitiesAsync< async loadManyAssociatedEntitiesAsync<
TAssociatedFields, TAssociatedFields,
TAssociatedID, TAssociatedID extends NonNullable<TAssociatedFields[TAssociatedSelectedFields]>,
TAssociatedEntity extends ReadonlyEntity< TAssociatedEntity extends ReadonlyEntity<
TAssociatedFields, TAssociatedFields,
TAssociatedID, TAssociatedID,
@@ -140,7 +140,7 @@ export default class EntityAssociationLoader<
*/ */
async loadAssociatedEntityByFieldEqualingAsync< async loadAssociatedEntityByFieldEqualingAsync<
TAssociatedFields, TAssociatedFields,
TAssociatedID, TAssociatedID extends NonNullable<TAssociatedFields[TAssociatedSelectedFields]>,
TAssociatedEntity extends ReadonlyEntity< TAssociatedEntity extends ReadonlyEntity<
TAssociatedFields, TAssociatedFields,
TAssociatedID, TAssociatedID,
@@ -197,7 +197,7 @@ export default class EntityAssociationLoader<
*/ */
async loadManyAssociatedEntitiesByFieldEqualingAsync< async loadManyAssociatedEntitiesByFieldEqualingAsync<
TAssociatedFields, TAssociatedFields,
TAssociatedID, TAssociatedID extends NonNullable<TAssociatedFields[TAssociatedSelectedFields]>,
TAssociatedEntity extends ReadonlyEntity< TAssociatedEntity extends ReadonlyEntity<
TAssociatedFields, TAssociatedFields,
TAssociatedID, TAssociatedID,
@@ -253,7 +253,7 @@ export default class EntityAssociationLoader<
*/ */
async loadAssociatedEntityThroughAsync< async loadAssociatedEntityThroughAsync<
TFields2, TFields2,
TID2, TID2 extends NonNullable<TFields2[TSelectedFields2]>,
TEntity2 extends ReadonlyEntity<TFields2, TID2, TViewerContext, TSelectedFields2>, TEntity2 extends ReadonlyEntity<TFields2, TID2, TViewerContext, TSelectedFields2>,
TPrivacyPolicy2 extends EntityPrivacyPolicy< TPrivacyPolicy2 extends EntityPrivacyPolicy<
TFields2, TFields2,
@@ -287,7 +287,7 @@ export default class EntityAssociationLoader<
*/ */
async loadAssociatedEntityThroughAsync< async loadAssociatedEntityThroughAsync<
TFields2, TFields2,
TID2, TID2 extends NonNullable<TFields2[TSelectedFields2]>,
TEntity2 extends ReadonlyEntity<TFields2, TID2, TViewerContext, TSelectedFields2>, TEntity2 extends ReadonlyEntity<TFields2, TID2, TViewerContext, TSelectedFields2>,
TPrivacyPolicy2 extends EntityPrivacyPolicy< TPrivacyPolicy2 extends EntityPrivacyPolicy<
TFields2, TFields2,
@@ -297,7 +297,7 @@ export default class EntityAssociationLoader<
TSelectedFields2 TSelectedFields2
>, >,
TFields3, TFields3,
TID3, TID3 extends NonNullable<TFields3[TSelectedFields3]>,
TEntity3 extends ReadonlyEntity<TFields3, TID3, TViewerContext, TSelectedFields3>, TEntity3 extends ReadonlyEntity<TFields3, TID3, TViewerContext, TSelectedFields3>,
TPrivacyPolicy3 extends EntityPrivacyPolicy< TPrivacyPolicy3 extends EntityPrivacyPolicy<
TFields3, TFields3,
@@ -342,7 +342,7 @@ export default class EntityAssociationLoader<
*/ */
async loadAssociatedEntityThroughAsync< async loadAssociatedEntityThroughAsync<
TFields2, TFields2,
TID2, TID2 extends NonNullable<TFields2[TSelectedFields2]>,
TEntity2 extends ReadonlyEntity<TFields2, TID2, TViewerContext, TSelectedFields2>, TEntity2 extends ReadonlyEntity<TFields2, TID2, TViewerContext, TSelectedFields2>,
TPrivacyPolicy2 extends EntityPrivacyPolicy< TPrivacyPolicy2 extends EntityPrivacyPolicy<
TFields2, TFields2,
@@ -352,7 +352,7 @@ export default class EntityAssociationLoader<
TSelectedFields2 TSelectedFields2
>, >,
TFields3, TFields3,
TID3, TID3 extends NonNullable<TFields3[TSelectedFields3]>,
TEntity3 extends ReadonlyEntity<TFields3, TID3, TViewerContext, TSelectedFields3>, TEntity3 extends ReadonlyEntity<TFields3, TID3, TViewerContext, TSelectedFields3>,
TPrivacyPolicy3 extends EntityPrivacyPolicy< TPrivacyPolicy3 extends EntityPrivacyPolicy<
TFields3, TFields3,
@@ -362,7 +362,7 @@ export default class EntityAssociationLoader<
TSelectedFields3 TSelectedFields3
>, >,
TFields4, TFields4,
TID4, TID4 extends NonNullable<TFields4[TSelectedFields4]>,
TEntity4 extends ReadonlyEntity<TFields4, TID4, TViewerContext, TSelectedFields4>, TEntity4 extends ReadonlyEntity<TFields4, TID4, TViewerContext, TSelectedFields4>,
TPrivacyPolicy4 extends EntityPrivacyPolicy< TPrivacyPolicy4 extends EntityPrivacyPolicy<
TFields4, TFields4,
@@ -478,7 +478,7 @@ export interface EntityLoadThroughDirective<
TViewerContext extends ViewerContext, TViewerContext extends ViewerContext,
TFields, TFields,
TAssociatedFields, TAssociatedFields,
TAssociatedID, TAssociatedID extends NonNullable<TAssociatedFields[TAssociatedSelectedFields]>,
TAssociatedEntity extends ReadonlyEntity< TAssociatedEntity extends ReadonlyEntity<
TAssociatedFields, TAssociatedFields,
TAssociatedID, TAssociatedID,
@@ -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
+12 -4
View File
@@ -19,7 +19,7 @@ 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, TSelectedFields>, TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
TPrivacyPolicy extends EntityPrivacyPolicy< TPrivacyPolicy extends EntityPrivacyPolicy<
@@ -73,7 +73,7 @@ export default class EntityCompanion<
TEntity, TEntity,
TSelectedFields TSelectedFields
>, >,
metricsAdapter: IEntityMetricsAdapter private readonly metricsAdapter: IEntityMetricsAdapter
) { ) {
const privacyPolicy = new PrivacyPolicyClass(); const privacyPolicy = new PrivacyPolicyClass();
this.entityLoaderFactory = new EntityLoaderFactory< this.entityLoaderFactory = new EntityLoaderFactory<
@@ -84,10 +84,11 @@ export default class EntityCompanion<
TPrivacyPolicy, TPrivacyPolicy,
TSelectedFields TSelectedFields
>( >(
tableDataCoordinator.entityConfiguration.idField as keyof Pick<TFields, TSelectedFields>, tableDataCoordinator.entityConfiguration,
entityClass, entityClass,
privacyPolicy, privacyPolicy,
tableDataCoordinator.dataManager tableDataCoordinator.dataManager,
metricsAdapter
); );
this.entityMutatorFactory = new EntityMutatorFactory( this.entityMutatorFactory = new EntityMutatorFactory(
tableDataCoordinator.entityConfiguration, tableDataCoordinator.entityConfiguration,
@@ -129,4 +130,11 @@ export default class EntityCompanion<
getQueryContextProvider(): EntityQueryContextProvider { getQueryContextProvider(): EntityQueryContextProvider {
return this.tableDataCoordinator.getQueryContextProvider(); return this.tableDataCoordinator.getQueryContextProvider();
} }
/**
* Get the {@link IEntityMetricsAdapter} for this companion.
*/
getMetricsAdapter(): IEntityMetricsAdapter {
return this.metricsAdapter;
}
} }
+70 -38
View File
@@ -1,3 +1,5 @@
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';
@@ -16,25 +18,14 @@ 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
@@ -60,7 +51,7 @@ export interface CacheAdapterFlavorDefinition {
*/ */
export class EntityCompanionDefinition< export class EntityCompanionDefinition<
TFields, TFields,
TID, TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext, TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>, TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
TPrivacyPolicy extends EntityPrivacyPolicy< TPrivacyPolicy extends EntityPrivacyPolicy<
@@ -82,14 +73,14 @@ export class EntityCompanionDefinition<
>; >;
readonly entityConfiguration: EntityConfiguration<TFields>; readonly entityConfiguration: EntityConfiguration<TFields>;
readonly privacyPolicyClass: IPrivacyPolicyClass<TPrivacyPolicy>; readonly privacyPolicyClass: IPrivacyPolicyClass<TPrivacyPolicy>;
readonly mutationValidators: EntityMutationValidator< readonly mutationValidators: () => EntityMutationValidator<
TFields, TFields,
TID, TID,
TViewerContext, TViewerContext,
TEntity, TEntity,
TSelectedFields TSelectedFields
>[]; >[];
readonly mutationTriggers: EntityMutationTriggerConfiguration< readonly mutationTriggers: () => EntityMutationTriggerConfiguration<
TFields, TFields,
TID, TID,
TViewerContext, TViewerContext,
@@ -102,10 +93,13 @@ export class EntityCompanionDefinition<
entityClass, entityClass,
entityConfiguration, entityConfiguration,
privacyPolicyClass, privacyPolicyClass,
mutationValidators = [], mutationValidators = () => [],
mutationTriggers = {}, mutationTriggers = () => ({}),
entitySelectedFields = Array.from(entityConfiguration.schema.keys()) as TSelectedFields[], entitySelectedFields = Array.from(entityConfiguration.schema.keys()) as TSelectedFields[],
}: { }: {
/**
* The concrete Entity class for which this is the definition.
*/
entityClass: IEntityClass< entityClass: IEntityClass<
TFields, TFields,
TID, TID,
@@ -114,22 +108,39 @@ export class EntityCompanionDefinition<
TPrivacyPolicy, TPrivacyPolicy,
TSelectedFields TSelectedFields
>; >;
/**
* The {@link EntityConfiguration} for this entity.
*/
entityConfiguration: EntityConfiguration<TFields>; entityConfiguration: EntityConfiguration<TFields>;
/**
* The {@link EntityPrivacyPolicy} class for this entity.
*/
privacyPolicyClass: IPrivacyPolicyClass<TPrivacyPolicy>; privacyPolicyClass: IPrivacyPolicyClass<TPrivacyPolicy>;
mutationValidators?: EntityMutationValidator< /**
* An optional list of {@link EntityMutationValidator} for this entity.
*/
mutationValidators?: () => EntityMutationValidator<
TFields, TFields,
TID, TID,
TViewerContext, TViewerContext,
TEntity, TEntity,
TSelectedFields TSelectedFields
>[]; >[];
mutationTriggers?: EntityMutationTriggerConfiguration< /**
* An optional list of {@link EntityMutationTrigger} for this entity.
*/
mutationTriggers?: () => EntityMutationTriggerConfiguration<
TFields, TFields,
TID, TID,
TViewerContext, TViewerContext,
TEntity, TEntity,
TSelectedFields 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[]; entitySelectedFields?: TSelectedFields[];
}) { }) {
this.entityClass = entityClass; this.entityClass = entityClass;
@@ -151,14 +162,10 @@ export class EntityCompanionDefinition<
* {@link EntityCompanion} for each type of {@link Entity}. * {@link EntityCompanion} for each type of {@link Entity}.
*/ */
export default class EntityCompanionProvider { export default class EntityCompanionProvider {
private readonly companionMap: Map< private readonly companionMap: Map<string, EntityCompanion<any, any, any, any, any, any>> =
string, new Map();
EntityCompanion<any, any, any, any, any, any> private readonly tableDataCoordinatorMap: Map<string, EntityTableDataCoordinator<any>> =
> = new Map(); new Map();
private readonly tableDataCoordinatorMap: Map<
string,
EntityTableDataCoordinator<any>
> = new Map();
/** /**
* Instantiate an Entity framework. * Instantiate an Entity framework.
@@ -167,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>
) {} ) {}
/** /**
@@ -181,7 +191,7 @@ 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, TSelectedFields>, TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
TPrivacyPolicy extends EntityPrivacyPolicy< TPrivacyPolicy extends EntityPrivacyPolicy<
@@ -219,24 +229,46 @@ export default class EntityCompanionProvider {
entityCompanionDefinition.entityClass, entityCompanionDefinition.entityClass,
tableDataCoordinator, tableDataCoordinator,
entityCompanionDefinition.privacyPolicyClass, entityCompanionDefinition.privacyPolicyClass,
entityCompanionDefinition.mutationValidators, entityCompanionDefinition.mutationValidators(),
entityCompanionDefinition.mutationTriggers, 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>( private getTableDataCoordinatorForEntity<TFields>(
entityConfiguration: EntityConfiguration<TFields>, entityConfiguration: EntityConfiguration<TFields>,
entityClassName: string entityClassName: string
): EntityTableDataCoordinator<TFields> { ): EntityTableDataCoordinator<TFields> {
return computeIfAbsent(this.tableDataCoordinatorMap, entityConfiguration.tableName, () => { return computeIfAbsent(this.tableDataCoordinatorMap, entityConfiguration.tableName, () => {
const entityDatabaseAdapterFlavor = this.databaseAdapterFlavors[ const entityDatabaseAdapterFlavor = this.databaseAdapterFlavors.get(
entityConfiguration.databaseAdapterFlavor entityConfiguration.databaseAdapterFlavor
]; );
const entityCacheAdapterFlavor = this.cacheAdapterFlavors[ invariant(
entityDatabaseAdapterFlavor,
`No database adaptor configuration found for flavor: ${entityConfiguration.databaseAdapterFlavor}`
);
const entityCacheAdapterFlavor = this.cacheAdapterFlavors.get(
entityConfiguration.cacheAdapterFlavor entityConfiguration.cacheAdapterFlavor
]; );
invariant(
entityCacheAdapterFlavor,
`No cache adaptor configuration found for flavor: ${entityConfiguration.cacheAdapterFlavor}`
);
return new EntityTableDataCoordinator( return new EntityTableDataCoordinator(
entityConfiguration, entityConfiguration,
entityDatabaseAdapterFlavor.adapterProvider, entityDatabaseAdapterFlavor.adapterProvider,
+33 -5
View File
@@ -1,6 +1,6 @@
import { IEntityClass } from './Entity'; import { IEntityClass } from './Entity';
import { DatabaseAdapterFlavor, CacheAdapterFlavor } from './EntityCompanionProvider'; import { DatabaseAdapterFlavor, CacheAdapterFlavor } from './EntityCompanionProvider';
import { EntityFieldDefinition } from './EntityFields'; import { EntityFieldDefinition } from './EntityFieldDefinition';
import { mapMap, invertMap, reduceMap } from './utils/collections/maps'; import { mapMap, invertMap, reduceMap } from './utils/collections/maps';
/** /**
@@ -14,7 +14,7 @@ export default class EntityConfiguration<TFields> {
readonly cacheKeyVersion: number; readonly cacheKeyVersion: number;
readonly getInboundEdges: () => IEntityClass<any, any, any, any, any, any>[]; readonly getInboundEdges: () => IEntityClass<any, any, any, any, any, any>[];
readonly schema: ReadonlyMap<keyof TFields, EntityFieldDefinition>; 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>;
@@ -30,12 +30,40 @@ export default class EntityConfiguration<TFields> {
databaseAdapterFlavor, databaseAdapterFlavor,
cacheAdapterFlavor, 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>[]; 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; databaseAdapterFlavor: DatabaseAdapterFlavor;
/**
* Cache system for this entity.
*/
cacheAdapterFlavor: CacheAdapterFlavor; cacheAdapterFlavor: CacheAdapterFlavor;
}) { }) {
this.idField = idField; this.idField = idField;
@@ -59,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,
@@ -74,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 -11
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]!
); );
} }
-61
View File
@@ -1,61 +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, TSelectedFields>,
TPrivacyPolicy extends EntityPrivacyPolicy<
TFields,
TID,
TViewerContext,
TEntity,
TSelectedFields
>,
N extends keyof TFields,
TSelectedFields extends keyof TFields = keyof TFields
> extends EntityError {
constructor(
entityClass: IEntityClass<
TFields,
TID,
TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
>,
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, TSelectedFields>,
TSelectedFields extends keyof TFields = keyof TFields
> 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;
}
}
@@ -0,0 +1,124 @@
import { IEntityClass } from './Entity';
import EntityPrivacyPolicy from './EntityPrivacyPolicy';
import ReadonlyEntity from './ReadonlyEntity';
import ViewerContext from './ViewerContext';
export enum EntityEdgeDeletionBehavior {
/**
* Default. Invalidate the cache for all entities that reference the entity
* being deleted through this field, and transitively run deletions on those entities.
* This is most useful when the database itself expresses foreign
* keys and cascading deletes or set nulls and the entity framework just needs to
* be kept consistent with the state of the database.
*/
CASCADE_DELETE_INVALIDATE_CACHE,
/**
* Delete all entities that reference the entity being deleted through this field. This is very similar
* to SQL `ON DELETE CASCADE` but is done in the Entity framework instead of at the underlying level.
* This will also invalidate the cached referencing entities.
*/
CASCADE_DELETE,
/**
* Set this field to null when the referenced entity is deleted. This is very similar
* to SQL `ON DELETE SET NULL` but is done in the Entity framework instead of at the underlying level.
* This will also invalidate the cached referencing entities.
*/
SET_NULL,
}
export interface EntityAssociationDefinition<
TViewerContext extends ViewerContext,
TAssociatedFields,
TAssociatedID extends NonNullable<TAssociatedFields[TAssociatedSelectedFields]>,
TAssociatedEntity extends ReadonlyEntity<
TAssociatedFields,
TAssociatedID,
TViewerContext,
TAssociatedSelectedFields
>,
TAssociatedPrivacyPolicy extends EntityPrivacyPolicy<
TAssociatedFields,
TAssociatedID,
TViewerContext,
TAssociatedEntity,
TAssociatedSelectedFields
>,
TAssociatedSelectedFields extends keyof TAssociatedFields = keyof TAssociatedFields
> {
/**
* Class of entity on the other end of this edge.
*/
getAssociatedEntityClass: () => IEntityClass<
TAssociatedFields,
TAssociatedID,
TViewerContext,
TAssociatedEntity,
TAssociatedPrivacyPolicy,
TAssociatedSelectedFields
>;
/**
* Field by which to load the instance of associatedEntityClass. If not provided, the
* associatedEntityClass instance is fetched by its ID.
*/
associatedEntityLookupByField?: keyof TAssociatedFields;
/**
* What action to perform on the current entity when the entity on the referencing end of
* this edge is deleted.
*
* @remarks
* The entity framework doesn't prescribe a one-size-fits-all solution for referential
* integrity; instead it exposes mechanisms that supports both database foreign key constraints
* and implicit entity-specified foreign keys. Choosing which approach to use often depends on
* application requirements, and sometimes even a mix-and-match is the right choice.
*
* - If referential integrity is critical to your application, database foreign key constraints
* combined with {@link EntityEdgeDeletionBehavior.CASACDE_DELETE_INVALIDATE_CACHE} are recommended.
* - If the database being used doesn't support foreign keys, then using the entity framework for referential
* integrity is recommended.
*/
edgeDeletionBehavior?: EntityEdgeDeletionBehavior;
}
export abstract class EntityFieldDefinition<T> {
readonly columnName: string;
readonly cache: boolean;
readonly association: EntityAssociationDefinition<any, any, any, any, any, any> | undefined;
/**
*
* @param columnName - Column name in the database.
* @param cache - Whether or not to cache loaded instances of the entity by this field. The column name is
* used to derive a cache key for the cache entry. If true, this column must be able uniquely
* identify the entity.
*/
constructor({
columnName,
cache = false,
association,
}: {
columnName: string;
cache?: boolean;
association?: EntityAssociationDefinition<any, any, any, any, any, any>;
}) {
this.columnName = columnName;
this.cache = cache;
this.association = association;
}
/**
* Validates input value for a field of this type. Null and undefined are considered valid by default. This is used for things like:
* - EntityLoader.loadByFieldValue - to ensure the value being loaded by is a valid value
* - EntityMutator.setField - to ensure the value being set is a valid value
*/
public validateInputValue(value: T | null | undefined): boolean {
if (value === null || value === undefined) {
return true;
}
return this.validateInputValueInternal(value);
}
protected abstract validateInputValueInternal(value: T): boolean;
}
+55 -114
View File
@@ -1,121 +1,62 @@
import { IEntityClass } from './Entity'; import { validate as validateUUID } from 'uuid';
import EntityPrivacyPolicy from './EntityPrivacyPolicy';
import ReadonlyEntity from './ReadonlyEntity';
import ViewerContext from './ViewerContext';
export enum EntityEdgeDeletionBehavior { import { EntityFieldDefinition } from './EntityFieldDefinition';
/**
* Default. Invalidate the cache for all entities that reference the entity
* being deleted through this field, and transitively run deletions on those entities.
* This is most useful when the database itself expresses foreign
* keys and cascading deletes or set nulls and the entity framework just needs to
* be kept consistent with the state of the database.
*/
CASCADE_DELETE_INVALIDATE_CACHE,
/** export class StringField extends EntityFieldDefinition<string> {
* Delete all entities that reference the entity being deleted through this field. This is very similar protected validateInputValueInternal(value: string): boolean {
* to SQL `ON DELETE CASCADE` but is done in the Entity framework instead of at the underlying level. return typeof value === 'string';
* This will also invalidate the cached referencing entities. }
*/
CASCADE_DELETE,
/**
* Set this field to null when the referenced entity is deleted. This is very similar
* to SQL `ON DELETE SET NULL` but is done in the Entity framework instead of at the underlying level.
* This will also invalidate the cached referencing entities.
*/
SET_NULL,
} }
export class UUIDField extends StringField {
export interface EntityAssociationDefinition< protected override validateInputValueInternal(value: string): boolean {
TViewerContext extends ViewerContext, return validateUUID(value);
TAssociatedFields, }
TAssociatedID,
TAssociatedEntity extends ReadonlyEntity<
TAssociatedFields,
TAssociatedID,
TViewerContext,
TAssociatedSelectedFields
>,
TAssociatedPrivacyPolicy extends EntityPrivacyPolicy<
TAssociatedFields,
TAssociatedID,
TViewerContext,
TAssociatedEntity,
TAssociatedSelectedFields
>,
TAssociatedSelectedFields extends keyof TAssociatedFields = keyof TAssociatedFields
> {
/**
* Class of entity on the other end of this edge.
*/
getAssociatedEntityClass: () => IEntityClass<
TAssociatedFields,
TAssociatedID,
TViewerContext,
TAssociatedEntity,
TAssociatedPrivacyPolicy,
TAssociatedSelectedFields
>;
/**
* Field by which to load the instance of associatedEntityClass. If not provided, the
* associatedEntityClass instance is fetched by its ID.
*/
associatedEntityLookupByField?: keyof TAssociatedFields;
/**
* What action to perform on the current entity when the entity on the referencing end of
* this edge is deleted.
*
* @remarks
* The entity framework doesn't prescribe a one-size-fits-all solution for referential
* integrity; instead it exposes mechanisms that supports both database foreign key constraints
* and implicit entity-specified foreign keys. Choosing which approach to use often depends on
* application requirements, and sometimes even a mix-and-match is the right choice.
*
* - If referential integrity is critical to your application, database foreign key constraints
* combined with {@link EntityEdgeDeletionBehavior.CASACDE_DELETE_INVALIDATE_CACHE} are recommended.
* - If the database being used doesn't support foreign keys, then using the entity framework for referential
* integrity is recommended.
*/
edgeDeletionBehavior?: EntityEdgeDeletionBehavior;
} }
export class DateField extends EntityFieldDefinition<Date> {
export abstract class EntityFieldDefinition { protected validateInputValueInternal(value: Date): boolean {
readonly columnName: string; return value instanceof Date;
readonly cache: boolean; }
readonly association?: EntityAssociationDefinition<any, any, any, any, any, any>; }
/** export class BooleanField extends EntityFieldDefinition<boolean> {
* protected validateInputValueInternal(value: boolean): boolean {
* @param columnName - Column name in the database. return typeof value === 'boolean';
* @param cache - Whether or not to cache loaded instances of the entity by this field. The column name is
* used to derive a cache key for the cache entry. If true, this column must be able uniquely
* identify the entity.
*/
constructor({
columnName,
cache = false,
association,
}: {
columnName: string;
cache?: boolean;
association?: EntityAssociationDefinition<any, any, any, any, any, any>;
}) {
this.columnName = columnName;
this.cache = cache;
this.association = association;
} }
} }
export class StringField extends EntityFieldDefinition {} export class IntField extends EntityFieldDefinition<number> {
export class UUIDField extends StringField {} protected validateInputValueInternal(value: number): boolean {
export class DateField extends EntityFieldDefinition {} return typeof value === 'number' && Number.isInteger(value);
export class BooleanField extends EntityFieldDefinition {} }
export class NumberField extends EntityFieldDefinition {} }
export class StringArrayField extends EntityFieldDefinition {}
export class JSONObjectField extends EntityFieldDefinition {} export class FloatField extends EntityFieldDefinition<number> {
export class EnumField extends EntityFieldDefinition {} protected validateInputValueInternal(value: number): boolean {
export class JSONArrayField extends EntityFieldDefinition {} return typeof value === 'number';
export class MaybeJSONArrayField extends EntityFieldDefinition {} }
}
export class StringArrayField extends EntityFieldDefinition<string[]> {
protected validateInputValueInternal(value: string[]): boolean {
return Array.isArray(value) && value.every((subValue) => typeof subValue === 'string');
}
}
export class JSONObjectField extends EntityFieldDefinition<object> {
protected validateInputValueInternal(value: object): boolean {
return typeof value === 'object' && !Array.isArray(value);
}
}
export class EnumField extends EntityFieldDefinition<string | number> {
protected validateInputValueInternal(value: string | number): boolean {
return typeof value === 'number' || typeof value === 'string';
}
}
export class JSONArrayField extends EntityFieldDefinition<any[]> {
protected validateInputValueInternal(value: any[]): boolean {
return Array.isArray(value);
}
}
export class MaybeJSONArrayField extends EntityFieldDefinition<any | any[]> {
protected validateInputValueInternal(_value: any): boolean {
return true;
}
}
+88 -41
View File
@@ -3,13 +3,20 @@ import invariant from 'invariant';
import EnforcingEntityLoader from './EnforcingEntityLoader'; import EnforcingEntityLoader from './EnforcingEntityLoader';
import { IEntityClass } from './Entity'; import { IEntityClass } from './Entity';
import { FieldEqualityCondition, QuerySelectionModifiers } from './EntityDatabaseAdapter'; import EntityConfiguration from './EntityConfiguration';
import { EntityNotFoundError } from './EntityErrors'; import {
FieldEqualityCondition,
QuerySelectionModifiers,
isSingleValueFieldEqualityCondition,
} from './EntityDatabaseAdapter';
import EntityPrivacyPolicy from './EntityPrivacyPolicy'; import EntityPrivacyPolicy from './EntityPrivacyPolicy';
import { EntityQueryContext } from './EntityQueryContext'; import { EntityQueryContext } from './EntityQueryContext';
import ReadonlyEntity from './ReadonlyEntity'; import ReadonlyEntity from './ReadonlyEntity';
import ViewerContext from './ViewerContext'; import ViewerContext from './ViewerContext';
import EntityInvalidFieldValueError from './errors/EntityInvalidFieldValueError';
import EntityNotFoundError from './errors/EntityNotFoundError';
import EntityDataManager from './internal/EntityDataManager'; import EntityDataManager from './internal/EntityDataManager';
import IEntityMetricsAdapter from './metrics/IEntityMetricsAdapter';
import { mapMap, mapMapAsync } from './utils/collections/maps'; import { mapMap, mapMapAsync } from './utils/collections/maps';
/** /**
@@ -18,7 +25,7 @@ import { mapMap, mapMapAsync } from './utils/collections/maps';
*/ */
export default class EntityLoader< export default class EntityLoader<
TFields, TFields,
TID, TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext, TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>, TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
TPrivacyPolicy extends EntityPrivacyPolicy< TPrivacyPolicy extends EntityPrivacyPolicy<
@@ -33,7 +40,7 @@ export default class EntityLoader<
constructor( constructor(
private readonly viewerContext: TViewerContext, private readonly viewerContext: TViewerContext,
private readonly queryContext: EntityQueryContext, private readonly queryContext: EntityQueryContext,
private readonly idField: keyof Pick<TFields, TSelectedFields>, private readonly entityConfiguration: EntityConfiguration<TFields>,
private readonly entityClass: IEntityClass< private readonly entityClass: IEntityClass<
TFields, TFields,
TID, TID,
@@ -43,7 +50,8 @@ export default class EntityLoader<
TSelectedFields TSelectedFields
>, >,
private readonly privacyPolicy: TPrivacyPolicy, private readonly privacyPolicy: TPrivacyPolicy,
private readonly dataManager: EntityDataManager<TFields> private readonly dataManager: EntityDataManager<TFields>,
protected readonly metricsAdapter: IEntityMetricsAdapter
) {} ) {}
/** /**
@@ -73,34 +81,15 @@ export default class EntityLoader<
fieldName: N, fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[] fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<ReadonlyMap<NonNullable<TFields[N]>, readonly Result<TEntity>[]>> { ): Promise<ReadonlyMap<NonNullable<TFields[N]>, readonly Result<TEntity>[]>> {
this.validateFieldValues(fieldName, fieldValues);
const fieldValuesToFieldObjects = await this.dataManager.loadManyByFieldEqualingAsync( const fieldValuesToFieldObjects = await this.dataManager.loadManyByFieldEqualingAsync(
this.queryContext, this.queryContext,
fieldName, fieldName,
fieldValues fieldValues
); );
const fieldValuesToUncheckedEntityResults = mapMap(fieldValuesToFieldObjects, (fieldObjects) =>
this.tryConstructEntities(fieldObjects)
);
return await mapMapAsync( return await this.constructAndAuthorizeEntitiesAsync(fieldValuesToFieldObjects);
fieldValuesToUncheckedEntityResults,
async (uncheckedEntityResults) => {
return await Promise.all(
uncheckedEntityResults.map(async (uncheckedEntityResult) => {
if (!uncheckedEntityResult.ok) {
return uncheckedEntityResult;
}
return await asyncResult(
this.privacyPolicy.authorizeReadAsync(
this.viewerContext,
this.queryContext,
uncheckedEntityResult.value
)
);
})
);
}
);
} }
/** /**
@@ -150,7 +139,9 @@ export default class EntityLoader<
const entityResults = await this.loadManyByIDsAsync([id]); const entityResults = await this.loadManyByIDsAsync([id]);
const entityResult = entityResults.get(id); const entityResult = entityResults.get(id);
if (entityResult === undefined) { if (entityResult === undefined) {
return result(new EntityNotFoundError(this.entityClass, this.idField, id as any)); return result(
new EntityNotFoundError(this.entityClass, this.entityConfiguration.idField, id)
);
} }
return entityResult; return entityResult;
} }
@@ -161,7 +152,10 @@ export default class EntityLoader<
* @returns entity result for matching ID, or null if no entity exists for ID. * @returns entity result for matching ID, or null if no entity exists for ID.
*/ */
async loadByIDNullableAsync(id: TID): Promise<Result<TEntity> | null> { async loadByIDNullableAsync(id: TID): Promise<Result<TEntity> | null> {
return await this.loadByFieldEqualingAsync(this.idField, id as any); return await this.loadByFieldEqualingAsync(
this.entityConfiguration.idField as TSelectedFields,
id
);
} }
/** /**
@@ -171,14 +165,15 @@ export default class EntityLoader<
* @returns map from ID to corresponding entity result, where result error can be UnauthorizedError or EntityNotFoundError. * @returns map from ID to corresponding entity result, where result error can be UnauthorizedError or EntityNotFoundError.
*/ */
async loadManyByIDsAsync(ids: readonly TID[]): Promise<ReadonlyMap<TID, Result<TEntity>>> { async loadManyByIDsAsync(ids: readonly TID[]): Promise<ReadonlyMap<TID, Result<TEntity>>> {
const entityResults = ((await this.loadManyByFieldEqualingManyAsync( const entityResults = (await this.loadManyByFieldEqualingManyAsync(
this.idField, this.entityConfiguration.idField as TSelectedFields,
ids as any ids
)) as any) as ReadonlyMap<TID, readonly Result<TEntity>[]>; )) as ReadonlyMap<TID, readonly Result<TEntity>[]>;
return mapMap(entityResults, (entityResultsForId, id) => { return mapMap(entityResults, (entityResultsForId, id) => {
const entityResult = entityResultsForId[0]; const entityResult = entityResultsForId[0];
return ( return (
entityResult ?? result(new EntityNotFoundError(this.entityClass, this.idField, id as any)) entityResult ??
result(new EntityNotFoundError(this.entityClass, this.entityConfiguration.idField, id))
); );
}); });
} }
@@ -201,6 +196,13 @@ export default class EntityLoader<
fieldEqualityOperands: FieldEqualityCondition<TFields, N>[], fieldEqualityOperands: FieldEqualityCondition<TFields, N>[],
querySelectionModifiers: QuerySelectionModifiers<TFields> = {} querySelectionModifiers: QuerySelectionModifiers<TFields> = {}
): Promise<readonly Result<TEntity>[]> { ): Promise<readonly Result<TEntity>[]> {
for (const fieldEqualityOperand of fieldEqualityOperands) {
const fieldValues = isSingleValueFieldEqualityCondition(fieldEqualityOperand)
? [fieldEqualityOperand.fieldValue]
: fieldEqualityOperand.fieldValues;
this.validateFieldValues(fieldEqualityOperand.fieldName, fieldValues);
}
const fieldObjects = await this.dataManager.loadManyByFieldEqualityConjunctionAsync( const fieldObjects = await this.dataManager.loadManyByFieldEqualityConjunctionAsync(
this.queryContext, this.queryContext,
fieldEqualityOperands, fieldEqualityOperands,
@@ -216,7 +218,8 @@ export default class EntityLoader<
this.privacyPolicy.authorizeReadAsync( this.privacyPolicy.authorizeReadAsync(
this.viewerContext, this.viewerContext,
this.queryContext, this.queryContext,
uncheckedEntityResult.value uncheckedEntityResult.value,
this.metricsAdapter
) )
); );
}) })
@@ -269,7 +272,8 @@ export default class EntityLoader<
this.privacyPolicy.authorizeReadAsync( this.privacyPolicy.authorizeReadAsync(
this.viewerContext, this.viewerContext,
this.queryContext, this.queryContext,
uncheckedEntityResult.value uncheckedEntityResult.value,
this.metricsAdapter
) )
); );
}) })
@@ -293,18 +297,61 @@ export default class EntityLoader<
await this.invalidateFieldsAsync(entity.getAllDatabaseFields()); await this.invalidateFieldsAsync(entity.getAllDatabaseFields());
} }
/**
* Wrap entity construction in try/catch and return Results. Used to prevent constructor errors
* thrown for one entity instantiation from interfering with other entity instantiatons of the same query.
* @param fieldsObjects - array of entity data objects to construct entities
*/
private tryConstructEntities(fieldsObjects: readonly TFields[]): readonly Result<TEntity>[] { private tryConstructEntities(fieldsObjects: readonly TFields[]): readonly Result<TEntity>[] {
return fieldsObjects.map((fieldsObject) => { return fieldsObjects.map((fieldsObject) => {
try { try {
return result(new this.entityClass(this.viewerContext, fieldsObject)); return result(new this.entityClass(this.viewerContext, fieldsObject));
} catch (e) { } catch (e) {
if (!(e instanceof Error)) {
throw e;
}
return result(e); return result(e);
} }
}); });
} }
/**
* Construct and authorize entities from fields map, returning error results for entities that fail
* to construct or fail to authorize.
*
* @param map - map from an arbitrary key type to an array of entity field objects
*/
public async constructAndAuthorizeEntitiesAsync<K>(
map: ReadonlyMap<K, readonly Readonly<TFields>[]>
): Promise<ReadonlyMap<K, readonly Result<TEntity>[]>> {
const uncheckedEntityResultsMap = mapMap(map, (fieldObjects) =>
this.tryConstructEntities(fieldObjects)
);
return await mapMapAsync(uncheckedEntityResultsMap, async (uncheckedEntityResults) => {
return await Promise.all(
uncheckedEntityResults.map(async (uncheckedEntityResult) => {
if (!uncheckedEntityResult.ok) {
return uncheckedEntityResult;
}
return await asyncResult(
this.privacyPolicy.authorizeReadAsync(
this.viewerContext,
this.queryContext,
uncheckedEntityResult.value,
this.metricsAdapter
)
);
})
);
});
}
private validateFieldValues<N extends keyof Pick<TFields, TSelectedFields>>(
fieldName: N,
fieldValues: readonly TFields[N][]
): void {
const fieldDefinition = this.entityConfiguration.schema.get(fieldName);
invariant(fieldDefinition, `must have field definition for field = ${fieldName}`);
for (const fieldValue of fieldValues) {
const isInputValid = fieldDefinition.validateInputValue(fieldValue);
if (!isInputValid) {
throw new EntityInvalidFieldValueError(this.entityClass, fieldName, fieldValue);
}
}
}
} }
+9 -5
View File
@@ -1,17 +1,19 @@
import { IEntityClass } from './Entity'; import { IEntityClass } from './Entity';
import EntityConfiguration from './EntityConfiguration';
import EntityLoader from './EntityLoader'; import EntityLoader from './EntityLoader';
import EntityPrivacyPolicy from './EntityPrivacyPolicy'; import EntityPrivacyPolicy from './EntityPrivacyPolicy';
import { EntityQueryContext } from './EntityQueryContext'; import { EntityQueryContext } from './EntityQueryContext';
import ReadonlyEntity from './ReadonlyEntity'; import ReadonlyEntity from './ReadonlyEntity';
import ViewerContext from './ViewerContext'; import ViewerContext from './ViewerContext';
import EntityDataManager from './internal/EntityDataManager'; import EntityDataManager from './internal/EntityDataManager';
import IEntityMetricsAdapter from './metrics/IEntityMetricsAdapter';
/** /**
* The primary entry point for loading entities. * The primary entry point for loading entities.
*/ */
export default class EntityLoaderFactory< export default class EntityLoaderFactory<
TFields, TFields,
TID, TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext, TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>, TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
TPrivacyPolicy extends EntityPrivacyPolicy< TPrivacyPolicy extends EntityPrivacyPolicy<
@@ -24,7 +26,7 @@ export default class EntityLoaderFactory<
TSelectedFields extends keyof TFields TSelectedFields extends keyof TFields
> { > {
constructor( constructor(
private readonly idField: keyof Pick<TFields, TSelectedFields>, private readonly entityConfiguration: EntityConfiguration<TFields>,
private readonly entityClass: IEntityClass< private readonly entityClass: IEntityClass<
TFields, TFields,
TID, TID,
@@ -34,7 +36,8 @@ export default class EntityLoaderFactory<
TSelectedFields TSelectedFields
>, >,
private readonly privacyPolicyClass: TPrivacyPolicy, private readonly privacyPolicyClass: TPrivacyPolicy,
private readonly dataManager: EntityDataManager<TFields> private readonly dataManager: EntityDataManager<TFields>,
protected readonly metricsAdapter: IEntityMetricsAdapter
) {} ) {}
/** /**
@@ -49,10 +52,11 @@ export default class EntityLoaderFactory<
return new EntityLoader( return new EntityLoader(
viewerContext, viewerContext,
queryContext, queryContext,
this.idField, this.entityConfiguration,
this.entityClass, this.entityClass,
this.privacyPolicyClass, this.privacyPolicyClass,
this.dataManager this.dataManager,
this.metricsAdapter
); );
} }
} }
+47
View File
@@ -0,0 +1,47 @@
import Entity from './Entity';
import ViewerContext from './ViewerContext';
export enum EntityMutationType {
CREATE,
UPDATE,
DELETE,
}
export type EntityValidatorMutationInfo<
TFields,
TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext,
TEntity extends Entity<TFields, TID, TViewerContext, TSelectedFields>,
TSelectedFields extends keyof TFields = keyof TFields
> =
| {
type: EntityMutationType.CREATE;
}
| {
type: EntityMutationType.UPDATE;
previousValue: TEntity;
};
export type EntityMutationTriggerDeleteCascadeInfo<> = {
entity: Entity<any, any, any, any>;
cascadingDeleteCause: EntityMutationTriggerDeleteCascadeInfo | null;
};
export type EntityTriggerMutationInfo<
TFields,
TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext,
TEntity extends Entity<TFields, TID, TViewerContext, TSelectedFields>,
TSelectedFields extends keyof TFields = keyof TFields
> =
| {
type: EntityMutationType.CREATE;
}
| {
type: EntityMutationType.UPDATE;
previousValue: TEntity;
}
| {
type: EntityMutationType.DELETE;
cascadingDeleteCause: EntityMutationTriggerDeleteCascadeInfo | null;
};
@@ -1,4 +1,5 @@
import { EntityQueryContext } from './EntityQueryContext'; import { EntityTriggerMutationInfo } from './EntityMutationInfo';
import { EntityTransactionalQueryContext } from './EntityQueryContext';
import ReadonlyEntity from './ReadonlyEntity'; import ReadonlyEntity from './ReadonlyEntity';
import ViewerContext from './ViewerContext'; import ViewerContext from './ViewerContext';
@@ -7,7 +8,7 @@ import ViewerContext from './ViewerContext';
*/ */
export default interface EntityMutationTriggerConfiguration< export default interface EntityMutationTriggerConfiguration<
TFields, TFields,
TID, TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext, TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>, TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
TSelectedFields extends keyof TFields = keyof TFields TSelectedFields extends keyof TFields = keyof TFields
@@ -70,15 +71,16 @@ export default interface EntityMutationTriggerConfiguration<
*/ */
export abstract class EntityMutationTrigger< export abstract class EntityMutationTrigger<
TFields, TFields,
TID, TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext, TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>, TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
TSelectedFields extends keyof TFields = keyof TFields TSelectedFields extends keyof TFields = keyof TFields
> { > {
abstract async executeAsync( abstract executeAsync(
viewerContext: TViewerContext, viewerContext: TViewerContext,
queryContext: EntityQueryContext, queryContext: EntityTransactionalQueryContext,
entity: TEntity entity: TEntity,
mutationInfo: EntityTriggerMutationInfo<TFields, TID, TViewerContext, TEntity, TSelectedFields>
): Promise<void>; ): Promise<void>;
} }
@@ -88,10 +90,14 @@ export abstract class EntityMutationTrigger<
*/ */
export abstract class EntityNonTransactionalMutationTrigger< export abstract class EntityNonTransactionalMutationTrigger<
TFields, TFields,
TID, TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext, TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>, TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
TSelectedFields extends keyof TFields = keyof TFields TSelectedFields extends keyof TFields = keyof TFields
> { > {
abstract async executeAsync(viewerContext: TViewerContext, entity: TEntity): Promise<void>; abstract executeAsync(
viewerContext: TViewerContext,
entity: TEntity,
mutationInfo: EntityTriggerMutationInfo<TFields, TID, TViewerContext, TEntity, TSelectedFields>
): Promise<void>;
} }
+14 -6
View File
@@ -1,21 +1,29 @@
import { EntityQueryContext } from './EntityQueryContext'; import { EntityValidatorMutationInfo } from './EntityMutationInfo';
import { EntityTransactionalQueryContext } from './EntityQueryContext';
import ReadonlyEntity from './ReadonlyEntity'; import ReadonlyEntity from './ReadonlyEntity';
import ViewerContext from './ViewerContext'; import ViewerContext from './ViewerContext';
/* /**
* A validator is a way to specify entity mutation validation that runs within the * A validator is a way to specify entity mutation validation that runs within the
* same transaction as the mutation itself before creating or updating an entity. * same transaction as the mutation itself before creating or updating an entity.
*/ */
export default abstract class EntityMutationValidator< export default abstract class EntityMutationValidator<
TFields, TFields,
TID, TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext, TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>, TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
TSelectedFields extends keyof TFields = keyof TFields TSelectedFields extends keyof TFields = keyof TFields
> { > {
abstract async executeAsync( abstract executeAsync(
viewerContext: TViewerContext, viewerContext: TViewerContext,
queryContext: EntityQueryContext, queryContext: EntityTransactionalQueryContext,
entity: TEntity entity: TEntity,
mutationInfo: EntityValidatorMutationInfo<
TFields,
TID,
TViewerContext,
TEntity,
TSelectedFields
>
): Promise<void>; ): Promise<void>;
} }
+175 -93
View File
@@ -1,11 +1,18 @@
import { Result, asyncResult, result, enforceAsyncResult } from '@expo/results'; import { Result, asyncResult, result, enforceAsyncResult } from '@expo/results';
import invariant from 'invariant';
import Entity, { IEntityClass } from './Entity'; import Entity, { IEntityClass } from './Entity';
import { EntityCompanionDefinition } from './EntityCompanionProvider'; import { EntityCompanionDefinition } from './EntityCompanionProvider';
import EntityConfiguration from './EntityConfiguration'; import EntityConfiguration from './EntityConfiguration';
import EntityDatabaseAdapter from './EntityDatabaseAdapter'; import EntityDatabaseAdapter from './EntityDatabaseAdapter';
import { EntityEdgeDeletionBehavior } from './EntityFields'; import { EntityEdgeDeletionBehavior } from './EntityFieldDefinition';
import EntityLoaderFactory from './EntityLoaderFactory'; import EntityLoaderFactory from './EntityLoaderFactory';
import {
EntityValidatorMutationInfo,
EntityMutationType,
EntityTriggerMutationInfo,
EntityMutationTriggerDeleteCascadeInfo,
} from './EntityMutationInfo';
import EntityMutationTriggerConfiguration, { import EntityMutationTriggerConfiguration, {
EntityMutationTrigger, EntityMutationTrigger,
EntityNonTransactionalMutationTrigger, EntityNonTransactionalMutationTrigger,
@@ -15,13 +22,14 @@ import EntityPrivacyPolicy from './EntityPrivacyPolicy';
import { EntityQueryContext, EntityTransactionalQueryContext } from './EntityQueryContext'; import { EntityQueryContext, EntityTransactionalQueryContext } from './EntityQueryContext';
import ReadonlyEntity from './ReadonlyEntity'; import ReadonlyEntity from './ReadonlyEntity';
import ViewerContext from './ViewerContext'; import ViewerContext from './ViewerContext';
import EntityInvalidFieldValueError from './errors/EntityInvalidFieldValueError';
import { timeAndLogMutationEventAsync } from './metrics/EntityMetricsUtils'; import { timeAndLogMutationEventAsync } from './metrics/EntityMetricsUtils';
import IEntityMetricsAdapter, { EntityMetricsMutationType } from './metrics/IEntityMetricsAdapter'; import IEntityMetricsAdapter, { EntityMetricsMutationType } from './metrics/IEntityMetricsAdapter';
import { mapMapAsync } from './utils/collections/maps'; import { mapMapAsync } from './utils/collections/maps';
abstract class BaseMutator< abstract class BaseMutator<
TFields, TFields,
TID, TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext, TViewerContext extends ViewerContext,
TEntity extends Entity<TFields, TID, TViewerContext, TSelectedFields>, TEntity extends Entity<TFields, TID, TViewerContext, TSelectedFields>,
TPrivacyPolicy extends EntityPrivacyPolicy< TPrivacyPolicy extends EntityPrivacyPolicy<
@@ -72,25 +80,56 @@ abstract class BaseMutator<
protected readonly metricsAdapter: IEntityMetricsAdapter protected readonly metricsAdapter: IEntityMetricsAdapter
) {} ) {}
protected async executeMutationTriggersOrValidatorsAsync( protected validateFields(fields: Partial<TFields>): void {
triggersOrValidators: for (const fieldName in fields) {
| EntityMutationTrigger<TFields, TID, TViewerContext, TEntity, TSelectedFields>[] const fieldValue = fields[fieldName];
| EntityMutationValidator<TFields, TID, TViewerContext, TEntity, TSelectedFields>[] const fieldDefinition = this.entityConfiguration.schema.get(fieldName);
| undefined, invariant(fieldDefinition, `must have field definition for field = ${fieldName}`);
queryContext: EntityQueryContext, const isInputValid = fieldDefinition.validateInputValue(fieldValue);
entity: TEntity if (!isInputValid) {
): Promise<void> { throw new EntityInvalidFieldValueError(this.entityClass, fieldName, fieldValue);
if (!triggersOrValidators) { }
return;
} }
}
protected async executeMutationValidatorsAsync(
validators: EntityMutationValidator<TFields, TID, TViewerContext, TEntity, TSelectedFields>[],
queryContext: EntityTransactionalQueryContext,
entity: TEntity,
mutationInfo: EntityValidatorMutationInfo<
TFields,
TID,
TViewerContext,
TEntity,
TSelectedFields
>
): Promise<void> {
await Promise.all( await Promise.all(
triggersOrValidators.map((triggerOrValidator) => validators.map((validator) =>
triggerOrValidator.executeAsync(this.viewerContext, queryContext, entity) validator.executeAsync(this.viewerContext, queryContext, entity, mutationInfo)
) )
); );
} }
protected async executeNonTransactionalMutationTriggersOrValidatorsAsync( protected async executeMutationTriggersAsync(
triggers:
| EntityMutationTrigger<TFields, TID, TViewerContext, TEntity, TSelectedFields>[]
| undefined,
queryContext: EntityTransactionalQueryContext,
entity: TEntity,
mutationInfo: EntityTriggerMutationInfo<TFields, TID, TViewerContext, TEntity, TSelectedFields>
): Promise<void> {
if (!triggers) {
return;
}
await Promise.all(
triggers.map((trigger) =>
trigger.executeAsync(this.viewerContext, queryContext, entity, mutationInfo)
)
);
}
protected async executeNonTransactionalMutationTriggersAsync(
triggers: triggers:
| EntityNonTransactionalMutationTrigger< | EntityNonTransactionalMutationTrigger<
TFields, TFields,
@@ -100,12 +139,15 @@ abstract class BaseMutator<
TSelectedFields TSelectedFields
>[] >[]
| undefined, | undefined,
entity: TEntity entity: TEntity,
mutationInfo: EntityTriggerMutationInfo<TFields, TID, TViewerContext, TEntity, TSelectedFields>
): Promise<void> { ): Promise<void> {
if (!triggers) { if (!triggers) {
return; return;
} }
await Promise.all(triggers.map((trigger) => trigger.executeAsync(this.viewerContext, entity))); await Promise.all(
triggers.map((trigger) => trigger.executeAsync(this.viewerContext, entity, mutationInfo))
);
} }
} }
@@ -114,7 +156,7 @@ abstract class BaseMutator<
*/ */
export class CreateMutator< export class CreateMutator<
TFields, TFields,
TID, TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext, TViewerContext extends ViewerContext,
TEntity extends Entity<TFields, TID, TViewerContext, TSelectedFields>, TEntity extends Entity<TFields, TID, TViewerContext, TSelectedFields>,
TPrivacyPolicy extends EntityPrivacyPolicy< TPrivacyPolicy extends EntityPrivacyPolicy<
@@ -167,42 +209,48 @@ export class CreateMutator<
private async createInternalAsync( private async createInternalAsync(
queryContext: EntityTransactionalQueryContext queryContext: EntityTransactionalQueryContext
): Promise<Result<TEntity>> { ): Promise<Result<TEntity>> {
const temporaryEntityForPrivacyCheck = new this.entityClass(this.viewerContext, ({ this.validateFields(this.fieldsForEntity);
const temporaryEntityForPrivacyCheck = new this.entityClass(this.viewerContext, {
[this.entityConfiguration.idField]: '00000000-0000-0000-0000-000000000000', // zero UUID [this.entityConfiguration.idField]: '00000000-0000-0000-0000-000000000000', // zero UUID
...this.fieldsForEntity, ...this.fieldsForEntity,
} as unknown) as TFields); } as unknown as TFields);
const authorizeCreateResult = await asyncResult( const authorizeCreateResult = await asyncResult(
this.privacyPolicy.authorizeCreateAsync( this.privacyPolicy.authorizeCreateAsync(
this.viewerContext, this.viewerContext,
queryContext, queryContext,
temporaryEntityForPrivacyCheck temporaryEntityForPrivacyCheck,
this.metricsAdapter
) )
); );
if (!authorizeCreateResult.ok) { if (!authorizeCreateResult.ok) {
return authorizeCreateResult; return authorizeCreateResult;
} }
await this.executeMutationTriggersOrValidatorsAsync( await this.executeMutationValidatorsAsync(
this.mutationValidators, this.mutationValidators,
queryContext, queryContext,
temporaryEntityForPrivacyCheck temporaryEntityForPrivacyCheck,
{ type: EntityMutationType.CREATE }
); );
await this.executeMutationTriggersOrValidatorsAsync( await this.executeMutationTriggersAsync(
this.mutationTriggers.beforeAll, this.mutationTriggers.beforeAll,
queryContext, queryContext,
temporaryEntityForPrivacyCheck temporaryEntityForPrivacyCheck,
{ type: EntityMutationType.CREATE }
); );
await this.executeMutationTriggersOrValidatorsAsync( await this.executeMutationTriggersAsync(
this.mutationTriggers.beforeCreate, this.mutationTriggers.beforeCreate,
queryContext, queryContext,
temporaryEntityForPrivacyCheck temporaryEntityForPrivacyCheck,
{ type: EntityMutationType.CREATE }
); );
const insertResult = await this.databaseAdapter.insertAsync(queryContext, this.fieldsForEntity); const insertResult = await this.databaseAdapter.insertAsync(queryContext, this.fieldsForEntity);
const entityLoader = this.entityLoaderFactory.forLoad(this.viewerContext, queryContext); const entityLoader = this.entityLoaderFactory.forLoad(this.viewerContext, queryContext);
queryContext.appendPostCommitCallback( queryContext.appendPostCommitInvalidationCallback(
entityLoader.invalidateFieldsAsync.bind(entityLoader, insertResult) entityLoader.invalidateFieldsAsync.bind(entityLoader, insertResult)
); );
@@ -211,22 +259,25 @@ export class CreateMutator<
.enforcing() .enforcing()
.loadByIDAsync(unauthorizedEntityAfterInsert.getID()); .loadByIDAsync(unauthorizedEntityAfterInsert.getID());
await this.executeMutationTriggersOrValidatorsAsync( await this.executeMutationTriggersAsync(
this.mutationTriggers.afterCreate, this.mutationTriggers.afterCreate,
queryContext, queryContext,
newEntity newEntity,
{ type: EntityMutationType.CREATE }
); );
await this.executeMutationTriggersOrValidatorsAsync( await this.executeMutationTriggersAsync(
this.mutationTriggers.afterAll, this.mutationTriggers.afterAll,
queryContext, queryContext,
newEntity newEntity,
{ type: EntityMutationType.CREATE }
); );
queryContext.appendPostCommitCallback( queryContext.appendPostCommitCallback(
this.executeNonTransactionalMutationTriggersOrValidatorsAsync.bind( this.executeNonTransactionalMutationTriggersAsync.bind(
this, this,
this.mutationTriggers.afterCommit, this.mutationTriggers.afterCommit,
newEntity newEntity,
{ type: EntityMutationType.CREATE }
) )
); );
@@ -239,7 +290,7 @@ export class CreateMutator<
*/ */
export class UpdateMutator< export class UpdateMutator<
TFields, TFields,
TID, TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext, TViewerContext extends ViewerContext,
TEntity extends Entity<TFields, TID, TViewerContext, TSelectedFields>, TEntity extends Entity<TFields, TID, TViewerContext, TSelectedFields>,
TPrivacyPolicy extends EntityPrivacyPolicy< TPrivacyPolicy extends EntityPrivacyPolicy<
@@ -251,7 +302,7 @@ export class UpdateMutator<
>, >,
TSelectedFields extends keyof TFields TSelectedFields extends keyof TFields
> extends BaseMutator<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy, TSelectedFields> { > extends BaseMutator<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy, TSelectedFields> {
private readonly originalFieldsForEntity: Readonly<TFields>; private readonly originalEntity: TEntity;
private readonly fieldsForEntity: TFields; private readonly fieldsForEntity: TFields;
private readonly updatedFields: Partial<TFields> = {}; private readonly updatedFields: Partial<TFields> = {};
@@ -292,7 +343,7 @@ export class UpdateMutator<
>, >,
databaseAdapter: EntityDatabaseAdapter<TFields>, databaseAdapter: EntityDatabaseAdapter<TFields>,
metricsAdapter: IEntityMetricsAdapter, metricsAdapter: IEntityMetricsAdapter,
fieldsForEntity: Readonly<TFields> originalEntity: TEntity
) { ) {
super( super(
viewerContext, viewerContext,
@@ -306,8 +357,8 @@ export class UpdateMutator<
databaseAdapter, databaseAdapter,
metricsAdapter metricsAdapter
); );
this.originalFieldsForEntity = { ...fieldsForEntity }; this.originalEntity = originalEntity;
this.fieldsForEntity = { ...fieldsForEntity }; this.fieldsForEntity = { ...originalEntity.getAllDatabaseFields() };
} }
/** /**
@@ -351,32 +402,38 @@ export class UpdateMutator<
private async updateInternalAsync( private async updateInternalAsync(
queryContext: EntityTransactionalQueryContext queryContext: EntityTransactionalQueryContext
): Promise<Result<TEntity>> { ): Promise<Result<TEntity>> {
this.validateFields(this.updatedFields);
const entityAboutToBeUpdated = new this.entityClass(this.viewerContext, this.fieldsForEntity); const entityAboutToBeUpdated = new this.entityClass(this.viewerContext, this.fieldsForEntity);
const authorizeUpdateResult = await asyncResult( const authorizeUpdateResult = await asyncResult(
this.privacyPolicy.authorizeUpdateAsync( this.privacyPolicy.authorizeUpdateAsync(
this.viewerContext, this.viewerContext,
queryContext, queryContext,
entityAboutToBeUpdated entityAboutToBeUpdated,
this.metricsAdapter
) )
); );
if (!authorizeUpdateResult.ok) { if (!authorizeUpdateResult.ok) {
return authorizeUpdateResult; return authorizeUpdateResult;
} }
await this.executeMutationTriggersOrValidatorsAsync( await this.executeMutationValidatorsAsync(
this.mutationValidators, this.mutationValidators,
queryContext, queryContext,
entityAboutToBeUpdated entityAboutToBeUpdated,
{ type: EntityMutationType.UPDATE, previousValue: this.originalEntity }
); );
await this.executeMutationTriggersOrValidatorsAsync( await this.executeMutationTriggersAsync(
this.mutationTriggers.beforeAll, this.mutationTriggers.beforeAll,
queryContext, queryContext,
entityAboutToBeUpdated entityAboutToBeUpdated,
{ type: EntityMutationType.UPDATE, previousValue: this.originalEntity }
); );
await this.executeMutationTriggersOrValidatorsAsync( await this.executeMutationTriggersAsync(
this.mutationTriggers.beforeUpdate, this.mutationTriggers.beforeUpdate,
queryContext, queryContext,
entityAboutToBeUpdated entityAboutToBeUpdated,
{ type: EntityMutationType.UPDATE, previousValue: this.originalEntity }
); );
const updateResult = await this.databaseAdapter.updateAsync( const updateResult = await this.databaseAdapter.updateAsync(
@@ -388,10 +445,13 @@ export class UpdateMutator<
const entityLoader = this.entityLoaderFactory.forLoad(this.viewerContext, queryContext); const entityLoader = this.entityLoaderFactory.forLoad(this.viewerContext, queryContext);
queryContext.appendPostCommitCallback( queryContext.appendPostCommitInvalidationCallback(
entityLoader.invalidateFieldsAsync.bind(entityLoader, this.originalFieldsForEntity) entityLoader.invalidateFieldsAsync.bind(
entityLoader,
this.originalEntity.getAllDatabaseFields()
)
); );
queryContext.appendPostCommitCallback( queryContext.appendPostCommitInvalidationCallback(
entityLoader.invalidateFieldsAsync.bind(entityLoader, this.fieldsForEntity) entityLoader.invalidateFieldsAsync.bind(entityLoader, this.fieldsForEntity)
); );
@@ -400,22 +460,25 @@ export class UpdateMutator<
.enforcing() .enforcing()
.loadByIDAsync(unauthorizedEntityAfterUpdate.getID()); .loadByIDAsync(unauthorizedEntityAfterUpdate.getID());
await this.executeMutationTriggersOrValidatorsAsync( await this.executeMutationTriggersAsync(
this.mutationTriggers.afterUpdate, this.mutationTriggers.afterUpdate,
queryContext, queryContext,
updatedEntity updatedEntity,
{ type: EntityMutationType.UPDATE, previousValue: this.originalEntity }
); );
await this.executeMutationTriggersOrValidatorsAsync( await this.executeMutationTriggersAsync(
this.mutationTriggers.afterAll, this.mutationTriggers.afterAll,
queryContext, queryContext,
updatedEntity updatedEntity,
{ type: EntityMutationType.UPDATE, previousValue: this.originalEntity }
); );
queryContext.appendPostCommitCallback( queryContext.appendPostCommitCallback(
this.executeNonTransactionalMutationTriggersOrValidatorsAsync.bind( this.executeNonTransactionalMutationTriggersAsync.bind(
this, this,
this.mutationTriggers.afterCommit, this.mutationTriggers.afterCommit,
updatedEntity updatedEntity,
{ type: EntityMutationType.UPDATE, previousValue: this.originalEntity }
) )
); );
@@ -428,7 +491,7 @@ export class UpdateMutator<
*/ */
export class DeleteMutator< export class DeleteMutator<
TFields, TFields,
TID, TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext, TViewerContext extends ViewerContext,
TEntity extends Entity<TFields, TID, TViewerContext, TSelectedFields>, TEntity extends Entity<TFields, TID, TViewerContext, TSelectedFields>,
TPrivacyPolicy extends EntityPrivacyPolicy< TPrivacyPolicy extends EntityPrivacyPolicy<
@@ -502,7 +565,7 @@ export class DeleteMutator<
this.metricsAdapter, this.metricsAdapter,
EntityMetricsMutationType.DELETE, EntityMetricsMutationType.DELETE,
this.entityClass.name this.entityClass.name
)(this.deleteInTransactionAsync()); )(this.deleteInTransactionAsync(new Set(), false, null));
} }
/** /**
@@ -513,14 +576,16 @@ export class DeleteMutator<
} }
private async deleteInTransactionAsync( private async deleteInTransactionAsync(
processedEntityIdentifiersFromTransitiveDeletions: Set<string> = new Set(), processedEntityIdentifiersFromTransitiveDeletions: Set<string>,
skipDatabaseDeletion: boolean = false skipDatabaseDeletion: boolean,
cascadingDeleteCause: EntityMutationTriggerDeleteCascadeInfo | null
): Promise<Result<void>> { ): Promise<Result<void>> {
return await this.queryContext.runInTransactionIfNotInTransactionAsync((innerQueryContext) => return await this.queryContext.runInTransactionIfNotInTransactionAsync((innerQueryContext) =>
this.deleteInternalAsync( this.deleteInternalAsync(
innerQueryContext, innerQueryContext,
processedEntityIdentifiersFromTransitiveDeletions, processedEntityIdentifiersFromTransitiveDeletions,
skipDatabaseDeletion skipDatabaseDeletion,
cascadingDeleteCause
) )
); );
} }
@@ -528,30 +593,39 @@ export class DeleteMutator<
private async deleteInternalAsync( private async deleteInternalAsync(
queryContext: EntityTransactionalQueryContext, queryContext: EntityTransactionalQueryContext,
processedEntityIdentifiersFromTransitiveDeletions: Set<string>, processedEntityIdentifiersFromTransitiveDeletions: Set<string>,
skipDatabaseDeletion: boolean skipDatabaseDeletion: boolean,
cascadingDeleteCause: EntityMutationTriggerDeleteCascadeInfo | null
): Promise<Result<void>> { ): Promise<Result<void>> {
const authorizeDeleteResult = await asyncResult( const authorizeDeleteResult = await asyncResult(
this.privacyPolicy.authorizeDeleteAsync(this.viewerContext, queryContext, this.entity) this.privacyPolicy.authorizeDeleteAsync(
this.viewerContext,
queryContext,
this.entity,
this.metricsAdapter
)
); );
if (!authorizeDeleteResult.ok) { if (!authorizeDeleteResult.ok) {
return authorizeDeleteResult; return authorizeDeleteResult;
} }
await DeleteMutator.processEntityDeletionForInboundEdgesAsync( await this.processEntityDeletionForInboundEdgesAsync(
this.entity, this.entity,
queryContext, queryContext,
processedEntityIdentifiersFromTransitiveDeletions processedEntityIdentifiersFromTransitiveDeletions,
cascadingDeleteCause
); );
await this.executeMutationTriggersOrValidatorsAsync( await this.executeMutationTriggersAsync(
this.mutationTriggers.beforeAll, this.mutationTriggers.beforeAll,
queryContext, queryContext,
this.entity this.entity,
{ type: EntityMutationType.DELETE, cascadingDeleteCause }
); );
await this.executeMutationTriggersOrValidatorsAsync( await this.executeMutationTriggersAsync(
this.mutationTriggers.beforeDelete, this.mutationTriggers.beforeDelete,
queryContext, queryContext,
this.entity this.entity,
{ type: EntityMutationType.DELETE, cascadingDeleteCause }
); );
if (!skipDatabaseDeletion) { if (!skipDatabaseDeletion) {
@@ -563,26 +637,29 @@ export class DeleteMutator<
} }
const entityLoader = this.entityLoaderFactory.forLoad(this.viewerContext, queryContext); const entityLoader = this.entityLoaderFactory.forLoad(this.viewerContext, queryContext);
queryContext.appendPostCommitCallback( queryContext.appendPostCommitInvalidationCallback(
entityLoader.invalidateFieldsAsync.bind(entityLoader, this.entity.getAllDatabaseFields()) entityLoader.invalidateFieldsAsync.bind(entityLoader, this.entity.getAllDatabaseFields())
); );
await this.executeMutationTriggersOrValidatorsAsync( await this.executeMutationTriggersAsync(
this.mutationTriggers.afterDelete, this.mutationTriggers.afterDelete,
queryContext, queryContext,
this.entity this.entity,
{ type: EntityMutationType.DELETE, cascadingDeleteCause }
); );
await this.executeMutationTriggersOrValidatorsAsync( await this.executeMutationTriggersAsync(
this.mutationTriggers.afterAll, this.mutationTriggers.afterAll,
queryContext, queryContext,
this.entity this.entity,
{ type: EntityMutationType.DELETE, cascadingDeleteCause }
); );
queryContext.appendPostCommitCallback( queryContext.appendPostCommitCallback(
this.executeNonTransactionalMutationTriggersOrValidatorsAsync.bind( this.executeNonTransactionalMutationTriggersAsync.bind(
this, this,
this.mutationTriggers.afterCommit, this.mutationTriggers.afterCommit,
this.entity this.entity,
{ type: EntityMutationType.DELETE, cascadingDeleteCause }
) )
); );
@@ -603,16 +680,11 @@ export class DeleteMutator<
* *
* @param entity - entity to find all references to * @param entity - entity to find all references to
*/ */
private static async processEntityDeletionForInboundEdgesAsync< private async processEntityDeletionForInboundEdgesAsync(
TMFields, entity: TEntity,
TMID,
TMViewerContext extends ViewerContext,
TMEntity extends Entity<TMFields, TMID, TMViewerContext, TMSelectedFields>,
TMSelectedFields extends keyof TMFields
>(
entity: TMEntity,
queryContext: EntityTransactionalQueryContext, queryContext: EntityTransactionalQueryContext,
processedEntityIdentifiers: Set<string> processedEntityIdentifiers: Set<string>,
cascadingDeleteCause: EntityMutationTriggerDeleteCascadeInfo | null
): Promise<void> { ): Promise<void> {
// prevent infinite reference cycles by keeping track of entities already processed // prevent infinite reference cycles by keeping track of entities already processed
if (processedEntityIdentifiers.has(entity.getUniqueIdentifier())) { if (processedEntityIdentifiers.has(entity.getUniqueIdentifier())) {
@@ -620,13 +692,15 @@ export class DeleteMutator<
} }
processedEntityIdentifiers.add(entity.getUniqueIdentifier()); processedEntityIdentifiers.add(entity.getUniqueIdentifier());
const companionDefinition = (entity.constructor as any).getCompanionDefinition() as EntityCompanionDefinition< const companionDefinition = (
TMFields, entity.constructor as any
TMID, ).getCompanionDefinition() as EntityCompanionDefinition<
TMViewerContext, TFields,
TMEntity, TID,
EntityPrivacyPolicy<TMFields, TMID, TMViewerContext, TMEntity, TMSelectedFields>, TViewerContext,
TMSelectedFields TEntity,
TPrivacyPolicy,
TSelectedFields
>; >;
const entityConfiguration = companionDefinition.entityConfiguration; const entityConfiguration = companionDefinition.entityConfiguration;
const inboundEdges = entityConfiguration.getInboundEdges(); const inboundEdges = entityConfiguration.getInboundEdges();
@@ -683,7 +757,11 @@ export class DeleteMutator<
.forDelete(inboundReferenceEntity, queryContext) .forDelete(inboundReferenceEntity, queryContext)
.deleteInTransactionAsync( .deleteInTransactionAsync(
processedEntityIdentifiers, processedEntityIdentifiers,
/* skipDatabaseDeletion */ true // deletion is handled by DB /* skipDatabaseDeletion */ true, // deletion is handled by DB
{
entity,
cascadingDeleteCause,
}
) )
) )
); );
@@ -707,7 +785,11 @@ export class DeleteMutator<
.forDelete(inboundReferenceEntity, queryContext) .forDelete(inboundReferenceEntity, queryContext)
.deleteInTransactionAsync( .deleteInTransactionAsync(
processedEntityIdentifiers, processedEntityIdentifiers,
/* skipDatabaseDeletion */ false /* skipDatabaseDeletion */ false,
{
entity,
cascadingDeleteCause,
}
) )
) )
); );
+2 -2
View File
@@ -15,7 +15,7 @@ import IEntityMetricsAdapter from './metrics/IEntityMetricsAdapter';
*/ */
export default class EntityMutatorFactory< export default class EntityMutatorFactory<
TFields, TFields,
TID, TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext, TViewerContext extends ViewerContext,
TEntity extends Entity<TFields, TID, TViewerContext, TSelectedFields>, TEntity extends Entity<TFields, TID, TViewerContext, TSelectedFields>,
TPrivacyPolicy extends EntityPrivacyPolicy< TPrivacyPolicy extends EntityPrivacyPolicy<
@@ -109,7 +109,7 @@ export default class EntityMutatorFactory<
this.entityLoaderFactory, this.entityLoaderFactory,
this.databaseAdapter, this.databaseAdapter,
this.metricsAdapter, this.metricsAdapter,
existingEntity.getAllDatabaseFields() existingEntity
); );
} }
+94 -25
View File
@@ -1,7 +1,10 @@
import { EntityNotAuthorizedError } from './EntityErrors';
import { EntityQueryContext } from './EntityQueryContext'; import { EntityQueryContext } from './EntityQueryContext';
import ReadonlyEntity from './ReadonlyEntity'; import ReadonlyEntity from './ReadonlyEntity';
import ViewerContext from './ViewerContext'; import ViewerContext from './ViewerContext';
import EntityNotAuthorizedError from './errors/EntityNotAuthorizedError';
import IEntityMetricsAdapter, {
EntityMetricsAuthorizationResult,
} from './metrics/IEntityMetricsAdapter';
import PrivacyPolicyRule, { RuleEvaluationResult } from './rules/PrivacyPolicyRule'; import PrivacyPolicyRule, { RuleEvaluationResult } from './rules/PrivacyPolicyRule';
export enum EntityPrivacyPolicyEvaluationMode { export enum EntityPrivacyPolicyEvaluationMode {
@@ -12,7 +15,7 @@ export enum EntityPrivacyPolicyEvaluationMode {
export type EntityPrivacyPolicyEvaluator< export type EntityPrivacyPolicyEvaluator<
TFields, TFields,
TID, TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext, TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>, TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
TSelectedFields extends keyof TFields = keyof TFields TSelectedFields extends keyof TFields = keyof TFields
@@ -63,7 +66,7 @@ export enum EntityAuthorizationAction {
*/ */
export default abstract class EntityPrivacyPolicy< export default abstract class EntityPrivacyPolicy<
TFields, TFields,
TID, TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext, TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>, TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
TSelectedFields extends keyof TFields = keyof TFields TSelectedFields extends keyof TFields = keyof TFields
@@ -124,14 +127,16 @@ export default abstract class EntityPrivacyPolicy<
async authorizeCreateAsync( async authorizeCreateAsync(
viewerContext: TViewerContext, viewerContext: TViewerContext,
queryContext: EntityQueryContext, queryContext: EntityQueryContext,
entity: TEntity entity: TEntity,
metricsAdapter: IEntityMetricsAdapter
): Promise<TEntity> { ): Promise<TEntity> {
return await this.authorizeForRulesetAsync( return await this.authorizeForRulesetAsync(
this.createRules, this.createRules,
viewerContext, viewerContext,
queryContext, queryContext,
entity, entity,
EntityAuthorizationAction.CREATE EntityAuthorizationAction.CREATE,
metricsAdapter
); );
} }
@@ -146,14 +151,16 @@ export default abstract class EntityPrivacyPolicy<
async authorizeReadAsync( async authorizeReadAsync(
viewerContext: TViewerContext, viewerContext: TViewerContext,
queryContext: EntityQueryContext, queryContext: EntityQueryContext,
entity: TEntity entity: TEntity,
metricsAdapter: IEntityMetricsAdapter
): Promise<TEntity> { ): Promise<TEntity> {
return await this.authorizeForRulesetAsync( return await this.authorizeForRulesetAsync(
this.readRules, this.readRules,
viewerContext, viewerContext,
queryContext, queryContext,
entity, entity,
EntityAuthorizationAction.READ EntityAuthorizationAction.READ,
metricsAdapter
); );
} }
@@ -168,14 +175,16 @@ export default abstract class EntityPrivacyPolicy<
async authorizeUpdateAsync( async authorizeUpdateAsync(
viewerContext: TViewerContext, viewerContext: TViewerContext,
queryContext: EntityQueryContext, queryContext: EntityQueryContext,
entity: TEntity entity: TEntity,
metricsAdapter: IEntityMetricsAdapter
): Promise<TEntity> { ): Promise<TEntity> {
return await this.authorizeForRulesetAsync( return await this.authorizeForRulesetAsync(
this.updateRules, this.updateRules,
viewerContext, viewerContext,
queryContext, queryContext,
entity, entity,
EntityAuthorizationAction.UPDATE EntityAuthorizationAction.UPDATE,
metricsAdapter
); );
} }
@@ -190,14 +199,16 @@ export default abstract class EntityPrivacyPolicy<
async authorizeDeleteAsync( async authorizeDeleteAsync(
viewerContext: TViewerContext, viewerContext: TViewerContext,
queryContext: EntityQueryContext, queryContext: EntityQueryContext,
entity: TEntity entity: TEntity,
metricsAdapter: IEntityMetricsAdapter
): Promise<TEntity> { ): Promise<TEntity> {
return await this.authorizeForRulesetAsync( return await this.authorizeForRulesetAsync(
this.deleteRules, this.deleteRules,
viewerContext, viewerContext,
queryContext, queryContext,
entity, entity,
EntityAuthorizationAction.DELETE EntityAuthorizationAction.DELETE,
metricsAdapter
); );
} }
@@ -206,48 +217,95 @@ export default abstract class EntityPrivacyPolicy<
viewerContext: TViewerContext, viewerContext: TViewerContext,
queryContext: EntityQueryContext, queryContext: EntityQueryContext,
entity: TEntity, entity: TEntity,
action: EntityAuthorizationAction action: EntityAuthorizationAction,
metricsAdapter: IEntityMetricsAdapter
): Promise<TEntity> { ): Promise<TEntity> {
const privacyPolicyEvaluator = this.getPrivacyPolicyEvaluator(viewerContext); const privacyPolicyEvaluator = this.getPrivacyPolicyEvaluator(viewerContext);
switch (privacyPolicyEvaluator.mode) { switch (privacyPolicyEvaluator.mode) {
case EntityPrivacyPolicyEvaluationMode.ENFORCE: case EntityPrivacyPolicyEvaluationMode.ENFORCE:
return await this.authorizeForRulesetInnerAsync(
ruleset,
viewerContext,
queryContext,
entity,
action
);
case EntityPrivacyPolicyEvaluationMode.ENFORCE_AND_LOG:
try { try {
return await this.authorizeForRulesetInnerAsync( const result = await this.authorizeForRulesetInnerAsync(
ruleset, ruleset,
viewerContext, viewerContext,
queryContext, queryContext,
entity, entity,
action action
); );
metricsAdapter.logAuthorizationEvent({
entityClassName: entity.constructor.name,
action,
evaluationResult: EntityMetricsAuthorizationResult.ALLOW,
privacyPolicyEvaluationMode: privacyPolicyEvaluator.mode,
});
return result;
} catch (e) {
if (!(e instanceof EntityNotAuthorizedError)) {
throw e;
}
metricsAdapter.logAuthorizationEvent({
entityClassName: entity.constructor.name,
action,
evaluationResult: EntityMetricsAuthorizationResult.DENY,
privacyPolicyEvaluationMode: privacyPolicyEvaluator.mode,
});
throw e;
}
case EntityPrivacyPolicyEvaluationMode.ENFORCE_AND_LOG:
try {
const result = await this.authorizeForRulesetInnerAsync(
ruleset,
viewerContext,
queryContext,
entity,
action
);
metricsAdapter.logAuthorizationEvent({
entityClassName: entity.constructor.name,
action,
evaluationResult: EntityMetricsAuthorizationResult.ALLOW,
privacyPolicyEvaluationMode: privacyPolicyEvaluator.mode,
});
return result;
} catch (e) { } catch (e) {
if (!(e instanceof EntityNotAuthorizedError)) { if (!(e instanceof EntityNotAuthorizedError)) {
throw e; throw e;
} }
privacyPolicyEvaluator.denyHandler(e); privacyPolicyEvaluator.denyHandler(e);
metricsAdapter.logAuthorizationEvent({
entityClassName: entity.constructor.name,
action,
evaluationResult: EntityMetricsAuthorizationResult.DENY,
privacyPolicyEvaluationMode: privacyPolicyEvaluator.mode,
});
throw e; throw e;
} }
case EntityPrivacyPolicyEvaluationMode.DRY_RUN: case EntityPrivacyPolicyEvaluationMode.DRY_RUN:
try { try {
return await this.authorizeForRulesetInnerAsync( const result = await this.authorizeForRulesetInnerAsync(
ruleset, ruleset,
viewerContext, viewerContext,
queryContext, queryContext,
entity, entity,
action action
); );
metricsAdapter.logAuthorizationEvent({
entityClassName: entity.constructor.name,
action,
evaluationResult: EntityMetricsAuthorizationResult.ALLOW,
privacyPolicyEvaluationMode: privacyPolicyEvaluator.mode,
});
return result;
} catch (e) { } catch (e) {
if (!(e instanceof EntityNotAuthorizedError)) { if (!(e instanceof EntityNotAuthorizedError)) {
throw e; throw e;
} }
privacyPolicyEvaluator.denyHandler(e); privacyPolicyEvaluator.denyHandler(e);
metricsAdapter.logAuthorizationEvent({
entityClassName: entity.constructor.name,
action,
evaluationResult: EntityMetricsAuthorizationResult.DENY,
privacyPolicyEvaluationMode: privacyPolicyEvaluator.mode,
});
return entity; return entity;
} }
} }
@@ -261,11 +319,17 @@ export default abstract class EntityPrivacyPolicy<
action: EntityAuthorizationAction action: EntityAuthorizationAction
): Promise<TEntity> { ): Promise<TEntity> {
for (let i = 0; i < ruleset.length; i++) { for (let i = 0; i < ruleset.length; i++) {
const rule = ruleset[i]; const rule = ruleset[i]!;
const ruleEvaluationResult = await rule.evaluateAsync(viewerContext, queryContext, entity); const ruleEvaluationResult = await rule.evaluateAsync(viewerContext, queryContext, entity);
switch (ruleEvaluationResult) { switch (ruleEvaluationResult) {
case RuleEvaluationResult.DENY: case RuleEvaluationResult.DENY:
throw new EntityNotAuthorizedError(entity, viewerContext, action, i); throw new EntityNotAuthorizedError<
TFields,
TID,
TViewerContext,
TEntity,
TSelectedFields
>(entity, viewerContext, action, i);
case RuleEvaluationResult.SKIP: case RuleEvaluationResult.SKIP:
continue; continue;
case RuleEvaluationResult.ALLOW: case RuleEvaluationResult.ALLOW:
@@ -275,6 +339,11 @@ export default abstract class EntityPrivacyPolicy<
} }
} }
throw new EntityNotAuthorizedError(entity, viewerContext, action, -1); throw new EntityNotAuthorizedError<TFields, TID, TViewerContext, TEntity, TSelectedFields>(
entity,
viewerContext,
action,
-1
);
} }
} }
+55 -1
View File
@@ -1,6 +1,12 @@
import assert from 'assert';
import EntityQueryContextProvider from './EntityQueryContextProvider'; import EntityQueryContextProvider from './EntityQueryContextProvider';
export type PostCommitCallback = (...args: any) => Promise<any>; export type PostCommitCallback = (...args: any) => Promise<any>;
export type PreCommitCallback = (
queryContext: EntityTransactionalQueryContext,
...args: any
) => Promise<any>;
/** /**
* Entity framework representation of transactional and non-transactional database * Entity framework representation of transactional and non-transactional database
@@ -18,7 +24,7 @@ export abstract class EntityQueryContext {
return this.queryInterface; return this.queryInterface;
} }
abstract async runInTransactionIfNotInTransactionAsync<T>( abstract runInTransactionIfNotInTransactionAsync<T>(
transactionScope: (queryContext: EntityTransactionalQueryContext) => Promise<T> transactionScope: (queryContext: EntityTransactionalQueryContext) => Promise<T>
): Promise<T>; ): Promise<T>;
} }
@@ -43,13 +49,61 @@ export class EntityNonTransactionalQueryContext extends EntityQueryContext {
} }
export class EntityTransactionalQueryContext extends EntityQueryContext { export class EntityTransactionalQueryContext extends EntityQueryContext {
private readonly postCommitInvalidationCallbacks: PostCommitCallback[] = [];
private readonly postCommitCallbacks: PostCommitCallback[] = []; private readonly postCommitCallbacks: PostCommitCallback[] = [];
private readonly preCommitCallbacks: { callback: PreCommitCallback; order: number }[] = [];
/**
* Schedule a pre-commit callback. These will be run within the transaction right before it is
* committed, and will be run in the order specified. Ordering of callbacks scheduled with the
* same value for the order parameter is undefined within that ordering group.
* @param callback - callback to schedule
* @param order - order in which this should be run relative to other scheduled pre-commit callbacks,
* with higher numbers running later than lower numbers.
*/
public appendPreCommitCallback(callback: PreCommitCallback, order: number): void {
assert(
order >= Number.MIN_SAFE_INTEGER && order <= Number.MAX_SAFE_INTEGER,
`Invalid order specified: ${order}`
);
this.preCommitCallbacks.push({ callback, order });
}
/**
* Schedule a post-commit cache invalidation callback. These are run before normal
* post-commit callbacks in order to have cache consistency in normal post-commit callbacks.
* @param callback - callback to schedule
*/
public appendPostCommitInvalidationCallback(callback: PostCommitCallback): void {
this.postCommitInvalidationCallbacks.push(callback);
}
/**
* Schedule a post-commit callback. These will be run after the transaction has
* been committed.
* @param callback - callback to schedule
*/
public appendPostCommitCallback(callback: PostCommitCallback): void { public appendPostCommitCallback(callback: PostCommitCallback): void {
this.postCommitCallbacks.push(callback); this.postCommitCallbacks.push(callback);
} }
public async runPreCommitCallbacksAsync(): Promise<void> {
const callbacks = [...this.preCommitCallbacks]
.sort((a, b) => a.order - b.order)
.map((c) => c.callback);
this.preCommitCallbacks.length = 0;
for (const callback of callbacks) {
await callback(this);
}
}
public async runPostCommitCallbacksAsync(): Promise<void> { public async runPostCommitCallbacksAsync(): Promise<void> {
const invalidationCallbacks = [...this.postCommitInvalidationCallbacks];
this.postCommitInvalidationCallbacks.length = 0;
await Promise.all(invalidationCallbacks.map((callback) => callback()));
const callbacks = [...this.postCommitCallbacks]; const callbacks = [...this.postCommitCallbacks];
this.postCommitCallbacks.length = 0; this.postCommitCallbacks.length = 0;
await Promise.all(callbacks.map((callback) => callback())); await Promise.all(callbacks.map((callback) => callback()));
@@ -38,6 +38,7 @@ export default abstract class EntityQueryContextProvider {
>()(async (queryInterface) => { >()(async (queryInterface) => {
const queryContext = new EntityTransactionalQueryContext(queryInterface); const queryContext = new EntityTransactionalQueryContext(queryInterface);
const result = await transactionScope(queryContext); const result = await transactionScope(queryContext);
await queryContext.runPreCommitCallbacksAsync();
return [result, queryContext]; return [result, queryContext];
}); });
await queryContext.runPostCommitCallbacksAsync(); await queryContext.runPostCommitCallbacksAsync();
@@ -0,0 +1,112 @@
import { Result } from '@expo/results';
import EntityLoader from './EntityLoader';
import EntityPrivacyPolicy from './EntityPrivacyPolicy';
import ReadonlyEntity from './ReadonlyEntity';
import ViewerContext from './ViewerContext';
import { mapMap } from './utils/collections/maps';
/**
* An interface that knows how to load many objects from a cache by load params and invalidate
* those same load params.
*/
export interface ISecondaryEntityCache<TFields, TLoadParams> {
/**
* Read-through cache function.
* @param loadParamsArray - array of loadParams to load from the cache
* @param fetcher - closure used to provide underlying data source objects for loadParams
* @returns map from loadParams to the entity field object
*/
loadManyThroughAsync(
loadParamsArray: readonly Readonly<TLoadParams>[],
fetcher: (
fetcherLoadParamsArray: readonly Readonly<TLoadParams>[]
) => Promise<ReadonlyMap<Readonly<TLoadParams>, Readonly<TFields> | null>>
): Promise<ReadonlyMap<Readonly<TLoadParams>, Readonly<TFields> | null>>;
/**
* Invalidate the cache for objects cached by loadParams.
*
* @param loadParamsArray - array of load params objects to invalidate
*/
invalidateManyAsync(loadParamsArray: readonly Readonly<TLoadParams>[]): Promise<void>;
}
/**
* A secondary cache loader allows for arbitrary cache keying for load params, which are a set of params used to load
* a single entity field object.
*
* Note that this cache cannot be automatically invalidated like other entity caches so it must be manually invalidated
* when the underlying data of a cache key could be stale.
*
* This is most commonly used to further optimize hot paths that cannot make use of normal entity cache loading
* due to use of a non-unique-field-based {@link EntityLoader} method like `loadManyByFieldEqualityConjunctionAsync` or
* `loadManyByRawWhereClauseAsync`.
*/
export default abstract class EntitySecondaryCacheLoader<
TLoadParams,
TFields,
TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
TPrivacyPolicy extends EntityPrivacyPolicy<
TFields,
TID,
TViewerContext,
TEntity,
TSelectedFields
>,
TSelectedFields extends keyof TFields = keyof TFields
> {
constructor(
private readonly secondaryEntityCache: ISecondaryEntityCache<TFields, TLoadParams>,
protected readonly entityLoader: EntityLoader<
TFields,
TID,
TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
>
) {}
/**
* Load many by load params objects
*
* @param loadParamsArray - array of loadParams to load through the cache
*/
public async loadManyAsync(
loadParamsArray: readonly Readonly<TLoadParams>[]
): Promise<ReadonlyMap<Readonly<TLoadParams>, Result<TEntity> | null>> {
const loadParamsToFieldObjects = await this.secondaryEntityCache.loadManyThroughAsync(
loadParamsArray,
this.fetchObjectsFromDatabaseAsync.bind(this)
);
// convert value to and from array to reuse complex code
const entitiesMap = await this.entityLoader.constructAndAuthorizeEntitiesAsync(
mapMap(loadParamsToFieldObjects, (fieldObject) => (fieldObject ? [fieldObject] : []))
);
return mapMap(entitiesMap, (fieldObjects) => fieldObjects[0] ?? null);
}
/**
* Invalidate the cache for objects cached by loadParams.
*
* @param loadParamsArray - array of load params objects to invalidate
*/
public async invalidateManyAsync(
loadParamsArray: readonly Readonly<TLoadParams>[]
): Promise<void> {
await this.secondaryEntityCache.invalidateManyAsync(loadParamsArray);
}
/**
* Load through method called to fetch objects when load params objects are not in the cache
*
* @param loadParamsArray - array of load params objects to load
*/
protected abstract fetchObjectsFromDatabaseAsync(
loadParamsArray: readonly Readonly<TLoadParams>[]
): Promise<ReadonlyMap<Readonly<Readonly<TLoadParams>>, Readonly<TFields> | null>>;
}
+6 -79
View File
@@ -5,7 +5,7 @@ import EntityAssociationLoader from './EntityAssociationLoader';
import { EntityCompanionDefinition } from './EntityCompanionProvider'; import { EntityCompanionDefinition } from './EntityCompanionProvider';
import EntityLoader from './EntityLoader'; import EntityLoader from './EntityLoader';
import EntityPrivacyPolicy from './EntityPrivacyPolicy'; import EntityPrivacyPolicy from './EntityPrivacyPolicy';
import { EntityQueryContext, EntityTransactionalQueryContext } from './EntityQueryContext'; import { EntityQueryContext } from './EntityQueryContext';
import ViewerContext from './ViewerContext'; import ViewerContext from './ViewerContext';
import { pick } from './entityUtils'; import { pick } from './entityUtils';
@@ -19,7 +19,7 @@ import { pick } from './entityUtils';
*/ */
export default abstract class ReadonlyEntity< export default abstract class ReadonlyEntity<
TFields, TFields,
TID, TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext, TViewerContext extends ViewerContext,
TSelectedFields extends keyof TFields = keyof TFields TSelectedFields extends keyof TFields = keyof TFields
> { > {
@@ -37,8 +37,9 @@ export default abstract class ReadonlyEntity<
private readonly viewerContext: TViewerContext, private readonly viewerContext: TViewerContext,
private readonly databaseFields: Readonly<TFields> private readonly databaseFields: Readonly<TFields>
) { ) {
const companionDefinition = (this const companionDefinition = (
.constructor as any).getCompanionDefinition() as EntityCompanionDefinition< this.constructor as any
).getCompanionDefinition() as EntityCompanionDefinition<
TFields, TFields,
TID, TID,
TViewerContext, TViewerContext,
@@ -118,80 +119,6 @@ export default abstract class ReadonlyEntity<
return { ...this.databaseFields }; return { ...this.databaseFields };
} }
/**
* Get the regular (non-transactional) query context for this entity.
* @param viewerContext - viewer context of calling user
*/
static getQueryContext<
TMFields,
TMID,
TMViewerContext extends ViewerContext,
TMViewerContext2 extends TMViewerContext,
TMEntity extends ReadonlyEntity<TMFields, TMID, TMViewerContext, TMSelectedFields>,
TMPrivacyPolicy extends EntityPrivacyPolicy<
TMFields,
TMID,
TMViewerContext,
TMEntity,
TMSelectedFields
>,
TMSelectedFields extends keyof TMFields = keyof TMFields
>(
this: IEntityClass<
TMFields,
TMID,
TMViewerContext,
TMEntity,
TMPrivacyPolicy,
TMSelectedFields
>,
viewerContext: TMViewerContext2
): EntityQueryContext {
return viewerContext
.getViewerScopedEntityCompanionForClass(this)
.getQueryContextProvider()
.getQueryContext();
}
/**
* Start a transaction and execute the provided transaction-scoped closure within the transaction.
* @param viewerContext - viewer context of calling user
* @param transactionScope - async callback to execute within the transaction
*/
static async runInTransactionAsync<
TResult,
TMFields,
TMID,
TMViewerContext extends ViewerContext,
TMViewerContext2 extends TMViewerContext,
TMEntity extends ReadonlyEntity<TMFields, TMID, TMViewerContext, TMSelectedFields>,
TMPrivacyPolicy extends EntityPrivacyPolicy<
TMFields,
TMID,
TMViewerContext,
TMEntity,
TMSelectedFields
>,
TMSelectedFields extends keyof TMFields = keyof TMFields
>(
this: IEntityClass<
TMFields,
TMID,
TMViewerContext,
TMEntity,
TMPrivacyPolicy,
TMSelectedFields
>,
viewerContext: TMViewerContext2,
transactionScope: (queryContext: EntityTransactionalQueryContext) => Promise<TResult>
): Promise<TResult> {
return await viewerContext
.getViewerScopedEntityCompanionForClass(this)
.getQueryContextProvider()
.getQueryContext()
.runInTransactionIfNotInTransactionAsync(transactionScope);
}
/** /**
* Vend loader for loading an entity in a given query context. * Vend loader for loading an entity in a given query context.
* @param viewerContext - viewer context of loading user * @param viewerContext - viewer context of loading user
@@ -199,7 +126,7 @@ export default abstract class ReadonlyEntity<
*/ */
static loader< static loader<
TMFields, TMFields,
TMID, TMID extends NonNullable<TMFields[TMSelectedFields]>,
TMViewerContext extends ViewerContext, TMViewerContext extends ViewerContext,
TMViewerContext2 extends TMViewerContext, TMViewerContext2 extends TMViewerContext,
TMEntity extends ReadonlyEntity<TMFields, TMID, TMViewerContext, TMSelectedFields>, TMEntity extends ReadonlyEntity<TMFields, TMID, TMViewerContext, TMSelectedFields>,
+31 -2
View File
@@ -1,6 +1,7 @@
import { IEntityClass } from './Entity'; import { IEntityClass } from './Entity';
import EntityCompanionProvider from './EntityCompanionProvider'; import EntityCompanionProvider, { DatabaseAdapterFlavor } from './EntityCompanionProvider';
import EntityPrivacyPolicy from './EntityPrivacyPolicy'; import EntityPrivacyPolicy from './EntityPrivacyPolicy';
import { EntityQueryContext, EntityTransactionalQueryContext } from './EntityQueryContext';
import ReadonlyEntity from './ReadonlyEntity'; import ReadonlyEntity from './ReadonlyEntity';
import ViewerScopedEntityCompanion from './ViewerScopedEntityCompanion'; import ViewerScopedEntityCompanion from './ViewerScopedEntityCompanion';
import ViewerScopedEntityCompanionProvider from './ViewerScopedEntityCompanionProvider'; import ViewerScopedEntityCompanionProvider from './ViewerScopedEntityCompanionProvider';
@@ -27,7 +28,7 @@ export default class ViewerContext {
getViewerScopedEntityCompanionForClass< getViewerScopedEntityCompanionForClass<
TMFields, TMFields,
TMID, TMID extends NonNullable<TMFields[TMSelectedFields]>,
TMViewerContext extends ViewerContext, TMViewerContext extends ViewerContext,
TMEntity extends ReadonlyEntity<TMFields, TMID, TMViewerContext, TMSelectedFields>, TMEntity extends ReadonlyEntity<TMFields, TMID, TMViewerContext, TMSelectedFields>,
TMPrivacyPolicy extends EntityPrivacyPolicy< TMPrivacyPolicy extends EntityPrivacyPolicy<
@@ -60,4 +61,32 @@ export default class ViewerContext {
entityClass.getCompanionDefinition() entityClass.getCompanionDefinition()
); );
} }
/**
* Get the regular (non-transactional) query context for a database adaptor flavor.
* @param databaseAdaptorFlavor - database adaptor flavor
*/
getQueryContextForDatabaseAdaptorFlavor(
databaseAdaptorFlavor: DatabaseAdapterFlavor
): EntityQueryContext {
return this.entityCompanionProvider
.getQueryContextProviderForDatabaseAdaptorFlavor(databaseAdaptorFlavor)
.getQueryContext();
}
/**
* Run a transaction of specified database adaptor flavor and execute the provided
* transaction-scoped closure within the transaction.
* @param databaseAdaptorFlavor - databaseAdaptorFlavor
* @param transactionScope - async callback to execute within the transaction
*/
async runInTransactionForDatabaseAdaptorFlavorAsync<TResult>(
databaseAdaptorFlavor: DatabaseAdapterFlavor,
transactionScope: (queryContext: EntityTransactionalQueryContext) => Promise<TResult>
): Promise<TResult> {
return await this.entityCompanionProvider
.getQueryContextProviderForDatabaseAdaptorFlavor(databaseAdaptorFlavor)
.getQueryContext()
.runInTransactionIfNotInTransactionAsync(transactionScope);
}
} }
@@ -5,6 +5,7 @@ import ReadonlyEntity from './ReadonlyEntity';
import ViewerContext from './ViewerContext'; import ViewerContext from './ViewerContext';
import ViewerScopedEntityLoaderFactory from './ViewerScopedEntityLoaderFactory'; import ViewerScopedEntityLoaderFactory from './ViewerScopedEntityLoaderFactory';
import ViewerScopedEntityMutatorFactory from './ViewerScopedEntityMutatorFactory'; import ViewerScopedEntityMutatorFactory from './ViewerScopedEntityMutatorFactory';
import IEntityMetricsAdapter from './metrics/IEntityMetricsAdapter';
/** /**
* Provides a simpler API for loading and mutating entities by injecting the {@link ViewerContext} * Provides a simpler API for loading and mutating entities by injecting the {@link ViewerContext}
@@ -12,7 +13,7 @@ import ViewerScopedEntityMutatorFactory from './ViewerScopedEntityMutatorFactory
*/ */
export default class ViewerScopedEntityCompanion< export default class ViewerScopedEntityCompanion<
TFields, TFields,
TID, TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext, TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>, TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
TPrivacyPolicy extends EntityPrivacyPolicy< TPrivacyPolicy extends EntityPrivacyPolicy<
@@ -76,4 +77,11 @@ export default class ViewerScopedEntityCompanion<
getQueryContextProvider(): EntityQueryContextProvider { getQueryContextProvider(): EntityQueryContextProvider {
return this.entityCompanion.getQueryContextProvider(); return this.entityCompanion.getQueryContextProvider();
} }
/**
* Get the {@link IEntityMetricsAdapter} for this companion.
*/
getMetricsAdapter(): IEntityMetricsAdapter {
return this.entityCompanion.getMetricsAdapter();
}
} }
@@ -23,7 +23,7 @@ export default class ViewerScopedEntityCompanionProvider {
*/ */
getViewerScopedCompanionForEntity< getViewerScopedCompanionForEntity<
TFields, TFields,
TID, TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext, TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>, TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
TPrivacyPolicy extends EntityPrivacyPolicy< TPrivacyPolicy extends EntityPrivacyPolicy<
@@ -10,7 +10,7 @@ import ViewerContext from './ViewerContext';
*/ */
export default class ViewerScopedEntityLoaderFactory< export default class ViewerScopedEntityLoaderFactory<
TFields, TFields,
TID, TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext, TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>, TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
TPrivacyPolicy extends EntityPrivacyPolicy< TPrivacyPolicy extends EntityPrivacyPolicy<
@@ -10,7 +10,7 @@ import ViewerContext from './ViewerContext';
*/ */
export default class ViewerScopedEntityMutatorFactory< export default class ViewerScopedEntityMutatorFactory<
TFields, TFields,
TID, TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext, TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>, TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
TPrivacyPolicy extends EntityPrivacyPolicy< TPrivacyPolicy extends EntityPrivacyPolicy<
@@ -288,6 +288,8 @@ describe(EnforcingEntityLoader, () => {
'invalidateFieldsAsync', 'invalidateFieldsAsync',
'invalidateEntityAsync', 'invalidateEntityAsync',
'tryConstructEntities', 'tryConstructEntities',
'validateFieldValues',
'constructAndAuthorizeEntitiesAsync',
]; ];
expect(loaderProperties).toEqual(expect.arrayContaining(knownLoaderOnlyDifferences)); expect(loaderProperties).toEqual(expect.arrayContaining(knownLoaderOnlyDifferences));
Loaded 100 of 156 files, more files were not shown because too many files have changed in this diff. Show more