Compare commits

...
Author SHA1 Message Date
Will Schurman a067a44230 v0.27.0 (#190) 2022-08-09 13:07:40 -07:00
Will Schurman bffa7ca870 ci: fix typedoc (#189) 2022-08-09 12:35:38 -07:00
Will Schurman 847c20fabe ci: fix node version in docs action (#188) 2022-08-09 12:09:39 -07:00
Will Schurman 8a98f35b91 chore: upgrade lerna (#187) 2022-08-09 11:41:15 -07:00
Will Schurman 7829d90f4b ci: fix github actions docs workflow branch (#186) 2022-08-09 11:06:36 -07:00
Juwan Wheatley 2817d78392 feat: add orderByRaw to loadManyByRawWhereClauseAsync (#185)
* feat: add orderByRaw to querySelectionModifiers

* implement PR feedback

* fix tsc

* restrict orderByRaw to loadManyByRawWhereClauseAsync

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

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

* Update packages/entity/src/ComposedEntityCacheAdapter.ts

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

* pr feedback

* Update packages/entity/src/ComposedEntityCacheAdapter.ts

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

* pr feedback

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

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

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

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

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

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

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

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

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

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

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

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

* some pr feedback

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

* fix lint

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

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

* integ tests

* fix codecov
2022-02-07 15:04:02 -08:00
113 changed files with 7748 additions and 4734 deletions

No files matched your search

+3 -3
View File
@@ -3,16 +3,16 @@ name: docs
on: on:
push: push:
branches: branches:
- master - main
jobs: jobs:
deploy: deploy:
runs-on: ubuntu-18.04 runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- uses: actions/setup-node@v1 - uses: actions/setup-node@v1
with: with:
node-version: 12.x node-version: 18.x
- name: Cache Node.js modules - name: Cache Node.js modules
uses: actions/cache@v1 uses: actions/cache@v1
with: with:
+2 -2
View File
@@ -2,7 +2,7 @@ name: tests
on: on:
push: push:
branches: [ master ] branches: [ main ]
pull_request: pull_request:
jobs: jobs:
@@ -14,7 +14,7 @@ jobs:
ref: ${{ github.event.pull_request.head.sha }} ref: ${{ github.event.pull_request.head.sha }}
- uses: actions/setup-node@v1 - uses: actions/setup-node@v1
with: with:
node-version: 12.x node-version: 18.x
- name: Cache Node.js modules - name: Cache Node.js modules
uses: actions/cache@v1 uses: actions/cache@v1
with: with:
+108
View File
@@ -3,6 +3,114 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.27.0](https://github.com/expo/entity/compare/v0.26.1...v0.27.0) (2022-08-09)
### Features
* add orderByRaw to loadManyByRawWhereClauseAsync ([#185](https://github.com/expo/entity/issues/185)) ([2817d78](https://github.com/expo/entity/commit/2817d78392dfecd0093111c24d581bdf01dba06d))
## [0.26.1](https://github.com/expo/entity/compare/v0.26.0...v0.26.1) (2022-07-28)
### Features
* recache to higher cache levels when loading from a composed cache ([#182](https://github.com/expo/entity/pull/182))
# [0.26.0](https://github.com/expo/entity/compare/v0.25.3...v0.26.0) (2022-07-14)
### Bug Fixes
* fix workflow branch specification ([#179](https://github.com/expo/entity/issues/179)) ([25d14bf](https://github.com/expo/entity/commit/25d14bf8c1688524ad488aa2b63221393a4f0b05))
### Features
* add EntityEdgeDeletionBehavior.SET_NULL_INVALIDATE_CACHE_ONLY ([#178](https://github.com/expo/entity/issues/178)) ([a0c35dd](https://github.com/expo/entity/commit/a0c35ddd7bca05d0c3f41dcf48e16d058918231c))
* update ioredis to v5, make it a devDependency of the cache packages ([#172](https://github.com/expo/entity/issues/172)) ([9fcfbf7](https://github.com/expo/entity/commit/9fcfbf746a61554cefedf7fb0f1a3a9056b58ffe))
## [0.25.3](https://github.com/expo/entity/compare/v0.25.2...v0.25.3) (2022-03-12)
### Bug Fixes
* lock lru-cache to v6.0.0 ([#170](https://github.com/expo/entity/issues/170)) ([293868b](https://github.com/expo/entity/commit/293868b1a73df9f749216af68b1ae94ac22ac2b9))
## [0.25.2](https://github.com/expo/entity/compare/v0.25.1...v0.25.2) (2022-03-12)
### Bug Fixes
* lru-cache yarn workspace version resolution ([#168](https://github.com/expo/entity/issues/168)) ([e76ca8c](https://github.com/expo/entity/commit/e76ca8c2f72da84aa372a64ef1898ade220ed0a8))
## [0.25.1](https://github.com/expo/entity/compare/v0.25.0...v0.25.1) (2022-03-10)
### Bug Fixes
* add composed cache to index ([#166](https://github.com/expo/entity/issues/166)) ([5c82edc](https://github.com/expo/entity/commit/5c82edcd9f26854878d29950a05852e117bbd723))
# [0.25.0](https://github.com/expo/entity/compare/v0.24.0...v0.25.0) (2022-03-10)
### Features
* composed cache adapter, composed secondary cache, local memory secondary cache ([#164](https://github.com/expo/entity/issues/164)) ([6b6e70e](https://github.com/expo/entity/commit/6b6e70e75f7e42a4dce13edeb406a4d88d9264f0))
* privacy policy evaluation context ([#162](https://github.com/expo/entity/issues/162)) ([3a81a4c](https://github.com/expo/entity/commit/3a81a4c059e2e995eb800e35e76b3d432c22b23f))
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
### Features
* add local memory cache package ([#158](https://github.com/expo/entity/issues/158)) ([85d3575](https://github.com/expo/entity/commit/85d35752fa0919a62efb776a762a6829e83ca7af))
### Chores
* Refactor cachers to use an abstract class and interface ([#157](https://github.com/expo/entity/pull/157))
# [0.23.0](https://github.com/expo/entity/compare/v0.22.0...v0.23.0) (2022-02-09)
### Breaking changes
* Move data transforming responsibilities to the CacheAdapter instead of the ReadThroughEntityCache. Affects any classes extending the EntityCacheAdapter and relying on the transformation behavior of the ReadThroughEntityCache. ([#153](https://github.com/expo/entity/pull/153))
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04) # [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
+3 -3
View File
@@ -4,7 +4,7 @@ Entity is a privacy-aware data layer for defining, caching, and authorizing acce
[![tests](https://github.com/expo/entity/workflows/tests/badge.svg)](https://github.com/expo/entity/actions?query=workflow%3Atests) [![tests](https://github.com/expo/entity/workflows/tests/badge.svg)](https://github.com/expo/entity/actions?query=workflow%3Atests)
[![docs](https://github.com/expo/entity/workflows/docs/badge.svg)](https://expo.github.io/entity/) [![docs](https://github.com/expo/entity/workflows/docs/badge.svg)](https://expo.github.io/entity/)
[![codecov](https://codecov.io/gh/expo/entity/branch/master/graph/badge.svg)](https://codecov.io/gh/expo/entity) [![codecov](https://codecov.io/gh/expo/entity/branch/main/graph/badge.svg)](https://codecov.io/gh/expo/entity)
[![npm](https://img.shields.io/npm/v/@expo/entity)](https://www.npmjs.com/package/@expo/entity) [![npm](https://img.shields.io/npm/v/@expo/entity)](https://www.npmjs.com/package/@expo/entity)
[![NPM](https://img.shields.io/npm/l/@expo/entity)](https://www.npmjs.com/package/@expo/entity) [![NPM](https://img.shields.io/npm/l/@expo/entity)](https://www.npmjs.com/package/@expo/entity)
@@ -82,10 +82,10 @@ Note: The entity framework instance should not be shared across multiple request
To release a new version, let's say `v1.0.0` for example: To release a new version, let's say `v1.0.0` for example:
1. `git checkout -b bump-v1.0.0` 1. `git checkout -b bump-v1.0.0`
1. `yarn lerna version minor -- --no-push --conventional-commits` 1. `yarn lerna version [patch|minor|major] -- --no-push --conventional-commits`
1. `git push -u origin bump-v1.0.0` 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. 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 checkout main && git pull`
1. `git tag -d v1.0.0` 1. `git tag -d v1.0.0`
1. `git tag v1.0.0` 1. `git tag v1.0.0`
1. `git push origin v1.0.0` 1. `git push origin v1.0.0`
+1 -1
View File
@@ -4,5 +4,5 @@
], ],
"npmClient": "yarn", "npmClient": "yarn",
"useWorkspaces": true, "useWorkspaces": true,
"version": "0.22.0" "version": "0.27.0"
} }
+11 -7
View File
@@ -19,9 +19,9 @@
"@expo/entity": "file:packages/entity", "@expo/entity": "file:packages/entity",
"@expo/results": "^1.0.0", "@expo/results": "^1.0.0",
"@types/invariant": "^2.2.33", "@types/invariant": "^2.2.33",
"@types/ioredis": "^4.26.4",
"@types/jest": "^26.0.10", "@types/jest": "^26.0.10",
"@types/jsbn": "^1.2.29", "@types/jsbn": "^1.2.29",
"@types/lru-cache": "^5.1.1",
"@types/node": "^14.6.0", "@types/node": "^14.6.0",
"@types/uuid": "^8.3.0", "@types/uuid": "^8.3.0",
"@typescript-eslint/eslint-plugin": "^4.14.0", "@typescript-eslint/eslint-plugin": "^4.14.0",
@@ -29,23 +29,27 @@
"barrelsby": "^2.2.0", "barrelsby": "^2.2.0",
"eslint": "^7.7.0", "eslint": "^7.7.0",
"eslint-config-universe": "^7.0.0", "eslint-config-universe": "^7.0.0",
"eslint-plugin-tsdoc": "^0.2.11", "eslint-plugin-tsdoc": "^0.2.16",
"ioredis": "^5.0.4",
"jest": "^26.6.3", "jest": "^26.6.3",
"lerna": "^4.0.0", "lerna": "^5.4.0",
"lru-cache": "^6.0.0",
"nullthrows": "^1.1.1", "nullthrows": "^1.1.1",
"pg": "^8.6.0", "pg": "^8.6.0",
"prettier": "^2.3.2", "prettier": "^2.3.2",
"ts-jest": "~26.4.4", "ts-jest": "~26.4.4",
"ts-mockito": "^2.6.1", "ts-mockito": "^2.6.1",
"typedoc": "^0.21.9", "typedoc": "^0.23.10",
"typescript": "^4.4.2", "typescript": "^4.6.3",
"uuid": "^8.3.0" "uuid": "^8.3.0"
}, },
"dependencies": { "dependencies": {
"@expo/entity": "file:packages/entity", "@expo/entity": "file:packages/entity",
"@expo/entity-cache-adapter-local-memory": "file:packages/entity-cache-adapter-local-memory",
"@expo/entity-cache-adapter-redis": "file:packages/entity-cache-adapter-redis", "@expo/entity-cache-adapter-redis": "file:packages/entity-cache-adapter-redis",
"@expo/entity-database-adapter-knex": "file:packages/entity-database-adapter-knex", "@expo/entity-database-adapter-knex": "file:packages/entity-database-adapter-knex",
"@expo/entity-secondary-cache-redis": "file:packages/entity-secondary-cache-redis", "@expo/entity-ip-address-field": "file:packages/entity-ip-address-field",
"@expo/entity-ip-address-field": "file:packages/entity-ip-address-field" "@expo/entity-secondary-cache-local-memory": "file:packages/entity-secondary-cache-local-memory",
"@expo/entity-secondary-cache-redis": "file:packages/entity-secondary-cache-redis"
} }
} }
@@ -0,0 +1,76 @@
# Change Log
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.27.0](https://github.com/expo/entity/compare/v0.26.1...v0.27.0) (2022-08-09)
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
## [0.26.1](https://github.com/expo/entity/compare/v0.26.0...v0.26.1) (2022-07-28)
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
# [0.26.0](https://github.com/expo/entity/compare/v0.25.3...v0.26.0) (2022-07-14)
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
## [0.25.3](https://github.com/expo/entity/compare/v0.25.2...v0.25.3) (2022-03-12)
### Bug Fixes
* lock lru-cache to v6.0.0 ([#170](https://github.com/expo/entity/issues/170)) ([293868b](https://github.com/expo/entity/commit/293868b1a73df9f749216af68b1ae94ac22ac2b9))
## [0.25.2](https://github.com/expo/entity/compare/v0.25.1...v0.25.2) (2022-03-12)
### Bug Fixes
* lru-cache yarn workspace version resolution ([#168](https://github.com/expo/entity/issues/168)) ([e76ca8c](https://github.com/expo/entity/commit/e76ca8c2f72da84aa372a64ef1898ade220ed0a8))
## [0.25.1](https://github.com/expo/entity/compare/v0.25.0...v0.25.1) (2022-03-10)
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
# [0.25.0](https://github.com/expo/entity/compare/v0.24.0...v0.25.0) (2022-03-10)
### Features
* composed cache adapter, composed secondary cache, local memory secondary cache ([#164](https://github.com/expo/entity/issues/164)) ([6b6e70e](https://github.com/expo/entity/commit/6b6e70e75f7e42a4dce13edeb406a4d88d9264f0))
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
### Features
* add local memory cache package ([#158](https://github.com/expo/entity/issues/158)) ([85d3575](https://github.com/expo/entity/commit/85d35752fa0919a62efb776a762a6829e83ca7af))
@@ -0,0 +1,49 @@
# `@expo/entity-cache-adapter-local-memory`
Cross-request [LRU](https://github.com/isaacs/node-lru-cache) cache adapter for `@expo/entity`. Use
this cache with caution - it is nonstandard. The cache is shared between requests in the node process.
Note: This uses version 6.0.0 of `node-lru-cache` since it the most tuned version for our use case (low TTL + LRU). Upgrading
to 7.x will cause high memory usage for the entity cache adapter use case since it allocates fixed-size data structures up front to tune for the non-TTL use case: https://github.com/isaacs/node-lru-cache/issues/208.
[Documentation](https://expo.github.io/entity/modules/_expo_cache_adapter_local_memory.html)
## Why NOT use this cache
Because this is an in-memory cache, cross-machine invalidation is not possible. Do not use this cache
if you have the following use cases:
- The objects stored are mutable
- Reading a stale object from the cache is not acceptable in your application
- Cross-machine invalidation is not possible
## Typical use cases
If your application sees many requests fetching the same objects, you can save a trip to your cache
cluster and backing datastore by using this in-memory cache. Here are some good use cases:
- The objects stored are mostly immutable, and reading a stale object for a short TTL is acceptable
- You have a low TTL setting in your cache
## Usage
During `EntityCompanionProvider` instantiation:
```typescript
export const createDefaultEntityCompanionProvider = (
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter()
): EntityCompanionProvider => {
return new EntityCompanionProvider(
metricsAdapter,
{
...
},
{
['local-memory']: {
cacheAdapterProvider: new LocalMemoryCacheAdapterProvider.getProvider(),
},
}
);
};
```
@@ -0,0 +1,38 @@
{
"name": "@expo/entity-cache-adapter-local-memory",
"version": "0.27.0",
"description": "Cross-request local memory cache adapter for @expo/entity",
"files": [
"build",
"src"
],
"main": "build/index.js",
"types": "build/index.d.ts",
"scripts": {
"tsc": "tsc",
"clean": "rm -rf build coverage coverage-integration",
"lint": "eslint src",
"lint-fix": "eslint src --fix",
"test": "jest --rootDir . --config ../../resources/jest.config.js",
"integration": "../../resources/run-with-docker yarn integration-no-setup",
"integration-no-setup": "jest --config ../../resources/jest-integration.config.js --rootDir . --runInBand --passWithNoTests",
"barrelsby": "barrelsby --directory src --location top --exclude tests__ --singleQuotes --exportDefault --delete"
},
"engines": {
"node": ">=12"
},
"keywords": [
"entity"
],
"author": "Expo",
"license": "MIT",
"peerDependencies": {
"@expo/entity": "*"
},
"dependencies": {
"lru-cache": "^6.0.0"
},
"devDependencies": {
"@expo/entity": "^0.27.0"
}
}
@@ -0,0 +1,82 @@
import { CacheLoadResult, CacheStatus, IEntityGenericCacher } from '@expo/entity';
import LRUCache from 'lru-cache';
// Sentinel value we store in local memory to negatively cache a database miss.
// The sentinel value is distinct from any (positively) cached value.
export const DOES_NOT_EXIST_LOCAL_MEMORY_CACHE = Symbol('doesNotExist');
type LocalMemoryCacheValue<TFields> = Readonly<TFields> | typeof DOES_NOT_EXIST_LOCAL_MEMORY_CACHE;
export type LocalMemoryCache<TFields> = LRUCache<string, LocalMemoryCacheValue<TFields>>;
export default class GenericLocalMemoryCacher<TFields> implements IEntityGenericCacher<TFields> {
constructor(private readonly localMemoryCache: LocalMemoryCache<TFields>) {}
static createLRUCache<TFields>(
options: { maxSize?: number; ttlSeconds?: number } = {}
): LocalMemoryCache<TFields> {
const DEFAULT_LRU_CACHE_MAX_AGE_SECONDS = 10;
const DEFAULT_LRU_CACHE_SIZE = 10000;
const maxAgeSeconds = options.ttlSeconds ?? DEFAULT_LRU_CACHE_MAX_AGE_SECONDS;
return new LRUCache<string, LocalMemoryCacheValue<TFields>>({
max: options.maxSize ?? DEFAULT_LRU_CACHE_SIZE,
length: (value) => (value === DOES_NOT_EXIST_LOCAL_MEMORY_CACHE ? 0 : 1),
maxAge: maxAgeSeconds * 1000, // convert to ms
});
}
static createNoOpCache<TFields>(): LocalMemoryCache<TFields> {
return new LRUCache<string, LocalMemoryCacheValue<TFields>>({
max: 0,
maxAge: -1,
});
}
public async loadManyAsync(
keys: readonly string[]
): Promise<ReadonlyMap<string, CacheLoadResult<TFields>>> {
const cacheResults = new Map<string, CacheLoadResult<TFields>>();
for (const key of keys) {
const cacheResult = this.localMemoryCache.get(key);
if (cacheResult === DOES_NOT_EXIST_LOCAL_MEMORY_CACHE) {
cacheResults.set(key, {
status: CacheStatus.NEGATIVE,
});
} else if (cacheResult) {
cacheResults.set(key, {
status: CacheStatus.HIT,
item: cacheResult as unknown as TFields,
});
} else {
cacheResults.set(key, {
status: CacheStatus.MISS,
});
}
}
return cacheResults;
}
public async cacheManyAsync(objectMap: ReadonlyMap<string, Readonly<TFields>>): Promise<void> {
for (const [key, item] of objectMap) {
this.localMemoryCache.set(key, item);
}
}
public async cacheDBMissesAsync(keys: readonly string[]): Promise<void> {
for (const key of keys) {
this.localMemoryCache.set(key, DOES_NOT_EXIST_LOCAL_MEMORY_CACHE);
}
}
public async invalidateManyAsync(keys: readonly string[]): Promise<void> {
for (const key of keys) {
this.localMemoryCache.del(key);
}
}
public makeCacheKey(parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`)
);
return escapedParts.join(delimiter);
}
}
@@ -0,0 +1,80 @@
import { EntityCacheAdapter, CacheLoadResult, EntityConfiguration, mapKeys } from '@expo/entity';
import invariant from 'invariant';
import GenericLocalMemoryCacher, { LocalMemoryCache } from './GenericLocalMemoryCacher';
export default class LocalMemoryCacheAdapter<TFields> extends EntityCacheAdapter<TFields> {
private readonly genericLocalMemoryCacher: GenericLocalMemoryCacher<TFields>;
constructor(
entityConfiguration: EntityConfiguration<TFields>,
localMemoryCache: LocalMemoryCache<TFields>
) {
super(entityConfiguration);
this.genericLocalMemoryCacher = new GenericLocalMemoryCacher(localMemoryCache);
}
public async loadManyAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult<TFields>>> {
const localMemoryCacheKeyToFieldValueMapping = new Map(
fieldValues.map((fieldValue) => [this.makeCacheKey(fieldName, fieldValue), fieldValue])
);
const cacheResults = await this.genericLocalMemoryCacher.loadManyAsync(
Array.from(localMemoryCacheKeyToFieldValueMapping.keys())
);
return mapKeys(cacheResults, (cacheKey) => {
const fieldValue = localMemoryCacheKeyToFieldValueMapping.get(cacheKey);
invariant(
fieldValue !== undefined,
'Unspecified cache key %s returned from generic local memory cacher',
cacheKey
);
return fieldValue;
});
}
public async cacheManyAsync<N extends keyof TFields>(
fieldName: N,
objectMap: ReadonlyMap<NonNullable<TFields[N]>, Readonly<TFields>>
): Promise<void> {
await this.genericLocalMemoryCacher.cacheManyAsync(
mapKeys(objectMap, (fieldValue) => this.makeCacheKey(fieldName, fieldValue))
);
}
public async cacheDBMissesAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<void> {
await this.genericLocalMemoryCacher.cacheDBMissesAsync(
fieldValues.map((fieldValue) => this.makeCacheKey(fieldName, fieldValue))
);
}
public async invalidateManyAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<void> {
await this.genericLocalMemoryCacher.invalidateManyAsync(
fieldValues.map((fieldValue) => this.makeCacheKey(fieldName, fieldValue))
);
}
private makeCacheKey<N extends keyof TFields>(
fieldName: N,
fieldValue: NonNullable<TFields[N]>
): string {
const columnName = this.entityConfiguration.entityToDBFieldsKeyMapping.get(fieldName);
invariant(columnName, `database field mapping missing for ${String(fieldName)}`);
const parts = [
this.entityConfiguration.tableName,
`${this.entityConfiguration.cacheKeyVersion}`,
columnName,
String(fieldValue),
];
return this.genericLocalMemoryCacher.makeCacheKey(parts);
}
}
@@ -0,0 +1,45 @@
import {
computeIfAbsent,
EntityCacheAdapter,
EntityConfiguration,
IEntityCacheAdapterProvider,
} from '@expo/entity';
import GenericLocalMemoryCacher, { LocalMemoryCache } from './GenericLocalMemoryCacher';
import LocalMemoryCacheAdapter from './LocalMemoryCacheAdapter';
export default class LocalMemoryCacheAdapterProvider implements IEntityCacheAdapterProvider {
// local memory cache adapters should be shared/reused across requests
static localMemoryCacheAdapterMap = new Map<string, LocalMemoryCacheAdapter<any>>();
static getNoOpProvider(): IEntityCacheAdapterProvider {
return new LocalMemoryCacheAdapterProvider(<TFields>() =>
GenericLocalMemoryCacher.createNoOpCache<TFields>()
);
}
static getProvider(
options: { maxSize?: number; ttlSeconds?: number } = {}
): IEntityCacheAdapterProvider {
return new LocalMemoryCacheAdapterProvider(<TFields>() =>
GenericLocalMemoryCacher.createLRUCache<TFields>(options)
);
}
private constructor(
private readonly localMemoryCacheCreator: <TFields>() => LocalMemoryCache<TFields>
) {}
public getCacheAdapter<TFields>(
entityConfiguration: EntityConfiguration<TFields>
): EntityCacheAdapter<TFields> {
return computeIfAbsent(
LocalMemoryCacheAdapterProvider.localMemoryCacheAdapterMap,
entityConfiguration.tableName,
() => {
const localMemoryCache = this.localMemoryCacheCreator<TFields>();
return new LocalMemoryCacheAdapter(entityConfiguration, localMemoryCache);
}
);
}
}
@@ -0,0 +1,28 @@
import GenericLocalMemoryCacher from '../GenericLocalMemoryCacher';
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();
});
});
});
@@ -0,0 +1,174 @@
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 {
createLocalMemoryTestEntityCompanionProvider,
createNoopLocalMemoryIntegrationTestEntityCompanionProvider,
} from '../testfixtures/createLocalMemoryTestEntityCompanionProvider';
class TestViewerContext extends ViewerContext {}
describe(LocalMemoryCacheAdapter, () => {
beforeEach(async () => {
LocalMemoryCacheAdapterProvider.localMemoryCacheAdapterMap.clear();
});
it('has correct caching behavior', async () => {
const viewerContext = new TestViewerContext(createLocalMemoryTestEntityCompanionProvider());
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(createLocalMemoryTestEntityCompanionProvider());
const date = new Date();
const entity1Created = await LocalMemoryTestEntity.creator(viewerContext)
.setField('name', 'blah')
.setField('dateField', date)
.enforceCreateAsync();
// loading an entity should put it in cache
await LocalMemoryTestEntity.loader(viewerContext)
.enforcing()
.loadByIDAsync(entity1Created.getID());
// load entity with a different request
const viewerContext2 = new TestViewerContext(createLocalMemoryTestEntityCompanionProvider());
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,107 @@
import { CacheStatus, UUIDField, EntityConfiguration } from '@expo/entity';
import GenericLocalMemoryCacher, {
DOES_NOT_EXIST_LOCAL_MEMORY_CACHE,
} from '../GenericLocalMemoryCacher';
import LocalMemoryCacheAdapter from '../LocalMemoryCacheAdapter';
type BlahFields = {
id: string;
};
const entityConfiguration = new EntityConfiguration<BlahFields>({
idField: 'id',
tableName: 'blah',
schema: {
id: new UUIDField({ columnName: 'id', cache: true }),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'local-memory',
});
describe(LocalMemoryCacheAdapter, () => {
describe('loadManyAsync', () => {
it('returns appropriate cache results', async () => {
const cacheAdapter = new LocalMemoryCacheAdapter(
entityConfiguration,
GenericLocalMemoryCacher.createLRUCache()
);
const cacheHits = new Map<string, Readonly<BlahFields>>([['test-id-1', { id: 'test-id-1' }]]);
await cacheAdapter.cacheManyAsync('id', cacheHits);
await cacheAdapter.cacheDBMissesAsync('id', ['test-id-2']);
const results = await cacheAdapter.loadManyAsync('id', [
'test-id-1',
'test-id-2',
'test-id-3',
]);
expect(results.get('test-id-1')).toMatchObject({
status: CacheStatus.HIT,
item: { id: 'test-id-1' },
});
expect(results.get('test-id-2')).toMatchObject({ status: CacheStatus.NEGATIVE });
expect(results.get('test-id-3')).toMatchObject({ status: CacheStatus.MISS });
expect(results.size).toBe(3);
});
it('returns empty map when passed empty array of fieldValues', async () => {
const cacheAdapter = new LocalMemoryCacheAdapter(
entityConfiguration,
GenericLocalMemoryCacher.createLRUCache()
);
const results = await cacheAdapter.loadManyAsync('id', []);
expect(results).toEqual(new Map());
});
});
describe('cacheManyAsync', () => {
it('correctly caches all objects', async () => {
const localMemoryCache = GenericLocalMemoryCacher.createLRUCache<BlahFields>({});
const cacheAdapter = new LocalMemoryCacheAdapter(entityConfiguration, localMemoryCache);
await cacheAdapter.cacheManyAsync('id', new Map([['test-id-1', { id: 'test-id-1' }]]));
const cacheKey = cacheAdapter['makeCacheKey']('id', 'test-id-1');
expect(localMemoryCache.get(cacheKey)).toMatchObject({
id: 'test-id-1',
});
});
});
describe('cacheDBMissesAsync', () => {
it('correctly caches misses', async () => {
const localMemoryCache = GenericLocalMemoryCacher.createLRUCache<BlahFields>({});
const cacheAdapter = new LocalMemoryCacheAdapter(entityConfiguration, localMemoryCache);
await cacheAdapter.cacheDBMissesAsync('id', ['test-id-1']);
const cacheKey = cacheAdapter['makeCacheKey']('id', 'test-id-1');
expect(localMemoryCache.get(cacheKey)).toEqual(DOES_NOT_EXIST_LOCAL_MEMORY_CACHE);
});
});
describe('invalidateManyAsync', () => {
it('invalidates correctly', async () => {
const localMemoryCache = GenericLocalMemoryCacher.createLRUCache<BlahFields>({});
const cacheAdapter = new LocalMemoryCacheAdapter(entityConfiguration, localMemoryCache);
await cacheAdapter.cacheManyAsync('id', new Map([['test-id-1', { id: 'test-id-1' }]]));
await cacheAdapter.cacheDBMissesAsync('id', ['test-id-2']);
await cacheAdapter.invalidateManyAsync('id', ['test-id-1', 'test-id-2']);
const results = await cacheAdapter.loadManyAsync('id', ['test-id-1', 'test-id-2']);
expect(results.get('test-id-1')).toMatchObject({ status: CacheStatus.MISS });
expect(results.get('test-id-2')).toMatchObject({ status: CacheStatus.MISS });
});
it('returns when passed empty array of fieldValues', async () => {
const cacheAdapter = new LocalMemoryCacheAdapter(
entityConfiguration,
GenericLocalMemoryCacher.createLRUCache<BlahFields>({})
);
await cacheAdapter.invalidateManyAsync('id', []);
});
});
});
@@ -0,0 +1,10 @@
/* eslint-disable tsdoc/syntax */
/**
* @packageDocumentation
* @module @expo/entity-cache-adapter-local-memory
*/
export { default as GenericLocalMemoryCacher } from './GenericLocalMemoryCacher';
export * from './GenericLocalMemoryCacher';
export { default as LocalMemoryCacheAdapter } from './LocalMemoryCacheAdapter';
export { default as LocalMemoryCacheAdapterProvider } from './LocalMemoryCacheAdapterProvider';
@@ -0,0 +1,100 @@
import {
AlwaysAllowPrivacyPolicyRule,
EntityPrivacyPolicy,
ViewerContext,
UUIDField,
DateField,
StringField,
EntityConfiguration,
EntityCompanionDefinition,
Entity,
} from '@expo/entity';
export type LocalMemoryTestEntityFields = {
id: string;
name: string;
dateField: Date | null;
};
export default class LocalMemoryTestEntity extends Entity<
LocalMemoryTestEntityFields,
string,
ViewerContext
> {
static getCompanionDefinition(): EntityCompanionDefinition<
LocalMemoryTestEntityFields,
string,
ViewerContext,
LocalMemoryTestEntity,
LocalMemoryTestEntityPrivacyPolicy
> {
return localMemoryTestEntityCompanionDefinition;
}
}
export class LocalMemoryTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
LocalMemoryTestEntityFields,
string,
ViewerContext,
LocalMemoryTestEntity
> {
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<
LocalMemoryTestEntityFields,
string,
ViewerContext,
LocalMemoryTestEntity
>(),
];
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<
LocalMemoryTestEntityFields,
string,
ViewerContext,
LocalMemoryTestEntity
>(),
];
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<
LocalMemoryTestEntityFields,
string,
ViewerContext,
LocalMemoryTestEntity
>(),
];
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<
LocalMemoryTestEntityFields,
string,
ViewerContext,
LocalMemoryTestEntity
>(),
];
}
export const localMemoryTestEntityConfiguration =
new EntityConfiguration<LocalMemoryTestEntityFields>({
idField: 'id',
tableName: 'local_memory_test_entities',
schema: {
id: new UUIDField({
columnName: 'id',
cache: true,
}),
name: new StringField({
columnName: 'name',
cache: true,
}),
dateField: new DateField({
columnName: 'date_field',
}),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'local-memory',
});
const localMemoryTestEntityCompanionDefinition = new EntityCompanionDefinition({
entityClass: LocalMemoryTestEntity,
entityConfiguration: localMemoryTestEntityConfiguration,
privacyPolicyClass: LocalMemoryTestEntityPrivacyPolicy,
});
@@ -0,0 +1,48 @@
import {
NoOpEntityMetricsAdapter,
IEntityMetricsAdapter,
EntityCompanionProvider,
StubQueryContextProvider,
StubDatabaseAdapterProvider,
} from '@expo/entity';
import LocalMemoryCacheAdapterProvider from '../LocalMemoryCacheAdapterProvider';
export const createLocalMemoryTestEntityCompanionProvider = (
localMemoryOptions: { maxSize?: number; ttlSeconds?: number } = {},
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter()
): EntityCompanionProvider => {
const localMemoryCacheAdapterProvider =
localMemoryOptions.maxSize === 0 && localMemoryOptions.ttlSeconds === 0
? LocalMemoryCacheAdapterProvider.getNoOpProvider()
: LocalMemoryCacheAdapterProvider.getProvider(localMemoryOptions);
return new EntityCompanionProvider(
metricsAdapter,
new Map([
[
'postgres',
{
adapterProvider: new StubDatabaseAdapterProvider(),
queryContextProvider: StubQueryContextProvider,
},
],
]),
new Map([
[
'local-memory',
{
cacheAdapterProvider: localMemoryCacheAdapterProvider,
},
],
])
);
};
export const createNoopLocalMemoryIntegrationTestEntityCompanionProvider = (
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter()
): EntityCompanionProvider => {
return createLocalMemoryTestEntityCompanionProvider(
{ maxSize: 0, ttlSeconds: 0 },
metricsAdapter
);
};
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "build",
},
"include": ["src"]
}
@@ -3,6 +3,84 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.27.0](https://github.com/expo/entity/compare/v0.26.1...v0.27.0) (2022-08-09)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
## [0.26.1](https://github.com/expo/entity/compare/v0.26.0...v0.26.1) (2022-07-28)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.26.0](https://github.com/expo/entity/compare/v0.25.3...v0.26.0) (2022-07-14)
### Features
* update ioredis to v5, make it a devDependency of the cache packages ([#172](https://github.com/expo/entity/issues/172)) ([9fcfbf7](https://github.com/expo/entity/commit/9fcfbf746a61554cefedf7fb0f1a3a9056b58ffe))
## [0.25.3](https://github.com/expo/entity/compare/v0.25.2...v0.25.3) (2022-03-12)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
## [0.25.2](https://github.com/expo/entity/compare/v0.25.1...v0.25.2) (2022-03-12)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
## [0.25.1](https://github.com/expo/entity/compare/v0.25.0...v0.25.1) (2022-03-10)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.25.0](https://github.com/expo/entity/compare/v0.24.0...v0.25.0) (2022-03-10)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
### Chores
* Refactor cachers to use an abstract class and interface ([#157](https://github.com/expo/entity/pull/157))
# [0.23.0](https://github.com/expo/entity/compare/v0.22.0...v0.23.0) (2022-02-09)
### Chores
* Move data transforming responsibilities to the CacheAdapter instead of the ReadThroughEntityCache. ([#153](https://github.com/expo/entity/pull/153))
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04) # [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis **Note:** Version bump only for package @expo/entity-cache-adapter-redis
@@ -1,6 +1,6 @@
{ {
"name": "@expo/entity-cache-adapter-redis", "name": "@expo/entity-cache-adapter-redis",
"version": "0.22.0", "version": "0.27.0",
"description": "Redis cache adapter for @expo/entity", "description": "Redis cache adapter for @expo/entity",
"files": [ "files": [
"build", "build",
@@ -27,12 +27,11 @@
"author": "Expo", "author": "Expo",
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"@expo/entity": "*" "@expo/entity": "*",
}, "ioredis": ">=5"
"dependencies": {
"ioredis": "^4.27.3"
}, },
"devDependencies": { "devDependencies": {
"@expo/entity": "^0.22.0" "@expo/entity": "^0.27.0",
"ioredis": "^5.0.4"
} }
} }
@@ -1,6 +1,14 @@
import { CacheLoadResult, CacheStatus } from '@expo/entity'; import {
CacheLoadResult,
CacheStatus,
EntityConfiguration,
transformCacheObjectToFields,
transformFieldsToCacheObject,
IEntityGenericCacher,
} from '@expo/entity';
import { Redis } from 'ioredis'; import { Redis } from 'ioredis';
import { redisTransformerMap } from './RedisCommon';
import wrapNativeRedisCallAsync from './errors/wrapNativeRedisCallAsync'; import wrapNativeRedisCallAsync from './errors/wrapNativeRedisCallAsync';
// Sentinel value we store in Redis to negatively cache a database miss. // Sentinel value we store in Redis to negatively cache a database miss.
@@ -26,12 +34,15 @@ export interface GenericRedisCacheContext {
ttlSecondsNegative: number; ttlSecondsNegative: number;
} }
export default class GenericRedisCacher { export default class GenericRedisCacher<TFields> implements IEntityGenericCacher<TFields> {
constructor(private readonly context: GenericRedisCacheContext) {} constructor(
private readonly context: GenericRedisCacheContext,
private readonly entityConfiguration: EntityConfiguration<TFields>
) {}
public async loadManyAsync( public async loadManyAsync(
keys: readonly string[] keys: readonly string[]
): Promise<ReadonlyMap<string, CacheLoadResult>> { ): Promise<ReadonlyMap<string, CacheLoadResult<TFields>>> {
if (keys.length === 0) { if (keys.length === 0) {
return new Map(); return new Map();
} }
@@ -40,7 +51,7 @@ export default class GenericRedisCacher {
this.context.redisClient.mget(...keys) this.context.redisClient.mget(...keys)
); );
const results = new Map<string, CacheLoadResult>(); const results = new Map<string, CacheLoadResult<TFields>>();
for (let i = 0; i < keys.length; i++) { for (let i = 0; i < keys.length; i++) {
const key = keys[i]!; const key = keys[i]!;
const redisResult = redisResults[i]; const redisResult = redisResults[i];
@@ -52,7 +63,11 @@ export default class GenericRedisCacher {
} else if (redisResult) { } else if (redisResult) {
results.set(key, { results.set(key, {
status: CacheStatus.HIT, status: CacheStatus.HIT,
item: JSON.parse(redisResult), item: transformCacheObjectToFields(
this.entityConfiguration,
redisTransformerMap,
JSON.parse(redisResult)
),
}); });
} else { } else {
results.set(key, { results.set(key, {
@@ -63,7 +78,7 @@ export default class GenericRedisCacher {
return results; return results;
} }
public async cacheManyAsync(objectMap: ReadonlyMap<string, object>): Promise<void> { public async cacheManyAsync(objectMap: ReadonlyMap<string, Readonly<TFields>>): Promise<void> {
if (objectMap.size === 0) { if (objectMap.size === 0) {
return; return;
} }
@@ -72,7 +87,9 @@ export default class GenericRedisCacher {
objectMap.forEach((object, key) => { objectMap.forEach((object, key) => {
redisTransaction = redisTransaction.set( redisTransaction = redisTransaction.set(
key, key,
JSON.stringify(object), JSON.stringify(
transformFieldsToCacheObject(this.entityConfiguration, redisTransformerMap, object)
),
'EX', 'EX',
this.context.ttlSecondsPositive this.context.ttlSecondsPositive
); );
@@ -80,7 +97,7 @@ export default class GenericRedisCacher {
await wrapNativeRedisCallAsync(() => redisTransaction.exec()); await wrapNativeRedisCallAsync(() => redisTransaction.exec());
} }
public async cacheDBMissesAsync(keys: string[]): Promise<void> { public async cacheDBMissesAsync(keys: readonly string[]): Promise<void> {
if (keys.length === 0) { if (keys.length === 0) {
return; return;
} }
@@ -97,7 +114,7 @@ export default class GenericRedisCacher {
await wrapNativeRedisCallAsync(() => redisTransaction.exec()); await wrapNativeRedisCallAsync(() => redisTransaction.exec());
} }
public async invalidateManyAsync(keys: string[]): Promise<void> { public async invalidateManyAsync(keys: readonly string[]): Promise<void> {
if (keys.length === 0) { if (keys.length === 0) {
return; return;
} }
@@ -1,15 +1,8 @@
import { import { EntityCacheAdapter, EntityConfiguration, CacheLoadResult, mapKeys } from '@expo/entity';
EntityCacheAdapter,
EntityConfiguration,
CacheLoadResult,
FieldTransformerMap,
mapKeys,
} from '@expo/entity';
import invariant from 'invariant'; import invariant from 'invariant';
import { Redis } from 'ioredis'; import type { Redis } from 'ioredis';
import GenericRedisCacher from './GenericRedisCacher'; import GenericRedisCacher from './GenericRedisCacher';
import { redisTransformerMap } from './RedisCommon';
export interface RedisCacheAdapterContext { export interface RedisCacheAdapterContext {
/** /**
@@ -49,28 +42,27 @@ export interface RedisCacheAdapterContext {
} }
export default class RedisCacheAdapter<TFields> extends EntityCacheAdapter<TFields> { export default class RedisCacheAdapter<TFields> extends EntityCacheAdapter<TFields> {
private readonly genericRedisCacher: GenericRedisCacher; private readonly genericRedisCacher: GenericRedisCacher<TFields>;
constructor( constructor(
entityConfiguration: EntityConfiguration<TFields>, entityConfiguration: EntityConfiguration<TFields>,
private readonly context: RedisCacheAdapterContext private readonly context: RedisCacheAdapterContext
) { ) {
super(entityConfiguration); super(entityConfiguration);
this.genericRedisCacher = new GenericRedisCacher({ this.genericRedisCacher = new GenericRedisCacher(
{
redisClient: context.redisClient, redisClient: context.redisClient,
ttlSecondsNegative: context.ttlSecondsNegative, ttlSecondsNegative: context.ttlSecondsNegative,
ttlSecondsPositive: context.ttlSecondsPositive, ttlSecondsPositive: context.ttlSecondsPositive,
}); },
} entityConfiguration
);
public getFieldTransformerMap(): FieldTransformerMap {
return redisTransformerMap;
} }
public async loadManyAsync<N extends keyof TFields>( public async loadManyAsync<N extends keyof TFields>(
fieldName: N, fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[] fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult>> { ): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult<TFields>>> {
const redisCacheKeyToFieldValueMapping = new Map( const redisCacheKeyToFieldValueMapping = new Map(
fieldValues.map((fieldValue) => [this.makeCacheKey(fieldName, fieldValue), fieldValue]) fieldValues.map((fieldValue) => [this.makeCacheKey(fieldName, fieldValue), fieldValue])
); );
@@ -91,7 +83,7 @@ export default class RedisCacheAdapter<TFields> extends EntityCacheAdapter<TFiel
public async cacheManyAsync<N extends keyof TFields>( public async cacheManyAsync<N extends keyof TFields>(
fieldName: N, fieldName: N,
objectMap: ReadonlyMap<NonNullable<TFields[N]>, object> objectMap: ReadonlyMap<NonNullable<TFields[N]>, Readonly<TFields>>
): Promise<void> { ): Promise<void> {
await this.genericRedisCacher.cacheManyAsync( await this.genericRedisCacher.cacheManyAsync(
mapKeys(objectMap, (fieldValue) => this.makeCacheKey(fieldName, fieldValue)) mapKeys(objectMap, (fieldValue) => this.makeCacheKey(fieldName, fieldValue))
@@ -121,7 +113,7 @@ export default class RedisCacheAdapter<TFields> extends EntityCacheAdapter<TFiel
fieldValue: NonNullable<TFields[N]> fieldValue: NonNullable<TFields[N]>
): string { ): string {
const columnName = this.entityConfiguration.entityToDBFieldsKeyMapping.get(fieldName); const columnName = this.entityConfiguration.entityToDBFieldsKeyMapping.get(fieldName);
invariant(columnName, `database field mapping missing for ${fieldName}`); invariant(columnName, `database field mapping missing for ${String(fieldName)}`);
return this.context.makeKeyFn( return this.context.makeKeyFn(
this.context.cacheKeyPrefix, this.context.cacheKeyPrefix,
this.entityConfiguration.tableName, this.entityConfiguration.tableName,
@@ -0,0 +1,125 @@
import { CacheStatus, ViewerContext } from '@expo/entity';
import Redis from 'ioredis';
import { URL } from 'url';
import GenericRedisCacher from '../GenericRedisCacher';
import { RedisCacheAdapterContext } from '../RedisCacheAdapter';
import RedisTestEntity, {
redisTestEntityConfiguration,
RedisTestEntityFields,
} from '../testfixtures/RedisTestEntity';
import { createRedisIntegrationTestEntityCompanionProvider } from '../testfixtures/createRedisIntegrationTestEntityCompanionProvider';
class TestViewerContext extends ViewerContext {}
describe(GenericRedisCacher, () => {
let redisCacheAdapterContext: RedisCacheAdapterContext;
beforeAll(() => {
redisCacheAdapterContext = {
redisClient: new Redis(new URL(process.env['REDIS_URL']!).toString()),
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`)
);
return escapedParts.join(delimiter);
},
cacheKeyPrefix: 'test-',
cacheKeyVersion: 1,
ttlSecondsPositive: 86400, // 1 day
ttlSecondsNegative: 600, // 10 minutes
};
});
beforeEach(async () => {
await redisCacheAdapterContext.redisClient.flushdb();
});
afterAll(async () => {
redisCacheAdapterContext.redisClient.disconnect();
});
it('has correct caching and loading behavior', async () => {
const viewerContext = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
);
const genericRedisCacher = new GenericRedisCacher(
{
redisClient: redisCacheAdapterContext.redisClient,
ttlSecondsNegative: redisCacheAdapterContext.ttlSecondsNegative,
ttlSecondsPositive: redisCacheAdapterContext.ttlSecondsPositive,
},
redisTestEntityConfiguration
);
const date = new Date();
const entity1Created = await RedisTestEntity.creator(viewerContext)
.setField('name', 'blah')
.setField('dateField', date)
.enforceCreateAsync();
const testKey = `test-id-key-${entity1Created.getID()}`;
const objectMap = new Map<string, Readonly<RedisTestEntityFields>>([
[testKey, entity1Created.getAllFields()],
]);
await genericRedisCacher.cacheManyAsync(objectMap);
const cachedJSON = await redisCacheAdapterContext.redisClient.get(testKey);
const cachedValue = JSON.parse(cachedJSON!);
expect(cachedValue).toMatchObject({
id: entity1Created.getID(),
dateField: date.toISOString(),
});
const loadedObjectMap = await genericRedisCacher.loadManyAsync([testKey]);
const cacheLoadResult = loadedObjectMap.get(testKey)!;
expect(cacheLoadResult).toMatchObject({
status: CacheStatus.HIT,
item: entity1Created.getAllFields(),
});
expect(loadedObjectMap.size).toBe(1);
});
it('has correct negative caching behaviour', async () => {
const genericRedisCacher = new GenericRedisCacher(
{
redisClient: redisCacheAdapterContext.redisClient,
ttlSecondsNegative: redisCacheAdapterContext.ttlSecondsNegative,
ttlSecondsPositive: redisCacheAdapterContext.ttlSecondsPositive,
},
redisTestEntityConfiguration
);
const testKey = `test-id-key-non-existent-id`;
await genericRedisCacher.cacheDBMissesAsync([testKey]);
const loadedObjectMap = await genericRedisCacher.loadManyAsync([testKey]);
const cacheLoadResult = loadedObjectMap.get(testKey)!;
expect(cacheLoadResult.status).toBe(CacheStatus.NEGATIVE);
});
it('has correct invalidation behaviour', async () => {
const viewerContext = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
);
const genericRedisCacher = new GenericRedisCacher(
{
redisClient: redisCacheAdapterContext.redisClient,
ttlSecondsNegative: redisCacheAdapterContext.ttlSecondsNegative,
ttlSecondsPositive: redisCacheAdapterContext.ttlSecondsPositive,
},
redisTestEntityConfiguration
);
const date = new Date();
const entity1Created = await RedisTestEntity.creator(viewerContext)
.setField('name', 'blah')
.setField('dateField', date)
.enforceCreateAsync();
const testKey = `test-id-key-${entity1Created.getID()}`;
const objectMap = new Map<string, Readonly<RedisTestEntityFields>>([
[testKey, entity1Created.getAllFields()],
]);
await genericRedisCacher.cacheManyAsync(objectMap);
await genericRedisCacher.invalidateManyAsync([testKey]);
const loadedObjectMap = await genericRedisCacher.loadManyAsync([testKey]);
const cacheLoadResult = loadedObjectMap.get(testKey)!;
expect(cacheLoadResult.status).toBe(CacheStatus.MISS);
});
});
@@ -3,6 +3,81 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.27.0](https://github.com/expo/entity/compare/v0.26.1...v0.27.0) (2022-08-09)
### Features
* add orderByRaw to loadManyByRawWhereClauseAsync ([#185](https://github.com/expo/entity/issues/185)) ([2817d78](https://github.com/expo/entity/commit/2817d78392dfecd0093111c24d581bdf01dba06d))
## [0.26.1](https://github.com/expo/entity/compare/v0.26.0...v0.26.1) (2022-07-28)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
# [0.26.0](https://github.com/expo/entity/compare/v0.25.3...v0.26.0) (2022-07-14)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
## [0.25.3](https://github.com/expo/entity/compare/v0.25.2...v0.25.3) (2022-03-12)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
## [0.25.2](https://github.com/expo/entity/compare/v0.25.1...v0.25.2) (2022-03-12)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
## [0.25.1](https://github.com/expo/entity/compare/v0.25.0...v0.25.1) (2022-03-10)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
# [0.25.0](https://github.com/expo/entity/compare/v0.24.0...v0.25.0) (2022-03-10)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
# [0.23.0](https://github.com/expo/entity/compare/v0.22.0...v0.23.0) (2022-02-09)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04) # [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
@@ -1,6 +1,6 @@
{ {
"name": "@expo/entity-database-adapter-knex", "name": "@expo/entity-database-adapter-knex",
"version": "0.22.0", "version": "0.27.0",
"description": "Knex database adapter for @expo/entity", "description": "Knex database adapter for @expo/entity",
"files": [ "files": [
"build", "build",
@@ -33,7 +33,7 @@
"knex": "^1.0.2" "knex": "^1.0.2"
}, },
"devDependencies": { "devDependencies": {
"@expo/entity": "^0.22.0", "@expo/entity": "^0.27.0",
"pg": "^8.7.1" "pg": "^8.7.1"
} }
} }
@@ -7,6 +7,7 @@ import {
TableQuerySelectionModifiers, TableQuerySelectionModifiers,
TableFieldSingleValueEqualityCondition, TableFieldSingleValueEqualityCondition,
TableFieldMultiValueEqualityCondition, TableFieldMultiValueEqualityCondition,
TableQuerySelectionModifiersWithOrderByRaw,
} from '@expo/entity'; } from '@expo/entity';
import { Knex } from 'knex'; import { Knex } from 'knex';
@@ -53,6 +54,20 @@ export default class PostgresEntityDatabaseAdapter<TFields> extends EntityDataba
); );
} }
private applyQueryModifiersToQueryOrderByRaw(
query: Knex.QueryBuilder,
querySelectionModifiers: TableQuerySelectionModifiersWithOrderByRaw
): Knex.QueryBuilder {
let ret = this.applyQueryModifiersToQuery(query, querySelectionModifiers);
const { orderByRaw } = querySelectionModifiers;
if (orderByRaw !== undefined) {
ret = ret.orderByRaw(orderByRaw);
}
return ret;
}
private applyQueryModifiersToQuery( private applyQueryModifiersToQuery(
query: Knex.QueryBuilder, query: Knex.QueryBuilder,
querySelectionModifiers: TableQuerySelectionModifiers querySelectionModifiers: TableQuerySelectionModifiers
@@ -129,13 +144,13 @@ export default class PostgresEntityDatabaseAdapter<TFields> extends EntityDataba
tableName: string, tableName: string,
rawWhereClause: string, rawWhereClause: string,
bindings: object | any[], bindings: object | any[],
querySelectionModifiers: TableQuerySelectionModifiers querySelectionModifiers: TableQuerySelectionModifiersWithOrderByRaw
): Promise<object[]> { ): Promise<object[]> {
let query = queryInterface let query = queryInterface
.select() .select()
.from(tableName) .from(tableName)
.whereRaw(rawWhereClause, bindings as any); .whereRaw(rawWhereClause, bindings as any);
query = this.applyQueryModifiersToQuery(query, querySelectionModifiers); query = this.applyQueryModifiersToQueryOrderByRaw(query, querySelectionModifiers);
return await wrapNativePostgresCallAsync(() => query); return await wrapNativePostgresCallAsync(() => query);
} }
@@ -379,6 +379,15 @@ describe('postgres entity integration', () => {
expect(resultsMultipleOrderBy).toHaveLength(3); expect(resultsMultipleOrderBy).toHaveLength(3);
expect(resultsMultipleOrderBy.map((e) => e.getField('name'))).toEqual(['c', 'b', 'a']); expect(resultsMultipleOrderBy.map((e) => e.getField('name'))).toEqual(['c', 'b', 'a']);
const resultsOrderByRaw = await PostgresTestEntity.loader(vc1)
.enforcing()
.loadManyByRawWhereClauseAsync('has_a_dog = ?', [true], {
orderByRaw: 'has_a_dog ASC, name DESC',
});
expect(resultsOrderByRaw).toHaveLength(3);
expect(resultsOrderByRaw.map((e) => e.getField('name'))).toEqual(['c', 'b', 'a']);
}); });
}); });
+75
View File
@@ -3,6 +3,81 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.27.0](https://github.com/expo/entity/compare/v0.26.1...v0.27.0) (2022-08-09)
**Note:** Version bump only for package @expo/entity-example
## [0.26.1](https://github.com/expo/entity/compare/v0.26.0...v0.26.1) (2022-07-28)
**Note:** Version bump only for package @expo/entity-example
# [0.26.0](https://github.com/expo/entity/compare/v0.25.3...v0.26.0) (2022-07-14)
**Note:** Version bump only for package @expo/entity-example
## [0.25.3](https://github.com/expo/entity/compare/v0.25.2...v0.25.3) (2022-03-12)
**Note:** Version bump only for package @expo/entity-example
## [0.25.2](https://github.com/expo/entity/compare/v0.25.1...v0.25.2) (2022-03-12)
**Note:** Version bump only for package @expo/entity-example
## [0.25.1](https://github.com/expo/entity/compare/v0.25.0...v0.25.1) (2022-03-10)
**Note:** Version bump only for package @expo/entity-example
# [0.25.0](https://github.com/expo/entity/compare/v0.24.0...v0.25.0) (2022-03-10)
### Features
* privacy policy evaluation context ([#162](https://github.com/expo/entity/issues/162)) ([3a81a4c](https://github.com/expo/entity/commit/3a81a4c059e2e995eb800e35e76b3d432c22b23f))
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
**Note:** Version bump only for package @expo/entity-example
# [0.23.0](https://github.com/expo/entity/compare/v0.22.0...v0.23.0) (2022-02-09)
**Note:** Version bump only for package @expo/entity-example
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04) # [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
**Note:** Version bump only for package @expo/entity-example **Note:** Version bump only for package @expo/entity-example
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "@expo/entity-example", "name": "@expo/entity-example",
"private": true, "private": true,
"version": "0.22.0", "version": "0.27.0",
"description": "An example integration of the @expo/entity framework", "description": "An example integration of the @expo/entity framework",
"scripts": { "scripts": {
"tsc": "tsc", "tsc": "tsc",
@@ -23,7 +23,7 @@
"author": "Expo", "author": "Expo",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@expo/entity": "^0.22.0", "@expo/entity": "^0.27.0",
"apollo-server-koa": "^2.16.1", "apollo-server-koa": "^2.16.1",
"graphql": "^15.3.0", "graphql": "^15.3.0",
"koa": "^2.13.0", "koa": "^2.13.0",
@@ -3,6 +3,7 @@ import {
ReadonlyEntity, ReadonlyEntity,
EntityQueryContext, EntityQueryContext,
RuleEvaluationResult, RuleEvaluationResult,
EntityPrivacyPolicyEvaluationContext,
} from '@expo/entity'; } from '@expo/entity';
import { ExampleViewerContext } from '../viewerContexts'; import { ExampleViewerContext } from '../viewerContexts';
@@ -34,6 +35,7 @@ export default class AllowIfUserOwnerPrivacyRule<
async evaluateAsync( async evaluateAsync(
viewerContext: ExampleViewerContext, viewerContext: ExampleViewerContext,
_queryContext: EntityQueryContext, _queryContext: EntityQueryContext,
_evaluationContext: EntityPrivacyPolicyEvaluationContext,
entity: TEntity entity: TEntity
): Promise<RuleEvaluationResult> { ): Promise<RuleEvaluationResult> {
if (viewerContext.isUserViewerContext()) { if (viewerContext.isUserViewerContext()) {
@@ -30,7 +30,7 @@ router.get('/', async (ctx: ExampleContext) => {
router.get('/:id', async (ctx: ExampleContext) => { router.get('/:id', async (ctx: ExampleContext) => {
const viewerContext = ctx.state.viewerContext; const viewerContext = ctx.state.viewerContext;
const noteResult = await NoteEntity.loader(viewerContext).loadByIDAsync(ctx.params.id); const noteResult = await NoteEntity.loader(viewerContext).loadByIDAsync(ctx.params['id']!);
if (!noteResult.ok) { if (!noteResult.ok) {
ctx.throw(403, noteResult.reason); ctx.throw(403, noteResult.reason);
} }
@@ -69,7 +69,7 @@ router.put('/:id', async (ctx: ExampleContext) => {
const viewerContext = ctx.state.viewerContext; const viewerContext = ctx.state.viewerContext;
const { title, body } = ctx.request.body; const { title, body } = ctx.request.body;
const noteLoadResult = await NoteEntity.loader(viewerContext).loadByIDAsync(ctx.params.id); const noteLoadResult = await NoteEntity.loader(viewerContext).loadByIDAsync(ctx.params['id']!);
if (!noteLoadResult.ok) { if (!noteLoadResult.ok) {
ctx.throw(403, noteLoadResult.reason); ctx.throw(403, noteLoadResult.reason);
} }
@@ -90,7 +90,7 @@ router.put('/:id', async (ctx: ExampleContext) => {
router.delete('/:id', async (ctx: ExampleContext) => { router.delete('/:id', async (ctx: ExampleContext) => {
const viewerContext = ctx.state.viewerContext; const viewerContext = ctx.state.viewerContext;
const noteLoadResult = await NoteEntity.loader(viewerContext).loadByIDAsync(ctx.params.id); const noteLoadResult = await NoteEntity.loader(viewerContext).loadByIDAsync(ctx.params['id']!);
if (!noteLoadResult.ok) { if (!noteLoadResult.ok) {
ctx.throw(403, noteLoadResult.reason); ctx.throw(403, noteLoadResult.reason);
} }
@@ -3,6 +3,81 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.27.0](https://github.com/expo/entity/compare/v0.26.1...v0.27.0) (2022-08-09)
**Note:** Version bump only for package @expo/entity-full-integration-tests
## [0.26.1](https://github.com/expo/entity/compare/v0.26.0...v0.26.1) (2022-07-28)
**Note:** Version bump only for package @expo/entity-full-integration-tests
# [0.26.0](https://github.com/expo/entity/compare/v0.25.3...v0.26.0) (2022-07-14)
### Features
* add EntityEdgeDeletionBehavior.SET_NULL_INVALIDATE_CACHE_ONLY ([#178](https://github.com/expo/entity/issues/178)) ([a0c35dd](https://github.com/expo/entity/commit/a0c35ddd7bca05d0c3f41dcf48e16d058918231c))
## [0.25.3](https://github.com/expo/entity/compare/v0.25.2...v0.25.3) (2022-03-12)
**Note:** Version bump only for package @expo/entity-full-integration-tests
## [0.25.2](https://github.com/expo/entity/compare/v0.25.1...v0.25.2) (2022-03-12)
**Note:** Version bump only for package @expo/entity-full-integration-tests
## [0.25.1](https://github.com/expo/entity/compare/v0.25.0...v0.25.1) (2022-03-10)
**Note:** Version bump only for package @expo/entity-full-integration-tests
# [0.25.0](https://github.com/expo/entity/compare/v0.24.0...v0.25.0) (2022-03-10)
**Note:** Version bump only for package @expo/entity-full-integration-tests
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
**Note:** Version bump only for package @expo/entity-full-integration-tests
# [0.23.0](https://github.com/expo/entity/compare/v0.22.0...v0.23.0) (2022-02-09)
**Note:** Version bump only for package @expo/entity-full-integration-tests
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04) # [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
**Note:** Version bump only for package @expo/entity-full-integration-tests **Note:** Version bump only for package @expo/entity-full-integration-tests
@@ -1,7 +1,7 @@
{ {
"name": "@expo/entity-full-integration-tests", "name": "@expo/entity-full-integration-tests",
"private": true, "private": true,
"version": "0.22.0", "version": "0.27.0",
"description": "Full redis and knex integration tests for the entity framework", "description": "Full redis and knex integration tests for the entity framework",
"scripts": { "scripts": {
"tsc": "tsc", "tsc": "tsc",
@@ -27,8 +27,8 @@
"@expo/entity-database-adapter-knex": "*" "@expo/entity-database-adapter-knex": "*"
}, },
"devDependencies": { "devDependencies": {
"@expo/entity": "^0.22.0", "@expo/entity": "^0.27.0",
"@expo/entity-cache-adapter-redis": "^0.22.0", "@expo/entity-cache-adapter-redis": "^0.27.0",
"@expo/entity-database-adapter-knex": "^0.22.0" "@expo/entity-database-adapter-knex": "^0.27.0"
} }
} }
@@ -92,11 +92,8 @@ async function createOrTruncatePostgresTables(knex: Knex): Promise<void> {
} }
async function dropPostgresTable(knex: Knex): Promise<void> { async function dropPostgresTable(knex: Knex): Promise<void> {
if (await knex.schema.hasTable('children')) { if (await knex.schema.hasTable('testentities')) {
await knex.schema.dropTable('children'); await knex.schema.dropTable('testentities');
}
if (await knex.schema.hasTable('parents')) {
await knex.schema.dropTable('parents');
} }
} }
@@ -135,26 +135,40 @@ const makeEntityClasses = async (knex: Knex, edgeDeletionBehavior: EntityEdgeDel
await knex.schema.createTable(othersTableName, (table) => { await knex.schema.createTable(othersTableName, (table) => {
table.uuid('id').defaultTo(knex.raw('uuid_generate_v4()')).primary(); table.uuid('id').defaultTo(knex.raw('uuid_generate_v4()')).primary();
if (edgeDeletionBehavior === EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE) { if (edgeDeletionBehavior === EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE_ONLY) {
table table
.uuid('parent_category_id') .uuid('parent_category_id')
.references('id') .references('id')
.inTable(categoriesTableName) .inTable(categoriesTableName)
.unique() .unique()
.onDelete('cascade'); .onDelete('cascade');
} else if (edgeDeletionBehavior === EntityEdgeDeletionBehavior.SET_NULL_INVALIDATE_CACHE_ONLY) {
table
.uuid('parent_category_id')
.references('id')
.inTable(categoriesTableName)
.unique()
.onDelete('set null');
} else { } else {
table.uuid('parent_category_id').unique(); table.uuid('parent_category_id').unique();
} }
}); });
await knex.schema.alterTable(categoriesTableName, (table) => { await knex.schema.alterTable(categoriesTableName, (table) => {
if (edgeDeletionBehavior === EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE) { if (edgeDeletionBehavior === EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE_ONLY) {
table table
.uuid('parent_other_id') .uuid('parent_other_id')
.references('id') .references('id')
.inTable(othersTableName) .inTable(othersTableName)
.unique() .unique()
.onDelete('cascade'); .onDelete('cascade');
} else if (edgeDeletionBehavior === EntityEdgeDeletionBehavior.SET_NULL_INVALIDATE_CACHE_ONLY) {
table
.uuid('parent_other_id')
.references('id')
.inTable(othersTableName)
.unique()
.onDelete('set null');
} else { } else {
table.uuid('parent_other_id').unique(); table.uuid('parent_other_id').unique();
} }
@@ -200,8 +214,9 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
it.each([ it.each([
EntityEdgeDeletionBehavior.CASCADE_DELETE, EntityEdgeDeletionBehavior.CASCADE_DELETE,
EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE, EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE_ONLY,
EntityEdgeDeletionBehavior.SET_NULL, EntityEdgeDeletionBehavior.SET_NULL,
EntityEdgeDeletionBehavior.SET_NULL_INVALIDATE_CACHE_ONLY,
])('behavior: %p', async (edgeDeletionBehavior) => { ])('behavior: %p', async (edgeDeletionBehavior) => {
const { CategoryEntity, OtherEntity } = await makeEntityClasses( const { CategoryEntity, OtherEntity } = await makeEntityClasses(
knexInstance, knexInstance,
@@ -230,6 +245,14 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
.enforcing() .enforcing()
.loadByIDNullableAsync(other1.getID()); .loadByIDNullableAsync(other1.getID());
expect(otherLoaded?.getField('parent_category_id')).toBeNull(); expect(otherLoaded?.getField('parent_category_id')).toBeNull();
} else if (edgeDeletionBehavior === EntityEdgeDeletionBehavior.SET_NULL_INVALIDATE_CACHE_ONLY) {
await expect(
CategoryEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(category1.getID())
).resolves.toBeNull();
const otherLoaded = await OtherEntity.loader(viewerContext)
.enforcing()
.loadByIDNullableAsync(other1.getID());
expect(otherLoaded?.getField('parent_category_id')).toBeNull();
} else { } else {
await expect( await expect(
CategoryEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(category1.getID()) CategoryEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(category1.getID())
@@ -56,7 +56,7 @@ const childEntityConfiguration = new EntityConfiguration<ChildFields>({
cache: true, cache: true,
association: { association: {
getAssociatedEntityClass: () => ParentEntity, getAssociatedEntityClass: () => ParentEntity,
edgeDeletionBehavior: EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE, edgeDeletionBehavior: EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE_ONLY,
}, },
}), }),
}, },
@@ -3,6 +3,78 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.27.0](https://github.com/expo/entity/compare/v0.26.1...v0.27.0) (2022-08-09)
**Note:** Version bump only for package @expo/entity-ip-address-field
## [0.26.1](https://github.com/expo/entity/compare/v0.26.0...v0.26.1) (2022-07-28)
**Note:** Version bump only for package @expo/entity-ip-address-field
# [0.26.0](https://github.com/expo/entity/compare/v0.25.3...v0.26.0) (2022-07-14)
**Note:** Version bump only for package @expo/entity-ip-address-field
## [0.25.3](https://github.com/expo/entity/compare/v0.25.2...v0.25.3) (2022-03-12)
**Note:** Version bump only for package @expo/entity-ip-address-field
## [0.25.2](https://github.com/expo/entity/compare/v0.25.1...v0.25.2) (2022-03-12)
**Note:** Version bump only for package @expo/entity-ip-address-field
## [0.25.1](https://github.com/expo/entity/compare/v0.25.0...v0.25.1) (2022-03-10)
**Note:** Version bump only for package @expo/entity-ip-address-field
# [0.25.0](https://github.com/expo/entity/compare/v0.24.0...v0.25.0) (2022-03-10)
**Note:** Version bump only for package @expo/entity-ip-address-field
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
**Note:** Version bump only for package @expo/entity-ip-address-field
# [0.23.0](https://github.com/expo/entity/compare/v0.22.0...v0.23.0) (2022-02-09)
**Note:** Version bump only for package @expo/entity-ip-address-field
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04) # [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
**Note:** Version bump only for package @expo/entity-ip-address-field **Note:** Version bump only for package @expo/entity-ip-address-field
@@ -1,6 +1,6 @@
{ {
"name": "@expo/entity-ip-address-field", "name": "@expo/entity-ip-address-field",
"version": "0.22.0", "version": "0.27.0",
"description": "IP address EntityField definitions for @expo/entity", "description": "IP address EntityField definitions for @expo/entity",
"files": [ "files": [
"build", "build",
@@ -28,7 +28,7 @@
"ip-address": "^8.1.0" "ip-address": "^8.1.0"
}, },
"devDependencies": { "devDependencies": {
"@expo/entity": "^0.22.0", "@expo/entity": "^0.27.0",
"@types/jsbn": "^1.2.29" "@types/jsbn": "^1.2.29"
}, },
"peerDependencies": { "peerDependencies": {
@@ -0,0 +1,59 @@
# Change Log
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [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.27.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": ">=12"
},
"keywords": [
"entity"
],
"author": "Expo",
"license": "MIT",
"peerDependencies": {
"@expo/entity": "*",
"@expo/entity-cache-adapter-local-memory": "*"
},
"devDependencies": {
"@expo/entity": "^0.27.0",
"@expo/entity-cache-adapter-local-memory": "^0.27.0",
"nullthrows": "^1.1.1"
}
}
@@ -0,0 +1,21 @@
import { 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(localMemoryCache: LocalMemoryCache<TFields>) {
super(new GenericLocalMemoryCacher(localMemoryCache), (params) => JSON.stringify(params));
}
}
@@ -0,0 +1,228 @@
import {
EntitySecondaryCacheLoader,
mapMapAsync,
ViewerContext,
NoOpEntityMetricsAdapter,
IEntityMetricsAdapter,
EntityCompanionProvider,
StubQueryContextProvider,
StubDatabaseAdapterProvider,
AlwaysAllowPrivacyPolicyRule,
EntityPrivacyPolicy,
UUIDField,
StringField,
EntityConfiguration,
EntityCompanionDefinition,
Entity,
} from '@expo/entity';
import {
GenericLocalMemoryCacher,
LocalMemoryCacheAdapterProvider,
} from '@expo/entity-cache-adapter-local-memory';
import nullthrows from 'nullthrows';
import LocalMemorySecondaryEntityCache from '../LocalMemorySecondaryEntityCache';
export type LocalMemoryTestEntityFields = {
id: string;
name: string;
};
export default class LocalMemoryTestEntity extends Entity<
LocalMemoryTestEntityFields,
string,
ViewerContext
> {
static getCompanionDefinition(): EntityCompanionDefinition<
LocalMemoryTestEntityFields,
string,
ViewerContext,
LocalMemoryTestEntity,
LocalMemoryTestEntityPrivacyPolicy
> {
return localMemoryTestEntityCompanionDefinition;
}
}
export class LocalMemoryTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
LocalMemoryTestEntityFields,
string,
ViewerContext,
LocalMemoryTestEntity
> {
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<
LocalMemoryTestEntityFields,
string,
ViewerContext,
LocalMemoryTestEntity
>(),
];
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<
LocalMemoryTestEntityFields,
string,
ViewerContext,
LocalMemoryTestEntity
>(),
];
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<
LocalMemoryTestEntityFields,
string,
ViewerContext,
LocalMemoryTestEntity
>(),
];
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<
LocalMemoryTestEntityFields,
string,
ViewerContext,
LocalMemoryTestEntity
>(),
];
}
export const localMemoryTestEntityConfiguration =
new EntityConfiguration<LocalMemoryTestEntityFields>({
idField: 'id',
tableName: 'local_memory_test_entities',
schema: {
id: new UUIDField({
columnName: 'id',
}),
name: new StringField({
columnName: 'name',
}),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'local-memory',
});
const localMemoryTestEntityCompanionDefinition = new EntityCompanionDefinition({
entityClass: LocalMemoryTestEntity,
entityConfiguration: localMemoryTestEntityConfiguration,
privacyPolicyClass: LocalMemoryTestEntityPrivacyPolicy,
});
export const createTestEntityCompanionProvider = (
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter()
): EntityCompanionProvider => {
return new EntityCompanionProvider(
metricsAdapter,
new Map([
[
'postgres',
{
adapterProvider: new StubDatabaseAdapterProvider(),
queryContextProvider: StubQueryContextProvider,
},
],
]),
new Map([
[
'local-memory',
{
cacheAdapterProvider: LocalMemoryCacheAdapterProvider.getProvider(),
},
],
])
);
};
class TestViewerContext extends ViewerContext {}
type TestLoadParams = { id: string };
const FAKE_ID = 'fake';
class TestSecondaryLocalMemoryCacheLoader extends EntitySecondaryCacheLoader<
TestLoadParams,
LocalMemoryTestEntityFields,
string,
TestViewerContext,
LocalMemoryTestEntity,
LocalMemoryTestEntityPrivacyPolicy
> {
public databaseLoadCount = 0;
protected async fetchObjectsFromDatabaseAsync(
loadParamsArray: readonly Readonly<TestLoadParams>[]
): Promise<ReadonlyMap<Readonly<TestLoadParams>, Readonly<LocalMemoryTestEntityFields> | null>> {
this.databaseLoadCount += loadParamsArray.length;
const emptyMap = new Map(loadParamsArray.map((p) => [p, null]));
return await mapMapAsync(emptyMap, async (_value, loadParams) => {
if (loadParams.id === FAKE_ID) {
return null;
}
return nullthrows(
(
await this.entityLoader
.enforcing()
.loadManyByFieldEqualityConjunctionAsync([
{ fieldName: 'id', fieldValue: loadParams.id },
])
)[0]
).getAllFields();
});
}
}
describe(LocalMemorySecondaryEntityCache, () => {
it('Loads through secondary loader, caches, and invalidates', async () => {
const viewerContext = new TestViewerContext(createTestEntityCompanionProvider());
const createdEntity = await LocalMemoryTestEntity.creator(viewerContext)
.setField('name', 'wat')
.enforceCreateAsync();
const secondaryCacheLoader = new TestSecondaryLocalMemoryCacheLoader(
new LocalMemorySecondaryEntityCache(
GenericLocalMemoryCacher.createLRUCache<LocalMemoryTestEntityFields>({})
),
LocalMemoryTestEntity.loader(viewerContext)
);
const loadParams = { id: createdEntity.getID() };
const results = await secondaryCacheLoader.loadManyAsync([loadParams]);
expect(nullthrows(results.get(loadParams)).enforceValue().getID()).toEqual(
createdEntity.getID()
);
expect(secondaryCacheLoader.databaseLoadCount).toEqual(1);
const results2 = await secondaryCacheLoader.loadManyAsync([loadParams]);
expect(nullthrows(results2.get(loadParams)).enforceValue().getID()).toEqual(
createdEntity.getID()
);
expect(secondaryCacheLoader.databaseLoadCount).toEqual(1);
await secondaryCacheLoader.invalidateManyAsync([loadParams]);
const results3 = await secondaryCacheLoader.loadManyAsync([loadParams]);
expect(nullthrows(results3.get(loadParams)).enforceValue().getID()).toEqual(
createdEntity.getID()
);
expect(secondaryCacheLoader.databaseLoadCount).toEqual(2);
});
it('correctly handles uncached and unfetchable load params', async () => {
const viewerContext = new TestViewerContext(createTestEntityCompanionProvider());
const secondaryCacheLoader = new TestSecondaryLocalMemoryCacheLoader(
new LocalMemorySecondaryEntityCache(
GenericLocalMemoryCacher.createLRUCache<LocalMemoryTestEntityFields>({})
),
LocalMemoryTestEntity.loader(viewerContext)
);
const loadParams = { id: FAKE_ID };
const results = await secondaryCacheLoader.loadManyAsync([loadParams]);
expect(results.size).toBe(1);
expect(results.get(loadParams)).toBe(null);
});
});
@@ -0,0 +1,7 @@
/* eslint-disable tsdoc/syntax */
/**
* @packageDocumentation
* @module @expo/entity-secondary-cache-local-memory
*/
export { default as LocalMemorySecondaryEntityCache } from './LocalMemorySecondaryEntityCache';
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "build",
},
"include": ["src"]
}
@@ -3,6 +3,86 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.27.0](https://github.com/expo/entity/compare/v0.26.1...v0.27.0) (2022-08-09)
**Note:** Version bump only for package @expo/entity-secondary-cache-redis
## [0.26.1](https://github.com/expo/entity/compare/v0.26.0...v0.26.1) (2022-07-28)
**Note:** Version bump only for package @expo/entity-secondary-cache-redis
# [0.26.0](https://github.com/expo/entity/compare/v0.25.3...v0.26.0) (2022-07-14)
### Features
* update ioredis to v5, make it a devDependency of the cache packages ([#172](https://github.com/expo/entity/issues/172)) ([9fcfbf7](https://github.com/expo/entity/commit/9fcfbf746a61554cefedf7fb0f1a3a9056b58ffe))
## [0.25.3](https://github.com/expo/entity/compare/v0.25.2...v0.25.3) (2022-03-12)
**Note:** Version bump only for package @expo/entity-secondary-cache-redis
## [0.25.2](https://github.com/expo/entity/compare/v0.25.1...v0.25.2) (2022-03-12)
**Note:** Version bump only for package @expo/entity-secondary-cache-redis
## [0.25.1](https://github.com/expo/entity/compare/v0.25.0...v0.25.1) (2022-03-10)
**Note:** Version bump only for package @expo/entity-secondary-cache-redis
# [0.25.0](https://github.com/expo/entity/compare/v0.24.0...v0.25.0) (2022-03-10)
### Features
* composed cache adapter, composed secondary cache, local memory secondary cache ([#164](https://github.com/expo/entity/issues/164)) ([6b6e70e](https://github.com/expo/entity/commit/6b6e70e75f7e42a4dce13edeb406a4d88d9264f0))
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
### Chores
* Refactor cachers to use an abstract class and interface ([#157](https://github.com/expo/entity/pull/157))
# [0.23.0](https://github.com/expo/entity/compare/v0.22.0...v0.23.0) (2022-02-09)
### Chores
* Move data transforming responsibilities to the CacheAdapter instead of the RedisSecondaryEntityCache. ([#153](https://github.com/expo/entity/pull/153))
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04) # [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
**Note:** Version bump only for package @expo/entity-secondary-cache-redis **Note:** Version bump only for package @expo/entity-secondary-cache-redis
@@ -1,6 +1,6 @@
{ {
"name": "@expo/entity-secondary-cache-redis", "name": "@expo/entity-secondary-cache-redis",
"version": "0.22.0", "version": "0.27.0",
"description": "Redis secondary cache for @expo/entity", "description": "Redis secondary cache for @expo/entity",
"files": [ "files": [
"build", "build",
@@ -30,12 +30,10 @@
"@expo/entity": "*", "@expo/entity": "*",
"@expo/entity-cache-adapter-redis": "*" "@expo/entity-cache-adapter-redis": "*"
}, },
"dependencies": {
"ioredis": "^4.17.3"
},
"devDependencies": { "devDependencies": {
"@expo/entity": "^0.22.0", "@expo/entity": "^0.27.0",
"@expo/entity-cache-adapter-redis": "^0.22.0", "@expo/entity-cache-adapter-redis": "^0.27.0",
"ioredis": "^5.0.4",
"nullthrows": "^1.1.1" "nullthrows": "^1.1.1"
} }
} }
@@ -1,136 +1,18 @@
import { import { EntityConfiguration, GenericSecondaryEntityCache } from '@expo/entity';
CacheStatus, import { GenericRedisCacheContext, GenericRedisCacher } from '@expo/entity-cache-adapter-redis';
EntityConfiguration,
filterMap,
ISecondaryEntityCache,
transformCacheObjectToFields,
transformFieldsToCacheObject,
zipToMap,
} from '@expo/entity';
import {
GenericRedisCacheContext,
GenericRedisCacher,
redisTransformerMap,
} from '@expo/entity-cache-adapter-redis';
import invariant from 'invariant';
/** /**
* A custom secondary read-through entity cache is a way to add a custom second layer of caching for a particular * A redis GenericSecondaryEntityCache.
* single entity load. One common way this may be used is to add a second layer of caching in a hot path that makes
* a call to {@link EntityLoader.loadManyByFieldEqualityConjunctionAsync} is guaranteed to return at most one entity.
*/ */
export default class RedisSecondaryEntityCache<TFields, TLoadParams> export default class RedisSecondaryEntityCache<
implements ISecondaryEntityCache<TFields, TLoadParams> TFields,
{ TLoadParams
private readonly genericRedisCacher: GenericRedisCacher; > extends GenericSecondaryEntityCache<TFields, TLoadParams> {
constructor( constructor(
private readonly entityConfiguration: EntityConfiguration<TFields>, entityConfiguration: EntityConfiguration<TFields>,
genericRedisCacheContext: GenericRedisCacheContext, genericRedisCacheContext: GenericRedisCacheContext,
private readonly constructRedisKey: (params: Readonly<TLoadParams>) => string constructRedisKey: (params: Readonly<TLoadParams>) => string
) { ) {
this.genericRedisCacher = new GenericRedisCacher(genericRedisCacheContext); super(new GenericRedisCacher(genericRedisCacheContext, entityConfiguration), constructRedisKey);
}
/**
* Read-through cache function. Steps:
*
* 1. Check for cached objects
* 2. Query the fetcher for loadParams not in the cache
* 3. Cache the results from the fetcher
* 4. Negatively cache anything missing from the fetcher
* 5. Return the full set of data for the query.
*
* @param loadParamsArray - array of loadParams to load from the cache
* @param fetcher - closure used to provide underlying data source objects for loadParams
* @returns map from loadParams to the entity field object
*/
public async loadManyThroughAsync(
loadParamsArray: readonly Readonly<TLoadParams>[],
fetcher: (
fetcherLoadParamsArray: readonly Readonly<TLoadParams>[]
) => Promise<ReadonlyMap<Readonly<TLoadParams>, Readonly<TFields> | null>>
): Promise<ReadonlyMap<Readonly<TLoadParams>, Readonly<TFields> | null>> {
const redisKeys = loadParamsArray.map(this.constructRedisKey);
const redisKeyToLoadParamsMap = zipToMap(redisKeys, loadParamsArray);
const cacheLoadResults = await this.genericRedisCacher.loadManyAsync(redisKeys);
invariant(
cacheLoadResults.size === loadParamsArray.length,
`${this.constructor.name} loadMany should return a result for each key`
);
const redisKeysToFetch = Array.from(
filterMap(
cacheLoadResults,
(cacheLoadResult) => cacheLoadResult.status === CacheStatus.MISS
).keys()
);
// put transformed cache hits in result map
const results: Map<Readonly<TLoadParams>, Readonly<TFields> | null> = new Map();
cacheLoadResults.forEach((cacheLoadResult, redisKey) => {
if (cacheLoadResult.status === CacheStatus.HIT) {
const loadParams = redisKeyToLoadParamsMap.get(redisKey);
invariant(loadParams !== undefined, 'load params should be in redis key map');
results.set(
loadParams,
transformCacheObjectToFields(
this.entityConfiguration,
redisTransformerMap,
cacheLoadResult.item
)
);
}
});
// fetch any misses from DB, add DB objects to results, cache DB results, inform cache of any missing DB results
if (redisKeysToFetch.length > 0) {
const loadParamsToFetch = redisKeysToFetch.map((redisKey) => {
const loadParams = redisKeyToLoadParamsMap.get(redisKey);
invariant(loadParams !== undefined, 'load params should be in redis key map');
return loadParams;
});
const fetchResults = await fetcher(loadParamsToFetch);
const fetchMisses = loadParamsToFetch.filter((loadParams) => {
// all values of fetchResults should be field objects or undefined
return !fetchResults.get(loadParams);
});
for (const fetchMiss of fetchMisses) {
results.set(fetchMiss, null);
}
const objectsToCache: Map<string, object> = new Map();
for (const [loadParams, object] of fetchResults.entries()) {
if (object) {
objectsToCache.set(
this.constructRedisKey(loadParams),
transformFieldsToCacheObject(this.entityConfiguration, redisTransformerMap, object)
);
results.set(loadParams, object);
}
}
await Promise.all([
this.genericRedisCacher.cacheManyAsync(objectsToCache),
this.genericRedisCacher.cacheDBMissesAsync(fetchMisses.map(this.constructRedisKey)),
]);
}
return results;
}
/**
* Invalidate the cache for objects cached by constructRedisKey(loadParams).
*
* @param loadParamsArray - load params to invalidate
*/
public async invalidateManyAsync(
loadParamsArray: readonly Readonly<TLoadParams>[]
): Promise<void> {
await this.genericRedisCacher.invalidateManyAsync(loadParamsArray.map(this.constructRedisKey));
} }
} }
+93
View File
@@ -3,6 +3,99 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.27.0](https://github.com/expo/entity/compare/v0.26.1...v0.27.0) (2022-08-09)
### Features
* add orderByRaw to loadManyByRawWhereClauseAsync ([#185](https://github.com/expo/entity/issues/185)) ([2817d78](https://github.com/expo/entity/commit/2817d78392dfecd0093111c24d581bdf01dba06d))
## [0.26.1](https://github.com/expo/entity/compare/v0.26.0...v0.26.1) (2022-07-28)
### Features
* recache to higher cache levels when loading from a composed cache ([#182](https://github.com/expo/entity/pull/182))
# [0.26.0](https://github.com/expo/entity/compare/v0.25.3...v0.26.0) (2022-07-14)
### Features
* add EntityEdgeDeletionBehavior.SET_NULL_INVALIDATE_CACHE_ONLY ([#178](https://github.com/expo/entity/issues/178)) ([a0c35dd](https://github.com/expo/entity/commit/a0c35ddd7bca05d0c3f41dcf48e16d058918231c))
## [0.25.3](https://github.com/expo/entity/compare/v0.25.2...v0.25.3) (2022-03-12)
**Note:** Version bump only for package @expo/entity
## [0.25.2](https://github.com/expo/entity/compare/v0.25.1...v0.25.2) (2022-03-12)
**Note:** Version bump only for package @expo/entity
## [0.25.1](https://github.com/expo/entity/compare/v0.25.0...v0.25.1) (2022-03-10)
### Bug Fixes
* add composed cache to index ([#166](https://github.com/expo/entity/issues/166)) ([5c82edc](https://github.com/expo/entity/commit/5c82edcd9f26854878d29950a05852e117bbd723))
# [0.25.0](https://github.com/expo/entity/compare/v0.24.0...v0.25.0) (2022-03-10)
### Features
* composed cache adapter, composed secondary cache, local memory secondary cache ([#164](https://github.com/expo/entity/issues/164)) ([6b6e70e](https://github.com/expo/entity/commit/6b6e70e75f7e42a4dce13edeb406a4d88d9264f0))
* privacy policy evaluation context ([#162](https://github.com/expo/entity/issues/162)) ([3a81a4c](https://github.com/expo/entity/commit/3a81a4c059e2e995eb800e35e76b3d432c22b23f))
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
**Note:** Version bump only for package @expo/entity
### Chores
* Refactor cachers to use an abstract class and interface ([#157](https://github.com/expo/entity/pull/157))
# [0.23.0](https://github.com/expo/entity/compare/v0.22.0...v0.23.0) (2022-02-09)
### Breaking changes
* Move data transforming responsibilities to the CacheAdapter instead of the ReadThroughEntityCache. Affects any classes extending the EntityCacheAdapter and relying on the transformation behavior of the ReadThroughEntityCache. ([#153](https://github.com/expo/entity/pull/153))
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04) # [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
**Note:** Version bump only for package @expo/entity **Note:** Version bump only for package @expo/entity
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@expo/entity", "name": "@expo/entity",
"version": "0.22.0", "version": "0.27.0",
"description": "A privacy-first data model", "description": "A privacy-first data model",
"files": [ "files": [
"build", "build",
@@ -0,0 +1,127 @@
import nullthrows from 'nullthrows';
import EntityCacheAdapter from './EntityCacheAdapter';
import EntityConfiguration from './EntityConfiguration';
import { CacheStatus, CacheLoadResult } from './internal/ReadThroughEntityCache';
/**
* A EntityCacheAdapter that composes other EntityCacheAdapter instances.
*/
export default class ComposedEntityCacheAdapter<TFields> extends EntityCacheAdapter<TFields> {
/**
* @param entityConfiguration - configuration for entity being loaded
* @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(
entityConfiguration: EntityConfiguration<TFields>,
private readonly cacheAdapters: EntityCacheAdapter<TFields>[]
) {
super(entityConfiguration);
}
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);
}
}
}
+14 -10
View File
@@ -1,4 +1,8 @@
import { FieldEqualityCondition, QuerySelectionModifiers } from './EntityDatabaseAdapter'; import {
FieldEqualityCondition,
QuerySelectionModifiers,
QuerySelectionModifiersWithOrderByRaw,
} from './EntityDatabaseAdapter';
import EntityLoader from './EntityLoader'; import EntityLoader from './EntityLoader';
import EntityPrivacyPolicy from './EntityPrivacyPolicy'; import EntityPrivacyPolicy from './EntityPrivacyPolicy';
import ReadonlyEntity from './ReadonlyEntity'; import ReadonlyEntity from './ReadonlyEntity';
@@ -36,7 +40,7 @@ export default class EnforcingEntityLoader<
/** /**
* Enforcing version of entity loader method by the same name. * Enforcing version of entity loader method by the same name.
* @throws {@link EntityNotAuthorizedError} when viewer is not authorized to view one or more of the returned entities * @throws EntityNotAuthorizedError when viewer is not authorized to view one or more of the returned entities
*/ */
async loadManyByFieldEqualingManyAsync<N extends keyof Pick<TFields, TSelectedFields>>( async loadManyByFieldEqualingManyAsync<N extends keyof Pick<TFields, TSelectedFields>>(
fieldName: N, fieldName: N,
@@ -53,7 +57,7 @@ export default class EnforcingEntityLoader<
/** /**
* Enforcing version of entity loader method by the same name. * Enforcing version of entity loader method by the same name.
* @throws {@link EntityNotAuthorizedError} when viewer is not authorized to view one or more of the returned entities * @throws EntityNotAuthorizedError when viewer is not authorized to view one or more of the returned entities
*/ */
async loadManyByFieldEqualingAsync<N extends keyof Pick<TFields, TSelectedFields>>( async loadManyByFieldEqualingAsync<N extends keyof Pick<TFields, TSelectedFields>>(
fieldName: N, fieldName: N,
@@ -68,7 +72,7 @@ export default class EnforcingEntityLoader<
/** /**
* Enforcing version of entity loader method by the same name. * Enforcing version of entity loader method by the same name.
* @throws {@link EntityNotAuthorizedError} when viewer is not authorized to view the returned entity * @throws EntityNotAuthorizedError when viewer is not authorized to view the returned entity
* @throws when multiple entities are found matching the condition * @throws when multiple entities are found matching the condition
*/ */
async loadByFieldEqualingAsync<N extends keyof Pick<TFields, TSelectedFields>>( async loadByFieldEqualingAsync<N extends keyof Pick<TFields, TSelectedFields>>(
@@ -84,7 +88,7 @@ export default class EnforcingEntityLoader<
/** /**
* Enforcing version of entity loader method by the same name. * Enforcing version of entity loader method by the same name.
* @throws {@link EntityNotAuthorizedError} when viewer is not authorized to view the returned entity * @throws EntityNotAuthorizedError when viewer is not authorized to view the returned entity
*/ */
async loadByIDAsync(id: TID): Promise<TEntity> { async loadByIDAsync(id: TID): Promise<TEntity> {
const entityResult = await this.entityLoader.loadByIDAsync(id); const entityResult = await this.entityLoader.loadByIDAsync(id);
@@ -93,7 +97,7 @@ export default class EnforcingEntityLoader<
/** /**
* Enforcing version of entity loader method by the same name. * Enforcing version of entity loader method by the same name.
* @throws {@link EntityNotAuthorizedError} when viewer is not authorized to view the returned entity * @throws EntityNotAuthorizedError when viewer is not authorized to view the returned entity
* @throws when multiple entities are found matching the condition * @throws when multiple entities are found matching the condition
*/ */
async loadByIDNullableAsync(id: TID): Promise<TEntity | null> { async loadByIDNullableAsync(id: TID): Promise<TEntity | null> {
@@ -103,7 +107,7 @@ export default class EnforcingEntityLoader<
/** /**
* Enforcing version of entity loader method by the same name. * Enforcing version of entity loader method by the same name.
* @throws {@link EntityNotAuthorizedError} when viewer is not authorized to view one or more of the returned entities * @throws EntityNotAuthorizedError when viewer is not authorized to view one or more of the returned entities
*/ */
async loadManyByIDsAsync(ids: readonly TID[]): Promise<ReadonlyMap<TID, TEntity>> { async loadManyByIDsAsync(ids: readonly TID[]): Promise<ReadonlyMap<TID, TEntity>> {
const entityResults = await this.entityLoader.loadManyByIDsAsync(ids); const entityResults = await this.entityLoader.loadManyByIDsAsync(ids);
@@ -112,7 +116,7 @@ export default class EnforcingEntityLoader<
/** /**
* Enforcing version of entity loader method by the same name. * Enforcing version of entity loader method by the same name.
* @throws {@link EntityNotAuthorizedError} when viewer is not authorized to view one or more of the returned entities * @throws EntityNotAuthorizedError when viewer is not authorized to view one or more of the returned entities
*/ */
async loadManyByFieldEqualityConjunctionAsync<N extends keyof Pick<TFields, TSelectedFields>>( async loadManyByFieldEqualityConjunctionAsync<N extends keyof Pick<TFields, TSelectedFields>>(
fieldEqualityOperands: FieldEqualityCondition<TFields, N>[], fieldEqualityOperands: FieldEqualityCondition<TFields, N>[],
@@ -127,12 +131,12 @@ export default class EnforcingEntityLoader<
/** /**
* Enforcing version of entity loader method by the same name. * Enforcing version of entity loader method by the same name.
* @throws {@link EntityNotAuthorizedError} when viewer is not authorized to view one or more of the returned entities * @throws EntityNotAuthorizedError when viewer is not authorized to view one or more of the returned entities
*/ */
async loadManyByRawWhereClauseAsync( async loadManyByRawWhereClauseAsync(
rawWhereClause: string, rawWhereClause: string,
bindings: any[] | object, bindings: any[] | object,
querySelectionModifiers: QuerySelectionModifiers<TFields> = {} querySelectionModifiers: QuerySelectionModifiersWithOrderByRaw<TFields> = {}
): Promise<readonly TEntity[]> { ): Promise<readonly TEntity[]> {
const entityResults = await this.entityLoader.loadManyByRawWhereClauseAsync( const entityResults = await this.entityLoader.loadManyByRawWhereClauseAsync(
rawWhereClause, rawWhereClause,
+7 -5
View File
@@ -11,19 +11,19 @@ import ViewerContext from './ViewerContext';
* Entity is a privacy-first data model. * Entity is a privacy-first data model.
* *
* A instance of an entity represents a single "row" of persisted data in a database that a * A instance of an entity represents a single "row" of persisted data in a database that a
* viewer, represented by the corresponding {@link ViewerContext}, has permission to read. * viewer, represented by the corresponding ViewerContext, has permission to read.
* *
* Create, read, update, and delete permissions for an entity are declaratively defined using an * Create, read, update, and delete permissions for an entity are declaratively defined using an
* {@link EntityPrivacyPolicy}. * EntityPrivacyPolicy.
* *
* Entites are loaded through an {@link EntityLoader}, which is responsible for * Entites are loaded through an EntityLoader, which is responsible for
* orchestrating fetching, caching, and authorization of reading "rows". * orchestrating fetching, caching, and authorization of reading "rows".
* *
* Entities are mutated and deleted through an {@link EntityMutator}, which is responsible for * Entities are mutated and deleted through an EntityMutator, which is responsible for
* orchestrating database writes, cache invalidation, and authorization of writing "rows". * orchestrating database writes, cache invalidation, and authorization of writing "rows".
* *
* All concrete entity implementations should extend this class and provide their * All concrete entity implementations should extend this class and provide their
* own {@link EntityCompanionDefinition}. * own EntityCompanionDefinition.
*/ */
export default abstract class Entity< export default abstract class Entity<
TFields, TFields,
@@ -251,6 +251,7 @@ export default abstract class Entity<
privacyPolicy.authorizeUpdateAsync( privacyPolicy.authorizeUpdateAsync(
existingEntity.getViewerContext(), existingEntity.getViewerContext(),
queryContext, queryContext,
{ cascadingDeleteCause: null },
existingEntity, existingEntity,
companion.getMetricsAdapter() companion.getMetricsAdapter()
) )
@@ -304,6 +305,7 @@ export default abstract class Entity<
privacyPolicy.authorizeDeleteAsync( privacyPolicy.authorizeDeleteAsync(
existingEntity.getViewerContext(), existingEntity.getViewerContext(),
queryContext, queryContext,
{ cascadingDeleteCause: null },
existingEntity, existingEntity,
companion.getMetricsAdapter() companion.getMetricsAdapter()
) )
+13 -8
View File
@@ -6,6 +6,11 @@ import { EntityQueryContext } from './EntityQueryContext';
import ReadonlyEntity from './ReadonlyEntity'; import ReadonlyEntity from './ReadonlyEntity';
import ViewerContext from './ViewerContext'; import ViewerContext from './ViewerContext';
/**
* An association loader is a set of convenience methods for loading entities
* associated with an entity. In relational databases, these entities are often referenced
* by foreign keys.
*/
export default class EntityAssociationLoader< export default class EntityAssociationLoader<
TFields, TFields,
TID extends NonNullable<TFields[TSelectedFields]>, TID extends NonNullable<TFields[TSelectedFields]>,
@@ -69,7 +74,7 @@ export default class EntityAssociationLoader<
.getViewerContext() .getViewerContext()
.getViewerScopedEntityCompanionForClass(associatedEntityClass) .getViewerScopedEntityCompanionForClass(associatedEntityClass)
.getLoaderFactory() .getLoaderFactory()
.forLoad(queryContext); .forLoad(queryContext, { cascadingDeleteCause: null });
return (await loader.loadByIDAsync(associatedEntityID as unknown as TAssociatedID)) as Result< return (await loader.loadByIDAsync(associatedEntityID as unknown as TAssociatedID)) as Result<
null extends TFields[TIdentifyingField] ? TAssociatedEntity | null : TAssociatedEntity null extends TFields[TIdentifyingField] ? TAssociatedEntity | null : TAssociatedEntity
@@ -123,7 +128,7 @@ export default class EntityAssociationLoader<
.getViewerContext() .getViewerContext()
.getViewerScopedEntityCompanionForClass(associatedEntityClass) .getViewerScopedEntityCompanionForClass(associatedEntityClass)
.getLoaderFactory() .getLoaderFactory()
.forLoad(queryContext); .forLoad(queryContext, { cascadingDeleteCause: null });
return await loader.loadManyByFieldEqualingAsync( return await loader.loadManyByFieldEqualingAsync(
associatedEntityFieldContainingThisID, associatedEntityFieldContainingThisID,
thisID as any thisID as any
@@ -180,7 +185,7 @@ export default class EntityAssociationLoader<
.getViewerContext() .getViewerContext()
.getViewerScopedEntityCompanionForClass(associatedEntityClass) .getViewerScopedEntityCompanionForClass(associatedEntityClass)
.getLoaderFactory() .getLoaderFactory()
.forLoad(queryContext); .forLoad(queryContext, { cascadingDeleteCause: null });
return await loader.loadByFieldEqualingAsync( return await loader.loadByFieldEqualingAsync(
associatedEntityLookupByField, associatedEntityLookupByField,
associatedFieldValue as any associatedFieldValue as any
@@ -238,7 +243,7 @@ export default class EntityAssociationLoader<
.getViewerContext() .getViewerContext()
.getViewerScopedEntityCompanionForClass(associatedEntityClass) .getViewerScopedEntityCompanionForClass(associatedEntityClass)
.getLoaderFactory() .getLoaderFactory()
.forLoad(queryContext); .forLoad(queryContext, { cascadingDeleteCause: null });
return await loader.loadManyByFieldEqualingAsync( return await loader.loadManyByFieldEqualingAsync(
associatedEntityLookupByField, associatedEntityLookupByField,
associatedFieldValue as any associatedFieldValue as any
@@ -246,7 +251,7 @@ export default class EntityAssociationLoader<
} }
/** /**
* Load an associated entity by folding a sequence of {@link EntityLoadThroughDirective}. At each * Load an associated entity by folding a sequence of EntityLoadThroughDirective. At each
* fold step, load an associated entity identified by a field value of the current fold value. * fold step, load an associated entity identified by a field value of the current fold value.
* @param loadDirectives - associated entity load directives instructing each step of the fold * @param loadDirectives - associated entity load directives instructing each step of the fold
* @param queryContext - query context in which to perform the loads * @param queryContext - query context in which to perform the loads
@@ -280,7 +285,7 @@ export default class EntityAssociationLoader<
): Promise<Result<TEntity2> | null>; ): Promise<Result<TEntity2> | null>;
/** /**
* Load an associated entity by folding a sequence of {@link EntityLoadThroughDirective}. At each * Load an associated entity by folding a sequence of EntityLoadThroughDirective. At each
* fold step, load an associated entity identified by a field value of the current fold value. * fold step, load an associated entity identified by a field value of the current fold value.
* @param loadDirectives - associated entity load directives instructing each step of the fold * @param loadDirectives - associated entity load directives instructing each step of the fold
* @param queryContext - query context in which to perform the loads * @param queryContext - query context in which to perform the loads
@@ -335,7 +340,7 @@ export default class EntityAssociationLoader<
): Promise<Result<TEntity3> | null>; ): Promise<Result<TEntity3> | null>;
/** /**
* Load an associated entity by folding a sequence of {@link EntityLoadThroughDirective}. At each * Load an associated entity by folding a sequence of EntityLoadThroughDirective. At each
* fold step, load an associated entity identified by a field value of the current fold value. * fold step, load an associated entity identified by a field value of the current fold value.
* @param loadDirectives - associated entity load directives instructing each step of the fold * @param loadDirectives - associated entity load directives instructing each step of the fold
* @param queryContext - query context in which to perform the loads * @param queryContext - query context in which to perform the loads
@@ -411,7 +416,7 @@ export default class EntityAssociationLoader<
): Promise<Result<TEntity4> | null>; ): Promise<Result<TEntity4> | null>;
/** /**
* Load an associated entity by folding a sequence of {@link EntityLoadThroughDirective}. At each * Load an associated entity by folding a sequence of EntityLoadThroughDirective. At each
* fold step, load an associated entity identified by a field value of the current fold value. * fold step, load an associated entity identified by a field value of the current fold value.
* @param loadDirectives - associated entity load directives instructing each step of the fold * @param loadDirectives - associated entity load directives instructing each step of the fold
* @param queryContext - query context in which to perform the loads * @param queryContext - query context in which to perform the loads
+3 -11
View File
@@ -1,5 +1,4 @@
import EntityConfiguration from './EntityConfiguration'; import EntityConfiguration from './EntityConfiguration';
import { FieldTransformerMap } from './internal/EntityFieldTransformationUtils';
import { CacheLoadResult } from './internal/ReadThroughEntityCache'; import { CacheLoadResult } from './internal/ReadThroughEntityCache';
/** /**
@@ -9,13 +8,6 @@ import { CacheLoadResult } from './internal/ReadThroughEntityCache';
export default abstract class EntityCacheAdapter<TFields> { export default abstract class EntityCacheAdapter<TFields> {
constructor(protected readonly entityConfiguration: EntityConfiguration<TFields>) {} constructor(protected readonly entityConfiguration: EntityConfiguration<TFields>) {}
/**
* Transformer definitions for field types. Used to modify values as they are read from or written to
* the cache. Override in concrete subclasses to change transformation behavior.
* If a field type is not present in the map, then fields of that type will not be transformed.
*/
public abstract getFieldTransformerMap(): FieldTransformerMap;
/** /**
* Load many objects from cache. * Load many objects from cache.
* @param fieldName - object field being queried * @param fieldName - object field being queried
@@ -25,7 +17,7 @@ export default abstract class EntityCacheAdapter<TFields> {
public abstract loadManyAsync<N extends keyof TFields>( public abstract loadManyAsync<N extends keyof TFields>(
fieldName: N, fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[] fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult>>; ): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult<TFields>>>;
/** /**
* Cache many objects fetched from the DB. * Cache many objects fetched from the DB.
@@ -34,13 +26,13 @@ export default abstract class EntityCacheAdapter<TFields> {
*/ */
public abstract cacheManyAsync<N extends keyof TFields>( public abstract cacheManyAsync<N extends keyof TFields>(
fieldName: N, fieldName: N,
objectMap: ReadonlyMap<NonNullable<TFields[N]>, object> objectMap: ReadonlyMap<NonNullable<TFields[N]>, Readonly<TFields>>
): Promise<void>; ): Promise<void>;
/** /**
* Negatively cache objects that could not be found in the cache or DB. * Negatively cache objects that could not be found in the cache or DB.
* @param fieldName - object field being queried * @param fieldName - object field being queried
* @param fieldValues - fieldValues for objects reported as {@link CacheStatus.NEGATIVE} * @param fieldValues - fieldValues for objects reported as CacheStatus.NEGATIVE
* in the cache and not found in the DB. * in the cache and not found in the DB.
*/ */
public abstract cacheDBMissesAsync<N extends keyof TFields>( public abstract cacheDBMissesAsync<N extends keyof TFields>(
+1 -1
View File
@@ -132,7 +132,7 @@ export default class EntityCompanion<
} }
/** /**
* Get the {@link IEntityMetricsAdapter} for this companion. * Get the IEntityMetricsAdapter for this companion.
*/ */
getMetricsAdapter(): IEntityMetricsAdapter { getMetricsAdapter(): IEntityMetricsAdapter {
return this.metricsAdapter; return this.metricsAdapter;
@@ -109,15 +109,15 @@ export class EntityCompanionDefinition<
TSelectedFields TSelectedFields
>; >;
/** /**
* The {@link EntityConfiguration} for this entity. * The EntityConfiguration for this entity.
*/ */
entityConfiguration: EntityConfiguration<TFields>; entityConfiguration: EntityConfiguration<TFields>;
/** /**
* The {@link EntityPrivacyPolicy} class for this entity. * The EntityPrivacyPolicy class for this entity.
*/ */
privacyPolicyClass: IPrivacyPolicyClass<TPrivacyPolicy>; privacyPolicyClass: IPrivacyPolicyClass<TPrivacyPolicy>;
/** /**
* An optional list of {@link EntityMutationValidator} for this entity. * An optional list of EntityMutationValidator for this entity.
*/ */
mutationValidators?: () => EntityMutationValidator< mutationValidators?: () => EntityMutationValidator<
TFields, TFields,
@@ -127,7 +127,7 @@ export class EntityCompanionDefinition<
TSelectedFields TSelectedFields
>[]; >[];
/** /**
* An optional list of {@link EntityMutationTrigger} for this entity. * An optional list of EntityMutationTrigger for this entity.
*/ */
mutationTriggers?: () => EntityMutationTriggerConfiguration< mutationTriggers?: () => EntityMutationTriggerConfiguration<
TFields, TFields,
@@ -137,8 +137,8 @@ export class EntityCompanionDefinition<
TSelectedFields TSelectedFields
>; >;
/** /**
* An optional subset of fields defined in the {@link EntityConfiguration} which belong to this entity. * 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 ({@link EntityConfiguration}) yet * For use when multiple types of entities are backed by a single table (EntityConfiguration) yet
* only expose a subset of the fields. * only expose a subset of the fields.
*/ */
entitySelectedFields?: TSelectedFields[]; entitySelectedFields?: TSelectedFields[];
@@ -155,11 +155,11 @@ export class EntityCompanionDefinition<
/** /**
* An instance of the Entity framework. * An instance of the Entity framework.
* *
* Required to create a {@link ViewerContext}, which is the application entry point * Required to create a ViewerContext, which is the application entry point
* into the framework. * into the framework.
* *
* Internally, this is a lazy entity companion factory that instantiates and caches one * Internally, this is a lazy entity companion factory that instantiates and caches one
* {@link EntityCompanion} for each type of {@link Entity}. * EntityCompanion for each type of Entity.
*/ */
export default class EntityCompanionProvider { export default class EntityCompanionProvider {
private readonly companionMap: Map<string, EntityCompanion<any, any, any, any, any, any>> = private readonly companionMap: Map<string, EntityCompanion<any, any, any, any, any, any>> =
@@ -169,7 +169,7 @@ export default class EntityCompanionProvider {
/** /**
* Instantiate an Entity framework. * Instantiate an Entity framework.
* @param metricsAdapter - An {@link IEntityMetricsAdapter} for collecting metrics on this instance * @param metricsAdapter - An IEntityMetricsAdapter for collecting metrics on this instance
* @param databaseAdapterFlavors - Database adapter configurations for this instance * @param databaseAdapterFlavors - Database adapter configurations for this instance
* @param cacheAdapterFlavors - Cache adapter configurations for this instance * @param cacheAdapterFlavors - Cache adapter configurations for this instance
*/ */
+2 -2
View File
@@ -41,12 +41,12 @@ export default class EntityConfiguration<TFields> {
tableName: string; tableName: string;
/** /**
* Map from each entity field to an {@link EntityFieldDefinition} specifying information about the field. * Map from each entity field to an EntityFieldDefinition specifying information about the field.
*/ */
schema: Record<keyof TFields, EntityFieldDefinition<any>>; schema: Record<keyof TFields, EntityFieldDefinition<any>>;
/** /**
* List of other entity types that reference this type in {@link EntityFieldDefinition} associations. * List of other entity types that reference this type in EntityFieldDefinition associations.
*/ */
getInboundEdges?: () => IEntityClass<any, any, any, any, any, any>[]; getInboundEdges?: () => IEntityClass<any, any, any, any, any, any>[];
+33 -6
View File
@@ -7,12 +7,18 @@ import {
FieldTransformerMap, FieldTransformerMap,
} from './internal/EntityFieldTransformationUtils'; } from './internal/EntityFieldTransformationUtils';
interface SingleValueFieldEqualityCondition<TFields, N extends keyof TFields = keyof TFields> { export interface SingleValueFieldEqualityCondition<
TFields,
N extends keyof TFields = keyof TFields
> {
fieldName: N; fieldName: N;
fieldValue: TFields[N]; fieldValue: TFields[N];
} }
interface MultiValueFieldEqualityCondition<TFields, N extends keyof TFields = keyof TFields> { export interface MultiValueFieldEqualityCondition<
TFields,
N extends keyof TFields = keyof TFields
> {
fieldName: N; fieldName: N;
fieldValues: readonly TFields[N][]; fieldValues: readonly TFields[N][];
} }
@@ -68,6 +74,14 @@ export interface QuerySelectionModifiers<TFields> {
limit?: number; limit?: number;
} }
export interface QuerySelectionModifiersWithOrderByRaw<TFields>
extends QuerySelectionModifiers<TFields> {
/**
* Order the entities by a raw SQL `ORDER BY` clause.
*/
orderByRaw?: string;
}
export interface TableQuerySelectionModifiers { export interface TableQuerySelectionModifiers {
orderBy: orderBy:
| { | {
@@ -79,6 +93,10 @@ export interface TableQuerySelectionModifiers {
limit: number | undefined; limit: number | undefined;
} }
export interface TableQuerySelectionModifiersWithOrderByRaw extends TableQuerySelectionModifiers {
orderByRaw: string | undefined;
}
/** /**
* A database adapter is an interface by which entity objects can be * A database adapter is an interface by which entity objects can be
* fetched, inserted, updated, and deleted from a database. This base class * fetched, inserted, updated, and deleted from a database. This base class
@@ -149,7 +167,7 @@ export default abstract class EntityDatabaseAdapter<TFields> {
* *
* @param queryContext - query context with which to perform the fetch * @param queryContext - query context with which to perform the fetch
* @param fieldEqualityOperands - list of field equality where clause operand specifications * @param fieldEqualityOperands - list of field equality where clause operand specifications
* @param querySelectionModifiers - limit, offset, and orderBy for the query * @param querySelectionModifiers - limit, offset, orderBy, and orderByRaw for the query
* @returns array of objects matching the query * @returns array of objects matching the query
*/ */
async fetchManyByFieldEqualityConjunctionAsync<N extends keyof TFields>( async fetchManyByFieldEqualityConjunctionAsync<N extends keyof TFields>(
@@ -207,14 +225,14 @@ export default abstract class EntityDatabaseAdapter<TFields> {
queryContext: EntityQueryContext, queryContext: EntityQueryContext,
rawWhereClause: string, rawWhereClause: string,
bindings: any[] | object, bindings: any[] | object,
querySelectionModifiers: QuerySelectionModifiers<TFields> querySelectionModifiers: QuerySelectionModifiersWithOrderByRaw<TFields>
): Promise<readonly Readonly<TFields>[]> { ): Promise<readonly Readonly<TFields>[]> {
const results = await this.fetchManyByRawWhereClauseInternalAsync( const results = await this.fetchManyByRawWhereClauseInternalAsync(
queryContext.getQueryInterface(), queryContext.getQueryInterface(),
this.entityConfiguration.tableName, this.entityConfiguration.tableName,
rawWhereClause, rawWhereClause,
bindings, bindings,
this.convertToTableQueryModifiers(querySelectionModifiers) this.convertToTableQueryModifiersWithOrderByRaw(querySelectionModifiers)
); );
return results.map((result) => return results.map((result) =>
@@ -227,7 +245,7 @@ export default abstract class EntityDatabaseAdapter<TFields> {
tableName: string, tableName: string,
rawWhereClause: string, rawWhereClause: string,
bindings: any[] | object, bindings: any[] | object,
querySelectionModifiers: TableQuerySelectionModifiers querySelectionModifiers: TableQuerySelectionModifiersWithOrderByRaw
): Promise<object[]>; ): Promise<object[]>;
/** /**
@@ -363,6 +381,15 @@ export default abstract class EntityDatabaseAdapter<TFields> {
id: any id: any
): Promise<number>; ): Promise<number>;
private convertToTableQueryModifiersWithOrderByRaw(
querySelectionModifiers: QuerySelectionModifiersWithOrderByRaw<TFields>
): TableQuerySelectionModifiersWithOrderByRaw {
return {
...this.convertToTableQueryModifiers(querySelectionModifiers),
orderByRaw: querySelectionModifiers.orderByRaw,
};
}
private convertToTableQueryModifiers( private convertToTableQueryModifiers(
querySelectionModifiers: QuerySelectionModifiers<TFields> querySelectionModifiers: QuerySelectionModifiers<TFields>
): TableQuerySelectionModifiers { ): TableQuerySelectionModifiers {
+23 -6
View File
@@ -5,13 +5,21 @@ import ViewerContext from './ViewerContext';
export enum EntityEdgeDeletionBehavior { export enum EntityEdgeDeletionBehavior {
/** /**
* Default. Invalidate the cache for all entities that reference the entity * Invalidate the cache for all entities that reference the entity
* being deleted through this field, and transitively run deletions on those entities. * being deleted through this field, and transitively run deletions on those entities.
* This is most useful when the database itself expresses foreign * This is most useful when the database itself expresses foreign
* keys and cascading deletes or set nulls and the entity framework just needs to * keys and cascading deletes and the entity framework just needs to
* be kept consistent with the state of the database. * be kept consistent with the state of the database.
*/ */
CASCADE_DELETE_INVALIDATE_CACHE, CASCADE_DELETE_INVALIDATE_CACHE_ONLY,
/**
* Invalidate the cache for all entities that reference the entity
* being deleted through this field. This is most useful when the database itself expresses
* foreign keys and cascading "SET NULL"s and the entity framework just needs to be
* kept consistent with the state of the database.
*/
SET_NULL_INVALIDATE_CACHE_ONLY,
/** /**
* Delete all entities that reference the entity being deleted through this field. This is very similar * Delete all entities that reference the entity being deleted through this field. This is very similar
@@ -28,6 +36,9 @@ export enum EntityEdgeDeletionBehavior {
SET_NULL, SET_NULL,
} }
/**
* Defines an association between entities. An association is primarily used to define cascading deletion behavior.
*/
export interface EntityAssociationDefinition< export interface EntityAssociationDefinition<
TViewerContext extends ViewerContext, TViewerContext extends ViewerContext,
TAssociatedFields, TAssociatedFields,
@@ -71,18 +82,23 @@ export interface EntityAssociationDefinition<
* *
* @remarks * @remarks
* The entity framework doesn't prescribe a one-size-fits-all solution for referential * The entity framework doesn't prescribe a one-size-fits-all solution for referential
* integrity; instead it exposes mechanisms that supports both database foreign key constraints * integrity; instead it exposes mechanisms that support both database foreign key constraints
* and implicit entity-specified foreign keys. Choosing which approach to use often depends on * and implicit entity-specified foreign keys. Choosing which approach to use often depends on
* application requirements, and sometimes even a mix-and-match is the right choice. * application requirements, and sometimes even a mix-and-match is the right choice.
* *
* - If referential integrity is critical to your application, database foreign key constraints * - If referential integrity is critical to your application, database foreign key constraints
* combined with {@link EntityEdgeDeletionBehavior.CASACDE_DELETE_INVALIDATE_CACHE} are recommended. * combined with EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE_ONLY or
* EntityEdgeDeletionBehavior.SET_NULL_INVALIDATE_CACHE_ONLY are recommended.
* - If the database being used doesn't support foreign keys, then using the entity framework for referential * - If the database being used doesn't support foreign keys, then using the entity framework for referential
* integrity is recommended. * integrity is recommended.
*/ */
edgeDeletionBehavior?: EntityEdgeDeletionBehavior; edgeDeletionBehavior: EntityEdgeDeletionBehavior;
} }
/**
* Definition for a field referencing a column in the underlying database. Specifies things like
* cache behavior and associations, and handles input validation.
*/
export abstract class EntityFieldDefinition<T> { export abstract class EntityFieldDefinition<T> {
readonly columnName: string; readonly columnName: string;
readonly cache: boolean; readonly cache: boolean;
@@ -93,6 +109,7 @@ export abstract class EntityFieldDefinition<T> {
* @param cache - Whether or not to cache loaded instances of the entity by this field. The column name is * @param cache - Whether or not to cache loaded instances of the entity by this field. The column name is
* used to derive a cache key for the cache entry. If true, this column must be able uniquely * used to derive a cache key for the cache entry. If true, this column must be able uniquely
* identify the entity. * identify the entity.
* @param association - Defines the association behavior for an entity that this column references.
*/ */
constructor({ constructor({
columnName, columnName,
+45
View File
@@ -2,59 +2,104 @@ import { validate as validateUUID } from 'uuid';
import { EntityFieldDefinition } from './EntityFieldDefinition'; import { EntityFieldDefinition } from './EntityFieldDefinition';
/**
* EntityFieldDefinition for a column with a JS string type.
*/
export class StringField extends EntityFieldDefinition<string> { export class StringField extends EntityFieldDefinition<string> {
protected validateInputValueInternal(value: string): boolean { protected validateInputValueInternal(value: string): boolean {
return typeof value === 'string'; return typeof value === 'string';
} }
} }
/**
* EntityFieldDefinition for a column with a JS string type.
* Enforces that the string is a valid UUID.
*/
export class UUIDField extends StringField { export class UUIDField extends StringField {
protected override validateInputValueInternal(value: string): boolean { protected override validateInputValueInternal(value: string): boolean {
return validateUUID(value); return validateUUID(value);
} }
} }
/**
* EntityFieldDefinition for a column with a JS Date type.
*/
export class DateField extends EntityFieldDefinition<Date> { export class DateField extends EntityFieldDefinition<Date> {
protected validateInputValueInternal(value: Date): boolean { protected validateInputValueInternal(value: Date): boolean {
return value instanceof Date; return value instanceof Date;
} }
} }
/**
* EntityFieldDefinition for a column with a JS boolean type.
*/
export class BooleanField extends EntityFieldDefinition<boolean> { export class BooleanField extends EntityFieldDefinition<boolean> {
protected validateInputValueInternal(value: boolean): boolean { protected validateInputValueInternal(value: boolean): boolean {
return typeof value === 'boolean'; return typeof value === 'boolean';
} }
} }
/**
* EntityFieldDefinition for a column with a JS number type.
* Enforces that the number is an integer.
*/
export class IntField extends EntityFieldDefinition<number> { export class IntField extends EntityFieldDefinition<number> {
protected validateInputValueInternal(value: number): boolean { protected validateInputValueInternal(value: number): boolean {
return typeof value === 'number' && Number.isInteger(value); return typeof value === 'number' && Number.isInteger(value);
} }
} }
/**
* EntityFieldDefinition for a column with a JS number type.
* Enforces that the number is a float (which includes integers in JS).
*/
export class FloatField extends EntityFieldDefinition<number> { export class FloatField extends EntityFieldDefinition<number> {
protected validateInputValueInternal(value: number): boolean { protected validateInputValueInternal(value: number): boolean {
return typeof value === 'number'; return typeof value === 'number';
} }
} }
/**
* EntityFieldDefinition for a column with a JS string array type.
* Enforces that every member of the string array is a string.
*/
export class StringArrayField extends EntityFieldDefinition<string[]> { export class StringArrayField extends EntityFieldDefinition<string[]> {
protected validateInputValueInternal(value: string[]): boolean { protected validateInputValueInternal(value: string[]): boolean {
return Array.isArray(value) && value.every((subValue) => typeof subValue === 'string'); return Array.isArray(value) && value.every((subValue) => typeof subValue === 'string');
} }
} }
/**
* EntityFieldDefinition for a column with a JS JSON object type.
*/
export class JSONObjectField extends EntityFieldDefinition<object> { export class JSONObjectField extends EntityFieldDefinition<object> {
protected validateInputValueInternal(value: object): boolean { protected validateInputValueInternal(value: object): boolean {
return typeof value === 'object' && !Array.isArray(value); return typeof value === 'object' && !Array.isArray(value);
} }
} }
/**
* EntityFieldDefinition for a enum column with a JS string or number type.
*/
export class EnumField extends EntityFieldDefinition<string | number> { export class EnumField extends EntityFieldDefinition<string | number> {
protected validateInputValueInternal(value: string | number): boolean { protected validateInputValueInternal(value: string | number): boolean {
return typeof value === 'number' || typeof value === 'string'; return typeof value === 'number' || typeof value === 'string';
} }
} }
/**
* EntityFieldDefinition for a column with a JS JSON array type.
*/
export class JSONArrayField extends EntityFieldDefinition<any[]> { export class JSONArrayField extends EntityFieldDefinition<any[]> {
protected validateInputValueInternal(value: any[]): boolean { protected validateInputValueInternal(value: any[]): boolean {
return Array.isArray(value); return Array.isArray(value);
} }
} }
/**
* EntityFieldDefinition for a column that may be a JS JSON array type.
* Does not do any validation.
*/
export class MaybeJSONArrayField extends EntityFieldDefinition<any | any[]> { export class MaybeJSONArrayField extends EntityFieldDefinition<any | any[]> {
protected validateInputValueInternal(_value: any): boolean { protected validateInputValueInternal(_value: any): boolean {
return true; return true;
+16 -9
View File
@@ -8,8 +8,9 @@ import {
FieldEqualityCondition, FieldEqualityCondition,
QuerySelectionModifiers, QuerySelectionModifiers,
isSingleValueFieldEqualityCondition, isSingleValueFieldEqualityCondition,
QuerySelectionModifiersWithOrderByRaw,
} from './EntityDatabaseAdapter'; } from './EntityDatabaseAdapter';
import EntityPrivacyPolicy from './EntityPrivacyPolicy'; import EntityPrivacyPolicy, { EntityPrivacyPolicyEvaluationContext } from './EntityPrivacyPolicy';
import { EntityQueryContext } from './EntityQueryContext'; import { EntityQueryContext } from './EntityQueryContext';
import ReadonlyEntity from './ReadonlyEntity'; import ReadonlyEntity from './ReadonlyEntity';
import ViewerContext from './ViewerContext'; import ViewerContext from './ViewerContext';
@@ -21,7 +22,7 @@ import { mapMap, mapMapAsync } from './utils/collections/maps';
/** /**
* The primary interface for loading entities. All normal loads are batched, * The primary interface for loading entities. All normal loads are batched,
* cached, and authorized against the entity's {@link EntityPrivacyPolicy}. * cached, and authorized against the entity's EntityPrivacyPolicy.
*/ */
export default class EntityLoader< export default class EntityLoader<
TFields, TFields,
@@ -40,6 +41,7 @@ export default class EntityLoader<
constructor( constructor(
private readonly viewerContext: TViewerContext, private readonly viewerContext: TViewerContext,
private readonly queryContext: EntityQueryContext, private readonly queryContext: EntityQueryContext,
private readonly privacyPolicyEvaluationContext: EntityPrivacyPolicyEvaluationContext,
private readonly entityConfiguration: EntityConfiguration<TFields>, private readonly entityConfiguration: EntityConfiguration<TFields>,
private readonly entityClass: IEntityClass< private readonly entityClass: IEntityClass<
TFields, TFields,
@@ -125,7 +127,9 @@ export default class EntityLoader<
const entityResults = await this.loadManyByFieldEqualingAsync(uniqueFieldName, fieldValue); const entityResults = await this.loadManyByFieldEqualingAsync(uniqueFieldName, fieldValue);
invariant( invariant(
entityResults.length <= 1, entityResults.length <= 1,
`loadByFieldEqualing: Multiple entities of type ${this.entityClass.name} found for ${uniqueFieldName}=${fieldValue}` `loadByFieldEqualing: Multiple entities of type ${this.entityClass.name} found for ${String(
uniqueFieldName
)}=${fieldValue}`
); );
return entityResults[0] ?? null; return entityResults[0] ?? null;
} }
@@ -218,6 +222,7 @@ export default class EntityLoader<
this.privacyPolicy.authorizeReadAsync( this.privacyPolicy.authorizeReadAsync(
this.viewerContext, this.viewerContext,
this.queryContext, this.queryContext,
this.privacyPolicyEvaluationContext,
uncheckedEntityResult.value, uncheckedEntityResult.value,
this.metricsAdapter this.metricsAdapter
) )
@@ -240,12 +245,12 @@ export default class EntityLoader<
* bindings: `[1]` * bindings: `[1]`
* Entites returned `WHERE id = 1` * Entites returned `WHERE id = 1`
* *
* {@link http://knexjs.org/#Builder-whereRaw} * http://knexjs.org/#Builder-whereRaw
* {@link http://knexjs.org/#Raw-Bindings} * http://knexjs.org/#Raw-Bindings
* *
* @param rawWhereClause - parameterized SQL WHERE clause with positional binding placeholders or named binding placeholders * @param rawWhereClause - parameterized SQL WHERE clause with positional binding placeholders or named binding placeholders
* @param bindings - array of positional bindings or object of named bindings * @param bindings - array of positional bindings or object of named bindings
* @param querySelectionModifiers - limit, offset, and orderBy for the query * @param querySelectionModifiers - limit, offset, orderBy, and orderByRaw for the query
* @returns array of entity results that match the query, where result error can be UnauthorizedError * @returns array of entity results that match the query, where result error can be UnauthorizedError
* @throws Error when rawWhereClause or bindings are invalid * @throws Error when rawWhereClause or bindings are invalid
* *
@@ -254,7 +259,7 @@ export default class EntityLoader<
async loadManyByRawWhereClauseAsync( async loadManyByRawWhereClauseAsync(
rawWhereClause: string, rawWhereClause: string,
bindings: any[] | object, bindings: any[] | object,
querySelectionModifiers: QuerySelectionModifiers<TFields> = {} querySelectionModifiers: QuerySelectionModifiersWithOrderByRaw<TFields> = {}
): Promise<readonly Result<TEntity>[]> { ): Promise<readonly Result<TEntity>[]> {
const fieldObjects = await this.dataManager.loadManyByRawWhereClauseAsync( const fieldObjects = await this.dataManager.loadManyByRawWhereClauseAsync(
this.queryContext, this.queryContext,
@@ -272,6 +277,7 @@ export default class EntityLoader<
this.privacyPolicy.authorizeReadAsync( this.privacyPolicy.authorizeReadAsync(
this.viewerContext, this.viewerContext,
this.queryContext, this.queryContext,
this.privacyPolicyEvaluationContext,
uncheckedEntityResult.value, uncheckedEntityResult.value,
this.metricsAdapter this.metricsAdapter
) )
@@ -281,7 +287,7 @@ export default class EntityLoader<
} }
/** /**
* Invalidate all caches for an entity's fields. Exposed primarily for internal use by {@link EntityMutator}. * Invalidate all caches for an entity's fields. Exposed primarily for internal use by EntityMutator.
* @param objectFields - entity data object to be invalidated * @param objectFields - entity data object to be invalidated
*/ */
async invalidateFieldsAsync(objectFields: Readonly<TFields>): Promise<void> { async invalidateFieldsAsync(objectFields: Readonly<TFields>): Promise<void> {
@@ -332,6 +338,7 @@ export default class EntityLoader<
this.privacyPolicy.authorizeReadAsync( this.privacyPolicy.authorizeReadAsync(
this.viewerContext, this.viewerContext,
this.queryContext, this.queryContext,
this.privacyPolicyEvaluationContext,
uncheckedEntityResult.value, uncheckedEntityResult.value,
this.metricsAdapter this.metricsAdapter
) )
@@ -346,7 +353,7 @@ export default class EntityLoader<
fieldValues: readonly TFields[N][] fieldValues: readonly TFields[N][]
): void { ): void {
const fieldDefinition = this.entityConfiguration.schema.get(fieldName); const fieldDefinition = this.entityConfiguration.schema.get(fieldName);
invariant(fieldDefinition, `must have field definition for field = ${fieldName}`); invariant(fieldDefinition, `must have field definition for field = ${String(fieldName)}`);
for (const fieldValue of fieldValues) { for (const fieldValue of fieldValues) {
const isInputValid = fieldDefinition.validateInputValue(fieldValue); const isInputValid = fieldDefinition.validateInputValue(fieldValue);
if (!isInputValid) { if (!isInputValid) {
+4 -2
View File
@@ -1,7 +1,7 @@
import { IEntityClass } from './Entity'; import { IEntityClass } from './Entity';
import EntityConfiguration from './EntityConfiguration'; import EntityConfiguration from './EntityConfiguration';
import EntityLoader from './EntityLoader'; import EntityLoader from './EntityLoader';
import EntityPrivacyPolicy from './EntityPrivacyPolicy'; import EntityPrivacyPolicy, { EntityPrivacyPolicyEvaluationContext } from './EntityPrivacyPolicy';
import { EntityQueryContext } from './EntityQueryContext'; import { EntityQueryContext } from './EntityQueryContext';
import ReadonlyEntity from './ReadonlyEntity'; import ReadonlyEntity from './ReadonlyEntity';
import ViewerContext from './ViewerContext'; import ViewerContext from './ViewerContext';
@@ -47,11 +47,13 @@ export default class EntityLoaderFactory<
*/ */
forLoad( forLoad(
viewerContext: TViewerContext, viewerContext: TViewerContext,
queryContext: EntityQueryContext queryContext: EntityQueryContext,
privacyPolicyEvaluationContext: EntityPrivacyPolicyEvaluationContext
): EntityLoader<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy, TSelectedFields> { ): EntityLoader<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy, TSelectedFields> {
return new EntityLoader( return new EntityLoader(
viewerContext, viewerContext,
queryContext, queryContext,
privacyPolicyEvaluationContext,
this.entityConfiguration, this.entityConfiguration,
this.entityClass, this.entityClass,
this.privacyPolicyClass, this.privacyPolicyClass,
+15 -3
View File
@@ -20,11 +20,22 @@ export type EntityValidatorMutationInfo<
| { | {
type: EntityMutationType.UPDATE; type: EntityMutationType.UPDATE;
previousValue: TEntity; previousValue: TEntity;
cascadingDeleteCause: EntityCascadingDeletionInfo | null;
}; };
export type EntityMutationTriggerDeleteCascadeInfo<> = { /**
* Information about a cascading deletion.
*/
export type EntityCascadingDeletionInfo = {
/**
* The entity that is being mutated at this step in the cascaded deletion.
*/
entity: Entity<any, any, any, any>; entity: Entity<any, any, any, any>;
cascadingDeleteCause: EntityMutationTriggerDeleteCascadeInfo | null;
/**
* The cascade deletion that caused this mutation.
*/
cascadingDeleteCause: EntityCascadingDeletionInfo | null;
}; };
export type EntityTriggerMutationInfo< export type EntityTriggerMutationInfo<
@@ -40,8 +51,9 @@ export type EntityTriggerMutationInfo<
| { | {
type: EntityMutationType.UPDATE; type: EntityMutationType.UPDATE;
previousValue: TEntity; previousValue: TEntity;
cascadingDeleteCause: EntityCascadingDeletionInfo | null;
} }
| { | {
type: EntityMutationType.DELETE; type: EntityMutationType.DELETE;
cascadingDeleteCause: EntityMutationTriggerDeleteCascadeInfo | null; cascadingDeleteCause: EntityCascadingDeletionInfo | null;
}; };
@@ -85,7 +85,7 @@ export abstract class EntityMutationTrigger<
} }
/** /**
* A non-transactional trigger is like a {@link EntityMutationTrigger} but used for afterCommit triggers * A non-transactional trigger is like a EntityMutationTrigger but used for afterCommit triggers
* since they explicitly occur outside of the transaction. * since they explicitly occur outside of the transaction.
*/ */
export abstract class EntityNonTransactionalMutationTrigger< export abstract class EntityNonTransactionalMutationTrigger<
+91 -37
View File
@@ -11,7 +11,7 @@ import {
EntityValidatorMutationInfo, EntityValidatorMutationInfo,
EntityMutationType, EntityMutationType,
EntityTriggerMutationInfo, EntityTriggerMutationInfo,
EntityMutationTriggerDeleteCascadeInfo, EntityCascadingDeletionInfo,
} from './EntityMutationInfo'; } from './EntityMutationInfo';
import EntityMutationTriggerConfiguration, { import EntityMutationTriggerConfiguration, {
EntityMutationTrigger, EntityMutationTrigger,
@@ -182,7 +182,7 @@ export class CreateMutator<
/** /**
* Commit the new entity after authorizing against creation privacy rules. Invalidates all caches for * Commit the new entity after authorizing against creation privacy rules. Invalidates all caches for
* queries that would return new entity and caches the new entity if not in a transactional query context. * queries that would return new entity.
* @returns authorized, cached, newly-created entity result, where result error can be UnauthorizedError * @returns authorized, cached, newly-created entity result, where result error can be UnauthorizedError
*/ */
async createAsync(): Promise<Result<TEntity>> { async createAsync(): Promise<Result<TEntity>> {
@@ -220,6 +220,7 @@ export class CreateMutator<
this.privacyPolicy.authorizeCreateAsync( this.privacyPolicy.authorizeCreateAsync(
this.viewerContext, this.viewerContext,
queryContext, queryContext,
{ cascadingDeleteCause: null },
temporaryEntityForPrivacyCheck, temporaryEntityForPrivacyCheck,
this.metricsAdapter this.metricsAdapter
) )
@@ -249,7 +250,9 @@ export class CreateMutator<
const insertResult = await this.databaseAdapter.insertAsync(queryContext, this.fieldsForEntity); const insertResult = await this.databaseAdapter.insertAsync(queryContext, this.fieldsForEntity);
const entityLoader = this.entityLoaderFactory.forLoad(this.viewerContext, queryContext); const entityLoader = this.entityLoaderFactory.forLoad(this.viewerContext, queryContext, {
cascadingDeleteCause: null,
});
queryContext.appendPostCommitInvalidationCallback( queryContext.appendPostCommitInvalidationCallback(
entityLoader.invalidateFieldsAsync.bind(entityLoader, insertResult) entityLoader.invalidateFieldsAsync.bind(entityLoader, insertResult)
); );
@@ -374,8 +377,7 @@ export class UpdateMutator<
/** /**
* Commit the changes to the entity after authorizing against update privacy rules. * Commit the changes to the entity after authorizing against update privacy rules.
* Invalidates all caches for pre-update entity and caches the updated entity if not in a * Invalidates all caches for pre-update entity.
* transactional query context.
* @returns authorized updated entity result, where result error can be UnauthorizedError * @returns authorized updated entity result, where result error can be UnauthorizedError
*/ */
async updateAsync(): Promise<Result<TEntity>> { async updateAsync(): Promise<Result<TEntity>> {
@@ -383,7 +385,7 @@ export class UpdateMutator<
this.metricsAdapter, this.metricsAdapter,
EntityMetricsMutationType.UPDATE, EntityMetricsMutationType.UPDATE,
this.entityClass.name this.entityClass.name
)(this.updateInTransactionAsync()); )(this.updateInTransactionAsync(false, null));
} }
/** /**
@@ -393,14 +395,27 @@ export class UpdateMutator<
return await enforceAsyncResult(this.updateAsync()); return await enforceAsyncResult(this.updateAsync());
} }
private async updateInTransactionAsync(): Promise<Result<TEntity>> { private async updateInTransactionAsync(
skipDatabaseUpdate: true,
cascadingDeleteCause: EntityCascadingDeletionInfo
): Promise<Result<TEntity>>;
private async updateInTransactionAsync(
skipDatabaseUpdate: false,
cascadingDeleteCause: EntityCascadingDeletionInfo | null
): Promise<Result<TEntity>>;
private async updateInTransactionAsync(
skipDatabaseUpdate: boolean,
cascadingDeleteCause: EntityCascadingDeletionInfo | null
): Promise<Result<TEntity>> {
return await this.queryContext.runInTransactionIfNotInTransactionAsync((innerQueryContext) => return await this.queryContext.runInTransactionIfNotInTransactionAsync((innerQueryContext) =>
this.updateInternalAsync(innerQueryContext) this.updateInternalAsync(innerQueryContext, skipDatabaseUpdate, cascadingDeleteCause)
); );
} }
private async updateInternalAsync( private async updateInternalAsync(
queryContext: EntityTransactionalQueryContext queryContext: EntityTransactionalQueryContext,
skipDatabaseUpdate: boolean,
cascadingDeleteCause: EntityCascadingDeletionInfo | null
): Promise<Result<TEntity>> { ): Promise<Result<TEntity>> {
this.validateFields(this.updatedFields); this.validateFields(this.updatedFields);
@@ -409,6 +424,7 @@ export class UpdateMutator<
this.privacyPolicy.authorizeUpdateAsync( this.privacyPolicy.authorizeUpdateAsync(
this.viewerContext, this.viewerContext,
queryContext, queryContext,
{ cascadingDeleteCause },
entityAboutToBeUpdated, entityAboutToBeUpdated,
this.metricsAdapter this.metricsAdapter
) )
@@ -421,29 +437,34 @@ export class UpdateMutator<
this.mutationValidators, this.mutationValidators,
queryContext, queryContext,
entityAboutToBeUpdated, entityAboutToBeUpdated,
{ type: EntityMutationType.UPDATE, previousValue: this.originalEntity } { type: EntityMutationType.UPDATE, previousValue: this.originalEntity, cascadingDeleteCause }
); );
await this.executeMutationTriggersAsync( await this.executeMutationTriggersAsync(
this.mutationTriggers.beforeAll, this.mutationTriggers.beforeAll,
queryContext, queryContext,
entityAboutToBeUpdated, entityAboutToBeUpdated,
{ type: EntityMutationType.UPDATE, previousValue: this.originalEntity } { type: EntityMutationType.UPDATE, previousValue: this.originalEntity, cascadingDeleteCause }
); );
await this.executeMutationTriggersAsync( await this.executeMutationTriggersAsync(
this.mutationTriggers.beforeUpdate, this.mutationTriggers.beforeUpdate,
queryContext, queryContext,
entityAboutToBeUpdated, entityAboutToBeUpdated,
{ type: EntityMutationType.UPDATE, previousValue: this.originalEntity } { type: EntityMutationType.UPDATE, previousValue: this.originalEntity, cascadingDeleteCause }
); );
const updateResult = await this.databaseAdapter.updateAsync( // skip the database update when specified
const updateResult = skipDatabaseUpdate
? null
: await this.databaseAdapter.updateAsync(
queryContext, queryContext,
this.entityConfiguration.idField, this.entityConfiguration.idField,
entityAboutToBeUpdated.getID(), entityAboutToBeUpdated.getID(),
this.updatedFields this.updatedFields
); );
const entityLoader = this.entityLoaderFactory.forLoad(this.viewerContext, queryContext); const entityLoader = this.entityLoaderFactory.forLoad(this.viewerContext, queryContext, {
cascadingDeleteCause,
});
queryContext.appendPostCommitInvalidationCallback( queryContext.appendPostCommitInvalidationCallback(
entityLoader.invalidateFieldsAsync.bind( entityLoader.invalidateFieldsAsync.bind(
@@ -455,22 +476,25 @@ export class UpdateMutator<
entityLoader.invalidateFieldsAsync.bind(entityLoader, this.fieldsForEntity) entityLoader.invalidateFieldsAsync.bind(entityLoader, this.fieldsForEntity)
); );
const unauthorizedEntityAfterUpdate = new this.entityClass(this.viewerContext, updateResult); // when the database update was skipped, assume it succeeded and use the optimistic
const updatedEntity = await entityLoader // entity to execute triggers
const updatedEntity = updateResult
? await entityLoader
.enforcing() .enforcing()
.loadByIDAsync(unauthorizedEntityAfterUpdate.getID()); .loadByIDAsync(new this.entityClass(this.viewerContext, updateResult).getID())
: entityAboutToBeUpdated;
await this.executeMutationTriggersAsync( await this.executeMutationTriggersAsync(
this.mutationTriggers.afterUpdate, this.mutationTriggers.afterUpdate,
queryContext, queryContext,
updatedEntity, updatedEntity,
{ type: EntityMutationType.UPDATE, previousValue: this.originalEntity } { type: EntityMutationType.UPDATE, previousValue: this.originalEntity, cascadingDeleteCause }
); );
await this.executeMutationTriggersAsync( await this.executeMutationTriggersAsync(
this.mutationTriggers.afterAll, this.mutationTriggers.afterAll,
queryContext, queryContext,
updatedEntity, updatedEntity,
{ type: EntityMutationType.UPDATE, previousValue: this.originalEntity } { type: EntityMutationType.UPDATE, previousValue: this.originalEntity, cascadingDeleteCause }
); );
queryContext.appendPostCommitCallback( queryContext.appendPostCommitCallback(
@@ -478,7 +502,11 @@ export class UpdateMutator<
this, this,
this.mutationTriggers.afterCommit, this.mutationTriggers.afterCommit,
updatedEntity, updatedEntity,
{ type: EntityMutationType.UPDATE, previousValue: this.originalEntity } {
type: EntityMutationType.UPDATE,
previousValue: this.originalEntity,
cascadingDeleteCause,
}
) )
); );
@@ -578,7 +606,7 @@ export class DeleteMutator<
private async deleteInTransactionAsync( private async deleteInTransactionAsync(
processedEntityIdentifiersFromTransitiveDeletions: Set<string>, processedEntityIdentifiersFromTransitiveDeletions: Set<string>,
skipDatabaseDeletion: boolean, skipDatabaseDeletion: boolean,
cascadingDeleteCause: EntityMutationTriggerDeleteCascadeInfo | null cascadingDeleteCause: EntityCascadingDeletionInfo | null
): Promise<Result<void>> { ): Promise<Result<void>> {
return await this.queryContext.runInTransactionIfNotInTransactionAsync((innerQueryContext) => return await this.queryContext.runInTransactionIfNotInTransactionAsync((innerQueryContext) =>
this.deleteInternalAsync( this.deleteInternalAsync(
@@ -594,12 +622,13 @@ export class DeleteMutator<
queryContext: EntityTransactionalQueryContext, queryContext: EntityTransactionalQueryContext,
processedEntityIdentifiersFromTransitiveDeletions: Set<string>, processedEntityIdentifiersFromTransitiveDeletions: Set<string>,
skipDatabaseDeletion: boolean, skipDatabaseDeletion: boolean,
cascadingDeleteCause: EntityMutationTriggerDeleteCascadeInfo | null cascadingDeleteCause: EntityCascadingDeletionInfo | null
): Promise<Result<void>> { ): Promise<Result<void>> {
const authorizeDeleteResult = await asyncResult( const authorizeDeleteResult = await asyncResult(
this.privacyPolicy.authorizeDeleteAsync( this.privacyPolicy.authorizeDeleteAsync(
this.viewerContext, this.viewerContext,
queryContext, queryContext,
{ cascadingDeleteCause },
this.entity, this.entity,
this.metricsAdapter this.metricsAdapter
) )
@@ -636,7 +665,9 @@ export class DeleteMutator<
); );
} }
const entityLoader = this.entityLoaderFactory.forLoad(this.viewerContext, queryContext); const entityLoader = this.entityLoaderFactory.forLoad(this.viewerContext, queryContext, {
cascadingDeleteCause,
});
queryContext.appendPostCommitInvalidationCallback( queryContext.appendPostCommitInvalidationCallback(
entityLoader.invalidateFieldsAsync.bind(entityLoader, this.entity.getAllDatabaseFields()) entityLoader.invalidateFieldsAsync.bind(entityLoader, this.entity.getAllDatabaseFields())
); );
@@ -669,7 +700,7 @@ export class DeleteMutator<
/** /**
* Finds all entities referencing the specified entity and either deletes them, nullifies * Finds all entities referencing the specified entity and either deletes them, nullifies
* their references to the specified entity, or invalidates the cache depending on the * their references to the specified entity, or invalidates the cache depending on the
* {@link OnDeleteBehavior} of the field referencing the specified entity. * OnDeleteBehavior of the field referencing the specified entity.
* *
* @remarks * @remarks
* This works by doing reverse fan-out queries: * This works by doing reverse fan-out queries:
@@ -684,7 +715,7 @@ export class DeleteMutator<
entity: TEntity, entity: TEntity,
queryContext: EntityTransactionalQueryContext, queryContext: EntityTransactionalQueryContext,
processedEntityIdentifiers: Set<string>, processedEntityIdentifiers: Set<string>,
cascadingDeleteCause: EntityMutationTriggerDeleteCascadeInfo | null cascadingDeleteCause: EntityCascadingDeletionInfo | null
): Promise<void> { ): Promise<void> {
// prevent infinite reference cycles by keeping track of entities already processed // prevent infinite reference cycles by keeping track of entities already processed
if (processedEntityIdentifiers.has(entity.getUniqueIdentifier())) { if (processedEntityIdentifiers.has(entity.getUniqueIdentifier())) {
@@ -732,10 +763,15 @@ export class DeleteMutator<
.getViewerScopedEntityCompanionForClass(entityClass) .getViewerScopedEntityCompanionForClass(entityClass)
.getMutatorFactory(); .getMutatorFactory();
const newCascadingDeleteCause = {
entity,
cascadingDeleteCause,
};
let inboundReferenceEntities: readonly ReadonlyEntity<any, any, any, any>[]; let inboundReferenceEntities: readonly ReadonlyEntity<any, any, any, any>[];
if (associatedEntityLookupByField) { if (associatedEntityLookupByField) {
inboundReferenceEntities = await loaderFactory inboundReferenceEntities = await loaderFactory
.forLoad(queryContext) .forLoad(queryContext, { cascadingDeleteCause: newCascadingDeleteCause })
.enforcing() .enforcing()
.loadManyByFieldEqualingAsync( .loadManyByFieldEqualingAsync(
fieldName, fieldName,
@@ -743,25 +779,39 @@ export class DeleteMutator<
); );
} else { } else {
inboundReferenceEntities = await loaderFactory inboundReferenceEntities = await loaderFactory
.forLoad(queryContext) .forLoad(queryContext, { cascadingDeleteCause: newCascadingDeleteCause })
.enforcing() .enforcing()
.loadManyByFieldEqualingAsync(fieldName, entity.getID()); .loadManyByFieldEqualingAsync(fieldName, entity.getID());
} }
switch (association.edgeDeletionBehavior) { switch (association.edgeDeletionBehavior) {
case undefined: case EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE_ONLY: {
case EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE: {
await Promise.all( await Promise.all(
inboundReferenceEntities.map((inboundReferenceEntity) => inboundReferenceEntities.map((inboundReferenceEntity) =>
enforceAsyncResult(
mutatorFactory mutatorFactory
.forDelete(inboundReferenceEntity, queryContext) .forDelete(inboundReferenceEntity, queryContext)
.deleteInTransactionAsync( .deleteInTransactionAsync(
processedEntityIdentifiers, processedEntityIdentifiers,
/* skipDatabaseDeletion */ true, // deletion is handled by DB /* skipDatabaseDeletion */ true, // deletion is handled by DB
{ newCascadingDeleteCause
entity, )
cascadingDeleteCause, )
)
);
break;
} }
case EntityEdgeDeletionBehavior.SET_NULL_INVALIDATE_CACHE_ONLY: {
await Promise.all(
inboundReferenceEntities.map((inboundReferenceEntity) =>
enforceAsyncResult(
mutatorFactory
.forUpdate(inboundReferenceEntity, queryContext)
.setField(fieldName, null)
['updateInTransactionAsync'](
/* skipDatabaseUpdate */ true,
newCascadingDeleteCause
)
) )
) )
); );
@@ -770,10 +820,15 @@ export class DeleteMutator<
case EntityEdgeDeletionBehavior.SET_NULL: { case EntityEdgeDeletionBehavior.SET_NULL: {
await Promise.all( await Promise.all(
inboundReferenceEntities.map((inboundReferenceEntity) => inboundReferenceEntities.map((inboundReferenceEntity) =>
enforceAsyncResult(
mutatorFactory mutatorFactory
.forUpdate(inboundReferenceEntity, queryContext) .forUpdate(inboundReferenceEntity, queryContext)
.setField(fieldName, null) .setField(fieldName, null)
.enforceUpdateAsync() ['updateInTransactionAsync'](
/* skipDatabaseUpdate */ false,
newCascadingDeleteCause
)
)
) )
); );
break; break;
@@ -781,15 +836,14 @@ export class DeleteMutator<
case EntityEdgeDeletionBehavior.CASCADE_DELETE: { case EntityEdgeDeletionBehavior.CASCADE_DELETE: {
await Promise.all( await Promise.all(
inboundReferenceEntities.map((inboundReferenceEntity) => inboundReferenceEntities.map((inboundReferenceEntity) =>
enforceAsyncResult(
mutatorFactory mutatorFactory
.forDelete(inboundReferenceEntity, queryContext) .forDelete(inboundReferenceEntity, queryContext)
.deleteInTransactionAsync( .deleteInTransactionAsync(
processedEntityIdentifiers, processedEntityIdentifiers,
/* skipDatabaseDeletion */ false, /* skipDatabaseDeletion */ false,
{ newCascadingDeleteCause
entity, )
cascadingDeleteCause,
}
) )
) )
); );
+51 -6
View File
@@ -1,3 +1,4 @@
import { EntityCascadingDeletionInfo } from './EntityMutationInfo';
import { EntityQueryContext } from './EntityQueryContext'; import { EntityQueryContext } from './EntityQueryContext';
import ReadonlyEntity from './ReadonlyEntity'; import ReadonlyEntity from './ReadonlyEntity';
import ViewerContext from './ViewerContext'; import ViewerContext from './ViewerContext';
@@ -7,9 +8,35 @@ import IEntityMetricsAdapter, {
} from './metrics/IEntityMetricsAdapter'; } from './metrics/IEntityMetricsAdapter';
import PrivacyPolicyRule, { RuleEvaluationResult } from './rules/PrivacyPolicyRule'; import PrivacyPolicyRule, { RuleEvaluationResult } from './rules/PrivacyPolicyRule';
/**
* Information about the reason this privacy policy is being evaluated.
*/
export type EntityPrivacyPolicyEvaluationContext = {
/**
* When this privacy policy is being evaluated as a result of a cascading deletion, this will be populated
* with information on the cascading delete.
*/
cascadingDeleteCause: EntityCascadingDeletionInfo | null;
};
/**
* Evaluation mode for a EntityPrivacyPolicy. Useful when transitioning to
* using Entity for privacy.
*/
export enum EntityPrivacyPolicyEvaluationMode { export enum EntityPrivacyPolicyEvaluationMode {
/**
* Enforce this privacy policy. Throw upon denial.
*/
ENFORCE, ENFORCE,
/**
* Do not enforce this privacy policy. Always allow but log when it would have denied.
*/
DRY_RUN, DRY_RUN,
/**
* Enforce this privacy policy. Throw and log upon denial.
*/
ENFORCE_AND_LOG, ENFORCE_AND_LOG,
} }
@@ -48,7 +75,7 @@ export enum EntityAuthorizationAction {
* *
* @remarks * @remarks
* *
* A privacy policy declares lists of {@link PrivacyPolicyRule} for create, read, update, and delete actions * A privacy policy declares lists of PrivacyPolicyRule for create, read, update, and delete actions
* for an entity and provides logic for authorizing an entity against rules. * for an entity and provides logic for authorizing an entity against rules.
* *
* Evaluation of a list of rules is performed according the following example. This allows constructing of * Evaluation of a list of rules is performed according the following example. This allows constructing of
@@ -122,11 +149,12 @@ export default abstract class EntityPrivacyPolicy<
* @param queryContext - query context in which to perform the create authorization * @param queryContext - query context in which to perform the create authorization
* @param entity - entity to authorize * @param entity - entity to authorize
* @returns entity if authorized * @returns entity if authorized
* @throws {@link EntityNotAuthorizedError} when not authorized * @throws EntityNotAuthorizedError when not authorized
*/ */
async authorizeCreateAsync( async authorizeCreateAsync(
viewerContext: TViewerContext, viewerContext: TViewerContext,
queryContext: EntityQueryContext, queryContext: EntityQueryContext,
evaluationContext: EntityPrivacyPolicyEvaluationContext,
entity: TEntity, entity: TEntity,
metricsAdapter: IEntityMetricsAdapter metricsAdapter: IEntityMetricsAdapter
): Promise<TEntity> { ): Promise<TEntity> {
@@ -134,6 +162,7 @@ export default abstract class EntityPrivacyPolicy<
this.createRules, this.createRules,
viewerContext, viewerContext,
queryContext, queryContext,
evaluationContext,
entity, entity,
EntityAuthorizationAction.CREATE, EntityAuthorizationAction.CREATE,
metricsAdapter metricsAdapter
@@ -146,11 +175,12 @@ export default abstract class EntityPrivacyPolicy<
* @param queryContext - query context in which to perform the read authorization * @param queryContext - query context in which to perform the read authorization
* @param entity - entity to authorize * @param entity - entity to authorize
* @returns entity if authorized * @returns entity if authorized
* @throws {@link EntityNotAuthorizedError} when not authorized * @throws EntityNotAuthorizedError when not authorized
*/ */
async authorizeReadAsync( async authorizeReadAsync(
viewerContext: TViewerContext, viewerContext: TViewerContext,
queryContext: EntityQueryContext, queryContext: EntityQueryContext,
evaluationContext: EntityPrivacyPolicyEvaluationContext,
entity: TEntity, entity: TEntity,
metricsAdapter: IEntityMetricsAdapter metricsAdapter: IEntityMetricsAdapter
): Promise<TEntity> { ): Promise<TEntity> {
@@ -158,6 +188,7 @@ export default abstract class EntityPrivacyPolicy<
this.readRules, this.readRules,
viewerContext, viewerContext,
queryContext, queryContext,
evaluationContext,
entity, entity,
EntityAuthorizationAction.READ, EntityAuthorizationAction.READ,
metricsAdapter metricsAdapter
@@ -170,11 +201,12 @@ export default abstract class EntityPrivacyPolicy<
* @param queryContext - query context in which to perform the update authorization * @param queryContext - query context in which to perform the update authorization
* @param entity - entity to authorize * @param entity - entity to authorize
* @returns entity if authorized * @returns entity if authorized
* @throws {@link EntityNotAuthorizedError} when not authorized * @throws EntityNotAuthorizedError when not authorized
*/ */
async authorizeUpdateAsync( async authorizeUpdateAsync(
viewerContext: TViewerContext, viewerContext: TViewerContext,
queryContext: EntityQueryContext, queryContext: EntityQueryContext,
evaluationContext: EntityPrivacyPolicyEvaluationContext,
entity: TEntity, entity: TEntity,
metricsAdapter: IEntityMetricsAdapter metricsAdapter: IEntityMetricsAdapter
): Promise<TEntity> { ): Promise<TEntity> {
@@ -182,6 +214,7 @@ export default abstract class EntityPrivacyPolicy<
this.updateRules, this.updateRules,
viewerContext, viewerContext,
queryContext, queryContext,
evaluationContext,
entity, entity,
EntityAuthorizationAction.UPDATE, EntityAuthorizationAction.UPDATE,
metricsAdapter metricsAdapter
@@ -194,11 +227,12 @@ export default abstract class EntityPrivacyPolicy<
* @param queryContext - query context in which to perform the delete authorization * @param queryContext - query context in which to perform the delete authorization
* @param entity - entity to authorize * @param entity - entity to authorize
* @returns entity if authorized * @returns entity if authorized
* @throws {@link EntityNotAuthorizedError} when not authorized * @throws EntityNotAuthorizedError when not authorized
*/ */
async authorizeDeleteAsync( async authorizeDeleteAsync(
viewerContext: TViewerContext, viewerContext: TViewerContext,
queryContext: EntityQueryContext, queryContext: EntityQueryContext,
evaluationContext: EntityPrivacyPolicyEvaluationContext,
entity: TEntity, entity: TEntity,
metricsAdapter: IEntityMetricsAdapter metricsAdapter: IEntityMetricsAdapter
): Promise<TEntity> { ): Promise<TEntity> {
@@ -206,6 +240,7 @@ export default abstract class EntityPrivacyPolicy<
this.deleteRules, this.deleteRules,
viewerContext, viewerContext,
queryContext, queryContext,
evaluationContext,
entity, entity,
EntityAuthorizationAction.DELETE, EntityAuthorizationAction.DELETE,
metricsAdapter metricsAdapter
@@ -216,6 +251,7 @@ export default abstract class EntityPrivacyPolicy<
ruleset: readonly PrivacyPolicyRule<TFields, TID, TViewerContext, TEntity, TSelectedFields>[], ruleset: readonly PrivacyPolicyRule<TFields, TID, TViewerContext, TEntity, TSelectedFields>[],
viewerContext: TViewerContext, viewerContext: TViewerContext,
queryContext: EntityQueryContext, queryContext: EntityQueryContext,
evaluationContext: EntityPrivacyPolicyEvaluationContext,
entity: TEntity, entity: TEntity,
action: EntityAuthorizationAction, action: EntityAuthorizationAction,
metricsAdapter: IEntityMetricsAdapter metricsAdapter: IEntityMetricsAdapter
@@ -228,6 +264,7 @@ export default abstract class EntityPrivacyPolicy<
ruleset, ruleset,
viewerContext, viewerContext,
queryContext, queryContext,
evaluationContext,
entity, entity,
action action
); );
@@ -256,6 +293,7 @@ export default abstract class EntityPrivacyPolicy<
ruleset, ruleset,
viewerContext, viewerContext,
queryContext, queryContext,
evaluationContext,
entity, entity,
action action
); );
@@ -285,6 +323,7 @@ export default abstract class EntityPrivacyPolicy<
ruleset, ruleset,
viewerContext, viewerContext,
queryContext, queryContext,
evaluationContext,
entity, entity,
action action
); );
@@ -315,12 +354,18 @@ export default abstract class EntityPrivacyPolicy<
ruleset: readonly PrivacyPolicyRule<TFields, TID, TViewerContext, TEntity, TSelectedFields>[], ruleset: readonly PrivacyPolicyRule<TFields, TID, TViewerContext, TEntity, TSelectedFields>[],
viewerContext: TViewerContext, viewerContext: TViewerContext,
queryContext: EntityQueryContext, queryContext: EntityQueryContext,
evaluationContext: EntityPrivacyPolicyEvaluationContext,
entity: TEntity, entity: TEntity,
action: EntityAuthorizationAction action: EntityAuthorizationAction
): Promise<TEntity> { ): Promise<TEntity> {
for (let i = 0; i < ruleset.length; i++) { for (let i = 0; i < ruleset.length; i++) {
const rule = ruleset[i]!; const rule = ruleset[i]!;
const ruleEvaluationResult = await rule.evaluateAsync(viewerContext, queryContext, entity); const ruleEvaluationResult = await rule.evaluateAsync(
viewerContext,
queryContext,
evaluationContext,
entity
);
switch (ruleEvaluationResult) { switch (ruleEvaluationResult) {
case RuleEvaluationResult.DENY: case RuleEvaluationResult.DENY:
throw new EntityNotAuthorizedError< throw new EntityNotAuthorizedError<
+12 -1
View File
@@ -12,7 +12,7 @@ export type PreCommitCallback = (
* Entity framework representation of transactional and non-transactional database * Entity framework representation of transactional and non-transactional database
* query execution units. * query execution units.
* *
* The behavior of {@link EntityMutator} and {@link EntityLoader} * The behavior of EntityMutator and EntityLoader
* differs when in a transactional context. * differs when in a transactional context.
*/ */
export abstract class EntityQueryContext { export abstract class EntityQueryContext {
@@ -29,6 +29,12 @@ export abstract class EntityQueryContext {
): Promise<T>; ): Promise<T>;
} }
/**
* Entity framework representation of a non-transactional query execution unit.
* When supplied to EntityMutator and EntityLoader methods, they will be
* run independently of any running transaction (though mutations start their own
* independent transactions internally when not being run in a transaction).
*/
export class EntityNonTransactionalQueryContext extends EntityQueryContext { export class EntityNonTransactionalQueryContext extends EntityQueryContext {
constructor( constructor(
queryInterface: any, queryInterface: any,
@@ -48,6 +54,11 @@ export class EntityNonTransactionalQueryContext extends EntityQueryContext {
} }
} }
/**
* Entity framework representation of a transactional query execution unit. When supplied
* to EntityMutator and EntityLoader methods, those methods and their
* dependent triggers and validators will run within the transaction.
*/
export class EntityTransactionalQueryContext extends EntityQueryContext { export class EntityTransactionalQueryContext extends EntityQueryContext {
private readonly postCommitInvalidationCallbacks: PostCommitCallback[] = []; private readonly postCommitInvalidationCallbacks: PostCommitCallback[] = [];
private readonly postCommitCallbacks: PostCommitCallback[] = []; private readonly postCommitCallbacks: PostCommitCallback[] = [];
@@ -20,7 +20,7 @@ export default abstract class EntityQueryContextProvider {
protected abstract getQueryInterface(): any; protected abstract getQueryInterface(): any;
/** /**
* Vend a transaction runner for use in {@link runInTransactionAsync}. * Vend a transaction runner for use in runInTransactionAsync.
*/ */
protected abstract createTransactionRunner<T>(): ( protected abstract createTransactionRunner<T>(): (
transactionScope: (queryInterface: any) => Promise<T> transactionScope: (queryInterface: any) => Promise<T>
@@ -40,7 +40,7 @@ export interface ISecondaryEntityCache<TFields, TLoadParams> {
* when the underlying data of a cache key could be stale. * when the underlying data of a cache key could be stale.
* *
* This is most commonly used to further optimize hot paths that cannot make use of normal entity cache loading * This is most commonly used to further optimize hot paths that cannot make use of normal entity cache loading
* due to use of a non-unique-field-based {@link EntityLoader} method like `loadManyByFieldEqualityConjunctionAsync` or * due to use of a non-unique-field-based EntityLoader method like `loadManyByFieldEqualityConjunctionAsync` or
* `loadManyByRawWhereClauseAsync`. * `loadManyByRawWhereClauseAsync`.
*/ */
export default abstract class EntitySecondaryCacheLoader< export default abstract class EntitySecondaryCacheLoader<
@@ -0,0 +1,98 @@
import invariant from 'invariant';
import { ISecondaryEntityCache } from './EntitySecondaryCacheLoader';
import IEntityGenericCacher from './IEntityGenericCacher';
import { CacheStatus } from './internal/ReadThroughEntityCache';
import { filterMap, zipToMap } from './utils/collections/maps';
/**
* 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 EntityLoader.loadManyByFieldEqualityConjunctionAsync is guaranteed to return at most one entity.
*/
export default abstract class GenericSecondaryEntityCache<TFields, TLoadParams>
implements ISecondaryEntityCache<TFields, TLoadParams>
{
constructor(
protected readonly cacher: IEntityGenericCacher<TFields>,
protected readonly constructCacheKey: (params: Readonly<TLoadParams>) => string
) {}
public async loadManyThroughAsync(
loadParamsArray: readonly Readonly<TLoadParams>[],
fetcher: (
fetcherLoadParamsArray: readonly Readonly<TLoadParams>[]
) => Promise<ReadonlyMap<Readonly<TLoadParams>, Readonly<TFields> | null>>
): Promise<ReadonlyMap<Readonly<TLoadParams>, Readonly<TFields> | null>> {
const cacheKeys = loadParamsArray.map(this.constructCacheKey);
const cacheKeyToLoadParamsMap = zipToMap(cacheKeys, loadParamsArray);
const cacheLoadResults = await this.cacher.loadManyAsync(cacheKeys);
invariant(
cacheLoadResults.size === loadParamsArray.length,
`${this.constructor.name} loadMany should return a result for each key`
);
const cacheKeysToFetch = Array.from(
filterMap(
cacheLoadResults,
(cacheLoadResult) => cacheLoadResult.status === CacheStatus.MISS
).keys()
);
// put cache hits in result map
const results: Map<Readonly<TLoadParams>, Readonly<TFields> | null> = new Map();
cacheLoadResults.forEach((cacheLoadResult, cacheKey) => {
if (cacheLoadResult.status === CacheStatus.HIT) {
const loadParams = cacheKeyToLoadParamsMap.get(cacheKey);
invariant(loadParams !== undefined, 'load params should be in cache key map');
results.set(loadParams, cacheLoadResult.item);
}
});
// fetch any misses from DB, add DB objects to results, cache DB results, inform cache of any missing DB results
if (cacheKeysToFetch.length > 0) {
const loadParamsToFetch = cacheKeysToFetch.map((cacheKey) => {
const loadParams = cacheKeyToLoadParamsMap.get(cacheKey);
invariant(loadParams !== undefined, 'load params should be in cache key map');
return loadParams;
});
const fetchResults = await fetcher(loadParamsToFetch);
const fetchMisses = loadParamsToFetch.filter((loadParams) => {
// all values of fetchResults should be field objects or undefined
return !fetchResults.get(loadParams);
});
for (const fetchMiss of fetchMisses) {
results.set(fetchMiss, null);
}
const objectsToCache: Map<string, Readonly<TFields>> = new Map();
for (const [loadParams, object] of fetchResults.entries()) {
if (object) {
objectsToCache.set(this.constructCacheKey(loadParams), object);
results.set(loadParams, object);
}
}
await Promise.all([
this.cacher.cacheManyAsync(objectsToCache),
this.cacher.cacheDBMissesAsync(fetchMisses.map(this.constructCacheKey)),
]);
}
return results;
}
/**
* Invalidate the cache for objects cached by constructCacheKey(loadParams).
*
* @param loadParamsArray - load params to invalidate
*/
public invalidateManyAsync(loadParamsArray: readonly Readonly<TLoadParams>[]): Promise<void> {
const cacheKeys = loadParamsArray.map(this.constructCacheKey);
return this.cacher.invalidateManyAsync(cacheKeys);
}
}
@@ -0,0 +1,15 @@
import { CacheLoadResult } from './internal/ReadThroughEntityCache';
/**
* A cacher stores and loads key-value pairs. It also supports negative caching - it stores the absence
* of keys that don't exist in the backing datastore.
*/
export default interface IEntityGenericCacher<TFields> {
loadManyAsync(keys: readonly string[]): Promise<ReadonlyMap<string, CacheLoadResult<TFields>>>;
cacheManyAsync(objectMap: ReadonlyMap<string, Readonly<TFields>>): Promise<void>;
cacheDBMissesAsync(keys: readonly string[]): Promise<void>;
invalidateManyAsync(keys: readonly string[]): Promise<void>;
}
+2 -2
View File
@@ -82,7 +82,7 @@ export default abstract class ReadonlyEntity<
} }
/** /**
* @returns {@link EntityAssociationLoader} for this entity * @returns EntityAssociationLoader for this entity
*/ */
associationLoader(): EntityAssociationLoader< associationLoader(): EntityAssociationLoader<
TFields, TFields,
@@ -156,6 +156,6 @@ export default abstract class ReadonlyEntity<
return viewerContext return viewerContext
.getViewerScopedEntityCompanionForClass(this) .getViewerScopedEntityCompanionForClass(this)
.getLoaderFactory() .getLoaderFactory()
.forLoad(queryContext); .forLoad(queryContext, { cascadingDeleteCause: null });
} }
} }
+2 -2
View File
@@ -7,9 +7,9 @@ import ViewerScopedEntityCompanion from './ViewerScopedEntityCompanion';
import ViewerScopedEntityCompanionProvider from './ViewerScopedEntityCompanionProvider'; import ViewerScopedEntityCompanionProvider from './ViewerScopedEntityCompanionProvider';
/** /**
* A viewer context encapsulates all information necessary to evaluate an {@link EntityPrivacyPolicy}. * A viewer context encapsulates all information necessary to evaluate an EntityPrivacyPolicy.
* *
* In combination with an {@link EntityCompanionProvider}, a viewer context is the * In combination with an EntityCompanionProvider, a viewer context is the
* entry point into the Entity framework. * entry point into the Entity framework.
*/ */
export default class ViewerContext { export default class ViewerContext {
@@ -8,7 +8,7 @@ import ViewerScopedEntityMutatorFactory from './ViewerScopedEntityMutatorFactory
import IEntityMetricsAdapter from './metrics/IEntityMetricsAdapter'; import IEntityMetricsAdapter from './metrics/IEntityMetricsAdapter';
/** /**
* Provides a simpler API for loading and mutating entities by injecting the {@link ViewerContext} * Provides a simpler API for loading and mutating entities by injecting the ViewerContext
* from the viewer-scoped entity companion provider. * from the viewer-scoped entity companion provider.
*/ */
export default class ViewerScopedEntityCompanion< export default class ViewerScopedEntityCompanion<
@@ -79,7 +79,7 @@ export default class ViewerScopedEntityCompanion<
} }
/** /**
* Get the {@link IEntityMetricsAdapter} for this companion. * Get the IEntityMetricsAdapter for this companion.
*/ */
getMetricsAdapter(): IEntityMetricsAdapter { getMetricsAdapter(): IEntityMetricsAdapter {
return this.entityCompanion.getMetricsAdapter(); return this.entityCompanion.getMetricsAdapter();
@@ -1,12 +1,12 @@
import EntityLoader from './EntityLoader'; import EntityLoader from './EntityLoader';
import EntityLoaderFactory from './EntityLoaderFactory'; import EntityLoaderFactory from './EntityLoaderFactory';
import EntityPrivacyPolicy from './EntityPrivacyPolicy'; import EntityPrivacyPolicy, { EntityPrivacyPolicyEvaluationContext } from './EntityPrivacyPolicy';
import { EntityQueryContext } from './EntityQueryContext'; import { EntityQueryContext } from './EntityQueryContext';
import ReadonlyEntity from './ReadonlyEntity'; import ReadonlyEntity from './ReadonlyEntity';
import ViewerContext from './ViewerContext'; import ViewerContext from './ViewerContext';
/** /**
* Provides a cleaner API for loading entities by passing through the {@link ViewerContext}. * Provides a cleaner API for loading entities by passing through the ViewerContext.
*/ */
export default class ViewerScopedEntityLoaderFactory< export default class ViewerScopedEntityLoaderFactory<
TFields, TFields,
@@ -35,8 +35,13 @@ export default class ViewerScopedEntityLoaderFactory<
) {} ) {}
forLoad( forLoad(
queryContext: EntityQueryContext queryContext: EntityQueryContext,
privacyPolicyEvaluationContext: EntityPrivacyPolicyEvaluationContext
): EntityLoader<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy, TSelectedFields> { ): EntityLoader<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy, TSelectedFields> {
return this.entityLoaderFactory.forLoad(this.viewerContext, queryContext); return this.entityLoaderFactory.forLoad(
this.viewerContext,
queryContext,
privacyPolicyEvaluationContext
);
} }
} }
@@ -6,7 +6,7 @@ import ReadonlyEntity from './ReadonlyEntity';
import ViewerContext from './ViewerContext'; import ViewerContext from './ViewerContext';
/** /**
* Provides a cleaner API for mutating entities by passing through the {@link ViewerContext}. * Provides a cleaner API for mutating entities by passing through the ViewerContext.
*/ */
export default class ViewerScopedEntityMutatorFactory< export default class ViewerScopedEntityMutatorFactory<
TFields, TFields,
@@ -0,0 +1,319 @@
import invariant from 'invariant';
import ComposedEntityCacheAdapter from '../ComposedEntityCacheAdapter';
import EntityCacheAdapter from '../EntityCacheAdapter';
import EntityConfiguration from '../EntityConfiguration';
import { UUIDField } from '../EntityFields';
import { CacheLoadResult, CacheStatus } from '../internal/ReadThroughEntityCache';
type BlahFields = {
id: string;
};
const entityConfiguration = new EntityConfiguration<BlahFields>({
idField: 'id',
tableName: 'blah',
schema: {
id: new UUIDField({ columnName: 'id', cache: true }),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'local-memory-and-redis',
});
export const DOES_NOT_EXIST_LOCAL_MEMORY_CACHE = Symbol('doesNotExist');
type LocalMemoryCacheValue<TFields> = Readonly<TFields> | typeof DOES_NOT_EXIST_LOCAL_MEMORY_CACHE;
class TestLocalCacheAdapter<TFields> extends EntityCacheAdapter<TFields> {
constructor(
entityConfiguration: EntityConfiguration<TFields>,
private readonly cache: Map<string, LocalMemoryCacheValue<TFields>>
) {
super(entityConfiguration);
}
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 = new Map<NonNullable<TFields[N]>, CacheLoadResult<TFields>>();
for (const [cacheKey, fieldValue] of localMemoryCacheKeyToFieldValueMapping) {
const cacheResult = this.cache.get(cacheKey);
if (cacheResult === DOES_NOT_EXIST_LOCAL_MEMORY_CACHE) {
cacheResults.set(fieldValue, {
status: CacheStatus.NEGATIVE,
});
} else if (cacheResult) {
cacheResults.set(fieldValue, {
status: CacheStatus.HIT,
item: cacheResult,
});
} else {
cacheResults.set(fieldValue, {
status: CacheStatus.MISS,
});
}
}
return cacheResults;
}
public async cacheManyAsync<N extends keyof TFields>(
fieldName: N,
objectMap: ReadonlyMap<NonNullable<TFields[N]>, Readonly<TFields>>
): Promise<void> {
for (const [fieldValue, item] of objectMap) {
const cacheKey = this.makeCacheKey(fieldName, fieldValue);
this.cache.set(cacheKey, item);
}
}
public async cacheDBMissesAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<void> {
for (const fieldValue of fieldValues) {
const cacheKey = this.makeCacheKey(fieldName, fieldValue);
this.cache.set(cacheKey, DOES_NOT_EXIST_LOCAL_MEMORY_CACHE);
}
}
public async invalidateManyAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<void> {
for (const fieldValue of fieldValues) {
const cacheKey = this.makeCacheKey(fieldName, fieldValue);
this.cache.delete(cacheKey);
}
}
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 ${String(fieldName)}`);
const parts = [
this.entityConfiguration.tableName,
`${this.entityConfiguration.cacheKeyVersion}`,
columnName,
String(fieldValue),
];
const delimiter = ':';
const escapedParts = parts.map((part) =>
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`)
);
return escapedParts.join(delimiter);
}
}
function makeTestCacheAdapters(): {
primaryCache: Map<string, LocalMemoryCacheValue<BlahFields>>;
primaryCacheAdapter: TestLocalCacheAdapter<BlahFields>;
fallbackCache: Map<string, LocalMemoryCacheValue<BlahFields>>;
fallbackCacheAdapter: TestLocalCacheAdapter<BlahFields>;
cacheAdapter: ComposedEntityCacheAdapter<BlahFields>;
} {
const primaryCache = new Map();
const primaryCacheAdapter = new TestLocalCacheAdapter(entityConfiguration, primaryCache);
const fallbackCache = new Map();
const fallbackCacheAdapter = new TestLocalCacheAdapter(entityConfiguration, fallbackCache);
const cacheAdapter = new ComposedEntityCacheAdapter(entityConfiguration, [
primaryCacheAdapter,
fallbackCacheAdapter,
]);
return {
primaryCache,
primaryCacheAdapter,
fallbackCache,
fallbackCacheAdapter,
cacheAdapter,
};
}
describe(ComposedEntityCacheAdapter, () => {
describe('loadManyAsync', () => {
it('returns primary results when populated', async () => {
const { primaryCacheAdapter, cacheAdapter } = makeTestCacheAdapters();
const cacheHits = new Map<string, Readonly<BlahFields>>([['test-id-1', { id: 'test-id-1' }]]);
await primaryCacheAdapter.cacheManyAsync('id', cacheHits);
await primaryCacheAdapter.cacheDBMissesAsync('id', ['test-id-2']);
const results = await cacheAdapter.loadManyAsync('id', [
'test-id-1',
'test-id-2',
'test-id-3',
]);
expect(results.get('test-id-1')).toMatchObject({
status: CacheStatus.HIT,
item: { id: 'test-id-1' },
});
expect(results.get('test-id-2')).toMatchObject({ status: CacheStatus.NEGATIVE });
expect(results.get('test-id-3')).toMatchObject({ status: CacheStatus.MISS });
expect(results.size).toBe(3);
});
it('returns fallback adapter results primary is empty', async () => {
const { fallbackCacheAdapter, cacheAdapter } = makeTestCacheAdapters();
const cacheHits = new Map<string, Readonly<BlahFields>>([['test-id-1', { id: 'test-id-1' }]]);
await fallbackCacheAdapter.cacheManyAsync('id', cacheHits);
await fallbackCacheAdapter.cacheDBMissesAsync('id', ['test-id-2']);
const results = await cacheAdapter.loadManyAsync('id', [
'test-id-1',
'test-id-2',
'test-id-3',
]);
expect(results.get('test-id-1')).toMatchObject({
status: CacheStatus.HIT,
item: { id: 'test-id-1' },
});
expect(results.get('test-id-2')).toMatchObject({ status: CacheStatus.NEGATIVE });
expect(results.get('test-id-3')).toMatchObject({ status: CacheStatus.MISS });
expect(results.size).toBe(3);
});
it('populates primary adapter with fallback adapter results', async () => {
const { primaryCacheAdapter, fallbackCacheAdapter, cacheAdapter } = makeTestCacheAdapters();
const cacheHits = new Map<string, Readonly<BlahFields>>([['test-id-1', { id: 'test-id-1' }]]);
await fallbackCacheAdapter.cacheManyAsync('id', cacheHits);
await fallbackCacheAdapter.cacheDBMissesAsync('id', ['test-id-2']);
// should populate primary cache with fallback cache results
await cacheAdapter.loadManyAsync('id', ['test-id-1', 'test-id-2', 'test-id-3']);
const primaryResults = await primaryCacheAdapter.loadManyAsync('id', [
'test-id-1',
'test-id-2',
'test-id-3',
]);
expect(primaryResults.get('test-id-1')).toMatchObject({
status: CacheStatus.HIT,
item: { id: 'test-id-1' },
});
expect(primaryResults.get('test-id-2')).toMatchObject({ status: CacheStatus.NEGATIVE });
expect(primaryResults.get('test-id-3')).toMatchObject({ status: CacheStatus.MISS });
expect(primaryResults.size).toBe(3);
// ensure that populating the primary cache doesn't change the output
const composedResults = await cacheAdapter.loadManyAsync('id', [
'test-id-1',
'test-id-2',
'test-id-3',
]);
expect(composedResults.get('test-id-1')).toMatchObject({
status: CacheStatus.HIT,
item: { id: 'test-id-1' },
});
expect(composedResults.get('test-id-2')).toMatchObject({ status: CacheStatus.NEGATIVE });
expect(composedResults.get('test-id-3')).toMatchObject({ status: CacheStatus.MISS });
expect(composedResults.size).toBe(3);
});
it('returns empty map when passed empty array of fieldValues', async () => {
const { cacheAdapter } = makeTestCacheAdapters();
const results = await cacheAdapter.loadManyAsync('id', []);
expect(results).toEqual(new Map());
});
it('handles 0 cache adapter compose case', async () => {
const cacheAdapter = new ComposedEntityCacheAdapter(entityConfiguration, []);
const results = await cacheAdapter.loadManyAsync('id', []);
expect(results).toEqual(new Map());
});
});
describe('cacheManyAsync', () => {
it('correctly caches all objects', async () => {
const {
primaryCache,
primaryCacheAdapter,
fallbackCache,
fallbackCacheAdapter,
cacheAdapter,
} = makeTestCacheAdapters();
await cacheAdapter.cacheManyAsync('id', new Map([['test-id-1', { id: 'test-id-1' }]]));
const primaryLocalMemoryCacheKey = primaryCacheAdapter['makeCacheKey']('id', 'test-id-1');
expect(primaryCache.get(primaryLocalMemoryCacheKey)).toMatchObject({
id: 'test-id-1',
});
const fallbackLocalMemoryCacheKey = fallbackCacheAdapter['makeCacheKey']('id', 'test-id-1');
expect(fallbackCache.get(fallbackLocalMemoryCacheKey)).toMatchObject({
id: 'test-id-1',
});
});
});
describe('cacheDBMissesAsync', () => {
it('correctly caches misses', async () => {
const {
primaryCache,
primaryCacheAdapter,
fallbackCache,
fallbackCacheAdapter,
cacheAdapter,
} = makeTestCacheAdapters();
await cacheAdapter.cacheDBMissesAsync('id', ['test-id-1']);
const primaryLocalMemoryCacheKey = primaryCacheAdapter['makeCacheKey']('id', 'test-id-1');
expect(primaryCache.get(primaryLocalMemoryCacheKey)).toBe(DOES_NOT_EXIST_LOCAL_MEMORY_CACHE);
const fallbackLocalMemoryCacheKey = fallbackCacheAdapter['makeCacheKey']('id', 'test-id-1');
expect(fallbackCache.get(fallbackLocalMemoryCacheKey)).toBe(
DOES_NOT_EXIST_LOCAL_MEMORY_CACHE
);
});
});
describe('invalidateManyAsync', () => {
it('invalidates correctly', async () => {
const {
primaryCache,
primaryCacheAdapter,
fallbackCache,
fallbackCacheAdapter,
cacheAdapter,
} = makeTestCacheAdapters();
const cacheHits = new Map<string, Readonly<BlahFields>>([['test-id-1', { id: 'test-id-1' }]]);
await primaryCacheAdapter.cacheManyAsync('id', cacheHits);
await primaryCacheAdapter.cacheDBMissesAsync('id', ['test-id-2']);
await fallbackCacheAdapter.cacheManyAsync('id', cacheHits);
await fallbackCacheAdapter.cacheDBMissesAsync('id', ['test-id-2']);
await cacheAdapter.invalidateManyAsync('id', ['test-id-1', 'test-id-2']);
const primaryLocalMemoryCacheKey1 = primaryCacheAdapter['makeCacheKey']('id', 'test-id-1');
expect(primaryCache.get(primaryLocalMemoryCacheKey1)).toBe(undefined);
const primaryLocalMemoryCacheKey2 = primaryCacheAdapter['makeCacheKey']('id', 'test-id-1');
expect(primaryCache.get(primaryLocalMemoryCacheKey2)).toBe(undefined);
const fallbackLocalMemoryCacheKey1 = fallbackCacheAdapter['makeCacheKey']('id', 'test-id-1');
expect(fallbackCache.get(fallbackLocalMemoryCacheKey1)).toBe(undefined);
const fallbackLocalMemoryCacheKey2 = fallbackCacheAdapter['makeCacheKey']('id', 'test-id-1');
expect(fallbackCache.get(fallbackLocalMemoryCacheKey2)).toBe(undefined);
});
it('returns when passed empty array of fieldValues', async () => {
const { cacheAdapter } = makeTestCacheAdapters();
await cacheAdapter.invalidateManyAsync('id', []);
});
});
});
@@ -0,0 +1,101 @@
import invariant from 'invariant';
import nullthrows from 'nullthrows';
import ComposedSecondaryEntityCache from '../ComposedSecondaryEntityCache';
import { ISecondaryEntityCache } from '../EntitySecondaryCacheLoader';
type TestFields = { id: string };
type TestLoadParams = { lp: string };
class TestEntitySecondaryCache implements ISecondaryEntityCache<TestFields, TestLoadParams> {
constructor(
private readonly prefilledResults: Map<Readonly<TestLoadParams>, Readonly<TestFields>>
) {}
async loadManyThroughAsync(
loadParamsArray: readonly Readonly<TestLoadParams>[],
fetcher: (
fetcherLoadParamsArray: readonly Readonly<TestLoadParams>[]
) => Promise<ReadonlyMap<Readonly<TestLoadParams>, Readonly<TestFields> | null>>
): Promise<ReadonlyMap<Readonly<TestLoadParams>, Readonly<TestFields> | null>> {
// this does an unusual method of calling fetcher, but there's no constraint that says fetcher can only be called once
// so this tests that
const retMap = new Map<Readonly<TestLoadParams>, Readonly<TestFields> | null>();
for (const loadParams of loadParamsArray) {
if (this.prefilledResults.has(loadParams)) {
retMap.set(loadParams, nullthrows(this.prefilledResults.get(loadParams)));
} else {
const fetcherResult = await fetcher([loadParams]);
const toSet = fetcherResult.get(loadParams);
invariant(toSet !== undefined, 'should be set');
retMap.set(loadParams, toSet);
}
}
return retMap;
}
async invalidateManyAsync(loadParamsArray: readonly Readonly<TestLoadParams>[]): Promise<void> {
for (const loadParams of loadParamsArray) {
this.prefilledResults.delete(loadParams);
}
}
}
describe(ComposedSecondaryEntityCache, () => {
it('composes correctly', async () => {
// TODO(wschurman): investigate whether we can use immutable or something to do better object equality for the map keys
const lp1 = { lp: '1' };
const lp2 = { lp: '2' };
const lp3 = { lp: '3' };
const primarySecondaryEntityCache = new TestEntitySecondaryCache(
new Map([[lp1, { id: 'primary-1' }]])
);
const fallbackSecondaryEntityCache = new TestEntitySecondaryCache(
new Map([[lp2, { id: 'fallback-2' }]])
);
const composedSecondaryEntityCache = new ComposedSecondaryEntityCache([
primarySecondaryEntityCache,
fallbackSecondaryEntityCache,
]);
const results = await composedSecondaryEntityCache.loadManyThroughAsync(
[lp1, lp2, lp3],
async (fetcherLoadParamsArray) =>
new Map(fetcherLoadParamsArray.map((flp) => [flp, { id: `db-fetched-${flp.lp}` }]))
);
expect(results.get(lp1)).toEqual({ id: 'primary-1' });
expect(results.get(lp2)).toEqual({ id: 'fallback-2' });
expect(results.get(lp3)).toEqual({ id: 'db-fetched-3' });
await composedSecondaryEntityCache.invalidateManyAsync([lp1, lp2, lp3]);
const resultsAfterInvalidate = await composedSecondaryEntityCache.loadManyThroughAsync(
[lp1, lp2, lp3],
async (fetcherLoadParamsArray) =>
new Map(fetcherLoadParamsArray.map((flp) => [flp, { id: `db-fetched-${flp.lp}` }]))
);
expect(resultsAfterInvalidate.get(lp1)).toEqual({ id: 'db-fetched-1' });
expect(resultsAfterInvalidate.get(lp2)).toEqual({ id: 'db-fetched-2' });
expect(resultsAfterInvalidate.get(lp3)).toEqual({ id: 'db-fetched-3' });
});
it('handles n=0 compose case', async () => {
const lp1 = { lp: '1' };
const composedSecondaryEntityCache = new ComposedSecondaryEntityCache<
TestLoadParams,
TestFields
>([]);
const results = await composedSecondaryEntityCache.loadManyThroughAsync(
[lp1],
async (fetcherLoadParamsArray) =>
new Map(fetcherLoadParamsArray.map((flp) => [flp, { id: `db-fetched-${flp.lp}` }]))
);
expect(results.get(lp1)).toEqual({ id: 'db-fetched-1' });
});
});
@@ -5,7 +5,7 @@ import Entity from '../Entity';
import EntityCompanionProvider, { EntityCompanionDefinition } from '../EntityCompanionProvider'; import EntityCompanionProvider, { EntityCompanionDefinition } from '../EntityCompanionProvider';
import EntityConfiguration from '../EntityConfiguration'; import EntityConfiguration from '../EntityConfiguration';
import { UUIDField } from '../EntityFields'; import { UUIDField } from '../EntityFields';
import EntityPrivacyPolicy from '../EntityPrivacyPolicy'; import EntityPrivacyPolicy, { EntityPrivacyPolicyEvaluationContext } from '../EntityPrivacyPolicy';
import { EntityQueryContext } from '../EntityQueryContext'; import { EntityQueryContext } from '../EntityQueryContext';
import ViewerContext from '../ViewerContext'; import ViewerContext from '../ViewerContext';
import { enforceResultsAsync } from '../entityUtils'; import { enforceResultsAsync } from '../entityUtils';
@@ -51,6 +51,7 @@ class DenyIfNotOwnerPrivacyPolicyRule extends PrivacyPolicyRule<
async evaluateAsync( async evaluateAsync(
viewerContext: TestUserViewerContext, viewerContext: TestUserViewerContext,
_queryContext: EntityQueryContext, _queryContext: EntityQueryContext,
_evaluationContext: EntityPrivacyPolicyEvaluationContext,
entity: BlahEntity entity: BlahEntity
): Promise<RuleEvaluationResult> { ): Promise<RuleEvaluationResult> {
if (viewerContext.getUserID() === entity.getField('ownerID')) { if (viewerContext.getUserID() === entity.getField('ownerID')) {
+554 -58
View File
@@ -1,3 +1,5 @@
import invariant from 'invariant';
import Entity from '../Entity'; import Entity from '../Entity';
import { EntityCompanionDefinition } from '../EntityCompanionProvider'; import { EntityCompanionDefinition } from '../EntityCompanionProvider';
import EntityConfiguration from '../EntityConfiguration'; import EntityConfiguration from '../EntityConfiguration';
@@ -5,10 +7,13 @@ import { EntityEdgeDeletionBehavior } from '../EntityFieldDefinition';
import { UUIDField } from '../EntityFields'; import { UUIDField } from '../EntityFields';
import { EntityTriggerMutationInfo, EntityMutationType } from '../EntityMutationInfo'; import { EntityTriggerMutationInfo, EntityMutationType } from '../EntityMutationInfo';
import { EntityMutationTrigger } from '../EntityMutationTriggerConfiguration'; import { EntityMutationTrigger } from '../EntityMutationTriggerConfiguration';
import EntityPrivacyPolicy from '../EntityPrivacyPolicy'; import EntityPrivacyPolicy, {
import { EntityTransactionalQueryContext } from '../EntityQueryContext'; EntityPrivacyPolicyEvaluationContext,
EntityAuthorizationAction,
} from '../EntityPrivacyPolicy';
import { EntityTransactionalQueryContext, EntityQueryContext } from '../EntityQueryContext';
import { CacheStatus } from '../internal/ReadThroughEntityCache'; import { CacheStatus } from '../internal/ReadThroughEntityCache';
import AlwaysAllowPrivacyPolicyRule from '../rules/AlwaysAllowPrivacyPolicyRule'; import PrivacyPolicyRule, { RuleEvaluationResult } from '../rules/PrivacyPolicyRule';
import TestViewerContext from '../testfixtures/TestViewerContext'; import TestViewerContext from '../testfixtures/TestViewerContext';
import { InMemoryFullCacheStubCacheAdapter } from '../utils/testing/StubCacheAdapter'; import { InMemoryFullCacheStubCacheAdapter } from '../utils/testing/StubCacheAdapter';
import { createUnitTestEntityCompanionProvider } from '../utils/testing/createUnitTestEntityCompanionProvider'; import { createUnitTestEntityCompanionProvider } from '../utils/testing/createUnitTestEntityCompanionProvider';
@@ -27,36 +32,88 @@ interface GrandChildFields {
parent_id: string; parent_id: string;
} }
class TestEntityPrivacyPolicy extends EntityPrivacyPolicy< // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
const makeEntityClasses = (edgeDeletionBehavior: EntityEdgeDeletionBehavior) => {
const triggerExecutionCounts = {
ParentEntityDeletion: 0,
ChildEntityDeletion: 0,
GrandChildEntityDeletion: 0,
ParentEntityUpdate: 0,
ChildEntityUpdate: 0,
GrandChildEntityUpdate: 0,
};
const privacyPolicyEvaluationRecords = {
shouldRecord: false,
ParentEntity: {
[EntityAuthorizationAction.CREATE]: [],
[EntityAuthorizationAction.READ]: [],
[EntityAuthorizationAction.UPDATE]: [],
[EntityAuthorizationAction.DELETE]: [],
},
ChildEntity: {
[EntityAuthorizationAction.CREATE]: [],
[EntityAuthorizationAction.READ]: [],
[EntityAuthorizationAction.UPDATE]: [],
[EntityAuthorizationAction.DELETE]: [],
},
GrandChildEntity: {
[EntityAuthorizationAction.CREATE]: [],
[EntityAuthorizationAction.READ]: [],
[EntityAuthorizationAction.UPDATE]: [],
[EntityAuthorizationAction.DELETE]: [],
},
};
class AlwaysAllowPrivacyPolicyRuleThatRecords extends PrivacyPolicyRule<
any, any,
string, string,
TestViewerContext, TestViewerContext,
any, any,
any any
> { > {
constructor(private readonly action: EntityAuthorizationAction) {
super();
}
async evaluateAsync(
_viewerContext: TestViewerContext,
_queryContext: EntityQueryContext,
evaluationContext: EntityPrivacyPolicyEvaluationContext,
entity: any
): Promise<RuleEvaluationResult> {
if (privacyPolicyEvaluationRecords.shouldRecord) {
(privacyPolicyEvaluationRecords as any)[entity.constructor.name][this.action].push(
evaluationContext
);
}
return RuleEvaluationResult.ALLOW;
}
}
class TestEntityPrivacyPolicy extends EntityPrivacyPolicy<
any,
string,
TestViewerContext,
any,
any
> {
protected override readonly readRules = [ protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, TestViewerContext, any, any>(), new AlwaysAllowPrivacyPolicyRuleThatRecords(EntityAuthorizationAction.READ),
]; ];
protected override readonly createRules = [ protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, TestViewerContext, any, any>(), new AlwaysAllowPrivacyPolicyRuleThatRecords(EntityAuthorizationAction.CREATE),
]; ];
protected override readonly updateRules = [ protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, TestViewerContext, any, any>(), new AlwaysAllowPrivacyPolicyRuleThatRecords(EntityAuthorizationAction.UPDATE),
]; ];
protected override readonly deleteRules = [ protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, TestViewerContext, any, any>(), new AlwaysAllowPrivacyPolicyRuleThatRecords(EntityAuthorizationAction.DELETE),
]; ];
} }
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type class ParentCheckInfoDeletionTrigger extends EntityMutationTrigger<
const makeEntityClasses = (edgeDeletionBehavior: EntityEdgeDeletionBehavior) => {
const triggerExecutionCounts = {
parent: 0,
child: 0,
grandchild: 0,
};
class ParentCheckInfoTrigger extends EntityMutationTrigger<
ParentFields, ParentFields,
string, string,
TestViewerContext, TestViewerContext,
@@ -68,19 +125,37 @@ const makeEntityClasses = (edgeDeletionBehavior: EntityEdgeDeletionBehavior) =>
_entity: ParentEntity, _entity: ParentEntity,
mutationInfo: EntityTriggerMutationInfo<ParentFields, string, TestViewerContext, ParentEntity> mutationInfo: EntityTriggerMutationInfo<ParentFields, string, TestViewerContext, ParentEntity>
): Promise<void> { ): Promise<void> {
if (mutationInfo.type !== EntityMutationType.DELETE) { invariant(mutationInfo.type === EntityMutationType.DELETE, 'invalid EntityMutationType');
return;
}
if (mutationInfo.cascadingDeleteCause !== null) { if (mutationInfo.cascadingDeleteCause !== null) {
throw new Error('Parent entity should not have casade delete cause'); throw new Error('Parent entity should not have casade delete cause');
} }
triggerExecutionCounts.parent++; triggerExecutionCounts.ParentEntityDeletion++;
} }
} }
class ChildCheckInfoTrigger extends EntityMutationTrigger< class ParentCheckInfoUpdateTrigger extends EntityMutationTrigger<
ParentFields,
string,
TestViewerContext,
ParentEntity
> {
async executeAsync(
_viewerContext: TestViewerContext,
_queryContext: EntityTransactionalQueryContext,
_entity: ParentEntity,
mutationInfo: EntityTriggerMutationInfo<ParentFields, string, TestViewerContext, ParentEntity>
): Promise<void> {
invariant(mutationInfo.type === EntityMutationType.UPDATE, 'invalid EntityMutationType');
if (mutationInfo.cascadingDeleteCause !== null) {
throw new Error('Parent entity should not have casade delete cause');
}
triggerExecutionCounts.ParentEntityUpdate++;
}
}
class ChildCheckInfoDeletionTrigger extends EntityMutationTrigger<
ChildFields, ChildFields,
string, string,
TestViewerContext, TestViewerContext,
@@ -92,10 +167,7 @@ const makeEntityClasses = (edgeDeletionBehavior: EntityEdgeDeletionBehavior) =>
_entity: ChildEntity, _entity: ChildEntity,
mutationInfo: EntityTriggerMutationInfo<ChildFields, string, TestViewerContext, ChildEntity> mutationInfo: EntityTriggerMutationInfo<ChildFields, string, TestViewerContext, ChildEntity>
): Promise<void> { ): Promise<void> {
if (mutationInfo.type !== EntityMutationType.DELETE) { invariant(mutationInfo.type === EntityMutationType.DELETE, 'invalid EntityMutationType');
return;
}
if (mutationInfo.cascadingDeleteCause === null) { if (mutationInfo.cascadingDeleteCause === null) {
throw new Error('Child entity should have casade delete cause'); throw new Error('Child entity should have casade delete cause');
} }
@@ -111,11 +183,43 @@ const makeEntityClasses = (edgeDeletionBehavior: EntityEdgeDeletionBehavior) =>
throw new Error('Child entity should not have two-level casade delete cause'); throw new Error('Child entity should not have two-level casade delete cause');
} }
triggerExecutionCounts.child++; triggerExecutionCounts.ChildEntityDeletion++;
} }
} }
class GrandChildCheckInfoTrigger extends EntityMutationTrigger< class ChildCheckInfoUpdateTrigger extends EntityMutationTrigger<
ChildFields,
string,
TestViewerContext,
ChildEntity
> {
async executeAsync(
_viewerContext: TestViewerContext,
_queryContext: EntityTransactionalQueryContext,
_entity: ChildEntity,
mutationInfo: EntityTriggerMutationInfo<ChildFields, string, TestViewerContext, ChildEntity>
): Promise<void> {
invariant(mutationInfo.type === EntityMutationType.UPDATE, 'invalid EntityMutationType');
if (mutationInfo.cascadingDeleteCause === null) {
throw new Error('Child entity should have casade delete cause');
}
const cascadingDeleteCauseEntity = mutationInfo.cascadingDeleteCause.entity;
if (!(cascadingDeleteCauseEntity instanceof ParentEntity)) {
throw new Error('Child entity should have casade delete cause entity of type ParentEntity');
}
const secondLevelCascadingDeleteCause =
mutationInfo.cascadingDeleteCause.cascadingDeleteCause;
if (secondLevelCascadingDeleteCause) {
throw new Error('Child entity should not have two-level casade delete cause');
}
triggerExecutionCounts.ChildEntityUpdate++;
}
}
class GrandChildCheckInfoDeletionTrigger extends EntityMutationTrigger<
GrandChildFields, GrandChildFields,
string, string,
TestViewerContext, TestViewerContext,
@@ -132,10 +236,7 @@ const makeEntityClasses = (edgeDeletionBehavior: EntityEdgeDeletionBehavior) =>
GrandChildEntity GrandChildEntity
> >
): Promise<void> { ): Promise<void> {
if (mutationInfo.type !== EntityMutationType.DELETE) { invariant(mutationInfo.type === EntityMutationType.DELETE, 'invalid EntityMutationType');
return;
}
if (mutationInfo.cascadingDeleteCause === null) { if (mutationInfo.cascadingDeleteCause === null) {
throw new Error('GrandChild entity should have cascade delete cause'); throw new Error('GrandChild entity should have cascade delete cause');
} }
@@ -165,7 +266,58 @@ const makeEntityClasses = (edgeDeletionBehavior: EntityEdgeDeletionBehavior) =>
throw new Error('GrandChild entity should not have three-level casade delete cause'); throw new Error('GrandChild entity should not have three-level casade delete cause');
} }
triggerExecutionCounts.grandchild++; triggerExecutionCounts.GrandChildEntityDeletion++;
}
}
class GrandChildCheckInfoUpdateTrigger extends EntityMutationTrigger<
GrandChildFields,
string,
TestViewerContext,
GrandChildEntity
> {
async executeAsync(
_viewerContext: TestViewerContext,
_queryContext: EntityTransactionalQueryContext,
_entity: GrandChildEntity,
mutationInfo: EntityTriggerMutationInfo<
GrandChildFields,
string,
TestViewerContext,
GrandChildEntity
>
): Promise<void> {
invariant(mutationInfo.type === EntityMutationType.UPDATE, 'invalid EntityMutationType');
if (mutationInfo.cascadingDeleteCause === null) {
throw new Error('GrandChild entity should have cascade delete cause');
}
const cascadingDeleteCauseEntity = mutationInfo.cascadingDeleteCause.entity;
if (!(cascadingDeleteCauseEntity instanceof ChildEntity)) {
throw new Error(
'GrandChild entity should have cascade delete cause entity of type ChildEntity'
);
}
const secondLevelCascadingDeleteCause =
mutationInfo.cascadingDeleteCause.cascadingDeleteCause;
if (!secondLevelCascadingDeleteCause) {
throw new Error('GrandChild entity should have two-level casade delete cause');
}
const secondLevelCascadingDeleteCauseEntity = secondLevelCascadingDeleteCause.entity;
if (!(secondLevelCascadingDeleteCauseEntity instanceof ParentEntity)) {
throw new Error(
'GrandChild entity should have second level casade delete cause entity of type ParentEntity'
);
}
const thirdLevelCascadingDeleteCause = secondLevelCascadingDeleteCause.cascadingDeleteCause;
if (thirdLevelCascadingDeleteCause) {
throw new Error('GrandChild entity should not have three-level casade delete cause');
}
triggerExecutionCounts.GrandChildEntityUpdate++;
} }
} }
@@ -267,8 +419,11 @@ const makeEntityClasses = (edgeDeletionBehavior: EntityEdgeDeletionBehavior) =>
entityConfiguration: parentEntityConfiguration, entityConfiguration: parentEntityConfiguration,
privacyPolicyClass: TestEntityPrivacyPolicy, privacyPolicyClass: TestEntityPrivacyPolicy,
mutationTriggers: () => ({ mutationTriggers: () => ({
beforeDelete: [new ParentCheckInfoTrigger()], beforeDelete: [new ParentCheckInfoDeletionTrigger()],
afterDelete: [new ParentCheckInfoTrigger()], afterDelete: [new ParentCheckInfoDeletionTrigger()],
beforeUpdate: [new ParentCheckInfoUpdateTrigger()],
afterUpdate: [new ParentCheckInfoUpdateTrigger()],
}), }),
}); });
@@ -277,8 +432,11 @@ const makeEntityClasses = (edgeDeletionBehavior: EntityEdgeDeletionBehavior) =>
entityConfiguration: childEntityConfiguration, entityConfiguration: childEntityConfiguration,
privacyPolicyClass: TestEntityPrivacyPolicy, privacyPolicyClass: TestEntityPrivacyPolicy,
mutationTriggers: () => ({ mutationTriggers: () => ({
beforeDelete: [new ChildCheckInfoTrigger()], beforeDelete: [new ChildCheckInfoDeletionTrigger()],
afterDelete: [new ChildCheckInfoTrigger()], afterDelete: [new ChildCheckInfoDeletionTrigger()],
beforeUpdate: [new ChildCheckInfoUpdateTrigger()],
afterUpdate: [new ChildCheckInfoUpdateTrigger()],
}), }),
}); });
@@ -287,8 +445,11 @@ const makeEntityClasses = (edgeDeletionBehavior: EntityEdgeDeletionBehavior) =>
entityConfiguration: grandChildEntityConfiguration, entityConfiguration: grandChildEntityConfiguration,
privacyPolicyClass: TestEntityPrivacyPolicy, privacyPolicyClass: TestEntityPrivacyPolicy,
mutationTriggers: () => ({ mutationTriggers: () => ({
beforeDelete: [new GrandChildCheckInfoTrigger()], beforeDelete: [new GrandChildCheckInfoDeletionTrigger()],
afterDelete: [new GrandChildCheckInfoTrigger()], afterDelete: [new GrandChildCheckInfoDeletionTrigger()],
beforeUpdate: [new GrandChildCheckInfoUpdateTrigger()],
afterUpdate: [new GrandChildCheckInfoUpdateTrigger()],
}), }),
}); });
@@ -297,14 +458,20 @@ const makeEntityClasses = (edgeDeletionBehavior: EntityEdgeDeletionBehavior) =>
ChildEntity, ChildEntity,
GrandChildEntity, GrandChildEntity,
triggerExecutionCounts, triggerExecutionCounts,
privacyPolicyEvaluationRecords,
}; };
}; };
describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => { describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
describe('EntityEdgeDeletionBehavior.CASCADE_DELETE', () => { describe('EntityEdgeDeletionBehavior.CASCADE_DELETE', () => {
it('deletes', async () => { it('deletes', async () => {
const { ParentEntity, ChildEntity, GrandChildEntity, triggerExecutionCounts } = const {
makeEntityClasses(EntityEdgeDeletionBehavior.CASCADE_DELETE); ParentEntity,
ChildEntity,
GrandChildEntity,
triggerExecutionCounts,
privacyPolicyEvaluationRecords,
} = makeEntityClasses(EntityEdgeDeletionBehavior.CASCADE_DELETE);
const companionProvider = createUnitTestEntityCompanionProvider(); const companionProvider = createUnitTestEntityCompanionProvider();
const viewerContext = new TestViewerContext(companionProvider); const viewerContext = new TestViewerContext(companionProvider);
@@ -326,7 +493,9 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
GrandChildEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(grandchild.getID()) GrandChildEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(grandchild.getID())
).resolves.not.toBeNull(); ).resolves.not.toBeNull();
privacyPolicyEvaluationRecords.shouldRecord = true;
await ParentEntity.enforceDeleteAsync(parent); await ParentEntity.enforceDeleteAsync(parent);
privacyPolicyEvaluationRecords.shouldRecord = false;
await expect( await expect(
ParentEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(parent.getID()) ParentEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(parent.getID())
@@ -340,17 +509,86 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
// two calls for each trigger, one beforeDelete, one afterDelete // two calls for each trigger, one beforeDelete, one afterDelete
expect(triggerExecutionCounts).toMatchObject({ expect(triggerExecutionCounts).toMatchObject({
parent: 2, ParentEntityDeletion: 2,
child: 2, ChildEntityDeletion: 2,
grandchild: 2, GrandChildEntityDeletion: 2,
ParentEntityUpdate: 0,
ChildEntityUpdate: 0,
GrandChildEntityUpdate: 0,
});
expect(privacyPolicyEvaluationRecords).toMatchObject({
ParentEntity: {
[EntityAuthorizationAction.CREATE]: [],
[EntityAuthorizationAction.READ]: [],
[EntityAuthorizationAction.UPDATE]: [],
// one DELETE auth action for parent (since it's being deleted)
[EntityAuthorizationAction.DELETE]: [{ cascadingDeleteCause: null }],
},
ChildEntity: {
[EntityAuthorizationAction.CREATE]: [],
// one READ auth action for child in order to delete via cascade
[EntityAuthorizationAction.READ]: [
{
cascadingDeleteCause: {
entity: expect.any(ParentEntity),
cascadingDeleteCause: null,
},
},
],
[EntityAuthorizationAction.UPDATE]: [],
// one DELETE auth action for child (since it's being deleted via cascade)
[EntityAuthorizationAction.DELETE]: [
{
cascadingDeleteCause: {
entity: expect.any(ParentEntity),
cascadingDeleteCause: null,
},
},
],
},
GrandChildEntity: {
[EntityAuthorizationAction.CREATE]: [],
// one READ auth action for grandchild in order to delete via cascade
[EntityAuthorizationAction.READ]: [
{
cascadingDeleteCause: {
entity: expect.any(ChildEntity),
cascadingDeleteCause: {
entity: expect.any(ParentEntity),
cascadingDeleteCause: null,
},
},
},
],
[EntityAuthorizationAction.UPDATE]: [],
// one DELETE auth action for grandchild (since it's being deleted via cascade)
[EntityAuthorizationAction.DELETE]: [
{
cascadingDeleteCause: {
entity: expect.any(ChildEntity),
cascadingDeleteCause: {
entity: expect.any(ParentEntity),
cascadingDeleteCause: null,
},
},
},
],
},
}); });
}); });
}); });
describe('EntityEdgeDeletionBehavior.SET_NULL', () => { describe('EntityEdgeDeletionBehavior.SET_NULL', () => {
it('sets null', async () => { it('sets null', async () => {
const { ParentEntity, ChildEntity, GrandChildEntity, triggerExecutionCounts } = const {
makeEntityClasses(EntityEdgeDeletionBehavior.SET_NULL); ParentEntity,
ChildEntity,
GrandChildEntity,
triggerExecutionCounts,
privacyPolicyEvaluationRecords,
} = makeEntityClasses(EntityEdgeDeletionBehavior.SET_NULL);
const companionProvider = createUnitTestEntityCompanionProvider(); const companionProvider = createUnitTestEntityCompanionProvider();
const viewerContext = new TestViewerContext(companionProvider); const viewerContext = new TestViewerContext(companionProvider);
@@ -373,7 +611,9 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
GrandChildEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(grandchild.getID()) GrandChildEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(grandchild.getID())
).resolves.not.toBeNull(); ).resolves.not.toBeNull();
privacyPolicyEvaluationRecords.shouldRecord = true;
await ParentEntity.enforceDeleteAsync(parent); await ParentEntity.enforceDeleteAsync(parent);
privacyPolicyEvaluationRecords.shouldRecord = false;
await expect( await expect(
ParentEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(parent.getID()) ParentEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(parent.getID())
@@ -392,17 +632,74 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
// two calls for only parent trigger, one beforeDelete, one afterDelete // two calls for only parent trigger, one beforeDelete, one afterDelete
// when using set null the descendants aren't deleted // when using set null the descendants aren't deleted
expect(triggerExecutionCounts).toMatchObject({ expect(triggerExecutionCounts).toMatchObject({
parent: 2, ParentEntityDeletion: 2,
child: 0, ChildEntityDeletion: 0,
grandchild: 0, GrandChildEntityDeletion: 0,
ParentEntityUpdate: 0,
ChildEntityUpdate: 2,
GrandChildEntityUpdate: 0,
});
expect(privacyPolicyEvaluationRecords).toMatchObject({
ParentEntity: {
[EntityAuthorizationAction.CREATE]: [],
[EntityAuthorizationAction.READ]: [],
[EntityAuthorizationAction.UPDATE]: [],
// one DELETE auth action for parent (since it's being deleted)
[EntityAuthorizationAction.DELETE]: [{ cascadingDeleteCause: null }],
},
ChildEntity: {
[EntityAuthorizationAction.CREATE]: [],
// two READs auth action for Child during parent deletion:
// 1. Read to initiate the SET_NULL (to update the entity)
// 1. Read automatically post-update
// no other entities are read since it is not cascaded past first entity
[EntityAuthorizationAction.READ]: [
{
cascadingDeleteCause: {
entity: expect.any(ParentEntity),
cascadingDeleteCause: null,
},
},
{
cascadingDeleteCause: {
entity: expect.any(ParentEntity),
cascadingDeleteCause: null,
},
},
],
// one UPDATE to set null
[EntityAuthorizationAction.UPDATE]: [
{
cascadingDeleteCause: {
entity: expect.any(ParentEntity),
cascadingDeleteCause: null,
},
},
],
[EntityAuthorizationAction.DELETE]: [],
},
GrandChildEntity: {
[EntityAuthorizationAction.CREATE]: [],
[EntityAuthorizationAction.READ]: [],
[EntityAuthorizationAction.UPDATE]: [],
[EntityAuthorizationAction.DELETE]: [],
},
}); });
}); });
}); });
describe('EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE', () => { describe('EntityEdgeDeletionBehavior.SET_NULL_INVALIDATE_CACHE_ONLY', () => {
it('invalidates the cache', async () => { it('invalidates the cache', async () => {
const { ParentEntity, ChildEntity, GrandChildEntity, triggerExecutionCounts } = const {
makeEntityClasses(EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE); ParentEntity,
ChildEntity,
GrandChildEntity,
triggerExecutionCounts,
privacyPolicyEvaluationRecords,
} = makeEntityClasses(EntityEdgeDeletionBehavior.SET_NULL_INVALIDATE_CACHE_ONLY);
const companionProvider = createUnitTestEntityCompanionProvider(); const companionProvider = createUnitTestEntityCompanionProvider();
const viewerContext = new TestViewerContext(companionProvider); const viewerContext = new TestViewerContext(companionProvider);
@@ -447,7 +744,142 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
]); ]);
expect(grandChildCachedBefore.get(child.getID())?.status).toEqual(CacheStatus.HIT); expect(grandChildCachedBefore.get(child.getID())?.status).toEqual(CacheStatus.HIT);
privacyPolicyEvaluationRecords.shouldRecord = true;
await ParentEntity.enforceDeleteAsync(parent); await ParentEntity.enforceDeleteAsync(parent);
privacyPolicyEvaluationRecords.shouldRecord = false;
const childCachedAfter = await childCacheAdapter.loadManyAsync('parent_id', [parent.getID()]);
expect(childCachedAfter.get(parent.getID())?.status).toEqual(CacheStatus.MISS);
const grandChildCachedAfter = await grandChildCacheAdapter.loadManyAsync('parent_id', [
child.getID(),
]);
expect(grandChildCachedAfter.get(child.getID())?.status).toEqual(CacheStatus.HIT);
await expect(
ParentEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(parent.getID())
).resolves.toBeNull();
const loadedChild = await ChildEntity.loader(viewerContext)
.enforcing()
.loadByIDAsync(child.getID());
expect(loadedChild).not.toBeNull();
const loadedGrandchild = await GrandChildEntity.loader(viewerContext)
.enforcing()
.loadByIDAsync(grandchild.getID());
expect(loadedGrandchild.getField('parent_id')).toEqual(loadedChild.getID());
// two calls for only parent trigger, one beforeDelete, one afterDelete
// when using set null the descendants aren't deleted
expect(triggerExecutionCounts).toMatchObject({
ParentEntityDeletion: 2,
ChildEntityDeletion: 0,
GrandChildEntityDeletion: 0,
ParentEntityUpdate: 0,
ChildEntityUpdate: 2,
GrandChildEntityUpdate: 0,
});
expect(privacyPolicyEvaluationRecords).toMatchObject({
ParentEntity: {
[EntityAuthorizationAction.CREATE]: [],
[EntityAuthorizationAction.READ]: [],
[EntityAuthorizationAction.UPDATE]: [],
// one DELETE auth action for parent (since it's being deleted)
[EntityAuthorizationAction.DELETE]: [{ cascadingDeleteCause: null }],
},
ChildEntity: {
[EntityAuthorizationAction.CREATE]: [],
// one READ auth action for child in order to update via cascade
// no other entities are read since it is not cascaded past first entity
[EntityAuthorizationAction.READ]: [
{
cascadingDeleteCause: {
entity: expect.any(ParentEntity),
cascadingDeleteCause: null,
},
},
],
// one UPDATE to set null
[EntityAuthorizationAction.UPDATE]: [
{
cascadingDeleteCause: {
entity: expect.any(ParentEntity),
cascadingDeleteCause: null,
},
},
],
[EntityAuthorizationAction.DELETE]: [],
},
GrandChildEntity: {
[EntityAuthorizationAction.CREATE]: [],
[EntityAuthorizationAction.READ]: [],
[EntityAuthorizationAction.UPDATE]: [],
[EntityAuthorizationAction.DELETE]: [],
},
});
});
});
describe('EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE_ONLY', () => {
it('invalidates the cache', async () => {
const {
ParentEntity,
ChildEntity,
GrandChildEntity,
triggerExecutionCounts,
privacyPolicyEvaluationRecords,
} = makeEntityClasses(EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE_ONLY);
const companionProvider = createUnitTestEntityCompanionProvider();
const viewerContext = new TestViewerContext(companionProvider);
const parent = await ParentEntity.creator(viewerContext).enforceCreateAsync();
const child = await ChildEntity.creator(viewerContext)
.setField('parent_id', parent.getID())
.enforceCreateAsync();
const grandchild = await GrandChildEntity.creator(viewerContext)
.setField('parent_id', child.getID())
.enforceCreateAsync();
await expect(
ParentEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(parent.getID())
).resolves.not.toBeNull();
await expect(
ChildEntity.loader(viewerContext)
.enforcing()
.loadByFieldEqualingAsync('parent_id', parent.getID())
).resolves.not.toBeNull();
await expect(
GrandChildEntity.loader(viewerContext)
.enforcing()
.loadByFieldEqualingAsync('parent_id', child.getID())
).resolves.not.toBeNull();
const childCacheAdapter = viewerContext.getViewerScopedEntityCompanionForClass(ChildEntity)[
'entityCompanion'
]['tableDataCoordinator']['cacheAdapter'] as InMemoryFullCacheStubCacheAdapter<ChildFields>;
const childCachedBefore = await childCacheAdapter.loadManyAsync('parent_id', [
parent.getID(),
]);
expect(childCachedBefore.get(parent.getID())?.status).toEqual(CacheStatus.HIT);
const grandChildCacheAdapter = viewerContext.getViewerScopedEntityCompanionForClass(
GrandChildEntity
)['entityCompanion']['tableDataCoordinator'][
'cacheAdapter'
] as InMemoryFullCacheStubCacheAdapter<ChildFields>;
const grandChildCachedBefore = await grandChildCacheAdapter.loadManyAsync('parent_id', [
child.getID(),
]);
expect(grandChildCachedBefore.get(child.getID())?.status).toEqual(CacheStatus.HIT);
privacyPolicyEvaluationRecords.shouldRecord = true;
await ParentEntity.enforceDeleteAsync(parent);
privacyPolicyEvaluationRecords.shouldRecord = false;
const childCachedAfter = await childCacheAdapter.loadManyAsync('parent_id', [parent.getID()]); const childCachedAfter = await childCacheAdapter.loadManyAsync('parent_id', [parent.getID()]);
expect(childCachedAfter.get(parent.getID())?.status).toEqual(CacheStatus.MISS); expect(childCachedAfter.get(parent.getID())?.status).toEqual(CacheStatus.MISS);
@@ -469,9 +901,73 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
// two calls for each trigger, one beforeDelete, one afterDelete // two calls for each trigger, one beforeDelete, one afterDelete
expect(triggerExecutionCounts).toMatchObject({ expect(triggerExecutionCounts).toMatchObject({
parent: 2, ParentEntityDeletion: 2,
child: 2, ChildEntityDeletion: 2,
grandchild: 2, GrandChildEntityDeletion: 2,
ParentEntityUpdate: 0,
ChildEntityUpdate: 0,
GrandChildEntityUpdate: 0,
});
expect(privacyPolicyEvaluationRecords).toMatchObject({
ParentEntity: {
[EntityAuthorizationAction.CREATE]: [],
[EntityAuthorizationAction.READ]: [],
[EntityAuthorizationAction.UPDATE]: [],
// one DELETE auth action for parent (since it's being deleted)
[EntityAuthorizationAction.DELETE]: [{ cascadingDeleteCause: null }],
},
ChildEntity: {
[EntityAuthorizationAction.CREATE]: [],
// one READ auth action for child in order to delete via cascade
[EntityAuthorizationAction.READ]: [
{
cascadingDeleteCause: {
entity: expect.any(ParentEntity),
cascadingDeleteCause: null,
},
},
],
[EntityAuthorizationAction.UPDATE]: [],
// one DELETE auth action for child (since it's being deleted via cascade)
[EntityAuthorizationAction.DELETE]: [
{
cascadingDeleteCause: {
entity: expect.any(ParentEntity),
cascadingDeleteCause: null,
},
},
],
},
GrandChildEntity: {
[EntityAuthorizationAction.CREATE]: [],
// one READ auth action for grandchild in order to delete via cascade
[EntityAuthorizationAction.READ]: [
{
cascadingDeleteCause: {
entity: expect.any(ChildEntity),
cascadingDeleteCause: {
entity: expect.any(ParentEntity),
cascadingDeleteCause: null,
},
},
},
],
[EntityAuthorizationAction.UPDATE]: [],
// one DELETE auth action for grandchild (since it's being deleted via cascade)
[EntityAuthorizationAction.DELETE]: [
{
cascadingDeleteCause: {
entity: expect.any(ChildEntity),
cascadingDeleteCause: {
entity: expect.any(ParentEntity),
cascadingDeleteCause: null,
},
},
},
],
},
}); });
}); });
}); });
@@ -5,7 +5,7 @@ import { EntityCompanionDefinition } from '../EntityCompanionProvider';
import EntityConfiguration from '../EntityConfiguration'; import EntityConfiguration from '../EntityConfiguration';
import { StringField } from '../EntityFields'; import { StringField } from '../EntityFields';
import EntityLoader from '../EntityLoader'; import EntityLoader from '../EntityLoader';
import EntityPrivacyPolicy from '../EntityPrivacyPolicy'; import EntityPrivacyPolicy, { EntityPrivacyPolicyEvaluationContext } from '../EntityPrivacyPolicy';
import ViewerContext from '../ViewerContext'; import ViewerContext from '../ViewerContext';
import EntityDataManager from '../internal/EntityDataManager'; import EntityDataManager from '../internal/EntityDataManager';
import ReadThroughEntityCache from '../internal/ReadThroughEntityCache'; import ReadThroughEntityCache from '../internal/ReadThroughEntityCache';
@@ -118,6 +118,7 @@ export const testEntityCompanion = new EntityCompanionDefinition({
describe(EntityLoader, () => { describe(EntityLoader, () => {
it('handles thrown errors and literals from constructor', async () => { it('handles thrown errors and literals from constructor', async () => {
const viewerContext = instance(mock(ViewerContext)); const viewerContext = instance(mock(ViewerContext));
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const metricsAdapter = instance(mock<IEntityMetricsAdapter>()); const metricsAdapter = instance(mock<IEntityMetricsAdapter>());
const queryContext = StubQueryContextProvider.getQueryContext(); const queryContext = StubQueryContextProvider.getQueryContext();
@@ -154,6 +155,7 @@ describe(EntityLoader, () => {
const entityLoader = new EntityLoader( const entityLoader = new EntityLoader(
viewerContext, viewerContext,
queryContext, queryContext,
privacyPolicyEvaluationContext,
testEntityConfiguration, testEntityConfiguration,
TestEntity, TestEntity,
privacyPolicy, privacyPolicy,
@@ -3,6 +3,7 @@ import { mock, instance, verify, spy, deepEqual, anyOfClass, anything, when } fr
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import EntityLoader from '../EntityLoader'; import EntityLoader from '../EntityLoader';
import { EntityPrivacyPolicyEvaluationContext } from '../EntityPrivacyPolicy';
import ViewerContext from '../ViewerContext'; import ViewerContext from '../ViewerContext';
import { enforceResultsAsync } from '../entityUtils'; import { enforceResultsAsync } from '../entityUtils';
import EntityDataManager from '../internal/EntityDataManager'; import EntityDataManager from '../internal/EntityDataManager';
@@ -21,6 +22,7 @@ describe(EntityLoader, () => {
it('loads entities', async () => { it('loads entities', async () => {
const dateToInsert = new Date(); const dateToInsert = new Date();
const viewerContext = instance(mock(ViewerContext)); const viewerContext = instance(mock(ViewerContext));
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const metricsAdapter = instance(mock<IEntityMetricsAdapter>()); const metricsAdapter = instance(mock<IEntityMetricsAdapter>());
const queryContext = StubQueryContextProvider.getQueryContext(); const queryContext = StubQueryContextProvider.getQueryContext();
@@ -69,6 +71,7 @@ describe(EntityLoader, () => {
const entityLoader = new EntityLoader( const entityLoader = new EntityLoader(
viewerContext, viewerContext,
queryContext, queryContext,
privacyPolicyEvaluationContext,
testEntityConfiguration, testEntityConfiguration,
TestEntity, TestEntity,
privacyPolicy, privacyPolicy,
@@ -113,6 +116,7 @@ describe(EntityLoader, () => {
const privacyPolicy = new TestEntityPrivacyPolicy(); const privacyPolicy = new TestEntityPrivacyPolicy();
const spiedPrivacyPolicy = spy(privacyPolicy); const spiedPrivacyPolicy = spy(privacyPolicy);
const viewerContext = instance(mock(ViewerContext)); const viewerContext = instance(mock(ViewerContext));
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const metricsAdapter = instance(mock<IEntityMetricsAdapter>()); const metricsAdapter = instance(mock<IEntityMetricsAdapter>());
const queryContext = StubQueryContextProvider.getQueryContext(); const queryContext = StubQueryContextProvider.getQueryContext();
@@ -169,6 +173,7 @@ describe(EntityLoader, () => {
const entityLoader = new EntityLoader( const entityLoader = new EntityLoader(
viewerContext, viewerContext,
queryContext, queryContext,
privacyPolicyEvaluationContext,
testEntityConfiguration, testEntityConfiguration,
TestEntity, TestEntity,
privacyPolicy, privacyPolicy,
@@ -192,6 +197,7 @@ describe(EntityLoader, () => {
spiedPrivacyPolicy.authorizeReadAsync( spiedPrivacyPolicy.authorizeReadAsync(
viewerContext, viewerContext,
queryContext, queryContext,
privacyPolicyEvaluationContext,
anyOfClass(TestEntity), anyOfClass(TestEntity),
anything() anything()
) )
@@ -209,6 +215,7 @@ describe(EntityLoader, () => {
const spiedPrivacyPolicy = spy(privacyPolicy); const spiedPrivacyPolicy = spy(privacyPolicy);
const viewerContext = instance(mock(ViewerContext)); const viewerContext = instance(mock(ViewerContext));
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const metricsAdapter = instance(mock<IEntityMetricsAdapter>()); const metricsAdapter = instance(mock<IEntityMetricsAdapter>());
const queryContext = StubQueryContextProvider.getQueryContext(); const queryContext = StubQueryContextProvider.getQueryContext();
@@ -247,6 +254,7 @@ describe(EntityLoader, () => {
const entityLoader = new EntityLoader( const entityLoader = new EntityLoader(
viewerContext, viewerContext,
queryContext, queryContext,
privacyPolicyEvaluationContext,
testEntityConfiguration, testEntityConfiguration,
TestEntity, TestEntity,
privacyPolicy, privacyPolicy,
@@ -255,12 +263,19 @@ describe(EntityLoader, () => {
); );
const entity = await enforceAsyncResult(entityLoader.loadByIDAsync(id1)); const entity = await enforceAsyncResult(entityLoader.loadByIDAsync(id1));
verify( verify(
spiedPrivacyPolicy.authorizeReadAsync(viewerContext, queryContext, entity, anything()) spiedPrivacyPolicy.authorizeReadAsync(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
entity,
anything()
)
).once(); ).once();
}); });
it('invalidates upon invalidate one', async () => { it('invalidates upon invalidate one', async () => {
const viewerContext = instance(mock(ViewerContext)); const viewerContext = instance(mock(ViewerContext));
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const metricsAdapter = instance(mock<IEntityMetricsAdapter>()); const metricsAdapter = instance(mock<IEntityMetricsAdapter>());
const queryContext = StubQueryContextProvider.getQueryContext(); const queryContext = StubQueryContextProvider.getQueryContext();
const privacyPolicy = instance(mock(TestEntityPrivacyPolicy)); const privacyPolicy = instance(mock(TestEntityPrivacyPolicy));
@@ -271,6 +286,7 @@ describe(EntityLoader, () => {
const entityLoader = new EntityLoader( const entityLoader = new EntityLoader(
viewerContext, viewerContext,
queryContext, queryContext,
privacyPolicyEvaluationContext,
testEntityConfiguration, testEntityConfiguration,
TestEntity, TestEntity,
privacyPolicy, privacyPolicy,
@@ -286,6 +302,7 @@ describe(EntityLoader, () => {
it('invalidates upon invalidate by field', async () => { it('invalidates upon invalidate by field', async () => {
const viewerContext = instance(mock(ViewerContext)); const viewerContext = instance(mock(ViewerContext));
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const metricsAdapter = instance(mock<IEntityMetricsAdapter>()); const metricsAdapter = instance(mock<IEntityMetricsAdapter>());
const queryContext = StubQueryContextProvider.getQueryContext(); const queryContext = StubQueryContextProvider.getQueryContext();
const privacyPolicy = instance(mock(TestEntityPrivacyPolicy)); const privacyPolicy = instance(mock(TestEntityPrivacyPolicy));
@@ -296,6 +313,7 @@ describe(EntityLoader, () => {
const entityLoader = new EntityLoader( const entityLoader = new EntityLoader(
viewerContext, viewerContext,
queryContext, queryContext,
privacyPolicyEvaluationContext,
testEntityConfiguration, testEntityConfiguration,
TestEntity, TestEntity,
privacyPolicy, privacyPolicy,
@@ -310,6 +328,7 @@ describe(EntityLoader, () => {
it('invalidates upon invalidate by entity', async () => { it('invalidates upon invalidate by entity', async () => {
const viewerContext = instance(mock(ViewerContext)); const viewerContext = instance(mock(ViewerContext));
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const metricsAdapter = instance(mock<IEntityMetricsAdapter>()); const metricsAdapter = instance(mock<IEntityMetricsAdapter>());
const queryContext = StubQueryContextProvider.getQueryContext(); const queryContext = StubQueryContextProvider.getQueryContext();
const privacyPolicy = instance(mock(TestEntityPrivacyPolicy)); const privacyPolicy = instance(mock(TestEntityPrivacyPolicy));
@@ -324,6 +343,7 @@ describe(EntityLoader, () => {
const entityLoader = new EntityLoader( const entityLoader = new EntityLoader(
viewerContext, viewerContext,
queryContext, queryContext,
privacyPolicyEvaluationContext,
testEntityConfiguration, testEntityConfiguration,
TestEntity, TestEntity,
privacyPolicy, privacyPolicy,
@@ -338,6 +358,7 @@ describe(EntityLoader, () => {
it('returns error result when not allowed', async () => { it('returns error result when not allowed', async () => {
const viewerContext = instance(mock(ViewerContext)); const viewerContext = instance(mock(ViewerContext));
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const metricsAdapter = instance(mock<IEntityMetricsAdapter>()); const metricsAdapter = instance(mock<IEntityMetricsAdapter>());
const queryContext = StubQueryContextProvider.getQueryContext(); const queryContext = StubQueryContextProvider.getQueryContext();
const privacyPolicyMock = mock(TestEntityPrivacyPolicy); const privacyPolicyMock = mock(TestEntityPrivacyPolicy);
@@ -354,6 +375,7 @@ describe(EntityLoader, () => {
privacyPolicyMock.authorizeReadAsync( privacyPolicyMock.authorizeReadAsync(
viewerContext, viewerContext,
queryContext, queryContext,
privacyPolicyEvaluationContext,
anyOfClass(TestEntity), anyOfClass(TestEntity),
anything() anything()
) )
@@ -365,6 +387,7 @@ describe(EntityLoader, () => {
const entityLoader = new EntityLoader( const entityLoader = new EntityLoader(
viewerContext, viewerContext,
queryContext, queryContext,
privacyPolicyEvaluationContext,
testEntityConfiguration, testEntityConfiguration,
TestEntity, TestEntity,
privacyPolicy, privacyPolicy,
@@ -380,6 +403,7 @@ describe(EntityLoader, () => {
it('throws upon database adapter error', async () => { it('throws upon database adapter error', async () => {
const viewerContext = instance(mock(ViewerContext)); const viewerContext = instance(mock(ViewerContext));
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const metricsAdapter = instance(mock<IEntityMetricsAdapter>()); const metricsAdapter = instance(mock<IEntityMetricsAdapter>());
const queryContext = StubQueryContextProvider.getQueryContext(); const queryContext = StubQueryContextProvider.getQueryContext();
const privacyPolicy = instance(mock(TestEntityPrivacyPolicy)); const privacyPolicy = instance(mock(TestEntityPrivacyPolicy));
@@ -396,6 +420,7 @@ describe(EntityLoader, () => {
const entityLoader = new EntityLoader( const entityLoader = new EntityLoader(
viewerContext, viewerContext,
queryContext, queryContext,
privacyPolicyEvaluationContext,
testEntityConfiguration, testEntityConfiguration,
TestEntity, TestEntity,
privacyPolicy, privacyPolicy,
@@ -25,6 +25,7 @@ import EntityMutationTriggerConfiguration, {
} from '../EntityMutationTriggerConfiguration'; } from '../EntityMutationTriggerConfiguration';
import EntityMutationValidator from '../EntityMutationValidator'; import EntityMutationValidator from '../EntityMutationValidator';
import EntityMutatorFactory from '../EntityMutatorFactory'; import EntityMutatorFactory from '../EntityMutatorFactory';
import { EntityPrivacyPolicyEvaluationContext } from '../EntityPrivacyPolicy';
import { EntityTransactionalQueryContext, EntityQueryContext } from '../EntityQueryContext'; import { EntityTransactionalQueryContext, EntityQueryContext } from '../EntityQueryContext';
import ViewerContext from '../ViewerContext'; import ViewerContext from '../ViewerContext';
import { enforceResultsAsync } from '../entityUtils'; import { enforceResultsAsync } from '../entityUtils';
@@ -405,6 +406,7 @@ describe(EntityMutatorFactory, () => {
spiedPrivacyPolicy.authorizeCreateAsync( spiedPrivacyPolicy.authorizeCreateAsync(
viewerContext, viewerContext,
anyOfClass(EntityTransactionalQueryContext), anyOfClass(EntityTransactionalQueryContext),
deepEqual({ cascadingDeleteCause: null }),
anyOfClass(TestEntity), anyOfClass(TestEntity),
anything() anything()
) )
@@ -497,6 +499,7 @@ describe(EntityMutatorFactory, () => {
describe('forUpdate', () => { describe('forUpdate', () => {
it('updates entities', async () => { it('updates entities', async () => {
const viewerContext = mock<ViewerContext>(); const viewerContext = mock<ViewerContext>();
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const queryContext = StubQueryContextProvider.getQueryContext(); const queryContext = StubQueryContextProvider.getQueryContext();
const id1 = uuidv4(); const id1 = uuidv4();
@@ -521,7 +524,9 @@ describe(EntityMutatorFactory, () => {
]); ]);
const existingEntity = await enforceAsyncResult( const existingEntity = await enforceAsyncResult(
entityLoaderFactory.forLoad(viewerContext, queryContext).loadByIDAsync(id2) entityLoaderFactory
.forLoad(viewerContext, queryContext, privacyPolicyEvaluationContext)
.loadByIDAsync(id2)
); );
const updatedEntity = await entityMutatorFactory const updatedEntity = await entityMutatorFactory
@@ -534,13 +539,16 @@ describe(EntityMutatorFactory, () => {
expect(updatedEntity.getField('stringField')).toEqual('huh2'); expect(updatedEntity.getField('stringField')).toEqual('huh2');
const reloadedEntity = await enforceAsyncResult( const reloadedEntity = await enforceAsyncResult(
entityLoaderFactory.forLoad(viewerContext, queryContext).loadByIDAsync(id2) entityLoaderFactory
.forLoad(viewerContext, queryContext, privacyPolicyEvaluationContext)
.loadByIDAsync(id2)
); );
expect(reloadedEntity.getAllFields()).toMatchObject(updatedEntity.getAllFields()); expect(reloadedEntity.getAllFields()).toMatchObject(updatedEntity.getAllFields());
}); });
it('checks privacy', async () => { it('checks privacy', async () => {
const viewerContext = mock<ViewerContext>(); const viewerContext = mock<ViewerContext>();
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const queryContext = StubQueryContextProvider.getQueryContext(); const queryContext = StubQueryContextProvider.getQueryContext();
const id1 = uuidv4(); const id1 = uuidv4();
@@ -568,7 +576,9 @@ describe(EntityMutatorFactory, () => {
const spiedPrivacyPolicy = spy(privacyPolicy); const spiedPrivacyPolicy = spy(privacyPolicy);
const existingEntity = await enforceAsyncResult( const existingEntity = await enforceAsyncResult(
entityLoaderFactory.forLoad(viewerContext, queryContext).loadByIDAsync(id2) entityLoaderFactory
.forLoad(viewerContext, queryContext, privacyPolicyEvaluationContext)
.loadByIDAsync(id2)
); );
await entityMutatorFactory await entityMutatorFactory
@@ -580,6 +590,7 @@ describe(EntityMutatorFactory, () => {
spiedPrivacyPolicy.authorizeUpdateAsync( spiedPrivacyPolicy.authorizeUpdateAsync(
viewerContext, viewerContext,
anyOfClass(EntityTransactionalQueryContext), anyOfClass(EntityTransactionalQueryContext),
deepEqual({ cascadingDeleteCause: null }),
anyOfClass(TestEntity), anyOfClass(TestEntity),
anything() anything()
) )
@@ -588,6 +599,7 @@ describe(EntityMutatorFactory, () => {
it('executes triggers', async () => { it('executes triggers', async () => {
const viewerContext = mock<ViewerContext>(); const viewerContext = mock<ViewerContext>();
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const queryContext = StubQueryContextProvider.getQueryContext(); const queryContext = StubQueryContextProvider.getQueryContext();
const id1 = uuidv4(); const id1 = uuidv4();
@@ -615,7 +627,9 @@ describe(EntityMutatorFactory, () => {
const triggerSpies = setUpMutationTriggerSpies(mutationTriggers); const triggerSpies = setUpMutationTriggerSpies(mutationTriggers);
const existingEntity = await enforceAsyncResult( const existingEntity = await enforceAsyncResult(
entityLoaderFactory.forLoad(viewerContext, queryContext).loadByIDAsync(id2) entityLoaderFactory
.forLoad(viewerContext, queryContext, privacyPolicyEvaluationContext)
.loadByIDAsync(id2)
); );
await entityMutatorFactory await entityMutatorFactory
@@ -634,11 +648,16 @@ describe(EntityMutatorFactory, () => {
beforeDelete: false, beforeDelete: false,
afterDelete: false, afterDelete: false,
}, },
{ type: EntityMutationType.UPDATE, previousValue: existingEntity } {
type: EntityMutationType.UPDATE,
previousValue: existingEntity,
cascadingDeleteCause: null,
}
); );
}); });
it('executes validators', async () => { it('executes validators', async () => {
const viewerContext = mock<ViewerContext>(); const viewerContext = mock<ViewerContext>();
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const queryContext = StubQueryContextProvider.getQueryContext(); const queryContext = StubQueryContextProvider.getQueryContext();
const id1 = uuidv4(); const id1 = uuidv4();
@@ -666,7 +685,9 @@ describe(EntityMutatorFactory, () => {
const validatorSpies = setUpMutationValidatorSpies(mutationValidators); const validatorSpies = setUpMutationValidatorSpies(mutationValidators);
const existingEntity = await enforceAsyncResult( const existingEntity = await enforceAsyncResult(
entityLoaderFactory.forLoad(viewerContext, queryContext).loadByIDAsync(id2) entityLoaderFactory
.forLoad(viewerContext, queryContext, privacyPolicyEvaluationContext)
.loadByIDAsync(id2)
); );
await entityMutatorFactory await entityMutatorFactory
@@ -677,6 +698,7 @@ describe(EntityMutatorFactory, () => {
verifyValidatorCounts(viewerContext, validatorSpies, 1, { verifyValidatorCounts(viewerContext, validatorSpies, 1, {
type: EntityMutationType.UPDATE, type: EntityMutationType.UPDATE,
previousValue: existingEntity, previousValue: existingEntity,
cascadingDeleteCause: null,
}); });
}); });
}); });
@@ -684,6 +706,7 @@ describe(EntityMutatorFactory, () => {
describe('forDelete', () => { describe('forDelete', () => {
it('deletes entities', async () => { it('deletes entities', async () => {
const viewerContext = mock<ViewerContext>(); const viewerContext = mock<ViewerContext>();
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const queryContext = StubQueryContextProvider.getQueryContext(); const queryContext = StubQueryContextProvider.getQueryContext();
const id1 = uuidv4(); const id1 = uuidv4();
@@ -699,7 +722,9 @@ describe(EntityMutatorFactory, () => {
]); ]);
const existingEntity = await enforceAsyncResult( const existingEntity = await enforceAsyncResult(
entityLoaderFactory.forLoad(viewerContext, queryContext).loadByIDAsync(id1) entityLoaderFactory
.forLoad(viewerContext, queryContext, privacyPolicyEvaluationContext)
.loadByIDAsync(id1)
); );
expect(existingEntity).toBeTruthy(); expect(existingEntity).toBeTruthy();
@@ -707,13 +732,16 @@ describe(EntityMutatorFactory, () => {
await expect( await expect(
enforceAsyncResult( enforceAsyncResult(
entityLoaderFactory.forLoad(viewerContext, queryContext).loadByIDAsync(id1) entityLoaderFactory
.forLoad(viewerContext, queryContext, privacyPolicyEvaluationContext)
.loadByIDAsync(id1)
) )
).rejects.toBeInstanceOf(Error); ).rejects.toBeInstanceOf(Error);
}); });
it('checks privacy', async () => { it('checks privacy', async () => {
const viewerContext = mock<ViewerContext>(); const viewerContext = mock<ViewerContext>();
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const queryContext = StubQueryContextProvider.getQueryContext(); const queryContext = StubQueryContextProvider.getQueryContext();
const id1 = uuidv4(); const id1 = uuidv4();
@@ -732,7 +760,9 @@ describe(EntityMutatorFactory, () => {
const spiedPrivacyPolicy = spy(privacyPolicy); const spiedPrivacyPolicy = spy(privacyPolicy);
const existingEntity = await enforceAsyncResult( const existingEntity = await enforceAsyncResult(
entityLoaderFactory.forLoad(viewerContext, queryContext).loadByIDAsync(id1) entityLoaderFactory
.forLoad(viewerContext, queryContext, privacyPolicyEvaluationContext)
.loadByIDAsync(id1)
); );
await entityMutatorFactory.forDelete(existingEntity, queryContext).enforceDeleteAsync(); await entityMutatorFactory.forDelete(existingEntity, queryContext).enforceDeleteAsync();
@@ -741,6 +771,7 @@ describe(EntityMutatorFactory, () => {
spiedPrivacyPolicy.authorizeDeleteAsync( spiedPrivacyPolicy.authorizeDeleteAsync(
viewerContext, viewerContext,
anyOfClass(EntityTransactionalQueryContext), anyOfClass(EntityTransactionalQueryContext),
anything(),
anyOfClass(TestEntity), anyOfClass(TestEntity),
anything() anything()
) )
@@ -749,6 +780,7 @@ describe(EntityMutatorFactory, () => {
it('executes triggers', async () => { it('executes triggers', async () => {
const viewerContext = mock<ViewerContext>(); const viewerContext = mock<ViewerContext>();
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const queryContext = StubQueryContextProvider.getQueryContext(); const queryContext = StubQueryContextProvider.getQueryContext();
const id1 = uuidv4(); const id1 = uuidv4();
@@ -767,7 +799,9 @@ describe(EntityMutatorFactory, () => {
const triggerSpies = setUpMutationTriggerSpies(mutationTriggers); const triggerSpies = setUpMutationTriggerSpies(mutationTriggers);
const existingEntity = await enforceAsyncResult( const existingEntity = await enforceAsyncResult(
entityLoaderFactory.forLoad(viewerContext, queryContext).loadByIDAsync(id1) entityLoaderFactory
.forLoad(viewerContext, queryContext, privacyPolicyEvaluationContext)
.loadByIDAsync(id1)
); );
await entityMutatorFactory.forDelete(existingEntity, queryContext).enforceDeleteAsync(); await entityMutatorFactory.forDelete(existingEntity, queryContext).enforceDeleteAsync();
@@ -789,6 +823,7 @@ describe(EntityMutatorFactory, () => {
it('does not execute validators', async () => { it('does not execute validators', async () => {
const viewerContext = mock<ViewerContext>(); const viewerContext = mock<ViewerContext>();
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const queryContext = StubQueryContextProvider.getQueryContext(); const queryContext = StubQueryContextProvider.getQueryContext();
const id1 = uuidv4(); const id1 = uuidv4();
@@ -807,7 +842,9 @@ describe(EntityMutatorFactory, () => {
const validatorSpies = setUpMutationValidatorSpies(mutationValidators); const validatorSpies = setUpMutationValidatorSpies(mutationValidators);
const existingEntity = await enforceAsyncResult( const existingEntity = await enforceAsyncResult(
entityLoaderFactory.forLoad(viewerContext, queryContext).loadByIDAsync(id1) entityLoaderFactory
.forLoad(viewerContext, queryContext, privacyPolicyEvaluationContext)
.loadByIDAsync(id1)
); );
await entityMutatorFactory.forDelete(existingEntity, queryContext).enforceDeleteAsync(); await entityMutatorFactory.forDelete(existingEntity, queryContext).enforceDeleteAsync();
@@ -820,6 +857,7 @@ describe(EntityMutatorFactory, () => {
it('invalidates cache for fields upon create', async () => { it('invalidates cache for fields upon create', async () => {
const viewerContext = mock<ViewerContext>(); const viewerContext = mock<ViewerContext>();
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const queryContext = StubQueryContextProvider.getQueryContext(); const queryContext = StubQueryContextProvider.getQueryContext();
const id1 = uuidv4(); const id1 = uuidv4();
@@ -836,7 +874,7 @@ describe(EntityMutatorFactory, () => {
const entites1 = await enforceResultsAsync( const entites1 = await enforceResultsAsync(
entityLoaderFactory entityLoaderFactory
.forLoad(viewerContext, queryContext) .forLoad(viewerContext, queryContext, privacyPolicyEvaluationContext)
.loadManyByFieldEqualingAsync('stringField', 'huh') .loadManyByFieldEqualingAsync('stringField', 'huh')
); );
expect(entites1).toHaveLength(1); expect(entites1).toHaveLength(1);
@@ -850,7 +888,7 @@ describe(EntityMutatorFactory, () => {
const entities2 = await enforceResultsAsync( const entities2 = await enforceResultsAsync(
entityLoaderFactory entityLoaderFactory
.forLoad(viewerContext, queryContext) .forLoad(viewerContext, queryContext, privacyPolicyEvaluationContext)
.loadManyByFieldEqualingAsync('stringField', 'huh') .loadManyByFieldEqualingAsync('stringField', 'huh')
); );
expect(entities2).toHaveLength(2); expect(entities2).toHaveLength(2);
@@ -916,6 +954,7 @@ describe(EntityMutatorFactory, () => {
privacyPolicyMock.authorizeCreateAsync( privacyPolicyMock.authorizeCreateAsync(
viewerContext, viewerContext,
anyOfClass(EntityTransactionalQueryContext), anyOfClass(EntityTransactionalQueryContext),
anything(),
anyOfClass(SimpleTestEntity), anyOfClass(SimpleTestEntity),
anything() anything()
) )
@@ -924,6 +963,7 @@ describe(EntityMutatorFactory, () => {
privacyPolicyMock.authorizeUpdateAsync( privacyPolicyMock.authorizeUpdateAsync(
viewerContext, viewerContext,
anyOfClass(EntityTransactionalQueryContext), anyOfClass(EntityTransactionalQueryContext),
anything(),
anyOfClass(SimpleTestEntity), anyOfClass(SimpleTestEntity),
anything() anything()
) )
@@ -932,6 +972,7 @@ describe(EntityMutatorFactory, () => {
privacyPolicyMock.authorizeDeleteAsync( privacyPolicyMock.authorizeDeleteAsync(
viewerContext, viewerContext,
anyOfClass(EntityTransactionalQueryContext), anyOfClass(EntityTransactionalQueryContext),
anything(),
anyOfClass(SimpleTestEntity), anyOfClass(SimpleTestEntity),
anything() anything()
) )
@@ -8,6 +8,7 @@ import EntityPrivacyPolicy, {
EntityPrivacyPolicyEvaluator, EntityPrivacyPolicyEvaluator,
EntityAuthorizationAction, EntityAuthorizationAction,
EntityPrivacyPolicyEvaluationMode, EntityPrivacyPolicyEvaluationMode,
EntityPrivacyPolicyEvaluationContext,
} from '../EntityPrivacyPolicy'; } from '../EntityPrivacyPolicy';
import { EntityQueryContext } from '../EntityQueryContext'; import { EntityQueryContext } from '../EntityQueryContext';
import ViewerContext from '../ViewerContext'; import ViewerContext from '../ViewerContext';
@@ -166,6 +167,7 @@ class AlwaysThrowPrivacyPolicyRule extends PrivacyPolicyRule<
evaluateAsync( evaluateAsync(
_viewerContext: ViewerContext, _viewerContext: ViewerContext,
_queryContext: EntityQueryContext, _queryContext: EntityQueryContext,
_evaluationContext: EntityPrivacyPolicyEvaluationContext,
_entity: BlahEntity _entity: BlahEntity
): Promise<RuleEvaluationResult> { ): Promise<RuleEvaluationResult> {
throw new Error('WooHoo!'); throw new Error('WooHoo!');
@@ -245,12 +247,19 @@ describe(EntityPrivacyPolicy, () => {
it('throws EntityNotAuthorizedError when deny', async () => { it('throws EntityNotAuthorizedError when deny', async () => {
const viewerContext = instance(mock(ViewerContext)); const viewerContext = instance(mock(ViewerContext));
const queryContext = instance(mock(EntityQueryContext)); const queryContext = instance(mock(EntityQueryContext));
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const metricsAdapterMock = mock<IEntityMetricsAdapter>(); const metricsAdapterMock = mock<IEntityMetricsAdapter>();
const metricsAdapter = instance(metricsAdapterMock); const metricsAdapter = instance(metricsAdapterMock);
const entity = new BlahEntity(viewerContext, { id: '1' }); const entity = new BlahEntity(viewerContext, { id: '1' });
const policy = new AlwaysDenyPolicy(); const policy = new AlwaysDenyPolicy();
await expect( await expect(
policy.authorizeCreateAsync(viewerContext, queryContext, entity, metricsAdapter) policy.authorizeCreateAsync(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
entity,
metricsAdapter
)
).rejects.toBeInstanceOf(EntityNotAuthorizedError); ).rejects.toBeInstanceOf(EntityNotAuthorizedError);
verify( verify(
metricsAdapterMock.logAuthorizationEvent( metricsAdapterMock.logAuthorizationEvent(
@@ -267,6 +276,7 @@ describe(EntityPrivacyPolicy, () => {
it('returns entity when allowed', async () => { it('returns entity when allowed', async () => {
const viewerContext = instance(mock(ViewerContext)); const viewerContext = instance(mock(ViewerContext));
const queryContext = instance(mock(EntityQueryContext)); const queryContext = instance(mock(EntityQueryContext));
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const metricsAdapterMock = mock<IEntityMetricsAdapter>(); const metricsAdapterMock = mock<IEntityMetricsAdapter>();
const metricsAdapter = instance(metricsAdapterMock); const metricsAdapter = instance(metricsAdapterMock);
const entity = new BlahEntity(viewerContext, { id: '1' }); const entity = new BlahEntity(viewerContext, { id: '1' });
@@ -274,6 +284,7 @@ describe(EntityPrivacyPolicy, () => {
const approvedEntity = await policy.authorizeCreateAsync( const approvedEntity = await policy.authorizeCreateAsync(
viewerContext, viewerContext,
queryContext, queryContext,
privacyPolicyEvaluationContext,
entity, entity,
metricsAdapter metricsAdapter
); );
@@ -293,12 +304,19 @@ describe(EntityPrivacyPolicy, () => {
it('throws EntityNotAuthorizedError when all skipped', async () => { it('throws EntityNotAuthorizedError when all skipped', async () => {
const viewerContext = instance(mock(ViewerContext)); const viewerContext = instance(mock(ViewerContext));
const queryContext = instance(mock(EntityQueryContext)); const queryContext = instance(mock(EntityQueryContext));
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const metricsAdapterMock = mock<IEntityMetricsAdapter>(); const metricsAdapterMock = mock<IEntityMetricsAdapter>();
const metricsAdapter = instance(metricsAdapterMock); const metricsAdapter = instance(metricsAdapterMock);
const entity = new BlahEntity(viewerContext, { id: '1' }); const entity = new BlahEntity(viewerContext, { id: '1' });
const policy = new SkipAllPolicy(); const policy = new SkipAllPolicy();
await expect( await expect(
policy.authorizeCreateAsync(viewerContext, queryContext, entity, metricsAdapter) policy.authorizeCreateAsync(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
entity,
metricsAdapter
)
).rejects.toBeInstanceOf(EntityNotAuthorizedError); ).rejects.toBeInstanceOf(EntityNotAuthorizedError);
verify( verify(
metricsAdapterMock.logAuthorizationEvent( metricsAdapterMock.logAuthorizationEvent(
@@ -315,12 +333,19 @@ describe(EntityPrivacyPolicy, () => {
it('throws EntityNotAuthorizedError when empty policy', async () => { it('throws EntityNotAuthorizedError when empty policy', async () => {
const viewerContext = instance(mock(ViewerContext)); const viewerContext = instance(mock(ViewerContext));
const queryContext = instance(mock(EntityQueryContext)); const queryContext = instance(mock(EntityQueryContext));
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const metricsAdapterMock = mock<IEntityMetricsAdapter>(); const metricsAdapterMock = mock<IEntityMetricsAdapter>();
const metricsAdapter = instance(metricsAdapterMock); const metricsAdapter = instance(metricsAdapterMock);
const entity = new BlahEntity(viewerContext, { id: '1' }); const entity = new BlahEntity(viewerContext, { id: '1' });
const policy = new EmptyPolicy(); const policy = new EmptyPolicy();
await expect( await expect(
policy.authorizeCreateAsync(viewerContext, queryContext, entity, metricsAdapter) policy.authorizeCreateAsync(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
entity,
metricsAdapter
)
).rejects.toBeInstanceOf(EntityNotAuthorizedError); ).rejects.toBeInstanceOf(EntityNotAuthorizedError);
verify( verify(
metricsAdapterMock.logAuthorizationEvent( metricsAdapterMock.logAuthorizationEvent(
@@ -337,12 +362,19 @@ describe(EntityPrivacyPolicy, () => {
it('throws when rule throws', async () => { it('throws when rule throws', async () => {
const viewerContext = instance(mock(ViewerContext)); const viewerContext = instance(mock(ViewerContext));
const queryContext = instance(mock(EntityQueryContext)); const queryContext = instance(mock(EntityQueryContext));
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const metricsAdapterMock = mock<IEntityMetricsAdapter>(); const metricsAdapterMock = mock<IEntityMetricsAdapter>();
const metricsAdapter = instance(metricsAdapterMock); const metricsAdapter = instance(metricsAdapterMock);
const entity = new BlahEntity(viewerContext, { id: '1' }); const entity = new BlahEntity(viewerContext, { id: '1' });
const policy = new ThrowAllPolicy(); const policy = new ThrowAllPolicy();
await expect( await expect(
policy.authorizeCreateAsync(viewerContext, queryContext, entity, metricsAdapter) policy.authorizeCreateAsync(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
entity,
metricsAdapter
)
).rejects.toThrowError('WooHoo!'); ).rejects.toThrowError('WooHoo!');
verify(metricsAdapterMock.logAuthorizationEvent(anything())).never(); verify(metricsAdapterMock.logAuthorizationEvent(anything())).never();
}); });
@@ -352,6 +384,7 @@ describe(EntityPrivacyPolicy, () => {
it('returns entity when denied but calls denialHandler', async () => { it('returns entity when denied but calls denialHandler', async () => {
const viewerContext = instance(mock(ViewerContext)); const viewerContext = instance(mock(ViewerContext));
const queryContext = instance(mock(EntityQueryContext)); const queryContext = instance(mock(EntityQueryContext));
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const metricsAdapterMock = mock<IEntityMetricsAdapter>(); const metricsAdapterMock = mock<IEntityMetricsAdapter>();
const metricsAdapter = instance(metricsAdapterMock); const metricsAdapter = instance(metricsAdapterMock);
const entity = new BlahEntity(viewerContext, { id: '1' }); const entity = new BlahEntity(viewerContext, { id: '1' });
@@ -362,6 +395,7 @@ describe(EntityPrivacyPolicy, () => {
const approvedEntity = await policy.authorizeCreateAsync( const approvedEntity = await policy.authorizeCreateAsync(
viewerContext, viewerContext,
queryContext, queryContext,
privacyPolicyEvaluationContext,
entity, entity,
metricsAdapter metricsAdapter
); );
@@ -384,6 +418,7 @@ describe(EntityPrivacyPolicy, () => {
it('does not log when not denied', async () => { it('does not log when not denied', async () => {
const viewerContext = instance(mock(ViewerContext)); const viewerContext = instance(mock(ViewerContext));
const queryContext = instance(mock(EntityQueryContext)); const queryContext = instance(mock(EntityQueryContext));
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const metricsAdapterMock = mock<IEntityMetricsAdapter>(); const metricsAdapterMock = mock<IEntityMetricsAdapter>();
const metricsAdapter = instance(metricsAdapterMock); const metricsAdapter = instance(metricsAdapterMock);
const entity = new BlahEntity(viewerContext, { id: '1' }); const entity = new BlahEntity(viewerContext, { id: '1' });
@@ -394,6 +429,7 @@ describe(EntityPrivacyPolicy, () => {
const approvedEntity = await policy.authorizeCreateAsync( const approvedEntity = await policy.authorizeCreateAsync(
viewerContext, viewerContext,
queryContext, queryContext,
privacyPolicyEvaluationContext,
entity, entity,
metricsAdapter metricsAdapter
); );
@@ -416,6 +452,7 @@ describe(EntityPrivacyPolicy, () => {
it('passes through other errors', async () => { it('passes through other errors', async () => {
const viewerContext = instance(mock(ViewerContext)); const viewerContext = instance(mock(ViewerContext));
const queryContext = instance(mock(EntityQueryContext)); const queryContext = instance(mock(EntityQueryContext));
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const metricsAdapterMock = mock<IEntityMetricsAdapter>(); const metricsAdapterMock = mock<IEntityMetricsAdapter>();
const metricsAdapter = instance(metricsAdapterMock); const metricsAdapter = instance(metricsAdapterMock);
const entity = new BlahEntity(viewerContext, { id: '1' }); const entity = new BlahEntity(viewerContext, { id: '1' });
@@ -424,7 +461,13 @@ describe(EntityPrivacyPolicy, () => {
const policySpy = spy(policy); const policySpy = spy(policy);
await expect( await expect(
policy.authorizeCreateAsync(viewerContext, queryContext, entity, metricsAdapter) policy.authorizeCreateAsync(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
entity,
metricsAdapter
)
).rejects.toThrowError('WooHoo!'); ).rejects.toThrowError('WooHoo!');
verify(policySpy.denyHandler(anyOfClass(EntityNotAuthorizedError))).never(); verify(policySpy.denyHandler(anyOfClass(EntityNotAuthorizedError))).never();
@@ -437,6 +480,7 @@ describe(EntityPrivacyPolicy, () => {
it('denies when denied but calls denialHandler', async () => { it('denies when denied but calls denialHandler', async () => {
const viewerContext = instance(mock(ViewerContext)); const viewerContext = instance(mock(ViewerContext));
const queryContext = instance(mock(EntityQueryContext)); const queryContext = instance(mock(EntityQueryContext));
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const metricsAdapterMock = mock<IEntityMetricsAdapter>(); const metricsAdapterMock = mock<IEntityMetricsAdapter>();
const metricsAdapter = instance(metricsAdapterMock); const metricsAdapter = instance(metricsAdapterMock);
const entity = new BlahEntity(viewerContext, { id: '1' }); const entity = new BlahEntity(viewerContext, { id: '1' });
@@ -445,7 +489,13 @@ describe(EntityPrivacyPolicy, () => {
const policySpy = spy(policy); const policySpy = spy(policy);
await expect( await expect(
policy.authorizeCreateAsync(viewerContext, queryContext, entity, metricsAdapter) policy.authorizeCreateAsync(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
entity,
metricsAdapter
)
).rejects.toBeInstanceOf(EntityNotAuthorizedError); ).rejects.toBeInstanceOf(EntityNotAuthorizedError);
verify(policySpy.denyHandler(anyOfClass(EntityNotAuthorizedError))).once(); verify(policySpy.denyHandler(anyOfClass(EntityNotAuthorizedError))).once();
@@ -465,6 +515,7 @@ describe(EntityPrivacyPolicy, () => {
it('does not log when not denied', async () => { it('does not log when not denied', async () => {
const viewerContext = instance(mock(ViewerContext)); const viewerContext = instance(mock(ViewerContext));
const queryContext = instance(mock(EntityQueryContext)); const queryContext = instance(mock(EntityQueryContext));
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const metricsAdapterMock = mock<IEntityMetricsAdapter>(); const metricsAdapterMock = mock<IEntityMetricsAdapter>();
const metricsAdapter = instance(metricsAdapterMock); const metricsAdapter = instance(metricsAdapterMock);
const entity = new BlahEntity(viewerContext, { id: '1' }); const entity = new BlahEntity(viewerContext, { id: '1' });
@@ -475,6 +526,7 @@ describe(EntityPrivacyPolicy, () => {
const approvedEntity = await policy.authorizeCreateAsync( const approvedEntity = await policy.authorizeCreateAsync(
viewerContext, viewerContext,
queryContext, queryContext,
privacyPolicyEvaluationContext,
entity, entity,
metricsAdapter metricsAdapter
); );
@@ -497,6 +549,7 @@ describe(EntityPrivacyPolicy, () => {
it('passes through other errors', async () => { it('passes through other errors', async () => {
const viewerContext = instance(mock(ViewerContext)); const viewerContext = instance(mock(ViewerContext));
const queryContext = instance(mock(EntityQueryContext)); const queryContext = instance(mock(EntityQueryContext));
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const metricsAdapterMock = mock<IEntityMetricsAdapter>(); const metricsAdapterMock = mock<IEntityMetricsAdapter>();
const metricsAdapter = instance(metricsAdapterMock); const metricsAdapter = instance(metricsAdapterMock);
const entity = new BlahEntity(viewerContext, { id: '1' }); const entity = new BlahEntity(viewerContext, { id: '1' });
@@ -505,7 +558,13 @@ describe(EntityPrivacyPolicy, () => {
const policySpy = spy(policy); const policySpy = spy(policy);
await expect( await expect(
policy.authorizeCreateAsync(viewerContext, queryContext, entity, metricsAdapter) policy.authorizeCreateAsync(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
entity,
metricsAdapter
)
).rejects.toThrowError('WooHoo!'); ).rejects.toThrowError('WooHoo!');
verify(policySpy.denyHandler(anyOfClass(EntityNotAuthorizedError))).never(); verify(policySpy.denyHandler(anyOfClass(EntityNotAuthorizedError))).never();
@@ -79,6 +79,7 @@ describe(EntitySecondaryCacheLoader, () => {
vc1, vc1,
anyOfClass(EntityNonTransactionalQueryContext), anyOfClass(EntityNonTransactionalQueryContext),
anything(), anything(),
anything(),
anything() anything()
) )
).once(); ).once();
@@ -213,7 +213,7 @@ describe('EntityEdgeDeletionBehavior.SET_NULL', () => {
describe('EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE', () => { describe('EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE', () => {
it('invalidates the cache', async () => { it('invalidates the cache', async () => {
const { CategoryEntity } = makeEntityClass( const { CategoryEntity } = makeEntityClass(
EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE_ONLY
); );
const companionProvider = createUnitTestEntityCompanionProvider(); const companionProvider = createUnitTestEntityCompanionProvider();
@@ -283,7 +283,7 @@ describe('EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE', () => {
it('handles cycles', async () => { it('handles cycles', async () => {
const { CategoryEntity } = makeEntityClass( const { CategoryEntity } = makeEntityClass(
EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE_ONLY
); );
const companionProvider = createUnitTestEntityCompanionProvider(); const companionProvider = createUnitTestEntityCompanionProvider();
@@ -1,6 +1,7 @@
import { mock, verify, instance } from 'ts-mockito'; import { mock, verify, instance } from 'ts-mockito';
import EntityLoaderFactory from '../EntityLoaderFactory'; import EntityLoaderFactory from '../EntityLoaderFactory';
import { EntityPrivacyPolicyEvaluationContext } from '../EntityPrivacyPolicy';
import { EntityQueryContext } from '../EntityQueryContext'; import { EntityQueryContext } from '../EntityQueryContext';
import ViewerContext from '../ViewerContext'; import ViewerContext from '../ViewerContext';
import ViewerScopedEntityLoaderFactory from '../ViewerScopedEntityLoaderFactory'; import ViewerScopedEntityLoaderFactory from '../ViewerScopedEntityLoaderFactory';
@@ -8,6 +9,7 @@ import ViewerScopedEntityLoaderFactory from '../ViewerScopedEntityLoaderFactory'
describe(ViewerScopedEntityLoaderFactory, () => { describe(ViewerScopedEntityLoaderFactory, () => {
it('correctly scopes viewer to entity loads', async () => { it('correctly scopes viewer to entity loads', async () => {
const viewerContext = instance(mock(ViewerContext)); const viewerContext = instance(mock(ViewerContext));
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const queryContext = instance(mock(EntityQueryContext)); const queryContext = instance(mock(EntityQueryContext));
const baseLoader = mock<EntityLoaderFactory<any, any, any, any, any, any>>(EntityLoaderFactory); const baseLoader = mock<EntityLoaderFactory<any, any, any, any, any, any>>(EntityLoaderFactory);
const baseLoaderInstance = instance(baseLoader); const baseLoaderInstance = instance(baseLoader);
@@ -21,8 +23,8 @@ describe(ViewerScopedEntityLoaderFactory, () => {
any any
>(baseLoaderInstance, viewerContext); >(baseLoaderInstance, viewerContext);
viewerScopedEntityLoader.forLoad(queryContext); viewerScopedEntityLoader.forLoad(queryContext, privacyPolicyEvaluationContext);
verify(baseLoader.forLoad(viewerContext, queryContext)).once(); verify(baseLoader.forLoad(viewerContext, queryContext, privacyPolicyEvaluationContext)).once();
}); });
}); });
@@ -34,6 +34,6 @@ export default class EntityInvalidFieldValueError<
fieldName: N, fieldName: N,
fieldValue?: TFields[N] fieldValue?: TFields[N]
) { ) {
super(`Entity field not valid: ${entityClass.name} (${fieldName} = ${fieldValue})`); super(`Entity field not valid: ${entityClass.name} (${String(fieldName)} = ${fieldValue})`);
} }
} }
@@ -34,6 +34,6 @@ export default class EntityNotFoundError<
fieldName: N, fieldName: N,
fieldValue: TFields[N] fieldValue: TFields[N]
) { ) {
super(`Entity not found: ${entityClass.name} (${fieldName} = ${fieldValue})`); super(`Entity not found: ${entityClass.name} (${String(fieldName)} = ${fieldValue})`);
} }
} }
+4
View File
@@ -4,6 +4,9 @@
* @module @expo/entity * @module @expo/entity
*/ */
export { default as ComposedEntityCacheAdapter } from './ComposedEntityCacheAdapter';
export { default as ComposedSecondaryEntityCache } from './ComposedSecondaryEntityCache';
export { default as GenericSecondaryEntityCache } from './GenericSecondaryEntityCache';
export { default as EnforcingEntityLoader } from './EnforcingEntityLoader'; export { default as EnforcingEntityLoader } from './EnforcingEntityLoader';
export { default as Entity } from './Entity'; export { default as Entity } from './Entity';
export * from './Entity'; export * from './Entity';
@@ -43,6 +46,7 @@ export * from './EntityQueryContext';
export { default as IEntityCacheAdapterProvider } from './IEntityCacheAdapterProvider'; export { default as IEntityCacheAdapterProvider } from './IEntityCacheAdapterProvider';
export { default as IEntityDatabaseAdapterProvider } from './IEntityDatabaseAdapterProvider'; export { default as IEntityDatabaseAdapterProvider } from './IEntityDatabaseAdapterProvider';
export { default as EntityQueryContextProvider } from './EntityQueryContextProvider'; export { default as EntityQueryContextProvider } from './EntityQueryContextProvider';
export { default as IEntityGenericCacher } from './IEntityGenericCacher';
export { default as ReadonlyEntity } from './ReadonlyEntity'; export { default as ReadonlyEntity } from './ReadonlyEntity';
export { default as ViewerContext } from './ViewerContext'; export { default as ViewerContext } from './ViewerContext';
export { default as ViewerScopedEntityCompanion } from './ViewerScopedEntityCompanion'; export { default as ViewerScopedEntityCompanion } from './ViewerScopedEntityCompanion';
@@ -3,6 +3,7 @@ import DataLoader from 'dataloader';
import EntityDatabaseAdapter, { import EntityDatabaseAdapter, {
FieldEqualityCondition, FieldEqualityCondition,
QuerySelectionModifiers, QuerySelectionModifiers,
QuerySelectionModifiersWithOrderByRaw,
} from '../EntityDatabaseAdapter'; } from '../EntityDatabaseAdapter';
import { EntityQueryContext } from '../EntityQueryContext'; import { EntityQueryContext } from '../EntityQueryContext';
import EntityQueryContextProvider from '../EntityQueryContextProvider'; import EntityQueryContextProvider from '../EntityQueryContextProvider';
@@ -11,15 +12,18 @@ import {
timeAndLogLoadEventAsync, timeAndLogLoadEventAsync,
timeAndLogLoadMapEventAsync, timeAndLogLoadMapEventAsync,
} from '../metrics/EntityMetricsUtils'; } from '../metrics/EntityMetricsUtils';
import IEntityMetricsAdapter, { EntityMetricsLoadType } from '../metrics/IEntityMetricsAdapter'; import IEntityMetricsAdapter, {
EntityMetricsLoadType,
IncrementLoadCountEventType,
} from '../metrics/IEntityMetricsAdapter';
import { computeIfAbsent, zipToMap } from '../utils/collections/maps'; import { computeIfAbsent, zipToMap } from '../utils/collections/maps';
import ReadThroughEntityCache from './ReadThroughEntityCache'; import ReadThroughEntityCache from './ReadThroughEntityCache';
/** /**
* A data manager is responsible for orchestrating multiple sources of entity * A data manager is responsible for orchestrating multiple sources of entity
* data including local caches, {@link EntityCacheAdapter}, and {@link EntityDatabaseAdapter}. * data including local caches, EntityCacheAdapter, and EntityDatabaseAdapter.
* *
* It is also responsible for invalidating all sources of data when mutated using {@link EntityMutator}. * It is also responsible for invalidating all sources of data when mutated using EntityMutator.
*/ */
export default class EntityDataManager<TFields> { export default class EntityDataManager<TFields> {
private readonly fieldDataLoaders: Map< private readonly fieldDataLoaders: Map<
@@ -57,7 +61,8 @@ export default class EntityDataManager<TFields> {
fieldName: N, fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[] fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<ReadonlyMap<NonNullable<TFields[N]>, readonly Readonly<TFields>[]>> { ): Promise<ReadonlyMap<NonNullable<TFields[N]>, readonly Readonly<TFields>[]>> {
this.metricsAdapter.incrementDataManagerCacheLoadCount({ this.metricsAdapter.incrementDataManagerLoadCount({
type: IncrementLoadCountEventType.CACHE,
fieldValueCount: fieldValues.length, fieldValueCount: fieldValues.length,
entityClassName: this.entityClassName, entityClassName: this.entityClassName,
}); });
@@ -65,7 +70,8 @@ export default class EntityDataManager<TFields> {
fieldName, fieldName,
fieldValues, fieldValues,
async (fetcherValues) => { async (fetcherValues) => {
this.metricsAdapter.incrementDataManagerDatabaseLoadCount({ this.metricsAdapter.incrementDataManagerLoadCount({
type: IncrementLoadCountEventType.DATABASE,
fieldValueCount: fieldValues.length, fieldValueCount: fieldValues.length,
entityClassName: this.entityClassName, entityClassName: this.entityClassName,
}); });
@@ -108,7 +114,9 @@ export default class EntityDataManager<TFields> {
); );
if (nullOrUndefinedValueIndex >= 0) { if (nullOrUndefinedValueIndex >= 0) {
throw new Error( throw new Error(
`Invalid load: ${this.entityClassName} (${fieldName} = ${fieldValues[nullOrUndefinedValueIndex]})` `Invalid load: ${this.entityClassName} (${String(fieldName)} = ${
fieldValues[nullOrUndefinedValueIndex]
})`
); );
} }
@@ -117,7 +125,8 @@ export default class EntityDataManager<TFields> {
return await this.databaseAdapter.fetchManyWhereAsync(queryContext, fieldName, fieldValues); return await this.databaseAdapter.fetchManyWhereAsync(queryContext, fieldName, fieldValues);
} }
this.metricsAdapter.incrementDataManagerDataloaderLoadCount({ this.metricsAdapter.incrementDataManagerLoadCount({
type: IncrementLoadCountEventType.DATALOADER,
fieldValueCount: fieldValues.length, fieldValueCount: fieldValues.length,
entityClassName: this.entityClassName, entityClassName: this.entityClassName,
}); });
@@ -165,14 +174,14 @@ export default class EntityDataManager<TFields> {
* @param queryContext - query context in which to perform the load * @param queryContext - query context in which to perform the load
* @param rawWhereClause - parameterized SQL WHERE clause with positional binding placeholders or named binding placeholders * @param rawWhereClause - parameterized SQL WHERE clause with positional binding placeholders or named binding placeholders
* @param bindings - array of positional bindings or object of named bindings * @param bindings - array of positional bindings or object of named bindings
* @param querySelectionModifiers - limit, offset, and orderBy for the query * @param querySelectionModifiers - limit, offset, orderBy, and orderByRaw for the query
* @returns array of objects matching the query * @returns array of objects matching the query
*/ */
async loadManyByRawWhereClauseAsync( async loadManyByRawWhereClauseAsync(
queryContext: EntityQueryContext, queryContext: EntityQueryContext,
rawWhereClause: string, rawWhereClause: string,
bindings: any[] | object, bindings: any[] | object,
querySelectionModifiers: QuerySelectionModifiers<TFields> querySelectionModifiers: QuerySelectionModifiersWithOrderByRaw<TFields>
): Promise<readonly Readonly<TFields>[]> { ): Promise<readonly Readonly<TFields>[]> {
return await timeAndLogLoadEventAsync( return await timeAndLogLoadEventAsync(
this.metricsAdapter, this.metricsAdapter,
@@ -24,7 +24,7 @@ export const getDatabaseFieldForEntityField = <TFields>(
entityField: keyof TFields entityField: keyof TFields
): string => { ): string => {
const databaseField = entityConfiguration.entityToDBFieldsKeyMapping.get(entityField); const databaseField = entityConfiguration.entityToDBFieldsKeyMapping.get(entityField);
invariant(databaseField, `database field mapping missing for ${entityField}`); invariant(databaseField, `database field mapping missing for ${String(entityField)}`);
return databaseField!; return databaseField!;
}; };
@@ -3,11 +3,6 @@ import invariant from 'invariant';
import EntityCacheAdapter from '../EntityCacheAdapter'; import EntityCacheAdapter from '../EntityCacheAdapter';
import EntityConfiguration from '../EntityConfiguration'; import EntityConfiguration from '../EntityConfiguration';
import { filterMap } from '../utils/collections/maps'; import { filterMap } from '../utils/collections/maps';
import {
FieldTransformerMap,
transformCacheObjectToFields,
transformFieldsToCacheObject,
} from './EntityFieldTransformationUtils';
export enum CacheStatus { export enum CacheStatus {
HIT, HIT,
@@ -15,10 +10,10 @@ export enum CacheStatus {
NEGATIVE, NEGATIVE,
} }
export type CacheLoadResult = export type CacheLoadResult<TFields> =
| { | {
status: CacheStatus.HIT; status: CacheStatus.HIT;
item: Readonly<object>; item: Readonly<TFields>;
} }
| { | {
status: CacheStatus.MISS; status: CacheStatus.MISS;
@@ -28,18 +23,14 @@ export type CacheLoadResult =
}; };
/** /**
* A read-through entity cache is responsible for coordinating {@link EntityDatabaseAdapter} and * A read-through entity cache is responsible for coordinating EntityDatabaseAdapter and
* {@link EntityCacheAdapter} within the {@link EntityDataManager}. * EntityCacheAdapter within the EntityDataManager.
*/ */
export default class ReadThroughEntityCache<TFields> { export default class ReadThroughEntityCache<TFields> {
private readonly fieldTransformerMap: FieldTransformerMap;
constructor( constructor(
private readonly entityConfiguration: EntityConfiguration<TFields>, private readonly entityConfiguration: EntityConfiguration<TFields>,
private readonly entityCacheAdapter: EntityCacheAdapter<TFields> private readonly entityCacheAdapter: EntityCacheAdapter<TFields>
) { ) {}
this.fieldTransformerMap = entityCacheAdapter.getFieldTransformerMap();
}
private isFieldCacheable<N extends keyof TFields>(fieldName: N): boolean { private isFieldCacheable<N extends keyof TFields>(fieldName: N): boolean {
return this.entityConfiguration.cacheableKeys.has(fieldName); return this.entityConfiguration.cacheableKeys.has(fieldName);
@@ -91,13 +82,7 @@ export default class ReadThroughEntityCache<TFields> {
const results: Map<NonNullable<TFields[N]>, readonly Readonly<TFields>[]> = new Map(); const results: Map<NonNullable<TFields[N]>, readonly Readonly<TFields>[]> = new Map();
cacheLoadResults.forEach((cacheLoadResult, fieldValue) => { cacheLoadResults.forEach((cacheLoadResult, fieldValue) => {
if (cacheLoadResult.status === CacheStatus.HIT) { if (cacheLoadResult.status === CacheStatus.HIT) {
results.set(fieldValue, [ results.set(fieldValue, [cacheLoadResult.item]);
transformCacheObjectToFields(
this.entityConfiguration,
this.fieldTransformerMap,
cacheLoadResult.item
),
]);
} }
}); });
@@ -110,27 +95,22 @@ export default class ReadThroughEntityCache<TFields> {
return !objectsFromFulfillerForFv || objectsFromFulfillerForFv.length === 0; return !objectsFromFulfillerForFv || objectsFromFulfillerForFv.length === 0;
}); });
const objectsToCache: Map<NonNullable<TFields[N]>, object> = new Map(); const objectsToCache: Map<NonNullable<TFields[N]>, Readonly<TFields>> = new Map();
for (const [fieldValue, objects] of dbFetchResults.entries()) { for (const [fieldValue, objects] of dbFetchResults.entries()) {
if (objects.length > 1) { if (objects.length > 1) {
// multiple objects received for what was supposed to be a unique query, don't add to return map nor cache // multiple objects received for what was supposed to be a unique query, don't add to return map nor cache
// TODO(wschurman): emit or throw here since console may not be available // TODO(wschurman): emit or throw here since console may not be available
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.warn( console.warn(
`unique key ${fieldName} in ${this.entityConfiguration.tableName} returned multiple rows for ${fieldValue}` `unique key ${String(fieldName)} in ${
this.entityConfiguration.tableName
} returned multiple rows for ${fieldValue}`
); );
continue; continue;
} }
const uniqueObject = objects[0]; const uniqueObject = objects[0];
if (uniqueObject) { if (uniqueObject) {
objectsToCache.set( objectsToCache.set(fieldValue, uniqueObject);
fieldValue,
transformFieldsToCacheObject(
this.entityConfiguration,
this.fieldTransformerMap,
uniqueObject
)
);
results.set(fieldValue, [uniqueObject]); results.set(fieldValue, [uniqueObject]);
} }
} }
@@ -4,7 +4,6 @@ import {
when, when,
anything, anything,
verify, verify,
anyNumber,
objectContaining, objectContaining,
spy, spy,
anyString, anyString,
@@ -13,7 +12,10 @@ import {
} from 'ts-mockito'; } from 'ts-mockito';
import EntityDatabaseAdapter from '../../EntityDatabaseAdapter'; import EntityDatabaseAdapter from '../../EntityDatabaseAdapter';
import IEntityMetricsAdapter, { EntityMetricsLoadType } from '../../metrics/IEntityMetricsAdapter'; import IEntityMetricsAdapter, {
EntityMetricsLoadType,
IncrementLoadCountEventType,
} from '../../metrics/IEntityMetricsAdapter';
import NoOpEntityMetricsAdapter from '../../metrics/NoOpEntityMetricsAdapter'; import NoOpEntityMetricsAdapter from '../../metrics/NoOpEntityMetricsAdapter';
import TestEntity, { testEntityConfiguration, TestFields } from '../../testfixtures/TestEntity'; import TestEntity, { testEntityConfiguration, TestFields } from '../../testfixtures/TestEntity';
import { import {
@@ -519,24 +521,27 @@ describe(EntityDataManager, () => {
).once(); ).once();
verify( verify(
metricsAdapterMock.incrementDataManagerDataloaderLoadCount( metricsAdapterMock.incrementDataManagerLoadCount(
deepEqual({ deepEqual({
type: IncrementLoadCountEventType.DATALOADER,
fieldValueCount: 1, fieldValueCount: 1,
entityClassName: TestEntity.name, entityClassName: TestEntity.name,
}) })
) )
).once(); ).once();
verify( verify(
metricsAdapterMock.incrementDataManagerCacheLoadCount( metricsAdapterMock.incrementDataManagerLoadCount(
deepEqual({ deepEqual({
type: IncrementLoadCountEventType.CACHE,
fieldValueCount: 1, fieldValueCount: 1,
entityClassName: TestEntity.name, entityClassName: TestEntity.name,
}) })
) )
).once(); ).once();
verify( verify(
metricsAdapterMock.incrementDataManagerDatabaseLoadCount( metricsAdapterMock.incrementDataManagerLoadCount(
deepEqual({ deepEqual({
type: IncrementLoadCountEventType.DATABASE,
fieldValueCount: 1, fieldValueCount: 1,
entityClassName: TestEntity.name, entityClassName: TestEntity.name,
}) })
@@ -565,9 +570,7 @@ describe(EntityDataManager, () => {
) )
).once(); ).once();
verify(metricsAdapterMock.incrementDataManagerDataloaderLoadCount(anyNumber())).never(); verify(metricsAdapterMock.incrementDataManagerLoadCount(anything())).never();
verify(metricsAdapterMock.incrementDataManagerCacheLoadCount(anyNumber())).never();
verify(metricsAdapterMock.incrementDataManagerDatabaseLoadCount(anyNumber())).never();
}); });
it('throws when a load-by value is null or undefined', async () => { it('throws when a load-by value is null or undefined', async () => {
@@ -40,7 +40,6 @@ describe(ReadThroughEntityCache, () => {
describe('readManyThroughAsync', () => { describe('readManyThroughAsync', () => {
it('fetches from DB upon cache miss and caches the result', async () => { it('fetches from DB upon cache miss and caches the result', async () => {
const cacheAdapterMock = mock<EntityCacheAdapter<BlahFields>>(); const cacheAdapterMock = mock<EntityCacheAdapter<BlahFields>>();
when(cacheAdapterMock.getFieldTransformerMap()).thenReturn(new Map());
const cacheAdapter = instance(cacheAdapterMock); const cacheAdapter = instance(cacheAdapterMock);
const entityCache = new ReadThroughEntityCache(makeEntityConfiguration(true), cacheAdapter); const entityCache = new ReadThroughEntityCache(makeEntityConfiguration(true), cacheAdapter);
const fetcher = createIdFetcher(['wat', 'who']); const fetcher = createIdFetcher(['wat', 'who']);
@@ -77,7 +76,6 @@ describe(ReadThroughEntityCache, () => {
it('does not fetch from the DB or cache results when all cache fetches are hits', async () => { it('does not fetch from the DB or cache results when all cache fetches are hits', async () => {
const cacheAdapterMock = mock<EntityCacheAdapter<BlahFields>>(); const cacheAdapterMock = mock<EntityCacheAdapter<BlahFields>>();
when(cacheAdapterMock.getFieldTransformerMap()).thenReturn(new Map());
const cacheAdapter = instance(cacheAdapterMock); const cacheAdapter = instance(cacheAdapterMock);
const entityCache = new ReadThroughEntityCache(makeEntityConfiguration(true), cacheAdapter); const entityCache = new ReadThroughEntityCache(makeEntityConfiguration(true), cacheAdapter);
const fetcher = createIdFetcher(['wat', 'who']); const fetcher = createIdFetcher(['wat', 'who']);
@@ -114,7 +112,6 @@ describe(ReadThroughEntityCache, () => {
it('negatively caches db misses', async () => { it('negatively caches db misses', async () => {
const cacheAdapterMock = mock<EntityCacheAdapter<BlahFields>>(); const cacheAdapterMock = mock<EntityCacheAdapter<BlahFields>>();
when(cacheAdapterMock.getFieldTransformerMap()).thenReturn(new Map());
const cacheAdapter = instance(cacheAdapterMock); const cacheAdapter = instance(cacheAdapterMock);
const entityCache = new ReadThroughEntityCache(makeEntityConfiguration(true), cacheAdapter); const entityCache = new ReadThroughEntityCache(makeEntityConfiguration(true), cacheAdapter);
@@ -135,7 +132,6 @@ describe(ReadThroughEntityCache, () => {
it('does not return or fetch negatively cached results from DB', async () => { it('does not return or fetch negatively cached results from DB', async () => {
const cacheAdapterMock = mock<EntityCacheAdapter<BlahFields>>(); const cacheAdapterMock = mock<EntityCacheAdapter<BlahFields>>();
when(cacheAdapterMock.getFieldTransformerMap()).thenReturn(new Map());
const cacheAdapter = instance(cacheAdapterMock); const cacheAdapter = instance(cacheAdapterMock);
const entityCache = new ReadThroughEntityCache(makeEntityConfiguration(true), cacheAdapter); const entityCache = new ReadThroughEntityCache(makeEntityConfiguration(true), cacheAdapter);
const fetcher = createIdFetcher([]); const fetcher = createIdFetcher([]);
@@ -153,7 +149,6 @@ describe(ReadThroughEntityCache, () => {
it('does a mix and match of hit, miss, and negative', async () => { it('does a mix and match of hit, miss, and negative', async () => {
const cacheAdapterMock = mock<EntityCacheAdapter<BlahFields>>(); const cacheAdapterMock = mock<EntityCacheAdapter<BlahFields>>();
when(cacheAdapterMock.getFieldTransformerMap()).thenReturn(new Map());
const cacheAdapter = instance(cacheAdapterMock); const cacheAdapter = instance(cacheAdapterMock);
const entityCache = new ReadThroughEntityCache(makeEntityConfiguration(true), cacheAdapter); const entityCache = new ReadThroughEntityCache(makeEntityConfiguration(true), cacheAdapter);
const fetcher = createIdFetcher(['wat', 'who', 'why']); const fetcher = createIdFetcher(['wat', 'who', 'why']);
@@ -189,7 +184,6 @@ describe(ReadThroughEntityCache, () => {
it('does not call into cache for field that is not cacheable', async () => { it('does not call into cache for field that is not cacheable', async () => {
const cacheAdapterMock = mock<EntityCacheAdapter<BlahFields>>(); const cacheAdapterMock = mock<EntityCacheAdapter<BlahFields>>();
when(cacheAdapterMock.getFieldTransformerMap()).thenReturn(new Map());
const cacheAdapter = instance(cacheAdapterMock); const cacheAdapter = instance(cacheAdapterMock);
const entityCache = new ReadThroughEntityCache(makeEntityConfiguration(false), cacheAdapter); const entityCache = new ReadThroughEntityCache(makeEntityConfiguration(false), cacheAdapter);
const fetcher = createIdFetcher(['wat']); const fetcher = createIdFetcher(['wat']);
@@ -197,44 +191,6 @@ describe(ReadThroughEntityCache, () => {
verify(cacheAdapterMock.loadManyAsync('id', anything())).never(); verify(cacheAdapterMock.loadManyAsync('id', anything())).never();
expect(result).toEqual(new Map([['wat', [{ id: 'wat' }]]])); expect(result).toEqual(new Map([['wat', [{ id: 'wat' }]]]));
}); });
it('transforms fields for cache storage', async () => {
const cacheAdapterMock = mock<EntityCacheAdapter<BlahFields>>();
when(cacheAdapterMock.getFieldTransformerMap()).thenReturn(
new Map([
[
UUIDField.name,
{
read: (val) => val.split('-')[0],
write: (val) => `${val}-in-cache`,
},
],
])
);
const cacheAdapter = instance(cacheAdapterMock);
const entityCache = new ReadThroughEntityCache(makeEntityConfiguration(true), cacheAdapter);
const fetcher = createIdFetcher(['wat', 'who']);
when(cacheAdapterMock.loadManyAsync('id', deepEqual(['wat', 'who']))).thenResolve(
new Map([
['wat', { status: CacheStatus.MISS }],
['who', { status: CacheStatus.HIT, item: { id: 'who-in-cache' } }],
])
);
const result = await entityCache.readManyThroughAsync('id', ['wat', 'who'], fetcher);
verify(cacheAdapterMock.loadManyAsync('id', deepEqual(['wat', 'who']))).once();
verify(
cacheAdapterMock.cacheManyAsync('id', deepEqual(new Map([['wat', { id: 'wat-in-cache' }]])))
).once();
expect(result).toEqual(
new Map([
['wat', [{ id: 'wat' }]],
['who', [{ id: 'who' }]],
])
);
});
}); });
describe('invalidateManyAsync', () => { describe('invalidateManyAsync', () => {
@@ -9,10 +9,28 @@ export enum EntityMetricsLoadType {
LOAD_MANY_RAW, LOAD_MANY_RAW,
} }
/**
* Event about a single call to an EntityLoader method.
*/
export interface EntityMetricsLoadEvent { export interface EntityMetricsLoadEvent {
/**
* EntityMetricsLoadType for this load.
*/
type: EntityMetricsLoadType; type: EntityMetricsLoadType;
/**
* Class name of the Entity being loaded.
*/
entityClassName: string; entityClassName: string;
/**
* Total duration of this load, including fetch and construction of entities.
*/
duration: number; duration: number;
/**
* Number of entities returned for this load.
*/
count: number; count: number;
} }
@@ -23,13 +41,57 @@ export enum EntityMetricsMutationType {
} }
export interface EntityMetricsMutationEvent { export interface EntityMetricsMutationEvent {
/**
* EntityMetricsMutationType for this mutation.
*/
type: EntityMetricsMutationType; type: EntityMetricsMutationType;
/**
* Class name of the Entity being mutated.
*/
entityClassName: string; entityClassName: string;
/**
* Total duration of this mutation.
*/
duration: number; duration: number;
} }
export enum IncrementLoadCountEventType {
/**
* Type for when a dataloader load is initiated via the standard load methods
* since all loads go through a dataloader.
*/
DATALOADER,
/**
* Type for when a cache load is initiated due to a dataloader miss.
*/
CACHE,
/**
* Type for when a database load is initiated due to a dataloader and cache miss, when an entity query doesn't support caching, or during a transaction.
*/
DATABASE,
}
/**
* Event used to record dataloader, cache, and database load counts in EntityDataManager.
*/
export interface IncrementLoadCountEvent { export interface IncrementLoadCountEvent {
/**
* Type of this event.
*/
type: IncrementLoadCountEventType;
/**
* Number of field values being loaded for this call.
*/
fieldValueCount: number; fieldValueCount: number;
/**
* Class name of the Entity being loaded.
*/
entityClassName: string; entityClassName: string;
} }
@@ -38,7 +100,13 @@ export enum EntityMetricsAuthorizationResult {
ALLOW, ALLOW,
} }
/**
* Event used to record a singe EntityPrivacyPolicy authorization.
*/
export interface EntityMetricsAuthorizationEvent { export interface EntityMetricsAuthorizationEvent {
/**
* Class name of the Entity being authorized.
*/
entityClassName: string; entityClassName: string;
action: EntityAuthorizationAction; action: EntityAuthorizationAction;
evaluationResult: EntityMetricsAuthorizationResult; evaluationResult: EntityMetricsAuthorizationResult;
@@ -51,7 +119,7 @@ export interface EntityMetricsAuthorizationEvent {
*/ */
export default interface IEntityMetricsAdapter { export default interface IEntityMetricsAdapter {
/** /**
* Called when a {@link EntityPrivacyPolicy} authorization succeeds or fails. * Called when a EntityPrivacyPolicy authorization succeeds or fails.
* @param authorizationEvent - info about the authorization event * @param authorizationEvent - info about the authorization event
*/ */
logAuthorizationEvent(authorizationEvent: EntityMetricsAuthorizationEvent): void; logAuthorizationEvent(authorizationEvent: EntityMetricsAuthorizationEvent): void;
@@ -69,25 +137,10 @@ export default interface IEntityMetricsAdapter {
logMutatorMutationEvent(mutationEvent: EntityMetricsMutationEvent): void; logMutatorMutationEvent(mutationEvent: EntityMetricsMutationEvent): void;
/** /**
* Called when a dataloader load is initiated via the standard * Called when a dataloader, cache, or database load is initiated via the standard
* load methods (not equality conjunction or raw). * load methods (not equality conjunction or raw). Most commonly used for logging
* a waterfall to determine dataloader and cache hit rates and ratios.
* @param fieldValueCount - count of field values being loaded for a field * @param fieldValueCount - count of field values being loaded for a field
*/ */
incrementDataManagerDataloaderLoadCount(incrementLoadCountEvent: IncrementLoadCountEvent): void; incrementDataManagerLoadCount(incrementLoadCountEvent: IncrementLoadCountEvent): void;
/**
* Called when a cache load is initiated via the standard
* load methods (not equality conjunction or raw). Occurs upon a dataloader
* miss.
* @param fieldValueCount - count of field values being loaded for a field
*/
incrementDataManagerCacheLoadCount(incrementLoadCountEvent: IncrementLoadCountEvent): void;
/**
* Called when a database load is initiated via the standard
* load methods (not equality conjunction or raw). Occurs upon a cache
* miss or when fetching an uncacheable field.
* @param fieldValueCount - count of field values being loaded for a field
*/
incrementDataManagerDatabaseLoadCount(incrementLoadCountEvent: IncrementLoadCountEvent): void;
} }
@@ -9,9 +9,5 @@ export default class NoOpEntityMetricsAdapter implements IEntityMetricsAdapter {
logAuthorizationEvent(_authorizationEvent: EntityMetricsAuthorizationEvent): void {} logAuthorizationEvent(_authorizationEvent: EntityMetricsAuthorizationEvent): void {}
logDataManagerLoadEvent(_loadEvent: EntityMetricsLoadEvent): void {} logDataManagerLoadEvent(_loadEvent: EntityMetricsLoadEvent): void {}
logMutatorMutationEvent(_mutationEvent: EntityMetricsMutationEvent): void {} logMutatorMutationEvent(_mutationEvent: EntityMetricsMutationEvent): void {}
incrementDataManagerDataloaderLoadCount( incrementDataManagerLoadCount(_incrementLoadCountEvent: IncrementLoadCountEvent): void {}
_incrementLoadCountEvent: IncrementLoadCountEvent
): void {}
incrementDataManagerCacheLoadCount(_incrementLoadCountEvent: IncrementLoadCountEvent): void {}
incrementDataManagerDatabaseLoadCount(_incrementLoadCountEvent: IncrementLoadCountEvent): void {}
} }
Loaded 100 of 113 files, more files were not shown because too many files have changed in this diff. Show more