Compare commits

...
Author SHA1 Message Date
Will Schurman 659e08386f v0.33.0 2023-10-06 16:00:57 -07:00
Will Schurman aad94aca47 feat: add loadManyByIDsNullableAsync loader method (#214) 2023-10-04 15:28:28 -07:00
Will Schurman d2a4405fa1 v0.32.0 2023-06-09 20:13:23 -05:00
Will Schurman 503a2fb6a8 fix: remove unused field from redis configuration (#213) 2023-06-09 17:08:57 -07:00
Will Schurman 3a00a43036 chore: fix lint (#212) 2023-06-09 11:38:07 -07:00
Will Schurman 05b14843a6 chore: upgrade dependencies (#211) 2023-06-09 11:33:58 -07:00
Will Schurman 6f8020fbf8 chore: refactor common cache logic into GenericEntityCacheAdapter (#209) 2023-06-09 11:32:34 -07:00
Will Schurman 4b18010d42 feat: entity companion definition static thunk (#210) 2023-06-09 11:20:24 -07:00
Will Schurman 2069a0d9fd feat: add ability to specify knex transaction config (#207) 2023-04-20 18:10:07 -07:00
James Ide 83fe9cdea5 v0.31.1 2023-03-06 22:08:09 -08:00
James Ide 1934216b70 [rfc] loadFirstByFieldEqualityConjunction, a convenience method (#206) 2023-01-24 16:41:06 -08:00
Will Schurman 92f5529eba v0.31.0 2023-01-18 21:51:04 -08:00
Will Schurman 67732d6847 chore: upgrade ioredis to 5.2.5 (#205) 2023-01-18 21:49:52 -08:00
Will Schurman bb0cd51eb5 chore: upgrade knex to 2.4.1 for CVE-2016-20018 (#204) 2023-01-18 14:02:47 -08:00
Will Schurman abd86b91d3 v0.30.0 2022-12-23 15:42:23 -07:00
Will Schurman b5f734b1e1 [www] Upgrade knex to 2.3.0 (#203) 2022-12-23 12:13:46 -08:00
Will Schurman b11bd24351 v0.29.0 2022-10-06 11:48:07 -07:00
Will Schurman 684f91b1f2 feat: add example of redis mget batching using @expo/batcher (#199) 2022-10-06 11:42:22 -07:00
Will Schurman e381f5b2ac feat: change redis cache adapter to use redis-like interface (#200) 2022-10-06 11:34:28 -07:00
James Ide 69692c3b64 docs: fix typo descripting afterAll trigger (#198)
The docblock for the afterAll trigger used to say that the triggers were run before create/update/delete operations. s/before/after/
2022-10-05 14:53:12 -07:00
Will Schurman dbf1e03039 v0.28.0 2022-09-13 11:23:26 -07:00
Will Schurman 0fb74137f2 chore: upgrade integration test postgres to v14 (#195) 2022-09-13 11:18:56 -07:00
Will Schurman a77b914ad6 feat: nested transactions (#194) 2022-09-13 10:47:57 -07:00
Will Schurman acbad826a3 feat: add BigIntField (#193) 2022-08-31 18:48:48 -07:00
Will Schurman 8bdd9f7ac2 docs: improve documentation to FieldEqualityCondition (#192) 2022-08-24 13:44:48 -07:00
Will Schurman bc6a0d4310 chore: update readme for easier release (#191) 2022-08-09 13:13:13 -07:00
Will Schurman a067a44230 v0.27.0 (#190) 2022-08-09 13:07:40 -07:00
Will Schurman bffa7ca870 ci: fix typedoc (#189) 2022-08-09 12:35:38 -07:00
Will Schurman 847c20fabe ci: fix node version in docs action (#188) 2022-08-09 12:09:39 -07:00
Will Schurman 8a98f35b91 chore: upgrade lerna (#187) 2022-08-09 11:41:15 -07:00
Will Schurman 7829d90f4b ci: fix github actions docs workflow branch (#186) 2022-08-09 11:06:36 -07:00
Juwan Wheatley 2817d78392 feat: add orderByRaw to loadManyByRawWhereClauseAsync (#185)
* feat: add orderByRaw to querySelectionModifiers

* implement PR feedback

* fix tsc

* restrict orderByRaw to loadManyByRawWhereClauseAsync

* create new type for orderbyraw param
2022-08-09 13:44:07 -04:00
Quinlan Jung af81981772 docs: update readme deployment (#184) 2022-07-29 23:05:21 +02:00
Quinlan Jung 0c6aa30cdd v0.26.1 (#183)
* v0.26.1

* manually add changelog
2022-07-29 22:47:58 +02:00
Quinlan JungandWill Schurman 8530fb0983 [fix/feat] recache values in higher layers for composed cache adapter (#182)
* [fix/feat] recache values in lower layers for composed cache adapter

* Update packages/entity/src/ComposedEntityCacheAdapter.ts

Co-authored-by: Will Schurman <[email protected]>

* pr feedback

* Update packages/entity/src/ComposedEntityCacheAdapter.ts

Co-authored-by: Will Schurman <[email protected]>

* pr feedback

Co-authored-by: Will Schurman <[email protected]>
2022-07-28 03:46:00 +02:00
Will Schurman ce0ac6f8cb docs: add docblock for EntityAssociationLoader and fix other docs (#181) 2022-07-25 11:45:16 -07:00
Will Schurman c8f81a76c3 v0.26.0 (#180) 2022-07-14 14:46:42 -07:00
Will Schurman a0c35ddd7b feat: add EntityEdgeDeletionBehavior.SET_NULL_INVALIDATE_CACHE_ONLY (#178) 2022-07-14 14:15:41 -07:00
Will Schurman 25d14bf8c1 fix: fix workflow branch specification (#179) 2022-07-14 12:10:56 -07:00
James Ide 0dbf5e98bb [tests] Run tests on Node 18 instead of Node 12 (#174)
Node 16 is LTS now and 12 is unsupported. 18 is also now released and we will one day run on it so this makes sure one of our major dependencies works on Node 18.

All tests pass, no changes needed.
2022-06-28 17:24:42 -07:00
James Ide 4347d9d577 [npm][ts] Upgrade all minor npm deps incl. TS 4.7 (#176)
Why
---
There were some automated security notices about outdated npm package versions, many of which often can be addressed with minor dependency upgrades.

How
---
Deleted and regenerated yarn.lock, which affects this repo but not consumers of these packages. However, TypeScript 4.7 has a change where it considers stringifying a Symbol to be a potential error. This commit explicitly stringifies keys (entity field names).

I opted for `String(fieldName)` over constraining the field name with `N extends string & keyof TFields` because adding `string &` is "viral" in the sense that lots of files including users of Entity would need to add `string &`. Also, using Symbol field names probably would work fine, they just need to be stringified before printing them.

There were also some Koa router type changes that treat params as `string | undefined` so we need to use `!` in the Entity example router.

Test Plan
---
tsc + unit tests
2022-06-21 00:18:15 -07:00
Will Schurman 9a91b19466 chore: clean up metrics API a bit and add docblocks (#175) 2022-06-08 17:32:07 -07:00
Will Schurman cb7aaad979 docs: update docblock for EntityQueryContext classes (#173) 2022-05-11 11:22:49 -07:00
James Ide 9fcfbf746a feat: update ioredis to v5, make it a devDependency of the cache packages (#172)
Why: ioredis v5 ships with its own TypeScript declarations. The breaking changes from v4 to v5 affect the code that instantiates ioredis, not the code that uses it. As such, I believe that entity is still technically compatible with ioredis v4, but for the sake of keeping our test matrix smaller, this commit uses just ioredis v5 in tests and makes v5 a peer dependency of `entity-cache-adapter-redis`.

Additionally ioredis was a dependency of `entity-cache-adapter-redis`. I changed it to a devDependency and changed the import to `import type` since the package is used only for type checking.

ioredis v5.0.0 changelog: https://github.com/luin/ioredis/releases/tag/v5.0.0. Notable it drops support for Node 10 and for custom Promise libraries, neither of which we use.

Test Plan: building all packages and testing them with Lerna succeeds
2022-04-11 09:44:57 -07:00
James Ide 9c44e8e8d4 Upgrade TS to 4.6.x, use knex 1.0.5 during testing (#171)
Why: Expo's API server will use TS 4.6 and knex 1.0.5. This change makes it so that we run entity's CI tests against these new dependencies.

How: Upgraded the version of typescript in the root package.json. Modified yarn.lock to use knex 1.0.5 instead of updating the db adapter's package.json since knex 1.0.2 still works fine.

Test Plan: Lerna runs tsc in every package (no errors). Unit tests in CI.
2022-04-11 09:16:19 -07:00
Will Schurman d731b0ddb7 v0.25.3 2022-03-12 14:32:00 -07:00
Will Schurman 293868b1a7 fix: lock lru-cache to v6.0.0 (#170) 2022-03-12 13:29:16 -08:00
Will Schurman 02d2f24bb6 v0.25.2 (#169) 2022-03-11 17:09:48 -08:00
Will Schurman e76ca8c2f7 fix: lru-cache yarn workspace version resolution (#168) 2022-03-11 17:07:02 -08:00
Will Schurman 0736e20987 v0.25.1 (#167) 2022-03-10 15:26:07 -08:00
Will Schurman 5c82edcd9f fix: add composed cache to index (#166) 2022-03-10 15:24:09 -08:00
Will Schurman 96e1023d40 v0.25.0 (#165) 2022-03-10 15:05:15 -08:00
Will Schurman 3a81a4c059 feat: privacy policy evaluation context (#162) 2022-03-10 14:44:12 -08:00
Will Schurman 6b6e70e75f feat: composed cache adapter, composed secondary cache, local memory secondary cache (#164) 2022-03-10 14:42:01 -08:00
Quinlan Jung bc378b6c79 chore: better variable and method naming (#160) 2022-02-16 18:37:51 -08:00
Quinlan Jung fefa4100fc v0.24.0 (#159)
* v0.24.0

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

* some pr feedback

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

* fix lint

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

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

* integ tests

* fix codecov
2022-02-07 15:04:02 -08:00
176 changed files with 12147 additions and 7834 deletions

No files matched your search

+3 -3
View File
@@ -3,16 +3,16 @@ name: docs
on: on:
push: push:
branches: branches:
- master - main
jobs: jobs:
deploy: deploy:
runs-on: ubuntu-18.04 runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- uses: actions/setup-node@v1 - uses: actions/setup-node@v1
with: with:
node-version: 12.x node-version: 18.x
- name: Cache Node.js modules - name: Cache Node.js modules
uses: actions/cache@v1 uses: actions/cache@v1
with: with:
+2 -2
View File
@@ -2,7 +2,7 @@ name: tests
on: on:
push: push:
branches: [ master ] branches: [ main ]
pull_request: pull_request:
jobs: jobs:
@@ -14,7 +14,7 @@ jobs:
ref: ${{ github.event.pull_request.head.sha }} ref: ${{ github.event.pull_request.head.sha }}
- uses: actions/setup-node@v1 - uses: actions/setup-node@v1
with: with:
node-version: 12.x node-version: 18.x
- name: Cache Node.js modules - name: Cache Node.js modules
uses: actions/cache@v1 uses: actions/cache@v1
with: with:
+182
View File
@@ -3,6 +3,188 @@
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.33.0](https://github.com/expo/entity/compare/v0.32.0...v0.33.0) (2023-10-06)
### Features
* add loadManyByIDsNullableAsync loader method ([#214](https://github.com/expo/entity/issues/214)) ([aad94ac](https://github.com/expo/entity/commit/aad94aca479ac07cc7f76b5e1a122ff85a2d1542))
# [0.32.0](https://github.com/expo/entity/compare/v0.31.1...v0.32.0) (2023-06-10)
### Bug Fixes
* remove unused field from redis configuration ([#213](https://github.com/expo/entity/issues/213)) ([503a2fb](https://github.com/expo/entity/commit/503a2fb6a8d57f6a09f6da99daf5ab92fc3e7945))
### Features
* add ability to specify knex transaction config ([#207](https://github.com/expo/entity/issues/207)) ([2069a0d](https://github.com/expo/entity/commit/2069a0d9fd1d8f7d090a71a90a758b2429ea5dd3))
* entity companion definition static thunk ([#210](https://github.com/expo/entity/issues/210)) ([4b18010](https://github.com/expo/entity/commit/4b18010d42be50ef329f428b08330e21bf676586))
# [0.31.1](https://github.com/expo/entity/compare/v0.31.0...v0.31.1) (2023-03-06)
### Features
* add loadFirstByFieldEqualityConjunction, a convenience method ([#206](https://github.com/expo/entity/pull/206)) ([1934216](https://github.com/expo/entity/commit/1934216b70572cf2b9693e0f14939f49b90427dd))
# [0.31.0](https://github.com/expo/entity/compare/v0.30.0...v0.31.0) (2023-01-19)
**Note:** Version bump only for package root
# [0.30.0](https://github.com/expo/entity/compare/v0.29.0...v0.30.0) (2022-12-23)
**Note:** Version bump only for package root
# [0.29.0](https://github.com/expo/entity/compare/v0.28.0...v0.29.0) (2022-10-06)
### Features
* add example of redis mget batching using @expo/batcher ([#199](https://github.com/expo/entity/issues/199)) ([684f91b](https://github.com/expo/entity/commit/684f91b1f270851dbe331df2e2620664eea17266))
* change redis cache adapter to use redis-like interface ([#200](https://github.com/expo/entity/issues/200)) ([e381f5b](https://github.com/expo/entity/commit/e381f5b2acfc17dcade2cf6e8506fa1c1c255e73))
# [0.28.0](https://github.com/expo/entity/compare/v0.27.0...v0.28.0) (2022-09-13)
### Features
* add BigIntField ([#193](https://github.com/expo/entity/issues/193)) ([acbad82](https://github.com/expo/entity/commit/acbad826a37bb5a054b4dcf9be91d11aa5f7f29a))
* nested transactions ([#194](https://github.com/expo/entity/issues/194)) ([a77b914](https://github.com/expo/entity/commit/a77b914ad6dc018d1e309c9caf4971088a553ecf))
# [0.27.0](https://github.com/expo/entity/compare/v0.26.1...v0.27.0) (2022-08-09)
### Features
* add orderByRaw to loadManyByRawWhereClauseAsync ([#185](https://github.com/expo/entity/issues/185)) ([2817d78](https://github.com/expo/entity/commit/2817d78392dfecd0093111c24d581bdf01dba06d))
## [0.26.1](https://github.com/expo/entity/compare/v0.26.0...v0.26.1) (2022-07-28)
### Features
* recache to higher cache levels when loading from a composed cache ([#182](https://github.com/expo/entity/pull/182))
# [0.26.0](https://github.com/expo/entity/compare/v0.25.3...v0.26.0) (2022-07-14)
### Bug Fixes
* fix workflow branch specification ([#179](https://github.com/expo/entity/issues/179)) ([25d14bf](https://github.com/expo/entity/commit/25d14bf8c1688524ad488aa2b63221393a4f0b05))
### Features
* add EntityEdgeDeletionBehavior.SET_NULL_INVALIDATE_CACHE_ONLY ([#178](https://github.com/expo/entity/issues/178)) ([a0c35dd](https://github.com/expo/entity/commit/a0c35ddd7bca05d0c3f41dcf48e16d058918231c))
* update ioredis to v5, make it a devDependency of the cache packages ([#172](https://github.com/expo/entity/issues/172)) ([9fcfbf7](https://github.com/expo/entity/commit/9fcfbf746a61554cefedf7fb0f1a3a9056b58ffe))
## [0.25.3](https://github.com/expo/entity/compare/v0.25.2...v0.25.3) (2022-03-12)
### Bug Fixes
* lock lru-cache to v6.0.0 ([#170](https://github.com/expo/entity/issues/170)) ([293868b](https://github.com/expo/entity/commit/293868b1a73df9f749216af68b1ae94ac22ac2b9))
## [0.25.2](https://github.com/expo/entity/compare/v0.25.1...v0.25.2) (2022-03-12)
### Bug Fixes
* lru-cache yarn workspace version resolution ([#168](https://github.com/expo/entity/issues/168)) ([e76ca8c](https://github.com/expo/entity/commit/e76ca8c2f72da84aa372a64ef1898ade220ed0a8))
## [0.25.1](https://github.com/expo/entity/compare/v0.25.0...v0.25.1) (2022-03-10)
### Bug Fixes
* add composed cache to index ([#166](https://github.com/expo/entity/issues/166)) ([5c82edc](https://github.com/expo/entity/commit/5c82edcd9f26854878d29950a05852e117bbd723))
# [0.25.0](https://github.com/expo/entity/compare/v0.24.0...v0.25.0) (2022-03-10)
### Features
* composed cache adapter, composed secondary cache, local memory secondary cache ([#164](https://github.com/expo/entity/issues/164)) ([6b6e70e](https://github.com/expo/entity/commit/6b6e70e75f7e42a4dce13edeb406a4d88d9264f0))
* privacy policy evaluation context ([#162](https://github.com/expo/entity/issues/162)) ([3a81a4c](https://github.com/expo/entity/commit/3a81a4c059e2e995eb800e35e76b3d432c22b23f))
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
### Features
* add local memory cache package ([#158](https://github.com/expo/entity/issues/158)) ([85d3575](https://github.com/expo/entity/commit/85d35752fa0919a62efb776a762a6829e83ca7af))
### Chores
* Refactor cachers to use an abstract class and interface ([#157](https://github.com/expo/entity/pull/157))
# [0.23.0](https://github.com/expo/entity/compare/v0.22.0...v0.23.0) (2022-02-09)
### Breaking changes
* Move data transforming responsibilities to the CacheAdapter instead of the ReadThroughEntityCache. Affects any classes extending the EntityCacheAdapter and relying on the transformation behavior of the ReadThroughEntityCache. ([#153](https://github.com/expo/entity/pull/153))
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04) # [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
+4 -12
View File
@@ -4,7 +4,7 @@ Entity is a privacy-aware data layer for defining, caching, and authorizing acce
[![tests](https://github.com/expo/entity/workflows/tests/badge.svg)](https://github.com/expo/entity/actions?query=workflow%3Atests) [![tests](https://github.com/expo/entity/workflows/tests/badge.svg)](https://github.com/expo/entity/actions?query=workflow%3Atests)
[![docs](https://github.com/expo/entity/workflows/docs/badge.svg)](https://expo.github.io/entity/) [![docs](https://github.com/expo/entity/workflows/docs/badge.svg)](https://expo.github.io/entity/)
[![codecov](https://codecov.io/gh/expo/entity/branch/master/graph/badge.svg)](https://codecov.io/gh/expo/entity) [![codecov](https://codecov.io/gh/expo/entity/branch/main/graph/badge.svg)](https://codecov.io/gh/expo/entity)
[![npm](https://img.shields.io/npm/v/@expo/entity)](https://www.npmjs.com/package/@expo/entity) [![npm](https://img.shields.io/npm/v/@expo/entity)](https://www.npmjs.com/package/@expo/entity)
[![NPM](https://img.shields.io/npm/l/@expo/entity)](https://www.npmjs.com/package/@expo/entity) [![NPM](https://img.shields.io/npm/l/@expo/entity)](https://www.npmjs.com/package/@expo/entity)
@@ -80,18 +80,10 @@ Note: The entity framework instance should not be shared across multiple request
## Releasing ## Releasing
To release a new version, let's say `v1.0.0` for example: To release a new version:
1. `git checkout -b bump-v1.0.0` 1. `git checkout main`
1. `yarn lerna version minor -- --no-push --conventional-commits` 1. `yarn lerna publish [patch|minor|major] -- --conventional-commits`
1. `git push -u origin bump-v1.0.0`
1. Create a PR from the `bump-v1.0.0` branch, wait for tests, and commit the PR using GitHub interface.
1. `git checkout master && git pull`
1. `git tag -d v1.0.0`
1. `git tag v1.0.0`
1. `git push origin v1.0.0`
1. In GitHub release interface, create a new release from the tag, copy changelog changes to release description. 1. In GitHub release interface, create a new release from the tag, copy changelog changes to release description.
1. `yarn clean && yarn tsc`
1. In each public subpackage, run `npm publish` and enter 2FA token.
## License ## License
+1 -1
View File
@@ -4,5 +4,5 @@
], ],
"npmClient": "yarn", "npmClient": "yarn",
"useWorkspaces": true, "useWorkspaces": true,
"version": "0.22.0" "version": "0.33.0"
} }
+20 -17
View File
@@ -16,36 +16,39 @@
"barrelsby": "lerna run barrelsby" "barrelsby": "lerna run barrelsby"
}, },
"devDependencies": { "devDependencies": {
"@expo/batcher": "^1.0.0",
"@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.26.4", "@types/jest": "^29.5.2",
"@types/jest": "^26.0.10",
"@types/jsbn": "^1.2.29", "@types/jsbn": "^1.2.29",
"@types/node": "^14.6.0", "@types/lru-cache": "^5.1.1",
"@types/node": "^16.18.34",
"@types/uuid": "^8.3.0", "@types/uuid": "^8.3.0",
"@typescript-eslint/eslint-plugin": "^4.14.0",
"@typescript-eslint/parser": "^4.14.0",
"barrelsby": "^2.2.0", "barrelsby": "^2.2.0",
"eslint": "^7.7.0", "eslint": "^8.14.0",
"eslint-config-universe": "^7.0.0", "eslint-config-universe": "^11.2.0",
"eslint-plugin-tsdoc": "^0.2.11", "eslint-plugin-tsdoc": "^0.2.17",
"jest": "^26.6.3", "ioredis": "^5.2.5",
"lerna": "^4.0.0", "jest": "^29.1.2",
"lerna": "^5.4.0",
"lru-cache": "^6.0.0",
"nullthrows": "^1.1.1", "nullthrows": "^1.1.1",
"pg": "^8.6.0", "pg": "8.10.0",
"prettier": "^2.3.2", "prettier": "^2.8.8",
"ts-jest": "~26.4.4", "ts-jest": "^29.1.0",
"ts-mockito": "^2.6.1", "ts-mockito": "^2.6.1",
"typedoc": "^0.21.9", "typedoc": "^0.24.8",
"typescript": "^4.4.2", "typescript": "^5.1.3",
"uuid": "^8.3.0" "uuid": "^8.3.0"
}, },
"dependencies": { "dependencies": {
"@expo/entity": "file:packages/entity", "@expo/entity": "file:packages/entity",
"@expo/entity-cache-adapter-local-memory": "file:packages/entity-cache-adapter-local-memory",
"@expo/entity-cache-adapter-redis": "file:packages/entity-cache-adapter-redis", "@expo/entity-cache-adapter-redis": "file:packages/entity-cache-adapter-redis",
"@expo/entity-database-adapter-knex": "file:packages/entity-database-adapter-knex", "@expo/entity-database-adapter-knex": "file:packages/entity-database-adapter-knex",
"@expo/entity-secondary-cache-redis": "file:packages/entity-secondary-cache-redis", "@expo/entity-ip-address-field": "file:packages/entity-ip-address-field",
"@expo/entity-ip-address-field": "file:packages/entity-ip-address-field" "@expo/entity-secondary-cache-local-memory": "file:packages/entity-secondary-cache-local-memory",
"@expo/entity-secondary-cache-redis": "file:packages/entity-secondary-cache-redis"
} }
} }
@@ -0,0 +1,127 @@
# Change Log
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.33.0](https://github.com/expo/entity/compare/v0.32.0...v0.33.0) (2023-10-06)
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
# [0.32.0](https://github.com/expo/entity/compare/v0.31.1...v0.32.0) (2023-06-10)
### Features
* entity companion definition static thunk ([#210](https://github.com/expo/entity/issues/210)) ([4b18010](https://github.com/expo/entity/commit/4b18010d42be50ef329f428b08330e21bf676586))
# [0.31.0](https://github.com/expo/entity/compare/v0.30.0...v0.31.0) (2023-01-19)
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
# [0.30.0](https://github.com/expo/entity/compare/v0.29.0...v0.30.0) (2022-12-23)
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
# [0.29.0](https://github.com/expo/entity/compare/v0.28.0...v0.29.0) (2022-10-06)
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
# [0.28.0](https://github.com/expo/entity/compare/v0.27.0...v0.28.0) (2022-09-13)
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
# [0.27.0](https://github.com/expo/entity/compare/v0.26.1...v0.27.0) (2022-08-09)
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
## [0.26.1](https://github.com/expo/entity/compare/v0.26.0...v0.26.1) (2022-07-28)
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
# [0.26.0](https://github.com/expo/entity/compare/v0.25.3...v0.26.0) (2022-07-14)
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
## [0.25.3](https://github.com/expo/entity/compare/v0.25.2...v0.25.3) (2022-03-12)
### Bug Fixes
* lock lru-cache to v6.0.0 ([#170](https://github.com/expo/entity/issues/170)) ([293868b](https://github.com/expo/entity/commit/293868b1a73df9f749216af68b1ae94ac22ac2b9))
## [0.25.2](https://github.com/expo/entity/compare/v0.25.1...v0.25.2) (2022-03-12)
### Bug Fixes
* lru-cache yarn workspace version resolution ([#168](https://github.com/expo/entity/issues/168)) ([e76ca8c](https://github.com/expo/entity/commit/e76ca8c2f72da84aa372a64ef1898ade220ed0a8))
## [0.25.1](https://github.com/expo/entity/compare/v0.25.0...v0.25.1) (2022-03-10)
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
# [0.25.0](https://github.com/expo/entity/compare/v0.24.0...v0.25.0) (2022-03-10)
### Features
* composed cache adapter, composed secondary cache, local memory secondary cache ([#164](https://github.com/expo/entity/issues/164)) ([6b6e70e](https://github.com/expo/entity/commit/6b6e70e75f7e42a4dce13edeb406a4d88d9264f0))
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
### Features
* add local memory cache package ([#158](https://github.com/expo/entity/issues/158)) ([85d3575](https://github.com/expo/entity/commit/85d35752fa0919a62efb776a762a6829e83ca7af))
@@ -0,0 +1,49 @@
# `@expo/entity-cache-adapter-local-memory`
Cross-request [LRU](https://github.com/isaacs/node-lru-cache) cache adapter for `@expo/entity`. Use
this cache with caution - it is nonstandard. The cache is shared between requests in the node process.
Note: This uses version 6.0.0 of `node-lru-cache` since it the most tuned version for our use case (low TTL + LRU). Upgrading
to 7.x will cause high memory usage for the entity cache adapter use case since it allocates fixed-size data structures up front to tune for the non-TTL use case: https://github.com/isaacs/node-lru-cache/issues/208.
[Documentation](https://expo.github.io/entity/modules/_expo_cache_adapter_local_memory.html)
## Why NOT use this cache
Because this is an in-memory cache, cross-machine invalidation is not possible. Do not use this cache
if you have the following use cases:
- The objects stored are mutable
- Reading a stale object from the cache is not acceptable in your application
- Cross-machine invalidation is not possible
## Typical use cases
If your application sees many requests fetching the same objects, you can save a trip to your cache
cluster and backing datastore by using this in-memory cache. Here are some good use cases:
- The objects stored are mostly immutable, and reading a stale object for a short TTL is acceptable
- You have a low TTL setting in your cache
## Usage
During `EntityCompanionProvider` instantiation:
```typescript
export const createDefaultEntityCompanionProvider = (
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter()
): EntityCompanionProvider => {
return new EntityCompanionProvider(
metricsAdapter,
{
...
},
{
['local-memory']: {
cacheAdapterProvider: new LocalMemoryCacheAdapterProvider.getProvider(),
},
}
);
};
```
@@ -0,0 +1,38 @@
{
"name": "@expo/entity-cache-adapter-local-memory",
"version": "0.33.0",
"description": "Cross-request local memory cache adapter for @expo/entity",
"files": [
"build",
"src"
],
"main": "build/index.js",
"types": "build/index.d.ts",
"scripts": {
"tsc": "tsc",
"clean": "rm -rf build coverage coverage-integration",
"lint": "eslint src",
"lint-fix": "eslint src --fix",
"test": "jest --rootDir . --config ../../resources/jest.config.js",
"integration": "../../resources/run-with-docker yarn integration-no-setup",
"integration-no-setup": "jest --config ../../resources/jest-integration.config.js --rootDir . --runInBand --passWithNoTests",
"barrelsby": "barrelsby --directory src --location top --exclude tests__ --singleQuotes --exportDefault --delete"
},
"engines": {
"node": ">=16"
},
"keywords": [
"entity"
],
"author": "Expo",
"license": "MIT",
"peerDependencies": {
"@expo/entity": "*"
},
"dependencies": {
"lru-cache": "^6.0.0"
},
"devDependencies": {
"@expo/entity": "^0.33.0"
}
}
@@ -0,0 +1,103 @@
import {
CacheLoadResult,
CacheStatus,
EntityConfiguration,
IEntityGenericCacher,
} from '@expo/entity';
import invariant from 'invariant';
import LRUCache from 'lru-cache';
// Sentinel value we store in local memory to negatively cache a database miss.
// The sentinel value is distinct from any (positively) cached value.
export const DOES_NOT_EXIST_LOCAL_MEMORY_CACHE = Symbol('doesNotExist');
type LocalMemoryCacheValue<TFields> = Readonly<TFields> | typeof DOES_NOT_EXIST_LOCAL_MEMORY_CACHE;
export type LocalMemoryCache<TFields> = LRUCache<string, LocalMemoryCacheValue<TFields>>;
export default class GenericLocalMemoryCacher<TFields> implements IEntityGenericCacher<TFields> {
constructor(
private readonly entityConfiguration: EntityConfiguration<TFields>,
private readonly localMemoryCache: LocalMemoryCache<TFields>
) {}
static createLRUCache<TFields>(
options: { maxSize?: number; ttlSeconds?: number } = {}
): LocalMemoryCache<TFields> {
const DEFAULT_LRU_CACHE_MAX_AGE_SECONDS = 10;
const DEFAULT_LRU_CACHE_SIZE = 10000;
const maxAgeSeconds = options.ttlSeconds ?? DEFAULT_LRU_CACHE_MAX_AGE_SECONDS;
return new LRUCache<string, LocalMemoryCacheValue<TFields>>({
max: options.maxSize ?? DEFAULT_LRU_CACHE_SIZE,
length: (value) => (value === DOES_NOT_EXIST_LOCAL_MEMORY_CACHE ? 0 : 1),
maxAge: maxAgeSeconds * 1000, // convert to ms
});
}
static createNoOpCache<TFields>(): LocalMemoryCache<TFields> {
return new LRUCache<string, LocalMemoryCacheValue<TFields>>({
max: 0,
maxAge: -1,
});
}
public async loadManyAsync(
keys: readonly string[]
): Promise<ReadonlyMap<string, CacheLoadResult<TFields>>> {
const cacheResults = new Map<string, CacheLoadResult<TFields>>();
for (const key of keys) {
const cacheResult = this.localMemoryCache.get(key);
if (cacheResult === DOES_NOT_EXIST_LOCAL_MEMORY_CACHE) {
cacheResults.set(key, {
status: CacheStatus.NEGATIVE,
});
} else if (cacheResult) {
cacheResults.set(key, {
status: CacheStatus.HIT,
item: cacheResult as unknown as TFields,
});
} else {
cacheResults.set(key, {
status: CacheStatus.MISS,
});
}
}
return cacheResults;
}
public async cacheManyAsync(objectMap: ReadonlyMap<string, Readonly<TFields>>): Promise<void> {
for (const [key, item] of objectMap) {
this.localMemoryCache.set(key, item);
}
}
public async cacheDBMissesAsync(keys: readonly string[]): Promise<void> {
for (const key of keys) {
this.localMemoryCache.set(key, DOES_NOT_EXIST_LOCAL_MEMORY_CACHE);
}
}
public async invalidateManyAsync(keys: readonly string[]): Promise<void> {
for (const key of keys) {
this.localMemoryCache.del(key);
}
}
public makeCacheKey<N extends keyof TFields>(
fieldName: N,
fieldValue: NonNullable<TFields[N]>
): string {
const columnName = this.entityConfiguration.entityToDBFieldsKeyMapping.get(fieldName);
invariant(columnName, `database field mapping missing for ${String(fieldName)}`);
const parts = [
this.entityConfiguration.tableName,
`${this.entityConfiguration.cacheKeyVersion}`,
columnName,
String(fieldValue),
];
const delimiter = ':';
const escapedParts = parts.map((part) =>
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`)
);
return escapedParts.join(delimiter);
}
}
@@ -0,0 +1,47 @@
import {
computeIfAbsent,
IEntityCacheAdapter,
EntityConfiguration,
GenericEntityCacheAdapter,
IEntityCacheAdapterProvider,
} from '@expo/entity';
import GenericLocalMemoryCacher, { LocalMemoryCache } from './GenericLocalMemoryCacher';
export default class LocalMemoryCacheAdapterProvider implements IEntityCacheAdapterProvider {
// local memory cache adapters should be shared/reused across requests
private static localMemoryCacheAdapterMap = new Map<string, GenericEntityCacheAdapter<any>>();
static getNoOpProvider(): IEntityCacheAdapterProvider {
return new LocalMemoryCacheAdapterProvider(<TFields>() =>
GenericLocalMemoryCacher.createNoOpCache<TFields>()
);
}
static getProvider(
options: { maxSize?: number; ttlSeconds?: number } = {}
): IEntityCacheAdapterProvider {
return new LocalMemoryCacheAdapterProvider(<TFields>() =>
GenericLocalMemoryCacher.createLRUCache<TFields>(options)
);
}
private constructor(
private readonly localMemoryCacheCreator: <TFields>() => LocalMemoryCache<TFields>
) {}
public getCacheAdapter<TFields>(
entityConfiguration: EntityConfiguration<TFields>
): IEntityCacheAdapter<TFields> {
return computeIfAbsent(
LocalMemoryCacheAdapterProvider.localMemoryCacheAdapterMap,
entityConfiguration.tableName,
() => {
const localMemoryCache = this.localMemoryCacheCreator<TFields>();
return new GenericEntityCacheAdapter(
new GenericLocalMemoryCacher(entityConfiguration, localMemoryCache)
);
}
);
}
}
@@ -0,0 +1,175 @@
import { CacheStatus, ViewerContext } from '@expo/entity';
import { v4 as uuidv4 } from 'uuid';
import GenericLocalMemoryCacher from '../GenericLocalMemoryCacher';
import LocalMemoryCacheAdapterProvider from '../LocalMemoryCacheAdapterProvider';
import LocalMemoryTestEntity from '../testfixtures/LocalMemoryTestEntity';
import {
createLocalMemoryTestEntityCompanionProvider,
createNoopLocalMemoryIntegrationTestEntityCompanionProvider,
} from '../testfixtures/createLocalMemoryTestEntityCompanionProvider';
describe(GenericLocalMemoryCacher, () => {
beforeEach(async () => {
LocalMemoryCacheAdapterProvider['localMemoryCacheAdapterMap'].clear();
});
it('has correct caching behavior', async () => {
const viewerContext = new ViewerContext(createLocalMemoryTestEntityCompanionProvider());
const genericCacher =
viewerContext.entityCompanionProvider.getCompanionForEntity(LocalMemoryTestEntity)[
'tableDataCoordinator'
]['cacheAdapter']['genericCacher'];
const cacheKeyMaker = genericCacher['makeCacheKey'].bind(genericCacher);
const date = new Date();
const entity1Created = await LocalMemoryTestEntity.creator(viewerContext)
.setField('name', 'blah')
.setField('dateField', date)
.enforceCreateAsync();
// loading an entity should put it in cache
const entity1 = await LocalMemoryTestEntity.loader(viewerContext)
.enforcing()
.loadByIDAsync(entity1Created.getID());
const entitySpecificGenericCacher = LocalMemoryCacheAdapterProvider[
'localMemoryCacheAdapterMap'
].get(
viewerContext.entityCompanionProvider.getCompanionForEntity(LocalMemoryTestEntity)
.entityCompanionDefinition.entityConfiguration.tableName
)!['genericCacher'];
const cachedResult = await entitySpecificGenericCacher.loadManyAsync([
cacheKeyMaker('id', entity1.getID()),
]);
const cachedValue = cachedResult.get(cacheKeyMaker('id', entity1.getID()))!;
expect(cachedValue).toMatchObject({
status: CacheStatus.HIT,
item: {
id: entity1.getID(),
name: 'blah',
dateField: date,
},
});
// simulate non existent db fetch, should write negative result ('') to cache
const nonExistentId = uuidv4();
const entityNonExistentResult = await LocalMemoryTestEntity.loader(viewerContext).loadByIDAsync(
nonExistentId
);
expect(entityNonExistentResult.ok).toBe(false);
const nonExistentCachedResult = await entitySpecificGenericCacher.loadManyAsync([
cacheKeyMaker('id', nonExistentId),
]);
expect(nonExistentCachedResult.get(cacheKeyMaker('id', nonExistentId))).toMatchObject({
status: CacheStatus.NEGATIVE,
});
// load again through entities framework to ensure it reads negative result
const entityNonExistentResult2 = await LocalMemoryTestEntity.loader(
viewerContext
).loadByIDAsync(nonExistentId);
expect(entityNonExistentResult2.ok).toBe(false);
// invalidate from cache to ensure it invalidates correctly
await LocalMemoryTestEntity.loader(viewerContext).invalidateFieldsAsync(entity1.getAllFields());
const cachedResultMiss = await entitySpecificGenericCacher.loadManyAsync([
cacheKeyMaker('id', entity1.getID()),
]);
const cachedValueMiss = cachedResultMiss.get(cacheKeyMaker('id', entity1.getID()));
expect(cachedValueMiss).toMatchObject({ status: CacheStatus.MISS });
});
it('shares the cache between different requests', async () => {
const genericLocalMemoryCacherLoadManySpy = jest.spyOn(
GenericLocalMemoryCacher.prototype as unknown as any,
'loadManyAsync'
);
const viewerContext = new ViewerContext(createLocalMemoryTestEntityCompanionProvider());
const date = new Date();
const entity1Created = await LocalMemoryTestEntity.creator(viewerContext)
.setField('name', 'blah')
.setField('dateField', date)
.enforceCreateAsync();
// loading an entity should put it in cache
await LocalMemoryTestEntity.loader(viewerContext)
.enforcing()
.loadByIDAsync(entity1Created.getID());
// load entity with a different request
const viewerContext2 = new ViewerContext(createLocalMemoryTestEntityCompanionProvider());
const entity1WithVc2 = await LocalMemoryTestEntity.loader(viewerContext2)
.enforcing()
.loadByIDAsync(entity1Created.getID());
const genericCacher =
viewerContext.entityCompanionProvider.getCompanionForEntity(LocalMemoryTestEntity)[
'tableDataCoordinator'
]['cacheAdapter']['genericCacher'];
const cacheKeyMaker = genericCacher['makeCacheKey'].bind(genericCacher);
expect(entity1WithVc2.getAllFields()).toMatchObject({
id: entity1WithVc2.getID(),
name: 'blah',
dateField: date,
});
expect(genericLocalMemoryCacherLoadManySpy).toBeCalledWith([
cacheKeyMaker('id', entity1WithVc2.getID()),
]);
expect(genericLocalMemoryCacherLoadManySpy).toBeCalledTimes(2);
});
it('respects the parameters of a noop cache', async () => {
const viewerContext = new ViewerContext(
createNoopLocalMemoryIntegrationTestEntityCompanionProvider()
);
const genericCacher =
viewerContext.entityCompanionProvider.getCompanionForEntity(LocalMemoryTestEntity)[
'tableDataCoordinator'
]['cacheAdapter']['genericCacher'];
const cacheKeyMaker = genericCacher['makeCacheKey'].bind(genericCacher);
const date = new Date();
const entity1Created = await LocalMemoryTestEntity.creator(viewerContext)
.setField('name', 'blah')
.setField('dateField', date)
.enforceCreateAsync();
// loading an entity will try to put it in cache but it's a noop cache, so it should be a miss
const entity1 = await LocalMemoryTestEntity.loader(viewerContext)
.enforcing()
.loadByIDAsync(entity1Created.getID());
const entitySpecificGenericCacher = LocalMemoryCacheAdapterProvider[
'localMemoryCacheAdapterMap'
].get(
viewerContext.entityCompanionProvider.getCompanionForEntity(LocalMemoryTestEntity)
.entityCompanionDefinition.entityConfiguration.tableName
)!['genericCacher'];
const cachedResult = await entitySpecificGenericCacher.loadManyAsync([
cacheKeyMaker('id', entity1.getID()),
]);
const cachedValue = cachedResult.get(cacheKeyMaker('id', entity1.getID()))!;
expect(cachedValue).toMatchObject({
status: CacheStatus.MISS,
});
// a non existent db fetch should try to write negative result ('') but it's a noop cache, so it should be a miss
const nonExistentId = uuidv4();
const entityNonExistentResult = await LocalMemoryTestEntity.loader(viewerContext).loadByIDAsync(
nonExistentId
);
expect(entityNonExistentResult.ok).toBe(false);
const nonExistentCachedResult = await entitySpecificGenericCacher.loadManyAsync([
cacheKeyMaker('id', nonExistentId),
]);
expect(nonExistentCachedResult.get(cacheKeyMaker('id', nonExistentId))).toMatchObject({
status: CacheStatus.MISS,
});
});
});
@@ -0,0 +1,142 @@
import {
CacheStatus,
UUIDField,
EntityConfiguration,
GenericEntityCacheAdapter,
} from '@expo/entity';
import GenericLocalMemoryCacher, {
DOES_NOT_EXIST_LOCAL_MEMORY_CACHE,
} from '../GenericLocalMemoryCacher';
type BlahFields = {
id: string;
};
const entityConfiguration = new EntityConfiguration<BlahFields>({
idField: 'id',
tableName: 'blah',
schema: {
id: new UUIDField({ columnName: 'id', cache: true }),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'local-memory',
});
describe(GenericLocalMemoryCacher, () => {
describe(GenericLocalMemoryCacher.createLRUCache, () => {
it('creates a cache with default options', () => {
const cache = GenericLocalMemoryCacher.createLRUCache();
expect(cache.max).toBe(10000);
expect(cache.maxAge).toBe(10000);
});
it('respects specified options', () => {
const cache = GenericLocalMemoryCacher.createLRUCache({
ttlSeconds: 3,
maxSize: 10,
});
expect(cache.max).toBe(10);
expect(cache.maxAge).toBe(3000);
});
});
describe(GenericLocalMemoryCacher.createNoOpCache, () => {
it('creates a no-op cache', () => {
const cache = GenericLocalMemoryCacher.createNoOpCache<{ hello: 'world' }>();
cache.set('a', { hello: 'world' });
expect(cache.get('a')).toBeUndefined();
});
});
});
describe('Use within GenericEntityCacheAdapter', () => {
describe('loadManyAsync', () => {
it('returns appropriate cache results', async () => {
const cacheAdapter = new GenericEntityCacheAdapter(
new GenericLocalMemoryCacher(entityConfiguration, GenericLocalMemoryCacher.createLRUCache())
);
const cacheHits = new Map<string, Readonly<BlahFields>>([['test-id-1', { id: 'test-id-1' }]]);
await cacheAdapter.cacheManyAsync('id', cacheHits);
await cacheAdapter.cacheDBMissesAsync('id', ['test-id-2']);
const results = await cacheAdapter.loadManyAsync('id', [
'test-id-1',
'test-id-2',
'test-id-3',
]);
expect(results.get('test-id-1')).toMatchObject({
status: CacheStatus.HIT,
item: { id: 'test-id-1' },
});
expect(results.get('test-id-2')).toMatchObject({ status: CacheStatus.NEGATIVE });
expect(results.get('test-id-3')).toMatchObject({ status: CacheStatus.MISS });
expect(results.size).toBe(3);
});
it('returns empty map when passed empty array of fieldValues', async () => {
const cacheAdapter = new GenericEntityCacheAdapter(
new GenericLocalMemoryCacher(entityConfiguration, GenericLocalMemoryCacher.createLRUCache())
);
const results = await cacheAdapter.loadManyAsync('id', []);
expect(results).toEqual(new Map());
});
});
describe('cacheManyAsync', () => {
it('correctly caches all objects', async () => {
const localMemoryCache = GenericLocalMemoryCacher.createLRUCache<BlahFields>({});
const localMemoryCacher = new GenericLocalMemoryCacher(entityConfiguration, localMemoryCache);
const cacheAdapter = new GenericEntityCacheAdapter(localMemoryCacher);
await cacheAdapter.cacheManyAsync('id', new Map([['test-id-1', { id: 'test-id-1' }]]));
const cacheKey = localMemoryCacher['makeCacheKey']('id', 'test-id-1');
expect(localMemoryCache.get(cacheKey)).toMatchObject({
id: 'test-id-1',
});
});
});
describe('cacheDBMissesAsync', () => {
it('correctly caches misses', async () => {
const localMemoryCache = GenericLocalMemoryCacher.createLRUCache<BlahFields>({});
const localMemoryCacher = new GenericLocalMemoryCacher(entityConfiguration, localMemoryCache);
const cacheAdapter = new GenericEntityCacheAdapter(localMemoryCacher);
await cacheAdapter.cacheDBMissesAsync('id', ['test-id-1']);
const cacheKey = localMemoryCacher['makeCacheKey']('id', 'test-id-1');
expect(localMemoryCache.get(cacheKey)).toEqual(DOES_NOT_EXIST_LOCAL_MEMORY_CACHE);
});
});
describe('invalidateManyAsync', () => {
it('invalidates correctly', async () => {
const localMemoryCache = GenericLocalMemoryCacher.createLRUCache<BlahFields>({});
const cacheAdapter = new GenericEntityCacheAdapter(
new GenericLocalMemoryCacher(entityConfiguration, localMemoryCache)
);
await cacheAdapter.cacheManyAsync('id', new Map([['test-id-1', { id: 'test-id-1' }]]));
await cacheAdapter.cacheDBMissesAsync('id', ['test-id-2']);
await cacheAdapter.invalidateManyAsync('id', ['test-id-1', 'test-id-2']);
const results = await cacheAdapter.loadManyAsync('id', ['test-id-1', 'test-id-2']);
expect(results.get('test-id-1')).toMatchObject({ status: CacheStatus.MISS });
expect(results.get('test-id-2')).toMatchObject({ status: CacheStatus.MISS });
});
it('returns when passed empty array of fieldValues', async () => {
const cacheAdapter = new GenericEntityCacheAdapter(
new GenericLocalMemoryCacher(
entityConfiguration,
GenericLocalMemoryCacher.createLRUCache<BlahFields>({})
)
);
await cacheAdapter.invalidateManyAsync('id', []);
});
});
});
@@ -0,0 +1,9 @@
/* eslint-disable tsdoc/syntax */
/**
* @packageDocumentation
* @module @expo/entity-cache-adapter-local-memory
*/
export { default as GenericLocalMemoryCacher } from './GenericLocalMemoryCacher';
export * from './GenericLocalMemoryCacher';
export { default as LocalMemoryCacheAdapterProvider } from './LocalMemoryCacheAdapterProvider';
@@ -0,0 +1,98 @@
import {
AlwaysAllowPrivacyPolicyRule,
EntityPrivacyPolicy,
ViewerContext,
UUIDField,
DateField,
StringField,
EntityConfiguration,
EntityCompanionDefinition,
Entity,
} from '@expo/entity';
export type LocalMemoryTestEntityFields = {
id: string;
name: string;
dateField: Date | null;
};
export default class LocalMemoryTestEntity extends Entity<
LocalMemoryTestEntityFields,
string,
ViewerContext
> {
static defineCompanionDefinition(): EntityCompanionDefinition<
LocalMemoryTestEntityFields,
string,
ViewerContext,
LocalMemoryTestEntity,
LocalMemoryTestEntityPrivacyPolicy
> {
return {
entityClass: LocalMemoryTestEntity,
entityConfiguration: localMemoryTestEntityConfiguration,
privacyPolicyClass: LocalMemoryTestEntityPrivacyPolicy,
};
}
}
export class LocalMemoryTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
LocalMemoryTestEntityFields,
string,
ViewerContext,
LocalMemoryTestEntity
> {
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<
LocalMemoryTestEntityFields,
string,
ViewerContext,
LocalMemoryTestEntity
>(),
];
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<
LocalMemoryTestEntityFields,
string,
ViewerContext,
LocalMemoryTestEntity
>(),
];
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<
LocalMemoryTestEntityFields,
string,
ViewerContext,
LocalMemoryTestEntity
>(),
];
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<
LocalMemoryTestEntityFields,
string,
ViewerContext,
LocalMemoryTestEntity
>(),
];
}
export const localMemoryTestEntityConfiguration =
new EntityConfiguration<LocalMemoryTestEntityFields>({
idField: 'id',
tableName: 'local_memory_test_entities',
schema: {
id: new UUIDField({
columnName: 'id',
cache: true,
}),
name: new StringField({
columnName: 'name',
cache: true,
}),
dateField: new DateField({
columnName: 'date_field',
}),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'local-memory',
});
@@ -0,0 +1,48 @@
import {
NoOpEntityMetricsAdapter,
IEntityMetricsAdapter,
EntityCompanionProvider,
StubQueryContextProvider,
StubDatabaseAdapterProvider,
} from '@expo/entity';
import LocalMemoryCacheAdapterProvider from '../LocalMemoryCacheAdapterProvider';
export const createLocalMemoryTestEntityCompanionProvider = (
localMemoryOptions: { maxSize?: number; ttlSeconds?: number } = {},
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter()
): EntityCompanionProvider => {
const localMemoryCacheAdapterProvider =
localMemoryOptions.maxSize === 0 && localMemoryOptions.ttlSeconds === 0
? LocalMemoryCacheAdapterProvider.getNoOpProvider()
: LocalMemoryCacheAdapterProvider.getProvider(localMemoryOptions);
return new EntityCompanionProvider(
metricsAdapter,
new Map([
[
'postgres',
{
adapterProvider: new StubDatabaseAdapterProvider(),
queryContextProvider: StubQueryContextProvider,
},
],
]),
new Map([
[
'local-memory',
{
cacheAdapterProvider: localMemoryCacheAdapterProvider,
},
],
])
);
};
export const createNoopLocalMemoryIntegrationTestEntityCompanionProvider = (
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter()
): EntityCompanionProvider => {
return createLocalMemoryTestEntityCompanionProvider(
{ maxSize: 0, ttlSeconds: 0 },
metricsAdapter
);
};
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "build",
},
"include": ["src"]
}
@@ -3,6 +3,144 @@
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.33.0](https://github.com/expo/entity/compare/v0.32.0...v0.33.0) (2023-10-06)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.32.0](https://github.com/expo/entity/compare/v0.31.1...v0.32.0) (2023-06-10)
### Bug Fixes
* remove unused field from redis configuration ([#213](https://github.com/expo/entity/issues/213)) ([503a2fb](https://github.com/expo/entity/commit/503a2fb6a8d57f6a09f6da99daf5ab92fc3e7945))
### Features
* entity companion definition static thunk ([#210](https://github.com/expo/entity/issues/210)) ([4b18010](https://github.com/expo/entity/commit/4b18010d42be50ef329f428b08330e21bf676586))
# [0.31.0](https://github.com/expo/entity/compare/v0.30.0...v0.31.0) (2023-01-19)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.30.0](https://github.com/expo/entity/compare/v0.29.0...v0.30.0) (2022-12-23)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.29.0](https://github.com/expo/entity/compare/v0.28.0...v0.29.0) (2022-10-06)
### Features
* add example of redis mget batching using @expo/batcher ([#199](https://github.com/expo/entity/issues/199)) ([684f91b](https://github.com/expo/entity/commit/684f91b1f270851dbe331df2e2620664eea17266))
* change redis cache adapter to use redis-like interface ([#200](https://github.com/expo/entity/issues/200)) ([e381f5b](https://github.com/expo/entity/commit/e381f5b2acfc17dcade2cf6e8506fa1c1c255e73))
# [0.28.0](https://github.com/expo/entity/compare/v0.27.0...v0.28.0) (2022-09-13)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.27.0](https://github.com/expo/entity/compare/v0.26.1...v0.27.0) (2022-08-09)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
## [0.26.1](https://github.com/expo/entity/compare/v0.26.0...v0.26.1) (2022-07-28)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.26.0](https://github.com/expo/entity/compare/v0.25.3...v0.26.0) (2022-07-14)
### Features
* update ioredis to v5, make it a devDependency of the cache packages ([#172](https://github.com/expo/entity/issues/172)) ([9fcfbf7](https://github.com/expo/entity/commit/9fcfbf746a61554cefedf7fb0f1a3a9056b58ffe))
## [0.25.3](https://github.com/expo/entity/compare/v0.25.2...v0.25.3) (2022-03-12)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
## [0.25.2](https://github.com/expo/entity/compare/v0.25.1...v0.25.2) (2022-03-12)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
## [0.25.1](https://github.com/expo/entity/compare/v0.25.0...v0.25.1) (2022-03-10)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.25.0](https://github.com/expo/entity/compare/v0.24.0...v0.25.0) (2022-03-10)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
### Chores
* Refactor cachers to use an abstract class and interface ([#157](https://github.com/expo/entity/pull/157))
# [0.23.0](https://github.com/expo/entity/compare/v0.22.0...v0.23.0) (2022-02-09)
### Chores
* Move data transforming responsibilities to the CacheAdapter instead of the ReadThroughEntityCache. ([#153](https://github.com/expo/entity/pull/153))
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04) # [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 **Note:** Version bump only for package @expo/entity-cache-adapter-redis
@@ -11,7 +11,7 @@ During `EntityCompanionProvider` instantiation:
```typescript ```typescript
import Redis from 'ioredis'; import Redis from 'ioredis';
const redisCacheAdapterContext = { const genericRedisCacherContext = {
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 = ':';
@@ -21,7 +21,6 @@ const redisCacheAdapterContext = {
return escapedParts.join(delimiter); return escapedParts.join(delimiter);
}, },
cacheKeyPrefix: 'ent-', cacheKeyPrefix: 'ent-',
cacheKeyVersion: 1,
ttlSecondsPositive: 86400, // 1 day ttlSecondsPositive: 86400, // 1 day
ttlSecondsNegative: 600, // 10 minutes ttlSecondsNegative: 600, // 10 minutes
}; };
@@ -36,7 +35,7 @@ export const createDefaultEntityCompanionProvider = (
}, },
{ {
['redis']: { ['redis']: {
cacheAdapterProvider: new RedisCacheAdapterProvider(redisCacheAdapterContext), cacheAdapterProvider: new RedisCacheAdapterProvider(genericRedisCacheContext),
}, },
} }
); );
@@ -1,6 +1,6 @@
{ {
"name": "@expo/entity-cache-adapter-redis", "name": "@expo/entity-cache-adapter-redis",
"version": "0.22.0", "version": "0.33.0",
"description": "Redis cache adapter for @expo/entity", "description": "Redis cache adapter for @expo/entity",
"files": [ "files": [
"build", "build",
@@ -19,7 +19,7 @@
"barrelsby": "barrelsby --directory src --location top --exclude tests__ --singleQuotes --exportDefault --delete" "barrelsby": "barrelsby --directory src --location top --exclude tests__ --singleQuotes --exportDefault --delete"
}, },
"engines": { "engines": {
"node": ">=12" "node": ">=16"
}, },
"keywords": [ "keywords": [
"entity" "entity"
@@ -27,12 +27,11 @@
"author": "Expo", "author": "Expo",
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"@expo/entity": "*" "@expo/entity": "*",
}, "ioredis": ">=5"
"dependencies": {
"ioredis": "^4.27.3"
}, },
"devDependencies": { "devDependencies": {
"@expo/entity": "^0.22.0" "@expo/entity": "^0.33.0",
"ioredis": "^5.2.5"
} }
} }
@@ -1,17 +1,48 @@
import { CacheLoadResult, CacheStatus } from '@expo/entity'; import {
import { Redis } from 'ioredis'; CacheLoadResult,
CacheStatus,
EntityConfiguration,
transformCacheObjectToFields,
transformFieldsToCacheObject,
IEntityGenericCacher,
} from '@expo/entity';
import invariant from 'invariant';
import { redisTransformerMap } from './RedisCommon';
import wrapNativeRedisCallAsync from './errors/wrapNativeRedisCallAsync'; import wrapNativeRedisCallAsync from './errors/wrapNativeRedisCallAsync';
// Sentinel value we store in Redis to negatively cache a database miss. // Sentinel value we store in Redis to negatively cache a database miss.
// The sentinel value is distinct from any (positively) cached value. // The sentinel value is distinct from any (positively) cached value.
const DOES_NOT_EXIST_REDIS = ''; const DOES_NOT_EXIST_REDIS = '';
export interface IRedisTransaction {
set(key: string, value: string, secondsToken: 'EX', seconds: number): this;
exec(): Promise<any>;
}
export interface IRedis {
mget(...args: [...keys: string[]]): Promise<(string | null)[]>;
multi(): IRedisTransaction;
del(...args: [...keys: string[]]): Promise<any>;
}
export interface GenericRedisCacheContext { export interface GenericRedisCacheContext {
/** /**
* Instance of ioredis.Redis * Instance of ioredis.Redis
*/ */
redisClient: Redis; redisClient: IRedis;
/**
* Create a key string for key parts (cache key prefix, versions, entity name, etc).
* Most commonly a simple `parts.join(':')`. See integration test for example.
*/
makeKeyFn: (...parts: string[]) => string;
/**
* Prefix prepended to all entity cache keys. Useful for adding a short, human-readable
* distintion for entity keys, e.g. `ent-`
*/
cacheKeyPrefix: string;
/** /**
* TTL for caching database hits. Successive entity loads within this TTL * TTL for caching database hits. Successive entity loads within this TTL
@@ -26,12 +57,15 @@ export interface GenericRedisCacheContext {
ttlSecondsNegative: number; ttlSecondsNegative: number;
} }
export default class GenericRedisCacher { export default class GenericRedisCacher<TFields> implements IEntityGenericCacher<TFields> {
constructor(private readonly context: GenericRedisCacheContext) {} constructor(
private readonly context: GenericRedisCacheContext,
private readonly entityConfiguration: EntityConfiguration<TFields>
) {}
public async loadManyAsync( public async loadManyAsync(
keys: readonly string[] keys: readonly string[]
): Promise<ReadonlyMap<string, CacheLoadResult>> { ): Promise<ReadonlyMap<string, CacheLoadResult<TFields>>> {
if (keys.length === 0) { if (keys.length === 0) {
return new Map(); return new Map();
} }
@@ -40,7 +74,7 @@ export default class GenericRedisCacher {
this.context.redisClient.mget(...keys) this.context.redisClient.mget(...keys)
); );
const results = new Map<string, CacheLoadResult>(); const results = new Map<string, CacheLoadResult<TFields>>();
for (let i = 0; i < keys.length; i++) { for (let i = 0; i < keys.length; i++) {
const key = keys[i]!; const key = keys[i]!;
const redisResult = redisResults[i]; const redisResult = redisResults[i];
@@ -52,7 +86,11 @@ export default class GenericRedisCacher {
} else if (redisResult) { } else if (redisResult) {
results.set(key, { results.set(key, {
status: CacheStatus.HIT, status: CacheStatus.HIT,
item: JSON.parse(redisResult), item: transformCacheObjectToFields(
this.entityConfiguration,
redisTransformerMap,
JSON.parse(redisResult)
),
}); });
} else { } else {
results.set(key, { results.set(key, {
@@ -63,7 +101,7 @@ export default class GenericRedisCacher {
return results; return results;
} }
public async cacheManyAsync(objectMap: ReadonlyMap<string, object>): Promise<void> { public async cacheManyAsync(objectMap: ReadonlyMap<string, Readonly<TFields>>): Promise<void> {
if (objectMap.size === 0) { if (objectMap.size === 0) {
return; return;
} }
@@ -72,7 +110,9 @@ export default class GenericRedisCacher {
objectMap.forEach((object, key) => { objectMap.forEach((object, key) => {
redisTransaction = redisTransaction.set( redisTransaction = redisTransaction.set(
key, key,
JSON.stringify(object), JSON.stringify(
transformFieldsToCacheObject(this.entityConfiguration, redisTransformerMap, object)
),
'EX', 'EX',
this.context.ttlSecondsPositive this.context.ttlSecondsPositive
); );
@@ -80,7 +120,7 @@ export default class GenericRedisCacher {
await wrapNativeRedisCallAsync(() => redisTransaction.exec()); await wrapNativeRedisCallAsync(() => redisTransaction.exec());
} }
public async cacheDBMissesAsync(keys: string[]): Promise<void> { public async cacheDBMissesAsync(keys: readonly string[]): Promise<void> {
if (keys.length === 0) { if (keys.length === 0) {
return; return;
} }
@@ -97,11 +137,26 @@ export default class GenericRedisCacher {
await wrapNativeRedisCallAsync(() => redisTransaction.exec()); await wrapNativeRedisCallAsync(() => redisTransaction.exec());
} }
public async invalidateManyAsync(keys: string[]): Promise<void> { public async invalidateManyAsync(keys: readonly string[]): Promise<void> {
if (keys.length === 0) { if (keys.length === 0) {
return; return;
} }
await wrapNativeRedisCallAsync(() => this.context.redisClient.del(...keys)); await wrapNativeRedisCallAsync(() => this.context.redisClient.del(...keys));
} }
public makeCacheKey<N extends keyof TFields>(
fieldName: N,
fieldValue: NonNullable<TFields[N]>
): string {
const columnName = this.entityConfiguration.entityToDBFieldsKeyMapping.get(fieldName);
invariant(columnName, `database field mapping missing for ${String(fieldName)}`);
return this.context.makeKeyFn(
this.context.cacheKeyPrefix,
this.entityConfiguration.tableName,
`v2.${this.entityConfiguration.cacheKeyVersion}`,
columnName,
String(fieldValue)
);
}
} }
@@ -1,133 +0,0 @@
import {
EntityCacheAdapter,
EntityConfiguration,
CacheLoadResult,
FieldTransformerMap,
mapKeys,
} from '@expo/entity';
import invariant from 'invariant';
import { Redis } from 'ioredis';
import GenericRedisCacher from './GenericRedisCacher';
import { redisTransformerMap } from './RedisCommon';
export interface RedisCacheAdapterContext {
/**
* Instance of ioredis.Redis
*/
redisClient: Redis;
/**
* Create a key string for key parts (cache key prefix, versions, entity name, etc).
* Most commonly a simple `parts.join(':')`. See integration test for example.
*/
makeKeyFn: (...parts: string[]) => string;
/**
* Global cache version for the entity framework. Bumping this version will
* invalidate the cache for all entities at once.
*/
cacheKeyVersion: number;
/**
* Prefix prepended to all entity cache keys. Useful for adding a short, human-readable
* distintion for entity keys, e.g. `ent-`
*/
cacheKeyPrefix: string;
/**
* 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 RedisCacheAdapter<TFields> extends EntityCacheAdapter<TFields> {
private readonly genericRedisCacher: GenericRedisCacher;
constructor(
entityConfiguration: EntityConfiguration<TFields>,
private readonly context: RedisCacheAdapterContext
) {
super(entityConfiguration);
this.genericRedisCacher = new GenericRedisCacher({
redisClient: context.redisClient,
ttlSecondsNegative: context.ttlSecondsNegative,
ttlSecondsPositive: context.ttlSecondsPositive,
});
}
public getFieldTransformerMap(): FieldTransformerMap {
return redisTransformerMap;
}
public async loadManyAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult>> {
const redisCacheKeyToFieldValueMapping = new Map(
fieldValues.map((fieldValue) => [this.makeCacheKey(fieldName, fieldValue), fieldValue])
);
const cacheResults = await this.genericRedisCacher.loadManyAsync(
Array.from(redisCacheKeyToFieldValueMapping.keys())
);
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>(
fieldName: N,
objectMap: ReadonlyMap<NonNullable<TFields[N]>, object>
): Promise<void> {
await this.genericRedisCacher.cacheManyAsync(
mapKeys(objectMap, (fieldValue) => this.makeCacheKey(fieldName, fieldValue))
);
}
public async cacheDBMissesAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<void> {
await this.genericRedisCacher.cacheDBMissesAsync(
fieldValues.map((fieldValue) => this.makeCacheKey(fieldName, fieldValue))
);
}
public async invalidateManyAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<void> {
await this.genericRedisCacher.invalidateManyAsync(
fieldValues.map((fieldValue) => this.makeCacheKey(fieldName, fieldValue))
);
}
private makeCacheKey<N extends keyof TFields>(
fieldName: N,
fieldValue: NonNullable<TFields[N]>
): string {
const columnName = this.entityConfiguration.entityToDBFieldsKeyMapping.get(fieldName);
invariant(columnName, `database field mapping missing for ${fieldName}`);
return this.context.makeKeyFn(
this.context.cacheKeyPrefix,
this.entityConfiguration.tableName,
`v2.${this.entityConfiguration.cacheKeyVersion}`,
columnName,
String(fieldValue)
);
}
}
@@ -1,13 +1,18 @@
import { IEntityCacheAdapterProvider, EntityConfiguration, EntityCacheAdapter } from '@expo/entity'; import {
IEntityCacheAdapterProvider,
EntityConfiguration,
IEntityCacheAdapter,
GenericEntityCacheAdapter,
} from '@expo/entity';
import RedisCacheAdapter, { RedisCacheAdapterContext } from './RedisCacheAdapter'; import GenericRedisCacher, { GenericRedisCacheContext } from './GenericRedisCacher';
export default class RedisCacheAdapterProvider implements IEntityCacheAdapterProvider { export default class RedisCacheAdapterProvider implements IEntityCacheAdapterProvider {
constructor(private readonly context: RedisCacheAdapterContext) {} constructor(private readonly context: GenericRedisCacheContext) {}
getCacheAdapter<TFields>( getCacheAdapter<TFields>(
entityConfiguration: EntityConfiguration<TFields> entityConfiguration: EntityConfiguration<TFields>
): EntityCacheAdapter<TFields> { ): IEntityCacheAdapter<TFields> {
return new RedisCacheAdapter(entityConfiguration, this.context); return new GenericEntityCacheAdapter(new GenericRedisCacher(this.context, entityConfiguration));
} }
} }
@@ -0,0 +1,166 @@
import { Batcher } from '@expo/batcher';
import { ViewerContext, zipToMap } from '@expo/entity';
import invariant from 'invariant';
import Redis from 'ioredis';
import nullthrows from 'nullthrows';
import { URL } from 'url';
import { v4 as uuidv4 } from 'uuid';
import GenericRedisCacher, {
IRedis,
IRedisTransaction,
GenericRedisCacheContext,
} from '../GenericRedisCacher';
import RedisTestEntity from '../testfixtures/RedisTestEntity';
import { createRedisIntegrationTestEntityCompanionProvider } from '../testfixtures/createRedisIntegrationTestEntityCompanionProvider';
class BatchedRedis implements IRedis {
private readonly mgetBatcher = new Batcher<string[], (string | null)[]>(
this.batchMgetAsync.bind(this),
{
maxBatchInterval: 0,
}
);
constructor(private readonly redis: Redis) {}
private async batchMgetAsync(keySets: string[][]): Promise<(string | null)[][]> {
// ordered distinct keys to fetch
const allKeysToFetch = [...new Set(keySets.flat())];
// fetch the distinct keys
const allResults = await this.redis.mget(...allKeysToFetch);
// put them into a map for fast lookup
const keysToResults = zipToMap(allKeysToFetch, allResults);
// re-associate them with original key sets
return keySets.map((keySet) =>
keySet.map((key) => {
const result = keysToResults.get(key);
invariant(result !== undefined, 'result should not be undefined');
return result;
})
);
}
async mget(...args: string[]): Promise<(string | null)[]> {
return await this.mgetBatcher.batchAsync(args);
}
multi(): IRedisTransaction {
return this.redis.multi();
}
async del(...args: string[]): Promise<void> {
await this.redis.del(...args);
}
}
describe(GenericRedisCacher, () => {
const redis = new Redis(new URL(process.env['REDIS_URL']!).toString());
const redisClient = new BatchedRedis(redis);
let genericRedisCacheContext: GenericRedisCacheContext;
beforeAll(() => {
genericRedisCacheContext = {
redisClient,
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`)
);
return escapedParts.join(delimiter);
},
cacheKeyPrefix: 'test-',
ttlSecondsPositive: 86400, // 1 day
ttlSecondsNegative: 600, // 10 minutes
};
});
beforeEach(async () => {
await redis.flushdb();
});
afterAll(async () => {
redis.disconnect();
});
it('has correct caching behavior', async () => {
// simulate two requests
const viewerContext = new ViewerContext(
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext)
);
const mgetSpy = jest.spyOn(redis, 'mget');
const genericCacher =
viewerContext.entityCompanionProvider.getCompanionForEntity(RedisTestEntity)[
'tableDataCoordinator'
]['cacheAdapter']['genericCacher'];
const cacheKeyMaker = genericCacher['makeCacheKey'].bind(genericCacher);
const entity1Created = await RedisTestEntity.creator(viewerContext)
.setField('name', 'blah')
.enforceCreateAsync();
// loading an entity should put it in cache. load by multiple requests and multiple fields in same tick to ensure batch works
mgetSpy.mockClear();
const viewerContext1 = new ViewerContext(
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext)
);
const viewerContext2 = new ViewerContext(
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext)
);
const viewerContext3 = new ViewerContext(
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext)
);
const [entity1, entity2, entity3] = await Promise.all([
RedisTestEntity.loader(viewerContext1).enforcing().loadByIDAsync(entity1Created.getID()),
RedisTestEntity.loader(viewerContext2).enforcing().loadByIDAsync(entity1Created.getID()),
RedisTestEntity.loader(viewerContext3)
.enforcing()
.loadByFieldEqualingAsync('name', entity1Created.getField('name')),
]);
expect(mgetSpy).toHaveBeenCalledTimes(1);
expect(mgetSpy.mock.calls[0]).toHaveLength(2); // should dedupe the first two loads
expect(entity1.getID()).toEqual(entity2.getID());
expect(entity2.getID()).toEqual(nullthrows(entity3).getID());
const cacheKeyEntity1 = cacheKeyMaker('id', entity1Created.getID());
const cachedJSON = await redis.get(cacheKeyEntity1);
const cachedValue = JSON.parse(cachedJSON!);
expect(cachedValue).toMatchObject({
id: entity1.getID(),
name: 'blah',
});
const cacheKeyEntity1NameField = cacheKeyMaker('name', entity1Created.getField('name'));
await RedisTestEntity.loader(viewerContext)
.enforcing()
.loadByFieldEqualingAsync('name', entity1Created.getField('name'));
await expect(redis.get(cacheKeyEntity1NameField)).resolves.toEqual(cachedJSON);
// simulate non existent db fetch, should write negative result ('') to cache
const nonExistentId = uuidv4();
const entityNonExistentResult = await RedisTestEntity.loader(viewerContext).loadByIDAsync(
nonExistentId
);
expect(entityNonExistentResult.ok).toBe(false);
const cacheKeyNonExistent = cacheKeyMaker('id', nonExistentId);
const nonExistentCachedValue = await redis.get(cacheKeyNonExistent);
expect(nonExistentCachedValue).toEqual('');
// load again through entities framework to ensure it reads negative result
const entityNonExistentResult2 = await RedisTestEntity.loader(viewerContext).loadByIDAsync(
nonExistentId
);
expect(entityNonExistentResult2.ok).toBe(false);
// invalidate from cache to ensure it invalidates correctly in both caches
await RedisTestEntity.loader(viewerContext).invalidateFieldsAsync(entity1.getAllFields());
await expect(redis.get(cacheKeyEntity1)).resolves.toBeNull();
await expect(redis.get(cacheKeyEntity1NameField)).resolves.toBeNull();
});
});
@@ -4,17 +4,17 @@ import Redis from 'ioredis';
import { URL } from 'url'; import { URL } from 'url';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import RedisCacheAdapter, { RedisCacheAdapterContext } from '../RedisCacheAdapter'; import GenericRedisCacher, { GenericRedisCacheContext } from '../GenericRedisCacher';
import RedisTestEntity from '../testfixtures/RedisTestEntity'; import RedisTestEntity from '../testfixtures/RedisTestEntity';
import { createRedisIntegrationTestEntityCompanionProvider } from '../testfixtures/createRedisIntegrationTestEntityCompanionProvider'; import { createRedisIntegrationTestEntityCompanionProvider } from '../testfixtures/createRedisIntegrationTestEntityCompanionProvider';
class TestViewerContext extends ViewerContext {} class TestViewerContext extends ViewerContext {}
describe(RedisCacheAdapter, () => { describe(GenericRedisCacher, () => {
let redisCacheAdapterContext: RedisCacheAdapterContext; let genericRedisCacheContext: GenericRedisCacheContext;
beforeAll(() => { beforeAll(() => {
redisCacheAdapterContext = { genericRedisCacheContext = {
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 = ':';
@@ -24,28 +24,27 @@ describe(RedisCacheAdapter, () => {
return escapedParts.join(delimiter); return escapedParts.join(delimiter);
}, },
cacheKeyPrefix: 'test-', cacheKeyPrefix: 'test-',
cacheKeyVersion: 1,
ttlSecondsPositive: 86400, // 1 day ttlSecondsPositive: 86400, // 1 day
ttlSecondsNegative: 600, // 10 minutes ttlSecondsNegative: 600, // 10 minutes
}; };
}); });
beforeEach(async () => { beforeEach(async () => {
await redisCacheAdapterContext.redisClient.flushdb(); await (genericRedisCacheContext.redisClient as Redis).flushdb();
}); });
afterAll(async () => { afterAll(async () => {
redisCacheAdapterContext.redisClient.disconnect(); (genericRedisCacheContext.redisClient as Redis).disconnect();
}); });
it('has correct caching behavior', async () => { it('has correct caching behavior', async () => {
const viewerContext = new TestViewerContext( const viewerContext = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext) createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext)
); );
const cacheAdapter = viewerContext.entityCompanionProvider.getCompanionForEntity( const genericCacher =
RedisTestEntity, viewerContext.entityCompanionProvider.getCompanionForEntity(RedisTestEntity)[
RedisTestEntity.getCompanionDefinition() 'tableDataCoordinator'
)['tableDataCoordinator']['cacheAdapter']; ]['cacheAdapter']['genericCacher'];
const cacheKeyMaker = cacheAdapter['makeCacheKey'].bind(cacheAdapter); const cacheKeyMaker = genericCacher['makeCacheKey'].bind(genericCacher);
const entity1Created = await RedisTestEntity.creator(viewerContext) const entity1Created = await RedisTestEntity.creator(viewerContext)
.setField('name', 'blah') .setField('name', 'blah')
@@ -56,7 +55,7 @@ describe(RedisCacheAdapter, () => {
.enforcing() .enforcing()
.loadByIDAsync(entity1Created.getID()); .loadByIDAsync(entity1Created.getID());
const cachedJSON = await redisCacheAdapterContext.redisClient.get( const cachedJSON = await (genericRedisCacheContext.redisClient as Redis).get(
cacheKeyMaker('id', entity1.getID()) cacheKeyMaker('id', entity1.getID())
); );
const cachedValue = JSON.parse(cachedJSON!); const cachedValue = JSON.parse(cachedJSON!);
@@ -73,7 +72,7 @@ describe(RedisCacheAdapter, () => {
); );
expect(entityNonExistentResult.ok).toBe(false); expect(entityNonExistentResult.ok).toBe(false);
const nonExistentCachedValue = await redisCacheAdapterContext.redisClient.get( const nonExistentCachedValue = await (genericRedisCacheContext.redisClient as Redis).get(
cacheKeyMaker('id', nonExistentId) cacheKeyMaker('id', nonExistentId)
); );
expect(nonExistentCachedValue).toEqual(''); expect(nonExistentCachedValue).toEqual('');
@@ -86,7 +85,7 @@ describe(RedisCacheAdapter, () => {
// invalidate from cache to ensure it invalidates correctly // invalidate from cache to ensure it invalidates correctly
await RedisTestEntity.loader(viewerContext).invalidateFieldsAsync(entity1.getAllFields()); await RedisTestEntity.loader(viewerContext).invalidateFieldsAsync(entity1.getAllFields());
const cachedValueNull = await redisCacheAdapterContext.redisClient.get( const cachedValueNull = await (genericRedisCacheContext.redisClient as Redis).get(
cacheKeyMaker('id', entity1.getID()) cacheKeyMaker('id', entity1.getID())
); );
expect(cachedValueNull).toBe(null); expect(cachedValueNull).toBe(null);
@@ -94,7 +93,7 @@ describe(RedisCacheAdapter, () => {
it('caches and restores date fields', async () => { it('caches and restores date fields', async () => {
const viewerContext = new TestViewerContext( const viewerContext = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext) createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext)
); );
const date = new Date(); const date = new Date();
const entity1 = await enforceAsyncResult( const entity1 = await enforceAsyncResult(
@@ -109,7 +108,7 @@ describe(RedisCacheAdapter, () => {
// simulate new request // simulate new request
const vc2 = new TestViewerContext( const vc2 = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext) createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext)
); );
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);
@@ -117,7 +116,7 @@ describe(RedisCacheAdapter, () => {
it('caches and restores empty string field keys', async () => { it('caches and restores empty string field keys', async () => {
const viewerContext = new TestViewerContext( const viewerContext = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext) createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext)
); );
const entity1 = await enforceAsyncResult( const entity1 = await enforceAsyncResult(
RedisTestEntity.creator(viewerContext).setField('name', '').createAsync() RedisTestEntity.creator(viewerContext).setField('name', '').createAsync()
@@ -129,7 +128,7 @@ describe(RedisCacheAdapter, () => {
// simulate new request // simulate new request
const vc2 = new TestViewerContext( const vc2 = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext) createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext)
); );
const entity3 = await RedisTestEntity.loader(vc2) const entity3 = await RedisTestEntity.loader(vc2)
.enforcing() .enforcing()
@@ -0,0 +1,111 @@
import { CacheStatus, ViewerContext } from '@expo/entity';
import Redis from 'ioredis';
import { URL } from 'url';
import GenericRedisCacher, { GenericRedisCacheContext } from '../GenericRedisCacher';
import RedisTestEntity, {
redisTestEntityConfiguration,
RedisTestEntityFields,
} from '../testfixtures/RedisTestEntity';
import { createRedisIntegrationTestEntityCompanionProvider } from '../testfixtures/createRedisIntegrationTestEntityCompanionProvider';
class TestViewerContext extends ViewerContext {}
describe(GenericRedisCacher, () => {
let genericRedisCacheContext: GenericRedisCacheContext;
beforeAll(() => {
genericRedisCacheContext = {
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-',
ttlSecondsPositive: 86400, // 1 day
ttlSecondsNegative: 600, // 10 minutes
};
});
beforeEach(async () => {
await (genericRedisCacheContext.redisClient as Redis).flushdb();
});
afterAll(async () => {
(genericRedisCacheContext.redisClient as Redis).disconnect();
});
it('has correct caching and loading behavior', async () => {
const viewerContext = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext)
);
const genericRedisCacher = new GenericRedisCacher(
genericRedisCacheContext,
redisTestEntityConfiguration
);
const date = new Date();
const entity1Created = await RedisTestEntity.creator(viewerContext)
.setField('name', 'blah')
.setField('dateField', date)
.enforceCreateAsync();
const testKey = `test-id-key-${entity1Created.getID()}`;
const objectMap = new Map<string, Readonly<RedisTestEntityFields>>([
[testKey, entity1Created.getAllFields()],
]);
await genericRedisCacher.cacheManyAsync(objectMap);
const cachedJSON = await (genericRedisCacheContext.redisClient as Redis).get(testKey);
const cachedValue = JSON.parse(cachedJSON!);
expect(cachedValue).toMatchObject({
id: entity1Created.getID(),
dateField: date.toISOString(),
});
const loadedObjectMap = await genericRedisCacher.loadManyAsync([testKey]);
const cacheLoadResult = loadedObjectMap.get(testKey)!;
expect(cacheLoadResult).toMatchObject({
status: CacheStatus.HIT,
item: entity1Created.getAllFields(),
});
expect(loadedObjectMap.size).toBe(1);
});
it('has correct negative caching behaviour', async () => {
const genericRedisCacher = new GenericRedisCacher(
genericRedisCacheContext,
redisTestEntityConfiguration
);
const testKey = `test-id-key-non-existent-id`;
await genericRedisCacher.cacheDBMissesAsync([testKey]);
const loadedObjectMap = await genericRedisCacher.loadManyAsync([testKey]);
const cacheLoadResult = loadedObjectMap.get(testKey)!;
expect(cacheLoadResult.status).toBe(CacheStatus.NEGATIVE);
});
it('has correct invalidation behaviour', async () => {
const viewerContext = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext)
);
const genericRedisCacher = new GenericRedisCacher(
genericRedisCacheContext,
redisTestEntityConfiguration
);
const date = new Date();
const entity1Created = await RedisTestEntity.creator(viewerContext)
.setField('name', 'blah')
.setField('dateField', date)
.enforceCreateAsync();
const testKey = `test-id-key-${entity1Created.getID()}`;
const objectMap = new Map<string, Readonly<RedisTestEntityFields>>([
[testKey, entity1Created.getAllFields()],
]);
await genericRedisCacher.cacheManyAsync(objectMap);
await genericRedisCacher.invalidateManyAsync([testKey]);
const loadedObjectMap = await genericRedisCacher.loadManyAsync([testKey]);
const cacheLoadResult = loadedObjectMap.get(testKey)!;
expect(cacheLoadResult.status).toBe(CacheStatus.MISS);
});
});
@@ -2,18 +2,19 @@ import { EntityCacheAdapterTransientError, ViewerContext } from '@expo/entity';
import Redis from 'ioredis'; import Redis from 'ioredis';
import { URL } from 'url'; import { URL } from 'url';
import RedisCacheAdapter, { RedisCacheAdapterContext } from '../RedisCacheAdapter'; import GenericRedisCacher, { GenericRedisCacheContext } from '../GenericRedisCacher';
import RedisTestEntity from '../testfixtures/RedisTestEntity'; import RedisTestEntity from '../testfixtures/RedisTestEntity';
import { createRedisIntegrationTestEntityCompanionProvider } from '../testfixtures/createRedisIntegrationTestEntityCompanionProvider'; import { createRedisIntegrationTestEntityCompanionProvider } from '../testfixtures/createRedisIntegrationTestEntityCompanionProvider';
class TestViewerContext extends ViewerContext {} class TestViewerContext extends ViewerContext {}
describe(RedisCacheAdapter, () => { describe(GenericRedisCacher, () => {
let redisCacheAdapterContext: RedisCacheAdapterContext; const redisClient = new Redis(new URL(process.env['REDIS_URL']!).toString());
let genericRedisCacheContext: GenericRedisCacheContext;
beforeAll(() => { beforeAll(() => {
redisCacheAdapterContext = { genericRedisCacheContext = {
redisClient: new Redis(new URL(process.env['REDIS_URL']!).toString()), redisClient,
makeKeyFn(...parts: string[]): string { makeKeyFn(...parts: string[]): string {
const delimiter = ':'; const delimiter = ':';
const escapedParts = parts.map((part) => const escapedParts = parts.map((part) =>
@@ -22,21 +23,20 @@ describe(RedisCacheAdapter, () => {
return escapedParts.join(delimiter); return escapedParts.join(delimiter);
}, },
cacheKeyPrefix: 'test-', cacheKeyPrefix: 'test-',
cacheKeyVersion: 1,
ttlSecondsPositive: 86400, // 1 day ttlSecondsPositive: 86400, // 1 day
ttlSecondsNegative: 600, // 10 minutes ttlSecondsNegative: 600, // 10 minutes
}; };
}); });
beforeEach(async () => { beforeEach(async () => {
await redisCacheAdapterContext.redisClient.flushdb(); await redisClient.flushdb();
}); });
it('throws when redis is disconnected', async () => { it('throws when redis is disconnected', async () => {
redisCacheAdapterContext.redisClient.disconnect(); redisClient.disconnect();
const vc1 = new TestViewerContext( const vc1 = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext) createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext)
); );
await expect( await expect(
@@ -0,0 +1,193 @@
import { CacheStatus, UUIDField, EntityConfiguration } from '@expo/entity';
import { Redis, Pipeline } from 'ioredis';
import { mock, when, instance, anything, verify } from 'ts-mockito';
import GenericRedisCacher from '../GenericRedisCacher';
type BlahFields = {
id: string;
};
const entityConfiguration = new EntityConfiguration<BlahFields>({
idField: 'id',
tableName: 'blah',
schema: {
id: new UUIDField({ columnName: 'id', cache: true }),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
});
describe(GenericRedisCacher, () => {
describe('loadManyAsync', () => {
it('returns appropriate cache results', async () => {
const redisResults = new Map();
const mockRedisClient = mock<Redis>();
// need to have one anything() for each element of spread, in this case 3
when(mockRedisClient.mget(anything(), anything(), anything())).thenCall(async (...keys) =>
keys.map((k) => redisResults.get(k) ?? null)
);
const genericCacher = new GenericRedisCacher(
{
redisClient: instance(mockRedisClient),
makeKeyFn: (...parts) => parts.join(':'),
cacheKeyPrefix: 'hello-',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
},
entityConfiguration
);
const cacheKeyWat = genericCacher['makeCacheKey']('id', 'wat');
const cacheKeyWho = genericCacher['makeCacheKey']('id', 'who');
const cacheKeyWhy = genericCacher['makeCacheKey']('id', 'why');
redisResults.set(cacheKeyWat, JSON.stringify({ id: 'wat' }));
redisResults.set(cacheKeyWho, '');
const results = await genericCacher.loadManyAsync([cacheKeyWat, cacheKeyWho, cacheKeyWhy]);
expect(results.get(cacheKeyWat)).toMatchObject({
status: CacheStatus.HIT,
item: { id: 'wat' },
});
expect(results.get(cacheKeyWho)).toMatchObject({ status: CacheStatus.NEGATIVE });
expect(results.get(cacheKeyWhy)).toMatchObject({ status: CacheStatus.MISS });
expect(results.size).toBe(3);
});
it('returns empty map when passed empty array of fieldValues', async () => {
const genericCacher = new GenericRedisCacher(
{
redisClient: instance(mock<Redis>()),
makeKeyFn: (...parts) => parts.join(':'),
cacheKeyPrefix: 'hello-',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
},
entityConfiguration
);
const results = await genericCacher.loadManyAsync([]);
expect(results).toEqual(new Map());
});
});
describe('cacheManyAsync', () => {
it('correctly caches all objects', 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 genericCacher = new GenericRedisCacher(
{
redisClient: instance(mockRedisClient),
makeKeyFn: (...parts) => parts.join(':'),
cacheKeyPrefix: 'hello-',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
},
entityConfiguration
);
const cacheKey = genericCacher['makeCacheKey']('id', 'wat');
await genericCacher.cacheManyAsync(new Map([[cacheKey, { id: 'wat' }]]));
expect(redisResults.get(cacheKey)).toMatchObject({
value: JSON.stringify({ id: 'wat' }),
code: 'EX',
ttl: 1,
});
});
});
describe('cacheDBMissesAsync', () => {
it('correctly caches misses', 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 genericCacher = new GenericRedisCacher(
{
redisClient: instance(mockRedisClient),
makeKeyFn: (...parts) => parts.join(':'),
cacheKeyPrefix: 'hello-',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
},
entityConfiguration
);
const cacheKey = genericCacher['makeCacheKey']('id', 'wat');
await genericCacher.cacheDBMissesAsync([cacheKey]);
expect(redisResults.get(cacheKey)).toMatchObject({
value: '',
code: 'EX',
ttl: 2,
});
});
});
describe('invalidateManyAsync', () => {
it('invalidates correctly', async () => {
const mockRedisClient = mock<Redis>();
when(mockRedisClient.del()).thenResolve(1);
const genericCacher = new GenericRedisCacher(
{
redisClient: instance(mockRedisClient),
makeKeyFn: (...parts) => parts.join(':'),
cacheKeyPrefix: 'hello-',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
},
entityConfiguration
);
const cacheKey = genericCacher['makeCacheKey']('id', 'wat');
await genericCacher.invalidateManyAsync([cacheKey]);
verify(mockRedisClient.del(cacheKey)).once();
});
it('returns when passed empty array of fieldValues', async () => {
const genericCacher = new GenericRedisCacher(
{
redisClient: instance(mock<Redis>()),
makeKeyFn: (...parts) => parts.join(':'),
cacheKeyPrefix: 'hello-',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
},
entityConfiguration
);
await genericCacher.invalidateManyAsync([]);
});
});
});
@@ -1,169 +0,0 @@
import { CacheStatus, UUIDField, EntityConfiguration } from '@expo/entity';
import { Redis, Pipeline } from 'ioredis';
import { mock, when, instance, anything, verify } from 'ts-mockito';
import RedisCacheAdapter from '../RedisCacheAdapter';
type BlahFields = {
id: string;
};
const entityConfiguration = new EntityConfiguration<BlahFields>({
idField: 'id',
tableName: 'blah',
schema: {
id: new UUIDField({ columnName: 'id', cache: true }),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
});
describe(RedisCacheAdapter, () => {
describe('loadManyAsync', () => {
it('returns appropriate cache results', async () => {
const redisResults = new Map();
const mockRedisClient = mock<Redis>();
// need to have one anything() for each element of spread, in this case 3
when(mockRedisClient.mget(anything(), anything(), 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,
});
redisResults.set(cacheAdapter['makeCacheKey']('id', 'wat'), JSON.stringify({ id: 'wat' }));
redisResults.set(cacheAdapter['makeCacheKey']('id', 'who'), '');
const results = await cacheAdapter.loadManyAsync('id', ['wat', 'who', 'why']);
expect(results.get('wat')).toMatchObject({ status: CacheStatus.HIT, item: { id: 'wat' } });
expect(results.get('who')).toMatchObject({ status: CacheStatus.NEGATIVE });
expect(results.get('why')).toMatchObject({ status: CacheStatus.MISS });
expect(results.size).toBe(3);
});
it('returns empty map when passed empty array of fieldValues', async () => {
const cacheAdapter = new RedisCacheAdapter(entityConfiguration, {
redisClient: instance(mock<Redis>()),
makeKeyFn: (...parts) => parts.join(':'),
cacheKeyVersion: 1,
cacheKeyPrefix: 'hello-',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
});
const results = await cacheAdapter.loadManyAsync('id', []);
expect(results).toEqual(new Map());
});
});
describe('cacheManyAsync', () => {
it('correctly caches all objects', 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,
});
await cacheAdapter.cacheManyAsync('id', new Map([['wat', { id: 'wat' }]]));
const cacheKey = cacheAdapter['makeCacheKey']('id', 'wat');
expect(redisResults.get(cacheKey)).toMatchObject({
value: JSON.stringify({ id: 'wat' }),
code: 'EX',
ttl: 1,
});
});
});
describe('cacheDBMissesAsync', () => {
it('correctly caches misses', 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,
});
await cacheAdapter.cacheDBMissesAsync('id', ['wat']);
const cacheKey = cacheAdapter['makeCacheKey']('id', 'wat');
expect(redisResults.get(cacheKey)).toMatchObject({
value: '',
code: 'EX',
ttl: 2,
});
});
});
describe('invalidateManyAsync', () => {
it('invalidates correctly', async () => {
const mockRedisClient = mock<Redis>();
when(mockRedisClient.del()).thenResolve(1);
const cacheAdapter = new RedisCacheAdapter(entityConfiguration, {
redisClient: instance(mockRedisClient),
makeKeyFn: (...parts) => parts.join(':'),
cacheKeyVersion: 1,
cacheKeyPrefix: 'hello-',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
});
await cacheAdapter.invalidateManyAsync('id', ['wat']);
const cacheKey = cacheAdapter['makeCacheKey']('id', 'wat');
verify(mockRedisClient.del(cacheKey)).once();
});
it('returns when passed empty array of fieldValues', async () => {
const cacheAdapter = new RedisCacheAdapter(entityConfiguration, {
redisClient: instance(mock<Redis>()),
makeKeyFn: (...parts) => parts.join(':'),
cacheKeyVersion: 1,
cacheKeyPrefix: 'hello-',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
});
await cacheAdapter.invalidateManyAsync('id', []);
});
});
});
@@ -4,8 +4,6 @@
* @module @expo/entity-cache-adapter-redis * @module @expo/entity-cache-adapter-redis
*/ */
export { default as RedisCacheAdapter } 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 { default as GenericRedisCacher } from './GenericRedisCacher';
@@ -17,14 +17,18 @@ export type RedisTestEntityFields = {
}; };
export default class RedisTestEntity extends Entity<RedisTestEntityFields, string, ViewerContext> { export default class RedisTestEntity extends Entity<RedisTestEntityFields, string, ViewerContext> {
static getCompanionDefinition(): EntityCompanionDefinition< static defineCompanionDefinition(): EntityCompanionDefinition<
RedisTestEntityFields, RedisTestEntityFields,
string, string,
ViewerContext, ViewerContext,
RedisTestEntity, RedisTestEntity,
RedisTestEntityPrivacyPolicy RedisTestEntityPrivacyPolicy
> { > {
return redisTestEntityCompanionDefinition; return {
entityClass: RedisTestEntity,
entityConfiguration: redisTestEntityConfiguration,
privacyPolicyClass: RedisTestEntityPrivacyPolicy,
};
} }
} }
@@ -87,9 +91,3 @@ export const redisTestEntityConfiguration = new EntityConfiguration<RedisTestEnt
databaseAdapterFlavor: 'postgres', databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis', cacheAdapterFlavor: 'redis',
}); });
const redisTestEntityCompanionDefinition = new EntityCompanionDefinition({
entityClass: RedisTestEntity,
entityConfiguration: redisTestEntityConfiguration,
privacyPolicyClass: RedisTestEntityPrivacyPolicy,
});
@@ -6,11 +6,14 @@ import {
StubDatabaseAdapterProvider, StubDatabaseAdapterProvider,
} from '@expo/entity'; } from '@expo/entity';
import { RedisCacheAdapterContext } from '../RedisCacheAdapter'; import { GenericRedisCacheContext } from '../GenericRedisCacher';
import RedisCacheAdapterProvider from '../RedisCacheAdapterProvider'; import RedisCacheAdapterProvider from '../RedisCacheAdapterProvider';
// share across all in calls in test to simulate postgres
const adapterProvider = new StubDatabaseAdapterProvider();
export const createRedisIntegrationTestEntityCompanionProvider = ( export const createRedisIntegrationTestEntityCompanionProvider = (
redisCacheAdapterContext: RedisCacheAdapterContext, genericRedisCacheContext: GenericRedisCacheContext,
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter() metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter()
): EntityCompanionProvider => { ): EntityCompanionProvider => {
return new EntityCompanionProvider( return new EntityCompanionProvider(
@@ -19,7 +22,7 @@ export const createRedisIntegrationTestEntityCompanionProvider = (
[ [
'postgres', 'postgres',
{ {
adapterProvider: new StubDatabaseAdapterProvider(), adapterProvider,
queryContextProvider: StubQueryContextProvider, queryContextProvider: StubQueryContextProvider,
}, },
], ],
@@ -28,7 +31,7 @@ export const createRedisIntegrationTestEntityCompanionProvider = (
[ [
'redis', 'redis',
{ {
cacheAdapterProvider: new RedisCacheAdapterProvider(redisCacheAdapterContext), cacheAdapterProvider: new RedisCacheAdapterProvider(genericRedisCacheContext),
}, },
], ],
]) ])
@@ -3,6 +3,137 @@
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.33.0](https://github.com/expo/entity/compare/v0.32.0...v0.33.0) (2023-10-06)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
# [0.32.0](https://github.com/expo/entity/compare/v0.31.1...v0.32.0) (2023-06-10)
### Features
* add ability to specify knex transaction config ([#207](https://github.com/expo/entity/issues/207)) ([2069a0d](https://github.com/expo/entity/commit/2069a0d9fd1d8f7d090a71a90a758b2429ea5dd3))
* entity companion definition static thunk ([#210](https://github.com/expo/entity/issues/210)) ([4b18010](https://github.com/expo/entity/commit/4b18010d42be50ef329f428b08330e21bf676586))
# [0.31.0](https://github.com/expo/entity/compare/v0.30.0...v0.31.0) (2023-01-19)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
# [0.30.0](https://github.com/expo/entity/compare/v0.29.0...v0.30.0) (2022-12-23)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
# [0.29.0](https://github.com/expo/entity/compare/v0.28.0...v0.29.0) (2022-10-06)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
# [0.28.0](https://github.com/expo/entity/compare/v0.27.0...v0.28.0) (2022-09-13)
### Features
* add BigIntField ([#193](https://github.com/expo/entity/issues/193)) ([acbad82](https://github.com/expo/entity/commit/acbad826a37bb5a054b4dcf9be91d11aa5f7f29a))
* nested transactions ([#194](https://github.com/expo/entity/issues/194)) ([a77b914](https://github.com/expo/entity/commit/a77b914ad6dc018d1e309c9caf4971088a553ecf))
# [0.27.0](https://github.com/expo/entity/compare/v0.26.1...v0.27.0) (2022-08-09)
### Features
* add orderByRaw to loadManyByRawWhereClauseAsync ([#185](https://github.com/expo/entity/issues/185)) ([2817d78](https://github.com/expo/entity/commit/2817d78392dfecd0093111c24d581bdf01dba06d))
## [0.26.1](https://github.com/expo/entity/compare/v0.26.0...v0.26.1) (2022-07-28)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
# [0.26.0](https://github.com/expo/entity/compare/v0.25.3...v0.26.0) (2022-07-14)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
## [0.25.3](https://github.com/expo/entity/compare/v0.25.2...v0.25.3) (2022-03-12)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
## [0.25.2](https://github.com/expo/entity/compare/v0.25.1...v0.25.2) (2022-03-12)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
## [0.25.1](https://github.com/expo/entity/compare/v0.25.0...v0.25.1) (2022-03-10)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
# [0.25.0](https://github.com/expo/entity/compare/v0.24.0...v0.25.0) (2022-03-10)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
# [0.23.0](https://github.com/expo/entity/compare/v0.22.0...v0.23.0) (2022-02-09)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04) # [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
@@ -1,6 +1,6 @@
{ {
"name": "@expo/entity-database-adapter-knex", "name": "@expo/entity-database-adapter-knex",
"version": "0.22.0", "version": "0.33.0",
"description": "Knex database adapter for @expo/entity", "description": "Knex database adapter for @expo/entity",
"files": [ "files": [
"build", "build",
@@ -19,7 +19,7 @@
"barrelsby": "barrelsby --directory src --location top --exclude tests__ --singleQuotes --exportDefault --delete" "barrelsby": "barrelsby --directory src --location top --exclude tests__ --singleQuotes --exportDefault --delete"
}, },
"engines": { "engines": {
"node": ">=12" "node": ">=16"
}, },
"keywords": [ "keywords": [
"entity" "entity"
@@ -30,10 +30,10 @@
"@expo/entity": "*" "@expo/entity": "*"
}, },
"dependencies": { "dependencies": {
"knex": "^1.0.2" "knex": "^2.4.2"
}, },
"devDependencies": { "devDependencies": {
"@expo/entity": "^0.22.0", "@expo/entity": "^0.33.0",
"pg": "^8.7.1" "pg": "8.10.0"
} }
} }
@@ -0,0 +1,29 @@
import { EntityFieldDefinition } from '@expo/entity';
/**
* EntityFieldDefinition for a Postres column with a JS JSON array type.
*/
export class JSONArrayField extends EntityFieldDefinition<any[]> {
protected validateInputValueInternal(value: any[]): boolean {
return Array.isArray(value);
}
}
/**
* EntityFieldDefinition for a Postgres column that may be a JS JSON array type.
* Does not do any validation.
*/
export class MaybeJSONArrayField extends EntityFieldDefinition<any | any[]> {
protected validateInputValueInternal(_value: any): boolean {
return true;
}
}
/**
* EntityFieldDefinition for a Postgres BIGINT column.
*/
export class BigIntField extends EntityFieldDefinition<string> {
protected validateInputValueInternal(value: string): boolean {
return typeof value === 'string';
}
}
@@ -2,14 +2,14 @@ import {
EntityDatabaseAdapter, EntityDatabaseAdapter,
FieldTransformerMap, FieldTransformerMap,
FieldTransformer, FieldTransformer,
JSONArrayField,
MaybeJSONArrayField,
TableQuerySelectionModifiers, TableQuerySelectionModifiers,
TableFieldSingleValueEqualityCondition, TableFieldSingleValueEqualityCondition,
TableFieldMultiValueEqualityCondition, TableFieldMultiValueEqualityCondition,
TableQuerySelectionModifiersWithOrderByRaw,
} from '@expo/entity'; } from '@expo/entity';
import { Knex } from 'knex'; import { Knex } from 'knex';
import { JSONArrayField, MaybeJSONArrayField } from './EntityFields';
import wrapNativePostgresCallAsync from './errors/wrapNativePostgresCallAsync'; import wrapNativePostgresCallAsync from './errors/wrapNativePostgresCallAsync';
export default class PostgresEntityDatabaseAdapter<TFields> extends EntityDatabaseAdapter<TFields> { export default class PostgresEntityDatabaseAdapter<TFields> extends EntityDatabaseAdapter<TFields> {
@@ -53,6 +53,20 @@ export default class PostgresEntityDatabaseAdapter<TFields> extends EntityDataba
); );
} }
private applyQueryModifiersToQueryOrderByRaw(
query: Knex.QueryBuilder,
querySelectionModifiers: TableQuerySelectionModifiersWithOrderByRaw
): Knex.QueryBuilder {
let ret = this.applyQueryModifiersToQuery(query, querySelectionModifiers);
const { orderByRaw } = querySelectionModifiers;
if (orderByRaw !== undefined) {
ret = ret.orderByRaw(orderByRaw);
}
return ret;
}
private applyQueryModifiersToQuery( private applyQueryModifiersToQuery(
query: Knex.QueryBuilder, query: Knex.QueryBuilder,
querySelectionModifiers: TableQuerySelectionModifiers querySelectionModifiers: TableQuerySelectionModifiers
@@ -129,13 +143,13 @@ export default class PostgresEntityDatabaseAdapter<TFields> extends EntityDataba
tableName: string, tableName: string,
rawWhereClause: string, rawWhereClause: string,
bindings: object | any[], bindings: object | any[],
querySelectionModifiers: TableQuerySelectionModifiers querySelectionModifiers: TableQuerySelectionModifiersWithOrderByRaw
): Promise<object[]> { ): Promise<object[]> {
let query = queryInterface let query = queryInterface
.select() .select()
.from(tableName) .from(tableName)
.whereRaw(rawWhereClause, bindings as any); .whereRaw(rawWhereClause, bindings as any);
query = this.applyQueryModifiersToQuery(query, querySelectionModifiers); query = this.applyQueryModifiersToQueryOrderByRaw(query, querySelectionModifiers);
return await wrapNativePostgresCallAsync(() => query); return await wrapNativePostgresCallAsync(() => query);
} }
@@ -1,4 +1,8 @@
import { EntityQueryContextProvider } from '@expo/entity'; import {
EntityQueryContextProvider,
TransactionConfig,
TransactionIsolationLevel,
} from '@expo/entity';
import { Knex } from 'knex'; import { Knex } from 'knex';
/** /**
@@ -13,9 +17,44 @@ export default class PostgresEntityQueryContextProvider extends EntityQueryConte
return this.knexInstance; return this.knexInstance;
} }
protected createTransactionRunner<T>(): ( protected createTransactionRunner<T>(
transactionScope: (trx: any) => Promise<T> transactionConfig?: TransactionConfig
) => Promise<T> { ): (transactionScope: (trx: any) => Promise<T>) => Promise<T> {
return (transactionScope) => this.knexInstance.transaction(transactionScope); return (transactionScope) =>
this.knexInstance.transaction(
transactionScope,
transactionConfig
? PostgresEntityQueryContextProvider.convertTransactionConfig(transactionConfig)
: undefined
);
}
protected createNestedTransactionRunner<T>(
outerQueryInterface: any
): (transactionScope: (queryInterface: any) => Promise<T>) => Promise<T> {
return (transactionScope) => (outerQueryInterface as Knex).transaction(transactionScope);
}
private static convertTransactionConfig(
transactionConfig: TransactionConfig
): Knex.TransactionConfig {
const convertIsolationLevel = (
isolationLevel: TransactionIsolationLevel
): Knex.IsolationLevels => {
switch (isolationLevel) {
case TransactionIsolationLevel.READ_COMMITTED:
return 'read committed';
case TransactionIsolationLevel.REPEATABLE_READ:
return 'repeatable read';
case TransactionIsolationLevel.SERIALIZABLE:
return 'serializable';
}
};
return {
...(transactionConfig.isolationLevel
? { isolationLevel: convertIsolationLevel(transactionConfig.isolationLevel) }
: {}),
};
} }
} }
@@ -3,6 +3,7 @@ import {
createUnitTestEntityCompanionProvider, createUnitTestEntityCompanionProvider,
enforceResultsAsync, enforceResultsAsync,
ViewerContext, ViewerContext,
TransactionIsolationLevel,
} from '@expo/entity'; } from '@expo/entity';
import { enforceAsyncResult } from '@expo/results'; import { enforceAsyncResult } from '@expo/results';
import { knex, Knex } from 'knex'; import { knex, Knex } from 'knex';
@@ -111,6 +112,44 @@ describe('postgres entity integration', () => {
expect(entities).toHaveLength(1); expect(entities).toHaveLength(1);
}); });
it('passes transaction config into transactions', async () => {
const vc1 = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance));
const firstEntity = await enforceAsyncResult(
PostgresTestEntity.creator(vc1).setField('name', 'hello').createAsync()
);
const loadAndUpdateAsync = async (newName: string): Promise<{ error?: Error }> => {
try {
await vc1.runInTransactionForDatabaseAdaptorFlavorAsync(
'postgres',
async (queryContext) => {
const entity = await PostgresTestEntity.loader(vc1, queryContext)
.enforcing()
.loadByIDAsync(firstEntity.getID());
await PostgresTestEntity.updater(entity, queryContext)
.setField('name', newName)
.enforceUpdateAsync();
},
{ isolationLevel: TransactionIsolationLevel.SERIALIZABLE }
);
return {};
} catch (e) {
return { error: e as Error };
}
};
// do some parallel updates to trigger serializable error in at least some of them
const results = await Promise.all([
loadAndUpdateAsync('hello2'),
loadAndUpdateAsync('hello3'),
loadAndUpdateAsync('hello4'),
loadAndUpdateAsync('hello5'),
]);
expect(results.filter((r) => (r.error as any)?.cause?.code === '40001').length > 0).toBe(true);
});
describe('JSON fields', () => { describe('JSON fields', () => {
it('supports both types of array fields', async () => { it('supports both types of array fields', async () => {
const vc1 = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance)); const vc1 = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance));
@@ -157,6 +196,27 @@ describe('postgres entity integration', () => {
}); });
}); });
describe('BIGINT fields', () => {
it('supports BIGINT fields', async () => {
const vc1 = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance));
let entity = await enforceAsyncResult(
PostgresTestEntity.creator(vc1).setField('bigintField', '72057594037928038').createAsync()
);
expect(entity.getField('bigintField')).toEqual('72057594037928038');
entity = await enforceAsyncResult(
PostgresTestEntity.updater(entity).setField('bigintField', '10').updateAsync()
);
expect(entity.getField('bigintField')).toEqual('10');
entity = await enforceAsyncResult(
PostgresTestEntity.updater(entity).setField('bigintField', '-10').updateAsync()
);
expect(entity.getField('bigintField')).toEqual('-10');
});
});
describe('conjunction field equality loading', () => { describe('conjunction field equality loading', () => {
it('supports single fieldValue and multiple fieldValues', async () => { it('supports single fieldValue and multiple fieldValues', async () => {
const vc1 = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance)); const vc1 = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance));
@@ -379,6 +439,15 @@ describe('postgres entity integration', () => {
expect(resultsMultipleOrderBy).toHaveLength(3); expect(resultsMultipleOrderBy).toHaveLength(3);
expect(resultsMultipleOrderBy.map((e) => e.getField('name'))).toEqual(['c', 'b', 'a']); expect(resultsMultipleOrderBy.map((e) => e.getField('name'))).toEqual(['c', 'b', 'a']);
const resultsOrderByRaw = await PostgresTestEntity.loader(vc1)
.enforcing()
.loadManyByRawWhereClauseAsync('has_a_dog = ?', [true], {
orderByRaw: 'has_a_dog ASC, name DESC',
});
expect(resultsOrderByRaw).toHaveLength(3);
expect(resultsOrderByRaw.map((e) => e.getField('name'))).toEqual(['c', 'b', 'a']);
}); });
}); });
@@ -606,6 +675,7 @@ describe('postgres entity integration', () => {
const vc1 = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance)); const vc1 = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance));
let preCommitCallCount = 0; let preCommitCallCount = 0;
let preCommitInnerCallCount = 0;
let postCommitCallCount = 0; let postCommitCallCount = 0;
await vc1.runInTransactionForDatabaseAdaptorFlavorAsync('postgres', async (queryContext) => { await vc1.runInTransactionForDatabaseAdaptorFlavorAsync('postgres', async (queryContext) => {
@@ -615,6 +685,28 @@ describe('postgres entity integration', () => {
queryContext.appendPreCommitCallback(async () => { queryContext.appendPreCommitCallback(async () => {
preCommitCallCount++; preCommitCallCount++;
}, 0); }, 0);
await queryContext.runInNestedTransactionAsync(async (innerQueryContext) => {
innerQueryContext.appendPostCommitCallback(async () => {
postCommitCallCount++;
});
innerQueryContext.appendPreCommitCallback(async () => {
preCommitInnerCallCount++;
}, 0);
});
// this one throws so its post commit shouldn't execute
try {
await queryContext.runInNestedTransactionAsync(async (innerQueryContext) => {
innerQueryContext.appendPostCommitCallback(async () => {
postCommitCallCount++;
});
innerQueryContext.appendPreCommitCallback(async () => {
preCommitInnerCallCount++;
throw Error('wat');
}, 0);
});
} catch {}
}); });
await expect( await expect(
@@ -630,7 +722,8 @@ describe('postgres entity integration', () => {
).rejects.toThrowError('wat'); ).rejects.toThrowError('wat');
expect(preCommitCallCount).toBe(2); expect(preCommitCallCount).toBe(2);
expect(postCommitCallCount).toBe(1); expect(preCommitInnerCallCount).toBe(2);
expect(postCommitCallCount).toBe(2);
}); });
}); });
}); });
@@ -0,0 +1,101 @@
import { ViewerContext } from '@expo/entity';
import { knex, Knex } from 'knex';
import nullthrows from 'nullthrows';
import PostgresEntityQueryContextProvider from '../PostgresEntityQueryContextProvider';
import PostgresUniqueTestEntity from '../testfixtures/PostgresUniqueTestEntity';
import { createKnexIntegrationTestEntityCompanionProvider } from '../testfixtures/createKnexIntegrationTestEntityCompanionProvider';
describe(PostgresEntityQueryContextProvider, () => {
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 PostgresUniqueTestEntity.createOrTruncatePostgresTable(knexInstance);
});
afterAll(async () => {
await PostgresUniqueTestEntity.dropPostgresTable(knexInstance);
await knexInstance.destroy();
});
it('supports nested transactions', async () => {
const vc1 = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance));
await PostgresUniqueTestEntity.creator(vc1).setField('name', 'unique').enforceCreateAsync();
const id = (
await PostgresUniqueTestEntity.creator(vc1).setField('name', 'wat').enforceCreateAsync()
).getID();
await vc1.runInTransactionForDatabaseAdaptorFlavorAsync('postgres', async (queryContext) => {
const entity = await PostgresUniqueTestEntity.loader(vc1, queryContext)
.enforcing()
.loadByIDAsync(id);
await PostgresUniqueTestEntity.updater(entity, queryContext)
.setField('name', 'wat2')
.enforceUpdateAsync();
// ensure the outer transaction is not aborted due to postgres error in inner transaction,
// in this case the error triggered is a unique constraint violation
try {
await queryContext.runInNestedTransactionAsync(async (innerQueryContext) => {
const entity = await PostgresUniqueTestEntity.loader(vc1, innerQueryContext)
.enforcing()
.loadByIDAsync(id);
await PostgresUniqueTestEntity.updater(entity, innerQueryContext)
.setField('name', 'unique')
.enforceUpdateAsync();
});
} catch {}
const entity2 = await PostgresUniqueTestEntity.loader(vc1, queryContext)
.enforcing()
.loadByIDAsync(id);
await PostgresUniqueTestEntity.updater(entity2, queryContext)
.setField('name', 'wat3')
.enforceUpdateAsync();
});
const entityLoaded = await PostgresUniqueTestEntity.loader(vc1).enforcing().loadByIDAsync(id);
expect(entityLoaded.getField('name')).toEqual('wat3');
});
it('supports multi-nested transactions', async () => {
const vc1 = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance));
const id = (
await PostgresUniqueTestEntity.creator(vc1).setField('name', 'wat').enforceCreateAsync()
).getID();
await vc1.runInTransactionForDatabaseAdaptorFlavorAsync('postgres', async (queryContext) => {
await queryContext.runInNestedTransactionAsync(async (innerQueryContext) => {
await innerQueryContext.runInNestedTransactionAsync(async (innerQueryContex2) => {
await innerQueryContex2.runInNestedTransactionAsync(async (innerQueryContex3) => {
const entity = await PostgresUniqueTestEntity.loader(vc1, innerQueryContex3)
.enforcing()
.loadByIDAsync(id);
await PostgresUniqueTestEntity.updater(entity, innerQueryContex3)
.setField('name', 'wat3')
.enforceUpdateAsync();
});
});
});
});
const entityLoaded = await PostgresUniqueTestEntity.loader(vc1).enforcing().loadByIDAsync(id);
expect(entityLoaded.getField('name')).toEqual('wat3');
});
});
@@ -0,0 +1,19 @@
import { describeFieldTestCase } from '@expo/entity';
import { BigIntField, JSONArrayField, MaybeJSONArrayField } from '../EntityFields';
describeFieldTestCase(
new JSONArrayField({ columnName: 'wat' }),
[[[1, 2]] as any, [['hello']] as any],
[1, 'hello']
);
describeFieldTestCase(
new MaybeJSONArrayField({ columnName: 'wat' }),
[1, 'hello', [['hello']]],
[]
);
describeFieldTestCase(
new BigIntField({ columnName: 'wat' }),
['123457682149816498126412896', '123', '-1', '-124147812641876482716841'],
[1, false, -1, 1e6, {}]
);
@@ -7,3 +7,4 @@
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';
export * from './EntityFields';
@@ -27,21 +27,24 @@ export default class ErrorsTestEntity extends Entity<
number, number,
ViewerContext ViewerContext
> { > {
static getCompanionDefinition(): EntityCompanionDefinition< static defineCompanionDefinition(): EntityCompanionDefinition<
ErrorsTestEntityFields, ErrorsTestEntityFields,
number, number,
ViewerContext, ViewerContext,
ErrorsTestEntity, ErrorsTestEntity,
ErrorsTestEntityPrivacyPolicy ErrorsTestEntityPrivacyPolicy
> { > {
return errorsTestEntityCompanionDefinition; return {
entityClass: ErrorsTestEntity,
entityConfiguration: ErrorsTestEntityConfiguration,
privacyPolicyClass: ErrorsTestEntityPrivacyPolicy,
};
} }
public static async createOrTruncatePostgresTable(knex: Knex): Promise<void> { public static async createOrTruncatePostgresTable(knex: Knex): Promise<void> {
await knex.raw('CREATE EXTENSION IF NOT EXISTS "btree_gist"'); // for gist exclusion on varchar await knex.raw('CREATE EXTENSION IF NOT EXISTS "btree_gist"'); // for gist exclusion on varchar
const tableName = this.getCompanionDefinition().entityConfiguration.tableName; const tableName = 'postgres_test_entities';
const hasForeignTable = await knex.schema.hasTable(foreignTableName); const hasForeignTable = await knex.schema.hasTable(foreignTableName);
if (!hasForeignTable) { if (!hasForeignTable) {
await knex.schema.createTable(foreignTableName, (table) => { await knex.schema.createTable(foreignTableName, (table) => {
@@ -87,7 +90,7 @@ export default class ErrorsTestEntity extends Entity<
} }
public static async dropPostgresTable(knex: Knex): Promise<void> { public static async dropPostgresTable(knex: Knex): Promise<void> {
const tableName = this.getCompanionDefinition().entityConfiguration.tableName; const tableName = 'postgres_test_entities';
const hasTable = await knex.schema.hasTable(tableName); const hasTable = await knex.schema.hasTable(tableName);
if (hasTable) { if (hasTable) {
await knex.schema.dropTable(tableName); await knex.schema.dropTable(tableName);
@@ -169,9 +172,3 @@ export const ErrorsTestEntityConfiguration = new EntityConfiguration<ErrorsTestE
databaseAdapterFlavor: 'postgres', databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis', cacheAdapterFlavor: 'redis',
}); });
const errorsTestEntityCompanionDefinition = new EntityCompanionDefinition({
entityClass: ErrorsTestEntity,
entityConfiguration: ErrorsTestEntityConfiguration,
privacyPolicyClass: ErrorsTestEntityPrivacyPolicy,
});
@@ -20,18 +20,22 @@ export default class InvalidTestEntity extends Entity<
number, number,
ViewerContext ViewerContext
> { > {
static getCompanionDefinition(): EntityCompanionDefinition< static defineCompanionDefinition(): EntityCompanionDefinition<
InvalidTestEntityFields, InvalidTestEntityFields,
number, number,
ViewerContext, ViewerContext,
InvalidTestEntity, InvalidTestEntity,
InvalidTestEntityPrivacyPolicy InvalidTestEntityPrivacyPolicy
> { > {
return invalidTestEntityCompanionDefinition; return {
entityClass: InvalidTestEntity,
entityConfiguration: invalidTestEntityConfiguration,
privacyPolicyClass: InvalidTestEntityPrivacyPolicy,
};
} }
public static async createOrTruncatePostgresTable(knex: Knex): Promise<void> { public static async createOrTruncatePostgresTable(knex: Knex): Promise<void> {
const tableName = this.getCompanionDefinition().entityConfiguration.tableName; const tableName = 'postgres_test_entities';
const hasTable = await knex.schema.hasTable(tableName); const hasTable = await knex.schema.hasTable(tableName);
if (!hasTable) { if (!hasTable) {
await knex.schema.createTable(tableName, (table) => { await knex.schema.createTable(tableName, (table) => {
@@ -43,7 +47,7 @@ export default class InvalidTestEntity extends Entity<
} }
public static async dropPostgresTable(knex: Knex): Promise<void> { public static async dropPostgresTable(knex: Knex): Promise<void> {
const tableName = this.getCompanionDefinition().entityConfiguration.tableName; const tableName = 'postgres_test_entities';
const hasTable = await knex.schema.hasTable(tableName); const hasTable = await knex.schema.hasTable(tableName);
if (hasTable) { if (hasTable) {
await knex.schema.dropTable(tableName); await knex.schema.dropTable(tableName);
@@ -105,9 +109,3 @@ export const invalidTestEntityConfiguration = new EntityConfiguration<InvalidTes
databaseAdapterFlavor: 'postgres', databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis', cacheAdapterFlavor: 'redis',
}); });
const invalidTestEntityCompanionDefinition = new EntityCompanionDefinition({
entityClass: InvalidTestEntity,
entityConfiguration: invalidTestEntityConfiguration,
privacyPolicyClass: InvalidTestEntityPrivacyPolicy,
});
@@ -6,16 +6,16 @@ import {
StringField, StringField,
BooleanField, BooleanField,
StringArrayField, StringArrayField,
JSONArrayField,
JSONObjectField, JSONObjectField,
DateField, DateField,
MaybeJSONArrayField,
EntityConfiguration, EntityConfiguration,
EntityCompanionDefinition, EntityCompanionDefinition,
Entity, Entity,
} from '@expo/entity'; } from '@expo/entity';
import { Knex } from 'knex'; import { Knex } from 'knex';
import { BigIntField, JSONArrayField, MaybeJSONArrayField } from '../EntityFields';
type PostgresTestEntityFields = { type PostgresTestEntityFields = {
id: string; id: string;
name: string | null; name: string | null;
@@ -28,6 +28,7 @@ type PostgresTestEntityFields = {
} | null; } | null;
dateField: Date | null; dateField: Date | null;
maybeJsonArrayField: string[] | { hello: string } | null; maybeJsonArrayField: string[] | { hello: string } | null;
bigintField: string | null;
}; };
export default class PostgresTestEntity extends Entity< export default class PostgresTestEntity extends Entity<
@@ -35,24 +36,26 @@ export default class PostgresTestEntity extends Entity<
string, string,
ViewerContext ViewerContext
> { > {
static getCompanionDefinition(): EntityCompanionDefinition< static defineCompanionDefinition(): EntityCompanionDefinition<
PostgresTestEntityFields, PostgresTestEntityFields,
string, string,
ViewerContext, ViewerContext,
PostgresTestEntity, PostgresTestEntity,
PostgresTestEntityPrivacyPolicy PostgresTestEntityPrivacyPolicy
> { > {
return postgresTestEntityCompanionDefinition; return {
entityClass: PostgresTestEntity,
entityConfiguration: postgresTestEntityConfiguration,
privacyPolicyClass: PostgresTestEntityPrivacyPolicy,
};
} }
public static async createOrTruncatePostgresTable(knex: Knex): Promise<void> { public static async createOrTruncatePostgresTable(knex: Knex): Promise<void> {
await knex.raw('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"'); // for uuid_generate_v4() const tableName = 'postgres_test_entities';
const tableName = this.getCompanionDefinition().entityConfiguration.tableName;
const hasTable = await knex.schema.hasTable(tableName); const hasTable = await knex.schema.hasTable(tableName);
if (!hasTable) { if (!hasTable) {
await knex.schema.createTable(tableName, (table) => { await knex.schema.createTable(tableName, (table) => {
table.uuid('id').defaultTo(knex.raw('uuid_generate_v4()')).primary(); table.uuid('id').defaultTo(knex.raw('gen_random_uuid()')).primary();
table.string('name'); table.string('name');
table.boolean('has_a_dog'); table.boolean('has_a_dog');
table.boolean('has_a_cat'); table.boolean('has_a_cat');
@@ -61,13 +64,14 @@ export default class PostgresTestEntity extends Entity<
table.jsonb('json_object_field'); table.jsonb('json_object_field');
table.dateTime('date_field', { useTz: true }); table.dateTime('date_field', { useTz: true });
table.jsonb('maybe_json_array_field'); table.jsonb('maybe_json_array_field');
table.bigint('bigint_field');
}); });
} }
await knex.into(tableName).truncate(); await knex.into(tableName).truncate();
} }
public static async dropPostgresTable(knex: Knex): Promise<void> { public static async dropPostgresTable(knex: Knex): Promise<void> {
const tableName = this.getCompanionDefinition().entityConfiguration.tableName; const tableName = 'postgres_test_entities';
const hasTable = await knex.schema.hasTable(tableName); const hasTable = await knex.schema.hasTable(tableName);
if (hasTable) { if (hasTable) {
await knex.schema.dropTable(tableName); await knex.schema.dropTable(tableName);
@@ -147,13 +151,10 @@ export const postgresTestEntityConfiguration = new EntityConfiguration<PostgresT
maybeJsonArrayField: new MaybeJSONArrayField({ maybeJsonArrayField: new MaybeJSONArrayField({
columnName: 'maybe_json_array_field', columnName: 'maybe_json_array_field',
}), }),
bigintField: new BigIntField({
columnName: 'bigint_field',
}),
}, },
databaseAdapterFlavor: 'postgres', databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis', cacheAdapterFlavor: 'redis',
}); });
const postgresTestEntityCompanionDefinition = new EntityCompanionDefinition({
entityClass: PostgresTestEntity,
entityConfiguration: postgresTestEntityConfiguration,
privacyPolicyClass: PostgresTestEntityPrivacyPolicy,
});
@@ -24,24 +24,37 @@ export default class PostgresTriggerTestEntity extends Entity<
string, string,
ViewerContext ViewerContext
> { > {
static getCompanionDefinition(): EntityCompanionDefinition< static defineCompanionDefinition(): EntityCompanionDefinition<
PostgresTriggerTestEntityFields, PostgresTriggerTestEntityFields,
string, string,
ViewerContext, ViewerContext,
PostgresTriggerTestEntity, PostgresTriggerTestEntity,
PostgresTriggerTestEntityPrivacyPolicy PostgresTriggerTestEntityPrivacyPolicy
> { > {
return postgresTestEntityCompanionDefinition; return {
entityClass: PostgresTriggerTestEntity,
entityConfiguration: postgresTestEntityConfiguration,
privacyPolicyClass: PostgresTriggerTestEntityPrivacyPolicy,
mutationTriggers: {
beforeCreate: [new ThrowConditionallyTrigger('name', 'beforeCreate')],
afterCreate: [new ThrowConditionallyTrigger('name', 'afterCreate')],
beforeUpdate: [new ThrowConditionallyTrigger('name', 'beforeUpdate')],
afterUpdate: [new ThrowConditionallyTrigger('name', 'afterUpdate')],
beforeDelete: [new ThrowConditionallyTrigger('name', 'beforeDelete')],
afterDelete: [new ThrowConditionallyTrigger('name', 'afterDelete')],
beforeAll: [new ThrowConditionallyTrigger('name', 'beforeAll')],
afterAll: [new ThrowConditionallyTrigger('name', 'afterAll')],
afterCommit: [new ThrowConditionallyNonTransactionalTrigger('name', 'afterCommit')],
},
};
} }
public static async createOrTruncatePostgresTable(knex: Knex): Promise<void> { public static async createOrTruncatePostgresTable(knex: Knex): Promise<void> {
await knex.raw('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"'); // for uuid_generate_v4() const tableName = 'postgres_test_entities';
const tableName = this.getCompanionDefinition().entityConfiguration.tableName;
const hasTable = await knex.schema.hasTable(tableName); const hasTable = await knex.schema.hasTable(tableName);
if (!hasTable) { if (!hasTable) {
await knex.schema.createTable(tableName, (table) => { await knex.schema.createTable(tableName, (table) => {
table.uuid('id').defaultTo(knex.raw('uuid_generate_v4()')).primary(); table.uuid('id').defaultTo(knex.raw('gen_random_uuid()')).primary();
table.string('name'); table.string('name');
}); });
} }
@@ -49,7 +62,7 @@ export default class PostgresTriggerTestEntity extends Entity<
} }
public static async dropPostgresTable(knex: Knex): Promise<void> { public static async dropPostgresTable(knex: Knex): Promise<void> {
const tableName = this.getCompanionDefinition().entityConfiguration.tableName; const tableName = 'postgres_test_entities';
const hasTable = await knex.schema.hasTable(tableName); const hasTable = await knex.schema.hasTable(tableName);
if (hasTable) { if (hasTable) {
await knex.schema.dropTable(tableName); await knex.schema.dropTable(tableName);
@@ -160,20 +173,3 @@ export const postgresTestEntityConfiguration =
databaseAdapterFlavor: 'postgres', databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis', cacheAdapterFlavor: 'redis',
}); });
const postgresTestEntityCompanionDefinition = new EntityCompanionDefinition({
entityClass: PostgresTriggerTestEntity,
entityConfiguration: postgresTestEntityConfiguration,
privacyPolicyClass: PostgresTriggerTestEntityPrivacyPolicy,
mutationTriggers: () => ({
beforeCreate: [new ThrowConditionallyTrigger('name', 'beforeCreate')],
afterCreate: [new ThrowConditionallyTrigger('name', 'afterCreate')],
beforeUpdate: [new ThrowConditionallyTrigger('name', 'beforeUpdate')],
afterUpdate: [new ThrowConditionallyTrigger('name', 'afterUpdate')],
beforeDelete: [new ThrowConditionallyTrigger('name', 'beforeDelete')],
afterDelete: [new ThrowConditionallyTrigger('name', 'afterDelete')],
beforeAll: [new ThrowConditionallyTrigger('name', 'beforeAll')],
afterAll: [new ThrowConditionallyTrigger('name', 'afterAll')],
afterCommit: [new ThrowConditionallyNonTransactionalTrigger('name', 'afterCommit')],
}),
});
@@ -0,0 +1,113 @@
import {
AlwaysAllowPrivacyPolicyRule,
EntityPrivacyPolicy,
ViewerContext,
UUIDField,
StringField,
EntityConfiguration,
EntityCompanionDefinition,
Entity,
} from '@expo/entity';
import { Knex } from 'knex';
type PostgresUniqueTestEntityFields = {
id: string;
name: string | null;
};
export default class PostgresUniqueTestEntity extends Entity<
PostgresUniqueTestEntityFields,
string,
ViewerContext
> {
static defineCompanionDefinition(): EntityCompanionDefinition<
PostgresUniqueTestEntityFields,
string,
ViewerContext,
PostgresUniqueTestEntity,
PostgresUniqueTestEntityPrivacyPolicy
> {
return {
entityClass: PostgresUniqueTestEntity,
entityConfiguration: postgresTestEntityConfiguration,
privacyPolicyClass: PostgresUniqueTestEntityPrivacyPolicy,
};
}
public static async createOrTruncatePostgresTable(knex: Knex): Promise<void> {
const tableName = 'postgres_test_entities';
const hasTable = await knex.schema.hasTable(tableName);
if (!hasTable) {
await knex.schema.createTable(tableName, (table) => {
table.uuid('id').defaultTo(knex.raw('gen_random_uuid()')).primary();
table.string('name').unique();
});
}
await knex.into(tableName).truncate();
}
public static async dropPostgresTable(knex: Knex): Promise<void> {
const tableName = 'postgres_test_entities';
const hasTable = await knex.schema.hasTable(tableName);
if (hasTable) {
await knex.schema.dropTable(tableName);
}
}
}
class PostgresUniqueTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
PostgresUniqueTestEntityFields,
string,
ViewerContext,
PostgresUniqueTestEntity
> {
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<
PostgresUniqueTestEntityFields,
string,
ViewerContext,
PostgresUniqueTestEntity
>(),
];
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<
PostgresUniqueTestEntityFields,
string,
ViewerContext,
PostgresUniqueTestEntity
>(),
];
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<
PostgresUniqueTestEntityFields,
string,
ViewerContext,
PostgresUniqueTestEntity
>(),
];
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<
PostgresUniqueTestEntityFields,
string,
ViewerContext,
PostgresUniqueTestEntity
>(),
];
}
export const postgresTestEntityConfiguration =
new EntityConfiguration<PostgresUniqueTestEntityFields>({
idField: 'id',
tableName: 'postgres_test_entities',
schema: {
id: new UUIDField({
columnName: 'id',
cache: true,
}),
name: new StringField({
columnName: 'name',
}),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
});
@@ -23,24 +23,27 @@ export default class PostgresValidatorTestEntity extends Entity<
string, string,
ViewerContext ViewerContext
> { > {
static getCompanionDefinition(): EntityCompanionDefinition< static defineCompanionDefinition(): EntityCompanionDefinition<
PostgresValidatorTestEntityFields, PostgresValidatorTestEntityFields,
string, string,
ViewerContext, ViewerContext,
PostgresValidatorTestEntity, PostgresValidatorTestEntity,
PostgresValidatorTestEntityPrivacyPolicy PostgresValidatorTestEntityPrivacyPolicy
> { > {
return postgresTestEntityCompanionDefinition; return {
entityClass: PostgresValidatorTestEntity,
entityConfiguration: postgresTestEntityConfiguration,
privacyPolicyClass: PostgresValidatorTestEntityPrivacyPolicy,
mutationValidators: [new ThrowConditionallyTrigger('name', 'beforeCreateAndBeforeUpdate')],
};
} }
public static async createOrTruncatePostgresTable(knex: Knex): Promise<void> { public static async createOrTruncatePostgresTable(knex: Knex): Promise<void> {
await knex.raw('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"'); // for uuid_generate_v4() const tableName = 'postgres_test_entities';
const tableName = this.getCompanionDefinition().entityConfiguration.tableName;
const hasTable = await knex.schema.hasTable(tableName); const hasTable = await knex.schema.hasTable(tableName);
if (!hasTable) { if (!hasTable) {
await knex.schema.createTable(tableName, (table) => { await knex.schema.createTable(tableName, (table) => {
table.uuid('id').defaultTo(knex.raw('uuid_generate_v4()')).primary(); table.uuid('id').defaultTo(knex.raw('gen_random_uuid()')).primary();
table.string('name'); table.string('name');
}); });
} }
@@ -48,7 +51,7 @@ export default class PostgresValidatorTestEntity extends Entity<
} }
public static async dropPostgresTable(knex: Knex): Promise<void> { public static async dropPostgresTable(knex: Knex): Promise<void> {
const tableName = this.getCompanionDefinition().entityConfiguration.tableName; const tableName = 'postgres_test_entities';
const hasTable = await knex.schema.hasTable(tableName); const hasTable = await knex.schema.hasTable(tableName);
if (hasTable) { if (hasTable) {
await knex.schema.dropTable(tableName); await knex.schema.dropTable(tableName);
@@ -142,10 +145,3 @@ export const postgresTestEntityConfiguration =
databaseAdapterFlavor: 'postgres', databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis', cacheAdapterFlavor: 'redis',
}); });
const postgresTestEntityCompanionDefinition = new EntityCompanionDefinition({
entityClass: PostgresValidatorTestEntity,
entityConfiguration: postgresTestEntityConfiguration,
privacyPolicyClass: PostgresValidatorTestEntityPrivacyPolicy,
mutationValidators: () => [new ThrowConditionallyTrigger('name', 'beforeCreateAndBeforeUpdate')],
});
+130
View File
@@ -3,6 +3,136 @@
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.33.0](https://github.com/expo/entity/compare/v0.32.0...v0.33.0) (2023-10-06)
**Note:** Version bump only for package @expo/entity-example
# [0.32.0](https://github.com/expo/entity/compare/v0.31.1...v0.32.0) (2023-06-10)
### Features
* add ability to specify knex transaction config ([#207](https://github.com/expo/entity/issues/207)) ([2069a0d](https://github.com/expo/entity/commit/2069a0d9fd1d8f7d090a71a90a758b2429ea5dd3))
* entity companion definition static thunk ([#210](https://github.com/expo/entity/issues/210)) ([4b18010](https://github.com/expo/entity/commit/4b18010d42be50ef329f428b08330e21bf676586))
# [0.31.0](https://github.com/expo/entity/compare/v0.30.0...v0.31.0) (2023-01-19)
**Note:** Version bump only for package @expo/entity-example
# [0.30.0](https://github.com/expo/entity/compare/v0.29.0...v0.30.0) (2022-12-23)
**Note:** Version bump only for package @expo/entity-example
# [0.29.0](https://github.com/expo/entity/compare/v0.28.0...v0.29.0) (2022-10-06)
**Note:** Version bump only for package @expo/entity-example
# [0.28.0](https://github.com/expo/entity/compare/v0.27.0...v0.28.0) (2022-09-13)
### Features
* nested transactions ([#194](https://github.com/expo/entity/issues/194)) ([a77b914](https://github.com/expo/entity/commit/a77b914ad6dc018d1e309c9caf4971088a553ecf))
# [0.27.0](https://github.com/expo/entity/compare/v0.26.1...v0.27.0) (2022-08-09)
**Note:** Version bump only for package @expo/entity-example
## [0.26.1](https://github.com/expo/entity/compare/v0.26.0...v0.26.1) (2022-07-28)
**Note:** Version bump only for package @expo/entity-example
# [0.26.0](https://github.com/expo/entity/compare/v0.25.3...v0.26.0) (2022-07-14)
**Note:** Version bump only for package @expo/entity-example
## [0.25.3](https://github.com/expo/entity/compare/v0.25.2...v0.25.3) (2022-03-12)
**Note:** Version bump only for package @expo/entity-example
## [0.25.2](https://github.com/expo/entity/compare/v0.25.1...v0.25.2) (2022-03-12)
**Note:** Version bump only for package @expo/entity-example
## [0.25.1](https://github.com/expo/entity/compare/v0.25.0...v0.25.1) (2022-03-10)
**Note:** Version bump only for package @expo/entity-example
# [0.25.0](https://github.com/expo/entity/compare/v0.24.0...v0.25.0) (2022-03-10)
### Features
* privacy policy evaluation context ([#162](https://github.com/expo/entity/issues/162)) ([3a81a4c](https://github.com/expo/entity/commit/3a81a4c059e2e995eb800e35e76b3d432c22b23f))
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
**Note:** Version bump only for package @expo/entity-example
# [0.23.0](https://github.com/expo/entity/compare/v0.22.0...v0.23.0) (2022-02-09)
**Note:** Version bump only for package @expo/entity-example
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04) # [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 **Note:** Version bump only for package @expo/entity-example
+14 -10
View File
@@ -1,7 +1,7 @@
{ {
"name": "@expo/entity-example", "name": "@expo/entity-example",
"private": true, "private": true,
"version": "0.22.0", "version": "0.33.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",
@@ -15,7 +15,7 @@
"start": "tsnd --transpile-only --inspect --respawn --no-notify --async-stack-traces --throw-deprecation -- src/index.ts" "start": "tsnd --transpile-only --inspect --respawn --no-notify --async-stack-traces --throw-deprecation -- src/index.ts"
}, },
"engines": { "engines": {
"node": ">=12" "node": ">=16"
}, },
"keywords": [ "keywords": [
"entity" "entity"
@@ -23,16 +23,20 @@
"author": "Expo", "author": "Expo",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@expo/entity": "^0.22.0", "@apollo/server": "^4.7.3",
"apollo-server-koa": "^2.16.1", "@as-integrations/koa": "^1.1.1",
"graphql": "^15.3.0", "@expo/entity": "^0.33.0",
"koa": "^2.13.0", "@koa/cors": "^4.0.0",
"koa-body": "^4.2.0", "@koa/router": "^12.0.0",
"koa-router": "^9.4.0" "graphql": "^16.6.0",
"koa": "^2.14.2",
"koa-bodyparser": "^4.4.0"
}, },
"devDependencies": { "devDependencies": {
"@types/koa": "^2.11.4", "@types/koa": "^2.13.6",
"@types/koa-router": "^7.4.1", "@types/koa-bodyparser": "^4.3.10",
"@types/koa__cors": "^4.0.0",
"@types/koa__router": "^12.0.0",
"@types/supertest": "^2.0.10", "@types/supertest": "^2.0.10",
"supertest": "^4.0.2", "supertest": "^4.0.2",
"ts-node-dev": "^1.0.0-pre.60" "ts-node-dev": "^1.0.0-pre.60"
@@ -2,10 +2,11 @@ import http from 'http';
import request from 'supertest'; import request from 'supertest';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import app from '../app'; import createAppAsync from '../app';
describe('graphql', () => { describe('graphql', () => {
it('allows CRUD of user notes', async () => { it('allows CRUD of user notes', async () => {
const app = await createAppAsync();
const server = request(http.createServer(app.callback())); const server = request(http.createServer(app.callback()));
const userId = uuidv4(); const userId = uuidv4();
@@ -117,6 +118,7 @@ describe('graphql', () => {
}); });
it('disallows anonymous note creation', async () => { it('disallows anonymous note creation', async () => {
const app = await createAppAsync();
const server = request(http.createServer(app.callback())); const server = request(http.createServer(app.callback()));
const createResponse1 = await server.post('/graphql').send({ const createResponse1 = await server.post('/graphql').send({
query: `mutation($note: NoteInput!) { addNote(note: $note) { id, title, body } }`, query: `mutation($note: NoteInput!) { addNote(note: $note) { id, title, body } }`,
@@ -133,6 +135,7 @@ describe('graphql', () => {
}); });
it('disallows cross-user note impersonation', async () => { it('disallows cross-user note impersonation', async () => {
const app = await createAppAsync();
const server = request(http.createServer(app.callback())); const server = request(http.createServer(app.callback()));
const userId = uuidv4(); const userId = uuidv4();
@@ -2,15 +2,17 @@ import http from 'http';
import request from 'supertest'; import request from 'supertest';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import app from '../app'; import createAppAsync from '../app';
describe('notesRouter', () => { describe('notesRouter', () => {
it('returns empty array for logged-out', async () => { it('returns empty array for logged-out', async () => {
const app = await createAppAsync();
const response = await request(http.createServer(app.callback())).get('/notes/').send(); const response = await request(http.createServer(app.callback())).get('/notes/').send();
expect(response.body.notes).toEqual([]); expect(response.body.notes).toEqual([]);
}); });
it('allows CRUD of user notes', async () => { it('allows CRUD of user notes', async () => {
const app = await createAppAsync();
const server = request(http.createServer(app.callback())); const server = request(http.createServer(app.callback()));
const userId = uuidv4(); const userId = uuidv4();
@@ -84,6 +86,7 @@ describe('notesRouter', () => {
}); });
it('disallows anonymous note creation', async () => { it('disallows anonymous note creation', async () => {
const app = await createAppAsync();
const server = request(http.createServer(app.callback())); const server = request(http.createServer(app.callback()));
const createResponse1 = await server.post('/notes/').send({ const createResponse1 = await server.post('/notes/').send({
title: 'track', title: 'track',
@@ -94,6 +97,7 @@ describe('notesRouter', () => {
}); });
it('disallows cross-user note impersonation', async () => { it('disallows cross-user note impersonation', async () => {
const app = await createAppAsync();
const server = request(http.createServer(app.callback())); const server = request(http.createServer(app.callback()));
const userId = uuidv4(); const userId = uuidv4();
const userId2 = uuidv4(); const userId2 = uuidv4();
@@ -1,13 +1,19 @@
import { EntityQueryContextProvider } from '@expo/entity'; import { EntityQueryContextProvider, TransactionConfig } from '@expo/entity';
export default class InMemoryQueryContextProvider extends EntityQueryContextProvider { export default class InMemoryQueryContextProvider extends EntityQueryContextProvider {
protected getQueryInterface(): any { protected getQueryInterface(): any {
return {}; return {};
} }
protected createTransactionRunner<T>(): ( protected createTransactionRunner<T>(
transactionScope: (queryInterface: any) => Promise<T> _transactionConfig?: TransactionConfig
) => Promise<T> { ): (transactionScope: (queryInterface: any) => Promise<T>) => Promise<T> {
return (transactionScope) => Promise.resolve(transactionScope({}));
}
protected createNestedTransactionRunner<T>(
_outerQueryInterface: any
): (transactionScope: (queryInterface: any) => Promise<T>) => Promise<T> {
return (transactionScope) => Promise.resolve(transactionScope({})); return (transactionScope) => Promise.resolve(transactionScope({}));
} }
} }
+37 -26
View File
@@ -1,15 +1,17 @@
import { ApolloServer } from '@apollo/server';
import { koaMiddleware } from '@as-integrations/koa';
import { EntityCompanionProvider } from '@expo/entity'; import { EntityCompanionProvider } from '@expo/entity';
import { ApolloServer } from 'apollo-server-koa'; import cors from '@koa/cors';
import Router from '@koa/router';
import Koa from 'koa'; import Koa from 'koa';
import koaBody from 'koa-body'; import bodyParser from 'koa-bodyparser';
import KoaRouter, { RouterContext } from 'koa-router';
import { entityCompanionMiddleware, viewerContextMiddleware } from './middleware'; import { entityCompanionMiddleware, viewerContextMiddleware } from './middleware';
import notesRouter from './routers/notesRouter'; import notesRouter from './routers/notesRouter';
import { typeDefs, resolvers } from './schema'; import { typeDefs, resolvers } from './schema';
import { ExampleViewerContext } from './viewerContexts'; import { ExampleViewerContext } from './viewerContexts';
export type ExampleContext = RouterContext<ExampleState>; export type ExampleContext = Koa.ParameterizedContext<ExampleState>;
/** /**
* Koa provides a per-request state container to place custom properties. * Koa provides a per-request state container to place custom properties.
@@ -21,31 +23,40 @@ export type ExampleState = {
entityCompanionProvider: EntityCompanionProvider; // entityCompanionMiddleware entityCompanionProvider: EntityCompanionProvider; // entityCompanionMiddleware
}; };
const app = new Koa(); export default async function createAppAsync(): Promise<Koa<ExampleState, ExampleContext>> {
const app = new Koa<ExampleState, ExampleContext>();
// body parsing for POST/PUT requests // initialze the entity framework for each request
app.use(koaBody()); app.use(entityCompanionMiddleware);
// initialze the entity framework for each request // generate a viewer context for each request
app.use(entityCompanionMiddleware); app.use(viewerContextMiddleware);
// generate a viewer context for each request app.use(cors());
app.use(viewerContextMiddleware);
// serve routes that make use of the viewer context // body parsing for POST/PUT requests
const router = new KoaRouter<ExampleState>(); app.use(bodyParser());
router.use(notesRouter.routes());
app.use(router.routes()).use(router.allowedMethods());
const server = new ApolloServer({ // serve routes that make use of the viewer context
typeDefs, const router = new Router<ExampleState, ExampleContext>();
resolvers,
context: ({ ctx }) => {
return {
viewerContext: ctx.state.viewerContext,
};
},
});
app.use(server.getMiddleware({ cors: false, disableHealthCheck: true, bodyParserConfig: false }));
export default app; // normal API routes
router.use(notesRouter.routes());
// GraphQL routes
const server = new ApolloServer({
typeDefs,
resolvers,
});
await server.start();
router.post(
'/graphql',
koaMiddleware(server, {
context: async ({ ctx }) => ({ viewerContext: (ctx as ExampleContext).state.viewerContext }),
})
);
app.use(router.routes()).use(router.allowedMethods());
return app;
}
@@ -3,6 +3,7 @@ import {
ReadonlyEntity, ReadonlyEntity,
EntityQueryContext, EntityQueryContext,
RuleEvaluationResult, RuleEvaluationResult,
EntityPrivacyPolicyEvaluationContext,
} from '@expo/entity'; } from '@expo/entity';
import { ExampleViewerContext } from '../viewerContexts'; import { ExampleViewerContext } from '../viewerContexts';
@@ -22,7 +23,7 @@ import { ExampleViewerContext } from '../viewerContexts';
* Otherwise, it defers to the next rule in the policy. * Otherwise, it defers to the next rule in the policy.
*/ */
export default class AllowIfUserOwnerPrivacyRule< export default class AllowIfUserOwnerPrivacyRule<
TFields, TFields extends object,
TID extends NonNullable<TFields[TSelectedFields]>, TID extends NonNullable<TFields[TSelectedFields]>,
TEntity extends ReadonlyEntity<TFields, TID, ExampleViewerContext>, TEntity extends ReadonlyEntity<TFields, TID, ExampleViewerContext>,
TSelectedFields extends keyof TFields = keyof TFields TSelectedFields extends keyof TFields = keyof TFields
@@ -34,6 +35,7 @@ export default class AllowIfUserOwnerPrivacyRule<
async evaluateAsync( async evaluateAsync(
viewerContext: ExampleViewerContext, viewerContext: ExampleViewerContext,
_queryContext: EntityQueryContext, _queryContext: EntityQueryContext,
_evaluationContext: EntityPrivacyPolicyEvaluationContext,
entity: TEntity entity: TEntity
): Promise<RuleEvaluationResult> { ): Promise<RuleEvaluationResult> {
if (viewerContext.isUserViewerContext()) { if (viewerContext.isUserViewerContext()) {
@@ -6,8 +6,8 @@ import {
StringField, StringField,
} from '@expo/entity'; } from '@expo/entity';
import { ExampleViewerContext } from '../viewerContexts';
import NotePrivacyPolicy from './NotePrivacyPolicy'; import NotePrivacyPolicy from './NotePrivacyPolicy';
import { ExampleViewerContext } from '../viewerContexts';
export interface NoteFields { export interface NoteFields {
id: string; id: string;
@@ -20,43 +20,41 @@ export interface NoteFields {
* A simple entity representing a "notes" table/collection. Each note has an owner, title, and body. * A simple entity representing a "notes" table/collection. Each note has an owner, title, and body.
*/ */
export default class NoteEntity extends Entity<NoteFields, string, ExampleViewerContext> { export default class NoteEntity extends Entity<NoteFields, string, ExampleViewerContext> {
static getCompanionDefinition(): EntityCompanionDefinition< /**
* The companion provides configuration instructions to the Entity framework for this type
* of entity. In some languages, this would be representable as "abstract" static members
* of the class itself, but TypeScript disallows static generic and abstract methods.
*/
static defineCompanionDefinition(): EntityCompanionDefinition<
NoteFields, NoteFields,
string, string,
ExampleViewerContext, ExampleViewerContext,
NoteEntity, NoteEntity,
NotePrivacyPolicy NotePrivacyPolicy
> { > {
return noteEntityCompanion; return {
entityClass: NoteEntity,
entityConfiguration: new EntityConfiguration<NoteFields>({
idField: 'id',
tableName: 'notes',
schema: {
id: new UUIDField({
columnName: 'id',
}),
userID: new UUIDField({
columnName: 'user_id',
}),
title: new StringField({
columnName: 'title',
}),
body: new StringField({
columnName: 'body',
}),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
}),
privacyPolicyClass: NotePrivacyPolicy,
};
} }
} }
/**
* The companion provides configuration instructions to the Entity framework for this type
* of entity. In some languages, this would be representable as "abstract" static members
* of the class itself, but TypeScript disallows static generic and abstract methods.
*/
export const noteEntityCompanion = new EntityCompanionDefinition({
entityClass: NoteEntity,
entityConfiguration: new EntityConfiguration<NoteFields>({
idField: 'id',
tableName: 'notes',
schema: {
id: new UUIDField({
columnName: 'id',
}),
userID: new UUIDField({
columnName: 'user_id',
}),
title: new StringField({
columnName: 'title',
}),
body: new StringField({
columnName: 'body',
}),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
}),
privacyPolicyClass: NotePrivacyPolicy,
});
@@ -1,8 +1,8 @@
import { EntityPrivacyPolicy, AlwaysAllowPrivacyPolicyRule } from '@expo/entity'; import { EntityPrivacyPolicy, AlwaysAllowPrivacyPolicyRule } from '@expo/entity';
import { ExampleViewerContext } from '../viewerContexts';
import AllowIfUserOwnerPrivacyRule from './AllowIfUserOwnerPrivacyRule'; import AllowIfUserOwnerPrivacyRule from './AllowIfUserOwnerPrivacyRule';
import NoteEntity, { NoteFields } from './NoteEntity'; import NoteEntity, { NoteFields } from './NoteEntity';
import { ExampleViewerContext } from '../viewerContexts';
/** /**
* For purposes of this demonstration, notes are considered public and can only be mutated by the owner. * For purposes of this demonstration, notes are considered public and can only be mutated by the owner.
+7 -2
View File
@@ -1,3 +1,8 @@
import app from './app'; import createAppAsync from './app';
app.listen(3000); async function main(): Promise<void> {
const app = await createAppAsync();
app.listen(3000);
}
main();
@@ -1,4 +1,4 @@
import KoaRouter from 'koa-router'; import Router from '@koa/router';
import { ExampleState, ExampleContext } from '../app'; import { ExampleState, ExampleContext } from '../app';
import NoteEntity from '../entities/NoteEntity'; import NoteEntity from '../entities/NoteEntity';
@@ -11,11 +11,11 @@ import NoteEntity from '../entities/NoteEntity';
* PUT /notes/:id - update a note * PUT /notes/:id - update a note
* DELETE /notes/:id - delete a note * DELETE /notes/:id - delete a note
*/ */
const router = new KoaRouter<ExampleState>({ const router = new Router<ExampleState, ExampleContext>({
prefix: '/notes', prefix: '/notes',
}); });
router.get('/', async (ctx: ExampleContext) => { router.get('/', async (ctx) => {
const viewerContext = ctx.state.viewerContext; const viewerContext = ctx.state.viewerContext;
let notes: readonly NoteEntity[] = []; let notes: readonly NoteEntity[] = [];
if (viewerContext.isUserViewerContext()) { if (viewerContext.isUserViewerContext()) {
@@ -28,11 +28,12 @@ router.get('/', async (ctx: ExampleContext) => {
}; };
}); });
router.get('/:id', async (ctx: ExampleContext) => { router.get('/:id', async (ctx) => {
const viewerContext = ctx.state.viewerContext; const viewerContext = ctx.state.viewerContext;
const noteResult = await NoteEntity.loader(viewerContext).loadByIDAsync(ctx.params.id); const noteResult = await NoteEntity.loader(viewerContext).loadByIDAsync(ctx.params['id']!);
if (!noteResult.ok) { if (!noteResult.ok) {
ctx.throw(403, noteResult.reason); ctx.throw(403, noteResult.reason);
return;
} }
ctx.body = { ctx.body = {
@@ -40,16 +41,17 @@ router.get('/:id', async (ctx: ExampleContext) => {
}; };
}); });
router.post('/', async (ctx: ExampleContext) => { router.post('/', async (ctx) => {
const viewerContext = ctx.state.viewerContext; const viewerContext = ctx.state.viewerContext;
if (!viewerContext.isUserViewerContext()) { if (!viewerContext.isUserViewerContext()) {
// Only logged-in users can create notes. Technically this // Only logged-in users can create notes. Technically this
// would be handled by the privacy policy (it would fail) // would be handled by the privacy policy (it would fail)
// but we throw here for better type safety. // but we throw here for better type safety.
ctx.throw(403); ctx.throw(403);
return;
} }
const { title, body } = ctx.request.body; const { title, body } = ctx.request.body as any;
const createResult = await NoteEntity.creator(viewerContext) const createResult = await NoteEntity.creator(viewerContext)
.setField('userID', viewerContext.userID) .setField('userID', viewerContext.userID)
@@ -58,6 +60,7 @@ router.post('/', async (ctx: ExampleContext) => {
.createAsync(); .createAsync();
if (!createResult.ok) { if (!createResult.ok) {
ctx.throw(403, createResult.reason); ctx.throw(403, createResult.reason);
return;
} }
ctx.body = { ctx.body = {
@@ -65,13 +68,14 @@ router.post('/', async (ctx: ExampleContext) => {
}; };
}); });
router.put('/:id', async (ctx: ExampleContext) => { router.put('/:id', async (ctx) => {
const viewerContext = ctx.state.viewerContext; const viewerContext = ctx.state.viewerContext;
const { title, body } = ctx.request.body; const { title, body } = ctx.request.body as any;
const noteLoadResult = await NoteEntity.loader(viewerContext).loadByIDAsync(ctx.params.id); const noteLoadResult = await NoteEntity.loader(viewerContext).loadByIDAsync(ctx.params['id']!);
if (!noteLoadResult.ok) { if (!noteLoadResult.ok) {
ctx.throw(403, noteLoadResult.reason); ctx.throw(403, noteLoadResult.reason);
return;
} }
const noteUpdateResult = await NoteEntity.updater(noteLoadResult.value) const noteUpdateResult = await NoteEntity.updater(noteLoadResult.value)
@@ -80,6 +84,7 @@ router.put('/:id', async (ctx: ExampleContext) => {
.updateAsync(); .updateAsync();
if (!noteUpdateResult.ok) { if (!noteUpdateResult.ok) {
ctx.throw(403, noteUpdateResult.reason); ctx.throw(403, noteUpdateResult.reason);
return;
} }
ctx.body = { ctx.body = {
@@ -87,17 +92,19 @@ router.put('/:id', async (ctx: ExampleContext) => {
}; };
}); });
router.delete('/:id', async (ctx: ExampleContext) => { router.delete('/:id', async (ctx) => {
const viewerContext = ctx.state.viewerContext; const viewerContext = ctx.state.viewerContext;
const noteLoadResult = await NoteEntity.loader(viewerContext).loadByIDAsync(ctx.params.id); const noteLoadResult = await NoteEntity.loader(viewerContext).loadByIDAsync(ctx.params['id']!);
if (!noteLoadResult.ok) { if (!noteLoadResult.ok) {
ctx.throw(403, noteLoadResult.reason); ctx.throw(403, noteLoadResult.reason);
return;
} }
const noteDeleteResult = await NoteEntity.deleteAsync(noteLoadResult.value); const noteDeleteResult = await NoteEntity.deleteAsync(noteLoadResult.value);
if (!noteDeleteResult.ok) { if (!noteDeleteResult.ok) {
ctx.throw(403, noteDeleteResult.reason); ctx.throw(403, noteDeleteResult.reason);
return;
} }
ctx.body = { ctx.body = {
+6 -6
View File
@@ -1,4 +1,4 @@
import { gql, IResolvers, IResolverObject } from 'apollo-server-koa'; import { IResolvers, IObjectTypeResolver } from '@graphql-tools/utils';
import NoteEntity from './entities/NoteEntity'; import NoteEntity from './entities/NoteEntity';
import { ExampleViewerContext } from './viewerContexts'; import { ExampleViewerContext } from './viewerContexts';
@@ -7,7 +7,7 @@ type GraphqlContext = {
viewerContext: ExampleViewerContext; viewerContext: ExampleViewerContext;
}; };
export const typeDefs = gql` export const typeDefs = `#graphql
type User { type User {
id: ID! id: ID!
notes: [Note]! notes: [Note]!
@@ -48,7 +48,7 @@ export const resolvers: IResolvers<any, GraphqlContext> = {
async noteByID(_root, args, { viewerContext }) { async noteByID(_root, args, { viewerContext }) {
return await NoteEntity.loader(viewerContext).enforcing().loadByIDAsync(args.id); return await NoteEntity.loader(viewerContext).enforcing().loadByIDAsync(args.id);
}, },
} as IResolverObject<any, GraphqlContext>, } as IObjectTypeResolver<any, GraphqlContext>,
User: { User: {
id: (root) => root, id: (root) => root,
@@ -57,14 +57,14 @@ export const resolvers: IResolvers<any, GraphqlContext> = {
.enforcing() .enforcing()
.loadManyByFieldEqualingAsync('userID', root); .loadManyByFieldEqualingAsync('userID', root);
}, },
} as IResolverObject<string, GraphqlContext>, } as IObjectTypeResolver<string, GraphqlContext>,
Note: { Note: {
id: (root) => root.getID(), id: (root) => root.getID(),
title: (root) => root.getField('title'), title: (root) => root.getField('title'),
body: (root) => root.getField('body'), body: (root) => root.getField('body'),
user: (root) => root.getField('userID'), user: (root) => root.getField('userID'),
} as IResolverObject<NoteEntity, GraphqlContext>, } as IObjectTypeResolver<NoteEntity, GraphqlContext>,
Mutation: { Mutation: {
async addNote(_root, args, { viewerContext }) { async addNote(_root, args, { viewerContext }) {
@@ -94,5 +94,5 @@ export const resolvers: IResolvers<any, GraphqlContext> = {
await NoteEntity.enforceDeleteAsync(existingNote); await NoteEntity.enforceDeleteAsync(existingNote);
return existingNote; return existingNote;
}, },
} as IResolverObject<any, GraphqlContext>, } as IObjectTypeResolver<any, GraphqlContext>,
}; };
@@ -3,6 +3,140 @@
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.33.0](https://github.com/expo/entity/compare/v0.32.0...v0.33.0) (2023-10-06)
**Note:** Version bump only for package @expo/entity-full-integration-tests
# [0.32.0](https://github.com/expo/entity/compare/v0.31.1...v0.32.0) (2023-06-10)
### Bug Fixes
* remove unused field from redis configuration ([#213](https://github.com/expo/entity/issues/213)) ([503a2fb](https://github.com/expo/entity/commit/503a2fb6a8d57f6a09f6da99daf5ab92fc3e7945))
### Features
* entity companion definition static thunk ([#210](https://github.com/expo/entity/issues/210)) ([4b18010](https://github.com/expo/entity/commit/4b18010d42be50ef329f428b08330e21bf676586))
# [0.31.0](https://github.com/expo/entity/compare/v0.30.0...v0.31.0) (2023-01-19)
**Note:** Version bump only for package @expo/entity-full-integration-tests
# [0.30.0](https://github.com/expo/entity/compare/v0.29.0...v0.30.0) (2022-12-23)
**Note:** Version bump only for package @expo/entity-full-integration-tests
# [0.29.0](https://github.com/expo/entity/compare/v0.28.0...v0.29.0) (2022-10-06)
### Features
* change redis cache adapter to use redis-like interface ([#200](https://github.com/expo/entity/issues/200)) ([e381f5b](https://github.com/expo/entity/commit/e381f5b2acfc17dcade2cf6e8506fa1c1c255e73))
# [0.28.0](https://github.com/expo/entity/compare/v0.27.0...v0.28.0) (2022-09-13)
**Note:** Version bump only for package @expo/entity-full-integration-tests
# [0.27.0](https://github.com/expo/entity/compare/v0.26.1...v0.27.0) (2022-08-09)
**Note:** Version bump only for package @expo/entity-full-integration-tests
## [0.26.1](https://github.com/expo/entity/compare/v0.26.0...v0.26.1) (2022-07-28)
**Note:** Version bump only for package @expo/entity-full-integration-tests
# [0.26.0](https://github.com/expo/entity/compare/v0.25.3...v0.26.0) (2022-07-14)
### Features
* add EntityEdgeDeletionBehavior.SET_NULL_INVALIDATE_CACHE_ONLY ([#178](https://github.com/expo/entity/issues/178)) ([a0c35dd](https://github.com/expo/entity/commit/a0c35ddd7bca05d0c3f41dcf48e16d058918231c))
## [0.25.3](https://github.com/expo/entity/compare/v0.25.2...v0.25.3) (2022-03-12)
**Note:** Version bump only for package @expo/entity-full-integration-tests
## [0.25.2](https://github.com/expo/entity/compare/v0.25.1...v0.25.2) (2022-03-12)
**Note:** Version bump only for package @expo/entity-full-integration-tests
## [0.25.1](https://github.com/expo/entity/compare/v0.25.0...v0.25.1) (2022-03-10)
**Note:** Version bump only for package @expo/entity-full-integration-tests
# [0.25.0](https://github.com/expo/entity/compare/v0.24.0...v0.25.0) (2022-03-10)
**Note:** Version bump only for package @expo/entity-full-integration-tests
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
**Note:** Version bump only for package @expo/entity-full-integration-tests
# [0.23.0](https://github.com/expo/entity/compare/v0.22.0...v0.23.0) (2022-02-09)
**Note:** Version bump only for package @expo/entity-full-integration-tests
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04) # [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 **Note:** Version bump only for package @expo/entity-full-integration-tests
@@ -1,7 +1,7 @@
{ {
"name": "@expo/entity-full-integration-tests", "name": "@expo/entity-full-integration-tests",
"private": true, "private": true,
"version": "0.22.0", "version": "0.33.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",
@@ -14,7 +14,7 @@
"barrelsby": "barrelsby --directory src --location top --exclude tests__ --singleQuotes --exportDefault --delete" "barrelsby": "barrelsby --directory src --location top --exclude tests__ --singleQuotes --exportDefault --delete"
}, },
"engines": { "engines": {
"node": ">=12" "node": ">=16"
}, },
"keywords": [ "keywords": [
"entity" "entity"
@@ -27,8 +27,8 @@
"@expo/entity-database-adapter-knex": "*" "@expo/entity-database-adapter-knex": "*"
}, },
"devDependencies": { "devDependencies": {
"@expo/entity": "^0.22.0", "@expo/entity": "^0.33.0",
"@expo/entity-cache-adapter-redis": "^0.22.0", "@expo/entity-cache-adapter-redis": "^0.33.0",
"@expo/entity-database-adapter-knex": "^0.22.0" "@expo/entity-database-adapter-knex": "^0.33.0"
} }
} }
@@ -8,7 +8,7 @@ import {
UUIDField, UUIDField,
StringField, StringField,
} from '@expo/entity'; } from '@expo/entity';
import { RedisCacheAdapterContext } from '@expo/entity-cache-adapter-redis'; import { GenericRedisCacheContext } from '@expo/entity-cache-adapter-redis';
import Redis from 'ioredis'; import Redis from 'ioredis';
import { knex, Knex } from 'knex'; import { knex, Knex } from 'knex';
import nullthrows from 'nullthrows'; import nullthrows from 'nullthrows';
@@ -43,14 +43,18 @@ class TestEntityPrivacyPolicy extends EntityPrivacyPolicy<
} }
class TestEntity extends Entity<TestFields, string, ViewerContext> { class TestEntity extends Entity<TestFields, string, ViewerContext> {
static getCompanionDefinition(): EntityCompanionDefinition< static defineCompanionDefinition(): EntityCompanionDefinition<
TestFields, TestFields,
string, string,
ViewerContext, ViewerContext,
TestEntity, TestEntity,
TestEntityPrivacyPolicy TestEntityPrivacyPolicy
> { > {
return testEntityCompanion; return {
entityClass: TestEntity,
entityConfiguration: testEntityConfiguration,
privacyPolicyClass: TestEntityPrivacyPolicy,
};
} }
} }
@@ -74,17 +78,9 @@ const testEntityConfiguration = new EntityConfiguration<TestFields>({
cacheAdapterFlavor: 'redis', cacheAdapterFlavor: 'redis',
}); });
const testEntityCompanion = new EntityCompanionDefinition({
entityClass: TestEntity,
entityConfiguration: testEntityConfiguration,
privacyPolicyClass: TestEntityPrivacyPolicy,
});
async function createOrTruncatePostgresTables(knex: Knex): Promise<void> { async function createOrTruncatePostgresTables(knex: Knex): Promise<void> {
await knex.raw('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"'); // for uuid_generate_v4()
await knex.schema.createTable('testentities', (table) => { await knex.schema.createTable('testentities', (table) => {
table.uuid('id').defaultTo(knex.raw('uuid_generate_v4()')).primary(); table.uuid('id').defaultTo(knex.raw('gen_random_uuid()')).primary();
table.string('other_string').notNullable(); table.string('other_string').notNullable();
table.string('third_string').notNullable(); table.string('third_string').notNullable();
}); });
@@ -92,17 +88,15 @@ async function createOrTruncatePostgresTables(knex: Knex): Promise<void> {
} }
async function dropPostgresTable(knex: Knex): Promise<void> { async function dropPostgresTable(knex: Knex): Promise<void> {
if (await knex.schema.hasTable('children')) { if (await knex.schema.hasTable('testentities')) {
await knex.schema.dropTable('children'); await knex.schema.dropTable('testentities');
}
if (await knex.schema.hasTable('parents')) {
await knex.schema.dropTable('parents');
} }
} }
describe('Entity cache inconsistency', () => { describe('Entity cache inconsistency', () => {
let knexInstance: Knex; let knexInstance: Knex;
let redisCacheAdapterContext: RedisCacheAdapterContext; const redisClient = new Redis(new URL(process.env['REDIS_URL']!).toString());
let genericRedisCacheContext: GenericRedisCacheContext;
beforeAll(() => { beforeAll(() => {
knexInstance = knex({ knexInstance = knex({
@@ -115,8 +109,8 @@ describe('Entity cache inconsistency', () => {
database: nullthrows(process.env['PGDATABASE']), database: nullthrows(process.env['PGDATABASE']),
}, },
}); });
redisCacheAdapterContext = { genericRedisCacheContext = {
redisClient: new Redis(new URL(process.env['REDIS_URL']!).toString()), redisClient,
makeKeyFn(...parts: string[]): string { makeKeyFn(...parts: string[]): string {
const delimiter = ':'; const delimiter = ':';
const escapedParts = parts.map((part) => const escapedParts = parts.map((part) =>
@@ -125,7 +119,6 @@ describe('Entity cache inconsistency', () => {
return escapedParts.join(delimiter); return escapedParts.join(delimiter);
}, },
cacheKeyPrefix: 'test-', cacheKeyPrefix: 'test-',
cacheKeyVersion: 1,
ttlSecondsPositive: 86400, // 1 day ttlSecondsPositive: 86400, // 1 day
ttlSecondsNegative: 600, // 10 minutes ttlSecondsNegative: 600, // 10 minutes
}; };
@@ -133,18 +126,18 @@ describe('Entity cache inconsistency', () => {
beforeEach(async () => { beforeEach(async () => {
await createOrTruncatePostgresTables(knexInstance); await createOrTruncatePostgresTables(knexInstance);
await redisCacheAdapterContext.redisClient.flushdb(); await redisClient.flushdb();
}); });
afterAll(async () => { afterAll(async () => {
await dropPostgresTable(knexInstance); await dropPostgresTable(knexInstance);
await knexInstance.destroy(); await knexInstance.destroy();
redisCacheAdapterContext.redisClient.disconnect(); redisClient.disconnect();
}); });
test('lots of updates in long-ish running transactions', async () => { test('lots of updates in long-ish running transactions', async () => {
const viewerContext = new ViewerContext( const viewerContext = new ViewerContext(
createFullIntegrationTestEntityCompanionProvider(knexInstance, redisCacheAdapterContext) createFullIntegrationTestEntityCompanionProvider(knexInstance, genericRedisCacheContext)
); );
const entity1 = await TestEntity.creator(viewerContext) const entity1 = await TestEntity.creator(viewerContext)
@@ -177,7 +170,7 @@ describe('Entity cache inconsistency', () => {
await barrier1; await barrier1;
const viewerContextInternal = new ViewerContext( const viewerContextInternal = new ViewerContext(
createFullIntegrationTestEntityCompanionProvider(knexInstance, redisCacheAdapterContext) createFullIntegrationTestEntityCompanionProvider(knexInstance, genericRedisCacheContext)
); );
await TestEntity.loader(viewerContextInternal).enforcing().loadByIDAsync(entity1.getID()); await TestEntity.loader(viewerContextInternal).enforcing().loadByIDAsync(entity1.getID());
@@ -200,7 +193,7 @@ describe('Entity cache inconsistency', () => {
// ensure cache consistency // ensure cache consistency
const viewerContextLast = new ViewerContext( const viewerContextLast = new ViewerContext(
createFullIntegrationTestEntityCompanionProvider(knexInstance, redisCacheAdapterContext) createFullIntegrationTestEntityCompanionProvider(knexInstance, genericRedisCacheContext)
); );
const loadedById = await TestEntity.loader(viewerContextLast) const loadedById = await TestEntity.loader(viewerContextLast)
@@ -1,24 +1,22 @@
import { ViewerContext } from '@expo/entity'; import { ViewerContext } from '@expo/entity';
import { RedisCacheAdapterContext } from '@expo/entity-cache-adapter-redis'; import { GenericRedisCacheContext } from '@expo/entity-cache-adapter-redis';
import Redis from 'ioredis'; import Redis from 'ioredis';
import { knex, Knex } from 'knex'; import { knex, Knex } from 'knex';
import nullthrows from 'nullthrows'; import nullthrows from 'nullthrows';
import { URL } from 'url'; import { URL } from 'url';
import { createFullIntegrationTestEntityCompanionProvider } from '../testfixtures/createFullIntegrationTestEntityCompanionProvider';
import ChildEntity from './entities/ChildEntity'; import ChildEntity from './entities/ChildEntity';
import ParentEntity from './entities/ParentEntity'; import ParentEntity from './entities/ParentEntity';
import { createFullIntegrationTestEntityCompanionProvider } from '../testfixtures/createFullIntegrationTestEntityCompanionProvider';
async function createOrTruncatePostgresTables(knex: Knex): Promise<void> { async function createOrTruncatePostgresTables(knex: Knex): Promise<void> {
await knex.raw('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"'); // for uuid_generate_v4()
await knex.schema.createTable('parents', (table) => { await knex.schema.createTable('parents', (table) => {
table.uuid('id').defaultTo(knex.raw('uuid_generate_v4()')).primary(); table.uuid('id').defaultTo(knex.raw('gen_random_uuid()')).primary();
}); });
await knex.into('parents').truncate(); await knex.into('parents').truncate();
await knex.schema.createTable('children', (table) => { await knex.schema.createTable('children', (table) => {
table.uuid('id').defaultTo(knex.raw('uuid_generate_v4()')).primary(); table.uuid('id').defaultTo(knex.raw('gen_random_uuid()')).primary();
table.uuid('parent_id').references('id').inTable('parents').onDelete('cascade').unique(); table.uuid('parent_id').references('id').inTable('parents').onDelete('cascade').unique();
}); });
await knex.into('children').truncate(); await knex.into('children').truncate();
@@ -35,7 +33,8 @@ async function dropPostgresTable(knex: Knex): Promise<void> {
describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => { describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
let knexInstance: Knex; let knexInstance: Knex;
let redisCacheAdapterContext: RedisCacheAdapterContext; const redisClient = new Redis(new URL(process.env['REDIS_URL']!).toString());
let genericRedisCacheContext: GenericRedisCacheContext;
beforeAll(() => { beforeAll(() => {
knexInstance = knex({ knexInstance = knex({
@@ -48,8 +47,8 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
database: nullthrows(process.env['PGDATABASE']), database: nullthrows(process.env['PGDATABASE']),
}, },
}); });
redisCacheAdapterContext = { genericRedisCacheContext = {
redisClient: new Redis(new URL(process.env['REDIS_URL']!).toString()), redisClient,
makeKeyFn(...parts: string[]): string { makeKeyFn(...parts: string[]): string {
const delimiter = ':'; const delimiter = ':';
const escapedParts = parts.map((part) => const escapedParts = parts.map((part) =>
@@ -58,7 +57,6 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
return escapedParts.join(delimiter); return escapedParts.join(delimiter);
}, },
cacheKeyPrefix: 'test-', cacheKeyPrefix: 'test-',
cacheKeyVersion: 1,
ttlSecondsPositive: 86400, // 1 day ttlSecondsPositive: 86400, // 1 day
ttlSecondsNegative: 600, // 10 minutes ttlSecondsNegative: 600, // 10 minutes
}; };
@@ -66,19 +64,19 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
beforeEach(async () => { beforeEach(async () => {
await createOrTruncatePostgresTables(knexInstance); await createOrTruncatePostgresTables(knexInstance);
await redisCacheAdapterContext.redisClient.flushdb(); await redisClient.flushdb();
}); });
afterAll(async () => { afterAll(async () => {
await dropPostgresTable(knexInstance); await dropPostgresTable(knexInstance);
await knexInstance.destroy(); await knexInstance.destroy();
redisCacheAdapterContext.redisClient.disconnect(); redisClient.disconnect();
}); });
describe('EntityEdgeDeletionBehavior.INVALIDATE_CACHE', () => { describe('EntityEdgeDeletionBehavior.INVALIDATE_CACHE', () => {
it('invalidates the cache', async () => { it('invalidates the cache', async () => {
const viewerContext = new ViewerContext( const viewerContext = new ViewerContext(
createFullIntegrationTestEntityCompanionProvider(knexInstance, redisCacheAdapterContext) createFullIntegrationTestEntityCompanionProvider(knexInstance, genericRedisCacheContext)
); );
const parent = await ParentEntity.creator(viewerContext).enforceCreateAsync(); const parent = await ParentEntity.creator(viewerContext).enforceCreateAsync();
@@ -8,7 +8,7 @@ import {
UUIDField, UUIDField,
EntityEdgeDeletionBehavior, EntityEdgeDeletionBehavior,
} from '@expo/entity'; } from '@expo/entity';
import { RedisCacheAdapterContext } from '@expo/entity-cache-adapter-redis'; import { GenericRedisCacheContext } from '@expo/entity-cache-adapter-redis';
import Redis from 'ioredis'; import Redis from 'ioredis';
import { knex, Knex } from 'knex'; import { knex, Knex } from 'knex';
import nullthrows from 'nullthrows'; import nullthrows from 'nullthrows';
@@ -48,33 +48,41 @@ const makeEntityClasses = async (knex: Knex, edgeDeletionBehavior: EntityEdgeDel
const othersTableName = uuidv4(); const othersTableName = uuidv4();
class CategoryEntity extends Entity<CategoryFields, string, ViewerContext> { class CategoryEntity extends Entity<CategoryFields, string, ViewerContext> {
static getCompanionDefinition(): EntityCompanionDefinition< static defineCompanionDefinition(): EntityCompanionDefinition<
CategoryFields, CategoryFields,
string, string,
ViewerContext, ViewerContext,
CategoryEntity, CategoryEntity,
PrivacyPolicy PrivacyPolicy
> { > {
return categoryEntityCompanion; return {
entityClass: CategoryEntity,
entityConfiguration: categoryEntityConfiguration,
privacyPolicyClass: PrivacyPolicy,
};
} }
} }
class OtherEntity extends Entity<OtherFields, string, ViewerContext> { class OtherEntity extends Entity<OtherFields, string, ViewerContext> {
static getCompanionDefinition(): EntityCompanionDefinition< static defineCompanionDefinition(): EntityCompanionDefinition<
OtherFields, OtherFields,
string, string,
ViewerContext, ViewerContext,
OtherEntity, OtherEntity,
PrivacyPolicy PrivacyPolicy
> { > {
return otherEntityCompanion; return {
entityClass: OtherEntity,
entityConfiguration: otherEntityConfiguration,
privacyPolicyClass: PrivacyPolicy,
};
} }
} }
const categoryEntityConfiguration = new EntityConfiguration<CategoryFields>({ const categoryEntityConfiguration = new EntityConfiguration<CategoryFields>({
idField: 'id', idField: 'id',
tableName: categoriesTableName, tableName: categoriesTableName,
getInboundEdges: () => [OtherEntity], inboundEdges: [OtherEntity],
schema: { schema: {
id: new UUIDField({ id: new UUIDField({
columnName: 'id', columnName: 'id',
@@ -84,7 +92,7 @@ const makeEntityClasses = async (knex: Knex, edgeDeletionBehavior: EntityEdgeDel
columnName: 'parent_other_id', columnName: 'parent_other_id',
cache: true, cache: true,
association: { association: {
getAssociatedEntityClass: () => OtherEntity, associatedEntityClass: OtherEntity,
edgeDeletionBehavior, edgeDeletionBehavior,
}, },
}), }),
@@ -93,16 +101,10 @@ const makeEntityClasses = async (knex: Knex, edgeDeletionBehavior: EntityEdgeDel
cacheAdapterFlavor: 'redis', cacheAdapterFlavor: 'redis',
}); });
const categoryEntityCompanion = new EntityCompanionDefinition({
entityClass: CategoryEntity,
entityConfiguration: categoryEntityConfiguration,
privacyPolicyClass: PrivacyPolicy,
});
const otherEntityConfiguration = new EntityConfiguration<OtherFields>({ const otherEntityConfiguration = new EntityConfiguration<OtherFields>({
idField: 'id', idField: 'id',
tableName: othersTableName, tableName: othersTableName,
getInboundEdges: () => [CategoryEntity], inboundEdges: [CategoryEntity],
schema: { schema: {
id: new UUIDField({ id: new UUIDField({
columnName: 'id', columnName: 'id',
@@ -112,7 +114,7 @@ const makeEntityClasses = async (knex: Knex, edgeDeletionBehavior: EntityEdgeDel
columnName: 'parent_category_id', columnName: 'parent_category_id',
cache: true, cache: true,
association: { association: {
getAssociatedEntityClass: () => CategoryEntity, associatedEntityClass: CategoryEntity,
edgeDeletionBehavior, edgeDeletionBehavior,
}, },
}), }),
@@ -121,40 +123,46 @@ const makeEntityClasses = async (knex: Knex, edgeDeletionBehavior: EntityEdgeDel
cacheAdapterFlavor: 'redis', cacheAdapterFlavor: 'redis',
}); });
const otherEntityCompanion = new EntityCompanionDefinition({
entityClass: OtherEntity,
entityConfiguration: otherEntityConfiguration,
privacyPolicyClass: PrivacyPolicy,
});
await knex.raw('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"'); // for uuid_generate_v4()
await knex.schema.createTable(categoriesTableName, (table) => { await knex.schema.createTable(categoriesTableName, (table) => {
table.uuid('id').defaultTo(knex.raw('uuid_generate_v4()')).primary(); table.uuid('id').defaultTo(knex.raw('gen_random_uuid()')).primary();
}); });
await knex.schema.createTable(othersTableName, (table) => { await knex.schema.createTable(othersTableName, (table) => {
table.uuid('id').defaultTo(knex.raw('uuid_generate_v4()')).primary(); table.uuid('id').defaultTo(knex.raw('gen_random_uuid()')).primary();
if (edgeDeletionBehavior === EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE) { if (edgeDeletionBehavior === EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE_ONLY) {
table table
.uuid('parent_category_id') .uuid('parent_category_id')
.references('id') .references('id')
.inTable(categoriesTableName) .inTable(categoriesTableName)
.unique() .unique()
.onDelete('cascade'); .onDelete('cascade');
} else if (edgeDeletionBehavior === EntityEdgeDeletionBehavior.SET_NULL_INVALIDATE_CACHE_ONLY) {
table
.uuid('parent_category_id')
.references('id')
.inTable(categoriesTableName)
.unique()
.onDelete('set null');
} else { } else {
table.uuid('parent_category_id').unique(); table.uuid('parent_category_id').unique();
} }
}); });
await knex.schema.alterTable(categoriesTableName, (table) => { await knex.schema.alterTable(categoriesTableName, (table) => {
if (edgeDeletionBehavior === EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE) { if (edgeDeletionBehavior === EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE_ONLY) {
table table
.uuid('parent_other_id') .uuid('parent_other_id')
.references('id') .references('id')
.inTable(othersTableName) .inTable(othersTableName)
.unique() .unique()
.onDelete('cascade'); .onDelete('cascade');
} else if (edgeDeletionBehavior === EntityEdgeDeletionBehavior.SET_NULL_INVALIDATE_CACHE_ONLY) {
table
.uuid('parent_other_id')
.references('id')
.inTable(othersTableName)
.unique()
.onDelete('set null');
} else { } else {
table.uuid('parent_other_id').unique(); table.uuid('parent_other_id').unique();
} }
@@ -164,7 +172,8 @@ const makeEntityClasses = async (knex: Knex, edgeDeletionBehavior: EntityEdgeDel
}; };
describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => { describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
let knexInstance: Knex; let knexInstance: Knex;
let redisCacheAdapterContext: RedisCacheAdapterContext; const redisClient = new Redis(new URL(process.env['REDIS_URL']!).toString());
let genericRedisCacheContext: GenericRedisCacheContext;
beforeAll(() => { beforeAll(() => {
knexInstance = knex({ knexInstance = knex({
@@ -177,8 +186,8 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
database: nullthrows(process.env['PGDATABASE']), database: nullthrows(process.env['PGDATABASE']),
}, },
}); });
redisCacheAdapterContext = { genericRedisCacheContext = {
redisClient: new Redis(new URL(process.env['REDIS_URL']!).toString()), redisClient,
makeKeyFn(...parts: string[]): string { makeKeyFn(...parts: string[]): string {
const delimiter = ':'; const delimiter = ':';
const escapedParts = parts.map((part) => const escapedParts = parts.map((part) =>
@@ -187,7 +196,6 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
return escapedParts.join(delimiter); return escapedParts.join(delimiter);
}, },
cacheKeyPrefix: 'test-', cacheKeyPrefix: 'test-',
cacheKeyVersion: 1,
ttlSecondsPositive: 86400, // 1 day ttlSecondsPositive: 86400, // 1 day
ttlSecondsNegative: 600, // 10 minutes ttlSecondsNegative: 600, // 10 minutes
}; };
@@ -195,13 +203,14 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
afterAll(async () => { afterAll(async () => {
await knexInstance.destroy(); await knexInstance.destroy();
redisCacheAdapterContext.redisClient.disconnect(); redisClient.disconnect();
}); });
it.each([ it.each([
EntityEdgeDeletionBehavior.CASCADE_DELETE, EntityEdgeDeletionBehavior.CASCADE_DELETE,
EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE, EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE_ONLY,
EntityEdgeDeletionBehavior.SET_NULL, EntityEdgeDeletionBehavior.SET_NULL,
EntityEdgeDeletionBehavior.SET_NULL_INVALIDATE_CACHE_ONLY,
])('behavior: %p', async (edgeDeletionBehavior) => { ])('behavior: %p', async (edgeDeletionBehavior) => {
const { CategoryEntity, OtherEntity } = await makeEntityClasses( const { CategoryEntity, OtherEntity } = await makeEntityClasses(
knexInstance, knexInstance,
@@ -209,7 +218,7 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
); );
const viewerContext = new ViewerContext( const viewerContext = new ViewerContext(
createFullIntegrationTestEntityCompanionProvider(knexInstance, redisCacheAdapterContext) createFullIntegrationTestEntityCompanionProvider(knexInstance, genericRedisCacheContext)
); );
const category1 = await CategoryEntity.creator(viewerContext).enforceCreateAsync(); const category1 = await CategoryEntity.creator(viewerContext).enforceCreateAsync();
@@ -230,6 +239,14 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
.enforcing() .enforcing()
.loadByIDNullableAsync(other1.getID()); .loadByIDNullableAsync(other1.getID());
expect(otherLoaded?.getField('parent_category_id')).toBeNull(); expect(otherLoaded?.getField('parent_category_id')).toBeNull();
} else if (edgeDeletionBehavior === EntityEdgeDeletionBehavior.SET_NULL_INVALIDATE_CACHE_ONLY) {
await expect(
CategoryEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(category1.getID())
).resolves.toBeNull();
const otherLoaded = await OtherEntity.loader(viewerContext)
.enforcing()
.loadByIDNullableAsync(other1.getID());
expect(otherLoaded?.getField('parent_category_id')).toBeNull();
} else { } else {
await expect( await expect(
CategoryEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(category1.getID()) CategoryEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(category1.getID())
@@ -32,40 +32,36 @@ class TestEntityPrivacyPolicy extends EntityPrivacyPolicy<any, string, ViewerCon
} }
export default class ChildEntity extends Entity<ChildFields, string, ViewerContext> { export default class ChildEntity extends Entity<ChildFields, string, ViewerContext> {
static getCompanionDefinition(): EntityCompanionDefinition< static defineCompanionDefinition(): EntityCompanionDefinition<
ChildFields, ChildFields,
string, string,
ViewerContext, ViewerContext,
ChildEntity, ChildEntity,
TestEntityPrivacyPolicy TestEntityPrivacyPolicy
> { > {
return childEntityCompanion; return {
entityClass: ChildEntity,
entityConfiguration: new EntityConfiguration<ChildFields>({
idField: 'id',
tableName: 'children',
schema: {
id: new UUIDField({
columnName: 'id',
cache: true,
}),
parent_id: new UUIDField({
columnName: 'parent_id',
cache: true,
association: {
associatedEntityClass: ParentEntity,
edgeDeletionBehavior: EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE_ONLY,
},
}),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
}),
privacyPolicyClass: TestEntityPrivacyPolicy,
};
} }
} }
const childEntityConfiguration = new EntityConfiguration<ChildFields>({
idField: 'id',
tableName: 'children',
schema: {
id: new UUIDField({
columnName: 'id',
cache: true,
}),
parent_id: new UUIDField({
columnName: 'parent_id',
cache: true,
association: {
getAssociatedEntityClass: () => ParentEntity,
edgeDeletionBehavior: EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE,
},
}),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
});
const childEntityCompanion = new EntityCompanionDefinition({
entityClass: ChildEntity,
entityConfiguration: childEntityConfiguration,
privacyPolicyClass: TestEntityPrivacyPolicy,
});
@@ -30,33 +30,29 @@ class TestEntityPrivacyPolicy extends EntityPrivacyPolicy<any, string, ViewerCon
} }
export default class ParentEntity extends Entity<ParentFields, string, ViewerContext> { export default class ParentEntity extends Entity<ParentFields, string, ViewerContext> {
static getCompanionDefinition(): EntityCompanionDefinition< static defineCompanionDefinition(): EntityCompanionDefinition<
ParentFields, ParentFields,
string, string,
ViewerContext, ViewerContext,
ParentEntity, ParentEntity,
TestEntityPrivacyPolicy TestEntityPrivacyPolicy
> { > {
return parentEntityCompanion; return {
entityClass: ParentEntity,
entityConfiguration: new EntityConfiguration<ParentFields>({
idField: 'id',
tableName: 'parents',
inboundEdges: [ChildEntity],
schema: {
id: new UUIDField({
columnName: 'id',
cache: true,
}),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
}),
privacyPolicyClass: TestEntityPrivacyPolicy,
};
} }
} }
const parentEntityConfiguration = new EntityConfiguration<ParentFields>({
idField: 'id',
tableName: 'parents',
getInboundEdges: () => [ChildEntity],
schema: {
id: new UUIDField({
columnName: 'id',
cache: true,
}),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
});
const parentEntityCompanion = new EntityCompanionDefinition({
entityClass: ParentEntity,
entityConfiguration: parentEntityConfiguration,
privacyPolicyClass: TestEntityPrivacyPolicy,
});
@@ -4,7 +4,7 @@ import {
EntityCompanionProvider, EntityCompanionProvider,
} from '@expo/entity'; } from '@expo/entity';
import { import {
RedisCacheAdapterContext, GenericRedisCacheContext,
RedisCacheAdapterProvider, RedisCacheAdapterProvider,
} from '@expo/entity-cache-adapter-redis'; } from '@expo/entity-cache-adapter-redis';
import { import {
@@ -15,7 +15,7 @@ import { Knex } from 'knex';
export const createFullIntegrationTestEntityCompanionProvider = ( export const createFullIntegrationTestEntityCompanionProvider = (
knex: Knex, knex: Knex,
redisCacheAdapterContext: RedisCacheAdapterContext, genericRedisCacheContext: GenericRedisCacheContext,
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter() metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter()
): EntityCompanionProvider => { ): EntityCompanionProvider => {
return new EntityCompanionProvider( return new EntityCompanionProvider(
@@ -33,7 +33,7 @@ export const createFullIntegrationTestEntityCompanionProvider = (
[ [
'redis', 'redis',
{ {
cacheAdapterProvider: new RedisCacheAdapterProvider(redisCacheAdapterContext), cacheAdapterProvider: new RedisCacheAdapterProvider(genericRedisCacheContext),
}, },
], ],
]) ])
@@ -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.33.0](https://github.com/expo/entity/compare/v0.32.0...v0.33.0) (2023-10-06)
**Note:** Version bump only for package @expo/entity-ip-address-field
# [0.32.0](https://github.com/expo/entity/compare/v0.31.1...v0.32.0) (2023-06-10)
**Note:** Version bump only for package @expo/entity-ip-address-field
# [0.31.0](https://github.com/expo/entity/compare/v0.30.0...v0.31.0) (2023-01-19)
**Note:** Version bump only for package @expo/entity-ip-address-field
# [0.30.0](https://github.com/expo/entity/compare/v0.29.0...v0.30.0) (2022-12-23)
**Note:** Version bump only for package @expo/entity-ip-address-field
# [0.29.0](https://github.com/expo/entity/compare/v0.28.0...v0.29.0) (2022-10-06)
**Note:** Version bump only for package @expo/entity-ip-address-field
# [0.28.0](https://github.com/expo/entity/compare/v0.27.0...v0.28.0) (2022-09-13)
**Note:** Version bump only for package @expo/entity-ip-address-field
# [0.27.0](https://github.com/expo/entity/compare/v0.26.1...v0.27.0) (2022-08-09)
**Note:** Version bump only for package @expo/entity-ip-address-field
## [0.26.1](https://github.com/expo/entity/compare/v0.26.0...v0.26.1) (2022-07-28)
**Note:** Version bump only for package @expo/entity-ip-address-field
# [0.26.0](https://github.com/expo/entity/compare/v0.25.3...v0.26.0) (2022-07-14)
**Note:** Version bump only for package @expo/entity-ip-address-field
## [0.25.3](https://github.com/expo/entity/compare/v0.25.2...v0.25.3) (2022-03-12)
**Note:** Version bump only for package @expo/entity-ip-address-field
## [0.25.2](https://github.com/expo/entity/compare/v0.25.1...v0.25.2) (2022-03-12)
**Note:** Version bump only for package @expo/entity-ip-address-field
## [0.25.1](https://github.com/expo/entity/compare/v0.25.0...v0.25.1) (2022-03-10)
**Note:** Version bump only for package @expo/entity-ip-address-field
# [0.25.0](https://github.com/expo/entity/compare/v0.24.0...v0.25.0) (2022-03-10)
**Note:** Version bump only for package @expo/entity-ip-address-field
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
**Note:** Version bump only for package @expo/entity-ip-address-field
# [0.23.0](https://github.com/expo/entity/compare/v0.22.0...v0.23.0) (2022-02-09)
**Note:** Version bump only for package @expo/entity-ip-address-field
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04) # [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 **Note:** Version bump only for package @expo/entity-ip-address-field
@@ -1,6 +1,6 @@
{ {
"name": "@expo/entity-ip-address-field", "name": "@expo/entity-ip-address-field",
"version": "0.22.0", "version": "0.33.0",
"description": "IP address EntityField definitions for @expo/entity", "description": "IP address EntityField definitions for @expo/entity",
"files": [ "files": [
"build", "build",
@@ -17,7 +17,7 @@
"barrelsby": "barrelsby --directory src --location top --exclude tests__ --singleQuotes --delete" "barrelsby": "barrelsby --directory src --location top --exclude tests__ --singleQuotes --delete"
}, },
"engines": { "engines": {
"node": ">=12" "node": ">=16"
}, },
"keywords": [ "keywords": [
"entity" "entity"
@@ -28,7 +28,7 @@
"ip-address": "^8.1.0" "ip-address": "^8.1.0"
}, },
"devDependencies": { "devDependencies": {
"@expo/entity": "^0.22.0", "@expo/entity": "^0.33.0",
"@types/jsbn": "^1.2.29" "@types/jsbn": "^1.2.29"
}, },
"peerDependencies": { "peerDependencies": {
@@ -0,0 +1,110 @@
# Change Log
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.33.0](https://github.com/expo/entity/compare/v0.32.0...v0.33.0) (2023-10-06)
**Note:** Version bump only for package @expo/entity-secondary-cache-local-memory
# [0.32.0](https://github.com/expo/entity/compare/v0.31.1...v0.32.0) (2023-06-10)
### Features
* entity companion definition static thunk ([#210](https://github.com/expo/entity/issues/210)) ([4b18010](https://github.com/expo/entity/commit/4b18010d42be50ef329f428b08330e21bf676586))
# [0.31.0](https://github.com/expo/entity/compare/v0.30.0...v0.31.0) (2023-01-19)
**Note:** Version bump only for package @expo/entity-secondary-cache-local-memory
# [0.30.0](https://github.com/expo/entity/compare/v0.29.0...v0.30.0) (2022-12-23)
**Note:** Version bump only for package @expo/entity-secondary-cache-local-memory
# [0.29.0](https://github.com/expo/entity/compare/v0.28.0...v0.29.0) (2022-10-06)
**Note:** Version bump only for package @expo/entity-secondary-cache-local-memory
# [0.28.0](https://github.com/expo/entity/compare/v0.27.0...v0.28.0) (2022-09-13)
**Note:** Version bump only for package @expo/entity-secondary-cache-local-memory
# [0.27.0](https://github.com/expo/entity/compare/v0.26.1...v0.27.0) (2022-08-09)
**Note:** Version bump only for package @expo/entity-secondary-cache-local-memory
## [0.26.1](https://github.com/expo/entity/compare/v0.26.0...v0.26.1) (2022-07-28)
**Note:** Version bump only for package @expo/entity-secondary-cache-local-memory
# [0.26.0](https://github.com/expo/entity/compare/v0.25.3...v0.26.0) (2022-07-14)
**Note:** Version bump only for package @expo/entity-secondary-cache-local-memory
## [0.25.3](https://github.com/expo/entity/compare/v0.25.2...v0.25.3) (2022-03-12)
**Note:** Version bump only for package @expo/entity-secondary-cache-local-memory
## [0.25.2](https://github.com/expo/entity/compare/v0.25.1...v0.25.2) (2022-03-12)
**Note:** Version bump only for package @expo/entity-secondary-cache-local-memory
## [0.25.1](https://github.com/expo/entity/compare/v0.25.0...v0.25.1) (2022-03-10)
**Note:** Version bump only for package @expo/entity-secondary-cache-local-memory
# [0.25.0](https://github.com/expo/entity/compare/v0.24.0...v0.25.0) (2022-03-10)
### Features
* composed cache adapter, composed secondary cache, local memory secondary cache ([#164](https://github.com/expo/entity/issues/164)) ([6b6e70e](https://github.com/expo/entity/commit/6b6e70e75f7e42a4dce13edeb406a4d88d9264f0))
@@ -0,0 +1,24 @@
# `@expo/entity-secondary-cache-local-memory`
Cross-request [LRU](https://github.com/isaacs/node-lru-cache) secondary cache for `@expo/entity`. Use
this cache with caution - it is nonstandard. The cache is shared between requests in the node process.
[Documentation](https://expo.github.io/entity/modules/_expo_secondary_cache_local_memory.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 `LocalMemorySecondaryEntityCache`.
```typescript
const secondaryCacheLoader = new TestSecondaryLocalMemoryCacheLoader(
new LocalMemorySecondaryEntityCache(
GenericLocalMemoryCacher.createLRUCache<LocalMemoryTestEntityFields>({})
),
LocalMemoryTestEntity.loader(viewerContext)
);
```
3. Load entities through it:
```typescript
const loadParams = { id: createdEntity.getID() };
const results = await secondaryCacheLoader.loadManyAsync([loadParams]);
```
@@ -0,0 +1,38 @@
{
"name": "@expo/entity-secondary-cache-local-memory",
"version": "0.33.0",
"description": "Local memory 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": ">=16"
},
"keywords": [
"entity"
],
"author": "Expo",
"license": "MIT",
"peerDependencies": {
"@expo/entity": "*",
"@expo/entity-cache-adapter-local-memory": "*"
},
"devDependencies": {
"@expo/entity": "^0.33.0",
"@expo/entity-cache-adapter-local-memory": "^0.33.0",
"nullthrows": "^1.1.1"
}
}
@@ -0,0 +1,26 @@
import { EntityConfiguration, GenericSecondaryEntityCache } from '@expo/entity';
import {
GenericLocalMemoryCacher,
LocalMemoryCache,
} from '@expo/entity-cache-adapter-local-memory';
/**
* A local memory GenericSecondaryEntityCache.
*
* @remarks
*
* TLoadParams must be JSON stringifyable.
*/
export default class LocalMemorySecondaryEntityCache<
TFields,
TLoadParams
> extends GenericSecondaryEntityCache<TFields, TLoadParams> {
constructor(
entityConfiguration: EntityConfiguration<TFields>,
localMemoryCache: LocalMemoryCache<TFields>
) {
super(new GenericLocalMemoryCacher(entityConfiguration, localMemoryCache), (params) =>
JSON.stringify(params)
);
}
}
@@ -0,0 +1,228 @@
import {
EntitySecondaryCacheLoader,
mapMapAsync,
ViewerContext,
NoOpEntityMetricsAdapter,
IEntityMetricsAdapter,
EntityCompanionProvider,
StubQueryContextProvider,
StubDatabaseAdapterProvider,
AlwaysAllowPrivacyPolicyRule,
EntityPrivacyPolicy,
UUIDField,
StringField,
EntityConfiguration,
EntityCompanionDefinition,
Entity,
} from '@expo/entity';
import {
GenericLocalMemoryCacher,
LocalMemoryCacheAdapterProvider,
} from '@expo/entity-cache-adapter-local-memory';
import nullthrows from 'nullthrows';
import LocalMemorySecondaryEntityCache from '../LocalMemorySecondaryEntityCache';
export type LocalMemoryTestEntityFields = {
id: string;
name: string;
};
export default class LocalMemoryTestEntity extends Entity<
LocalMemoryTestEntityFields,
string,
ViewerContext
> {
static defineCompanionDefinition(): EntityCompanionDefinition<
LocalMemoryTestEntityFields,
string,
ViewerContext,
LocalMemoryTestEntity,
LocalMemoryTestEntityPrivacyPolicy
> {
return {
entityClass: LocalMemoryTestEntity,
entityConfiguration: localMemoryTestEntityConfiguration,
privacyPolicyClass: LocalMemoryTestEntityPrivacyPolicy,
};
}
}
export class LocalMemoryTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
LocalMemoryTestEntityFields,
string,
ViewerContext,
LocalMemoryTestEntity
> {
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<
LocalMemoryTestEntityFields,
string,
ViewerContext,
LocalMemoryTestEntity
>(),
];
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<
LocalMemoryTestEntityFields,
string,
ViewerContext,
LocalMemoryTestEntity
>(),
];
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<
LocalMemoryTestEntityFields,
string,
ViewerContext,
LocalMemoryTestEntity
>(),
];
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<
LocalMemoryTestEntityFields,
string,
ViewerContext,
LocalMemoryTestEntity
>(),
];
}
export const localMemoryTestEntityConfiguration =
new EntityConfiguration<LocalMemoryTestEntityFields>({
idField: 'id',
tableName: 'local_memory_test_entities',
schema: {
id: new UUIDField({
columnName: 'id',
}),
name: new StringField({
columnName: 'name',
}),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'local-memory',
});
export const createTestEntityCompanionProvider = (
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter()
): EntityCompanionProvider => {
return new EntityCompanionProvider(
metricsAdapter,
new Map([
[
'postgres',
{
adapterProvider: new StubDatabaseAdapterProvider(),
queryContextProvider: StubQueryContextProvider,
},
],
]),
new Map([
[
'local-memory',
{
cacheAdapterProvider: LocalMemoryCacheAdapterProvider.getProvider(),
},
],
])
);
};
class TestViewerContext extends ViewerContext {}
type TestLoadParams = { id: string };
const FAKE_ID = 'fake';
class TestSecondaryLocalMemoryCacheLoader extends EntitySecondaryCacheLoader<
TestLoadParams,
LocalMemoryTestEntityFields,
string,
TestViewerContext,
LocalMemoryTestEntity,
LocalMemoryTestEntityPrivacyPolicy
> {
public databaseLoadCount = 0;
protected async fetchObjectsFromDatabaseAsync(
loadParamsArray: readonly Readonly<TestLoadParams>[]
): Promise<ReadonlyMap<Readonly<TestLoadParams>, Readonly<LocalMemoryTestEntityFields> | 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(LocalMemorySecondaryEntityCache, () => {
it('Loads through secondary loader, caches, and invalidates', async () => {
const viewerContext = new TestViewerContext(createTestEntityCompanionProvider());
const createdEntity = await LocalMemoryTestEntity.creator(viewerContext)
.setField('name', 'wat')
.enforceCreateAsync();
const secondaryCacheLoader = new TestSecondaryLocalMemoryCacheLoader(
new LocalMemorySecondaryEntityCache(
localMemoryTestEntityConfiguration,
GenericLocalMemoryCacher.createLRUCache<LocalMemoryTestEntityFields>({})
),
LocalMemoryTestEntity.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(createTestEntityCompanionProvider());
const secondaryCacheLoader = new TestSecondaryLocalMemoryCacheLoader(
new LocalMemorySecondaryEntityCache(
localMemoryTestEntityConfiguration,
GenericLocalMemoryCacher.createLRUCache<LocalMemoryTestEntityFields>({})
),
LocalMemoryTestEntity.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-local-memory
*/
export { default as LocalMemorySecondaryEntityCache } from './LocalMemorySecondaryEntityCache';
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "build",
},
"include": ["src"]
}
@@ -3,6 +3,145 @@
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.33.0](https://github.com/expo/entity/compare/v0.32.0...v0.33.0) (2023-10-06)
**Note:** Version bump only for package @expo/entity-secondary-cache-redis
# [0.32.0](https://github.com/expo/entity/compare/v0.31.1...v0.32.0) (2023-06-10)
### Bug Fixes
* remove unused field from redis configuration ([#213](https://github.com/expo/entity/issues/213)) ([503a2fb](https://github.com/expo/entity/commit/503a2fb6a8d57f6a09f6da99daf5ab92fc3e7945))
### Features
* entity companion definition static thunk ([#210](https://github.com/expo/entity/issues/210)) ([4b18010](https://github.com/expo/entity/commit/4b18010d42be50ef329f428b08330e21bf676586))
# [0.31.0](https://github.com/expo/entity/compare/v0.30.0...v0.31.0) (2023-01-19)
**Note:** Version bump only for package @expo/entity-secondary-cache-redis
# [0.30.0](https://github.com/expo/entity/compare/v0.29.0...v0.30.0) (2022-12-23)
**Note:** Version bump only for package @expo/entity-secondary-cache-redis
# [0.29.0](https://github.com/expo/entity/compare/v0.28.0...v0.29.0) (2022-10-06)
### Features
* change redis cache adapter to use redis-like interface ([#200](https://github.com/expo/entity/issues/200)) ([e381f5b](https://github.com/expo/entity/commit/e381f5b2acfc17dcade2cf6e8506fa1c1c255e73))
# [0.28.0](https://github.com/expo/entity/compare/v0.27.0...v0.28.0) (2022-09-13)
**Note:** Version bump only for package @expo/entity-secondary-cache-redis
# [0.27.0](https://github.com/expo/entity/compare/v0.26.1...v0.27.0) (2022-08-09)
**Note:** Version bump only for package @expo/entity-secondary-cache-redis
## [0.26.1](https://github.com/expo/entity/compare/v0.26.0...v0.26.1) (2022-07-28)
**Note:** Version bump only for package @expo/entity-secondary-cache-redis
# [0.26.0](https://github.com/expo/entity/compare/v0.25.3...v0.26.0) (2022-07-14)
### Features
* update ioredis to v5, make it a devDependency of the cache packages ([#172](https://github.com/expo/entity/issues/172)) ([9fcfbf7](https://github.com/expo/entity/commit/9fcfbf746a61554cefedf7fb0f1a3a9056b58ffe))
## [0.25.3](https://github.com/expo/entity/compare/v0.25.2...v0.25.3) (2022-03-12)
**Note:** Version bump only for package @expo/entity-secondary-cache-redis
## [0.25.2](https://github.com/expo/entity/compare/v0.25.1...v0.25.2) (2022-03-12)
**Note:** Version bump only for package @expo/entity-secondary-cache-redis
## [0.25.1](https://github.com/expo/entity/compare/v0.25.0...v0.25.1) (2022-03-10)
**Note:** Version bump only for package @expo/entity-secondary-cache-redis
# [0.25.0](https://github.com/expo/entity/compare/v0.24.0...v0.25.0) (2022-03-10)
### Features
* composed cache adapter, composed secondary cache, local memory secondary cache ([#164](https://github.com/expo/entity/issues/164)) ([6b6e70e](https://github.com/expo/entity/commit/6b6e70e75f7e42a4dce13edeb406a4d88d9264f0))
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
### Chores
* Refactor cachers to use an abstract class and interface ([#157](https://github.com/expo/entity/pull/157))
# [0.23.0](https://github.com/expo/entity/compare/v0.22.0...v0.23.0) (2022-02-09)
### Chores
* Move data transforming responsibilities to the CacheAdapter instead of the RedisSecondaryEntityCache. ([#153](https://github.com/expo/entity/pull/153))
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04) # [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 **Note:** Version bump only for package @expo/entity-secondary-cache-redis
@@ -12,7 +12,7 @@
const secondaryCacheLoader = new TestSecondaryRedisCacheLoader( const secondaryCacheLoader = new TestSecondaryRedisCacheLoader(
new RedisSecondaryEntityCache( new RedisSecondaryEntityCache(
redisTestEntityConfiguration, redisTestEntityConfiguration,
redisCacheAdapterContext, genericRedisCacheContext,
(loadParams) => `${loadParams.id}` (loadParams) => `${loadParams.id}`
), ),
RedisTestEntity.loader(viewerContext) RedisTestEntity.loader(viewerContext)
@@ -1,6 +1,6 @@
{ {
"name": "@expo/entity-secondary-cache-redis", "name": "@expo/entity-secondary-cache-redis",
"version": "0.22.0", "version": "0.33.0",
"description": "Redis secondary cache for @expo/entity", "description": "Redis secondary cache for @expo/entity",
"files": [ "files": [
"build", "build",
@@ -19,7 +19,7 @@
"barrelsby": "barrelsby --directory src --location top --exclude tests__ --singleQuotes --exportDefault --delete" "barrelsby": "barrelsby --directory src --location top --exclude tests__ --singleQuotes --exportDefault --delete"
}, },
"engines": { "engines": {
"node": ">=12" "node": ">=16"
}, },
"keywords": [ "keywords": [
"entity" "entity"
@@ -30,12 +30,10 @@
"@expo/entity": "*", "@expo/entity": "*",
"@expo/entity-cache-adapter-redis": "*" "@expo/entity-cache-adapter-redis": "*"
}, },
"dependencies": {
"ioredis": "^4.17.3"
},
"devDependencies": { "devDependencies": {
"@expo/entity": "^0.22.0", "@expo/entity": "^0.33.0",
"@expo/entity-cache-adapter-redis": "^0.22.0", "@expo/entity-cache-adapter-redis": "^0.33.0",
"ioredis": "^5.2.5",
"nullthrows": "^1.1.1" "nullthrows": "^1.1.1"
} }
} }
@@ -1,136 +1,18 @@
import { import { EntityConfiguration, GenericSecondaryEntityCache } from '@expo/entity';
CacheStatus, import { GenericRedisCacheContext, GenericRedisCacher } from '@expo/entity-cache-adapter-redis';
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 * A redis GenericSecondaryEntityCache.
* 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> export default class RedisSecondaryEntityCache<
implements ISecondaryEntityCache<TFields, TLoadParams> TFields,
{ TLoadParams
private readonly genericRedisCacher: GenericRedisCacher; > extends GenericSecondaryEntityCache<TFields, TLoadParams> {
constructor( constructor(
private readonly entityConfiguration: EntityConfiguration<TFields>, entityConfiguration: EntityConfiguration<TFields>,
genericRedisCacheContext: GenericRedisCacheContext, genericRedisCacheContext: GenericRedisCacheContext,
private readonly constructRedisKey: (params: Readonly<TLoadParams>) => string constructRedisKey: (params: Readonly<TLoadParams>) => string
) { ) {
this.genericRedisCacher = new GenericRedisCacher(genericRedisCacheContext); super(new GenericRedisCacher(genericRedisCacheContext, entityConfiguration), constructRedisKey);
}
/**
* Read-through cache function. Steps:
*
* 1. Check for cached objects
* 2. Query the fetcher for loadParams not in the cache
* 3. Cache the results from the fetcher
* 4. Negatively cache anything missing from the fetcher
* 5. Return the full set of data for the query.
*
* @param loadParamsArray - array of loadParams to load from the cache
* @param fetcher - closure used to provide underlying data source objects for loadParams
* @returns map from loadParams to the entity field object
*/
public async loadManyThroughAsync(
loadParamsArray: readonly Readonly<TLoadParams>[],
fetcher: (
fetcherLoadParamsArray: readonly Readonly<TLoadParams>[]
) => Promise<ReadonlyMap<Readonly<TLoadParams>, Readonly<TFields> | null>>
): Promise<ReadonlyMap<Readonly<TLoadParams>, Readonly<TFields> | null>> {
const redisKeys = loadParamsArray.map(this.constructRedisKey);
const redisKeyToLoadParamsMap = zipToMap(redisKeys, loadParamsArray);
const cacheLoadResults = await this.genericRedisCacher.loadManyAsync(redisKeys);
invariant(
cacheLoadResults.size === loadParamsArray.length,
`${this.constructor.name} loadMany should return a result for each key`
);
const redisKeysToFetch = Array.from(
filterMap(
cacheLoadResults,
(cacheLoadResult) => cacheLoadResult.status === CacheStatus.MISS
).keys()
);
// put transformed cache hits in result map
const results: Map<Readonly<TLoadParams>, Readonly<TFields> | null> = new Map();
cacheLoadResults.forEach((cacheLoadResult, redisKey) => {
if (cacheLoadResult.status === CacheStatus.HIT) {
const loadParams = redisKeyToLoadParamsMap.get(redisKey);
invariant(loadParams !== undefined, 'load params should be in redis key map');
results.set(
loadParams,
transformCacheObjectToFields(
this.entityConfiguration,
redisTransformerMap,
cacheLoadResult.item
)
);
}
});
// fetch any misses from DB, add DB objects to results, cache DB results, inform cache of any missing DB results
if (redisKeysToFetch.length > 0) {
const loadParamsToFetch = redisKeysToFetch.map((redisKey) => {
const loadParams = redisKeyToLoadParamsMap.get(redisKey);
invariant(loadParams !== undefined, 'load params should be in redis key map');
return loadParams;
});
const fetchResults = await fetcher(loadParamsToFetch);
const fetchMisses = loadParamsToFetch.filter((loadParams) => {
// all values of fetchResults should be field objects or undefined
return !fetchResults.get(loadParams);
});
for (const fetchMiss of fetchMisses) {
results.set(fetchMiss, null);
}
const objectsToCache: Map<string, object> = new Map();
for (const [loadParams, object] of fetchResults.entries()) {
if (object) {
objectsToCache.set(
this.constructRedisKey(loadParams),
transformFieldsToCacheObject(this.entityConfiguration, redisTransformerMap, object)
);
results.set(loadParams, object);
}
}
await Promise.all([
this.genericRedisCacher.cacheManyAsync(objectsToCache),
this.genericRedisCacher.cacheDBMissesAsync(fetchMisses.map(this.constructRedisKey)),
]);
}
return results;
}
/**
* Invalidate the cache for objects cached by constructRedisKey(loadParams).
*
* @param loadParamsArray - load params to invalidate
*/
public async invalidateManyAsync(
loadParamsArray: readonly Readonly<TLoadParams>[]
): Promise<void> {
await this.genericRedisCacher.invalidateManyAsync(loadParamsArray.map(this.constructRedisKey));
} }
} }
@@ -1,5 +1,5 @@
import { EntitySecondaryCacheLoader, mapMapAsync, ViewerContext } from '@expo/entity'; import { EntitySecondaryCacheLoader, mapMapAsync, ViewerContext } from '@expo/entity';
import { RedisCacheAdapterContext } from '@expo/entity-cache-adapter-redis'; import { GenericRedisCacheContext } from '@expo/entity-cache-adapter-redis';
import Redis from 'ioredis'; import Redis from 'ioredis';
import nullthrows from 'nullthrows'; import nullthrows from 'nullthrows';
import { URL } from 'url'; import { URL } from 'url';
@@ -52,31 +52,31 @@ class TestSecondaryRedisCacheLoader extends EntitySecondaryCacheLoader<
} }
describe(RedisSecondaryEntityCache, () => { describe(RedisSecondaryEntityCache, () => {
let redisCacheAdapterContext: RedisCacheAdapterContext; const redisClient = new Redis(new URL(process.env['REDIS_URL']!).toString());
let genericRedisCacheContext: GenericRedisCacheContext;
beforeAll(() => { beforeAll(() => {
redisCacheAdapterContext = { genericRedisCacheContext = {
redisClient: new Redis(new URL(process.env['REDIS_URL']!).toString()), redisClient,
makeKeyFn(..._parts: string[]): string { makeKeyFn(..._parts: string[]): string {
throw new Error('should not be used by this test'); throw new Error('should not be used by this test');
}, },
cacheKeyPrefix: 'test-', cacheKeyPrefix: 'test-',
cacheKeyVersion: 1,
ttlSecondsPositive: 86400, // 1 day ttlSecondsPositive: 86400, // 1 day
ttlSecondsNegative: 600, // 10 minutes ttlSecondsNegative: 600, // 10 minutes
}; };
}); });
beforeEach(async () => { beforeEach(async () => {
await redisCacheAdapterContext.redisClient.flushdb(); await redisClient.flushdb();
}); });
afterAll(async () => { afterAll(async () => {
redisCacheAdapterContext.redisClient.disconnect(); redisClient.disconnect();
}); });
it('Loads through secondary loader, caches, and invalidates', async () => { it('Loads through secondary loader, caches, and invalidates', async () => {
const viewerContext = new TestViewerContext( const viewerContext = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext) createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext)
); );
const createdEntity = await RedisTestEntity.creator(viewerContext) const createdEntity = await RedisTestEntity.creator(viewerContext)
@@ -86,7 +86,7 @@ describe(RedisSecondaryEntityCache, () => {
const secondaryCacheLoader = new TestSecondaryRedisCacheLoader( const secondaryCacheLoader = new TestSecondaryRedisCacheLoader(
new RedisSecondaryEntityCache( new RedisSecondaryEntityCache(
redisTestEntityConfiguration, redisTestEntityConfiguration,
redisCacheAdapterContext, genericRedisCacheContext,
(loadParams) => `test-key-${loadParams.id}` (loadParams) => `test-key-${loadParams.id}`
), ),
RedisTestEntity.loader(viewerContext) RedisTestEntity.loader(viewerContext)
@@ -119,13 +119,13 @@ describe(RedisSecondaryEntityCache, () => {
it('correctly handles uncached and unfetchable load params', async () => { it('correctly handles uncached and unfetchable load params', async () => {
const viewerContext = new TestViewerContext( const viewerContext = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext) createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext)
); );
const secondaryCacheLoader = new TestSecondaryRedisCacheLoader( const secondaryCacheLoader = new TestSecondaryRedisCacheLoader(
new RedisSecondaryEntityCache( new RedisSecondaryEntityCache(
redisTestEntityConfiguration, redisTestEntityConfiguration,
redisCacheAdapterContext, genericRedisCacheContext,
(loadParams) => `test-key-${loadParams.id}` (loadParams) => `test-key-${loadParams.id}`
), ),
RedisTestEntity.loader(viewerContext) RedisTestEntity.loader(viewerContext)
@@ -15,14 +15,18 @@ export type RedisTestEntityFields = {
}; };
export default class RedisTestEntity extends Entity<RedisTestEntityFields, string, ViewerContext> { export default class RedisTestEntity extends Entity<RedisTestEntityFields, string, ViewerContext> {
static getCompanionDefinition(): EntityCompanionDefinition< static defineCompanionDefinition(): EntityCompanionDefinition<
RedisTestEntityFields, RedisTestEntityFields,
string, string,
ViewerContext, ViewerContext,
RedisTestEntity, RedisTestEntity,
RedisTestEntityPrivacyPolicy RedisTestEntityPrivacyPolicy
> { > {
return redisTestEntityCompanionDefinition; return {
entityClass: RedisTestEntity,
entityConfiguration: redisTestEntityConfiguration,
privacyPolicyClass: RedisTestEntityPrivacyPolicy,
};
} }
} }
@@ -80,9 +84,3 @@ export const redisTestEntityConfiguration = new EntityConfiguration<RedisTestEnt
databaseAdapterFlavor: 'postgres', databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis', cacheAdapterFlavor: 'redis',
}); });
const redisTestEntityCompanionDefinition = new EntityCompanionDefinition({
entityClass: RedisTestEntity,
entityConfiguration: redisTestEntityConfiguration,
privacyPolicyClass: RedisTestEntityPrivacyPolicy,
});
@@ -6,12 +6,12 @@ import {
StubDatabaseAdapterProvider, StubDatabaseAdapterProvider,
} from '@expo/entity'; } from '@expo/entity';
import { import {
RedisCacheAdapterContext, GenericRedisCacheContext,
RedisCacheAdapterProvider, RedisCacheAdapterProvider,
} from '@expo/entity-cache-adapter-redis'; } from '@expo/entity-cache-adapter-redis';
export const createRedisIntegrationTestEntityCompanionProvider = ( export const createRedisIntegrationTestEntityCompanionProvider = (
redisCacheAdapterContext: RedisCacheAdapterContext, genericRedisCacheContext: GenericRedisCacheContext,
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter() metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter()
): EntityCompanionProvider => { ): EntityCompanionProvider => {
return new EntityCompanionProvider( return new EntityCompanionProvider(
@@ -29,7 +29,7 @@ export const createRedisIntegrationTestEntityCompanionProvider = (
[ [
'redis', 'redis',
{ {
cacheAdapterProvider: new RedisCacheAdapterProvider(redisCacheAdapterContext), cacheAdapterProvider: new RedisCacheAdapterProvider(genericRedisCacheContext),
}, },
], ],
]) ])
+152
View File
@@ -3,6 +3,158 @@
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.33.0](https://github.com/expo/entity/compare/v0.32.0...v0.33.0) (2023-10-06)
### Features
* add loadManyByIDsNullableAsync loader method ([#214](https://github.com/expo/entity/issues/214)) ([aad94ac](https://github.com/expo/entity/commit/aad94aca479ac07cc7f76b5e1a122ff85a2d1542))
# [0.32.0](https://github.com/expo/entity/compare/v0.31.1...v0.32.0) (2023-06-10)
### Features
* add ability to specify knex transaction config ([#207](https://github.com/expo/entity/issues/207)) ([2069a0d](https://github.com/expo/entity/commit/2069a0d9fd1d8f7d090a71a90a758b2429ea5dd3))
* entity companion definition static thunk ([#210](https://github.com/expo/entity/issues/210)) ([4b18010](https://github.com/expo/entity/commit/4b18010d42be50ef329f428b08330e21bf676586))
# [0.31.0](https://github.com/expo/entity/compare/v0.30.0...v0.31.0) (2023-01-19)
**Note:** Version bump only for package @expo/entity
# [0.30.0](https://github.com/expo/entity/compare/v0.29.0...v0.30.0) (2022-12-23)
**Note:** Version bump only for package @expo/entity
# [0.29.0](https://github.com/expo/entity/compare/v0.28.0...v0.29.0) (2022-10-06)
**Note:** Version bump only for package @expo/entity
# [0.28.0](https://github.com/expo/entity/compare/v0.27.0...v0.28.0) (2022-09-13)
### Features
* add BigIntField ([#193](https://github.com/expo/entity/issues/193)) ([acbad82](https://github.com/expo/entity/commit/acbad826a37bb5a054b4dcf9be91d11aa5f7f29a))
* nested transactions ([#194](https://github.com/expo/entity/issues/194)) ([a77b914](https://github.com/expo/entity/commit/a77b914ad6dc018d1e309c9caf4971088a553ecf))
# [0.27.0](https://github.com/expo/entity/compare/v0.26.1...v0.27.0) (2022-08-09)
### Features
* add orderByRaw to loadManyByRawWhereClauseAsync ([#185](https://github.com/expo/entity/issues/185)) ([2817d78](https://github.com/expo/entity/commit/2817d78392dfecd0093111c24d581bdf01dba06d))
## [0.26.1](https://github.com/expo/entity/compare/v0.26.0...v0.26.1) (2022-07-28)
### Features
* recache to higher cache levels when loading from a composed cache ([#182](https://github.com/expo/entity/pull/182))
# [0.26.0](https://github.com/expo/entity/compare/v0.25.3...v0.26.0) (2022-07-14)
### Features
* add EntityEdgeDeletionBehavior.SET_NULL_INVALIDATE_CACHE_ONLY ([#178](https://github.com/expo/entity/issues/178)) ([a0c35dd](https://github.com/expo/entity/commit/a0c35ddd7bca05d0c3f41dcf48e16d058918231c))
## [0.25.3](https://github.com/expo/entity/compare/v0.25.2...v0.25.3) (2022-03-12)
**Note:** Version bump only for package @expo/entity
## [0.25.2](https://github.com/expo/entity/compare/v0.25.1...v0.25.2) (2022-03-12)
**Note:** Version bump only for package @expo/entity
## [0.25.1](https://github.com/expo/entity/compare/v0.25.0...v0.25.1) (2022-03-10)
### Bug Fixes
* add composed cache to index ([#166](https://github.com/expo/entity/issues/166)) ([5c82edc](https://github.com/expo/entity/commit/5c82edcd9f26854878d29950a05852e117bbd723))
# [0.25.0](https://github.com/expo/entity/compare/v0.24.0...v0.25.0) (2022-03-10)
### Features
* composed cache adapter, composed secondary cache, local memory secondary cache ([#164](https://github.com/expo/entity/issues/164)) ([6b6e70e](https://github.com/expo/entity/commit/6b6e70e75f7e42a4dce13edeb406a4d88d9264f0))
* privacy policy evaluation context ([#162](https://github.com/expo/entity/issues/162)) ([3a81a4c](https://github.com/expo/entity/commit/3a81a4c059e2e995eb800e35e76b3d432c22b23f))
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
**Note:** Version bump only for package @expo/entity
### Chores
* Refactor cachers to use an abstract class and interface ([#157](https://github.com/expo/entity/pull/157))
# [0.23.0](https://github.com/expo/entity/compare/v0.22.0...v0.23.0) (2022-02-09)
### Breaking changes
* Move data transforming responsibilities to the CacheAdapter instead of the ReadThroughEntityCache. Affects any classes extending the EntityCacheAdapter and relying on the transformation behavior of the ReadThroughEntityCache. ([#153](https://github.com/expo/entity/pull/153))
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04) # [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 **Note:** Version bump only for package @expo/entity
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@expo/entity", "name": "@expo/entity",
"version": "0.22.0", "version": "0.33.0",
"description": "A privacy-first data model", "description": "A privacy-first data model",
"files": [ "files": [
"build", "build",
@@ -19,7 +19,7 @@
"barrelsby": "barrelsby --directory src --location top --exclude tests__ --singleQuotes --exportDefault --delete" "barrelsby": "barrelsby --directory src --location top --exclude tests__ --singleQuotes --exportDefault --delete"
}, },
"engines": { "engines": {
"node": ">=12" "node": ">=16"
}, },
"keywords": [ "keywords": [
"entity" "entity"
@@ -0,0 +1,120 @@
import nullthrows from 'nullthrows';
import IEntityCacheAdapter from './IEntityCacheAdapter';
import { CacheStatus, CacheLoadResult } from './internal/ReadThroughEntityCache';
/**
* A IEntityCacheAdapter that composes other IEntityCacheAdapter instances.
*/
export default class ComposedEntityCacheAdapter<TFields> implements IEntityCacheAdapter<TFields> {
/**
* @param cacheAdapters - list of cache adapters to compose in order of precedence.
* Earlier cache adapters are read from first and written to (including invalidations) last.
* Typically, caches closer to the application should be ordered before caches closer to the database.
* A lower layer cache is closer to the database, while a higher layer cache is closer to the application.
*/
constructor(private readonly cacheAdapters: IEntityCacheAdapter<TFields>[]) {}
public async loadManyAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult<TFields>>> {
const retMap = new Map<NonNullable<TFields[N]>, CacheLoadResult<TFields>>();
const fulfilledFieldValuesByCacheIndex: NonNullable<TFields[N]>[][] = Array.from(
{ length: this.cacheAdapters.length },
() => []
);
let unfulfilledFieldValues = fieldValues;
for (let i = 0; i < this.cacheAdapters.length; i++) {
const cacheAdapter = nullthrows(this.cacheAdapters[i]);
const cacheResultsFromAdapter = await cacheAdapter.loadManyAsync(
fieldName,
unfulfilledFieldValues
);
const newUnfulfilledFieldValues = [];
for (const [fieldValue, cacheResult] of cacheResultsFromAdapter) {
if (cacheResult.status === CacheStatus.MISS) {
newUnfulfilledFieldValues.push(fieldValue);
} else {
retMap.set(fieldValue, cacheResult);
nullthrows(fulfilledFieldValuesByCacheIndex[i]).push(fieldValue);
}
}
unfulfilledFieldValues = newUnfulfilledFieldValues;
if (unfulfilledFieldValues.length === 0) {
break;
}
}
// Recache values from lower layers that were not found in higher layers
// Write to lower layers first
for (let i = this.cacheAdapters.length - 1; i >= 0; i--) {
const cacheAdapter = nullthrows(this.cacheAdapters[i]);
const hitsToCache = new Map<NonNullable<TFields[N]>, Readonly<TFields>>();
const negativesToCache: NonNullable<TFields[N]>[] = [];
// Loop over all lower layer caches to collect hits and misses
for (let j = i + 1; j < this.cacheAdapters.length; j++) {
const fulfilledFieldValues = nullthrows(fulfilledFieldValuesByCacheIndex[j]);
fulfilledFieldValues.forEach((fieldValue) => {
const cacheResult = nullthrows(retMap.get(fieldValue));
if (cacheResult.status === CacheStatus.HIT) {
hitsToCache.set(fieldValue, cacheResult.item);
} else if (cacheResult.status === CacheStatus.NEGATIVE) {
negativesToCache.push(fieldValue);
}
});
}
const promises = [];
if (hitsToCache.size > 0) {
promises.push(cacheAdapter.cacheManyAsync(fieldName, hitsToCache));
}
if (negativesToCache.length > 0) {
promises.push(cacheAdapter.cacheDBMissesAsync(fieldName, negativesToCache));
}
await Promise.all(promises);
}
for (const fieldValue of unfulfilledFieldValues) {
retMap.set(fieldValue, { status: CacheStatus.MISS });
}
return retMap;
}
public async cacheManyAsync<N extends keyof TFields>(
fieldName: N,
objectMap: ReadonlyMap<NonNullable<TFields[N]>, Readonly<TFields>>
): Promise<void> {
// write to lower layers first
for (let i = this.cacheAdapters.length - 1; i >= 0; i--) {
const cacheAdapter = nullthrows(this.cacheAdapters[i]);
await cacheAdapter.cacheManyAsync(fieldName, objectMap);
}
}
public async cacheDBMissesAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<void> {
// write to lower layers first
for (let i = this.cacheAdapters.length - 1; i >= 0; i--) {
const cacheAdapter = nullthrows(this.cacheAdapters[i]);
await cacheAdapter.cacheDBMissesAsync(fieldName, fieldValues);
}
}
public async invalidateManyAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<void> {
// delete from lower layers first
for (let i = this.cacheAdapters.length - 1; i >= 0; i--) {
const cacheAdapter = nullthrows(this.cacheAdapters[i]);
await cacheAdapter.invalidateManyAsync(fieldName, fieldValues);
}
}
}
@@ -0,0 +1,64 @@
import nullthrows from 'nullthrows';
import { ISecondaryEntityCache } from './EntitySecondaryCacheLoader';
/**
* A ISecondaryEntityCache that composes other ISecondaryEntityCache instances.
*/
export default class ComposedSecondaryEntityCache<TLoadParams, TFields>
implements ISecondaryEntityCache<TFields, TLoadParams>
{
/**
* @param secondaryEntityCaches - list of caches to compose in order of precedence.
* Earlier caches are read from first and written to (including invalidations) last.
* Typically, caches closer to the application should be ordered before caches closer to the database.
*/
constructor(
private readonly secondaryEntityCaches: ISecondaryEntityCache<TFields, TLoadParams>[]
) {}
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>> {
return await ComposedSecondaryEntityCache.loadManyThroughRecursivelyAsync(
this.secondaryEntityCaches,
loadParamsArray,
fetcher
);
}
private static async loadManyThroughRecursivelyAsync<TLoadParams, TFields>(
secondaryEntityCaches: ISecondaryEntityCache<TFields, TLoadParams>[],
loadParamsArray: readonly Readonly<TLoadParams>[],
fetcher: (
fetcherLoadParamsArray: readonly Readonly<TLoadParams>[]
) => Promise<ReadonlyMap<Readonly<TLoadParams>, Readonly<TFields> | null>>
): Promise<ReadonlyMap<Readonly<TLoadParams>, Readonly<TFields> | null>> {
if (secondaryEntityCaches.length === 0) {
return await fetcher(loadParamsArray);
}
const [firstCache, ...restCaches] = secondaryEntityCaches;
return await nullthrows(firstCache).loadManyThroughAsync(
loadParamsArray,
(fetcherLoadParamsArray) =>
ComposedSecondaryEntityCache.loadManyThroughRecursivelyAsync(
restCaches,
fetcherLoadParamsArray,
fetcher
)
);
}
async invalidateManyAsync(loadParamsArray: readonly Readonly<TLoadParams>[]): Promise<void> {
// invalidate lower layers first
for (let i = this.secondaryEntityCaches.length - 1; i >= 0; i--) {
const secondaryEntityCache = nullthrows(this.secondaryEntityCaches[i]);
await secondaryEntityCache.invalidateManyAsync(loadParamsArray);
}
}
}
+40 -11
View File
@@ -1,4 +1,8 @@
import { FieldEqualityCondition, QuerySelectionModifiers } from './EntityDatabaseAdapter'; import {
FieldEqualityCondition,
QuerySelectionModifiers,
QuerySelectionModifiersWithOrderByRaw,
} from './EntityDatabaseAdapter';
import EntityLoader from './EntityLoader'; import EntityLoader from './EntityLoader';
import EntityPrivacyPolicy from './EntityPrivacyPolicy'; import EntityPrivacyPolicy from './EntityPrivacyPolicy';
import ReadonlyEntity from './ReadonlyEntity'; import ReadonlyEntity from './ReadonlyEntity';
@@ -10,7 +14,7 @@ import { mapMap } from './utils/collections/maps';
* if the loads are not successful. * if the loads are not successful.
*/ */
export default class EnforcingEntityLoader< export default class EnforcingEntityLoader<
TFields, TFields extends object,
TID extends NonNullable<TFields[TSelectedFields]>, 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>,
@@ -36,7 +40,7 @@ export default class EnforcingEntityLoader<
/** /**
* Enforcing version of entity loader method by the same name. * Enforcing version of entity loader method by the same name.
* @throws {@link EntityNotAuthorizedError} when viewer is not authorized to view one or more of the returned entities * @throws EntityNotAuthorizedError when viewer is not authorized to view one or more of the returned entities
*/ */
async loadManyByFieldEqualingManyAsync<N extends keyof Pick<TFields, TSelectedFields>>( async loadManyByFieldEqualingManyAsync<N extends keyof Pick<TFields, TSelectedFields>>(
fieldName: N, fieldName: N,
@@ -53,7 +57,7 @@ export default class EnforcingEntityLoader<
/** /**
* Enforcing version of entity loader method by the same name. * Enforcing version of entity loader method by the same name.
* @throws {@link EntityNotAuthorizedError} when viewer is not authorized to view one or more of the returned entities * @throws EntityNotAuthorizedError when viewer is not authorized to view one or more of the returned entities
*/ */
async loadManyByFieldEqualingAsync<N extends keyof Pick<TFields, TSelectedFields>>( async loadManyByFieldEqualingAsync<N extends keyof Pick<TFields, TSelectedFields>>(
fieldName: N, fieldName: N,
@@ -68,7 +72,7 @@ export default class EnforcingEntityLoader<
/** /**
* Enforcing version of entity loader method by the same name. * Enforcing version of entity loader method by the same name.
* @throws {@link EntityNotAuthorizedError} when viewer is not authorized to view the returned entity * @throws EntityNotAuthorizedError when viewer is not authorized to view the returned entity
* @throws when multiple entities are found matching the condition * @throws when multiple entities are found matching the condition
*/ */
async loadByFieldEqualingAsync<N extends keyof Pick<TFields, TSelectedFields>>( async loadByFieldEqualingAsync<N extends keyof Pick<TFields, TSelectedFields>>(
@@ -84,7 +88,7 @@ export default class EnforcingEntityLoader<
/** /**
* Enforcing version of entity loader method by the same name. * Enforcing version of entity loader method by the same name.
* @throws {@link EntityNotAuthorizedError} when viewer is not authorized to view the returned entity * @throws EntityNotAuthorizedError when viewer is not authorized to view the returned entity
*/ */
async loadByIDAsync(id: TID): Promise<TEntity> { async loadByIDAsync(id: TID): Promise<TEntity> {
const entityResult = await this.entityLoader.loadByIDAsync(id); const entityResult = await this.entityLoader.loadByIDAsync(id);
@@ -93,7 +97,7 @@ export default class EnforcingEntityLoader<
/** /**
* Enforcing version of entity loader method by the same name. * Enforcing version of entity loader method by the same name.
* @throws {@link EntityNotAuthorizedError} when viewer is not authorized to view the returned entity * @throws EntityNotAuthorizedError when viewer is not authorized to view the returned entity
* @throws when multiple entities are found matching the condition * @throws when multiple entities are found matching the condition
*/ */
async loadByIDNullableAsync(id: TID): Promise<TEntity | null> { async loadByIDNullableAsync(id: TID): Promise<TEntity | null> {
@@ -103,7 +107,7 @@ export default class EnforcingEntityLoader<
/** /**
* Enforcing version of entity loader method by the same name. * Enforcing version of entity loader method by the same name.
* @throws {@link EntityNotAuthorizedError} when viewer is not authorized to view one or more of the returned entities * @throws EntityNotAuthorizedError when viewer is not authorized to view one or more of the returned entities
*/ */
async loadManyByIDsAsync(ids: readonly TID[]): Promise<ReadonlyMap<TID, TEntity>> { async loadManyByIDsAsync(ids: readonly TID[]): Promise<ReadonlyMap<TID, TEntity>> {
const entityResults = await this.entityLoader.loadManyByIDsAsync(ids); const entityResults = await this.entityLoader.loadManyByIDsAsync(ids);
@@ -112,7 +116,32 @@ export default class EnforcingEntityLoader<
/** /**
* Enforcing version of entity loader method by the same name. * Enforcing version of entity loader method by the same name.
* @throws {@link EntityNotAuthorizedError} when viewer is not authorized to view one or more of the returned entities * @throws EntityNotAuthorizedError when viewer is not authorized to view one or more of the returned entities
*/
async loadManyByIDsNullableAsync(ids: readonly TID[]): Promise<ReadonlyMap<TID, TEntity | null>> {
const entityResults = await this.entityLoader.loadManyByIDsNullableAsync(ids);
return mapMap(entityResults, (result) => result?.enforceValue() ?? null);
}
/**
* Enforcing version of entity loader method by the same name.
* @throws EntityNotAuthorizedError when viewer is not authorized to view one or more of the returned entities
*/
async loadFirstByFieldEqualityConjunctionAsync<N extends keyof Pick<TFields, TSelectedFields>>(
fieldEqualityOperands: FieldEqualityCondition<TFields, N>[],
querySelectionModifiers: Omit<QuerySelectionModifiers<TFields>, 'limit'> &
Required<Pick<QuerySelectionModifiers<TFields>, 'orderBy'>>
): Promise<TEntity | null> {
const entityResult = await this.entityLoader.loadFirstByFieldEqualityConjunctionAsync(
fieldEqualityOperands,
querySelectionModifiers
);
return entityResult ? entityResult.enforceValue() : null;
}
/**
* Enforcing version of entity loader method by the same name.
* @throws EntityNotAuthorizedError when viewer is not authorized to view one or more of the returned entities
*/ */
async loadManyByFieldEqualityConjunctionAsync<N extends keyof Pick<TFields, TSelectedFields>>( async loadManyByFieldEqualityConjunctionAsync<N extends keyof Pick<TFields, TSelectedFields>>(
fieldEqualityOperands: FieldEqualityCondition<TFields, N>[], fieldEqualityOperands: FieldEqualityCondition<TFields, N>[],
@@ -127,12 +156,12 @@ export default class EnforcingEntityLoader<
/** /**
* Enforcing version of entity loader method by the same name. * Enforcing version of entity loader method by the same name.
* @throws {@link EntityNotAuthorizedError} when viewer is not authorized to view one or more of the returned entities * @throws EntityNotAuthorizedError when viewer is not authorized to view one or more of the returned entities
*/ */
async loadManyByRawWhereClauseAsync( async loadManyByRawWhereClauseAsync(
rawWhereClause: string, rawWhereClause: string,
bindings: any[] | object, bindings: any[] | object,
querySelectionModifiers: QuerySelectionModifiers<TFields> = {} querySelectionModifiers: QuerySelectionModifiersWithOrderByRaw<TFields> = {}
): Promise<readonly TEntity[]> { ): Promise<readonly TEntity[]> {
const entityResults = await this.entityLoader.loadManyByRawWhereClauseAsync( const entityResults = await this.entityLoader.loadManyByRawWhereClauseAsync(
rawWhereClause, rawWhereClause,
+30 -17
View File
@@ -11,22 +11,22 @@ import ViewerContext from './ViewerContext';
* Entity is a privacy-first data model. * Entity is a privacy-first data model.
* *
* A instance of an entity represents a single "row" of persisted data in a database that a * A instance of an entity represents a single "row" of persisted data in a database that a
* viewer, represented by the corresponding {@link ViewerContext}, has permission to read. * viewer, represented by the corresponding ViewerContext, has permission to read.
* *
* Create, read, update, and delete permissions for an entity are declaratively defined using an * Create, read, update, and delete permissions for an entity are declaratively defined using an
* {@link EntityPrivacyPolicy}. * EntityPrivacyPolicy.
* *
* Entites are loaded through an {@link EntityLoader}, which is responsible for * Entites are loaded through an EntityLoader, which is responsible for
* orchestrating fetching, caching, and authorization of reading "rows". * orchestrating fetching, caching, and authorization of reading "rows".
* *
* Entities are mutated and deleted through an {@link EntityMutator}, which is responsible for * Entities are mutated and deleted through an EntityMutator, which is responsible for
* orchestrating database writes, cache invalidation, and authorization of writing "rows". * orchestrating database writes, cache invalidation, and authorization of writing "rows".
* *
* All concrete entity implementations should extend this class and provide their * All concrete entity implementations should extend this class and provide their
* own {@link EntityCompanionDefinition}. * own EntityCompanionDefinition.
*/ */
export default abstract class Entity< export default abstract class Entity<
TFields, TFields extends object,
TID extends NonNullable<TFields[TSelectedFields]>, TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext, TViewerContext extends ViewerContext,
TSelectedFields extends keyof TFields = keyof TFields TSelectedFields extends keyof TFields = keyof TFields
@@ -38,7 +38,7 @@ export default abstract class Entity<
* @returns mutator for creating an entity * @returns mutator for creating an entity
*/ */
static creator< static creator<
TMFields, TMFields extends object,
TMID extends NonNullable<TMFields[TMSelectedFields]>, TMID extends NonNullable<TMFields[TMSelectedFields]>,
TMViewerContext extends ViewerContext, TMViewerContext extends ViewerContext,
TMViewerContext2 extends TMViewerContext, TMViewerContext2 extends TMViewerContext,
@@ -79,7 +79,7 @@ export default abstract class Entity<
* @returns mutator for updating existingEntity * @returns mutator for updating existingEntity
*/ */
static updater< static updater<
TMFields, TMFields extends object,
TMID extends NonNullable<TMFields[TMSelectedFields]>, 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>,
@@ -120,7 +120,7 @@ export default abstract class Entity<
* @param queryContext - query context in which to perform the delete * @param queryContext - query context in which to perform the delete
*/ */
static deleteAsync< static deleteAsync<
TMFields, TMFields extends object,
TMID extends NonNullable<TMFields[TMSelectedFields]>, 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>,
@@ -162,7 +162,7 @@ export default abstract class Entity<
* @param queryContext - query context in which to perform the delete * @param queryContext - query context in which to perform the delete
*/ */
static enforceDeleteAsync< static enforceDeleteAsync<
TMFields, TMFields extends object,
TMID extends NonNullable<TMFields[TMSelectedFields]>, 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>,
@@ -215,7 +215,7 @@ export default abstract class Entity<
* @param queryContext - query context in which to perform the check * @param queryContext - query context in which to perform the check
*/ */
static async canViewerUpdateAsync< static async canViewerUpdateAsync<
TMFields, TMFields extends object,
TMID extends NonNullable<TMFields[TMSelectedFields]>, 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>,
@@ -246,11 +246,12 @@ export default abstract class Entity<
const companion = existingEntity const companion = existingEntity
.getViewerContext() .getViewerContext()
.getViewerScopedEntityCompanionForClass(this); .getViewerScopedEntityCompanionForClass(this);
const privacyPolicy = new (this.getCompanionDefinition().privacyPolicyClass)(); const privacyPolicy = companion.entityCompanion.privacyPolicy;
const evaluationResult = await asyncResult( const evaluationResult = await asyncResult(
privacyPolicy.authorizeUpdateAsync( privacyPolicy.authorizeUpdateAsync(
existingEntity.getViewerContext(), existingEntity.getViewerContext(),
queryContext, queryContext,
{ cascadingDeleteCause: null },
existingEntity, existingEntity,
companion.getMetricsAdapter() companion.getMetricsAdapter()
) )
@@ -268,7 +269,7 @@ export default abstract class Entity<
* @param queryContext - query context in which to perform the check * @param queryContext - query context in which to perform the check
*/ */
static async canViewerDeleteAsync< static async canViewerDeleteAsync<
TMFields, TMFields extends object,
TMID extends NonNullable<TMFields[TMSelectedFields]>, 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>,
@@ -299,11 +300,12 @@ export default abstract class Entity<
const companion = existingEntity const companion = existingEntity
.getViewerContext() .getViewerContext()
.getViewerScopedEntityCompanionForClass(this); .getViewerScopedEntityCompanionForClass(this);
const privacyPolicy = new (this.getCompanionDefinition().privacyPolicyClass)(); const privacyPolicy = companion.entityCompanion.privacyPolicy;
const evaluationResult = await asyncResult( const evaluationResult = await asyncResult(
privacyPolicy.authorizeDeleteAsync( privacyPolicy.authorizeDeleteAsync(
existingEntity.getViewerContext(), existingEntity.getViewerContext(),
queryContext, queryContext,
{ cascadingDeleteCause: null },
existingEntity, existingEntity,
companion.getMetricsAdapter() companion.getMetricsAdapter()
) )
@@ -316,7 +318,7 @@ export default abstract class Entity<
* An interface to pass in constructor (class) of an Entity as a function argument. * An interface to pass in constructor (class) of an Entity as a function argument.
*/ */
export interface IEntityClass< export interface IEntityClass<
TFields, TFields extends object,
TID extends NonNullable<TFields[TSelectedFields]>, 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>,
@@ -329,8 +331,19 @@ export interface IEntityClass<
>, >,
TSelectedFields extends keyof TFields = keyof TFields TSelectedFields extends keyof TFields = keyof TFields
> { > {
new (viewerContext: TViewerContext, obj: Readonly<TFields>): TEntity; new (constructorParam: {
getCompanionDefinition(): EntityCompanionDefinition< viewerContext: TViewerContext;
id: TID;
databaseFields: Readonly<TFields>;
selectedFields: Readonly<Pick<TFields, TSelectedFields>>;
}): TEntity;
/**
* Returns a EntityCompanionDefinition for this entity.
*
* Memoized by the entity framework.
*/
defineCompanionDefinition(): EntityCompanionDefinition<
TFields, TFields,
TID, TID,
TViewerContext, TViewerContext,
+25 -20
View File
@@ -6,8 +6,13 @@ import { EntityQueryContext } from './EntityQueryContext';
import ReadonlyEntity from './ReadonlyEntity'; import ReadonlyEntity from './ReadonlyEntity';
import ViewerContext from './ViewerContext'; import ViewerContext from './ViewerContext';
/**
* An association loader is a set of convenience methods for loading entities
* associated with an entity. In relational databases, these entities are often referenced
* by foreign keys.
*/
export default class EntityAssociationLoader< export default class EntityAssociationLoader<
TFields, TFields extends object,
TID extends NonNullable<TFields[TSelectedFields]>, 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>,
@@ -24,7 +29,7 @@ export default class EntityAssociationLoader<
*/ */
async loadAssociatedEntityAsync< async loadAssociatedEntityAsync<
TIdentifyingField extends keyof Pick<TFields, TSelectedFields>, TIdentifyingField extends keyof Pick<TFields, TSelectedFields>,
TAssociatedFields, TAssociatedFields extends object,
TAssociatedID extends NonNullable<TAssociatedFields[TAssociatedSelectedFields]>, TAssociatedID extends NonNullable<TAssociatedFields[TAssociatedSelectedFields]>,
TAssociatedEntity extends ReadonlyEntity< TAssociatedEntity extends ReadonlyEntity<
TAssociatedFields, TAssociatedFields,
@@ -69,7 +74,7 @@ export default class EntityAssociationLoader<
.getViewerContext() .getViewerContext()
.getViewerScopedEntityCompanionForClass(associatedEntityClass) .getViewerScopedEntityCompanionForClass(associatedEntityClass)
.getLoaderFactory() .getLoaderFactory()
.forLoad(queryContext); .forLoad(queryContext, { cascadingDeleteCause: null });
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
@@ -86,7 +91,7 @@ export default class EntityAssociationLoader<
* @param queryContext - query context in which to perform the load * @param queryContext - query context in which to perform the load
*/ */
async loadManyAssociatedEntitiesAsync< async loadManyAssociatedEntitiesAsync<
TAssociatedFields, TAssociatedFields extends object,
TAssociatedID extends NonNullable<TAssociatedFields[TAssociatedSelectedFields]>, TAssociatedID extends NonNullable<TAssociatedFields[TAssociatedSelectedFields]>,
TAssociatedEntity extends ReadonlyEntity< TAssociatedEntity extends ReadonlyEntity<
TAssociatedFields, TAssociatedFields,
@@ -123,7 +128,7 @@ export default class EntityAssociationLoader<
.getViewerContext() .getViewerContext()
.getViewerScopedEntityCompanionForClass(associatedEntityClass) .getViewerScopedEntityCompanionForClass(associatedEntityClass)
.getLoaderFactory() .getLoaderFactory()
.forLoad(queryContext); .forLoad(queryContext, { cascadingDeleteCause: null });
return await loader.loadManyByFieldEqualingAsync( return await loader.loadManyByFieldEqualingAsync(
associatedEntityFieldContainingThisID, associatedEntityFieldContainingThisID,
thisID as any thisID as any
@@ -139,7 +144,7 @@ export default class EntityAssociationLoader<
* @param queryContext - query context in which to perform the load * @param queryContext - query context in which to perform the load
*/ */
async loadAssociatedEntityByFieldEqualingAsync< async loadAssociatedEntityByFieldEqualingAsync<
TAssociatedFields, TAssociatedFields extends object,
TAssociatedID extends NonNullable<TAssociatedFields[TAssociatedSelectedFields]>, TAssociatedID extends NonNullable<TAssociatedFields[TAssociatedSelectedFields]>,
TAssociatedEntity extends ReadonlyEntity< TAssociatedEntity extends ReadonlyEntity<
TAssociatedFields, TAssociatedFields,
@@ -180,7 +185,7 @@ export default class EntityAssociationLoader<
.getViewerContext() .getViewerContext()
.getViewerScopedEntityCompanionForClass(associatedEntityClass) .getViewerScopedEntityCompanionForClass(associatedEntityClass)
.getLoaderFactory() .getLoaderFactory()
.forLoad(queryContext); .forLoad(queryContext, { cascadingDeleteCause: null });
return await loader.loadByFieldEqualingAsync( return await loader.loadByFieldEqualingAsync(
associatedEntityLookupByField, associatedEntityLookupByField,
associatedFieldValue as any associatedFieldValue as any
@@ -196,7 +201,7 @@ export default class EntityAssociationLoader<
* @param queryContext - query context in which to perform the load * @param queryContext - query context in which to perform the load
*/ */
async loadManyAssociatedEntitiesByFieldEqualingAsync< async loadManyAssociatedEntitiesByFieldEqualingAsync<
TAssociatedFields, TAssociatedFields extends object,
TAssociatedID extends NonNullable<TAssociatedFields[TAssociatedSelectedFields]>, TAssociatedID extends NonNullable<TAssociatedFields[TAssociatedSelectedFields]>,
TAssociatedEntity extends ReadonlyEntity< TAssociatedEntity extends ReadonlyEntity<
TAssociatedFields, TAssociatedFields,
@@ -238,7 +243,7 @@ export default class EntityAssociationLoader<
.getViewerContext() .getViewerContext()
.getViewerScopedEntityCompanionForClass(associatedEntityClass) .getViewerScopedEntityCompanionForClass(associatedEntityClass)
.getLoaderFactory() .getLoaderFactory()
.forLoad(queryContext); .forLoad(queryContext, { cascadingDeleteCause: null });
return await loader.loadManyByFieldEqualingAsync( return await loader.loadManyByFieldEqualingAsync(
associatedEntityLookupByField, associatedEntityLookupByField,
associatedFieldValue as any associatedFieldValue as any
@@ -246,13 +251,13 @@ export default class EntityAssociationLoader<
} }
/** /**
* Load an associated entity by folding a sequence of {@link EntityLoadThroughDirective}. At each * Load an associated entity by folding a sequence of EntityLoadThroughDirective. At each
* fold step, load an associated entity identified by a field value of the current fold value. * fold step, load an associated entity identified by a field value of the current fold value.
* @param loadDirectives - associated entity load directives instructing each step of the fold * @param loadDirectives - associated entity load directives instructing each step of the fold
* @param queryContext - query context in which to perform the loads * @param queryContext - query context in which to perform the loads
*/ */
async loadAssociatedEntityThroughAsync< async loadAssociatedEntityThroughAsync<
TFields2, TFields2 extends object,
TID2 extends NonNullable<TFields2[TSelectedFields2]>, 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<
@@ -280,13 +285,13 @@ export default class EntityAssociationLoader<
): Promise<Result<TEntity2> | null>; ): Promise<Result<TEntity2> | null>;
/** /**
* Load an associated entity by folding a sequence of {@link EntityLoadThroughDirective}. At each * Load an associated entity by folding a sequence of EntityLoadThroughDirective. At each
* fold step, load an associated entity identified by a field value of the current fold value. * fold step, load an associated entity identified by a field value of the current fold value.
* @param loadDirectives - associated entity load directives instructing each step of the fold * @param loadDirectives - associated entity load directives instructing each step of the fold
* @param queryContext - query context in which to perform the loads * @param queryContext - query context in which to perform the loads
*/ */
async loadAssociatedEntityThroughAsync< async loadAssociatedEntityThroughAsync<
TFields2, TFields2 extends object,
TID2 extends NonNullable<TFields2[TSelectedFields2]>, 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<
@@ -296,7 +301,7 @@ export default class EntityAssociationLoader<
TEntity2, TEntity2,
TSelectedFields2 TSelectedFields2
>, >,
TFields3, TFields3 extends object,
TID3 extends NonNullable<TFields3[TSelectedFields3]>, 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<
@@ -335,13 +340,13 @@ export default class EntityAssociationLoader<
): Promise<Result<TEntity3> | null>; ): Promise<Result<TEntity3> | null>;
/** /**
* Load an associated entity by folding a sequence of {@link EntityLoadThroughDirective}. At each * Load an associated entity by folding a sequence of EntityLoadThroughDirective. At each
* fold step, load an associated entity identified by a field value of the current fold value. * fold step, load an associated entity identified by a field value of the current fold value.
* @param loadDirectives - associated entity load directives instructing each step of the fold * @param loadDirectives - associated entity load directives instructing each step of the fold
* @param queryContext - query context in which to perform the loads * @param queryContext - query context in which to perform the loads
*/ */
async loadAssociatedEntityThroughAsync< async loadAssociatedEntityThroughAsync<
TFields2, TFields2 extends object,
TID2 extends NonNullable<TFields2[TSelectedFields2]>, 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<
@@ -351,7 +356,7 @@ export default class EntityAssociationLoader<
TEntity2, TEntity2,
TSelectedFields2 TSelectedFields2
>, >,
TFields3, TFields3 extends object,
TID3 extends NonNullable<TFields3[TSelectedFields3]>, 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<
@@ -361,7 +366,7 @@ export default class EntityAssociationLoader<
TEntity3, TEntity3,
TSelectedFields3 TSelectedFields3
>, >,
TFields4, TFields4 extends object,
TID4 extends NonNullable<TFields4[TSelectedFields4]>, 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<
@@ -411,7 +416,7 @@ export default class EntityAssociationLoader<
): Promise<Result<TEntity4> | null>; ): Promise<Result<TEntity4> | null>;
/** /**
* Load an associated entity by folding a sequence of {@link EntityLoadThroughDirective}. At each * Load an associated entity by folding a sequence of EntityLoadThroughDirective. At each
* fold step, load an associated entity identified by a field value of the current fold value. * fold step, load an associated entity identified by a field value of the current fold value.
* @param loadDirectives - associated entity load directives instructing each step of the fold * @param loadDirectives - associated entity load directives instructing each step of the fold
* @param queryContext - query context in which to perform the loads * @param queryContext - query context in which to perform the loads
@@ -477,7 +482,7 @@ export default class EntityAssociationLoader<
export interface EntityLoadThroughDirective< export interface EntityLoadThroughDirective<
TViewerContext extends ViewerContext, TViewerContext extends ViewerContext,
TFields, TFields,
TAssociatedFields, TAssociatedFields extends object,
TAssociatedID extends NonNullable<TAssociatedFields[TAssociatedSelectedFields]>, TAssociatedID extends NonNullable<TAssociatedFields[TAssociatedSelectedFields]>,
TAssociatedEntity extends ReadonlyEntity< TAssociatedEntity extends ReadonlyEntity<
TAssociatedFields, TAssociatedFields,
+14 -33
View File
@@ -1,7 +1,5 @@
import { IEntityClass } from './Entity'; import EntityCompanionProvider, { EntityCompanionDefinition } from './EntityCompanionProvider';
import EntityLoaderFactory from './EntityLoaderFactory'; import EntityLoaderFactory from './EntityLoaderFactory';
import EntityMutationTriggerConfiguration from './EntityMutationTriggerConfiguration';
import EntityMutationValidator from './EntityMutationValidator';
import EntityMutatorFactory from './EntityMutatorFactory'; import EntityMutatorFactory from './EntityMutatorFactory';
import EntityPrivacyPolicy from './EntityPrivacyPolicy'; import EntityPrivacyPolicy from './EntityPrivacyPolicy';
import EntityQueryContextProvider from './EntityQueryContextProvider'; import EntityQueryContextProvider from './EntityQueryContextProvider';
@@ -18,7 +16,7 @@ export interface IPrivacyPolicyClass<TPrivacyPolicy> {
* Composition root responsible for orchestrating setup of Entity mutators and loaders. * Composition root responsible for orchestrating setup of Entity mutators and loaders.
*/ */
export default class EntityCompanion< export default class EntityCompanion<
TFields, TFields extends object,
TID extends NonNullable<TFields[TSelectedFields]>, 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>,
@@ -31,6 +29,8 @@ export default class EntityCompanion<
>, >,
TSelectedFields extends keyof TFields TSelectedFields extends keyof TFields
> { > {
public readonly privacyPolicy: TPrivacyPolicy;
private readonly entityLoaderFactory: EntityLoaderFactory< private readonly entityLoaderFactory: EntityLoaderFactory<
TFields, TFields,
TID, TID,
@@ -49,7 +49,8 @@ export default class EntityCompanion<
>; >;
constructor( constructor(
entityClass: IEntityClass< public readonly entityCompanionProvider: EntityCompanionProvider,
public readonly entityCompanionDefinition: EntityCompanionDefinition<
TFields, TFields,
TID, TID,
TViewerContext, TViewerContext,
@@ -58,24 +59,9 @@ export default class EntityCompanion<
TSelectedFields TSelectedFields
>, >,
private readonly tableDataCoordinator: EntityTableDataCoordinator<TFields>, private readonly tableDataCoordinator: EntityTableDataCoordinator<TFields>,
PrivacyPolicyClass: IPrivacyPolicyClass<TPrivacyPolicy>,
mutationValidators: EntityMutationValidator<
TFields,
TID,
TViewerContext,
TEntity,
TSelectedFields
>[],
mutationTriggers: EntityMutationTriggerConfiguration<
TFields,
TID,
TViewerContext,
TEntity,
TSelectedFields
>,
private readonly metricsAdapter: IEntityMetricsAdapter private readonly metricsAdapter: IEntityMetricsAdapter
) { ) {
const privacyPolicy = new PrivacyPolicyClass(); this.privacyPolicy = new entityCompanionDefinition.privacyPolicyClass();
this.entityLoaderFactory = new EntityLoaderFactory< this.entityLoaderFactory = new EntityLoaderFactory<
TFields, TFields,
TID, TID,
@@ -83,19 +69,14 @@ export default class EntityCompanion<
TEntity, TEntity,
TPrivacyPolicy, TPrivacyPolicy,
TSelectedFields TSelectedFields
>( >(this, tableDataCoordinator.dataManager, metricsAdapter);
tableDataCoordinator.entityConfiguration,
entityClass,
privacyPolicy,
tableDataCoordinator.dataManager,
metricsAdapter
);
this.entityMutatorFactory = new EntityMutatorFactory( this.entityMutatorFactory = new EntityMutatorFactory(
entityCompanionProvider,
tableDataCoordinator.entityConfiguration, tableDataCoordinator.entityConfiguration,
entityClass, entityCompanionDefinition.entityClass,
privacyPolicy, this.privacyPolicy,
mutationValidators, entityCompanionDefinition.mutationValidators ?? [],
mutationTriggers, entityCompanionDefinition.mutationTriggers ?? {},
this.entityLoaderFactory, this.entityLoaderFactory,
tableDataCoordinator.databaseAdapter, tableDataCoordinator.databaseAdapter,
metricsAdapter metricsAdapter
@@ -132,7 +113,7 @@ export default class EntityCompanion<
} }
/** /**
* Get the {@link IEntityMetricsAdapter} for this companion. * Get the IEntityMetricsAdapter for this companion.
*/ */
getMetricsAdapter(): IEntityMetricsAdapter { getMetricsAdapter(): IEntityMetricsAdapter {
return this.metricsAdapter; return this.metricsAdapter;
+44 -83
View File
@@ -49,8 +49,8 @@ export interface CacheAdapterFlavorDefinition {
* Definition for constructing a companion for an entity. Defines the core set of objects * Definition for constructing a companion for an entity. Defines the core set of objects
* used to power the entity framework for a particular type of entity. * used to power the entity framework for a particular type of entity.
*/ */
export class EntityCompanionDefinition< export interface EntityCompanionDefinition<
TFields, TFields extends object,
TID extends NonNullable<TFields[TSelectedFields]>, 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>,
@@ -63,6 +63,9 @@ export class EntityCompanionDefinition<
>, >,
TSelectedFields extends keyof TFields = keyof TFields TSelectedFields extends keyof TFields = keyof TFields
> { > {
/**
* The concrete Entity class for which this is the definition.
*/
readonly entityClass: IEntityClass< readonly entityClass: IEntityClass<
TFields, TFields,
TID, TID,
@@ -71,97 +74,61 @@ export class EntityCompanionDefinition<
TPrivacyPolicy, TPrivacyPolicy,
TSelectedFields TSelectedFields
>; >;
/**
* The EntityConfiguration for this entity.
*/
readonly entityConfiguration: EntityConfiguration<TFields>; readonly entityConfiguration: EntityConfiguration<TFields>;
/**
* The EntityPrivacyPolicy class for this entity.
*/
readonly privacyPolicyClass: IPrivacyPolicyClass<TPrivacyPolicy>; readonly privacyPolicyClass: IPrivacyPolicyClass<TPrivacyPolicy>;
readonly mutationValidators: () => EntityMutationValidator<
/**
* An optional list of EntityMutationValidator for this entity.
*/
readonly mutationValidators?: EntityMutationValidator<
TFields, TFields,
TID, TID,
TViewerContext, TViewerContext,
TEntity, TEntity,
TSelectedFields TSelectedFields
>[]; >[];
readonly mutationTriggers: () => EntityMutationTriggerConfiguration<
/**
* An optional list of EntityMutationTrigger for this entity.
*/
readonly mutationTriggers?: EntityMutationTriggerConfiguration<
TFields, TFields,
TID, TID,
TViewerContext, TViewerContext,
TEntity, TEntity,
TSelectedFields TSelectedFields
>; >;
readonly entitySelectedFields: TSelectedFields[];
constructor({ /**
entityClass, * An optional subset of fields defined in the EntityConfiguration which belong to this entity.
entityConfiguration, * For use when multiple types of entities are backed by a single table (EntityConfiguration) yet
privacyPolicyClass, * only expose a subset of the fields.
mutationValidators = () => [], */
mutationTriggers = () => ({}), readonly entitySelectedFields?: TSelectedFields[];
entitySelectedFields = Array.from(entityConfiguration.schema.keys()) as TSelectedFields[],
}: {
/**
* The concrete Entity class for which this is the definition.
*/
entityClass: IEntityClass<
TFields,
TID,
TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
>;
/**
* The {@link EntityConfiguration} for this entity.
*/
entityConfiguration: EntityConfiguration<TFields>;
/**
* The {@link EntityPrivacyPolicy} class for this entity.
*/
privacyPolicyClass: IPrivacyPolicyClass<TPrivacyPolicy>;
/**
* An optional list of {@link EntityMutationValidator} for this entity.
*/
mutationValidators?: () => EntityMutationValidator<
TFields,
TID,
TViewerContext,
TEntity,
TSelectedFields
>[];
/**
* An optional list of {@link EntityMutationTrigger} for this entity.
*/
mutationTriggers?: () => EntityMutationTriggerConfiguration<
TFields,
TID,
TViewerContext,
TEntity,
TSelectedFields
>;
/**
* An optional subset of fields defined in the {@link EntityConfiguration} which belong to this entity.
* For use when multiple types of entities are backed by a single table ({@link EntityConfiguration}) yet
* only expose a subset of the fields.
*/
entitySelectedFields?: TSelectedFields[];
}) {
this.entityClass = entityClass;
this.entityConfiguration = entityConfiguration;
this.privacyPolicyClass = privacyPolicyClass;
this.mutationValidators = mutationValidators;
this.mutationTriggers = mutationTriggers;
this.entitySelectedFields = entitySelectedFields;
}
} }
/** /**
* An instance of the Entity framework. * An instance of the Entity framework.
* *
* Required to create a {@link ViewerContext}, which is the application entry point * Required to create a ViewerContext, which is the application entry point
* into the framework. * into the framework.
* *
* Internally, this is a lazy entity companion factory that instantiates and caches one * Internally, this is a lazy entity companion factory that instantiates and caches one
* {@link EntityCompanion} for each type of {@link Entity}. * EntityCompanion for each type of Entity.
*/ */
export default class EntityCompanionProvider { export default class EntityCompanionProvider {
private readonly companionDefinitionMap: Map<
string,
EntityCompanionDefinition<any, any, any, any, any, any>
> = new Map();
private readonly companionMap: Map<string, EntityCompanion<any, any, any, any, any, any>> = private readonly companionMap: Map<string, EntityCompanion<any, any, any, any, any, any>> =
new Map(); new Map();
private readonly tableDataCoordinatorMap: Map<string, EntityTableDataCoordinator<any>> = private readonly tableDataCoordinatorMap: Map<string, EntityTableDataCoordinator<any>> =
@@ -169,7 +136,7 @@ export default class EntityCompanionProvider {
/** /**
* Instantiate an Entity framework. * Instantiate an Entity framework.
* @param metricsAdapter - An {@link IEntityMetricsAdapter} for collecting metrics on this instance * @param metricsAdapter - An IEntityMetricsAdapter for collecting metrics on this instance
* @param databaseAdapterFlavors - Database adapter configurations for this instance * @param databaseAdapterFlavors - Database adapter configurations for this instance
* @param cacheAdapterFlavors - Cache adapter configurations for this instance * @param cacheAdapterFlavors - Cache adapter configurations for this instance
*/ */
@@ -187,10 +154,9 @@ export default class EntityCompanionProvider {
* companion is constructed using the configuration provided by the factory. * companion is constructed using the configuration provided by the factory.
* *
* @param entityClass - entity class to load * @param entityClass - entity class to load
* @param factory - entity companion factory
*/ */
getCompanionForEntity< getCompanionForEntity<
TFields, TFields extends object,
TID extends NonNullable<TFields[TSelectedFields]>, 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>,
@@ -210,27 +176,22 @@ export default class EntityCompanionProvider {
TEntity, TEntity,
TPrivacyPolicy, TPrivacyPolicy,
TSelectedFields TSelectedFields
>,
entityCompanionDefinition: EntityCompanionDefinition<
TFields,
TID,
TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
> >
): EntityCompanion<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy, TSelectedFields> { ): EntityCompanion<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy, TSelectedFields> {
const entityCompanionDefinition = computeIfAbsent(
this.companionDefinitionMap,
entityClass.name,
() => entityClass.defineCompanionDefinition()
);
const tableDataCoordinator = this.getTableDataCoordinatorForEntity( const tableDataCoordinator = this.getTableDataCoordinatorForEntity(
entityCompanionDefinition.entityConfiguration, entityCompanionDefinition.entityConfiguration,
entityClass.name entityClass.name
); );
return computeIfAbsent(this.companionMap, entityClass.name, () => { return computeIfAbsent(this.companionMap, entityClass.name, () => {
return new EntityCompanion( return new EntityCompanion(
entityCompanionDefinition.entityClass, this,
entityCompanionDefinition,
tableDataCoordinator, tableDataCoordinator,
entityCompanionDefinition.privacyPolicyClass,
entityCompanionDefinition.mutationValidators(),
entityCompanionDefinition.mutationTriggers(),
this.metricsAdapter this.metricsAdapter
); );
}); });
+6 -7
View File
@@ -13,7 +13,7 @@ export default class EntityConfiguration<TFields> {
readonly cacheableKeys: ReadonlySet<keyof TFields>; readonly cacheableKeys: ReadonlySet<keyof TFields>;
readonly cacheKeyVersion: number; readonly cacheKeyVersion: number;
readonly getInboundEdges: () => IEntityClass<any, any, any, any, any, any>[]; readonly inboundEdges: IEntityClass<any, any, any, any, any, any>[];
readonly schema: ReadonlyMap<keyof TFields, EntityFieldDefinition<any>>; readonly schema: ReadonlyMap<keyof TFields, EntityFieldDefinition<any>>;
readonly entityToDBFieldsKeyMapping: ReadonlyMap<keyof TFields, string>; readonly entityToDBFieldsKeyMapping: ReadonlyMap<keyof TFields, string>;
readonly dbToEntityFieldsKeyMapping: ReadonlyMap<string, keyof TFields>; readonly dbToEntityFieldsKeyMapping: ReadonlyMap<string, keyof TFields>;
@@ -25,7 +25,7 @@ export default class EntityConfiguration<TFields> {
idField, idField,
tableName, tableName,
schema, schema,
getInboundEdges = () => [], inboundEdges = [],
cacheKeyVersion = 0, cacheKeyVersion = 0,
databaseAdapterFlavor, databaseAdapterFlavor,
cacheAdapterFlavor, cacheAdapterFlavor,
@@ -41,14 +41,14 @@ export default class EntityConfiguration<TFields> {
tableName: string; tableName: string;
/** /**
* Map from each entity field to an {@link EntityFieldDefinition} specifying information about the field. * Map from each entity field to an EntityFieldDefinition specifying information about the field.
*/ */
schema: Record<keyof TFields, EntityFieldDefinition<any>>; schema: Record<keyof TFields, EntityFieldDefinition<any>>;
/** /**
* List of other entity types that reference this type in {@link EntityFieldDefinition} associations. * List of other entity types that reference this type in EntityFieldDefinition associations.
*/ */
getInboundEdges?: () => IEntityClass<any, any, any, any, any, any>[]; inboundEdges?: 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 * Cache key version for this entity type. Should be bumped when a field is added to, removed from, or changed
@@ -71,8 +71,7 @@ export default class EntityConfiguration<TFields> {
this.cacheKeyVersion = cacheKeyVersion; this.cacheKeyVersion = cacheKeyVersion;
this.databaseAdapterFlavor = databaseAdapterFlavor; this.databaseAdapterFlavor = databaseAdapterFlavor;
this.cacheAdapterFlavor = cacheAdapterFlavor; this.cacheAdapterFlavor = cacheAdapterFlavor;
this.inboundEdges = inboundEdges;
this.getInboundEdges = getInboundEdges;
// external schema is a Record to typecheck that all fields have FieldDefinitions, // external schema is a Record to typecheck that all fields have FieldDefinitions,
// but internally the most useful representation is a map for lookups // but internally the most useful representation is a map for lookups
+43 -6
View File
@@ -7,16 +7,32 @@ import {
FieldTransformerMap, FieldTransformerMap,
} from './internal/EntityFieldTransformationUtils'; } from './internal/EntityFieldTransformationUtils';
interface SingleValueFieldEqualityCondition<TFields, N extends keyof TFields = keyof TFields> { /**
* Equality operand that is used for selecting entities with a field with a single value.
*/
export interface SingleValueFieldEqualityCondition<
TFields,
N extends keyof TFields = keyof TFields
> {
fieldName: N; fieldName: N;
fieldValue: TFields[N]; fieldValue: TFields[N];
} }
interface MultiValueFieldEqualityCondition<TFields, N extends keyof TFields = keyof TFields> { /**
* Equality operand that is used for selecting entities with a field matching one of multiple values.
*/
export interface MultiValueFieldEqualityCondition<
TFields,
N extends keyof TFields = keyof TFields
> {
fieldName: N; fieldName: N;
fieldValues: readonly TFields[N][]; fieldValues: readonly TFields[N][];
} }
/**
* A single equality operand for use in a selection clause.
* See EntityLoader.loadManyByFieldEqualityConjunctionAsync documentation for examples.
*/
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>;
@@ -68,6 +84,14 @@ export interface QuerySelectionModifiers<TFields> {
limit?: number; limit?: number;
} }
export interface QuerySelectionModifiersWithOrderByRaw<TFields>
extends QuerySelectionModifiers<TFields> {
/**
* Order the entities by a raw SQL `ORDER BY` clause.
*/
orderByRaw?: string;
}
export interface TableQuerySelectionModifiers { export interface TableQuerySelectionModifiers {
orderBy: orderBy:
| { | {
@@ -79,6 +103,10 @@ export interface TableQuerySelectionModifiers {
limit: number | undefined; limit: number | undefined;
} }
export interface TableQuerySelectionModifiersWithOrderByRaw extends TableQuerySelectionModifiers {
orderByRaw: string | undefined;
}
/** /**
* A database adapter is an interface by which entity objects can be * A database adapter is an interface by which entity objects can be
* fetched, inserted, updated, and deleted from a database. This base class * fetched, inserted, updated, and deleted from a database. This base class
@@ -149,7 +177,7 @@ export default abstract class EntityDatabaseAdapter<TFields> {
* *
* @param queryContext - query context with which to perform the fetch * @param queryContext - query context with which to perform the fetch
* @param fieldEqualityOperands - list of field equality where clause operand specifications * @param fieldEqualityOperands - list of field equality where clause operand specifications
* @param querySelectionModifiers - limit, offset, and orderBy for the query * @param querySelectionModifiers - limit, offset, orderBy, and orderByRaw for the query
* @returns array of objects matching the query * @returns array of objects matching the query
*/ */
async fetchManyByFieldEqualityConjunctionAsync<N extends keyof TFields>( async fetchManyByFieldEqualityConjunctionAsync<N extends keyof TFields>(
@@ -207,14 +235,14 @@ export default abstract class EntityDatabaseAdapter<TFields> {
queryContext: EntityQueryContext, queryContext: EntityQueryContext,
rawWhereClause: string, rawWhereClause: string,
bindings: any[] | object, bindings: any[] | object,
querySelectionModifiers: QuerySelectionModifiers<TFields> querySelectionModifiers: QuerySelectionModifiersWithOrderByRaw<TFields>
): Promise<readonly Readonly<TFields>[]> { ): Promise<readonly Readonly<TFields>[]> {
const results = await this.fetchManyByRawWhereClauseInternalAsync( const results = await this.fetchManyByRawWhereClauseInternalAsync(
queryContext.getQueryInterface(), queryContext.getQueryInterface(),
this.entityConfiguration.tableName, this.entityConfiguration.tableName,
rawWhereClause, rawWhereClause,
bindings, bindings,
this.convertToTableQueryModifiers(querySelectionModifiers) this.convertToTableQueryModifiersWithOrderByRaw(querySelectionModifiers)
); );
return results.map((result) => return results.map((result) =>
@@ -227,7 +255,7 @@ export default abstract class EntityDatabaseAdapter<TFields> {
tableName: string, tableName: string,
rawWhereClause: string, rawWhereClause: string,
bindings: any[] | object, bindings: any[] | object,
querySelectionModifiers: TableQuerySelectionModifiers querySelectionModifiers: TableQuerySelectionModifiersWithOrderByRaw
): Promise<object[]>; ): Promise<object[]>;
/** /**
@@ -363,6 +391,15 @@ export default abstract class EntityDatabaseAdapter<TFields> {
id: any id: any
): Promise<number>; ): Promise<number>;
private convertToTableQueryModifiersWithOrderByRaw(
querySelectionModifiers: QuerySelectionModifiersWithOrderByRaw<TFields>
): TableQuerySelectionModifiersWithOrderByRaw {
return {
...this.convertToTableQueryModifiers(querySelectionModifiers),
orderByRaw: querySelectionModifiers.orderByRaw,
};
}
private convertToTableQueryModifiers( private convertToTableQueryModifiers(
querySelectionModifiers: QuerySelectionModifiers<TFields> querySelectionModifiers: QuerySelectionModifiers<TFields>
): TableQuerySelectionModifiers { ): TableQuerySelectionModifiers {
+25 -8
View File
@@ -5,13 +5,21 @@ import ViewerContext from './ViewerContext';
export enum EntityEdgeDeletionBehavior { export enum EntityEdgeDeletionBehavior {
/** /**
* Default. Invalidate the cache for all entities that reference the entity * Invalidate the cache for all entities that reference the entity
* being deleted through this field, and transitively run deletions on those entities. * being deleted through this field, and transitively run deletions on those entities.
* This is most useful when the database itself expresses foreign * This is most useful when the database itself expresses foreign
* keys and cascading deletes or set nulls and the entity framework just needs to * keys and cascading deletes and the entity framework just needs to
* be kept consistent with the state of the database. * be kept consistent with the state of the database.
*/ */
CASCADE_DELETE_INVALIDATE_CACHE, CASCADE_DELETE_INVALIDATE_CACHE_ONLY,
/**
* Invalidate the cache for all entities that reference the entity
* being deleted through this field. This is most useful when the database itself expresses
* foreign keys and cascading "SET NULL"s and the entity framework just needs to be
* kept consistent with the state of the database.
*/
SET_NULL_INVALIDATE_CACHE_ONLY,
/** /**
* Delete all entities that reference the entity being deleted through this field. This is very similar * Delete all entities that reference the entity being deleted through this field. This is very similar
@@ -28,9 +36,12 @@ export enum EntityEdgeDeletionBehavior {
SET_NULL, SET_NULL,
} }
/**
* Defines an association between entities. An association is primarily used to define cascading deletion behavior.
*/
export interface EntityAssociationDefinition< export interface EntityAssociationDefinition<
TViewerContext extends ViewerContext, TViewerContext extends ViewerContext,
TAssociatedFields, TAssociatedFields extends object,
TAssociatedID extends NonNullable<TAssociatedFields[TAssociatedSelectedFields]>, TAssociatedID extends NonNullable<TAssociatedFields[TAssociatedSelectedFields]>,
TAssociatedEntity extends ReadonlyEntity< TAssociatedEntity extends ReadonlyEntity<
TAssociatedFields, TAssociatedFields,
@@ -50,7 +61,7 @@ export interface EntityAssociationDefinition<
/** /**
* Class of entity on the other end of this edge. * Class of entity on the other end of this edge.
*/ */
getAssociatedEntityClass: () => IEntityClass< associatedEntityClass: IEntityClass<
TAssociatedFields, TAssociatedFields,
TAssociatedID, TAssociatedID,
TViewerContext, TViewerContext,
@@ -71,18 +82,23 @@ export interface EntityAssociationDefinition<
* *
* @remarks * @remarks
* The entity framework doesn't prescribe a one-size-fits-all solution for referential * 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 * integrity; instead it exposes mechanisms that support both database foreign key constraints
* and implicit entity-specified foreign keys. Choosing which approach to use often depends on * 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. * 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 * - If referential integrity is critical to your application, database foreign key constraints
* combined with {@link EntityEdgeDeletionBehavior.CASACDE_DELETE_INVALIDATE_CACHE} are recommended. * combined with EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE_ONLY or
* EntityEdgeDeletionBehavior.SET_NULL_INVALIDATE_CACHE_ONLY are recommended.
* - If the database being used doesn't support foreign keys, then using the entity framework for referential * - If the database being used doesn't support foreign keys, then using the entity framework for referential
* integrity is recommended. * integrity is recommended.
*/ */
edgeDeletionBehavior?: EntityEdgeDeletionBehavior; edgeDeletionBehavior: EntityEdgeDeletionBehavior;
} }
/**
* Definition for a field referencing a column in the underlying database. Specifies things like
* cache behavior and associations, and handles input validation.
*/
export abstract class EntityFieldDefinition<T> { export abstract class EntityFieldDefinition<T> {
readonly columnName: string; readonly columnName: string;
readonly cache: boolean; readonly cache: boolean;
@@ -93,6 +109,7 @@ export abstract class EntityFieldDefinition<T> {
* @param cache - Whether or not to cache loaded instances of the entity by this field. The column name is * @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 * used to derive a cache key for the cache entry. If true, this column must be able uniquely
* identify the entity. * identify the entity.
* @param association - Defines the association behavior for an entity that this column references.
*/ */
constructor({ constructor({
columnName, columnName,
+36 -10
View File
@@ -2,61 +2,87 @@ import { validate as validateUUID } from 'uuid';
import { EntityFieldDefinition } from './EntityFieldDefinition'; import { EntityFieldDefinition } from './EntityFieldDefinition';
/**
* EntityFieldDefinition for a column with a JS string type.
*/
export class StringField extends EntityFieldDefinition<string> { export class StringField extends EntityFieldDefinition<string> {
protected validateInputValueInternal(value: string): boolean { protected validateInputValueInternal(value: string): boolean {
return typeof value === 'string'; return typeof value === 'string';
} }
} }
/**
* EntityFieldDefinition for a column with a JS string type.
* Enforces that the string is a valid UUID.
*/
export class UUIDField extends StringField { export class UUIDField extends StringField {
protected override validateInputValueInternal(value: string): boolean { protected override validateInputValueInternal(value: string): boolean {
return validateUUID(value); return validateUUID(value);
} }
} }
/**
* EntityFieldDefinition for a column with a JS Date type.
*/
export class DateField extends EntityFieldDefinition<Date> { export class DateField extends EntityFieldDefinition<Date> {
protected validateInputValueInternal(value: Date): boolean { protected validateInputValueInternal(value: Date): boolean {
return value instanceof Date; return value instanceof Date;
} }
} }
/**
* EntityFieldDefinition for a column with a JS boolean type.
*/
export class BooleanField extends EntityFieldDefinition<boolean> { export class BooleanField extends EntityFieldDefinition<boolean> {
protected validateInputValueInternal(value: boolean): boolean { protected validateInputValueInternal(value: boolean): boolean {
return typeof value === 'boolean'; return typeof value === 'boolean';
} }
} }
/**
* EntityFieldDefinition for a column with a JS number type.
* Enforces that the number is an integer.
*/
export class IntField extends EntityFieldDefinition<number> { export class IntField extends EntityFieldDefinition<number> {
protected validateInputValueInternal(value: number): boolean { protected validateInputValueInternal(value: number): boolean {
return typeof value === 'number' && Number.isInteger(value); return typeof value === 'number' && Number.isInteger(value);
} }
} }
/**
* EntityFieldDefinition for a column with a JS number type.
* Enforces that the number is a float (which includes integers in JS).
*/
export class FloatField extends EntityFieldDefinition<number> { export class FloatField extends EntityFieldDefinition<number> {
protected validateInputValueInternal(value: number): boolean { protected validateInputValueInternal(value: number): boolean {
return typeof value === 'number'; return typeof value === 'number';
} }
} }
/**
* EntityFieldDefinition for a column with a JS string array type.
* Enforces that every member of the string array is a string.
*/
export class StringArrayField extends EntityFieldDefinition<string[]> { export class StringArrayField extends EntityFieldDefinition<string[]> {
protected validateInputValueInternal(value: string[]): boolean { protected validateInputValueInternal(value: string[]): boolean {
return Array.isArray(value) && value.every((subValue) => typeof subValue === 'string'); return Array.isArray(value) && value.every((subValue) => typeof subValue === 'string');
} }
} }
/**
* EntityFieldDefinition for a column with a JS JSON object type.
*/
export class JSONObjectField extends EntityFieldDefinition<object> { export class JSONObjectField extends EntityFieldDefinition<object> {
protected validateInputValueInternal(value: object): boolean { protected validateInputValueInternal(value: object): boolean {
return typeof value === 'object' && !Array.isArray(value); return typeof value === 'object' && !Array.isArray(value);
} }
} }
/**
* EntityFieldDefinition for a enum column with a JS string or number type.
*/
export class EnumField extends EntityFieldDefinition<string | number> { export class EnumField extends EntityFieldDefinition<string | number> {
protected validateInputValueInternal(value: string | number): boolean { protected validateInputValueInternal(value: string | number): boolean {
return typeof value === 'number' || typeof value === 'string'; 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;
}
}
+81 -14
View File
@@ -1,5 +1,6 @@
import { Result, asyncResult, result } from '@expo/results'; import { Result, asyncResult, result } from '@expo/results';
import invariant from 'invariant'; import invariant from 'invariant';
import nullthrows from 'nullthrows';
import EnforcingEntityLoader from './EnforcingEntityLoader'; import EnforcingEntityLoader from './EnforcingEntityLoader';
import { IEntityClass } from './Entity'; import { IEntityClass } from './Entity';
@@ -8,11 +9,13 @@ import {
FieldEqualityCondition, FieldEqualityCondition,
QuerySelectionModifiers, QuerySelectionModifiers,
isSingleValueFieldEqualityCondition, isSingleValueFieldEqualityCondition,
QuerySelectionModifiersWithOrderByRaw,
} from './EntityDatabaseAdapter'; } from './EntityDatabaseAdapter';
import EntityPrivacyPolicy from './EntityPrivacyPolicy'; import EntityPrivacyPolicy, { EntityPrivacyPolicyEvaluationContext } 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 { pick } from './entityUtils';
import EntityInvalidFieldValueError from './errors/EntityInvalidFieldValueError'; import EntityInvalidFieldValueError from './errors/EntityInvalidFieldValueError';
import EntityNotFoundError from './errors/EntityNotFoundError'; import EntityNotFoundError from './errors/EntityNotFoundError';
import EntityDataManager from './internal/EntityDataManager'; import EntityDataManager from './internal/EntityDataManager';
@@ -21,10 +24,10 @@ import { mapMap, mapMapAsync } from './utils/collections/maps';
/** /**
* The primary interface for loading entities. All normal loads are batched, * The primary interface for loading entities. All normal loads are batched,
* cached, and authorized against the entity's {@link EntityPrivacyPolicy}. * cached, and authorized against the entity's EntityPrivacyPolicy.
*/ */
export default class EntityLoader< export default class EntityLoader<
TFields, TFields extends object,
TID extends NonNullable<TFields[TSelectedFields]>, 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>,
@@ -40,6 +43,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 privacyPolicyEvaluationContext: EntityPrivacyPolicyEvaluationContext,
private readonly entityConfiguration: EntityConfiguration<TFields>, private readonly entityConfiguration: EntityConfiguration<TFields>,
private readonly entityClass: IEntityClass< private readonly entityClass: IEntityClass<
TFields, TFields,
@@ -49,6 +53,7 @@ export default class EntityLoader<
TPrivacyPolicy, TPrivacyPolicy,
TSelectedFields TSelectedFields
>, >,
private readonly entitySelectedFields: TSelectedFields[] | undefined,
private readonly privacyPolicy: TPrivacyPolicy, private readonly privacyPolicy: TPrivacyPolicy,
private readonly dataManager: EntityDataManager<TFields>, private readonly dataManager: EntityDataManager<TFields>,
protected readonly metricsAdapter: IEntityMetricsAdapter protected readonly metricsAdapter: IEntityMetricsAdapter
@@ -125,7 +130,9 @@ export default class EntityLoader<
const entityResults = await this.loadManyByFieldEqualingAsync(uniqueFieldName, fieldValue); const entityResults = await this.loadManyByFieldEqualingAsync(uniqueFieldName, fieldValue);
invariant( invariant(
entityResults.length <= 1, entityResults.length <= 1,
`loadByFieldEqualing: Multiple entities of type ${this.entityClass.name} found for ${uniqueFieldName}=${fieldValue}` `loadByFieldEqualing: Multiple entities of type ${this.entityClass.name} found for ${String(
uniqueFieldName
)}=${fieldValue}`
); );
return entityResults[0] ?? null; return entityResults[0] ?? null;
} }
@@ -179,16 +186,59 @@ export default class EntityLoader<
} }
/** /**
* Loads many entities matching the conjunction of WHERE clauses constructed from specified operands. * Loads many entities for a list of IDs, returning null for any IDs that are non-existent.
* @param ids - IDs of the entities to load
* @returns map from ID to nullable corresponding entity result, where result error can be UnauthorizedError or EntityNotFoundError.
*/
async loadManyByIDsNullableAsync(
ids: readonly TID[]
): Promise<ReadonlyMap<TID, Result<TEntity> | null>> {
const entityResults = (await this.loadManyByFieldEqualingManyAsync(
this.entityConfiguration.idField as TSelectedFields,
ids
)) as ReadonlyMap<TID, readonly Result<TEntity>[]>;
return mapMap(entityResults, (entityResultsForId) => {
return entityResultsForId[0] ?? null;
});
}
/**
* Loads the first entity matching the selection constructed from the conjunction of specified
* operands, or null if no matching entity exists. Entities loaded using this method are not
* batched or cached.
*
* This is a convenience method for {@link loadManyByFieldEqualityConjunctionAsync}. However, the
* `orderBy` option must be specified to define what "first" means. If ordering doesn't matter,
* explicitly pass in an empty array.
*
* @param fieldEqualityOperands - list of field equality selection operand specifications
* @param querySelectionModifiers - orderBy and optional offset for the query
* @returns the first entity results that matches the query, where result error can be
* UnauthorizedError
*/
async loadFirstByFieldEqualityConjunctionAsync<N extends keyof Pick<TFields, TSelectedFields>>(
fieldEqualityOperands: FieldEqualityCondition<TFields, N>[],
querySelectionModifiers: Omit<QuerySelectionModifiers<TFields>, 'limit'> &
Required<Pick<QuerySelectionModifiers<TFields>, 'orderBy'>>
): Promise<Result<TEntity> | null> {
const results = await this.loadManyByFieldEqualityConjunctionAsync(fieldEqualityOperands, {
...querySelectionModifiers,
limit: 1,
});
return results[0] ?? null;
}
/**
* Loads many entities matching the selection constructed from the conjunction of specified operands.
* Entities loaded using this method are not batched or cached. * Entities loaded using this method are not batched or cached.
* *
* @example * @example
* fieldEqualityOperands: * fieldEqualityOperands:
* `[{fieldName: 'hello', fieldValue: 1}, {fieldName: 'world', fieldValues: [2, 3]}]` * `[{fieldName: 'hello', fieldValue: 1}, {fieldName: 'world', fieldValues: [2, 3]}]`
* Entities returned: * Entities returned with a SQL EntityDatabaseAdapter:
* `WHERE hello = 1 AND world = ANY({2, 3})` * `WHERE hello = 1 AND world = ANY({2, 3})`
* *
* @param fieldEqualityOperands - list of field equality WHERE clause operand specifications * @param fieldEqualityOperands - list of field equality selection operand specifications
* @param querySelectionModifiers - limit, offset, and orderBy for the query * @param querySelectionModifiers - limit, offset, and orderBy for the query
* @returns array of entity results that match the query, where result error can be UnauthorizedError * @returns array of entity results that match the query, where result error can be UnauthorizedError
*/ */
@@ -218,6 +268,7 @@ export default class EntityLoader<
this.privacyPolicy.authorizeReadAsync( this.privacyPolicy.authorizeReadAsync(
this.viewerContext, this.viewerContext,
this.queryContext, this.queryContext,
this.privacyPolicyEvaluationContext,
uncheckedEntityResult.value, uncheckedEntityResult.value,
this.metricsAdapter this.metricsAdapter
) )
@@ -240,12 +291,12 @@ export default class EntityLoader<
* bindings: `[1]` * bindings: `[1]`
* Entites returned `WHERE id = 1` * Entites returned `WHERE id = 1`
* *
* {@link http://knexjs.org/#Builder-whereRaw} * http://knexjs.org/#Builder-whereRaw
* {@link http://knexjs.org/#Raw-Bindings} * http://knexjs.org/#Raw-Bindings
* *
* @param rawWhereClause - parameterized SQL WHERE clause with positional binding placeholders or named binding placeholders * @param rawWhereClause - parameterized SQL WHERE clause with positional binding placeholders or named binding placeholders
* @param bindings - array of positional bindings or object of named bindings * @param bindings - array of positional bindings or object of named bindings
* @param querySelectionModifiers - limit, offset, and orderBy for the query * @param querySelectionModifiers - limit, offset, orderBy, and orderByRaw for the query
* @returns array of entity results that match the query, where result error can be UnauthorizedError * @returns array of entity results that match the query, where result error can be UnauthorizedError
* @throws Error when rawWhereClause or bindings are invalid * @throws Error when rawWhereClause or bindings are invalid
* *
@@ -254,7 +305,7 @@ export default class EntityLoader<
async loadManyByRawWhereClauseAsync( async loadManyByRawWhereClauseAsync(
rawWhereClause: string, rawWhereClause: string,
bindings: any[] | object, bindings: any[] | object,
querySelectionModifiers: QuerySelectionModifiers<TFields> = {} querySelectionModifiers: QuerySelectionModifiersWithOrderByRaw<TFields> = {}
): Promise<readonly Result<TEntity>[]> { ): Promise<readonly Result<TEntity>[]> {
const fieldObjects = await this.dataManager.loadManyByRawWhereClauseAsync( const fieldObjects = await this.dataManager.loadManyByRawWhereClauseAsync(
this.queryContext, this.queryContext,
@@ -272,6 +323,7 @@ export default class EntityLoader<
this.privacyPolicy.authorizeReadAsync( this.privacyPolicy.authorizeReadAsync(
this.viewerContext, this.viewerContext,
this.queryContext, this.queryContext,
this.privacyPolicyEvaluationContext,
uncheckedEntityResult.value, uncheckedEntityResult.value,
this.metricsAdapter this.metricsAdapter
) )
@@ -281,7 +333,7 @@ export default class EntityLoader<
} }
/** /**
* Invalidate all caches for an entity's fields. Exposed primarily for internal use by {@link EntityMutator}. * Invalidate all caches for an entity's fields. Exposed primarily for internal use by EntityMutator.
* @param objectFields - entity data object to be invalidated * @param objectFields - entity data object to be invalidated
*/ */
async invalidateFieldsAsync(objectFields: Readonly<TFields>): Promise<void> { async invalidateFieldsAsync(objectFields: Readonly<TFields>): Promise<void> {
@@ -300,7 +352,7 @@ export default class EntityLoader<
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(this.constructEntity(fieldsObject));
} catch (e) { } catch (e) {
if (!(e instanceof Error)) { if (!(e instanceof Error)) {
throw e; throw e;
@@ -310,6 +362,20 @@ export default class EntityLoader<
}); });
} }
public constructEntity(fieldsObject: TFields): TEntity {
const idField = this.entityConfiguration.idField;
const id = nullthrows(fieldsObject[idField], 'must provide ID to create an entity');
const entitySelectedFields =
this.entitySelectedFields ?? Array.from(this.entityConfiguration.schema.keys());
const selectedFields = pick(fieldsObject, entitySelectedFields);
return new this.entityClass({
viewerContext: this.viewerContext,
id: id as TID,
databaseFields: fieldsObject,
selectedFields,
});
}
/** /**
* Construct and authorize entities from fields map, returning error results for entities that fail * Construct and authorize entities from fields map, returning error results for entities that fail
* to construct or fail to authorize. * to construct or fail to authorize.
@@ -332,6 +398,7 @@ export default class EntityLoader<
this.privacyPolicy.authorizeReadAsync( this.privacyPolicy.authorizeReadAsync(
this.viewerContext, this.viewerContext,
this.queryContext, this.queryContext,
this.privacyPolicyEvaluationContext,
uncheckedEntityResult.value, uncheckedEntityResult.value,
this.metricsAdapter this.metricsAdapter
) )
@@ -346,7 +413,7 @@ export default class EntityLoader<
fieldValues: readonly TFields[N][] fieldValues: readonly TFields[N][]
): void { ): void {
const fieldDefinition = this.entityConfiguration.schema.get(fieldName); const fieldDefinition = this.entityConfiguration.schema.get(fieldName);
invariant(fieldDefinition, `must have field definition for field = ${fieldName}`); invariant(fieldDefinition, `must have field definition for field = ${String(fieldName)}`);
for (const fieldValue of fieldValues) { for (const fieldValue of fieldValues) {
const isInputValid = fieldDefinition.validateInputValue(fieldValue); const isInputValid = fieldDefinition.validateInputValue(fieldValue);
if (!isInputValid) { if (!isInputValid) {
+11 -11
View File
@@ -1,7 +1,6 @@
import { IEntityClass } from './Entity'; import EntityCompanion from './EntityCompanion';
import EntityConfiguration from './EntityConfiguration';
import EntityLoader from './EntityLoader'; import EntityLoader from './EntityLoader';
import EntityPrivacyPolicy from './EntityPrivacyPolicy'; import EntityPrivacyPolicy, { EntityPrivacyPolicyEvaluationContext } 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';
@@ -12,7 +11,7 @@ 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 extends object,
TID extends NonNullable<TFields[TSelectedFields]>, 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>,
@@ -26,8 +25,7 @@ export default class EntityLoaderFactory<
TSelectedFields extends keyof TFields TSelectedFields extends keyof TFields
> { > {
constructor( constructor(
private readonly entityConfiguration: EntityConfiguration<TFields>, private readonly entityCompanion: EntityCompanion<
private readonly entityClass: IEntityClass<
TFields, TFields,
TID, TID,
TViewerContext, TViewerContext,
@@ -35,7 +33,6 @@ export default class EntityLoaderFactory<
TPrivacyPolicy, TPrivacyPolicy,
TSelectedFields TSelectedFields
>, >,
private readonly privacyPolicyClass: TPrivacyPolicy,
private readonly dataManager: EntityDataManager<TFields>, private readonly dataManager: EntityDataManager<TFields>,
protected readonly metricsAdapter: IEntityMetricsAdapter protected readonly metricsAdapter: IEntityMetricsAdapter
) {} ) {}
@@ -47,14 +44,17 @@ export default class EntityLoaderFactory<
*/ */
forLoad( forLoad(
viewerContext: TViewerContext, viewerContext: TViewerContext,
queryContext: EntityQueryContext queryContext: EntityQueryContext,
privacyPolicyEvaluationContext: EntityPrivacyPolicyEvaluationContext
): EntityLoader<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy, TSelectedFields> { ): EntityLoader<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy, TSelectedFields> {
return new EntityLoader( return new EntityLoader(
viewerContext, viewerContext,
queryContext, queryContext,
this.entityConfiguration, privacyPolicyEvaluationContext,
this.entityClass, this.entityCompanion.entityCompanionDefinition.entityConfiguration,
this.privacyPolicyClass, this.entityCompanion.entityCompanionDefinition.entityClass,
this.entityCompanion.entityCompanionDefinition.entitySelectedFields,
this.entityCompanion.privacyPolicy,
this.dataManager, this.dataManager,
this.metricsAdapter this.metricsAdapter
); );
+17 -5
View File
@@ -8,7 +8,7 @@ export enum EntityMutationType {
} }
export type EntityValidatorMutationInfo< export type EntityValidatorMutationInfo<
TFields, TFields extends object,
TID extends NonNullable<TFields[TSelectedFields]>, 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>,
@@ -20,15 +20,26 @@ export type EntityValidatorMutationInfo<
| { | {
type: EntityMutationType.UPDATE; type: EntityMutationType.UPDATE;
previousValue: TEntity; previousValue: TEntity;
cascadingDeleteCause: EntityCascadingDeletionInfo | null;
}; };
export type EntityMutationTriggerDeleteCascadeInfo<> = { /**
* Information about a cascading deletion.
*/
export type EntityCascadingDeletionInfo = {
/**
* The entity that is being mutated at this step in the cascaded deletion.
*/
entity: Entity<any, any, any, any>; entity: Entity<any, any, any, any>;
cascadingDeleteCause: EntityMutationTriggerDeleteCascadeInfo | null;
/**
* The cascade deletion that caused this mutation.
*/
cascadingDeleteCause: EntityCascadingDeletionInfo | null;
}; };
export type EntityTriggerMutationInfo< export type EntityTriggerMutationInfo<
TFields, TFields extends object,
TID extends NonNullable<TFields[TSelectedFields]>, 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>,
@@ -40,8 +51,9 @@ export type EntityTriggerMutationInfo<
| { | {
type: EntityMutationType.UPDATE; type: EntityMutationType.UPDATE;
previousValue: TEntity; previousValue: TEntity;
cascadingDeleteCause: EntityCascadingDeletionInfo | null;
} }
| { | {
type: EntityMutationType.DELETE; type: EntityMutationType.DELETE;
cascadingDeleteCause: EntityMutationTriggerDeleteCascadeInfo | null; cascadingDeleteCause: EntityCascadingDeletionInfo | null;
}; };
@@ -7,7 +7,7 @@ import ViewerContext from './ViewerContext';
* Interface to define trigger behavior for entities. * Interface to define trigger behavior for entities.
*/ */
export default interface EntityMutationTriggerConfiguration< export default interface EntityMutationTriggerConfiguration<
TFields, TFields extends object,
TID extends NonNullable<TFields[TSelectedFields]>, 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>,
@@ -45,7 +45,7 @@ export default interface EntityMutationTriggerConfiguration<
*/ */
beforeAll?: EntityMutationTrigger<TFields, TID, TViewerContext, TEntity, TSelectedFields>[]; beforeAll?: EntityMutationTrigger<TFields, TID, TViewerContext, TEntity, TSelectedFields>[];
/** /**
* Trigger set that runs within the transaction but before the entity is created in, updated in, or deleted from * Trigger set that runs within the transaction but after the entity is created in, updated in, or deleted from
* the database and the cache is invalidated. * the database and the cache is invalidated.
*/ */
afterAll?: EntityMutationTrigger<TFields, TID, TViewerContext, TEntity, TSelectedFields>[]; afterAll?: EntityMutationTrigger<TFields, TID, TViewerContext, TEntity, TSelectedFields>[];
@@ -70,7 +70,7 @@ export default interface EntityMutationTriggerConfiguration<
* the transaction if a transaction is supplied. * the transaction if a transaction is supplied.
*/ */
export abstract class EntityMutationTrigger< export abstract class EntityMutationTrigger<
TFields, TFields extends object,
TID extends NonNullable<TFields[TSelectedFields]>, 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>,
@@ -85,11 +85,11 @@ export abstract class EntityMutationTrigger<
} }
/** /**
* A non-transactional trigger is like a {@link EntityMutationTrigger} but used for afterCommit triggers * A non-transactional trigger is like a EntityMutationTrigger but used for afterCommit triggers
* since they explicitly occur outside of the transaction. * since they explicitly occur outside of the transaction.
*/ */
export abstract class EntityNonTransactionalMutationTrigger< export abstract class EntityNonTransactionalMutationTrigger<
TFields, TFields extends object,
TID extends NonNullable<TFields[TSelectedFields]>, 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>,
Loaded 100 of 176 files, more files were not shown because too many files have changed in this diff. Show more