Compare commits

...
Author SHA1 Message Date
Will Schurman 0fa6f554a2 v0.34.0 2024-04-11 09:13:48 -07:00
Will Schurman 8b0b31fdde feat: require explicit query context specification (#219) 2024-04-11 09:09:18 -07:00
James Ide cc6f3dc1e6 feat: Allow UUIDv6/7/8 in UUIDField validation (#221)
Why
---
Currently the UUIDField type supports only UUIDv1-5 and to use a newer UUID version we need to use StringField.

How
---
Manually implemented UUID validation, adding support for versions 6, 7, and 8. Also added support for the "max" UUID, which is now part of the spec.

Test Plan
---
Added a UUIDv7 value (from https://uuid7.com/) to test cases.
2024-04-08 18:17:24 -07:00
Will Schurman 9c43a56a39 BREAKING CHANGE: hoist the responsibility of sharing local memory cache adapter into application (#218) 2023-12-06 12:38:30 -08:00
Will Schurman ef7b452c9f docs: fix EntityMutationTrigger docblocks (#216) 2023-12-04 16:18:29 -08:00
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
186 changed files with 13020 additions and 8872 deletions

No files matched your search

+3 -3
View File
@@ -3,16 +3,16 @@ name: docs
on:
push:
branches:
- master
- main
jobs:
deploy:
runs-on: ubuntu-18.04
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
with:
node-version: 12.x
node-version: 18.x
- name: Cache Node.js modules
uses: actions/cache@v1
with:
+2 -2
View File
@@ -2,7 +2,7 @@ name: tests
on:
push:
branches: [ master ]
branches: [ main ]
pull_request:
jobs:
@@ -14,7 +14,7 @@ jobs:
ref: ${{ github.event.pull_request.head.sha }}
- uses: actions/setup-node@v1
with:
node-version: 12.x
node-version: 18.x
- name: Cache Node.js modules
uses: actions/cache@v1
with:
+171 -1
View File
@@ -3,6 +3,176 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.34.0](https://github.com/expo/entity/compare/v0.33.0...v0.34.0) (2024-04-11)
### Features
* Allow UUIDv6/7/8 in UUIDField validation ([#221](https://github.com/expo/entity/issues/221)) ([cc6f3dc](https://github.com/expo/entity/commit/cc6f3dc1e677382eec220723c37b5b883d980372))
* require explicit query context specification ([#219](https://github.com/expo/entity/issues/219)) ([8b0b31f](https://github.com/expo/entity/commit/8b0b31fdde5bd565aa527719003ef283a45f55cc))
# [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)
@@ -12,7 +182,7 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline
### Chores
* Refactor cachers to use an abstract class and interface ([#157](https://github.com/expo/entity/pull/157))
* Refactor cachers to use an abstract class and interface ([#157](https://github.com/expo/entity/pull/157))
+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)
[![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/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
To release a new version, let's say `v1.0.0` for example:
1. `git checkout -b bump-v1.0.0`
1. `yarn lerna version minor -- --no-push --conventional-commits`
1. `git push -u origin bump-v1.0.0`
1. Create a PR from the `bump-v1.0.0` branch, wait for tests, and commit the PR using GitHub interface.
1. `git checkout master && git pull`
1. `git tag -d v1.0.0`
1. `git tag v1.0.0`
1. `git push origin v1.0.0`
To release a new version:
1. `git checkout main`
1. `yarn lerna publish [patch|minor|major] -- --conventional-commits`
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
+1 -1
View File
@@ -4,5 +4,5 @@
],
"npmClient": "yarn",
"useWorkspaces": true,
"version": "0.24.0"
"version": "0.34.0"
}
+19 -18
View File
@@ -16,30 +16,30 @@
"barrelsby": "lerna run barrelsby"
},
"devDependencies": {
"@expo/batcher": "^1.0.0",
"@expo/entity": "file:packages/entity",
"@expo/results": "^1.0.0",
"@types/lru-cache": "^5.1.1",
"@types/invariant": "^2.2.33",
"@types/ioredis": "^4.26.4",
"@types/jest": "^26.0.10",
"@types/jest": "^29.5.2",
"@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",
"@typescript-eslint/eslint-plugin": "^4.14.0",
"@typescript-eslint/parser": "^4.14.0",
"barrelsby": "^2.2.0",
"eslint": "^7.7.0",
"eslint-config-universe": "^7.0.0",
"eslint-plugin-tsdoc": "^0.2.11",
"jest": "^26.6.3",
"lerna": "^4.0.0",
"eslint": "^8.14.0",
"eslint-config-universe": "^11.2.0",
"eslint-plugin-tsdoc": "^0.2.17",
"ioredis": "^5.2.5",
"jest": "^29.1.2",
"lerna": "^5.4.0",
"lru-cache": "^6.0.0",
"nullthrows": "^1.1.1",
"pg": "^8.6.0",
"prettier": "^2.3.2",
"ts-jest": "~26.4.4",
"pg": "8.10.0",
"prettier": "^2.8.8",
"ts-jest": "^29.1.0",
"ts-mockito": "^2.6.1",
"typedoc": "^0.21.9",
"typescript": "^4.4.2",
"typedoc": "^0.24.8",
"typescript": "^5.1.3",
"uuid": "^8.3.0"
},
"dependencies": {
@@ -47,7 +47,8 @@
"@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-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"
}
}
@@ -3,6 +3,133 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.34.0](https://github.com/expo/entity/compare/v0.33.0...v0.34.0) (2024-04-11)
### Features
* require explicit query context specification ([#219](https://github.com/expo/entity/issues/219)) ([8b0b31f](https://github.com/expo/entity/commit/8b0b31fdde5bd565aa527719003ef283a45f55cc))
# [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)
@@ -3,21 +3,26 @@
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-box invalidation is not possible. Do 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 have high mutability
- The objects stored are mutable
- Reading a stale object from the cache is not acceptable in your application
- Cross-box invalidation is not possible
- 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
- 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
@@ -1,6 +1,6 @@
{
"name": "@expo/entity-cache-adapter-local-memory",
"version": "0.24.0",
"version": "0.34.0",
"description": "Cross-request local memory cache adapter for @expo/entity",
"files": [
"build",
@@ -19,7 +19,7 @@
"barrelsby": "barrelsby --directory src --location top --exclude tests__ --singleQuotes --exportDefault --delete"
},
"engines": {
"node": ">=12"
"node": ">=16"
},
"keywords": [
"entity"
@@ -30,9 +30,9 @@
"@expo/entity": "*"
},
"dependencies": {
"lru-cache": "^7.3.1"
"lru-cache": "^6.0.0"
},
"devDependencies": {
"@expo/entity": "^0.24.0"
"@expo/entity": "^0.34.0"
}
}
@@ -1,4 +1,10 @@
import { CacheLoadResult, CacheStatus, IEntityGenericCacher } from '@expo/entity';
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.
@@ -7,78 +13,11 @@ export const DOES_NOT_EXIST_LOCAL_MEMORY_CACHE = Symbol('doesNotExist');
type LocalMemoryCacheValue<TFields> = Readonly<TFields> | typeof DOES_NOT_EXIST_LOCAL_MEMORY_CACHE;
export type LocalMemoryCache<TFields> = LRUCache<string, LocalMemoryCacheValue<TFields>>;
type LRUCacheOptionsV7<K, V> = {
/**
* the number of most recently used items to keep.
* note that we may store fewer items than this if maxSize is hit.
*/
max: number;
/**
* if you wish to track item size, you must provide a maxSize
* note that we still will only keep up to max *actual items*,
* so size tracking may cause fewer than max items to be stored.
* At the extreme, a single item of maxSize size will cause everything
* else in the cache to be dropped when it is added. Use with caution!
* Note also that size tracking can negatively impact performance,
* though for most cases, only minimally.
*/
maxSize?: number;
/**
* function to calculate size of items. useful if storing strings or
* buffers or other items where memory size depends on the object itself.
* also note that oversized items do NOT immediately get dropped from
* the cache, though they will cause faster turnover in the storage.
*/
sizeCalculation?: (value: V, key: K) => number;
/**
* function to call when the item is removed from the cache
* Note that using this can negatively impact performance.
*/
dispose?: (value: V, key: K) => void;
/**
* max time to live for items before they are considered stale
* note that stale items are NOT preemptively removed by default,
* and MAY live in the cache, contributing to its LRU max, long after
* they have expired.
* Also, as this cache is optimized for LRU/MRU operations, some of
* the staleness/TTL checks will reduce performance, as they will incur
* overhead by deleting items.
* Must be a positive integer in ms, defaults to 0, which means "no TTL"
*/
ttl?: number;
/**
* return stale items from cache.get() before disposing of them
* boolean, default false
*/
allowStale?: boolean;
/**
* update the age of items on cache.get(), renewing their TTL
* boolean, default false
*/
updateAgeOnGet?: boolean;
/**
* update the age of items on cache.has(), renewing their TTL
* boolean, default false
*/
updateAgeOnHas?: boolean;
/**
* update the "recently-used"-ness of items on cache.has()
* boolean, default false
*/
updateRecencyOnHas?: boolean;
};
export default class GenericLocalMemoryCacher<TFields> implements IEntityGenericCacher<TFields> {
constructor(private readonly localMemoryCache: LocalMemoryCache<TFields>) {}
constructor(
private readonly entityConfiguration: EntityConfiguration<TFields>,
private readonly localMemoryCache: LocalMemoryCache<TFields>
) {}
static createLRUCache<TFields>(
options: { maxSize?: number; ttlSeconds?: number } = {}
@@ -86,13 +25,11 @@ export default class GenericLocalMemoryCacher<TFields> implements IEntityGeneric
const DEFAULT_LRU_CACHE_MAX_AGE_SECONDS = 10;
const DEFAULT_LRU_CACHE_SIZE = 10000;
const maxAgeSeconds = options.ttlSeconds ?? DEFAULT_LRU_CACHE_MAX_AGE_SECONDS;
const lruCacheOptions: LRUCacheOptionsV7<string, TFields> = {
return new LRUCache<string, LocalMemoryCacheValue<TFields>>({
max: options.maxSize ?? DEFAULT_LRU_CACHE_SIZE,
sizeCalculation: (value: LocalMemoryCacheValue<TFields>) =>
value === DOES_NOT_EXIST_LOCAL_MEMORY_CACHE ? 0 : 1,
ttl: maxAgeSeconds * 1000, // convert to ms
};
return new LRUCache<string, LocalMemoryCacheValue<TFields>>(lruCacheOptions as any);
length: (value) => (value === DOES_NOT_EXIST_LOCAL_MEMORY_CACHE ? 0 : 1),
maxAge: maxAgeSeconds * 1000, // convert to ms
});
}
static createNoOpCache<TFields>(): LocalMemoryCache<TFields> {
@@ -144,7 +81,19 @@ export default class GenericLocalMemoryCacher<TFields> implements IEntityGeneric
}
}
public makeCacheKey(parts: string[]): string {
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}`)
@@ -1,80 +0,0 @@
import { EntityCacheAdapter, CacheLoadResult, EntityConfiguration, mapKeys } from '@expo/entity';
import invariant from 'invariant';
import GenericLocalMemoryCacher, { LocalMemoryCache } from './GenericLocalMemoryCacher';
export default class LocalMemoryCacheAdapter<TFields> extends EntityCacheAdapter<TFields> {
private readonly genericLocalMemoryCacher: GenericLocalMemoryCacher<TFields>;
constructor(
entityConfiguration: EntityConfiguration<TFields>,
localMemoryCache: LocalMemoryCache<TFields>
) {
super(entityConfiguration);
this.genericLocalMemoryCacher = new GenericLocalMemoryCacher(localMemoryCache);
}
public async loadManyAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult<TFields>>> {
const localMemoryCacheKeyToFieldValueMapping = new Map(
fieldValues.map((fieldValue) => [this.makeCacheKey(fieldName, fieldValue), fieldValue])
);
const cacheResults = await this.genericLocalMemoryCacher.loadManyAsync(
Array.from(localMemoryCacheKeyToFieldValueMapping.keys())
);
return mapKeys(cacheResults, (cacheKey) => {
const fieldValue = localMemoryCacheKeyToFieldValueMapping.get(cacheKey);
invariant(
fieldValue !== undefined,
'Unspecified cache key %s returned from generic local memory cacher',
cacheKey
);
return fieldValue;
});
}
public async cacheManyAsync<N extends keyof TFields>(
fieldName: N,
objectMap: ReadonlyMap<NonNullable<TFields[N]>, Readonly<TFields>>
): Promise<void> {
await this.genericLocalMemoryCacher.cacheManyAsync(
mapKeys(objectMap, (fieldValue) => this.makeCacheKey(fieldName, fieldValue))
);
}
public async cacheDBMissesAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<void> {
await this.genericLocalMemoryCacher.cacheDBMissesAsync(
fieldValues.map((fieldValue) => this.makeCacheKey(fieldName, fieldValue))
);
}
public async invalidateManyAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<void> {
await this.genericLocalMemoryCacher.invalidateManyAsync(
fieldValues.map((fieldValue) => this.makeCacheKey(fieldName, fieldValue))
);
}
private makeCacheKey<N extends keyof TFields>(
fieldName: N,
fieldValue: NonNullable<TFields[N]>
): string {
const columnName = this.entityConfiguration.entityToDBFieldsKeyMapping.get(fieldName);
invariant(columnName, `database field mapping missing for ${fieldName}`);
const parts = [
this.entityConfiguration.tableName,
`${this.entityConfiguration.cacheKeyVersion}`,
columnName,
String(fieldValue),
];
return this.genericLocalMemoryCacher.makeCacheKey(parts);
}
}
@@ -1,24 +1,31 @@
import {
computeIfAbsent,
EntityCacheAdapter,
IEntityCacheAdapter,
EntityConfiguration,
GenericEntityCacheAdapter,
IEntityCacheAdapterProvider,
} from '@expo/entity';
import GenericLocalMemoryCacher, { LocalMemoryCache } from './GenericLocalMemoryCacher';
import LocalMemoryCacheAdapter from './LocalMemoryCacheAdapter';
export class LocalMemoryCacheAdapterProvider implements IEntityCacheAdapterProvider {
// local memory cache adapters should be shared/reused across requests
static localMemoryCacheAdapterMap = new Map<string, LocalMemoryCacheAdapter<any>>();
static getNoOpProvider(): IEntityCacheAdapterProvider {
/**
* Vends local memory cache adapters. An instance of this class may be shared across requests to
* share the local memory cache.
*/
export default class LocalMemoryCacheAdapterProvider implements IEntityCacheAdapterProvider {
/**
* @returns a no-op local memory cache adapter provider, or one that doesn't cache locally.
*/
static createNoOpProvider(): IEntityCacheAdapterProvider {
return new LocalMemoryCacheAdapterProvider(<TFields>() =>
GenericLocalMemoryCacher.createNoOpCache<TFields>()
);
}
static getProvider(
/**
* @returns a local memory cache adapter provider configured with the supplied options.
*/
static createProviderWithOptions(
options: { maxSize?: number; ttlSeconds?: number } = {}
): IEntityCacheAdapterProvider {
return new LocalMemoryCacheAdapterProvider(<TFields>() =>
@@ -26,20 +33,20 @@ export class LocalMemoryCacheAdapterProvider implements IEntityCacheAdapterProvi
);
}
private localMemoryCacheAdapterMap = new Map<string, GenericEntityCacheAdapter<any>>();
private constructor(
private readonly localMemoryCacheCreator: <TFields>() => LocalMemoryCache<TFields>
) {}
public getCacheAdapter<TFields>(
entityConfiguration: EntityConfiguration<TFields>
): EntityCacheAdapter<TFields> {
return computeIfAbsent(
LocalMemoryCacheAdapterProvider.localMemoryCacheAdapterMap,
entityConfiguration.tableName,
() => {
const localMemoryCache = this.localMemoryCacheCreator<TFields>();
return new LocalMemoryCacheAdapter(entityConfiguration, localMemoryCache);
}
);
): IEntityCacheAdapter<TFields> {
return computeIfAbsent(this.localMemoryCacheAdapterMap, entityConfiguration.tableName, () => {
const localMemoryCache = this.localMemoryCacheCreator<TFields>();
return new GenericEntityCacheAdapter(
new GenericLocalMemoryCacher(entityConfiguration, localMemoryCache)
);
});
}
}
@@ -1,180 +0,0 @@
import { CacheStatus, ViewerContext } from '@expo/entity';
import { v4 as uuidv4 } from 'uuid';
import GenericLocalMemoryCacher from '../GenericLocalMemoryCacher';
import LocalMemoryCacheAdapter from '../LocalMemoryCacheAdapter';
import { LocalMemoryCacheAdapterProvider } from '../LocalMemoryCacheAdapterProvider';
import LocalMemoryTestEntity from '../testfixtures/LocalMemoryTestEntity';
import {
createLocalMemoryIntegrationTestEntityCompanionProvider,
createNoopLocalMemoryIntegrationTestEntityCompanionProvider,
} from '../testfixtures/createLocalMemoryIntegrationTestEntityCompanionProvider';
class TestViewerContext extends ViewerContext {}
describe(LocalMemoryCacheAdapter, () => {
beforeEach(async () => {
LocalMemoryCacheAdapterProvider.localMemoryCacheAdapterMap.clear();
});
it('has correct caching behavior', async () => {
const viewerContext = new TestViewerContext(
createLocalMemoryIntegrationTestEntityCompanionProvider()
);
const cacheAdapter = viewerContext.entityCompanionProvider.getCompanionForEntity(
LocalMemoryTestEntity,
LocalMemoryTestEntity.getCompanionDefinition()
)['tableDataCoordinator']['cacheAdapter'];
const cacheKeyMaker = cacheAdapter['makeCacheKey'].bind(cacheAdapter);
const date = new Date();
const entity1Created = await LocalMemoryTestEntity.creator(viewerContext)
.setField('name', 'blah')
.setField('dateField', date)
.enforceCreateAsync();
// loading an entity should put it in cache
const entity1 = await LocalMemoryTestEntity.loader(viewerContext)
.enforcing()
.loadByIDAsync(entity1Created.getID());
const entitySpecificGenericCacher =
LocalMemoryCacheAdapterProvider.localMemoryCacheAdapterMap.get(
LocalMemoryTestEntity.getCompanionDefinition().entityConfiguration.tableName
)!['genericLocalMemoryCacher'];
const cachedResult = await entitySpecificGenericCacher.loadManyAsync([
cacheKeyMaker('id', entity1.getID()),
]);
const cachedValue = cachedResult.get(cacheKeyMaker('id', entity1.getID()))!;
expect(cachedValue).toMatchObject({
status: CacheStatus.HIT,
item: {
id: entity1.getID(),
name: 'blah',
dateField: date,
},
});
// simulate non existent db fetch, should write negative result ('') to cache
const nonExistentId = uuidv4();
const entityNonExistentResult = await LocalMemoryTestEntity.loader(viewerContext).loadByIDAsync(
nonExistentId
);
expect(entityNonExistentResult.ok).toBe(false);
const nonExistentCachedResult = await entitySpecificGenericCacher.loadManyAsync([
cacheKeyMaker('id', nonExistentId),
]);
expect(nonExistentCachedResult.get(cacheKeyMaker('id', nonExistentId))).toMatchObject({
status: CacheStatus.NEGATIVE,
});
// load again through entities framework to ensure it reads negative result
const entityNonExistentResult2 = await LocalMemoryTestEntity.loader(
viewerContext
).loadByIDAsync(nonExistentId);
expect(entityNonExistentResult2.ok).toBe(false);
// invalidate from cache to ensure it invalidates correctly
await LocalMemoryTestEntity.loader(viewerContext).invalidateFieldsAsync(entity1.getAllFields());
const cachedResultMiss = await entitySpecificGenericCacher.loadManyAsync([
cacheKeyMaker('id', entity1.getID()),
]);
const cachedValueMiss = cachedResultMiss.get(cacheKeyMaker('id', entity1.getID()));
expect(cachedValueMiss).toMatchObject({ status: CacheStatus.MISS });
});
it('shares the cache between different requests', async () => {
const genericLocalMemoryCacherLoadManySpy = jest.spyOn(
GenericLocalMemoryCacher.prototype as unknown as any,
'loadManyAsync'
);
const viewerContext = new TestViewerContext(
createLocalMemoryIntegrationTestEntityCompanionProvider()
);
const date = new Date();
const entity1Created = await LocalMemoryTestEntity.creator(viewerContext)
.setField('name', 'blah')
.setField('dateField', date)
.enforceCreateAsync();
// loading an entity should put it in cache
await LocalMemoryTestEntity.loader(viewerContext)
.enforcing()
.loadByIDAsync(entity1Created.getID());
// load entity with a different request
const viewerContext2 = new TestViewerContext(
createLocalMemoryIntegrationTestEntityCompanionProvider()
);
const entity1WithVc2 = await LocalMemoryTestEntity.loader(viewerContext2)
.enforcing()
.loadByIDAsync(entity1Created.getID());
const cacheAdapter = viewerContext.entityCompanionProvider.getCompanionForEntity(
LocalMemoryTestEntity,
LocalMemoryTestEntity.getCompanionDefinition()
)['tableDataCoordinator']['cacheAdapter'];
const cacheKeyMaker = cacheAdapter['makeCacheKey'].bind(cacheAdapter);
expect(entity1WithVc2.getAllFields()).toMatchObject({
id: entity1WithVc2.getID(),
name: 'blah',
dateField: date,
});
expect(genericLocalMemoryCacherLoadManySpy).toBeCalledWith([
cacheKeyMaker('id', entity1WithVc2.getID()),
]);
expect(genericLocalMemoryCacherLoadManySpy).toBeCalledTimes(2);
});
it('respects the parameters of a noop cache', async () => {
const viewerContext = new TestViewerContext(
createNoopLocalMemoryIntegrationTestEntityCompanionProvider()
);
const cacheAdapter = viewerContext.entityCompanionProvider.getCompanionForEntity(
LocalMemoryTestEntity,
LocalMemoryTestEntity.getCompanionDefinition()
)['tableDataCoordinator']['cacheAdapter'];
const cacheKeyMaker = cacheAdapter['makeCacheKey'].bind(cacheAdapter);
const date = new Date();
const entity1Created = await LocalMemoryTestEntity.creator(viewerContext)
.setField('name', 'blah')
.setField('dateField', date)
.enforceCreateAsync();
// loading an entity will try to put it in cache but it's a noop cache, so it should be a miss
const entity1 = await LocalMemoryTestEntity.loader(viewerContext)
.enforcing()
.loadByIDAsync(entity1Created.getID());
const entitySpecificGenericCacher =
LocalMemoryCacheAdapterProvider.localMemoryCacheAdapterMap.get(
LocalMemoryTestEntity.getCompanionDefinition().entityConfiguration.tableName
)!['genericLocalMemoryCacher'];
const cachedResult = await entitySpecificGenericCacher.loadManyAsync([
cacheKeyMaker('id', entity1.getID()),
]);
const cachedValue = cachedResult.get(cacheKeyMaker('id', entity1.getID()))!;
expect(cachedValue).toMatchObject({
status: CacheStatus.MISS,
});
// a non existent db fetch should try to write negative result ('') but it's a noop cache, so it should be a miss
const nonExistentId = uuidv4();
const entityNonExistentResult = await LocalMemoryTestEntity.loader(viewerContext).loadByIDAsync(
nonExistentId
);
expect(entityNonExistentResult.ok).toBe(false);
const nonExistentCachedResult = await entitySpecificGenericCacher.loadManyAsync([
cacheKeyMaker('id', nonExistentId),
]);
expect(nonExistentCachedResult.get(cacheKeyMaker('id', nonExistentId))).toMatchObject({
status: CacheStatus.MISS,
});
});
});
@@ -0,0 +1,166 @@
import {
CacheAdapterFlavor,
CacheAdapterFlavorDefinition,
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, () => {
it('has correct caching behavior', async () => {
const entityCompanionProvider = createLocalMemoryTestEntityCompanionProvider();
const viewerContext = new ViewerContext(entityCompanionProvider);
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,
viewerContext.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('name', 'blah')
.setField('dateField', date)
.enforceCreateAsync();
// loading an entity should put it in cache
const entity1 = await LocalMemoryTestEntity.loader(
viewerContext,
viewerContext.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.enforcing()
.loadByIDAsync(entity1Created.getID());
const localMemoryCacheAdapterProvider = (
entityCompanionProvider['cacheAdapterFlavors'] as ReadonlyMap<
CacheAdapterFlavor,
CacheAdapterFlavorDefinition
>
).get('local-memory')!.cacheAdapterProvider as LocalMemoryCacheAdapterProvider;
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,
viewerContext.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
).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,
viewerContext.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
).loadByIDAsync(nonExistentId);
expect(entityNonExistentResult2.ok).toBe(false);
// invalidate from cache to ensure it invalidates correctly
await LocalMemoryTestEntity.loader(
viewerContext,
viewerContext.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
).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('respects the parameters of a noop cache', async () => {
const entityCompanionProvider = createNoOpLocalMemoryIntegrationTestEntityCompanionProvider();
const viewerContext = new ViewerContext(entityCompanionProvider);
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,
viewerContext.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.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,
viewerContext.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.enforcing()
.loadByIDAsync(entity1Created.getID());
const localMemoryCacheAdapterProvider = (
entityCompanionProvider['cacheAdapterFlavors'] as ReadonlyMap<
CacheAdapterFlavor,
CacheAdapterFlavorDefinition
>
).get('local-memory')!.cacheAdapterProvider as LocalMemoryCacheAdapterProvider;
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,
viewerContext.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
).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,
});
});
});
@@ -1,9 +1,13 @@
import { CacheStatus, UUIDField, EntityConfiguration } from '@expo/entity';
import {
CacheStatus,
UUIDField,
EntityConfiguration,
GenericEntityCacheAdapter,
} from '@expo/entity';
import GenericLocalMemoryCacher, {
DOES_NOT_EXIST_LOCAL_MEMORY_CACHE,
} from '../GenericLocalMemoryCacher';
import LocalMemoryCacheAdapter from '../LocalMemoryCacheAdapter';
type BlahFields = {
id: string;
@@ -19,15 +23,38 @@ const entityConfiguration = new EntityConfiguration<BlahFields>({
cacheAdapterFlavor: 'local-memory',
});
describe(LocalMemoryCacheAdapter, () => {
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 LocalMemoryCacheAdapter(
entityConfiguration,
GenericLocalMemoryCacher.createLRUCache({
maxSize: Number.MAX_SAFE_INTEGER,
ttlSeconds: Number.MAX_SAFE_INTEGER,
})
const cacheAdapter = new GenericEntityCacheAdapter(
new GenericLocalMemoryCacher(entityConfiguration, GenericLocalMemoryCacher.createLRUCache())
);
const cacheHits = new Map<string, Readonly<BlahFields>>([['test-id-1', { id: 'test-id-1' }]]);
@@ -50,12 +77,8 @@ describe(LocalMemoryCacheAdapter, () => {
});
it('returns empty map when passed empty array of fieldValues', async () => {
const cacheAdapter = new LocalMemoryCacheAdapter(
entityConfiguration,
GenericLocalMemoryCacher.createLRUCache({
maxSize: Number.MAX_SAFE_INTEGER,
ttlSeconds: Number.MAX_SAFE_INTEGER,
})
const cacheAdapter = new GenericEntityCacheAdapter(
new GenericLocalMemoryCacher(entityConfiguration, GenericLocalMemoryCacher.createLRUCache())
);
const results = await cacheAdapter.loadManyAsync('id', []);
expect(results).toEqual(new Map());
@@ -64,15 +87,13 @@ describe(LocalMemoryCacheAdapter, () => {
describe('cacheManyAsync', () => {
it('correctly caches all objects', async () => {
const localMemoryCache = GenericLocalMemoryCacher.createLRUCache<BlahFields>({
maxSize: Number.MAX_SAFE_INTEGER,
ttlSeconds: Number.MAX_SAFE_INTEGER,
});
const localMemoryCache = GenericLocalMemoryCacher.createLRUCache<BlahFields>({});
const cacheAdapter = new LocalMemoryCacheAdapter(entityConfiguration, localMemoryCache);
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 = cacheAdapter['makeCacheKey']('id', 'test-id-1');
const cacheKey = localMemoryCacher['makeCacheKey']('id', 'test-id-1');
expect(localMemoryCache.get(cacheKey)).toMatchObject({
id: 'test-id-1',
});
@@ -81,27 +102,24 @@ describe(LocalMemoryCacheAdapter, () => {
describe('cacheDBMissesAsync', () => {
it('correctly caches misses', async () => {
const localMemoryCache = GenericLocalMemoryCacher.createLRUCache<BlahFields>({
maxSize: Number.MAX_SAFE_INTEGER,
ttlSeconds: Number.MAX_SAFE_INTEGER,
});
const localMemoryCache = GenericLocalMemoryCacher.createLRUCache<BlahFields>({});
const cacheAdapter = new LocalMemoryCacheAdapter(entityConfiguration, localMemoryCache);
const localMemoryCacher = new GenericLocalMemoryCacher(entityConfiguration, localMemoryCache);
const cacheAdapter = new GenericEntityCacheAdapter(localMemoryCacher);
await cacheAdapter.cacheDBMissesAsync('id', ['test-id-1']);
const cacheKey = cacheAdapter['makeCacheKey']('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>({
maxSize: Number.MAX_SAFE_INTEGER,
ttlSeconds: Number.MAX_SAFE_INTEGER,
});
const localMemoryCache = GenericLocalMemoryCacher.createLRUCache<BlahFields>({});
const cacheAdapter = new LocalMemoryCacheAdapter(entityConfiguration, localMemoryCache);
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']);
@@ -112,12 +130,11 @@ describe(LocalMemoryCacheAdapter, () => {
});
it('returns when passed empty array of fieldValues', async () => {
const cacheAdapter = new LocalMemoryCacheAdapter(
entityConfiguration,
GenericLocalMemoryCacher.createLRUCache<BlahFields>({
maxSize: Number.MAX_SAFE_INTEGER,
ttlSeconds: Number.MAX_SAFE_INTEGER,
})
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';
@@ -21,14 +21,18 @@ export default class LocalMemoryTestEntity extends Entity<
string,
ViewerContext
> {
static getCompanionDefinition(): EntityCompanionDefinition<
static defineCompanionDefinition(): EntityCompanionDefinition<
LocalMemoryTestEntityFields,
string,
ViewerContext,
LocalMemoryTestEntity,
LocalMemoryTestEntityPrivacyPolicy
> {
return localMemoryTestEntityCompanionDefinition;
return {
entityClass: LocalMemoryTestEntity,
entityConfiguration: localMemoryTestEntityConfiguration,
privacyPolicyClass: LocalMemoryTestEntityPrivacyPolicy,
};
}
}
@@ -92,9 +96,3 @@ export const localMemoryTestEntityConfiguration =
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'local-memory',
});
const localMemoryTestEntityCompanionDefinition = new EntityCompanionDefinition({
entityClass: LocalMemoryTestEntity,
entityConfiguration: localMemoryTestEntityConfiguration,
privacyPolicyClass: LocalMemoryTestEntityPrivacyPolicy,
});
@@ -6,16 +6,16 @@ import {
StubDatabaseAdapterProvider,
} from '@expo/entity';
import { LocalMemoryCacheAdapterProvider } from '../LocalMemoryCacheAdapterProvider';
import LocalMemoryCacheAdapterProvider from '../LocalMemoryCacheAdapterProvider';
export const createLocalMemoryIntegrationTestEntityCompanionProvider = (
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);
? LocalMemoryCacheAdapterProvider.createNoOpProvider()
: LocalMemoryCacheAdapterProvider.createProviderWithOptions(localMemoryOptions);
return new EntityCompanionProvider(
metricsAdapter,
new Map([
@@ -38,10 +38,10 @@ export const createLocalMemoryIntegrationTestEntityCompanionProvider = (
);
};
export const createNoopLocalMemoryIntegrationTestEntityCompanionProvider = (
export const createNoOpLocalMemoryIntegrationTestEntityCompanionProvider = (
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter()
): EntityCompanionProvider => {
return createLocalMemoryIntegrationTestEntityCompanionProvider(
return createLocalMemoryTestEntityCompanionProvider(
{ maxSize: 0, ttlSeconds: 0 },
metricsAdapter
);
@@ -3,6 +3,136 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.34.0](https://github.com/expo/entity/compare/v0.33.0...v0.34.0) (2024-04-11)
### Features
* require explicit query context specification ([#219](https://github.com/expo/entity/issues/219)) ([8b0b31f](https://github.com/expo/entity/commit/8b0b31fdde5bd565aa527719003ef283a45f55cc))
# [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
@@ -11,7 +11,7 @@ During `EntityCompanionProvider` instantiation:
```typescript
import Redis from 'ioredis';
const redisCacheAdapterContext = {
const genericRedisCacherContext = {
redisClient: new Redis(new URL(process.env['REDIS_URL']!).toString()),
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
@@ -21,7 +21,6 @@ const redisCacheAdapterContext = {
return escapedParts.join(delimiter);
},
cacheKeyPrefix: 'ent-',
cacheKeyVersion: 1,
ttlSecondsPositive: 86400, // 1 day
ttlSecondsNegative: 600, // 10 minutes
};
@@ -36,7 +35,7 @@ export const createDefaultEntityCompanionProvider = (
},
{
['redis']: {
cacheAdapterProvider: new RedisCacheAdapterProvider(redisCacheAdapterContext),
cacheAdapterProvider: new RedisCacheAdapterProvider(genericRedisCacheContext),
},
}
);
@@ -1,6 +1,6 @@
{
"name": "@expo/entity-cache-adapter-redis",
"version": "0.24.0",
"version": "0.34.0",
"description": "Redis cache adapter for @expo/entity",
"files": [
"build",
@@ -19,7 +19,7 @@
"barrelsby": "barrelsby --directory src --location top --exclude tests__ --singleQuotes --exportDefault --delete"
},
"engines": {
"node": ">=12"
"node": ">=16"
},
"keywords": [
"entity"
@@ -27,12 +27,11 @@
"author": "Expo",
"license": "MIT",
"peerDependencies": {
"@expo/entity": "*"
},
"dependencies": {
"ioredis": "^4.27.3"
"@expo/entity": "*",
"ioredis": ">=5"
},
"devDependencies": {
"@expo/entity": "^0.24.0"
"@expo/entity": "^0.34.0",
"ioredis": "^5.2.5"
}
}
@@ -6,7 +6,7 @@ import {
transformFieldsToCacheObject,
IEntityGenericCacher,
} from '@expo/entity';
import { Redis } from 'ioredis';
import invariant from 'invariant';
import { redisTransformerMap } from './RedisCommon';
import wrapNativeRedisCallAsync from './errors/wrapNativeRedisCallAsync';
@@ -15,11 +15,34 @@ import wrapNativeRedisCallAsync from './errors/wrapNativeRedisCallAsync';
// The sentinel value is distinct from any (positively) cached value.
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 {
/**
* 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
@@ -121,4 +144,19 @@ export default class GenericRedisCacher<TFields> implements IEntityGenericCacher
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,125 +0,0 @@
import { EntityCacheAdapter, EntityConfiguration, CacheLoadResult, mapKeys } from '@expo/entity';
import invariant from 'invariant';
import { Redis } from 'ioredis';
import GenericRedisCacher from './GenericRedisCacher';
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<TFields>;
constructor(
entityConfiguration: EntityConfiguration<TFields>,
private readonly context: RedisCacheAdapterContext
) {
super(entityConfiguration);
this.genericRedisCacher = new GenericRedisCacher(
{
redisClient: context.redisClient,
ttlSecondsNegative: context.ttlSecondsNegative,
ttlSecondsPositive: context.ttlSecondsPositive,
},
entityConfiguration
);
}
public async loadManyAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult<TFields>>> {
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]>, Readonly<TFields>>
): 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 {
constructor(private readonly context: RedisCacheAdapterContext) {}
constructor(private readonly context: GenericRedisCacheContext) {}
getCacheAdapter<TFields>(
entityConfiguration: EntityConfiguration<TFields>
): EntityCacheAdapter<TFields> {
return new RedisCacheAdapter(entityConfiguration, this.context);
): IEntityCacheAdapter<TFields> {
return new GenericEntityCacheAdapter(new GenericRedisCacher(this.context, entityConfiguration));
}
}
@@ -0,0 +1,190 @@
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,
viewerContext.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.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,
viewerContext1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.enforcing()
.loadByIDAsync(entity1Created.getID()),
RedisTestEntity.loader(
viewerContext2,
viewerContext2.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.enforcing()
.loadByIDAsync(entity1Created.getID()),
RedisTestEntity.loader(
viewerContext3,
viewerContext3.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.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,
viewerContext.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.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,
viewerContext.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
).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,
viewerContext.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
).loadByIDAsync(nonExistentId);
expect(entityNonExistentResult2.ok).toBe(false);
// invalidate from cache to ensure it invalidates correctly in both caches
await RedisTestEntity.loader(
viewerContext,
viewerContext.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
).invalidateFieldsAsync(entity1.getAllFields());
await expect(redis.get(cacheKeyEntity1)).resolves.toBeNull();
await expect(redis.get(cacheKeyEntity1NameField)).resolves.toBeNull();
});
});
@@ -0,0 +1,173 @@
import { ViewerContext } from '@expo/entity';
import { enforceAsyncResult } from '@expo/results';
import Redis from 'ioredis';
import { URL } from 'url';
import { v4 as uuidv4 } from 'uuid';
import GenericRedisCacher, { GenericRedisCacheContext } from '../GenericRedisCacher';
import RedisTestEntity 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 behavior', async () => {
const viewerContext = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext)
);
const genericCacher =
viewerContext.entityCompanionProvider.getCompanionForEntity(RedisTestEntity)[
'tableDataCoordinator'
]['cacheAdapter']['genericCacher'];
const cacheKeyMaker = genericCacher['makeCacheKey'].bind(genericCacher);
const entity1Created = await RedisTestEntity.creator(
viewerContext,
viewerContext.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('name', 'blah')
.enforceCreateAsync();
// loading an entity should put it in cache
const entity1 = await RedisTestEntity.loader(
viewerContext,
viewerContext.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.enforcing()
.loadByIDAsync(entity1Created.getID());
const cachedJSON = await (genericRedisCacheContext.redisClient as Redis).get(
cacheKeyMaker('id', entity1.getID())
);
const cachedValue = JSON.parse(cachedJSON!);
expect(cachedValue).toMatchObject({
id: entity1.getID(),
name: 'blah',
});
// simulate non existent db fetch, should write negative result ('') to cache
const nonExistentId = uuidv4();
const entityNonExistentResult = await RedisTestEntity.loader(
viewerContext,
viewerContext.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
).loadByIDAsync(nonExistentId);
expect(entityNonExistentResult.ok).toBe(false);
const nonExistentCachedValue = await (genericRedisCacheContext.redisClient as Redis).get(
cacheKeyMaker('id', nonExistentId)
);
expect(nonExistentCachedValue).toEqual('');
// load again through entities framework to ensure it reads negative result
const entityNonExistentResult2 = await RedisTestEntity.loader(
viewerContext,
viewerContext.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
).loadByIDAsync(nonExistentId);
expect(entityNonExistentResult2.ok).toBe(false);
// invalidate from cache to ensure it invalidates correctly
await RedisTestEntity.loader(
viewerContext,
viewerContext.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
).invalidateFieldsAsync(entity1.getAllFields());
const cachedValueNull = await (genericRedisCacheContext.redisClient as Redis).get(
cacheKeyMaker('id', entity1.getID())
);
expect(cachedValueNull).toBe(null);
});
it('caches and restores date fields', async () => {
const viewerContext = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext)
);
const date = new Date();
const entity1 = await enforceAsyncResult(
RedisTestEntity.creator(
viewerContext,
viewerContext.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('dateField', date)
.createAsync()
);
expect(entity1.getField('dateField')).toEqual(date);
const entity2 = await RedisTestEntity.loader(
viewerContext,
viewerContext.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.enforcing()
.loadByIDAsync(entity1.getID());
expect(entity2.getField('dateField')).toEqual(date);
// simulate new request
const vc2 = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext)
);
const entity3 = await RedisTestEntity.loader(
vc2,
vc2.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.enforcing()
.loadByIDAsync(entity1.getID());
expect(entity3.getField('dateField')).toEqual(date);
});
it('caches and restores empty string field keys', async () => {
const viewerContext = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext)
);
const entity1 = await enforceAsyncResult(
RedisTestEntity.creator(
viewerContext,
viewerContext.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('name', '')
.createAsync()
);
const entity2 = await RedisTestEntity.loader(
viewerContext,
viewerContext.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.enforcing()
.loadByFieldEqualingAsync('name', '');
expect(entity2?.getID()).toEqual(entity1.getID());
// simulate new request
const vc2 = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext)
);
const entity3 = await RedisTestEntity.loader(
vc2,
vc2.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.enforcing()
.loadByFieldEqualingAsync('name', '');
expect(entity3?.getID()).toEqual(entity1.getID());
});
});
@@ -2,8 +2,7 @@ import { CacheStatus, ViewerContext } from '@expo/entity';
import Redis from 'ioredis';
import { URL } from 'url';
import GenericRedisCacher from '../GenericRedisCacher';
import { RedisCacheAdapterContext } from '../RedisCacheAdapter';
import GenericRedisCacher, { GenericRedisCacheContext } from '../GenericRedisCacher';
import RedisTestEntity, {
redisTestEntityConfiguration,
RedisTestEntityFields,
@@ -13,10 +12,10 @@ import { createRedisIntegrationTestEntityCompanionProvider } from '../testfixtur
class TestViewerContext extends ViewerContext {}
describe(GenericRedisCacher, () => {
let redisCacheAdapterContext: RedisCacheAdapterContext;
let genericRedisCacheContext: GenericRedisCacheContext;
beforeAll(() => {
redisCacheAdapterContext = {
genericRedisCacheContext = {
redisClient: new Redis(new URL(process.env['REDIS_URL']!).toString()),
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
@@ -26,33 +25,31 @@ describe(GenericRedisCacher, () => {
return escapedParts.join(delimiter);
},
cacheKeyPrefix: 'test-',
cacheKeyVersion: 1,
ttlSecondsPositive: 86400, // 1 day
ttlSecondsNegative: 600, // 10 minutes
};
});
beforeEach(async () => {
await redisCacheAdapterContext.redisClient.flushdb();
await (genericRedisCacheContext.redisClient as Redis).flushdb();
});
afterAll(async () => {
redisCacheAdapterContext.redisClient.disconnect();
(genericRedisCacheContext.redisClient as Redis).disconnect();
});
it('has correct caching and loading behavior', async () => {
const viewerContext = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext)
);
const genericRedisCacher = new GenericRedisCacher(
{
redisClient: redisCacheAdapterContext.redisClient,
ttlSecondsNegative: redisCacheAdapterContext.ttlSecondsNegative,
ttlSecondsPositive: redisCacheAdapterContext.ttlSecondsPositive,
},
genericRedisCacheContext,
redisTestEntityConfiguration
);
const date = new Date();
const entity1Created = await RedisTestEntity.creator(viewerContext)
const entity1Created = await RedisTestEntity.creator(
viewerContext,
viewerContext.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('name', 'blah')
.setField('dateField', date)
.enforceCreateAsync();
@@ -62,7 +59,7 @@ describe(GenericRedisCacher, () => {
]);
await genericRedisCacher.cacheManyAsync(objectMap);
const cachedJSON = await redisCacheAdapterContext.redisClient.get(testKey);
const cachedJSON = await (genericRedisCacheContext.redisClient as Redis).get(testKey);
const cachedValue = JSON.parse(cachedJSON!);
expect(cachedValue).toMatchObject({
id: entity1Created.getID(),
@@ -79,11 +76,7 @@ describe(GenericRedisCacher, () => {
});
it('has correct negative caching behaviour', async () => {
const genericRedisCacher = new GenericRedisCacher(
{
redisClient: redisCacheAdapterContext.redisClient,
ttlSecondsNegative: redisCacheAdapterContext.ttlSecondsNegative,
ttlSecondsPositive: redisCacheAdapterContext.ttlSecondsPositive,
},
genericRedisCacheContext,
redisTestEntityConfiguration
);
@@ -95,18 +88,17 @@ describe(GenericRedisCacher, () => {
});
it('has correct invalidation behaviour', async () => {
const viewerContext = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext)
);
const genericRedisCacher = new GenericRedisCacher(
{
redisClient: redisCacheAdapterContext.redisClient,
ttlSecondsNegative: redisCacheAdapterContext.ttlSecondsNegative,
ttlSecondsPositive: redisCacheAdapterContext.ttlSecondsPositive,
},
genericRedisCacheContext,
redisTestEntityConfiguration
);
const date = new Date();
const entity1Created = await RedisTestEntity.creator(viewerContext)
const entity1Created = await RedisTestEntity.creator(
viewerContext,
viewerContext.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('name', 'blah')
.setField('dateField', date)
.enforceCreateAsync();
@@ -1,139 +0,0 @@
import { ViewerContext } from '@expo/entity';
import { enforceAsyncResult } from '@expo/results';
import Redis from 'ioredis';
import { URL } from 'url';
import { v4 as uuidv4 } from 'uuid';
import RedisCacheAdapter, { RedisCacheAdapterContext } from '../RedisCacheAdapter';
import RedisTestEntity from '../testfixtures/RedisTestEntity';
import { createRedisIntegrationTestEntityCompanionProvider } from '../testfixtures/createRedisIntegrationTestEntityCompanionProvider';
class TestViewerContext extends ViewerContext {}
describe(RedisCacheAdapter, () => {
let redisCacheAdapterContext: RedisCacheAdapterContext;
beforeAll(() => {
redisCacheAdapterContext = {
redisClient: new Redis(new URL(process.env['REDIS_URL']!).toString()),
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`)
);
return escapedParts.join(delimiter);
},
cacheKeyPrefix: 'test-',
cacheKeyVersion: 1,
ttlSecondsPositive: 86400, // 1 day
ttlSecondsNegative: 600, // 10 minutes
};
});
beforeEach(async () => {
await redisCacheAdapterContext.redisClient.flushdb();
});
afterAll(async () => {
redisCacheAdapterContext.redisClient.disconnect();
});
it('has correct caching behavior', async () => {
const viewerContext = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
);
const cacheAdapter = viewerContext.entityCompanionProvider.getCompanionForEntity(
RedisTestEntity,
RedisTestEntity.getCompanionDefinition()
)['tableDataCoordinator']['cacheAdapter'];
const cacheKeyMaker = cacheAdapter['makeCacheKey'].bind(cacheAdapter);
const entity1Created = await RedisTestEntity.creator(viewerContext)
.setField('name', 'blah')
.enforceCreateAsync();
// loading an entity should put it in cache
const entity1 = await RedisTestEntity.loader(viewerContext)
.enforcing()
.loadByIDAsync(entity1Created.getID());
const cachedJSON = await redisCacheAdapterContext.redisClient.get(
cacheKeyMaker('id', entity1.getID())
);
const cachedValue = JSON.parse(cachedJSON!);
expect(cachedValue).toMatchObject({
id: entity1.getID(),
name: 'blah',
});
// 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 nonExistentCachedValue = await redisCacheAdapterContext.redisClient.get(
cacheKeyMaker('id', nonExistentId)
);
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
await RedisTestEntity.loader(viewerContext).invalidateFieldsAsync(entity1.getAllFields());
const cachedValueNull = await redisCacheAdapterContext.redisClient.get(
cacheKeyMaker('id', entity1.getID())
);
expect(cachedValueNull).toBe(null);
});
it('caches and restores date fields', async () => {
const viewerContext = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
);
const date = new Date();
const entity1 = await enforceAsyncResult(
RedisTestEntity.creator(viewerContext).setField('dateField', date).createAsync()
);
expect(entity1.getField('dateField')).toEqual(date);
const entity2 = await RedisTestEntity.loader(viewerContext)
.enforcing()
.loadByIDAsync(entity1.getID());
expect(entity2.getField('dateField')).toEqual(date);
// simulate new request
const vc2 = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
);
const entity3 = await RedisTestEntity.loader(vc2).enforcing().loadByIDAsync(entity1.getID());
expect(entity3.getField('dateField')).toEqual(date);
});
it('caches and restores empty string field keys', async () => {
const viewerContext = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
);
const entity1 = await enforceAsyncResult(
RedisTestEntity.creator(viewerContext).setField('name', '').createAsync()
);
const entity2 = await RedisTestEntity.loader(viewerContext)
.enforcing()
.loadByFieldEqualingAsync('name', '');
expect(entity2?.getID()).toEqual(entity1.getID());
// simulate new request
const vc2 = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
);
const entity3 = await RedisTestEntity.loader(vc2)
.enforcing()
.loadByFieldEqualingAsync('name', '');
expect(entity3?.getID()).toEqual(entity1.getID());
});
});
@@ -2,18 +2,19 @@ import { EntityCacheAdapterTransientError, ViewerContext } from '@expo/entity';
import Redis from 'ioredis';
import { URL } from 'url';
import RedisCacheAdapter, { RedisCacheAdapterContext } from '../RedisCacheAdapter';
import GenericRedisCacher, { GenericRedisCacheContext } from '../GenericRedisCacher';
import RedisTestEntity from '../testfixtures/RedisTestEntity';
import { createRedisIntegrationTestEntityCompanionProvider } from '../testfixtures/createRedisIntegrationTestEntityCompanionProvider';
class TestViewerContext extends ViewerContext {}
describe(RedisCacheAdapter, () => {
let redisCacheAdapterContext: RedisCacheAdapterContext;
describe(GenericRedisCacher, () => {
const redisClient = new Redis(new URL(process.env['REDIS_URL']!).toString());
let genericRedisCacheContext: GenericRedisCacheContext;
beforeAll(() => {
redisCacheAdapterContext = {
redisClient: new Redis(new URL(process.env['REDIS_URL']!).toString()),
genericRedisCacheContext = {
redisClient,
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
@@ -22,25 +23,29 @@ describe(RedisCacheAdapter, () => {
return escapedParts.join(delimiter);
},
cacheKeyPrefix: 'test-',
cacheKeyVersion: 1,
ttlSecondsPositive: 86400, // 1 day
ttlSecondsNegative: 600, // 10 minutes
};
});
beforeEach(async () => {
await redisCacheAdapterContext.redisClient.flushdb();
await redisClient.flushdb();
});
it('throws when redis is disconnected', async () => {
redisCacheAdapterContext.redisClient.disconnect();
redisClient.disconnect();
const vc1 = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext)
);
await expect(
RedisTestEntity.creator(vc1).setField('name', 'blah').enforceCreateAsync()
RedisTestEntity.creator(
vc1,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('name', 'blah')
.enforceCreateAsync()
).rejects.toThrow(EntityCacheAdapterTransientError);
});
});
@@ -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
*/
export { default as RedisCacheAdapter } from './RedisCacheAdapter';
export * from './RedisCacheAdapter';
export { default as RedisCacheAdapterProvider } from './RedisCacheAdapterProvider';
export * from './RedisCommon';
export { default as GenericRedisCacher } from './GenericRedisCacher';
@@ -17,14 +17,18 @@ export type RedisTestEntityFields = {
};
export default class RedisTestEntity extends Entity<RedisTestEntityFields, string, ViewerContext> {
static getCompanionDefinition(): EntityCompanionDefinition<
static defineCompanionDefinition(): EntityCompanionDefinition<
RedisTestEntityFields,
string,
ViewerContext,
RedisTestEntity,
RedisTestEntityPrivacyPolicy
> {
return redisTestEntityCompanionDefinition;
return {
entityClass: RedisTestEntity,
entityConfiguration: redisTestEntityConfiguration,
privacyPolicyClass: RedisTestEntityPrivacyPolicy,
};
}
}
@@ -87,9 +91,3 @@ export const redisTestEntityConfiguration = new EntityConfiguration<RedisTestEnt
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
});
const redisTestEntityCompanionDefinition = new EntityCompanionDefinition({
entityClass: RedisTestEntity,
entityConfiguration: redisTestEntityConfiguration,
privacyPolicyClass: RedisTestEntityPrivacyPolicy,
});
@@ -6,11 +6,14 @@ import {
StubDatabaseAdapterProvider,
} from '@expo/entity';
import { RedisCacheAdapterContext } from '../RedisCacheAdapter';
import { GenericRedisCacheContext } from '../GenericRedisCacher';
import RedisCacheAdapterProvider from '../RedisCacheAdapterProvider';
// share across all in calls in test to simulate postgres
const adapterProvider = new StubDatabaseAdapterProvider();
export const createRedisIntegrationTestEntityCompanionProvider = (
redisCacheAdapterContext: RedisCacheAdapterContext,
genericRedisCacheContext: GenericRedisCacheContext,
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter()
): EntityCompanionProvider => {
return new EntityCompanionProvider(
@@ -19,7 +22,7 @@ export const createRedisIntegrationTestEntityCompanionProvider = (
[
'postgres',
{
adapterProvider: new StubDatabaseAdapterProvider(),
adapterProvider,
queryContextProvider: StubQueryContextProvider,
},
],
@@ -28,7 +31,7 @@ export const createRedisIntegrationTestEntityCompanionProvider = (
[
'redis',
{
cacheAdapterProvider: new RedisCacheAdapterProvider(redisCacheAdapterContext),
cacheAdapterProvider: new RedisCacheAdapterProvider(genericRedisCacheContext),
},
],
])
@@ -3,6 +3,132 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.34.0](https://github.com/expo/entity/compare/v0.33.0...v0.34.0) (2024-04-11)
### Features
* require explicit query context specification ([#219](https://github.com/expo/entity/issues/219)) ([8b0b31f](https://github.com/expo/entity/commit/8b0b31fdde5bd565aa527719003ef283a45f55cc))
# [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
@@ -1,6 +1,6 @@
{
"name": "@expo/entity-database-adapter-knex",
"version": "0.24.0",
"version": "0.34.0",
"description": "Knex database adapter for @expo/entity",
"files": [
"build",
@@ -19,7 +19,7 @@
"barrelsby": "barrelsby --directory src --location top --exclude tests__ --singleQuotes --exportDefault --delete"
},
"engines": {
"node": ">=12"
"node": ">=16"
},
"keywords": [
"entity"
@@ -30,10 +30,10 @@
"@expo/entity": "*"
},
"dependencies": {
"knex": "^1.0.2"
"knex": "^2.4.2"
},
"devDependencies": {
"@expo/entity": "^0.24.0",
"pg": "^8.7.1"
"@expo/entity": "^0.34.0",
"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,
FieldTransformerMap,
FieldTransformer,
JSONArrayField,
MaybeJSONArrayField,
TableQuerySelectionModifiers,
TableFieldSingleValueEqualityCondition,
TableFieldMultiValueEqualityCondition,
TableQuerySelectionModifiersWithOrderByRaw,
} from '@expo/entity';
import { Knex } from 'knex';
import { JSONArrayField, MaybeJSONArrayField } from './EntityFields';
import wrapNativePostgresCallAsync from './errors/wrapNativePostgresCallAsync';
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(
query: Knex.QueryBuilder,
querySelectionModifiers: TableQuerySelectionModifiers
@@ -129,13 +143,13 @@ export default class PostgresEntityDatabaseAdapter<TFields> extends EntityDataba
tableName: string,
rawWhereClause: string,
bindings: object | any[],
querySelectionModifiers: TableQuerySelectionModifiers
querySelectionModifiers: TableQuerySelectionModifiersWithOrderByRaw
): Promise<object[]> {
let query = queryInterface
.select()
.from(tableName)
.whereRaw(rawWhereClause, bindings as any);
query = this.applyQueryModifiersToQuery(query, querySelectionModifiers);
query = this.applyQueryModifiersToQueryOrderByRaw(query, querySelectionModifiers);
return await wrapNativePostgresCallAsync(() => query);
}
@@ -1,4 +1,8 @@
import { EntityQueryContextProvider } from '@expo/entity';
import {
EntityQueryContextProvider,
TransactionConfig,
TransactionIsolationLevel,
} from '@expo/entity';
import { Knex } from 'knex';
/**
@@ -13,9 +17,44 @@ export default class PostgresEntityQueryContextProvider extends EntityQueryConte
return this.knexInstance;
}
protected createTransactionRunner<T>(): (
transactionScope: (trx: any) => Promise<T>
) => Promise<T> {
return (transactionScope) => this.knexInstance.transaction(transactionScope);
protected createTransactionRunner<T>(
transactionConfig?: TransactionConfig
): (transactionScope: (trx: any) => Promise<T>) => Promise<T> {
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,
enforceResultsAsync,
ViewerContext,
TransactionIsolationLevel,
} from '@expo/entity';
import { enforceAsyncResult } from '@expo/results';
import { knex, Knex } from 'knex';
@@ -41,16 +42,34 @@ describe('postgres entity integration', () => {
it('supports parallel partial updates', async () => {
const vc = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance));
const entity = await enforceAsyncResult(
PostgresTestEntity.creator(vc).setField('name', 'hello').createAsync()
PostgresTestEntity.creator(
vc,
vc.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('name', 'hello')
.createAsync()
);
// update two different fields at the same time (from the same entity)
await Promise.all([
PostgresTestEntity.updater(entity).setField('hasACat', true).updateAsync(),
PostgresTestEntity.updater(entity).setField('hasADog', false).updateAsync(),
PostgresTestEntity.updater(
entity,
vc.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('hasACat', true)
.updateAsync(),
PostgresTestEntity.updater(
entity,
vc.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('hasADog', false)
.updateAsync(),
]);
const loadedEntity = await PostgresTestEntity.loader(vc)
const loadedEntity = await PostgresTestEntity.loader(
vc,
vc.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.enforcing()
.loadByIDAsync(entity.getID());
@@ -61,24 +80,49 @@ describe('postgres entity integration', () => {
describe('empty creates and updates', () => {
it('allows empty create', async () => {
const vc = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance));
const entity = await enforceAsyncResult(PostgresTestEntity.creator(vc).createAsync());
const entity = await enforceAsyncResult(
PostgresTestEntity.creator(
vc,
vc.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
).createAsync()
);
expect(entity.getID()).toBeTruthy();
});
it('throws knex error upon empty update', async () => {
const vc = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance));
const entity = await enforceAsyncResult(
PostgresTestEntity.creator(vc).setField('name', 'hello').createAsync()
PostgresTestEntity.creator(
vc,
vc.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('name', 'hello')
.createAsync()
);
await expect(PostgresTestEntity.updater(entity).updateAsync()).rejects.toThrow();
await expect(
PostgresTestEntity.updater(
entity,
vc.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
).updateAsync()
).rejects.toThrow();
});
it('throws error upon empty update for stub database adapter to match behavior', async () => {
const vc = new ViewerContext(createUnitTestEntityCompanionProvider());
const entity = await enforceAsyncResult(
PostgresTestEntity.creator(vc).setField('name', 'hello').createAsync()
PostgresTestEntity.creator(
vc,
vc.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('name', 'hello')
.createAsync()
);
await expect(PostgresTestEntity.updater(entity).updateAsync()).rejects.toThrow();
await expect(
PostgresTestEntity.updater(
entity,
vc.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
).updateAsync()
).rejects.toThrow();
});
});
@@ -87,10 +131,20 @@ describe('postgres entity integration', () => {
// put one in the DB
const firstEntity = await enforceAsyncResult(
PostgresTestEntity.creator(vc1).setField('name', 'hello').createAsync()
PostgresTestEntity.creator(
vc1,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('name', 'hello')
.createAsync()
);
await enforceAsyncResult(PostgresTestEntity.loader(vc1).loadByIDAsync(firstEntity.getID()));
await enforceAsyncResult(
PostgresTestEntity.loader(
vc1,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
).loadByIDAsync(firstEntity.getID())
);
const errorToThrow = new Error('Intentional error');
@@ -106,17 +160,66 @@ describe('postgres entity integration', () => {
).rejects.toEqual(errorToThrow);
const entities = await enforceResultsAsync(
PostgresTestEntity.loader(vc1).loadManyByFieldEqualingAsync('name', 'hello')
PostgresTestEntity.loader(
vc1,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
).loadManyByFieldEqualingAsync('name', 'hello')
);
expect(entities).toHaveLength(1);
});
it('passes transaction config into transactions', async () => {
const vc1 = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance));
const firstEntity = await enforceAsyncResult(
PostgresTestEntity.creator(
vc1,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.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', () => {
it('supports both types of array fields', async () => {
const vc1 = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance));
const entity = await enforceAsyncResult(
PostgresTestEntity.creator(vc1)
PostgresTestEntity.creator(
vc1,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('stringArray', ['hello', 'world'])
.setField('jsonArrayField', ['hello', 'world'])
.createAsync()
@@ -130,7 +233,10 @@ describe('postgres entity integration', () => {
const vc1 = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance));
const entity = await enforceAsyncResult(
PostgresTestEntity.creator(vc1)
PostgresTestEntity.creator(
vc1,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('jsonObjectField', { hello: 'world' })
.createAsync()
);
@@ -142,12 +248,18 @@ describe('postgres entity integration', () => {
const vc1 = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance));
const entity1 = await enforceAsyncResult(
PostgresTestEntity.creator(vc1)
PostgresTestEntity.creator(
vc1,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('maybeJsonArrayField', ['hello', 'world'])
.createAsync()
);
const entity2 = await enforceAsyncResult(
PostgresTestEntity.creator(vc1)
PostgresTestEntity.creator(
vc1,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('maybeJsonArrayField', { hello: 'world' })
.createAsync()
);
@@ -157,12 +269,51 @@ 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,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('bigintField', '72057594037928038')
.createAsync()
);
expect(entity.getField('bigintField')).toEqual('72057594037928038');
entity = await enforceAsyncResult(
PostgresTestEntity.updater(
entity,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('bigintField', '10')
.updateAsync()
);
expect(entity.getField('bigintField')).toEqual('10');
entity = await enforceAsyncResult(
PostgresTestEntity.updater(
entity,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('bigintField', '-10')
.updateAsync()
);
expect(entity.getField('bigintField')).toEqual('-10');
});
});
describe('conjunction field equality loading', () => {
it('supports single fieldValue and multiple fieldValues', async () => {
const vc1 = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance));
await enforceAsyncResult(
PostgresTestEntity.creator(vc1)
PostgresTestEntity.creator(
vc1,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('name', 'hello')
.setField('hasACat', false)
.setField('hasADog', true)
@@ -170,7 +321,10 @@ describe('postgres entity integration', () => {
);
await enforceAsyncResult(
PostgresTestEntity.creator(vc1)
PostgresTestEntity.creator(
vc1,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('name', 'world')
.setField('hasACat', false)
.setField('hasADog', true)
@@ -178,14 +332,20 @@ describe('postgres entity integration', () => {
);
await enforceAsyncResult(
PostgresTestEntity.creator(vc1)
PostgresTestEntity.creator(
vc1,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('name', 'wat')
.setField('hasACat', false)
.setField('hasADog', false)
.createAsync()
);
const results = await PostgresTestEntity.loader(vc1)
const results = await PostgresTestEntity.loader(
vc1,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.enforcing()
.loadManyByFieldEqualityConjunctionAsync([
{
@@ -200,7 +360,10 @@ describe('postgres entity integration', () => {
expect(results).toHaveLength(2);
const results2 = await PostgresTestEntity.loader(vc1)
const results2 = await PostgresTestEntity.loader(
vc1,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.enforcing()
.loadManyByFieldEqualityConjunctionAsync([
{ fieldName: 'hasADog', fieldValues: [true, false] },
@@ -211,13 +374,37 @@ describe('postgres entity integration', () => {
it('supports query modifiers', async () => {
const vc1 = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance));
await enforceAsyncResult(PostgresTestEntity.creator(vc1).setField('name', 'a').createAsync());
await enforceAsyncResult(
PostgresTestEntity.creator(
vc1,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('name', 'a')
.createAsync()
);
await enforceAsyncResult(PostgresTestEntity.creator(vc1).setField('name', 'b').createAsync());
await enforceAsyncResult(
PostgresTestEntity.creator(
vc1,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('name', 'b')
.createAsync()
);
await enforceAsyncResult(PostgresTestEntity.creator(vc1).setField('name', 'c').createAsync());
await enforceAsyncResult(
PostgresTestEntity.creator(
vc1,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('name', 'c')
.createAsync()
);
const results = await PostgresTestEntity.loader(vc1)
const results = await PostgresTestEntity.loader(
vc1,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.enforcing()
.loadManyByFieldEqualityConjunctionAsync([], {
limit: 2,
@@ -236,37 +423,55 @@ describe('postgres entity integration', () => {
it('supports null field values', async () => {
const vc1 = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance));
await enforceAsyncResult(
PostgresTestEntity.creator(vc1)
PostgresTestEntity.creator(
vc1,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('name', 'a')
.setField('hasADog', true)
.createAsync()
);
await enforceAsyncResult(
PostgresTestEntity.creator(vc1)
PostgresTestEntity.creator(
vc1,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('name', 'b')
.setField('hasADog', true)
.createAsync()
);
await enforceAsyncResult(
PostgresTestEntity.creator(vc1)
PostgresTestEntity.creator(
vc1,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('name', null)
.setField('hasADog', true)
.createAsync()
);
await enforceAsyncResult(
PostgresTestEntity.creator(vc1)
PostgresTestEntity.creator(
vc1,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('name', null)
.setField('hasADog', false)
.createAsync()
);
const results = await PostgresTestEntity.loader(vc1)
const results = await PostgresTestEntity.loader(
vc1,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.enforcing()
.loadManyByFieldEqualityConjunctionAsync([{ fieldName: 'name', fieldValue: null }]);
expect(results).toHaveLength(2);
expect(results[0]!.getField('name')).toBeNull();
const results2 = await PostgresTestEntity.loader(vc1)
const results2 = await PostgresTestEntity.loader(
vc1,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.enforcing()
.loadManyByFieldEqualityConjunctionAsync(
[
@@ -291,14 +496,20 @@ describe('postgres entity integration', () => {
it('loads by raw where clause', async () => {
const vc1 = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance));
await enforceAsyncResult(
PostgresTestEntity.creator(vc1)
PostgresTestEntity.creator(
vc1,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('name', 'hello')
.setField('hasACat', false)
.setField('hasADog', true)
.createAsync()
);
const results = await PostgresTestEntity.loader(vc1)
const results = await PostgresTestEntity.loader(
vc1,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.enforcing()
.loadManyByRawWhereClauseAsync('name = ?', ['hello']);
@@ -308,7 +519,10 @@ describe('postgres entity integration', () => {
it('throws with invalid where clause', async () => {
const vc1 = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance));
await enforceAsyncResult(
PostgresTestEntity.creator(vc1)
PostgresTestEntity.creator(
vc1,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('name', 'hello')
.setField('hasACat', false)
.setField('hasADog', true)
@@ -316,7 +530,10 @@ describe('postgres entity integration', () => {
);
await expect(
PostgresTestEntity.loader(vc1)
PostgresTestEntity.loader(
vc1,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.enforcing()
.loadManyByRawWhereClauseAsync('invalid_column = ?', ['hello'])
).rejects.toThrow();
@@ -326,27 +543,39 @@ describe('postgres entity integration', () => {
const vc1 = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance));
await enforceAsyncResult(
PostgresTestEntity.creator(vc1)
PostgresTestEntity.creator(
vc1,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('name', 'a')
.setField('hasADog', true)
.createAsync()
);
await enforceAsyncResult(
PostgresTestEntity.creator(vc1)
PostgresTestEntity.creator(
vc1,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('name', 'b')
.setField('hasADog', true)
.createAsync()
);
await enforceAsyncResult(
PostgresTestEntity.creator(vc1)
PostgresTestEntity.creator(
vc1,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('name', 'c')
.setField('hasADog', true)
.createAsync()
);
const results = await PostgresTestEntity.loader(vc1)
const results = await PostgresTestEntity.loader(
vc1,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.enforcing()
.loadManyByRawWhereClauseAsync('has_a_dog = ?', [true], {
limit: 2,
@@ -362,7 +591,10 @@ describe('postgres entity integration', () => {
expect(results).toHaveLength(2);
expect(results.map((e) => e.getField('name'))).toEqual(['b', 'c']);
const resultsMultipleOrderBy = await PostgresTestEntity.loader(vc1)
const resultsMultipleOrderBy = await PostgresTestEntity.loader(
vc1,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.enforcing()
.loadManyByRawWhereClauseAsync('has_a_dog = ?', [true], {
orderBy: [
@@ -379,6 +611,18 @@ describe('postgres entity integration', () => {
expect(resultsMultipleOrderBy).toHaveLength(3);
expect(resultsMultipleOrderBy.map((e) => e.getField('name'))).toEqual(['c', 'b', 'a']);
const resultsOrderByRaw = await PostgresTestEntity.loader(
vc1,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.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']);
});
});
@@ -390,52 +634,86 @@ describe('postgres entity integration', () => {
);
await expect(
PostgresTriggerTestEntity.creator(vc1)
PostgresTriggerTestEntity.creator(
vc1,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('name', 'beforeCreate')
.enforceCreateAsync()
).rejects.toThrowError('name cannot have value beforeCreate');
await expect(
PostgresTriggerTestEntity.loader(vc1)
PostgresTriggerTestEntity.loader(
vc1,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.enforcing()
.loadByFieldEqualingAsync('name', 'beforeCreate')
).resolves.toBeNull();
await expect(
PostgresTriggerTestEntity.creator(vc1)
PostgresTriggerTestEntity.creator(
vc1,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('name', 'afterCreate')
.enforceCreateAsync()
).rejects.toThrowError('name cannot have value afterCreate');
await expect(
PostgresTriggerTestEntity.loader(vc1)
PostgresTriggerTestEntity.loader(
vc1,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.enforcing()
.loadByFieldEqualingAsync('name', 'afterCreate')
).resolves.toBeNull();
await expect(
PostgresTriggerTestEntity.creator(vc1).setField('name', 'beforeAll').enforceCreateAsync()
PostgresTriggerTestEntity.creator(
vc1,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('name', 'beforeAll')
.enforceCreateAsync()
).rejects.toThrowError('name cannot have value beforeAll');
await expect(
PostgresTriggerTestEntity.loader(vc1)
PostgresTriggerTestEntity.loader(
vc1,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.enforcing()
.loadByFieldEqualingAsync('name', 'beforeAll')
).resolves.toBeNull();
await expect(
PostgresTriggerTestEntity.creator(vc1).setField('name', 'afterAll').enforceCreateAsync()
PostgresTriggerTestEntity.creator(
vc1,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('name', 'afterAll')
.enforceCreateAsync()
).rejects.toThrowError('name cannot have value afterAll');
await expect(
PostgresTriggerTestEntity.loader(vc1)
PostgresTriggerTestEntity.loader(
vc1,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.enforcing()
.loadByFieldEqualingAsync('name', 'afterAll')
).resolves.toBeNull();
await expect(
PostgresTriggerTestEntity.creator(vc1)
PostgresTriggerTestEntity.creator(
vc1,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('name', 'afterCommit')
.enforceCreateAsync()
).rejects.toThrowError('name cannot have value afterCommit');
await expect(
PostgresTriggerTestEntity.loader(vc1)
PostgresTriggerTestEntity.loader(
vc1,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.enforcing()
.loadByFieldEqualingAsync('name', 'afterCommit')
).resolves.not.toBeNull();
@@ -448,61 +726,94 @@ describe('postgres entity integration', () => {
createKnexIntegrationTestEntityCompanionProvider(knexInstance)
);
const entity = await PostgresTriggerTestEntity.creator(vc1)
const entity = await PostgresTriggerTestEntity.creator(
vc1,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('name', 'blah')
.enforceCreateAsync();
await expect(
PostgresTriggerTestEntity.updater(entity)
PostgresTriggerTestEntity.updater(
entity,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('name', 'beforeUpdate')
.enforceUpdateAsync()
).rejects.toThrowError('name cannot have value beforeUpdate');
await expect(
PostgresTriggerTestEntity.loader(vc1)
PostgresTriggerTestEntity.loader(
vc1,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.enforcing()
.loadByFieldEqualingAsync('name', 'beforeUpdate')
).resolves.toBeNull();
await expect(
PostgresTriggerTestEntity.updater(entity)
PostgresTriggerTestEntity.updater(
entity,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('name', 'afterUpdate')
.enforceUpdateAsync()
).rejects.toThrowError('name cannot have value afterUpdate');
await expect(
PostgresTriggerTestEntity.loader(vc1)
PostgresTriggerTestEntity.loader(
vc1,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.enforcing()
.loadByFieldEqualingAsync('name', 'afterUpdate')
).resolves.toBeNull();
await expect(
PostgresTriggerTestEntity.updater(entity)
PostgresTriggerTestEntity.updater(
entity,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('name', 'beforeAll')
.enforceUpdateAsync()
).rejects.toThrowError('name cannot have value beforeAll');
await expect(
PostgresTriggerTestEntity.loader(vc1)
PostgresTriggerTestEntity.loader(
vc1,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.enforcing()
.loadByFieldEqualingAsync('name', 'beforeAll')
).resolves.toBeNull();
await expect(
PostgresTriggerTestEntity.updater(entity)
PostgresTriggerTestEntity.updater(
entity,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('name', 'afterAll')
.enforceUpdateAsync()
).rejects.toThrowError('name cannot have value afterAll');
await expect(
PostgresTriggerTestEntity.loader(vc1)
PostgresTriggerTestEntity.loader(
vc1,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.enforcing()
.loadByFieldEqualingAsync('name', 'afterAll')
).resolves.toBeNull();
await expect(
PostgresTriggerTestEntity.updater(entity)
PostgresTriggerTestEntity.updater(
entity,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('name', 'afterCommit')
.enforceUpdateAsync()
).rejects.toThrowError('name cannot have value afterCommit');
await expect(
PostgresTriggerTestEntity.loader(vc1)
PostgresTriggerTestEntity.loader(
vc1,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.enforcing()
.loadByFieldEqualingAsync('name', 'afterCommit')
).resolves.not.toBeNull();
@@ -515,26 +826,44 @@ describe('postgres entity integration', () => {
createKnexIntegrationTestEntityCompanionProvider(knexInstance)
);
const entityBeforeDelete = await PostgresTriggerTestEntity.creator(vc1)
const entityBeforeDelete = await PostgresTriggerTestEntity.creator(
vc1,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('name', 'beforeDelete')
.enforceCreateAsync();
await expect(
PostgresTriggerTestEntity.enforceDeleteAsync(entityBeforeDelete)
PostgresTriggerTestEntity.enforceDeleteAsync(
entityBeforeDelete,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
).rejects.toThrowError('name cannot have value beforeDelete');
await expect(
PostgresTriggerTestEntity.loader(vc1)
PostgresTriggerTestEntity.loader(
vc1,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.enforcing()
.loadByFieldEqualingAsync('name', 'beforeDelete')
).resolves.not.toBeNull();
const entityAfterDelete = await PostgresTriggerTestEntity.creator(vc1)
const entityAfterDelete = await PostgresTriggerTestEntity.creator(
vc1,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('name', 'afterDelete')
.enforceCreateAsync();
await expect(
PostgresTriggerTestEntity.enforceDeleteAsync(entityAfterDelete)
PostgresTriggerTestEntity.enforceDeleteAsync(
entityAfterDelete,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
).rejects.toThrowError('name cannot have value afterDelete');
await expect(
PostgresTriggerTestEntity.loader(vc1)
PostgresTriggerTestEntity.loader(
vc1,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.enforcing()
.loadByFieldEqualingAsync('name', 'afterDelete')
).resolves.not.toBeNull();
@@ -548,12 +877,18 @@ describe('postgres entity integration', () => {
);
await expect(
PostgresValidatorTestEntity.creator(vc1)
PostgresValidatorTestEntity.creator(
vc1,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('name', 'beforeCreateAndBeforeUpdate')
.enforceCreateAsync()
).rejects.toThrowError('name cannot have value beforeCreateAndBeforeUpdate');
await expect(
PostgresValidatorTestEntity.loader(vc1)
PostgresValidatorTestEntity.loader(
vc1,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.enforcing()
.loadByFieldEqualingAsync('name', 'beforeCreateAndBeforeUpdate')
).resolves.toBeNull();
@@ -565,17 +900,26 @@ describe('postgres entity integration', () => {
createKnexIntegrationTestEntityCompanionProvider(knexInstance)
);
const entity = await PostgresValidatorTestEntity.creator(vc1)
const entity = await PostgresValidatorTestEntity.creator(
vc1,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('name', 'blah')
.enforceCreateAsync();
await expect(
PostgresValidatorTestEntity.updater(entity)
PostgresValidatorTestEntity.updater(
entity,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('name', 'beforeCreateAndBeforeUpdate')
.enforceUpdateAsync()
).rejects.toThrowError('name cannot have value beforeCreateAndBeforeUpdate');
await expect(
PostgresValidatorTestEntity.loader(vc1)
PostgresValidatorTestEntity.loader(
vc1,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.enforcing()
.loadByFieldEqualingAsync('name', 'beforeCreateAndBeforeUpdate')
).resolves.toBeNull();
@@ -587,12 +931,21 @@ describe('postgres entity integration', () => {
createKnexIntegrationTestEntityCompanionProvider(knexInstance)
);
const entityToDelete = await PostgresValidatorTestEntity.creator(vc1)
const entityToDelete = await PostgresValidatorTestEntity.creator(
vc1,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('name', 'shouldBeDeleted')
.enforceCreateAsync();
await PostgresValidatorTestEntity.enforceDeleteAsync(entityToDelete);
await PostgresValidatorTestEntity.enforceDeleteAsync(
entityToDelete,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
);
await expect(
PostgresValidatorTestEntity.loader(vc1)
PostgresValidatorTestEntity.loader(
vc1,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.enforcing()
.loadByFieldEqualingAsync('name', 'shouldBeDeleted')
).resolves.toBeNull();
@@ -606,6 +959,7 @@ describe('postgres entity integration', () => {
const vc1 = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance));
let preCommitCallCount = 0;
let preCommitInnerCallCount = 0;
let postCommitCallCount = 0;
await vc1.runInTransactionForDatabaseAdaptorFlavorAsync('postgres', async (queryContext) => {
@@ -615,6 +969,28 @@ describe('postgres entity integration', () => {
queryContext.appendPreCommitCallback(async () => {
preCommitCallCount++;
}, 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(
@@ -630,7 +1006,8 @@ describe('postgres entity integration', () => {
).rejects.toThrowError('wat');
expect(preCommitCallCount).toBe(2);
expect(postCommitCallCount).toBe(1);
expect(preCommitInnerCallCount).toBe(2);
expect(postCommitCallCount).toBe(2);
});
});
});
@@ -0,0 +1,126 @@
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,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('name', 'unique')
.enforceCreateAsync();
const id = (
await PostgresUniqueTestEntity.creator(
vc1,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.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,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.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,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.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,
vc1.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.enforcing()
.loadByIDAsync(id);
expect(entityLoaded.getField('name')).toEqual('wat3');
});
});
@@ -35,40 +35,86 @@ describe('postgres entity integration', () => {
it('throws after deletion of multiple rows or no rows', async () => {
const vc = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance));
const entity1 = await enforceAsyncResult(
InvalidTestEntity.creator(vc).setField('id', 1).setField('name', 'hello').createAsync()
InvalidTestEntity.creator(
vc,
vc.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('id', 1)
.setField('name', 'hello')
.createAsync()
);
await enforceAsyncResult(
InvalidTestEntity.creator(vc).setField('id', 1).setField('name', 'world').createAsync()
InvalidTestEntity.creator(
vc,
vc.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('id', 1)
.setField('name', 'world')
.createAsync()
);
await expect(InvalidTestEntity.deleteAsync(entity1)).rejects.toThrowError(
'Excessive deletions from database adapter delete'
);
await expect(
InvalidTestEntity.deleteAsync(
entity1,
vc.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
).rejects.toThrowError('Excessive deletions from database adapter delete');
});
it('throws after update of multiple rows', async () => {
const vc = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance));
const entity1 = await enforceAsyncResult(
InvalidTestEntity.creator(vc).setField('id', 1).setField('name', 'hello').createAsync()
InvalidTestEntity.creator(
vc,
vc.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('id', 1)
.setField('name', 'hello')
.createAsync()
);
await enforceAsyncResult(
InvalidTestEntity.creator(vc).setField('id', 1).setField('name', 'world').createAsync()
InvalidTestEntity.creator(
vc,
vc.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('id', 1)
.setField('name', 'world')
.createAsync()
);
await expect(
InvalidTestEntity.updater(entity1).setField('name', 'blah').enforceUpdateAsync()
InvalidTestEntity.updater(
entity1,
vc.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('name', 'blah')
.enforceUpdateAsync()
).rejects.toThrowError('Excessive results from database adapter update');
});
it('throws after update of no rows', async () => {
const vc = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance));
const entity1 = await enforceAsyncResult(
InvalidTestEntity.creator(vc).setField('id', 1).setField('name', 'hello').createAsync()
InvalidTestEntity.creator(
vc,
vc.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('id', 1)
.setField('name', 'hello')
.createAsync()
);
await InvalidTestEntity.deleteAsync(
entity1,
vc.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
);
await InvalidTestEntity.deleteAsync(entity1);
await expect(
InvalidTestEntity.updater(entity1).setField('name', 'blah').enforceUpdateAsync()
InvalidTestEntity.updater(
entity1,
vc.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('name', 'blah')
.enforceUpdateAsync()
).rejects.toThrowError('Empty results from database adapter update');
});
});
@@ -41,7 +41,10 @@ describe('postgres errors', () => {
it('throws EntityDatabaseAdapterTransientError on Knex timeout', async () => {
const vc = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance));
await ErrorsTestEntity.creator(vc)
await ErrorsTestEntity.creator(
vc,
vc.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('id', 1)
.setField('fieldNonNull', 'hello')
.enforceCreateAsync();
@@ -60,16 +63,24 @@ describe('postgres errors', () => {
const vc2 = new ViewerContext(
createKnexIntegrationTestEntityCompanionProvider(shortTimeoutKnexInstance)
);
await expect(ErrorsTestEntity.loader(vc2).enforcing().loadByIDAsync(1)).rejects.toThrow(
EntityDatabaseAdapterTransientError
);
await expect(
ErrorsTestEntity.loader(
vc2,
vc2.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.enforcing()
.loadByIDAsync(1)
).rejects.toThrow(EntityDatabaseAdapterTransientError);
await shortTimeoutKnexInstance.destroy();
});
it('throws EntityDatabaseAdapterNotNullConstraintError when not null is violated', async () => {
const vc = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance));
await expect(
ErrorsTestEntity.creator(vc)
ErrorsTestEntity.creator(
vc,
vc.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('id', 1)
.setField('fieldNonNull', null as any)
.enforceCreateAsync()
@@ -79,7 +90,10 @@ describe('postgres errors', () => {
it('throws EntityDatabaseAdapterForeignKeyConstraintError when foreign key is violated', async () => {
const vc = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance));
await expect(
ErrorsTestEntity.creator(vc)
ErrorsTestEntity.creator(
vc,
vc.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('id', 1)
.setField('fieldNonNull', 'hello')
.setField('fieldForeignKey', 2)
@@ -90,13 +104,19 @@ describe('postgres errors', () => {
it('throws EntityDatabaseAdapterUniqueConstraintError when primary key unique constraint is violated', async () => {
const vc = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance));
await ErrorsTestEntity.creator(vc)
await ErrorsTestEntity.creator(
vc,
vc.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('id', 1)
.setField('fieldNonNull', 'hello')
.enforceCreateAsync();
await expect(
ErrorsTestEntity.creator(vc)
ErrorsTestEntity.creator(
vc,
vc.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('id', 1)
.setField('fieldNonNull', 'hello')
.enforceCreateAsync()
@@ -105,14 +125,20 @@ describe('postgres errors', () => {
it('throws EntityDatabaseAdapterUniqueConstraintError when unique constraint is violated', async () => {
const vc = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance));
await ErrorsTestEntity.creator(vc)
await ErrorsTestEntity.creator(
vc,
vc.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('id', 2)
.setField('fieldNonNull', 'hello')
.setField('fieldUnique', 'hello')
.enforceCreateAsync();
await expect(
ErrorsTestEntity.creator(vc)
ErrorsTestEntity.creator(
vc,
vc.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('id', 1)
.setField('fieldNonNull', 'hello')
.setField('fieldUnique', 'hello')
@@ -123,7 +149,10 @@ describe('postgres errors', () => {
it('throws EntityDatabaseAdapterCheckConstraintError when check constraint is violated', async () => {
const vc = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance));
await expect(
ErrorsTestEntity.creator(vc)
ErrorsTestEntity.creator(
vc,
vc.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('id', 1)
.setField('fieldNonNull', 'hello')
.setField('checkLessThan5', 2)
@@ -131,7 +160,10 @@ describe('postgres errors', () => {
).resolves.toBeTruthy();
await expect(
ErrorsTestEntity.creator(vc)
ErrorsTestEntity.creator(
vc,
vc.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('id', 2)
.setField('fieldNonNull', 'hello')
.setField('checkLessThan5', 10)
@@ -142,7 +174,10 @@ describe('postgres errors', () => {
it('throws EntityDatabaseAdapterExclusionConstraintError when exclusion constraint is violated', async () => {
const vc = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance));
await expect(
ErrorsTestEntity.creator(vc)
ErrorsTestEntity.creator(
vc,
vc.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('id', 1)
.setField('fieldNonNull', 'hello')
.setField('fieldExclusion', 'what')
@@ -150,7 +185,10 @@ describe('postgres errors', () => {
).resolves.toBeTruthy();
await expect(
ErrorsTestEntity.creator(vc)
ErrorsTestEntity.creator(
vc,
vc.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('id', 2)
.setField('fieldNonNull', 'hello')
.setField('fieldExclusion', 'what')
@@ -161,7 +199,10 @@ describe('postgres errors', () => {
it('throws EntityDatabaseAdapterUnknownError otherwise', async () => {
const vc = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance));
await expect(
ErrorsTestEntity.creator(vc)
ErrorsTestEntity.creator(
vc,
vc.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('id', 1)
.setField('fieldNonNull', 'hello')
.setField('nonExistentColumn', 'what')
@@ -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 PostgresEntityDatabaseAdapterProvider } from './PostgresEntityDatabaseAdapterProvider';
export { default as PostgresEntityQueryContextProvider } from './PostgresEntityQueryContextProvider';
export * from './EntityFields';
@@ -27,21 +27,24 @@ export default class ErrorsTestEntity extends Entity<
number,
ViewerContext
> {
static getCompanionDefinition(): EntityCompanionDefinition<
static defineCompanionDefinition(): EntityCompanionDefinition<
ErrorsTestEntityFields,
number,
ViewerContext,
ErrorsTestEntity,
ErrorsTestEntityPrivacyPolicy
> {
return errorsTestEntityCompanionDefinition;
return {
entityClass: ErrorsTestEntity,
entityConfiguration: ErrorsTestEntityConfiguration,
privacyPolicyClass: ErrorsTestEntityPrivacyPolicy,
};
}
public static async createOrTruncatePostgresTable(knex: Knex): Promise<void> {
await knex.raw('CREATE EXTENSION IF NOT EXISTS "btree_gist"'); // for gist exclusion on varchar
const tableName = this.getCompanionDefinition().entityConfiguration.tableName;
const tableName = 'postgres_test_entities';
const hasForeignTable = await knex.schema.hasTable(foreignTableName);
if (!hasForeignTable) {
await knex.schema.createTable(foreignTableName, (table) => {
@@ -87,7 +90,7 @@ export default class ErrorsTestEntity extends Entity<
}
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);
if (hasTable) {
await knex.schema.dropTable(tableName);
@@ -169,9 +172,3 @@ export const ErrorsTestEntityConfiguration = new EntityConfiguration<ErrorsTestE
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
});
const errorsTestEntityCompanionDefinition = new EntityCompanionDefinition({
entityClass: ErrorsTestEntity,
entityConfiguration: ErrorsTestEntityConfiguration,
privacyPolicyClass: ErrorsTestEntityPrivacyPolicy,
});
@@ -20,18 +20,22 @@ export default class InvalidTestEntity extends Entity<
number,
ViewerContext
> {
static getCompanionDefinition(): EntityCompanionDefinition<
static defineCompanionDefinition(): EntityCompanionDefinition<
InvalidTestEntityFields,
number,
ViewerContext,
InvalidTestEntity,
InvalidTestEntityPrivacyPolicy
> {
return invalidTestEntityCompanionDefinition;
return {
entityClass: InvalidTestEntity,
entityConfiguration: invalidTestEntityConfiguration,
privacyPolicyClass: InvalidTestEntityPrivacyPolicy,
};
}
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);
if (!hasTable) {
await knex.schema.createTable(tableName, (table) => {
@@ -43,7 +47,7 @@ export default class InvalidTestEntity extends Entity<
}
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);
if (hasTable) {
await knex.schema.dropTable(tableName);
@@ -105,9 +109,3 @@ export const invalidTestEntityConfiguration = new EntityConfiguration<InvalidTes
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
});
const invalidTestEntityCompanionDefinition = new EntityCompanionDefinition({
entityClass: InvalidTestEntity,
entityConfiguration: invalidTestEntityConfiguration,
privacyPolicyClass: InvalidTestEntityPrivacyPolicy,
});
@@ -6,16 +6,16 @@ import {
StringField,
BooleanField,
StringArrayField,
JSONArrayField,
JSONObjectField,
DateField,
MaybeJSONArrayField,
EntityConfiguration,
EntityCompanionDefinition,
Entity,
} from '@expo/entity';
import { Knex } from 'knex';
import { BigIntField, JSONArrayField, MaybeJSONArrayField } from '../EntityFields';
type PostgresTestEntityFields = {
id: string;
name: string | null;
@@ -28,6 +28,7 @@ type PostgresTestEntityFields = {
} | null;
dateField: Date | null;
maybeJsonArrayField: string[] | { hello: string } | null;
bigintField: string | null;
};
export default class PostgresTestEntity extends Entity<
@@ -35,24 +36,26 @@ export default class PostgresTestEntity extends Entity<
string,
ViewerContext
> {
static getCompanionDefinition(): EntityCompanionDefinition<
static defineCompanionDefinition(): EntityCompanionDefinition<
PostgresTestEntityFields,
string,
ViewerContext,
PostgresTestEntity,
PostgresTestEntityPrivacyPolicy
> {
return postgresTestEntityCompanionDefinition;
return {
entityClass: PostgresTestEntity,
entityConfiguration: postgresTestEntityConfiguration,
privacyPolicyClass: PostgresTestEntityPrivacyPolicy,
};
}
public static async createOrTruncatePostgresTable(knex: Knex): Promise<void> {
await knex.raw('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"'); // for uuid_generate_v4()
const tableName = this.getCompanionDefinition().entityConfiguration.tableName;
const 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('uuid_generate_v4()')).primary();
table.uuid('id').defaultTo(knex.raw('gen_random_uuid()')).primary();
table.string('name');
table.boolean('has_a_dog');
table.boolean('has_a_cat');
@@ -61,13 +64,14 @@ export default class PostgresTestEntity extends Entity<
table.jsonb('json_object_field');
table.dateTime('date_field', { useTz: true });
table.jsonb('maybe_json_array_field');
table.bigint('bigint_field');
});
}
await knex.into(tableName).truncate();
}
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);
if (hasTable) {
await knex.schema.dropTable(tableName);
@@ -147,13 +151,10 @@ export const postgresTestEntityConfiguration = new EntityConfiguration<PostgresT
maybeJsonArrayField: new MaybeJSONArrayField({
columnName: 'maybe_json_array_field',
}),
bigintField: new BigIntField({
columnName: 'bigint_field',
}),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
});
const postgresTestEntityCompanionDefinition = new EntityCompanionDefinition({
entityClass: PostgresTestEntity,
entityConfiguration: postgresTestEntityConfiguration,
privacyPolicyClass: PostgresTestEntityPrivacyPolicy,
});
@@ -24,24 +24,37 @@ export default class PostgresTriggerTestEntity extends Entity<
string,
ViewerContext
> {
static getCompanionDefinition(): EntityCompanionDefinition<
static defineCompanionDefinition(): EntityCompanionDefinition<
PostgresTriggerTestEntityFields,
string,
ViewerContext,
PostgresTriggerTestEntity,
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> {
await knex.raw('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"'); // for uuid_generate_v4()
const tableName = this.getCompanionDefinition().entityConfiguration.tableName;
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('uuid_generate_v4()')).primary();
table.uuid('id').defaultTo(knex.raw('gen_random_uuid()')).primary();
table.string('name');
});
}
@@ -49,7 +62,7 @@ export default class PostgresTriggerTestEntity extends Entity<
}
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);
if (hasTable) {
await knex.schema.dropTable(tableName);
@@ -160,20 +173,3 @@ export const postgresTestEntityConfiguration =
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
});
const postgresTestEntityCompanionDefinition = new EntityCompanionDefinition({
entityClass: PostgresTriggerTestEntity,
entityConfiguration: postgresTestEntityConfiguration,
privacyPolicyClass: PostgresTriggerTestEntityPrivacyPolicy,
mutationTriggers: () => ({
beforeCreate: [new ThrowConditionallyTrigger('name', 'beforeCreate')],
afterCreate: [new ThrowConditionallyTrigger('name', 'afterCreate')],
beforeUpdate: [new ThrowConditionallyTrigger('name', 'beforeUpdate')],
afterUpdate: [new ThrowConditionallyTrigger('name', 'afterUpdate')],
beforeDelete: [new ThrowConditionallyTrigger('name', 'beforeDelete')],
afterDelete: [new ThrowConditionallyTrigger('name', 'afterDelete')],
beforeAll: [new ThrowConditionallyTrigger('name', 'beforeAll')],
afterAll: [new ThrowConditionallyTrigger('name', 'afterAll')],
afterCommit: [new ThrowConditionallyNonTransactionalTrigger('name', 'afterCommit')],
}),
});
@@ -0,0 +1,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,
ViewerContext
> {
static getCompanionDefinition(): EntityCompanionDefinition<
static defineCompanionDefinition(): EntityCompanionDefinition<
PostgresValidatorTestEntityFields,
string,
ViewerContext,
PostgresValidatorTestEntity,
PostgresValidatorTestEntityPrivacyPolicy
> {
return postgresTestEntityCompanionDefinition;
return {
entityClass: PostgresValidatorTestEntity,
entityConfiguration: postgresTestEntityConfiguration,
privacyPolicyClass: PostgresValidatorTestEntityPrivacyPolicy,
mutationValidators: [new ThrowConditionallyTrigger('name', 'beforeCreateAndBeforeUpdate')],
};
}
public static async createOrTruncatePostgresTable(knex: Knex): Promise<void> {
await knex.raw('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"'); // for uuid_generate_v4()
const tableName = this.getCompanionDefinition().entityConfiguration.tableName;
const 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('uuid_generate_v4()')).primary();
table.uuid('id').defaultTo(knex.raw('gen_random_uuid()')).primary();
table.string('name');
});
}
@@ -48,7 +51,7 @@ export default class PostgresValidatorTestEntity extends Entity<
}
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);
if (hasTable) {
await knex.schema.dropTable(tableName);
@@ -142,10 +145,3 @@ export const postgresTestEntityConfiguration =
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
});
const postgresTestEntityCompanionDefinition = new EntityCompanionDefinition({
entityClass: PostgresValidatorTestEntity,
entityConfiguration: postgresTestEntityConfiguration,
privacyPolicyClass: PostgresValidatorTestEntityPrivacyPolicy,
mutationValidators: () => [new ThrowConditionallyTrigger('name', 'beforeCreateAndBeforeUpdate')],
});
+125
View File
@@ -3,6 +3,131 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.34.0](https://github.com/expo/entity/compare/v0.33.0...v0.34.0) (2024-04-11)
### Features
* require explicit query context specification ([#219](https://github.com/expo/entity/issues/219)) ([8b0b31f](https://github.com/expo/entity/commit/8b0b31fdde5bd565aa527719003ef283a45f55cc))
# [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
+14 -10
View File
@@ -1,7 +1,7 @@
{
"name": "@expo/entity-example",
"private": true,
"version": "0.24.0",
"version": "0.34.0",
"description": "An example integration of the @expo/entity framework",
"scripts": {
"tsc": "tsc",
@@ -15,7 +15,7 @@
"start": "tsnd --transpile-only --inspect --respawn --no-notify --async-stack-traces --throw-deprecation -- src/index.ts"
},
"engines": {
"node": ">=12"
"node": ">=16"
},
"keywords": [
"entity"
@@ -23,16 +23,20 @@
"author": "Expo",
"license": "MIT",
"dependencies": {
"@expo/entity": "^0.24.0",
"apollo-server-koa": "^2.16.1",
"graphql": "^15.3.0",
"koa": "^2.13.0",
"koa-body": "^4.2.0",
"koa-router": "^9.4.0"
"@apollo/server": "^4.7.3",
"@as-integrations/koa": "^1.1.1",
"@expo/entity": "^0.34.0",
"@koa/cors": "^4.0.0",
"@koa/router": "^12.0.0",
"graphql": "^16.6.0",
"koa": "^2.14.2",
"koa-bodyparser": "^4.4.0"
},
"devDependencies": {
"@types/koa": "^2.11.4",
"@types/koa-router": "^7.4.1",
"@types/koa": "^2.13.6",
"@types/koa-bodyparser": "^4.3.10",
"@types/koa__cors": "^4.0.0",
"@types/koa__router": "^12.0.0",
"@types/supertest": "^2.0.10",
"supertest": "^4.0.2",
"ts-node-dev": "^1.0.0-pre.60"
@@ -10,14 +10,20 @@ describe(NoteEntity, () => {
const userId = uuidv4();
const viewerContext = new UserViewerContext(companionProvider, userId);
const createdEntityResult = await NoteEntity.creator(viewerContext)
const createdEntityResult = await NoteEntity.creator(
viewerContext,
viewerContext.getQueryContext()
)
.setField('userID', userId)
.setField('body', 'image')
.setField('title', 'page')
.createAsync();
expect(createdEntityResult.ok).toBe(true);
const createdEntityResultImpersonate = await NoteEntity.creator(viewerContext)
const createdEntityResultImpersonate = await NoteEntity.creator(
viewerContext,
viewerContext.getQueryContext()
)
.setField('userID', uuidv4()) // a different user
.setField('body', 'image')
.setField('title', 'page')
@@ -2,10 +2,11 @@ import http from 'http';
import request from 'supertest';
import { v4 as uuidv4 } from 'uuid';
import app from '../app';
import createAppAsync from '../app';
describe('graphql', () => {
it('allows CRUD of user notes', async () => {
const app = await createAppAsync();
const server = request(http.createServer(app.callback()));
const userId = uuidv4();
@@ -117,6 +118,7 @@ describe('graphql', () => {
});
it('disallows anonymous note creation', async () => {
const app = await createAppAsync();
const server = request(http.createServer(app.callback()));
const createResponse1 = await server.post('/graphql').send({
query: `mutation($note: NoteInput!) { addNote(note: $note) { id, title, body } }`,
@@ -133,6 +135,7 @@ describe('graphql', () => {
});
it('disallows cross-user note impersonation', async () => {
const app = await createAppAsync();
const server = request(http.createServer(app.callback()));
const userId = uuidv4();
@@ -2,15 +2,17 @@ import http from 'http';
import request from 'supertest';
import { v4 as uuidv4 } from 'uuid';
import app from '../app';
import createAppAsync from '../app';
describe('notesRouter', () => {
it('returns empty array for logged-out', async () => {
const app = await createAppAsync();
const response = await request(http.createServer(app.callback())).get('/notes/').send();
expect(response.body.notes).toEqual([]);
});
it('allows CRUD of user notes', async () => {
const app = await createAppAsync();
const server = request(http.createServer(app.callback()));
const userId = uuidv4();
@@ -84,6 +86,7 @@ describe('notesRouter', () => {
});
it('disallows anonymous note creation', async () => {
const app = await createAppAsync();
const server = request(http.createServer(app.callback()));
const createResponse1 = await server.post('/notes/').send({
title: 'track',
@@ -94,6 +97,7 @@ describe('notesRouter', () => {
});
it('disallows cross-user note impersonation', async () => {
const app = await createAppAsync();
const server = request(http.createServer(app.callback()));
const userId = 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 {
protected getQueryInterface(): any {
return {};
}
protected createTransactionRunner<T>(): (
transactionScope: (queryInterface: any) => Promise<T>
) => Promise<T> {
protected createTransactionRunner<T>(
_transactionConfig?: TransactionConfig
): (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({}));
}
}
+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 { ApolloServer } from 'apollo-server-koa';
import cors from '@koa/cors';
import Router from '@koa/router';
import Koa from 'koa';
import koaBody from 'koa-body';
import KoaRouter, { RouterContext } from 'koa-router';
import bodyParser from 'koa-bodyparser';
import { entityCompanionMiddleware, viewerContextMiddleware } from './middleware';
import notesRouter from './routers/notesRouter';
import { typeDefs, resolvers } from './schema';
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.
@@ -21,31 +23,40 @@ export type ExampleState = {
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
app.use(koaBody());
// initialze the entity framework for each request
app.use(entityCompanionMiddleware);
// initialze the entity framework for each request
app.use(entityCompanionMiddleware);
// generate a viewer context for each request
app.use(viewerContextMiddleware);
// generate a viewer context for each request
app.use(viewerContextMiddleware);
app.use(cors());
// serve routes that make use of the viewer context
const router = new KoaRouter<ExampleState>();
router.use(notesRouter.routes());
app.use(router.routes()).use(router.allowedMethods());
// body parsing for POST/PUT requests
app.use(bodyParser());
const server = new ApolloServer({
typeDefs,
resolvers,
context: ({ ctx }) => {
return {
viewerContext: ctx.state.viewerContext,
};
},
});
app.use(server.getMiddleware({ cors: false, disableHealthCheck: true, bodyParserConfig: false }));
// serve routes that make use of the viewer context
const router = new Router<ExampleState, ExampleContext>();
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,
EntityQueryContext,
RuleEvaluationResult,
EntityPrivacyPolicyEvaluationContext,
} from '@expo/entity';
import { ExampleViewerContext } from '../viewerContexts';
@@ -22,7 +23,7 @@ import { ExampleViewerContext } from '../viewerContexts';
* Otherwise, it defers to the next rule in the policy.
*/
export default class AllowIfUserOwnerPrivacyRule<
TFields,
TFields extends object,
TID extends NonNullable<TFields[TSelectedFields]>,
TEntity extends ReadonlyEntity<TFields, TID, ExampleViewerContext>,
TSelectedFields extends keyof TFields = keyof TFields
@@ -34,6 +35,7 @@ export default class AllowIfUserOwnerPrivacyRule<
async evaluateAsync(
viewerContext: ExampleViewerContext,
_queryContext: EntityQueryContext,
_evaluationContext: EntityPrivacyPolicyEvaluationContext,
entity: TEntity
): Promise<RuleEvaluationResult> {
if (viewerContext.isUserViewerContext()) {
@@ -6,8 +6,8 @@ import {
StringField,
} from '@expo/entity';
import { ExampleViewerContext } from '../viewerContexts';
import NotePrivacyPolicy from './NotePrivacyPolicy';
import { ExampleViewerContext } from '../viewerContexts';
export interface NoteFields {
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.
*/
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,
string,
ExampleViewerContext,
NoteEntity,
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 { ExampleViewerContext } from '../viewerContexts';
import AllowIfUserOwnerPrivacyRule from './AllowIfUserOwnerPrivacyRule';
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.
+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 NoteEntity from '../entities/NoteEntity';
@@ -11,15 +11,15 @@ import NoteEntity from '../entities/NoteEntity';
* PUT /notes/:id - update a note
* DELETE /notes/:id - delete a note
*/
const router = new KoaRouter<ExampleState>({
const router = new Router<ExampleState, ExampleContext>({
prefix: '/notes',
});
router.get('/', async (ctx: ExampleContext) => {
router.get('/', async (ctx) => {
const viewerContext = ctx.state.viewerContext;
let notes: readonly NoteEntity[] = [];
if (viewerContext.isUserViewerContext()) {
notes = await NoteEntity.loader(viewerContext)
notes = await NoteEntity.loader(viewerContext, viewerContext.getQueryContext())
.enforcing()
.loadManyByFieldEqualingAsync('userID', viewerContext.userID);
}
@@ -28,11 +28,15 @@ router.get('/', async (ctx: ExampleContext) => {
};
});
router.get('/:id', async (ctx: ExampleContext) => {
router.get('/:id', async (ctx) => {
const viewerContext = ctx.state.viewerContext;
const noteResult = await NoteEntity.loader(viewerContext).loadByIDAsync(ctx.params.id);
const noteResult = await NoteEntity.loader(
viewerContext,
viewerContext.getQueryContext()
).loadByIDAsync(ctx.params['id']!);
if (!noteResult.ok) {
ctx.throw(403, noteResult.reason);
return;
}
ctx.body = {
@@ -40,24 +44,26 @@ router.get('/:id', async (ctx: ExampleContext) => {
};
});
router.post('/', async (ctx: ExampleContext) => {
router.post('/', async (ctx) => {
const viewerContext = ctx.state.viewerContext;
if (!viewerContext.isUserViewerContext()) {
// Only logged-in users can create notes. Technically this
// would be handled by the privacy policy (it would fail)
// but we throw here for better type safety.
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, viewerContext.getQueryContext())
.setField('userID', viewerContext.userID)
.setField('title', title)
.setField('body', body)
.createAsync();
if (!createResult.ok) {
ctx.throw(403, createResult.reason);
return;
}
ctx.body = {
@@ -65,44 +71,45 @@ router.post('/', async (ctx: ExampleContext) => {
};
});
router.put('/:id', async (ctx: ExampleContext) => {
router.put('/:id', async (ctx) => {
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);
if (!noteLoadResult.ok) {
ctx.throw(403, noteLoadResult.reason);
try {
const updatedNote = await viewerContext.runInTransactionAsync(async (queryContext) => {
const note = await NoteEntity.loader(viewerContext, queryContext)
.enforcing()
.loadByIDAsync(ctx.params['id']!);
return await NoteEntity.updater(note, queryContext)
.setField('title', title)
.setField('body', body)
.enforceUpdateAsync();
});
ctx.body = {
note: updatedNote.getAllFields(),
};
} catch (e: any) {
ctx.throw(403, e.message);
}
const noteUpdateResult = await NoteEntity.updater(noteLoadResult.value)
.setField('title', title)
.setField('body', body)
.updateAsync();
if (!noteUpdateResult.ok) {
ctx.throw(403, noteUpdateResult.reason);
}
ctx.body = {
note: noteUpdateResult.value.getAllFields(),
};
});
router.delete('/:id', async (ctx: ExampleContext) => {
router.delete('/:id', async (ctx) => {
const viewerContext = ctx.state.viewerContext;
const noteLoadResult = await NoteEntity.loader(viewerContext).loadByIDAsync(ctx.params.id);
if (!noteLoadResult.ok) {
ctx.throw(403, noteLoadResult.reason);
try {
await viewerContext.runInTransactionAsync(async (queryContext) => {
const note = await NoteEntity.loader(viewerContext, queryContext)
.enforcing()
.loadByIDAsync(ctx.params['id']!);
await NoteEntity.enforceDeleteAsync(note, queryContext);
});
ctx.body = {
status: 'ok',
};
} catch (e: any) {
ctx.throw(403, e.message);
}
const noteDeleteResult = await NoteEntity.deleteAsync(noteLoadResult.value);
if (!noteDeleteResult.ok) {
ctx.throw(403, noteDeleteResult.reason);
}
ctx.body = {
status: 'ok',
};
});
export default router;
+27 -21
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 { ExampleViewerContext } from './viewerContexts';
@@ -7,7 +7,7 @@ type GraphqlContext = {
viewerContext: ExampleViewerContext;
};
export const typeDefs = gql`
export const typeDefs = `#graphql
type User {
id: ID!
notes: [Note]!
@@ -46,25 +46,27 @@ export const resolvers: IResolvers<any, GraphqlContext> = {
return viewerContext.userID;
},
async noteByID(_root, args, { viewerContext }) {
return await NoteEntity.loader(viewerContext).enforcing().loadByIDAsync(args.id);
return await NoteEntity.loader(viewerContext, viewerContext.getQueryContext())
.enforcing()
.loadByIDAsync(args.id);
},
} as IResolverObject<any, GraphqlContext>,
} as IObjectTypeResolver<any, GraphqlContext>,
User: {
id: (root) => root,
async notes(root, _args, { viewerContext }) {
return await NoteEntity.loader(viewerContext)
return await NoteEntity.loader(viewerContext, viewerContext.getQueryContext())
.enforcing()
.loadManyByFieldEqualingAsync('userID', root);
},
} as IResolverObject<string, GraphqlContext>,
} as IObjectTypeResolver<string, GraphqlContext>,
Note: {
id: (root) => root.getID(),
title: (root) => root.getField('title'),
body: (root) => root.getField('body'),
user: (root) => root.getField('userID'),
} as IResolverObject<NoteEntity, GraphqlContext>,
} as IObjectTypeResolver<NoteEntity, GraphqlContext>,
Mutation: {
async addNote(_root, args, { viewerContext }) {
@@ -72,27 +74,31 @@ export const resolvers: IResolvers<any, GraphqlContext> = {
throw new Error('not logged in');
}
return await NoteEntity.creator(viewerContext)
return await NoteEntity.creator(viewerContext, viewerContext.getQueryContext())
.setField('userID', viewerContext.userID)
.setField('title', args.note.title)
.setField('body', args.note.body)
.enforceCreateAsync();
},
async updateNote(_root, args, { viewerContext }) {
const existingNote = await NoteEntity.loader(viewerContext)
.enforcing()
.loadByIDAsync(args.id);
return await NoteEntity.updater(existingNote)
.setField('title', args.note.title)
.setField('body', args.note.body)
.enforceUpdateAsync();
return await viewerContext.runInTransactionAsync(async (queryContext) => {
const existingNote = await NoteEntity.loader(viewerContext, queryContext)
.enforcing()
.loadByIDAsync(args.id);
return await NoteEntity.updater(existingNote, queryContext)
.setField('title', args.note.title)
.setField('body', args.note.body)
.enforceUpdateAsync();
});
},
async deleteNote(_root, args, { viewerContext }) {
const existingNote = await NoteEntity.loader(viewerContext)
.enforcing()
.loadByIDAsync(args.id);
await NoteEntity.enforceDeleteAsync(existingNote);
return existingNote;
return await viewerContext.runInTransactionAsync(async (queryContext) => {
const existingNote = await NoteEntity.loader(viewerContext, queryContext)
.enforcing()
.loadByIDAsync(args.id);
await NoteEntity.enforceDeleteAsync(existingNote, queryContext);
return existingNote;
});
},
} as IResolverObject<any, GraphqlContext>,
} as IObjectTypeResolver<any, GraphqlContext>,
};
+16 -1
View File
@@ -1,4 +1,9 @@
import { ViewerContext, EntityCompanionProvider } from '@expo/entity';
import {
ViewerContext,
EntityCompanionProvider,
EntityQueryContext,
EntityTransactionalQueryContext,
} from '@expo/entity';
/**
* A base class for better typing Entities and Privacy Policies specific to this application.
@@ -11,6 +16,16 @@ export abstract class ExampleViewerContext extends ViewerContext {
isAnonymousViewerContext(): this is AnonymousViewerContext {
return this instanceof AnonymousViewerContext;
}
public getQueryContext(): EntityQueryContext {
return super.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres');
}
public async runInTransactionAsync<TResult>(
transactionScope: (queryContext: EntityTransactionalQueryContext) => Promise<TResult>
): Promise<TResult> {
return await super.runInTransactionForDatabaseAdaptorFlavorAsync('postgres', transactionScope);
}
}
/**
@@ -3,6 +3,135 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.34.0](https://github.com/expo/entity/compare/v0.33.0...v0.34.0) (2024-04-11)
### Features
* require explicit query context specification ([#219](https://github.com/expo/entity/issues/219)) ([8b0b31f](https://github.com/expo/entity/commit/8b0b31fdde5bd565aa527719003ef283a45f55cc))
# [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
@@ -1,7 +1,7 @@
{
"name": "@expo/entity-full-integration-tests",
"private": true,
"version": "0.24.0",
"version": "0.34.0",
"description": "Full redis and knex integration tests for the entity framework",
"scripts": {
"tsc": "tsc",
@@ -14,7 +14,7 @@
"barrelsby": "barrelsby --directory src --location top --exclude tests__ --singleQuotes --exportDefault --delete"
},
"engines": {
"node": ">=12"
"node": ">=16"
},
"keywords": [
"entity"
@@ -27,8 +27,8 @@
"@expo/entity-database-adapter-knex": "*"
},
"devDependencies": {
"@expo/entity": "^0.24.0",
"@expo/entity-cache-adapter-redis": "^0.24.0",
"@expo/entity-database-adapter-knex": "^0.24.0"
"@expo/entity": "^0.34.0",
"@expo/entity-cache-adapter-redis": "^0.34.0",
"@expo/entity-database-adapter-knex": "^0.34.0"
}
}
@@ -1,6 +1,5 @@
import {
EntityPrivacyPolicy,
ViewerContext,
AlwaysAllowPrivacyPolicyRule,
Entity,
EntityCompanionDefinition,
@@ -8,12 +7,13 @@ import {
UUIDField,
StringField,
} from '@expo/entity';
import { RedisCacheAdapterContext } from '@expo/entity-cache-adapter-redis';
import { GenericRedisCacheContext } from '@expo/entity-cache-adapter-redis';
import Redis from 'ioredis';
import { knex, Knex } from 'knex';
import nullthrows from 'nullthrows';
import { URL } from 'url';
import TestViewerContext from './entities/TestViewerContext';
import { createFullIntegrationTestEntityCompanionProvider } from '../testfixtures/createFullIntegrationTestEntityCompanionProvider';
interface TestFields {
@@ -25,32 +25,36 @@ interface TestFields {
class TestEntityPrivacyPolicy extends EntityPrivacyPolicy<
TestFields,
string,
ViewerContext,
TestViewerContext,
TestEntity
> {
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<TestFields, string, ViewerContext, TestEntity>(),
new AlwaysAllowPrivacyPolicyRule<TestFields, string, TestViewerContext, TestEntity>(),
];
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<TestFields, string, ViewerContext, TestEntity>(),
new AlwaysAllowPrivacyPolicyRule<TestFields, string, TestViewerContext, TestEntity>(),
];
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<TestFields, string, ViewerContext, TestEntity>(),
new AlwaysAllowPrivacyPolicyRule<TestFields, string, TestViewerContext, TestEntity>(),
];
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<TestFields, string, ViewerContext, TestEntity>(),
new AlwaysAllowPrivacyPolicyRule<TestFields, string, TestViewerContext, TestEntity>(),
];
}
class TestEntity extends Entity<TestFields, string, ViewerContext> {
static getCompanionDefinition(): EntityCompanionDefinition<
class TestEntity extends Entity<TestFields, string, TestViewerContext> {
static defineCompanionDefinition(): EntityCompanionDefinition<
TestFields,
string,
ViewerContext,
TestViewerContext,
TestEntity,
TestEntityPrivacyPolicy
> {
return testEntityCompanion;
return {
entityClass: TestEntity,
entityConfiguration: testEntityConfiguration,
privacyPolicyClass: TestEntityPrivacyPolicy,
};
}
}
@@ -74,17 +78,9 @@ const testEntityConfiguration = new EntityConfiguration<TestFields>({
cacheAdapterFlavor: 'redis',
});
const testEntityCompanion = new EntityCompanionDefinition({
entityClass: TestEntity,
entityConfiguration: testEntityConfiguration,
privacyPolicyClass: TestEntityPrivacyPolicy,
});
async function createOrTruncatePostgresTables(knex: Knex): Promise<void> {
await knex.raw('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"'); // for uuid_generate_v4()
await knex.schema.createTable('testentities', (table) => {
table.uuid('id').defaultTo(knex.raw('uuid_generate_v4()')).primary();
table.uuid('id').defaultTo(knex.raw('gen_random_uuid()')).primary();
table.string('other_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> {
if (await knex.schema.hasTable('children')) {
await knex.schema.dropTable('children');
}
if (await knex.schema.hasTable('parents')) {
await knex.schema.dropTable('parents');
if (await knex.schema.hasTable('testentities')) {
await knex.schema.dropTable('testentities');
}
}
describe('Entity cache inconsistency', () => {
let knexInstance: Knex;
let redisCacheAdapterContext: RedisCacheAdapterContext;
const redisClient = new Redis(new URL(process.env['REDIS_URL']!).toString());
let genericRedisCacheContext: GenericRedisCacheContext;
beforeAll(() => {
knexInstance = knex({
@@ -115,8 +109,8 @@ describe('Entity cache inconsistency', () => {
database: nullthrows(process.env['PGDATABASE']),
},
});
redisCacheAdapterContext = {
redisClient: new Redis(new URL(process.env['REDIS_URL']!).toString()),
genericRedisCacheContext = {
redisClient,
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
@@ -125,7 +119,6 @@ describe('Entity cache inconsistency', () => {
return escapedParts.join(delimiter);
},
cacheKeyPrefix: 'test-',
cacheKeyVersion: 1,
ttlSecondsPositive: 86400, // 1 day
ttlSecondsNegative: 600, // 10 minutes
};
@@ -133,28 +126,30 @@ describe('Entity cache inconsistency', () => {
beforeEach(async () => {
await createOrTruncatePostgresTables(knexInstance);
await redisCacheAdapterContext.redisClient.flushdb();
await redisClient.flushdb();
});
afterAll(async () => {
await dropPostgresTable(knexInstance);
await knexInstance.destroy();
redisCacheAdapterContext.redisClient.disconnect();
redisClient.disconnect();
});
test('lots of updates in long-ish running transactions', async () => {
const viewerContext = new ViewerContext(
createFullIntegrationTestEntityCompanionProvider(knexInstance, redisCacheAdapterContext)
const viewerContext = new TestViewerContext(
createFullIntegrationTestEntityCompanionProvider(knexInstance, genericRedisCacheContext)
);
const entity1 = await TestEntity.creator(viewerContext)
const entity1 = await TestEntity.creator(viewerContext, viewerContext.getQueryContext())
.setField('other_string', 'hello')
.setField('third_string', 'initial')
.enforceCreateAsync();
// put entities in cache and dataloader
await TestEntity.loader(viewerContext).enforcing().loadByIDAsync(entity1.getID());
await TestEntity.loader(viewerContext)
await TestEntity.loader(viewerContext, viewerContext.getQueryContext())
.enforcing()
.loadByIDAsync(entity1.getID());
await TestEntity.loader(viewerContext, viewerContext.getQueryContext())
.enforcing()
.loadByFieldEqualingAsync('other_string', 'hello');
@@ -171,15 +166,17 @@ describe('Entity cache inconsistency', () => {
await Promise.all([
// do a load after the transaction below updates the entity but before transaction commits to ensure
// that the cache is cleared after the transaction commits rather than in the middle where the changes
// may not be visible by other requests (ViewerContexts) which would cache the incorrect
// may not be visible by other requests (TestViewerContexts) which would cache the incorrect
// value during the read-through cache.
(async () => {
await barrier1;
const viewerContextInternal = new ViewerContext(
createFullIntegrationTestEntityCompanionProvider(knexInstance, redisCacheAdapterContext)
const viewerContextInternal = new TestViewerContext(
createFullIntegrationTestEntityCompanionProvider(knexInstance, genericRedisCacheContext)
);
await TestEntity.loader(viewerContextInternal).enforcing().loadByIDAsync(entity1.getID());
await TestEntity.loader(viewerContextInternal, viewerContextInternal.getQueryContext())
.enforcing()
.loadByIDAsync(entity1.getID());
openBarrier2!();
})(),
@@ -199,14 +196,20 @@ describe('Entity cache inconsistency', () => {
]);
// ensure cache consistency
const viewerContextLast = new ViewerContext(
createFullIntegrationTestEntityCompanionProvider(knexInstance, redisCacheAdapterContext)
const viewerContextLast = new TestViewerContext(
createFullIntegrationTestEntityCompanionProvider(knexInstance, genericRedisCacheContext)
);
const loadedById = await TestEntity.loader(viewerContextLast)
const loadedById = await TestEntity.loader(
viewerContextLast,
viewerContextLast.getQueryContext()
)
.enforcing()
.loadByIDAsync(entity1.getID());
const loadedByField = await TestEntity.loader(viewerContextLast)
const loadedByField = await TestEntity.loader(
viewerContextLast,
viewerContextLast.getQueryContext()
)
.enforcing()
.loadByFieldEqualingAsync('other_string', 'hello');
@@ -1,24 +1,22 @@
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 { knex, Knex } from 'knex';
import nullthrows from 'nullthrows';
import { URL } from 'url';
import { createFullIntegrationTestEntityCompanionProvider } from '../testfixtures/createFullIntegrationTestEntityCompanionProvider';
import ChildEntity from './entities/ChildEntity';
import ParentEntity from './entities/ParentEntity';
import TestViewerContext from './entities/TestViewerContext';
import { createFullIntegrationTestEntityCompanionProvider } from '../testfixtures/createFullIntegrationTestEntityCompanionProvider';
async function createOrTruncatePostgresTables(knex: Knex): Promise<void> {
await knex.raw('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"'); // for uuid_generate_v4()
await knex.schema.createTable('parents', (table) => {
table.uuid('id').defaultTo(knex.raw('uuid_generate_v4()')).primary();
table.uuid('id').defaultTo(knex.raw('gen_random_uuid()')).primary();
});
await knex.into('parents').truncate();
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();
});
await knex.into('children').truncate();
@@ -35,7 +33,8 @@ async function dropPostgresTable(knex: Knex): Promise<void> {
describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
let knexInstance: Knex;
let redisCacheAdapterContext: RedisCacheAdapterContext;
const redisClient = new Redis(new URL(process.env['REDIS_URL']!).toString());
let genericRedisCacheContext: GenericRedisCacheContext;
beforeAll(() => {
knexInstance = knex({
@@ -48,8 +47,8 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
database: nullthrows(process.env['PGDATABASE']),
},
});
redisCacheAdapterContext = {
redisClient: new Redis(new URL(process.env['REDIS_URL']!).toString()),
genericRedisCacheContext = {
redisClient,
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
@@ -58,7 +57,6 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
return escapedParts.join(delimiter);
},
cacheKeyPrefix: 'test-',
cacheKeyVersion: 1,
ttlSecondsPositive: 86400, // 1 day
ttlSecondsNegative: 600, // 10 minutes
};
@@ -66,43 +64,52 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
beforeEach(async () => {
await createOrTruncatePostgresTables(knexInstance);
await redisCacheAdapterContext.redisClient.flushdb();
await redisClient.flushdb();
});
afterAll(async () => {
await dropPostgresTable(knexInstance);
await knexInstance.destroy();
redisCacheAdapterContext.redisClient.disconnect();
redisClient.disconnect();
});
describe('EntityEdgeDeletionBehavior.INVALIDATE_CACHE', () => {
it('invalidates the cache', async () => {
const viewerContext = new ViewerContext(
createFullIntegrationTestEntityCompanionProvider(knexInstance, redisCacheAdapterContext)
const viewerContext = new TestViewerContext(
createFullIntegrationTestEntityCompanionProvider(knexInstance, genericRedisCacheContext)
);
const parent = await ParentEntity.creator(viewerContext).enforceCreateAsync();
const child = await ChildEntity.creator(viewerContext)
const parent = await ParentEntity.creator(
viewerContext,
viewerContext.getQueryContext()
).enforceCreateAsync();
const child = await ChildEntity.creator(viewerContext, viewerContext.getQueryContext())
.setField('parent_id', parent.getID())
.enforceCreateAsync();
await expect(
ParentEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(parent.getID())
ParentEntity.loader(viewerContext, viewerContext.getQueryContext())
.enforcing()
.loadByIDNullableAsync(parent.getID())
).resolves.not.toBeNull();
await expect(
ChildEntity.loader(viewerContext)
ChildEntity.loader(viewerContext, viewerContext.getQueryContext())
.enforcing()
.loadByFieldEqualingAsync('parent_id', parent.getID())
).resolves.not.toBeNull();
await ParentEntity.enforceDeleteAsync(parent);
await ParentEntity.enforceDeleteAsync(parent, viewerContext.getQueryContext());
await expect(
ParentEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(parent.getID())
ParentEntity.loader(viewerContext, viewerContext.getQueryContext())
.enforcing()
.loadByIDNullableAsync(parent.getID())
).resolves.toBeNull();
await expect(
ChildEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(child.getID())
ChildEntity.loader(viewerContext, viewerContext.getQueryContext())
.enforcing()
.loadByIDNullableAsync(child.getID())
).resolves.toBeNull();
});
});
@@ -8,13 +8,14 @@ import {
UUIDField,
EntityEdgeDeletionBehavior,
} from '@expo/entity';
import { RedisCacheAdapterContext } from '@expo/entity-cache-adapter-redis';
import { GenericRedisCacheContext } from '@expo/entity-cache-adapter-redis';
import Redis from 'ioredis';
import { knex, Knex } from 'knex';
import nullthrows from 'nullthrows';
import { URL } from 'url';
import { v4 as uuidv4 } from 'uuid';
import TestViewerContext from './entities/TestViewerContext';
import { createFullIntegrationTestEntityCompanionProvider } from '../testfixtures/createFullIntegrationTestEntityCompanionProvider';
interface CategoryFields {
@@ -48,33 +49,41 @@ const makeEntityClasses = async (knex: Knex, edgeDeletionBehavior: EntityEdgeDel
const othersTableName = uuidv4();
class CategoryEntity extends Entity<CategoryFields, string, ViewerContext> {
static getCompanionDefinition(): EntityCompanionDefinition<
static defineCompanionDefinition(): EntityCompanionDefinition<
CategoryFields,
string,
ViewerContext,
CategoryEntity,
PrivacyPolicy
> {
return categoryEntityCompanion;
return {
entityClass: CategoryEntity,
entityConfiguration: categoryEntityConfiguration,
privacyPolicyClass: PrivacyPolicy,
};
}
}
class OtherEntity extends Entity<OtherFields, string, ViewerContext> {
static getCompanionDefinition(): EntityCompanionDefinition<
static defineCompanionDefinition(): EntityCompanionDefinition<
OtherFields,
string,
ViewerContext,
OtherEntity,
PrivacyPolicy
> {
return otherEntityCompanion;
return {
entityClass: OtherEntity,
entityConfiguration: otherEntityConfiguration,
privacyPolicyClass: PrivacyPolicy,
};
}
}
const categoryEntityConfiguration = new EntityConfiguration<CategoryFields>({
idField: 'id',
tableName: categoriesTableName,
getInboundEdges: () => [OtherEntity],
inboundEdges: [OtherEntity],
schema: {
id: new UUIDField({
columnName: 'id',
@@ -84,7 +93,7 @@ const makeEntityClasses = async (knex: Knex, edgeDeletionBehavior: EntityEdgeDel
columnName: 'parent_other_id',
cache: true,
association: {
getAssociatedEntityClass: () => OtherEntity,
associatedEntityClass: OtherEntity,
edgeDeletionBehavior,
},
}),
@@ -93,16 +102,10 @@ const makeEntityClasses = async (knex: Knex, edgeDeletionBehavior: EntityEdgeDel
cacheAdapterFlavor: 'redis',
});
const categoryEntityCompanion = new EntityCompanionDefinition({
entityClass: CategoryEntity,
entityConfiguration: categoryEntityConfiguration,
privacyPolicyClass: PrivacyPolicy,
});
const otherEntityConfiguration = new EntityConfiguration<OtherFields>({
idField: 'id',
tableName: othersTableName,
getInboundEdges: () => [CategoryEntity],
inboundEdges: [CategoryEntity],
schema: {
id: new UUIDField({
columnName: 'id',
@@ -112,7 +115,7 @@ const makeEntityClasses = async (knex: Knex, edgeDeletionBehavior: EntityEdgeDel
columnName: 'parent_category_id',
cache: true,
association: {
getAssociatedEntityClass: () => CategoryEntity,
associatedEntityClass: CategoryEntity,
edgeDeletionBehavior,
},
}),
@@ -121,40 +124,46 @@ const makeEntityClasses = async (knex: Knex, edgeDeletionBehavior: EntityEdgeDel
cacheAdapterFlavor: 'redis',
});
const otherEntityCompanion = new EntityCompanionDefinition({
entityClass: OtherEntity,
entityConfiguration: otherEntityConfiguration,
privacyPolicyClass: PrivacyPolicy,
});
await knex.raw('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"'); // for uuid_generate_v4()
await knex.schema.createTable(categoriesTableName, (table) => {
table.uuid('id').defaultTo(knex.raw('uuid_generate_v4()')).primary();
table.uuid('id').defaultTo(knex.raw('gen_random_uuid()')).primary();
});
await knex.schema.createTable(othersTableName, (table) => {
table.uuid('id').defaultTo(knex.raw('uuid_generate_v4()')).primary();
if (edgeDeletionBehavior === EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE) {
table.uuid('id').defaultTo(knex.raw('gen_random_uuid()')).primary();
if (edgeDeletionBehavior === EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE_ONLY) {
table
.uuid('parent_category_id')
.references('id')
.inTable(categoriesTableName)
.unique()
.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 {
table.uuid('parent_category_id').unique();
}
});
await knex.schema.alterTable(categoriesTableName, (table) => {
if (edgeDeletionBehavior === EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE) {
if (edgeDeletionBehavior === EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE_ONLY) {
table
.uuid('parent_other_id')
.references('id')
.inTable(othersTableName)
.unique()
.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 {
table.uuid('parent_other_id').unique();
}
@@ -164,7 +173,8 @@ const makeEntityClasses = async (knex: Knex, edgeDeletionBehavior: EntityEdgeDel
};
describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
let knexInstance: Knex;
let redisCacheAdapterContext: RedisCacheAdapterContext;
const redisClient = new Redis(new URL(process.env['REDIS_URL']!).toString());
let genericRedisCacheContext: GenericRedisCacheContext;
beforeAll(() => {
knexInstance = knex({
@@ -177,8 +187,8 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
database: nullthrows(process.env['PGDATABASE']),
},
});
redisCacheAdapterContext = {
redisClient: new Redis(new URL(process.env['REDIS_URL']!).toString()),
genericRedisCacheContext = {
redisClient,
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
@@ -187,7 +197,6 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
return escapedParts.join(delimiter);
},
cacheKeyPrefix: 'test-',
cacheKeyVersion: 1,
ttlSecondsPositive: 86400, // 1 day
ttlSecondsNegative: 600, // 10 minutes
};
@@ -195,47 +204,67 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
afterAll(async () => {
await knexInstance.destroy();
redisCacheAdapterContext.redisClient.disconnect();
redisClient.disconnect();
});
it.each([
EntityEdgeDeletionBehavior.CASCADE_DELETE,
EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE,
EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE_ONLY,
EntityEdgeDeletionBehavior.SET_NULL,
EntityEdgeDeletionBehavior.SET_NULL_INVALIDATE_CACHE_ONLY,
])('behavior: %p', async (edgeDeletionBehavior) => {
const { CategoryEntity, OtherEntity } = await makeEntityClasses(
knexInstance,
edgeDeletionBehavior
);
const viewerContext = new ViewerContext(
createFullIntegrationTestEntityCompanionProvider(knexInstance, redisCacheAdapterContext)
const viewerContext = new TestViewerContext(
createFullIntegrationTestEntityCompanionProvider(knexInstance, genericRedisCacheContext)
);
const category1 = await CategoryEntity.creator(viewerContext).enforceCreateAsync();
const other1 = await OtherEntity.creator(viewerContext)
const category1 = await CategoryEntity.creator(
viewerContext,
viewerContext.getQueryContext()
).enforceCreateAsync();
const other1 = await OtherEntity.creator(viewerContext, viewerContext.getQueryContext())
.setField('parent_category_id', category1.getID())
.enforceCreateAsync();
await CategoryEntity.updater(category1)
await CategoryEntity.updater(category1, viewerContext.getQueryContext())
.setField('parent_other_id', other1.getID())
.enforceUpdateAsync();
await CategoryEntity.enforceDeleteAsync(category1);
await CategoryEntity.enforceDeleteAsync(category1, viewerContext.getQueryContext());
if (edgeDeletionBehavior === EntityEdgeDeletionBehavior.SET_NULL) {
await expect(
CategoryEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(category1.getID())
CategoryEntity.loader(viewerContext, viewerContext.getQueryContext())
.enforcing()
.loadByIDNullableAsync(category1.getID())
).resolves.toBeNull();
const otherLoaded = await OtherEntity.loader(viewerContext)
const otherLoaded = await OtherEntity.loader(viewerContext, viewerContext.getQueryContext())
.enforcing()
.loadByIDNullableAsync(other1.getID());
expect(otherLoaded?.getField('parent_category_id')).toBeNull();
} else if (edgeDeletionBehavior === EntityEdgeDeletionBehavior.SET_NULL_INVALIDATE_CACHE_ONLY) {
await expect(
CategoryEntity.loader(viewerContext, viewerContext.getQueryContext())
.enforcing()
.loadByIDNullableAsync(category1.getID())
).resolves.toBeNull();
const otherLoaded = await OtherEntity.loader(viewerContext, viewerContext.getQueryContext())
.enforcing()
.loadByIDNullableAsync(other1.getID());
expect(otherLoaded?.getField('parent_category_id')).toBeNull();
} else {
await expect(
CategoryEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(category1.getID())
CategoryEntity.loader(viewerContext, viewerContext.getQueryContext())
.enforcing()
.loadByIDNullableAsync(category1.getID())
).resolves.toBeNull();
await expect(
OtherEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(other1.getID())
OtherEntity.loader(viewerContext, viewerContext.getQueryContext())
.enforcing()
.loadByIDNullableAsync(other1.getID())
).resolves.toBeNull();
}
});
@@ -6,66 +6,68 @@ import {
EntityEdgeDeletionBehavior,
EntityPrivacyPolicy,
UUIDField,
ViewerContext,
} from '@expo/entity';
import ParentEntity from './ParentEntity';
import TestViewerContext from './TestViewerContext';
interface ChildFields {
id: string;
parent_id: string;
}
class TestEntityPrivacyPolicy extends EntityPrivacyPolicy<any, string, ViewerContext, any, any> {
class TestEntityPrivacyPolicy extends EntityPrivacyPolicy<
any,
string,
TestViewerContext,
any,
any
> {
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, ViewerContext, any, any>(),
new AlwaysAllowPrivacyPolicyRule<any, string, TestViewerContext, any, any>(),
];
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, ViewerContext, any, any>(),
new AlwaysAllowPrivacyPolicyRule<any, string, TestViewerContext, any, any>(),
];
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, ViewerContext, any, any>(),
new AlwaysAllowPrivacyPolicyRule<any, string, TestViewerContext, any, any>(),
];
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, ViewerContext, any, any>(),
new AlwaysAllowPrivacyPolicyRule<any, string, TestViewerContext, any, any>(),
];
}
export default class ChildEntity extends Entity<ChildFields, string, ViewerContext> {
static getCompanionDefinition(): EntityCompanionDefinition<
export default class ChildEntity extends Entity<ChildFields, string, TestViewerContext> {
static defineCompanionDefinition(): EntityCompanionDefinition<
ChildFields,
string,
ViewerContext,
TestViewerContext,
ChildEntity,
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,
});
@@ -5,58 +5,60 @@ import {
EntityConfiguration,
EntityPrivacyPolicy,
UUIDField,
ViewerContext,
} from '@expo/entity';
import ChildEntity from './ChildEntity';
import TestViewerContext from './TestViewerContext';
interface ParentFields {
id: string;
}
class TestEntityPrivacyPolicy extends EntityPrivacyPolicy<any, string, ViewerContext, any, any> {
class TestEntityPrivacyPolicy extends EntityPrivacyPolicy<
any,
string,
TestViewerContext,
any,
any
> {
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, ViewerContext, any, any>(),
new AlwaysAllowPrivacyPolicyRule<any, string, TestViewerContext, any, any>(),
];
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, ViewerContext, any, any>(),
new AlwaysAllowPrivacyPolicyRule<any, string, TestViewerContext, any, any>(),
];
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, ViewerContext, any, any>(),
new AlwaysAllowPrivacyPolicyRule<any, string, TestViewerContext, any, any>(),
];
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, ViewerContext, any, any>(),
new AlwaysAllowPrivacyPolicyRule<any, string, TestViewerContext, any, any>(),
];
}
export default class ParentEntity extends Entity<ParentFields, string, ViewerContext> {
static getCompanionDefinition(): EntityCompanionDefinition<
export default class ParentEntity extends Entity<ParentFields, string, TestViewerContext> {
static defineCompanionDefinition(): EntityCompanionDefinition<
ParentFields,
string,
ViewerContext,
TestViewerContext,
ParentEntity,
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,
});
@@ -0,0 +1,13 @@
import { EntityQueryContext, EntityTransactionalQueryContext, ViewerContext } from '@expo/entity';
export default class TestViewerContext extends ViewerContext {
public getQueryContext(): EntityQueryContext {
return super.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres');
}
public async runInTransactionAsync<TResult>(
transactionScope: (queryContext: EntityTransactionalQueryContext) => Promise<TResult>
): Promise<TResult> {
return await super.runInTransactionForDatabaseAdaptorFlavorAsync('postgres', transactionScope);
}
}
@@ -4,7 +4,7 @@ import {
EntityCompanionProvider,
} from '@expo/entity';
import {
RedisCacheAdapterContext,
GenericRedisCacheContext,
RedisCacheAdapterProvider,
} from '@expo/entity-cache-adapter-redis';
import {
@@ -15,7 +15,7 @@ import { Knex } from 'knex';
export const createFullIntegrationTestEntityCompanionProvider = (
knex: Knex,
redisCacheAdapterContext: RedisCacheAdapterContext,
genericRedisCacheContext: GenericRedisCacheContext,
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter()
): EntityCompanionProvider => {
return new EntityCompanionProvider(
@@ -33,7 +33,7 @@ export const createFullIntegrationTestEntityCompanionProvider = (
[
'redis',
{
cacheAdapterProvider: new RedisCacheAdapterProvider(redisCacheAdapterContext),
cacheAdapterProvider: new RedisCacheAdapterProvider(genericRedisCacheContext),
},
],
])
@@ -3,6 +3,118 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.34.0](https://github.com/expo/entity/compare/v0.33.0...v0.34.0) (2024-04-11)
**Note:** Version bump only for package @expo/entity-ip-address-field
# [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
@@ -1,6 +1,6 @@
{
"name": "@expo/entity-ip-address-field",
"version": "0.24.0",
"version": "0.34.0",
"description": "IP address EntityField definitions for @expo/entity",
"files": [
"build",
@@ -17,7 +17,7 @@
"barrelsby": "barrelsby --directory src --location top --exclude tests__ --singleQuotes --delete"
},
"engines": {
"node": ">=12"
"node": ">=16"
},
"keywords": [
"entity"
@@ -28,7 +28,7 @@
"ip-address": "^8.1.0"
},
"devDependencies": {
"@expo/entity": "^0.24.0",
"@expo/entity": "^0.34.0",
"@types/jsbn": "^1.2.29"
},
"peerDependencies": {
@@ -0,0 +1,121 @@
# Change Log
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.34.0](https://github.com/expo/entity/compare/v0.33.0...v0.34.0) (2024-04-11)
### Features
* require explicit query context specification ([#219](https://github.com/expo/entity/issues/219)) ([8b0b31f](https://github.com/expo/entity/commit/8b0b31fdde5bd565aa527719003ef283a45f55cc))
# [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.34.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.34.0",
"@expo/entity-cache-adapter-local-memory": "^0.34.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,237 @@
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.createProviderWithOptions(),
},
],
])
);
};
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,
viewerContext.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('name', 'wat')
.enforceCreateAsync();
const secondaryCacheLoader = new TestSecondaryLocalMemoryCacheLoader(
new LocalMemorySecondaryEntityCache(
localMemoryTestEntityConfiguration,
GenericLocalMemoryCacher.createLRUCache<LocalMemoryTestEntityFields>({})
),
LocalMemoryTestEntity.loader(
viewerContext,
viewerContext.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
);
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,
viewerContext.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
);
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,138 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.34.0](https://github.com/expo/entity/compare/v0.33.0...v0.34.0) (2024-04-11)
### Features
* require explicit query context specification ([#219](https://github.com/expo/entity/issues/219)) ([8b0b31f](https://github.com/expo/entity/commit/8b0b31fdde5bd565aa527719003ef283a45f55cc))
# [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
@@ -12,7 +12,7 @@
const secondaryCacheLoader = new TestSecondaryRedisCacheLoader(
new RedisSecondaryEntityCache(
redisTestEntityConfiguration,
redisCacheAdapterContext,
genericRedisCacheContext,
(loadParams) => `${loadParams.id}`
),
RedisTestEntity.loader(viewerContext)
@@ -1,6 +1,6 @@
{
"name": "@expo/entity-secondary-cache-redis",
"version": "0.24.0",
"version": "0.34.0",
"description": "Redis secondary cache for @expo/entity",
"files": [
"build",
@@ -19,7 +19,7 @@
"barrelsby": "barrelsby --directory src --location top --exclude tests__ --singleQuotes --exportDefault --delete"
},
"engines": {
"node": ">=12"
"node": ">=16"
},
"keywords": [
"entity"
@@ -30,12 +30,10 @@
"@expo/entity": "*",
"@expo/entity-cache-adapter-redis": "*"
},
"dependencies": {
"ioredis": "^4.17.3"
},
"devDependencies": {
"@expo/entity": "^0.24.0",
"@expo/entity-cache-adapter-redis": "^0.24.0",
"@expo/entity": "^0.34.0",
"@expo/entity-cache-adapter-redis": "^0.34.0",
"ioredis": "^5.2.5",
"nullthrows": "^1.1.1"
}
}
@@ -2,9 +2,7 @@ import { EntityConfiguration, GenericSecondaryEntityCache } from '@expo/entity';
import { GenericRedisCacheContext, GenericRedisCacher } from '@expo/entity-cache-adapter-redis';
/**
* A custom secondary read-through entity cache is a way to add a custom second layer of caching for a particular
* single entity load. One common way this may be used is to add a second layer of caching in a hot path that makes
* a call to {@link EntityLoader.loadManyByFieldEqualityConjunctionAsync} is guaranteed to return at most one entity.
* A redis GenericSecondaryEntityCache.
*/
export default class RedisSecondaryEntityCache<
TFields,
@@ -1,5 +1,5 @@
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 nullthrows from 'nullthrows';
import { URL } from 'url';
@@ -52,44 +52,50 @@ class TestSecondaryRedisCacheLoader extends EntitySecondaryCacheLoader<
}
describe(RedisSecondaryEntityCache, () => {
let redisCacheAdapterContext: RedisCacheAdapterContext;
const redisClient = new Redis(new URL(process.env['REDIS_URL']!).toString());
let genericRedisCacheContext: GenericRedisCacheContext;
beforeAll(() => {
redisCacheAdapterContext = {
redisClient: new Redis(new URL(process.env['REDIS_URL']!).toString()),
genericRedisCacheContext = {
redisClient,
makeKeyFn(..._parts: string[]): string {
throw new Error('should not be used by this test');
},
cacheKeyPrefix: 'test-',
cacheKeyVersion: 1,
ttlSecondsPositive: 86400, // 1 day
ttlSecondsNegative: 600, // 10 minutes
};
});
beforeEach(async () => {
await redisCacheAdapterContext.redisClient.flushdb();
await redisClient.flushdb();
});
afterAll(async () => {
redisCacheAdapterContext.redisClient.disconnect();
redisClient.disconnect();
});
it('Loads through secondary loader, caches, and invalidates', async () => {
const viewerContext = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext)
);
const createdEntity = await RedisTestEntity.creator(viewerContext)
const createdEntity = await RedisTestEntity.creator(
viewerContext,
viewerContext.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
.setField('name', 'wat')
.enforceCreateAsync();
const secondaryCacheLoader = new TestSecondaryRedisCacheLoader(
new RedisSecondaryEntityCache(
redisTestEntityConfiguration,
redisCacheAdapterContext,
genericRedisCacheContext,
(loadParams) => `test-key-${loadParams.id}`
),
RedisTestEntity.loader(viewerContext)
RedisTestEntity.loader(
viewerContext,
viewerContext.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
);
const loadParams = { id: createdEntity.getID() };
@@ -119,16 +125,19 @@ describe(RedisSecondaryEntityCache, () => {
it('correctly handles uncached and unfetchable load params', async () => {
const viewerContext = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext)
);
const secondaryCacheLoader = new TestSecondaryRedisCacheLoader(
new RedisSecondaryEntityCache(
redisTestEntityConfiguration,
redisCacheAdapterContext,
genericRedisCacheContext,
(loadParams) => `test-key-${loadParams.id}`
),
RedisTestEntity.loader(viewerContext)
RedisTestEntity.loader(
viewerContext,
viewerContext.getNonTransactionalQueryContextForDatabaseAdaptorFlavor('postgres')
)
);
const loadParams = { id: FAKE_ID };
@@ -15,14 +15,18 @@ export type RedisTestEntityFields = {
};
export default class RedisTestEntity extends Entity<RedisTestEntityFields, string, ViewerContext> {
static getCompanionDefinition(): EntityCompanionDefinition<
static defineCompanionDefinition(): EntityCompanionDefinition<
RedisTestEntityFields,
string,
ViewerContext,
RedisTestEntity,
RedisTestEntityPrivacyPolicy
> {
return redisTestEntityCompanionDefinition;
return {
entityClass: RedisTestEntity,
entityConfiguration: redisTestEntityConfiguration,
privacyPolicyClass: RedisTestEntityPrivacyPolicy,
};
}
}
@@ -80,9 +84,3 @@ export const redisTestEntityConfiguration = new EntityConfiguration<RedisTestEnt
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
});
const redisTestEntityCompanionDefinition = new EntityCompanionDefinition({
entityClass: RedisTestEntity,
entityConfiguration: redisTestEntityConfiguration,
privacyPolicyClass: RedisTestEntityPrivacyPolicy,
});
@@ -6,12 +6,12 @@ import {
StubDatabaseAdapterProvider,
} from '@expo/entity';
import {
RedisCacheAdapterContext,
GenericRedisCacheContext,
RedisCacheAdapterProvider,
} from '@expo/entity-cache-adapter-redis';
export const createRedisIntegrationTestEntityCompanionProvider = (
redisCacheAdapterContext: RedisCacheAdapterContext,
genericRedisCacheContext: GenericRedisCacheContext,
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter()
): EntityCompanionProvider => {
return new EntityCompanionProvider(
@@ -29,7 +29,7 @@ export const createRedisIntegrationTestEntityCompanionProvider = (
[
'redis',
{
cacheAdapterProvider: new RedisCacheAdapterProvider(redisCacheAdapterContext),
cacheAdapterProvider: new RedisCacheAdapterProvider(genericRedisCacheContext),
},
],
])
+143
View File
@@ -3,6 +3,149 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.34.0](https://github.com/expo/entity/compare/v0.33.0...v0.34.0) (2024-04-11)
### Features
* Allow UUIDv6/7/8 in UUIDField validation ([#221](https://github.com/expo/entity/issues/221)) ([cc6f3dc](https://github.com/expo/entity/commit/cc6f3dc1e677382eec220723c37b5b883d980372))
* require explicit query context specification ([#219](https://github.com/expo/entity/issues/219)) ([8b0b31f](https://github.com/expo/entity/commit/8b0b31fdde5bd565aa527719003ef283a45f55cc))
# [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
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/entity",
"version": "0.24.0",
"version": "0.34.0",
"description": "A privacy-first data model",
"files": [
"build",
@@ -19,7 +19,7 @@
"barrelsby": "barrelsby --directory src --location top --exclude tests__ --singleQuotes --exportDefault --delete"
},
"engines": {
"node": ">=12"
"node": ">=16"
},
"keywords": [
"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 EntityPrivacyPolicy from './EntityPrivacyPolicy';
import ReadonlyEntity from './ReadonlyEntity';
@@ -10,7 +14,7 @@ import { mapMap } from './utils/collections/maps';
* if the loads are not successful.
*/
export default class EnforcingEntityLoader<
TFields,
TFields extends object,
TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext,
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.
* @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>>(
fieldName: N,
@@ -53,7 +57,7 @@ export default class EnforcingEntityLoader<
/**
* 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>>(
fieldName: N,
@@ -68,7 +72,7 @@ export default class EnforcingEntityLoader<
/**
* 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
*/
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.
* @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> {
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.
* @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
*/
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.
* @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>> {
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.
* @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>>(
fieldEqualityOperands: FieldEqualityCondition<TFields, N>[],
@@ -127,12 +156,12 @@ export default class EnforcingEntityLoader<
/**
* 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(
rawWhereClause: string,
bindings: any[] | object,
querySelectionModifiers: QuerySelectionModifiers<TFields> = {}
querySelectionModifiers: QuerySelectionModifiersWithOrderByRaw<TFields> = {}
): Promise<readonly TEntity[]> {
const entityResults = await this.entityLoader.loadManyByRawWhereClauseAsync(
rawWhereClause,
+36 -46
View File
@@ -11,22 +11,22 @@ import ViewerContext from './ViewerContext';
* Entity is a privacy-first data model.
*
* 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
* {@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".
*
* 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".
*
* All concrete entity implementations should extend this class and provide their
* own {@link EntityCompanionDefinition}.
* own EntityCompanionDefinition.
*/
export default abstract class Entity<
TFields,
TFields extends object,
TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext,
TSelectedFields extends keyof TFields = keyof TFields
@@ -38,7 +38,7 @@ export default abstract class Entity<
* @returns mutator for creating an entity
*/
static creator<
TMFields,
TMFields extends object,
TMID extends NonNullable<TMFields[TMSelectedFields]>,
TMViewerContext extends ViewerContext,
TMViewerContext2 extends TMViewerContext,
@@ -61,10 +61,7 @@ export default abstract class Entity<
TMSelectedFields
>,
viewerContext: TMViewerContext2,
queryContext: EntityQueryContext = viewerContext
.getViewerScopedEntityCompanionForClass(this)
.getQueryContextProvider()
.getQueryContext()
queryContext: EntityQueryContext
): CreateMutator<TMFields, TMID, TMViewerContext, TMEntity, TMPrivacyPolicy, TMSelectedFields> {
return viewerContext
.getViewerScopedEntityCompanionForClass(this)
@@ -79,7 +76,7 @@ export default abstract class Entity<
* @returns mutator for updating existingEntity
*/
static updater<
TMFields,
TMFields extends object,
TMID extends NonNullable<TMFields[TMSelectedFields]>,
TMViewerContext extends ViewerContext,
TMEntity extends Entity<TMFields, TMID, TMViewerContext, TMSelectedFields>,
@@ -101,11 +98,7 @@ export default abstract class Entity<
TMSelectedFields
>,
existingEntity: TMEntity,
queryContext: EntityQueryContext = existingEntity
.getViewerContext()
.getViewerScopedEntityCompanionForClass(this)
.getQueryContextProvider()
.getQueryContext()
queryContext: EntityQueryContext
): UpdateMutator<TMFields, TMID, TMViewerContext, TMEntity, TMPrivacyPolicy, TMSelectedFields> {
return existingEntity
.getViewerContext()
@@ -120,7 +113,7 @@ export default abstract class Entity<
* @param queryContext - query context in which to perform the delete
*/
static deleteAsync<
TMFields,
TMFields extends object,
TMID extends NonNullable<TMFields[TMSelectedFields]>,
TMViewerContext extends ViewerContext,
TMEntity extends Entity<TMFields, TMID, TMViewerContext, TMSelectedFields>,
@@ -142,11 +135,7 @@ export default abstract class Entity<
TMSelectedFields
>,
existingEntity: TMEntity,
queryContext: EntityQueryContext = existingEntity
.getViewerContext()
.getViewerScopedEntityCompanionForClass(this)
.getQueryContextProvider()
.getQueryContext()
queryContext: EntityQueryContext
): Promise<Result<void>> {
return existingEntity
.getViewerContext()
@@ -162,7 +151,7 @@ export default abstract class Entity<
* @param queryContext - query context in which to perform the delete
*/
static enforceDeleteAsync<
TMFields,
TMFields extends object,
TMID extends NonNullable<TMFields[TMSelectedFields]>,
TMViewerContext extends ViewerContext,
TMEntity extends Entity<TMFields, TMID, TMViewerContext, TMSelectedFields>,
@@ -184,11 +173,7 @@ export default abstract class Entity<
TMSelectedFields
>,
existingEntity: TMEntity,
queryContext: EntityQueryContext = existingEntity
.getViewerContext()
.getViewerScopedEntityCompanionForClass(this)
.getQueryContextProvider()
.getQueryContext()
queryContext: EntityQueryContext
): Promise<void> {
return existingEntity
.getViewerContext()
@@ -215,7 +200,7 @@ export default abstract class Entity<
* @param queryContext - query context in which to perform the check
*/
static async canViewerUpdateAsync<
TMFields,
TMFields extends object,
TMID extends NonNullable<TMFields[TMSelectedFields]>,
TMViewerContext extends ViewerContext,
TMEntity extends Entity<TMFields, TMID, TMViewerContext, TMSelectedFields>,
@@ -237,20 +222,17 @@ export default abstract class Entity<
TMSelectedFields
>,
existingEntity: TMEntity,
queryContext: EntityQueryContext = existingEntity
.getViewerContext()
.getViewerScopedEntityCompanionForClass(this)
.getQueryContextProvider()
.getQueryContext()
queryContext: EntityQueryContext
): Promise<boolean> {
const companion = existingEntity
.getViewerContext()
.getViewerScopedEntityCompanionForClass(this);
const privacyPolicy = new (this.getCompanionDefinition().privacyPolicyClass)();
const privacyPolicy = companion.entityCompanion.privacyPolicy;
const evaluationResult = await asyncResult(
privacyPolicy.authorizeUpdateAsync(
existingEntity.getViewerContext(),
queryContext,
{ cascadingDeleteCause: null },
existingEntity,
companion.getMetricsAdapter()
)
@@ -268,7 +250,7 @@ export default abstract class Entity<
* @param queryContext - query context in which to perform the check
*/
static async canViewerDeleteAsync<
TMFields,
TMFields extends object,
TMID extends NonNullable<TMFields[TMSelectedFields]>,
TMViewerContext extends ViewerContext,
TMEntity extends Entity<TMFields, TMID, TMViewerContext, TMSelectedFields>,
@@ -290,20 +272,17 @@ export default abstract class Entity<
TMSelectedFields
>,
existingEntity: TMEntity,
queryContext: EntityQueryContext = existingEntity
.getViewerContext()
.getViewerScopedEntityCompanionForClass(this)
.getQueryContextProvider()
.getQueryContext()
queryContext: EntityQueryContext
): Promise<boolean> {
const companion = existingEntity
.getViewerContext()
.getViewerScopedEntityCompanionForClass(this);
const privacyPolicy = new (this.getCompanionDefinition().privacyPolicyClass)();
const privacyPolicy = companion.entityCompanion.privacyPolicy;
const evaluationResult = await asyncResult(
privacyPolicy.authorizeDeleteAsync(
existingEntity.getViewerContext(),
queryContext,
{ cascadingDeleteCause: null },
existingEntity,
companion.getMetricsAdapter()
)
@@ -316,7 +295,7 @@ export default abstract class Entity<
* An interface to pass in constructor (class) of an Entity as a function argument.
*/
export interface IEntityClass<
TFields,
TFields extends object,
TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
@@ -329,8 +308,19 @@ export interface IEntityClass<
>,
TSelectedFields extends keyof TFields = keyof TFields
> {
new (viewerContext: TViewerContext, obj: Readonly<TFields>): TEntity;
getCompanionDefinition(): EntityCompanionDefinition<
new (constructorParam: {
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,
TID,
TViewerContext,
+34 -45
View File
@@ -6,8 +6,13 @@ import { EntityQueryContext } from './EntityQueryContext';
import ReadonlyEntity from './ReadonlyEntity';
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<
TFields,
TFields extends object,
TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
@@ -24,7 +29,7 @@ export default class EntityAssociationLoader<
*/
async loadAssociatedEntityAsync<
TIdentifyingField extends keyof Pick<TFields, TSelectedFields>,
TAssociatedFields,
TAssociatedFields extends object,
TAssociatedID extends NonNullable<TAssociatedFields[TAssociatedSelectedFields]>,
TAssociatedEntity extends ReadonlyEntity<
TAssociatedFields,
@@ -50,11 +55,7 @@ export default class EntityAssociationLoader<
TAssociatedPrivacyPolicy,
TAssociatedSelectedFields
>,
queryContext: EntityQueryContext = this.entity
.getViewerContext()
.getViewerScopedEntityCompanionForClass(associatedEntityClass)
.getQueryContextProvider()
.getQueryContext()
queryContext: EntityQueryContext
): Promise<
Result<null extends TFields[TIdentifyingField] ? TAssociatedEntity | null : TAssociatedEntity>
> {
@@ -69,7 +70,7 @@ export default class EntityAssociationLoader<
.getViewerContext()
.getViewerScopedEntityCompanionForClass(associatedEntityClass)
.getLoaderFactory()
.forLoad(queryContext);
.forLoad(queryContext, { cascadingDeleteCause: null });
return (await loader.loadByIDAsync(associatedEntityID as unknown as TAssociatedID)) as Result<
null extends TFields[TIdentifyingField] ? TAssociatedEntity | null : TAssociatedEntity
@@ -86,7 +87,7 @@ export default class EntityAssociationLoader<
* @param queryContext - query context in which to perform the load
*/
async loadManyAssociatedEntitiesAsync<
TAssociatedFields,
TAssociatedFields extends object,
TAssociatedID extends NonNullable<TAssociatedFields[TAssociatedSelectedFields]>,
TAssociatedEntity extends ReadonlyEntity<
TAssociatedFields,
@@ -112,18 +113,14 @@ export default class EntityAssociationLoader<
TAssociatedSelectedFields
>,
associatedEntityFieldContainingThisID: keyof Pick<TAssociatedFields, TAssociatedSelectedFields>,
queryContext: EntityQueryContext = this.entity
.getViewerContext()
.getViewerScopedEntityCompanionForClass(associatedEntityClass)
.getQueryContextProvider()
.getQueryContext()
queryContext: EntityQueryContext
): Promise<readonly Result<TAssociatedEntity>[]> {
const thisID = this.entity.getID();
const loader = this.entity
.getViewerContext()
.getViewerScopedEntityCompanionForClass(associatedEntityClass)
.getLoaderFactory()
.forLoad(queryContext);
.forLoad(queryContext, { cascadingDeleteCause: null });
return await loader.loadManyByFieldEqualingAsync(
associatedEntityFieldContainingThisID,
thisID as any
@@ -139,7 +136,7 @@ export default class EntityAssociationLoader<
* @param queryContext - query context in which to perform the load
*/
async loadAssociatedEntityByFieldEqualingAsync<
TAssociatedFields,
TAssociatedFields extends object,
TAssociatedID extends NonNullable<TAssociatedFields[TAssociatedSelectedFields]>,
TAssociatedEntity extends ReadonlyEntity<
TAssociatedFields,
@@ -166,11 +163,7 @@ export default class EntityAssociationLoader<
TAssociatedSelectedFields
>,
associatedEntityLookupByField: keyof Pick<TAssociatedFields, TAssociatedSelectedFields>,
queryContext: EntityQueryContext = this.entity
.getViewerContext()
.getViewerScopedEntityCompanionForClass(associatedEntityClass)
.getQueryContextProvider()
.getQueryContext()
queryContext: EntityQueryContext
): Promise<Result<TAssociatedEntity> | null> {
const associatedFieldValue = this.entity.getField(fieldIdentifyingAssociatedEntity);
if (!associatedFieldValue) {
@@ -180,7 +173,7 @@ export default class EntityAssociationLoader<
.getViewerContext()
.getViewerScopedEntityCompanionForClass(associatedEntityClass)
.getLoaderFactory()
.forLoad(queryContext);
.forLoad(queryContext, { cascadingDeleteCause: null });
return await loader.loadByFieldEqualingAsync(
associatedEntityLookupByField,
associatedFieldValue as any
@@ -196,7 +189,7 @@ export default class EntityAssociationLoader<
* @param queryContext - query context in which to perform the load
*/
async loadManyAssociatedEntitiesByFieldEqualingAsync<
TAssociatedFields,
TAssociatedFields extends object,
TAssociatedID extends NonNullable<TAssociatedFields[TAssociatedSelectedFields]>,
TAssociatedEntity extends ReadonlyEntity<
TAssociatedFields,
@@ -223,11 +216,7 @@ export default class EntityAssociationLoader<
TAssociatedSelectedFields
>,
associatedEntityLookupByField: keyof Pick<TAssociatedFields, TAssociatedSelectedFields>,
queryContext: EntityQueryContext = this.entity
.getViewerContext()
.getViewerScopedEntityCompanionForClass(associatedEntityClass)
.getQueryContextProvider()
.getQueryContext()
queryContext: EntityQueryContext
): Promise<readonly Result<TAssociatedEntity>[]> {
const associatedFieldValue = this.entity.getField(fieldIdentifyingAssociatedEntity);
if (!associatedFieldValue) {
@@ -238,7 +227,7 @@ export default class EntityAssociationLoader<
.getViewerContext()
.getViewerScopedEntityCompanionForClass(associatedEntityClass)
.getLoaderFactory()
.forLoad(queryContext);
.forLoad(queryContext, { cascadingDeleteCause: null });
return await loader.loadManyByFieldEqualingAsync(
associatedEntityLookupByField,
associatedFieldValue as any
@@ -246,13 +235,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.
* @param loadDirectives - associated entity load directives instructing each step of the fold
* @param queryContext - query context in which to perform the loads
*/
async loadAssociatedEntityThroughAsync<
TFields2,
TFields2 extends object,
TID2 extends NonNullable<TFields2[TSelectedFields2]>,
TEntity2 extends ReadonlyEntity<TFields2, TID2, TViewerContext, TSelectedFields2>,
TPrivacyPolicy2 extends EntityPrivacyPolicy<
@@ -276,17 +265,17 @@ export default class EntityAssociationLoader<
TSelectedFields2
>
],
queryContext?: EntityQueryContext
queryContext: EntityQueryContext
): 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.
* @param loadDirectives - associated entity load directives instructing each step of the fold
* @param queryContext - query context in which to perform the loads
*/
async loadAssociatedEntityThroughAsync<
TFields2,
TFields2 extends object,
TID2 extends NonNullable<TFields2[TSelectedFields2]>,
TEntity2 extends ReadonlyEntity<TFields2, TID2, TViewerContext, TSelectedFields2>,
TPrivacyPolicy2 extends EntityPrivacyPolicy<
@@ -296,7 +285,7 @@ export default class EntityAssociationLoader<
TEntity2,
TSelectedFields2
>,
TFields3,
TFields3 extends object,
TID3 extends NonNullable<TFields3[TSelectedFields3]>,
TEntity3 extends ReadonlyEntity<TFields3, TID3, TViewerContext, TSelectedFields3>,
TPrivacyPolicy3 extends EntityPrivacyPolicy<
@@ -331,17 +320,17 @@ export default class EntityAssociationLoader<
TSelectedFields3
>
],
queryContext?: EntityQueryContext
queryContext: EntityQueryContext
): 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.
* @param loadDirectives - associated entity load directives instructing each step of the fold
* @param queryContext - query context in which to perform the loads
*/
async loadAssociatedEntityThroughAsync<
TFields2,
TFields2 extends object,
TID2 extends NonNullable<TFields2[TSelectedFields2]>,
TEntity2 extends ReadonlyEntity<TFields2, TID2, TViewerContext, TSelectedFields2>,
TPrivacyPolicy2 extends EntityPrivacyPolicy<
@@ -351,7 +340,7 @@ export default class EntityAssociationLoader<
TEntity2,
TSelectedFields2
>,
TFields3,
TFields3 extends object,
TID3 extends NonNullable<TFields3[TSelectedFields3]>,
TEntity3 extends ReadonlyEntity<TFields3, TID3, TViewerContext, TSelectedFields3>,
TPrivacyPolicy3 extends EntityPrivacyPolicy<
@@ -361,7 +350,7 @@ export default class EntityAssociationLoader<
TEntity3,
TSelectedFields3
>,
TFields4,
TFields4 extends object,
TID4 extends NonNullable<TFields4[TSelectedFields4]>,
TEntity4 extends ReadonlyEntity<TFields4, TID4, TViewerContext, TSelectedFields4>,
TPrivacyPolicy4 extends EntityPrivacyPolicy<
@@ -407,23 +396,23 @@ export default class EntityAssociationLoader<
TSelectedFields4
>
],
queryContext?: EntityQueryContext
queryContext: EntityQueryContext
): 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.
* @param loadDirectives - associated entity load directives instructing each step of the fold
* @param queryContext - query context in which to perform the loads
*/
async loadAssociatedEntityThroughAsync(
loadDirectives: EntityLoadThroughDirective<TViewerContext, any, any, any, any, any, any, any>[],
queryContext?: EntityQueryContext
queryContext: EntityQueryContext
): Promise<Result<ReadonlyEntity<any, any, any, any>> | null>;
async loadAssociatedEntityThroughAsync(
loadDirectives: EntityLoadThroughDirective<TViewerContext, any, any, any, any, any, any, any>[],
queryContext?: EntityQueryContext
queryContext: EntityQueryContext
): Promise<Result<ReadonlyEntity<any, any, any, any>> | null> {
let currentEntity: ReadonlyEntity<any, any, any, any> = this.entity;
for (const loadDirective of loadDirectives) {
@@ -477,7 +466,7 @@ export default class EntityAssociationLoader<
export interface EntityLoadThroughDirective<
TViewerContext extends ViewerContext,
TFields,
TAssociatedFields,
TAssociatedFields extends object,
TAssociatedID extends NonNullable<TAssociatedFields[TAssociatedSelectedFields]>,
TAssociatedEntity extends ReadonlyEntity<
TAssociatedFields,
+14 -33
View File
@@ -1,7 +1,5 @@
import { IEntityClass } from './Entity';
import EntityCompanionProvider, { EntityCompanionDefinition } from './EntityCompanionProvider';
import EntityLoaderFactory from './EntityLoaderFactory';
import EntityMutationTriggerConfiguration from './EntityMutationTriggerConfiguration';
import EntityMutationValidator from './EntityMutationValidator';
import EntityMutatorFactory from './EntityMutatorFactory';
import EntityPrivacyPolicy from './EntityPrivacyPolicy';
import EntityQueryContextProvider from './EntityQueryContextProvider';
@@ -18,7 +16,7 @@ export interface IPrivacyPolicyClass<TPrivacyPolicy> {
* Composition root responsible for orchestrating setup of Entity mutators and loaders.
*/
export default class EntityCompanion<
TFields,
TFields extends object,
TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
@@ -31,6 +29,8 @@ export default class EntityCompanion<
>,
TSelectedFields extends keyof TFields
> {
public readonly privacyPolicy: TPrivacyPolicy;
private readonly entityLoaderFactory: EntityLoaderFactory<
TFields,
TID,
@@ -49,7 +49,8 @@ export default class EntityCompanion<
>;
constructor(
entityClass: IEntityClass<
public readonly entityCompanionProvider: EntityCompanionProvider,
public readonly entityCompanionDefinition: EntityCompanionDefinition<
TFields,
TID,
TViewerContext,
@@ -58,24 +59,9 @@ export default class EntityCompanion<
TSelectedFields
>,
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
) {
const privacyPolicy = new PrivacyPolicyClass();
this.privacyPolicy = new entityCompanionDefinition.privacyPolicyClass();
this.entityLoaderFactory = new EntityLoaderFactory<
TFields,
TID,
@@ -83,19 +69,14 @@ export default class EntityCompanion<
TEntity,
TPrivacyPolicy,
TSelectedFields
>(
tableDataCoordinator.entityConfiguration,
entityClass,
privacyPolicy,
tableDataCoordinator.dataManager,
metricsAdapter
);
>(this, tableDataCoordinator.dataManager, metricsAdapter);
this.entityMutatorFactory = new EntityMutatorFactory(
entityCompanionProvider,
tableDataCoordinator.entityConfiguration,
entityClass,
privacyPolicy,
mutationValidators,
mutationTriggers,
entityCompanionDefinition.entityClass,
this.privacyPolicy,
entityCompanionDefinition.mutationValidators ?? [],
entityCompanionDefinition.mutationTriggers ?? {},
this.entityLoaderFactory,
tableDataCoordinator.databaseAdapter,
metricsAdapter
@@ -132,7 +113,7 @@ export default class EntityCompanion<
}
/**
* Get the {@link IEntityMetricsAdapter} for this companion.
* Get the IEntityMetricsAdapter for this companion.
*/
getMetricsAdapter(): IEntityMetricsAdapter {
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
* used to power the entity framework for a particular type of entity.
*/
export class EntityCompanionDefinition<
TFields,
export interface EntityCompanionDefinition<
TFields extends object,
TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
@@ -63,6 +63,9 @@ export class EntityCompanionDefinition<
>,
TSelectedFields extends keyof TFields = keyof TFields
> {
/**
* The concrete Entity class for which this is the definition.
*/
readonly entityClass: IEntityClass<
TFields,
TID,
@@ -71,97 +74,61 @@ export class EntityCompanionDefinition<
TPrivacyPolicy,
TSelectedFields
>;
/**
* The EntityConfiguration for this entity.
*/
readonly entityConfiguration: EntityConfiguration<TFields>;
/**
* The EntityPrivacyPolicy class for this entity.
*/
readonly privacyPolicyClass: IPrivacyPolicyClass<TPrivacyPolicy>;
readonly mutationValidators: () => EntityMutationValidator<
/**
* An optional list of EntityMutationValidator for this entity.
*/
readonly mutationValidators?: EntityMutationValidator<
TFields,
TID,
TViewerContext,
TEntity,
TSelectedFields
>[];
readonly mutationTriggers: () => EntityMutationTriggerConfiguration<
/**
* An optional list of EntityMutationTrigger for this entity.
*/
readonly mutationTriggers?: EntityMutationTriggerConfiguration<
TFields,
TID,
TViewerContext,
TEntity,
TSelectedFields
>;
readonly entitySelectedFields: TSelectedFields[];
constructor({
entityClass,
entityConfiguration,
privacyPolicyClass,
mutationValidators = () => [],
mutationTriggers = () => ({}),
entitySelectedFields = Array.from(entityConfiguration.schema.keys()) as TSelectedFields[],
}: {
/**
* The concrete Entity class for which this is the definition.
*/
entityClass: IEntityClass<
TFields,
TID,
TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
>;
/**
* The {@link EntityConfiguration} for this entity.
*/
entityConfiguration: EntityConfiguration<TFields>;
/**
* The {@link EntityPrivacyPolicy} class for this entity.
*/
privacyPolicyClass: IPrivacyPolicyClass<TPrivacyPolicy>;
/**
* An optional list of {@link EntityMutationValidator} for this entity.
*/
mutationValidators?: () => EntityMutationValidator<
TFields,
TID,
TViewerContext,
TEntity,
TSelectedFields
>[];
/**
* An optional list of {@link EntityMutationTrigger} for this entity.
*/
mutationTriggers?: () => EntityMutationTriggerConfiguration<
TFields,
TID,
TViewerContext,
TEntity,
TSelectedFields
>;
/**
* An optional subset of fields defined in the {@link EntityConfiguration} which belong to this entity.
* For use when multiple types of entities are backed by a single table ({@link EntityConfiguration}) yet
* only expose a subset of the fields.
*/
entitySelectedFields?: TSelectedFields[];
}) {
this.entityClass = entityClass;
this.entityConfiguration = entityConfiguration;
this.privacyPolicyClass = privacyPolicyClass;
this.mutationValidators = mutationValidators;
this.mutationTriggers = mutationTriggers;
this.entitySelectedFields = entitySelectedFields;
}
/**
* An optional subset of fields defined in the EntityConfiguration which belong to this entity.
* For use when multiple types of entities are backed by a single table (EntityConfiguration) yet
* only expose a subset of the fields.
*/
readonly entitySelectedFields?: TSelectedFields[];
}
/**
* 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.
*
* 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 {
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>> =
new Map();
private readonly tableDataCoordinatorMap: Map<string, EntityTableDataCoordinator<any>> =
@@ -169,7 +136,7 @@ export default class EntityCompanionProvider {
/**
* 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 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.
*
* @param entityClass - entity class to load
* @param factory - entity companion factory
*/
getCompanionForEntity<
TFields,
TFields extends object,
TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
@@ -210,27 +176,22 @@ export default class EntityCompanionProvider {
TEntity,
TPrivacyPolicy,
TSelectedFields
>,
entityCompanionDefinition: EntityCompanionDefinition<
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(
entityCompanionDefinition.entityConfiguration,
entityClass.name
);
return computeIfAbsent(this.companionMap, entityClass.name, () => {
return new EntityCompanion(
entityCompanionDefinition.entityClass,
this,
entityCompanionDefinition,
tableDataCoordinator,
entityCompanionDefinition.privacyPolicyClass,
entityCompanionDefinition.mutationValidators(),
entityCompanionDefinition.mutationTriggers(),
this.metricsAdapter
);
});
+6 -7
View File
@@ -13,7 +13,7 @@ export default class EntityConfiguration<TFields> {
readonly cacheableKeys: ReadonlySet<keyof TFields>;
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 entityToDBFieldsKeyMapping: ReadonlyMap<keyof TFields, string>;
readonly dbToEntityFieldsKeyMapping: ReadonlyMap<string, keyof TFields>;
@@ -25,7 +25,7 @@ export default class EntityConfiguration<TFields> {
idField,
tableName,
schema,
getInboundEdges = () => [],
inboundEdges = [],
cacheKeyVersion = 0,
databaseAdapterFlavor,
cacheAdapterFlavor,
@@ -41,14 +41,14 @@ export default class EntityConfiguration<TFields> {
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>>;
/**
* 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
@@ -71,8 +71,7 @@ export default class EntityConfiguration<TFields> {
this.cacheKeyVersion = cacheKeyVersion;
this.databaseAdapterFlavor = databaseAdapterFlavor;
this.cacheAdapterFlavor = cacheAdapterFlavor;
this.getInboundEdges = getInboundEdges;
this.inboundEdges = inboundEdges;
// external schema is a Record to typecheck that all fields have FieldDefinitions,
// but internally the most useful representation is a map for lookups
Loaded 100 of 186 files, more files were not shown because too many files have changed in this diff. Show more