Compare commits

..
Author SHA1 Message Date
Will Schurman 7715b8e5f9 Make GenericRedisCacher n=1 case of ShardedGenericRedisCacher 2022-10-04 17:09:48 -07:00
Will Schurman 45991b2cc1 feat: add sharded redis cache adapter 2022-10-04 16:39:42 -07:00
Will Schurman dbf1e03039 v0.28.0 2022-09-13 11:23:26 -07:00
Will Schurman 0fb74137f2 chore: upgrade integration test postgres to v14 (#195) 2022-09-13 11:18:56 -07:00
Will Schurman a77b914ad6 feat: nested transactions (#194) 2022-09-13 10:47:57 -07:00
Will Schurman acbad826a3 feat: add BigIntField (#193) 2022-08-31 18:48:48 -07:00
Will Schurman 8bdd9f7ac2 docs: improve documentation to FieldEqualityCondition (#192) 2022-08-24 13:44:48 -07:00
Will Schurman bc6a0d4310 chore: update readme for easier release (#191) 2022-08-09 13:13:13 -07:00
Will Schurman a067a44230 v0.27.0 (#190) 2022-08-09 13:07:40 -07:00
Will Schurman bffa7ca870 ci: fix typedoc (#189) 2022-08-09 12:35:38 -07:00
Will Schurman 847c20fabe ci: fix node version in docs action (#188) 2022-08-09 12:09:39 -07:00
Will Schurman 8a98f35b91 chore: upgrade lerna (#187) 2022-08-09 11:41:15 -07:00
Will Schurman 7829d90f4b ci: fix github actions docs workflow branch (#186) 2022-08-09 11:06:36 -07:00
Juwan Wheatley 2817d78392 feat: add orderByRaw to loadManyByRawWhereClauseAsync (#185)
* feat: add orderByRaw to querySelectionModifiers

* implement PR feedback

* fix tsc

* restrict orderByRaw to loadManyByRawWhereClauseAsync

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

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

* Update packages/entity/src/ComposedEntityCacheAdapter.ts

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

* pr feedback

* Update packages/entity/src/ComposedEntityCacheAdapter.ts

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

* pr feedback

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

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

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

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

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

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

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

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

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

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

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

No files matched your search

+3 -3
View File
@@ -3,16 +3,16 @@ name: docs
on:
push:
branches:
- master
- main
jobs:
deploy:
runs-on: ubuntu-18.04
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
with:
node-version: 12.x
node-version: 18.x
- name: Cache Node.js modules
uses: actions/cache@v1
with:
+2 -2
View File
@@ -2,7 +2,7 @@ name: tests
on:
push:
branches: [ master ]
branches: [ main ]
pull_request:
jobs:
@@ -14,7 +14,7 @@ jobs:
ref: ${{ github.event.pull_request.head.sha }}
- uses: actions/setup-node@v1
with:
node-version: 12.x
node-version: 18.x
- name: Cache Node.js modules
uses: actions/cache@v1
with:
+96
View File
@@ -3,6 +3,102 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.28.0](https://github.com/expo/entity/compare/v0.27.0...v0.28.0) (2022-09-13)
### Features
* add BigIntField ([#193](https://github.com/expo/entity/issues/193)) ([acbad82](https://github.com/expo/entity/commit/acbad826a37bb5a054b4dcf9be91d11aa5f7f29a))
* nested transactions ([#194](https://github.com/expo/entity/issues/194)) ([a77b914](https://github.com/expo/entity/commit/a77b914ad6dc018d1e309c9caf4971088a553ecf))
# [0.27.0](https://github.com/expo/entity/compare/v0.26.1...v0.27.0) (2022-08-09)
### Features
* add orderByRaw to loadManyByRawWhereClauseAsync ([#185](https://github.com/expo/entity/issues/185)) ([2817d78](https://github.com/expo/entity/commit/2817d78392dfecd0093111c24d581bdf01dba06d))
## [0.26.1](https://github.com/expo/entity/compare/v0.26.0...v0.26.1) (2022-07-28)
### Features
* recache to higher cache levels when loading from a composed cache ([#182](https://github.com/expo/entity/pull/182))
# [0.26.0](https://github.com/expo/entity/compare/v0.25.3...v0.26.0) (2022-07-14)
### Bug Fixes
* fix workflow branch specification ([#179](https://github.com/expo/entity/issues/179)) ([25d14bf](https://github.com/expo/entity/commit/25d14bf8c1688524ad488aa2b63221393a4f0b05))
### Features
* add EntityEdgeDeletionBehavior.SET_NULL_INVALIDATE_CACHE_ONLY ([#178](https://github.com/expo/entity/issues/178)) ([a0c35dd](https://github.com/expo/entity/commit/a0c35ddd7bca05d0c3f41dcf48e16d058918231c))
* update ioredis to v5, make it a devDependency of the cache packages ([#172](https://github.com/expo/entity/issues/172)) ([9fcfbf7](https://github.com/expo/entity/commit/9fcfbf746a61554cefedf7fb0f1a3a9056b58ffe))
## [0.25.3](https://github.com/expo/entity/compare/v0.25.2...v0.25.3) (2022-03-12)
### Bug Fixes
* lock lru-cache to v6.0.0 ([#170](https://github.com/expo/entity/issues/170)) ([293868b](https://github.com/expo/entity/commit/293868b1a73df9f749216af68b1ae94ac22ac2b9))
## [0.25.2](https://github.com/expo/entity/compare/v0.25.1...v0.25.2) (2022-03-12)
### Bug Fixes
* lru-cache yarn workspace version resolution ([#168](https://github.com/expo/entity/issues/168)) ([e76ca8c](https://github.com/expo/entity/commit/e76ca8c2f72da84aa372a64ef1898ade220ed0a8))
## [0.25.1](https://github.com/expo/entity/compare/v0.25.0...v0.25.1) (2022-03-10)
### Bug Fixes
* add composed cache to index ([#166](https://github.com/expo/entity/issues/166)) ([5c82edc](https://github.com/expo/entity/commit/5c82edcd9f26854878d29950a05852e117bbd723))
# [0.25.0](https://github.com/expo/entity/compare/v0.24.0...v0.25.0) (2022-03-10)
### Features
* composed cache adapter, composed secondary cache, local memory secondary cache ([#164](https://github.com/expo/entity/issues/164)) ([6b6e70e](https://github.com/expo/entity/commit/6b6e70e75f7e42a4dce13edeb406a4d88d9264f0))
* privacy policy evaluation context ([#162](https://github.com/expo/entity/issues/162)) ([3a81a4c](https://github.com/expo/entity/commit/3a81a4c059e2e995eb800e35e76b3d432c22b23f))
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
+4 -12
View File
@@ -4,7 +4,7 @@ Entity is a privacy-aware data layer for defining, caching, and authorizing acce
[![tests](https://github.com/expo/entity/workflows/tests/badge.svg)](https://github.com/expo/entity/actions?query=workflow%3Atests)
[![docs](https://github.com/expo/entity/workflows/docs/badge.svg)](https://expo.github.io/entity/)
[![codecov](https://codecov.io/gh/expo/entity/branch/master/graph/badge.svg)](https://codecov.io/gh/expo/entity)
[![codecov](https://codecov.io/gh/expo/entity/branch/main/graph/badge.svg)](https://codecov.io/gh/expo/entity)
[![npm](https://img.shields.io/npm/v/@expo/entity)](https://www.npmjs.com/package/@expo/entity)
[![NPM](https://img.shields.io/npm/l/@expo/entity)](https://www.npmjs.com/package/@expo/entity)
@@ -80,18 +80,10 @@ Note: The entity framework instance should not be shared across multiple request
## Releasing
To release a new version, let's say `v1.0.0` for example:
1. `git checkout -b bump-v1.0.0`
1. `yarn lerna version minor -- --no-push --conventional-commits`
1. `git push -u origin bump-v1.0.0`
1. Create a PR from the `bump-v1.0.0` branch, wait for tests, and commit the PR using GitHub interface.
1. `git checkout master && git pull`
1. `git tag -d v1.0.0`
1. `git tag v1.0.0`
1. `git push origin v1.0.0`
To release a new version:
1. `git checkout main`
1. `yarn lerna publish [patch|minor|major] -- --conventional-commits`
1. In GitHub release interface, create a new release from the tag, copy changelog changes to release description.
1. `yarn clean && yarn tsc`
1. In each public subpackage, run `npm publish` and enter 2FA token.
## License
+1 -1
View File
@@ -4,5 +4,5 @@
],
"npmClient": "yarn",
"useWorkspaces": true,
"version": "0.24.0"
"version": "0.28.0"
}
+10 -8
View File
@@ -18,11 +18,10 @@
"devDependencies": {
"@expo/entity": "file:packages/entity",
"@expo/results": "^1.0.0",
"@types/lru-cache": "^5.1.1",
"@types/invariant": "^2.2.33",
"@types/ioredis": "^4.26.4",
"@types/jest": "^26.0.10",
"@types/jsbn": "^1.2.29",
"@types/lru-cache": "^5.1.1",
"@types/node": "^14.6.0",
"@types/uuid": "^8.3.0",
"@typescript-eslint/eslint-plugin": "^4.14.0",
@@ -30,16 +29,18 @@
"barrelsby": "^2.2.0",
"eslint": "^7.7.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",
"lerna": "^4.0.0",
"lerna": "^5.4.0",
"lru-cache": "^6.0.0",
"nullthrows": "^1.1.1",
"pg": "^8.6.0",
"prettier": "^2.3.2",
"ts-jest": "~26.4.4",
"ts-mockito": "^2.6.1",
"typedoc": "^0.21.9",
"typescript": "^4.4.2",
"typedoc": "^0.23.10",
"typescript": "^4.6.3",
"uuid": "^8.3.0"
},
"dependencies": {
@@ -47,7 +48,8 @@
"@expo/entity-cache-adapter-local-memory": "file:packages/entity-cache-adapter-local-memory",
"@expo/entity-cache-adapter-redis": "file:packages/entity-cache-adapter-redis",
"@expo/entity-database-adapter-knex": "file:packages/entity-database-adapter-knex",
"@expo/entity-secondary-cache-redis": "file:packages/entity-secondary-cache-redis",
"@expo/entity-ip-address-field": "file:packages/entity-ip-address-field"
"@expo/entity-ip-address-field": "file:packages/entity-ip-address-field",
"@expo/entity-secondary-cache-local-memory": "file:packages/entity-secondary-cache-local-memory",
"@expo/entity-secondary-cache-redis": "file:packages/entity-secondary-cache-redis"
}
}
@@ -3,6 +3,79 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.28.0](https://github.com/expo/entity/compare/v0.27.0...v0.28.0) (2022-09-13)
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
# [0.27.0](https://github.com/expo/entity/compare/v0.26.1...v0.27.0) (2022-08-09)
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
## [0.26.1](https://github.com/expo/entity/compare/v0.26.0...v0.26.1) (2022-07-28)
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
# [0.26.0](https://github.com/expo/entity/compare/v0.25.3...v0.26.0) (2022-07-14)
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
## [0.25.3](https://github.com/expo/entity/compare/v0.25.2...v0.25.3) (2022-03-12)
### Bug Fixes
* lock lru-cache to v6.0.0 ([#170](https://github.com/expo/entity/issues/170)) ([293868b](https://github.com/expo/entity/commit/293868b1a73df9f749216af68b1ae94ac22ac2b9))
## [0.25.2](https://github.com/expo/entity/compare/v0.25.1...v0.25.2) (2022-03-12)
### Bug Fixes
* lru-cache yarn workspace version resolution ([#168](https://github.com/expo/entity/issues/168)) ([e76ca8c](https://github.com/expo/entity/commit/e76ca8c2f72da84aa372a64ef1898ade220ed0a8))
## [0.25.1](https://github.com/expo/entity/compare/v0.25.0...v0.25.1) (2022-03-10)
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
# [0.25.0](https://github.com/expo/entity/compare/v0.24.0...v0.25.0) (2022-03-10)
### Features
* composed cache adapter, composed secondary cache, local memory secondary cache ([#164](https://github.com/expo/entity/issues/164)) ([6b6e70e](https://github.com/expo/entity/commit/6b6e70e75f7e42a4dce13edeb406a4d88d9264f0))
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
@@ -3,21 +3,26 @@
Cross-request [LRU](https://github.com/isaacs/node-lru-cache) cache adapter for `@expo/entity`. Use
this cache with caution - it is nonstandard. The cache is shared between requests in the node process.
Note: This uses version 6.0.0 of `node-lru-cache` since it the most tuned version for our use case (low TTL + LRU). Upgrading
to 7.x will cause high memory usage for the entity cache adapter use case since it allocates fixed-size data structures up front to tune for the non-TTL use case: https://github.com/isaacs/node-lru-cache/issues/208.
[Documentation](https://expo.github.io/entity/modules/_expo_cache_adapter_local_memory.html)
## Why NOT use this cache
Because this is an in-memory cache, cross-box invalidation is not possible. Do not use this cache
Because this is an in-memory cache, cross-machine invalidation is not possible. Do not use this cache
if you have the following use cases:
- The objects stored have high mutability
- The objects stored are mutable
- Reading a stale object from the cache is not acceptable in your application
- Cross-box invalidation is not possible
- Cross-machine invalidation is not possible
## Typical use cases
If your application sees many requests fetching the same objects, you can save a trip to your cache
cluster and backing datastore by using this in-memory cache. Here are some good use cases:
- The objects stored are mostly immutable
- The objects stored are mostly immutable, and reading a stale object for a short TTL is acceptable
- You have a low TTL setting in your cache
## Usage
@@ -1,6 +1,6 @@
{
"name": "@expo/entity-cache-adapter-local-memory",
"version": "0.24.0",
"version": "0.28.0",
"description": "Cross-request local memory cache adapter for @expo/entity",
"files": [
"build",
@@ -30,9 +30,9 @@
"@expo/entity": "*"
},
"dependencies": {
"lru-cache": "^7.3.1"
"lru-cache": "^6.0.0"
},
"devDependencies": {
"@expo/entity": "^0.24.0"
"@expo/entity": "^0.28.0"
}
}
@@ -1,4 +1,10 @@
import { CacheLoadResult, CacheStatus, IEntityGenericCacher } from '@expo/entity';
import {
CacheLoadResult,
CacheStatus,
EntityConfiguration,
IEntityGenericCacher,
} from '@expo/entity';
import invariant from 'invariant';
import LRUCache from 'lru-cache';
// Sentinel value we store in local memory to negatively cache a database miss.
@@ -7,78 +13,11 @@ export const DOES_NOT_EXIST_LOCAL_MEMORY_CACHE = Symbol('doesNotExist');
type LocalMemoryCacheValue<TFields> = Readonly<TFields> | typeof DOES_NOT_EXIST_LOCAL_MEMORY_CACHE;
export type LocalMemoryCache<TFields> = LRUCache<string, LocalMemoryCacheValue<TFields>>;
type LRUCacheOptionsV7<K, V> = {
/**
* the number of most recently used items to keep.
* note that we may store fewer items than this if maxSize is hit.
*/
max: number;
/**
* if you wish to track item size, you must provide a maxSize
* note that we still will only keep up to max *actual items*,
* so size tracking may cause fewer than max items to be stored.
* At the extreme, a single item of maxSize size will cause everything
* else in the cache to be dropped when it is added. Use with caution!
* Note also that size tracking can negatively impact performance,
* though for most cases, only minimally.
*/
maxSize?: number;
/**
* function to calculate size of items. useful if storing strings or
* buffers or other items where memory size depends on the object itself.
* also note that oversized items do NOT immediately get dropped from
* the cache, though they will cause faster turnover in the storage.
*/
sizeCalculation?: (value: V, key: K) => number;
/**
* function to call when the item is removed from the cache
* Note that using this can negatively impact performance.
*/
dispose?: (value: V, key: K) => void;
/**
* max time to live for items before they are considered stale
* note that stale items are NOT preemptively removed by default,
* and MAY live in the cache, contributing to its LRU max, long after
* they have expired.
* Also, as this cache is optimized for LRU/MRU operations, some of
* the staleness/TTL checks will reduce performance, as they will incur
* overhead by deleting items.
* Must be a positive integer in ms, defaults to 0, which means "no TTL"
*/
ttl?: number;
/**
* return stale items from cache.get() before disposing of them
* boolean, default false
*/
allowStale?: boolean;
/**
* update the age of items on cache.get(), renewing their TTL
* boolean, default false
*/
updateAgeOnGet?: boolean;
/**
* update the age of items on cache.has(), renewing their TTL
* boolean, default false
*/
updateAgeOnHas?: boolean;
/**
* update the "recently-used"-ness of items on cache.has()
* boolean, default false
*/
updateRecencyOnHas?: boolean;
};
export default class GenericLocalMemoryCacher<TFields> implements IEntityGenericCacher<TFields> {
constructor(private readonly localMemoryCache: LocalMemoryCache<TFields>) {}
constructor(
private readonly entityConfiguration: EntityConfiguration<TFields>,
private readonly localMemoryCache: LocalMemoryCache<TFields>
) {}
static createLRUCache<TFields>(
options: { maxSize?: number; ttlSeconds?: number } = {}
@@ -86,13 +25,11 @@ export default class GenericLocalMemoryCacher<TFields> implements IEntityGeneric
const DEFAULT_LRU_CACHE_MAX_AGE_SECONDS = 10;
const DEFAULT_LRU_CACHE_SIZE = 10000;
const maxAgeSeconds = options.ttlSeconds ?? DEFAULT_LRU_CACHE_MAX_AGE_SECONDS;
const lruCacheOptions: LRUCacheOptionsV7<string, TFields> = {
return new LRUCache<string, LocalMemoryCacheValue<TFields>>({
max: options.maxSize ?? DEFAULT_LRU_CACHE_SIZE,
sizeCalculation: (value: LocalMemoryCacheValue<TFields>) =>
value === DOES_NOT_EXIST_LOCAL_MEMORY_CACHE ? 0 : 1,
ttl: maxAgeSeconds * 1000, // convert to ms
};
return new LRUCache<string, LocalMemoryCacheValue<TFields>>(lruCacheOptions as any);
length: (value) => (value === DOES_NOT_EXIST_LOCAL_MEMORY_CACHE ? 0 : 1),
maxAge: maxAgeSeconds * 1000, // convert to ms
});
}
static createNoOpCache<TFields>(): LocalMemoryCache<TFields> {
@@ -144,7 +81,18 @@ export default class GenericLocalMemoryCacher<TFields> implements IEntityGeneric
}
}
public makeCacheKey(parts: string[]): string {
public makeCacheKey<N extends keyof TFields>(
fieldName: N,
fieldValue: NonNullable<TFields[N]>
): string {
const columnName = this.entityConfiguration.entityToDBFieldsKeyMapping.get(fieldName);
invariant(columnName, `database field mapping missing for ${String(fieldName)}`);
const parts = [
this.entityConfiguration.tableName,
`${this.entityConfiguration.cacheKeyVersion}`,
columnName,
String(fieldValue),
];
const delimiter = ':';
const escapedParts = parts.map((part) =>
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`)
@@ -1,80 +0,0 @@
import { EntityCacheAdapter, CacheLoadResult, EntityConfiguration, mapKeys } from '@expo/entity';
import invariant from 'invariant';
import GenericLocalMemoryCacher, { LocalMemoryCache } from './GenericLocalMemoryCacher';
export default class LocalMemoryCacheAdapter<TFields> extends EntityCacheAdapter<TFields> {
private readonly genericLocalMemoryCacher: GenericLocalMemoryCacher<TFields>;
constructor(
entityConfiguration: EntityConfiguration<TFields>,
localMemoryCache: LocalMemoryCache<TFields>
) {
super(entityConfiguration);
this.genericLocalMemoryCacher = new GenericLocalMemoryCacher(localMemoryCache);
}
public async loadManyAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult<TFields>>> {
const localMemoryCacheKeyToFieldValueMapping = new Map(
fieldValues.map((fieldValue) => [this.makeCacheKey(fieldName, fieldValue), fieldValue])
);
const cacheResults = await this.genericLocalMemoryCacher.loadManyAsync(
Array.from(localMemoryCacheKeyToFieldValueMapping.keys())
);
return mapKeys(cacheResults, (cacheKey) => {
const fieldValue = localMemoryCacheKeyToFieldValueMapping.get(cacheKey);
invariant(
fieldValue !== undefined,
'Unspecified cache key %s returned from generic local memory cacher',
cacheKey
);
return fieldValue;
});
}
public async cacheManyAsync<N extends keyof TFields>(
fieldName: N,
objectMap: ReadonlyMap<NonNullable<TFields[N]>, Readonly<TFields>>
): Promise<void> {
await this.genericLocalMemoryCacher.cacheManyAsync(
mapKeys(objectMap, (fieldValue) => this.makeCacheKey(fieldName, fieldValue))
);
}
public async cacheDBMissesAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<void> {
await this.genericLocalMemoryCacher.cacheDBMissesAsync(
fieldValues.map((fieldValue) => this.makeCacheKey(fieldName, fieldValue))
);
}
public async invalidateManyAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<void> {
await this.genericLocalMemoryCacher.invalidateManyAsync(
fieldValues.map((fieldValue) => this.makeCacheKey(fieldName, fieldValue))
);
}
private makeCacheKey<N extends keyof TFields>(
fieldName: N,
fieldValue: NonNullable<TFields[N]>
): string {
const columnName = this.entityConfiguration.entityToDBFieldsKeyMapping.get(fieldName);
invariant(columnName, `database field mapping missing for ${fieldName}`);
const parts = [
this.entityConfiguration.tableName,
`${this.entityConfiguration.cacheKeyVersion}`,
columnName,
String(fieldValue),
];
return this.genericLocalMemoryCacher.makeCacheKey(parts);
}
}
@@ -1,16 +1,16 @@
import {
computeIfAbsent,
EntityCacheAdapter,
IEntityCacheAdapter,
GenericEntityCacheAdapter,
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>>();
private static localMemoryCacheAdapterMap = new Map<string, GenericEntityCacheAdapter<any>>();
static getNoOpProvider(): IEntityCacheAdapterProvider {
return new LocalMemoryCacheAdapterProvider(<TFields>() =>
@@ -32,13 +32,15 @@ export default class LocalMemoryCacheAdapterProvider implements IEntityCacheAdap
public getCacheAdapter<TFields>(
entityConfiguration: EntityConfiguration<TFields>
): EntityCacheAdapter<TFields> {
): IEntityCacheAdapter<TFields> {
return computeIfAbsent(
LocalMemoryCacheAdapterProvider.localMemoryCacheAdapterMap,
entityConfiguration.tableName,
() => {
const localMemoryCache = this.localMemoryCacheCreator<TFields>();
return new LocalMemoryCacheAdapter(entityConfiguration, localMemoryCache);
return new GenericEntityCacheAdapter(
new GenericLocalMemoryCacher(entityConfiguration, localMemoryCache)
);
}
);
}
@@ -2,30 +2,25 @@ 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 LocalMemoryCacheAdapterProvider from '../LocalMemoryCacheAdapterProvider';
import LocalMemoryTestEntity from '../testfixtures/LocalMemoryTestEntity';
import {
createLocalMemoryIntegrationTestEntityCompanionProvider,
createLocalMemoryTestEntityCompanionProvider,
createNoopLocalMemoryIntegrationTestEntityCompanionProvider,
} from '../testfixtures/createLocalMemoryIntegrationTestEntityCompanionProvider';
} from '../testfixtures/createLocalMemoryTestEntityCompanionProvider';
class TestViewerContext extends ViewerContext {}
describe(LocalMemoryCacheAdapter, () => {
describe(GenericLocalMemoryCacher, () => {
beforeEach(async () => {
LocalMemoryCacheAdapterProvider.localMemoryCacheAdapterMap.clear();
LocalMemoryCacheAdapterProvider['localMemoryCacheAdapterMap'].clear();
});
it('has correct caching behavior', async () => {
const viewerContext = new TestViewerContext(
createLocalMemoryIntegrationTestEntityCompanionProvider()
);
const cacheAdapter = viewerContext.entityCompanionProvider.getCompanionForEntity(
const viewerContext = new ViewerContext(createLocalMemoryTestEntityCompanionProvider());
const genericCacher = viewerContext.entityCompanionProvider.getCompanionForEntity(
LocalMemoryTestEntity,
LocalMemoryTestEntity.getCompanionDefinition()
)['tableDataCoordinator']['cacheAdapter'];
const cacheKeyMaker = cacheAdapter['makeCacheKey'].bind(cacheAdapter);
)['tableDataCoordinator']['cacheAdapter']['genericCacher'];
const cacheKeyMaker = genericCacher['makeCacheKey'].bind(genericCacher);
const date = new Date();
const entity1Created = await LocalMemoryTestEntity.creator(viewerContext)
@@ -38,10 +33,11 @@ describe(LocalMemoryCacheAdapter, () => {
.enforcing()
.loadByIDAsync(entity1Created.getID());
const entitySpecificGenericCacher =
LocalMemoryCacheAdapterProvider.localMemoryCacheAdapterMap.get(
LocalMemoryTestEntity.getCompanionDefinition().entityConfiguration.tableName
)!['genericLocalMemoryCacher'];
const entitySpecificGenericCacher = LocalMemoryCacheAdapterProvider[
'localMemoryCacheAdapterMap'
].get(LocalMemoryTestEntity.getCompanionDefinition().entityConfiguration.tableName)![
'genericCacher'
];
const cachedResult = await entitySpecificGenericCacher.loadManyAsync([
cacheKeyMaker('id', entity1.getID()),
]);
@@ -90,9 +86,7 @@ describe(LocalMemoryCacheAdapter, () => {
GenericLocalMemoryCacher.prototype as unknown as any,
'loadManyAsync'
);
const viewerContext = new TestViewerContext(
createLocalMemoryIntegrationTestEntityCompanionProvider()
);
const viewerContext = new ViewerContext(createLocalMemoryTestEntityCompanionProvider());
const date = new Date();
const entity1Created = await LocalMemoryTestEntity.creator(viewerContext)
@@ -106,18 +100,16 @@ describe(LocalMemoryCacheAdapter, () => {
.loadByIDAsync(entity1Created.getID());
// load entity with a different request
const viewerContext2 = new TestViewerContext(
createLocalMemoryIntegrationTestEntityCompanionProvider()
);
const viewerContext2 = new ViewerContext(createLocalMemoryTestEntityCompanionProvider());
const entity1WithVc2 = await LocalMemoryTestEntity.loader(viewerContext2)
.enforcing()
.loadByIDAsync(entity1Created.getID());
const cacheAdapter = viewerContext.entityCompanionProvider.getCompanionForEntity(
const genericCacher = viewerContext.entityCompanionProvider.getCompanionForEntity(
LocalMemoryTestEntity,
LocalMemoryTestEntity.getCompanionDefinition()
)['tableDataCoordinator']['cacheAdapter'];
const cacheKeyMaker = cacheAdapter['makeCacheKey'].bind(cacheAdapter);
)['tableDataCoordinator']['cacheAdapter']['genericCacher'];
const cacheKeyMaker = genericCacher['makeCacheKey'].bind(genericCacher);
expect(entity1WithVc2.getAllFields()).toMatchObject({
id: entity1WithVc2.getID(),
name: 'blah',
@@ -130,14 +122,14 @@ describe(LocalMemoryCacheAdapter, () => {
});
it('respects the parameters of a noop cache', async () => {
const viewerContext = new TestViewerContext(
const viewerContext = new ViewerContext(
createNoopLocalMemoryIntegrationTestEntityCompanionProvider()
);
const cacheAdapter = viewerContext.entityCompanionProvider.getCompanionForEntity(
const genericCacher = viewerContext.entityCompanionProvider.getCompanionForEntity(
LocalMemoryTestEntity,
LocalMemoryTestEntity.getCompanionDefinition()
)['tableDataCoordinator']['cacheAdapter'];
const cacheKeyMaker = cacheAdapter['makeCacheKey'].bind(cacheAdapter);
)['tableDataCoordinator']['cacheAdapter']['genericCacher'];
const cacheKeyMaker = genericCacher['makeCacheKey'].bind(genericCacher);
const date = new Date();
const entity1Created = await LocalMemoryTestEntity.creator(viewerContext)
@@ -150,10 +142,11 @@ describe(LocalMemoryCacheAdapter, () => {
.enforcing()
.loadByIDAsync(entity1Created.getID());
const entitySpecificGenericCacher =
LocalMemoryCacheAdapterProvider.localMemoryCacheAdapterMap.get(
LocalMemoryTestEntity.getCompanionDefinition().entityConfiguration.tableName
)!['genericLocalMemoryCacher'];
const entitySpecificGenericCacher = LocalMemoryCacheAdapterProvider[
'localMemoryCacheAdapterMap'
].get(LocalMemoryTestEntity.getCompanionDefinition().entityConfiguration.tableName)![
'genericCacher'
];
const cachedResult = await entitySpecificGenericCacher.loadManyAsync([
cacheKeyMaker('id', entity1.getID()),
]);
@@ -1,9 +1,13 @@
import { CacheStatus, UUIDField, EntityConfiguration } from '@expo/entity';
import {
CacheStatus,
UUIDField,
EntityConfiguration,
GenericEntityCacheAdapter,
} from '@expo/entity';
import GenericLocalMemoryCacher, {
DOES_NOT_EXIST_LOCAL_MEMORY_CACHE,
} from '../GenericLocalMemoryCacher';
import LocalMemoryCacheAdapter from '../LocalMemoryCacheAdapter';
type BlahFields = {
id: string;
@@ -19,15 +23,38 @@ const entityConfiguration = new EntityConfiguration<BlahFields>({
cacheAdapterFlavor: 'local-memory',
});
describe(LocalMemoryCacheAdapter, () => {
describe(GenericLocalMemoryCacher, () => {
describe(GenericLocalMemoryCacher.createLRUCache, () => {
it('creates a cache with default options', () => {
const cache = GenericLocalMemoryCacher.createLRUCache();
expect(cache.max).toBe(10000);
expect(cache.maxAge).toBe(10000);
});
it('respects specified options', () => {
const cache = GenericLocalMemoryCacher.createLRUCache({
ttlSeconds: 3,
maxSize: 10,
});
expect(cache.max).toBe(10);
expect(cache.maxAge).toBe(3000);
});
});
describe(GenericLocalMemoryCacher.createNoOpCache, () => {
it('creates a no-op cache', () => {
const cache = GenericLocalMemoryCacher.createNoOpCache<{ hello: 'world' }>();
cache.set('a', { hello: 'world' });
expect(cache.get('a')).toBeUndefined();
});
});
});
describe('Use within GenericEntityCacheAdapter', () => {
describe('loadManyAsync', () => {
it('returns appropriate cache results', async () => {
const cacheAdapter = new LocalMemoryCacheAdapter(
entityConfiguration,
GenericLocalMemoryCacher.createLRUCache({
maxSize: Number.MAX_SAFE_INTEGER,
ttlSeconds: Number.MAX_SAFE_INTEGER,
})
const cacheAdapter = new GenericEntityCacheAdapter(
new GenericLocalMemoryCacher(entityConfiguration, GenericLocalMemoryCacher.createLRUCache())
);
const cacheHits = new Map<string, Readonly<BlahFields>>([['test-id-1', { id: 'test-id-1' }]]);
@@ -50,12 +77,8 @@ describe(LocalMemoryCacheAdapter, () => {
});
it('returns empty map when passed empty array of fieldValues', async () => {
const cacheAdapter = new LocalMemoryCacheAdapter(
entityConfiguration,
GenericLocalMemoryCacher.createLRUCache({
maxSize: Number.MAX_SAFE_INTEGER,
ttlSeconds: Number.MAX_SAFE_INTEGER,
})
const cacheAdapter = new GenericEntityCacheAdapter(
new GenericLocalMemoryCacher(entityConfiguration, GenericLocalMemoryCacher.createLRUCache())
);
const results = await cacheAdapter.loadManyAsync('id', []);
expect(results).toEqual(new Map());
@@ -64,15 +87,13 @@ describe(LocalMemoryCacheAdapter, () => {
describe('cacheManyAsync', () => {
it('correctly caches all objects', async () => {
const localMemoryCache = GenericLocalMemoryCacher.createLRUCache<BlahFields>({
maxSize: Number.MAX_SAFE_INTEGER,
ttlSeconds: Number.MAX_SAFE_INTEGER,
});
const localMemoryCache = GenericLocalMemoryCacher.createLRUCache<BlahFields>({});
const cacheAdapter = new LocalMemoryCacheAdapter(entityConfiguration, localMemoryCache);
const localMemoryCacher = new GenericLocalMemoryCacher(entityConfiguration, localMemoryCache);
const cacheAdapter = new GenericEntityCacheAdapter(localMemoryCacher);
await cacheAdapter.cacheManyAsync('id', new Map([['test-id-1', { id: 'test-id-1' }]]));
const cacheKey = cacheAdapter['makeCacheKey']('id', 'test-id-1');
const cacheKey = localMemoryCacher['makeCacheKey']('id', 'test-id-1');
expect(localMemoryCache.get(cacheKey)).toMatchObject({
id: 'test-id-1',
});
@@ -81,27 +102,24 @@ describe(LocalMemoryCacheAdapter, () => {
describe('cacheDBMissesAsync', () => {
it('correctly caches misses', async () => {
const localMemoryCache = GenericLocalMemoryCacher.createLRUCache<BlahFields>({
maxSize: Number.MAX_SAFE_INTEGER,
ttlSeconds: Number.MAX_SAFE_INTEGER,
});
const localMemoryCache = GenericLocalMemoryCacher.createLRUCache<BlahFields>({});
const cacheAdapter = new LocalMemoryCacheAdapter(entityConfiguration, localMemoryCache);
const localMemoryCacher = new GenericLocalMemoryCacher(entityConfiguration, localMemoryCache);
const cacheAdapter = new GenericEntityCacheAdapter(localMemoryCacher);
await cacheAdapter.cacheDBMissesAsync('id', ['test-id-1']);
const cacheKey = cacheAdapter['makeCacheKey']('id', 'test-id-1');
const cacheKey = localMemoryCacher['makeCacheKey']('id', 'test-id-1');
expect(localMemoryCache.get(cacheKey)).toEqual(DOES_NOT_EXIST_LOCAL_MEMORY_CACHE);
});
});
describe('invalidateManyAsync', () => {
it('invalidates correctly', async () => {
const localMemoryCache = GenericLocalMemoryCacher.createLRUCache<BlahFields>({
maxSize: Number.MAX_SAFE_INTEGER,
ttlSeconds: Number.MAX_SAFE_INTEGER,
});
const localMemoryCache = GenericLocalMemoryCacher.createLRUCache<BlahFields>({});
const cacheAdapter = new LocalMemoryCacheAdapter(entityConfiguration, localMemoryCache);
const cacheAdapter = new GenericEntityCacheAdapter(
new GenericLocalMemoryCacher(entityConfiguration, localMemoryCache)
);
await cacheAdapter.cacheManyAsync('id', new Map([['test-id-1', { id: 'test-id-1' }]]));
await cacheAdapter.cacheDBMissesAsync('id', ['test-id-2']);
await cacheAdapter.invalidateManyAsync('id', ['test-id-1', 'test-id-2']);
@@ -112,12 +130,11 @@ describe(LocalMemoryCacheAdapter, () => {
});
it('returns when passed empty array of fieldValues', async () => {
const cacheAdapter = new LocalMemoryCacheAdapter(
entityConfiguration,
GenericLocalMemoryCacher.createLRUCache<BlahFields>({
maxSize: Number.MAX_SAFE_INTEGER,
ttlSeconds: Number.MAX_SAFE_INTEGER,
})
const cacheAdapter = new GenericEntityCacheAdapter(
new GenericLocalMemoryCacher(
entityConfiguration,
GenericLocalMemoryCacher.createLRUCache<BlahFields>({})
)
);
await cacheAdapter.invalidateManyAsync('id', []);
});
@@ -4,7 +4,6 @@
* @module @expo/entity-cache-adapter-local-memory
*/
export { default as LocalMemoryCacheAdapter } from './LocalMemoryCacheAdapter';
export { default as LocalMemoryCacheAdapterProvider } from './LocalMemoryCacheAdapterProvider';
export { default as GenericLocalMemoryCacher } from './GenericLocalMemoryCacher';
export * from './GenericLocalMemoryCacher';
export { default as LocalMemoryCacheAdapterProvider } from './LocalMemoryCacheAdapterProvider';
@@ -6,9 +6,9 @@ import {
StubDatabaseAdapterProvider,
} from '@expo/entity';
import { LocalMemoryCacheAdapterProvider } from '../LocalMemoryCacheAdapterProvider';
import LocalMemoryCacheAdapterProvider from '../LocalMemoryCacheAdapterProvider';
export const createLocalMemoryIntegrationTestEntityCompanionProvider = (
export const createLocalMemoryTestEntityCompanionProvider = (
localMemoryOptions: { maxSize?: number; ttlSeconds?: number } = {},
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter()
): EntityCompanionProvider => {
@@ -41,7 +41,7 @@ export const createLocalMemoryIntegrationTestEntityCompanionProvider = (
export const createNoopLocalMemoryIntegrationTestEntityCompanionProvider = (
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter()
): EntityCompanionProvider => {
return createLocalMemoryIntegrationTestEntityCompanionProvider(
return createLocalMemoryTestEntityCompanionProvider(
{ maxSize: 0, ttlSeconds: 0 },
metricsAdapter
);
@@ -3,6 +3,73 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.28.0](https://github.com/expo/entity/compare/v0.27.0...v0.28.0) (2022-09-13)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.27.0](https://github.com/expo/entity/compare/v0.26.1...v0.27.0) (2022-08-09)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
## [0.26.1](https://github.com/expo/entity/compare/v0.26.0...v0.26.1) (2022-07-28)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.26.0](https://github.com/expo/entity/compare/v0.25.3...v0.26.0) (2022-07-14)
### Features
* update ioredis to v5, make it a devDependency of the cache packages ([#172](https://github.com/expo/entity/issues/172)) ([9fcfbf7](https://github.com/expo/entity/commit/9fcfbf746a61554cefedf7fb0f1a3a9056b58ffe))
## [0.25.3](https://github.com/expo/entity/compare/v0.25.2...v0.25.3) (2022-03-12)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
## [0.25.2](https://github.com/expo/entity/compare/v0.25.1...v0.25.2) (2022-03-12)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
## [0.25.1](https://github.com/expo/entity/compare/v0.25.0...v0.25.1) (2022-03-10)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.25.0](https://github.com/expo/entity/compare/v0.24.0...v0.25.0) (2022-03-10)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
### Chores
@@ -1,6 +1,6 @@
{
"name": "@expo/entity-cache-adapter-redis",
"version": "0.24.0",
"version": "0.28.0",
"description": "Redis cache adapter for @expo/entity",
"files": [
"build",
@@ -27,12 +27,11 @@
"author": "Expo",
"license": "MIT",
"peerDependencies": {
"@expo/entity": "*"
},
"dependencies": {
"ioredis": "^4.27.3"
"@expo/entity": "*",
"ioredis": ">=5"
},
"devDependencies": {
"@expo/entity": "^0.24.0"
"@expo/entity": "^0.28.0",
"ioredis": "^5.0.4"
}
}
@@ -1,124 +1,62 @@
import {
CacheLoadResult,
CacheStatus,
EntityConfiguration,
transformCacheObjectToFields,
transformFieldsToCacheObject,
IEntityGenericCacher,
} from '@expo/entity';
import { Redis } from 'ioredis';
import { CacheLoadResult, EntityConfiguration, IEntityGenericCacher } from '@expo/entity';
import invariant from 'invariant';
import Redis from 'ioredis';
import { redisTransformerMap } from './RedisCommon';
import wrapNativeRedisCallAsync from './errors/wrapNativeRedisCallAsync';
import ShardedGenericRedisCacher, {
ShardedRedisCacheAdapterContext,
} from './ShardedGenericRedisCacher';
// Sentinel value we store in Redis to negatively cache a database miss.
// The sentinel value is distinct from any (positively) cached value.
const DOES_NOT_EXIST_REDIS = '';
export interface GenericRedisCacheContext {
export type RedisCacheAdapterContext = Pick<
ShardedRedisCacheAdapterContext,
'cacheKeyPrefix' | 'cacheKeyVersion' | 'makeKeyFn' | 'ttlSecondsNegative' | 'ttlSecondsPositive'
> & {
/**
* Instance of ioredis.Redis
*/
redisClient: Redis;
/**
* TTL for caching database hits. Successive entity loads within this TTL
* will be read from cache (unless invalidated).
*/
ttlSecondsPositive: number;
/**
* TTL for negatively caching database misses. Successive entity loads within
* this TTL will be assumed not present in the database (unless invalidated).
*/
ttlSecondsNegative: number;
}
};
export default class GenericRedisCacher<TFields> implements IEntityGenericCacher<TFields> {
private readonly shardedGenericRedisCacher: ShardedGenericRedisCacher<TFields>;
constructor(
private readonly context: GenericRedisCacheContext,
private readonly entityConfiguration: EntityConfiguration<TFields>
) {}
context: RedisCacheAdapterContext,
entityConfiguration: EntityConfiguration<TFields>
) {
this.shardedGenericRedisCacher = new ShardedGenericRedisCacher(
{
...context,
getShardGroupForKeysFn: (keys) => new Map(keys.map((k) => [k, 1])),
getRedisInstanceForShardGroup: (shardGroup) => {
invariant(shardGroup === 1, 'invalid shard group');
return context.redisClient;
},
shardingSchemeVersion: 1,
},
entityConfiguration
);
}
public async loadManyAsync(
keys: readonly string[]
): Promise<ReadonlyMap<string, CacheLoadResult<TFields>>> {
if (keys.length === 0) {
return new Map();
}
const redisResults = await wrapNativeRedisCallAsync(() =>
this.context.redisClient.mget(...keys)
);
const results = new Map<string, CacheLoadResult<TFields>>();
for (let i = 0; i < keys.length; i++) {
const key = keys[i]!;
const redisResult = redisResults[i];
if (redisResult === DOES_NOT_EXIST_REDIS) {
results.set(key, {
status: CacheStatus.NEGATIVE,
});
} else if (redisResult) {
results.set(key, {
status: CacheStatus.HIT,
item: transformCacheObjectToFields(
this.entityConfiguration,
redisTransformerMap,
JSON.parse(redisResult)
),
});
} else {
results.set(key, {
status: CacheStatus.MISS,
});
}
}
return results;
return this.shardedGenericRedisCacher.loadManyAsync(keys);
}
public async cacheManyAsync(objectMap: ReadonlyMap<string, Readonly<TFields>>): Promise<void> {
if (objectMap.size === 0) {
return;
}
let redisTransaction = this.context.redisClient.multi();
objectMap.forEach((object, key) => {
redisTransaction = redisTransaction.set(
key,
JSON.stringify(
transformFieldsToCacheObject(this.entityConfiguration, redisTransformerMap, object)
),
'EX',
this.context.ttlSecondsPositive
);
});
await wrapNativeRedisCallAsync(() => redisTransaction.exec());
return this.shardedGenericRedisCacher.cacheManyAsync(objectMap);
}
public async cacheDBMissesAsync(keys: readonly string[]): Promise<void> {
if (keys.length === 0) {
return;
}
let redisTransaction = this.context.redisClient.multi();
keys.forEach((key) => {
redisTransaction = redisTransaction.set(
key,
DOES_NOT_EXIST_REDIS,
'EX',
this.context.ttlSecondsNegative
);
});
await wrapNativeRedisCallAsync(() => redisTransaction.exec());
return this.shardedGenericRedisCacher.cacheDBMissesAsync(keys);
}
public async invalidateManyAsync(keys: readonly string[]): Promise<void> {
if (keys.length === 0) {
return;
}
return this.shardedGenericRedisCacher.invalidateManyAsync(keys);
}
await wrapNativeRedisCallAsync(() => this.context.redisClient.del(...keys));
public makeCacheKey<N extends keyof TFields>(
fieldName: N,
fieldValue: NonNullable<TFields[N]>
): string {
return this.shardedGenericRedisCacher.makeCacheKey(fieldName, fieldValue);
}
}
@@ -1,125 +0,0 @@
import { EntityCacheAdapter, EntityConfiguration, CacheLoadResult, mapKeys } from '@expo/entity';
import invariant from 'invariant';
import { Redis } from 'ioredis';
import GenericRedisCacher from './GenericRedisCacher';
export interface RedisCacheAdapterContext {
/**
* Instance of ioredis.Redis
*/
redisClient: Redis;
/**
* Create a key string for key parts (cache key prefix, versions, entity name, etc).
* Most commonly a simple `parts.join(':')`. See integration test for example.
*/
makeKeyFn: (...parts: string[]) => string;
/**
* Global cache version for the entity framework. Bumping this version will
* invalidate the cache for all entities at once.
*/
cacheKeyVersion: number;
/**
* Prefix prepended to all entity cache keys. Useful for adding a short, human-readable
* distintion for entity keys, e.g. `ent-`
*/
cacheKeyPrefix: string;
/**
* TTL for caching database hits. Successive entity loads within this TTL
* will be read from cache (unless invalidated).
*/
ttlSecondsPositive: number;
/**
* TTL for negatively caching database misses. Successive entity loads within
* this TTL will be assumed not present in the database (unless invalidated).
*/
ttlSecondsNegative: number;
}
export default class RedisCacheAdapter<TFields> extends EntityCacheAdapter<TFields> {
private readonly genericRedisCacher: GenericRedisCacher<TFields>;
constructor(
entityConfiguration: EntityConfiguration<TFields>,
private readonly context: RedisCacheAdapterContext
) {
super(entityConfiguration);
this.genericRedisCacher = new GenericRedisCacher(
{
redisClient: context.redisClient,
ttlSecondsNegative: context.ttlSecondsNegative,
ttlSecondsPositive: context.ttlSecondsPositive,
},
entityConfiguration
);
}
public async loadManyAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult<TFields>>> {
const redisCacheKeyToFieldValueMapping = new Map(
fieldValues.map((fieldValue) => [this.makeCacheKey(fieldName, fieldValue), fieldValue])
);
const cacheResults = await this.genericRedisCacher.loadManyAsync(
Array.from(redisCacheKeyToFieldValueMapping.keys())
);
return mapKeys(cacheResults, (redisCacheKey) => {
const fieldValue = redisCacheKeyToFieldValueMapping.get(redisCacheKey);
invariant(
fieldValue !== undefined,
'Unspecified cache key %s returned from generic Redis cacher',
redisCacheKey
);
return fieldValue;
});
}
public async cacheManyAsync<N extends keyof TFields>(
fieldName: N,
objectMap: ReadonlyMap<NonNullable<TFields[N]>, Readonly<TFields>>
): Promise<void> {
await this.genericRedisCacher.cacheManyAsync(
mapKeys(objectMap, (fieldValue) => this.makeCacheKey(fieldName, fieldValue))
);
}
public async cacheDBMissesAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<void> {
await this.genericRedisCacher.cacheDBMissesAsync(
fieldValues.map((fieldValue) => this.makeCacheKey(fieldName, fieldValue))
);
}
public async invalidateManyAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<void> {
await this.genericRedisCacher.invalidateManyAsync(
fieldValues.map((fieldValue) => this.makeCacheKey(fieldName, fieldValue))
);
}
private makeCacheKey<N extends keyof TFields>(
fieldName: N,
fieldValue: NonNullable<TFields[N]>
): string {
const columnName = this.entityConfiguration.entityToDBFieldsKeyMapping.get(fieldName);
invariant(columnName, `database field mapping missing for ${fieldName}`);
return this.context.makeKeyFn(
this.context.cacheKeyPrefix,
this.entityConfiguration.tableName,
`v2.${this.entityConfiguration.cacheKeyVersion}`,
columnName,
String(fieldValue)
);
}
}
@@ -1,13 +1,19 @@
import { IEntityCacheAdapterProvider, EntityConfiguration, EntityCacheAdapter } from '@expo/entity';
import {
IEntityCacheAdapterProvider,
EntityConfiguration,
IEntityCacheAdapter,
GenericEntityCacheAdapter,
} from '@expo/entity';
import RedisCacheAdapter, { RedisCacheAdapterContext } from './RedisCacheAdapter';
import GenericRedisCacher, { RedisCacheAdapterContext } from './GenericRedisCacher';
export default class RedisCacheAdapterProvider implements IEntityCacheAdapterProvider {
constructor(private readonly context: RedisCacheAdapterContext) {}
getCacheAdapter<TFields>(
entityConfiguration: EntityConfiguration<TFields>
): EntityCacheAdapter<TFields> {
return new RedisCacheAdapter(entityConfiguration, this.context);
): IEntityCacheAdapter<TFields> {
const genericCacher = new GenericRedisCacher(this.context, entityConfiguration);
return new GenericEntityCacheAdapter(genericCacher);
}
}
@@ -0,0 +1,222 @@
import {
CacheLoadResult,
CacheStatus,
EntityConfiguration,
transformCacheObjectToFields,
transformFieldsToCacheObject,
IEntityGenericCacher,
mapMapAsync,
filterMap,
computeIfAbsent,
} from '@expo/entity';
import invariant from 'invariant';
import { Redis } from 'ioredis';
import { redisTransformerMap } from './RedisCommon';
import wrapNativeRedisCallAsync from './errors/wrapNativeRedisCallAsync';
// Sentinel value we store in Redis to negatively cache a database miss.
// The sentinel value is distinct from any (positively) cached value.
const DOES_NOT_EXIST_REDIS = '';
export type ShardGroup = number;
export interface ShardedRedisCacheAdapterContext {
/**
* Shard computation function.
*/
getShardGroupForKeysFn: (keys: readonly string[]) => ReadonlyMap<string, ShardGroup>;
/**
* Get Redis instance for shard group.
*/
getRedisInstanceForShardGroup: (shardGroup: ShardGroup) => Redis;
/**
* Sharding scheme version. Increment when sharding scheme is updated.
*/
shardingSchemeVersion: number;
/**
* Create a key string for key parts (cache key prefix, versions, entity name, etc).
* Most commonly a simple `parts.join(':')`. See integration test for example.
*/
makeKeyFn: (...parts: string[]) => string;
/**
* Global cache version for the entity framework. Bumping this version will
* invalidate the cache for all entities at once.
*/
cacheKeyVersion: number;
/**
* Prefix prepended to all entity cache keys. Useful for adding a short, human-readable
* distintion for entity keys, e.g. `ent-`
*/
cacheKeyPrefix: string;
/**
* TTL for caching database hits. Successive entity loads within this TTL
* will be read from cache (unless invalidated).
*/
ttlSecondsPositive: number;
/**
* TTL for negatively caching database misses. Successive entity loads within
* this TTL will be assumed not present in the database (unless invalidated).
*/
ttlSecondsNegative: number;
}
export default class ShardedGenericRedisCacher<TFields> implements IEntityGenericCacher<TFields> {
constructor(
private readonly context: ShardedRedisCacheAdapterContext,
private readonly entityConfiguration: EntityConfiguration<TFields>
) {}
private getRedisClientsForKeys(
keys: readonly string[]
): Map<ShardGroup, { keys: string[]; redisClient: Redis }> {
const shardGroupsForKeys = this.context.getShardGroupForKeysFn(keys);
const redisClientsForKeys = new Map<ShardGroup, { keys: string[]; redisClient: Redis }>();
for (const [key, shardGroup] of shardGroupsForKeys) {
const entry = computeIfAbsent(redisClientsForKeys, shardGroup, (currShardGroup) => ({
keys: [],
redisClient: this.context.getRedisInstanceForShardGroup(currShardGroup),
}));
entry.keys.push(key);
}
return redisClientsForKeys;
}
public async loadManyAsync(
keys: readonly string[]
): Promise<ReadonlyMap<string, CacheLoadResult<TFields>>> {
if (keys.length === 0) {
return new Map();
}
const redisClientsForKeys = this.getRedisClientsForKeys(keys);
const allShardResults = (
await mapMapAsync(redisClientsForKeys, async ({ redisClient, keys }) => {
return await this.getCacheResultsForKeysAsync(redisClient, keys);
})
).values();
const results = new Map<string, CacheLoadResult<TFields>>();
for (const shardResults of allShardResults) {
for (const [shardResultKey, shardResult] of shardResults) {
results.set(shardResultKey, shardResult);
}
}
return results;
}
private async getCacheResultsForKeysAsync(
redisClient: Redis,
keys: readonly string[]
): Promise<ReadonlyMap<string, CacheLoadResult<TFields>>> {
const redisResults = await wrapNativeRedisCallAsync(() => redisClient.mget(...keys));
const results = new Map<string, CacheLoadResult<TFields>>();
for (let i = 0; i < keys.length; i++) {
const key = keys[i]!;
const redisResult = redisResults[i];
if (redisResult === DOES_NOT_EXIST_REDIS) {
results.set(key, {
status: CacheStatus.NEGATIVE,
});
} else if (redisResult) {
results.set(key, {
status: CacheStatus.HIT,
item: transformCacheObjectToFields(
this.entityConfiguration,
redisTransformerMap,
JSON.parse(redisResult)
),
});
} else {
results.set(key, {
status: CacheStatus.MISS,
});
}
}
return results;
}
public async cacheManyAsync(objectMap: ReadonlyMap<string, Readonly<TFields>>): Promise<void> {
if (objectMap.size === 0) {
return;
}
const keys = [...objectMap.keys()];
const redisClientsForKeys = this.getRedisClientsForKeys(keys);
await mapMapAsync(redisClientsForKeys, async ({ redisClient, keys }) => {
const keySet = new Set(keys);
const objectMapForKeys = filterMap(objectMap, (_, k) => keySet.has(k));
let redisTransaction = redisClient.multi();
objectMapForKeys.forEach((object, key) => {
redisTransaction = redisTransaction.set(
key,
JSON.stringify(
transformFieldsToCacheObject(this.entityConfiguration, redisTransformerMap, object)
),
'EX',
this.context.ttlSecondsPositive
);
});
await wrapNativeRedisCallAsync(() => redisTransaction.exec());
});
}
public async cacheDBMissesAsync(allKeys: readonly string[]): Promise<void> {
if (allKeys.length === 0) {
return;
}
const redisClientsForKeys = this.getRedisClientsForKeys(allKeys);
await mapMapAsync(redisClientsForKeys, async ({ redisClient, keys }) => {
let redisTransaction = redisClient.multi();
keys.forEach((key) => {
redisTransaction = redisTransaction.set(
key,
DOES_NOT_EXIST_REDIS,
'EX',
this.context.ttlSecondsNegative
);
});
await wrapNativeRedisCallAsync(() => redisTransaction.exec());
});
}
public async invalidateManyAsync(allKeys: readonly string[]): Promise<void> {
if (allKeys.length === 0) {
return;
}
const redisClientsForKeys = this.getRedisClientsForKeys(allKeys);
await mapMapAsync(redisClientsForKeys, async ({ redisClient, keys }) => {
await wrapNativeRedisCallAsync(() => redisClient.del(...keys));
});
}
public makeCacheKey<N extends keyof TFields>(
fieldName: N,
fieldValue: NonNullable<TFields[N]>
): string {
const columnName = this.entityConfiguration.entityToDBFieldsKeyMapping.get(fieldName);
invariant(columnName, `database field mapping missing for ${String(fieldName)}`);
return this.context.makeKeyFn(
this.context.cacheKeyPrefix,
`v${this.context.shardingSchemeVersion}`,
this.entityConfiguration.tableName,
`v2.${this.entityConfiguration.cacheKeyVersion}`,
columnName,
String(fieldValue)
);
}
}
@@ -0,0 +1,21 @@
import {
IEntityCacheAdapterProvider,
EntityConfiguration,
IEntityCacheAdapter,
GenericEntityCacheAdapter,
} from '@expo/entity';
import ShardedGenericRedisCacher, {
ShardedRedisCacheAdapterContext,
} from './ShardedGenericRedisCacher';
export default class ShardedRedisCacheAdapterProvider implements IEntityCacheAdapterProvider {
constructor(private readonly context: ShardedRedisCacheAdapterContext) {}
getCacheAdapter<TFields>(
entityConfiguration: EntityConfiguration<TFields>
): IEntityCacheAdapter<TFields> {
const genericCacher = new ShardedGenericRedisCacher(this.context, entityConfiguration);
return new GenericEntityCacheAdapter(genericCacher);
}
}
@@ -4,13 +4,13 @@ import Redis from 'ioredis';
import { URL } from 'url';
import { v4 as uuidv4 } from 'uuid';
import RedisCacheAdapter, { RedisCacheAdapterContext } from '../RedisCacheAdapter';
import GenericRedisCacher, { RedisCacheAdapterContext } from '../GenericRedisCacher';
import RedisTestEntity from '../testfixtures/RedisTestEntity';
import { createRedisIntegrationTestEntityCompanionProvider } from '../testfixtures/createRedisIntegrationTestEntityCompanionProvider';
class TestViewerContext extends ViewerContext {}
describe(RedisCacheAdapter, () => {
describe(GenericRedisCacher, () => {
let redisCacheAdapterContext: RedisCacheAdapterContext;
beforeAll(() => {
@@ -41,11 +41,11 @@ describe(RedisCacheAdapter, () => {
const viewerContext = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
);
const cacheAdapter = viewerContext.entityCompanionProvider.getCompanionForEntity(
const genericCacher = viewerContext.entityCompanionProvider.getCompanionForEntity(
RedisTestEntity,
RedisTestEntity.getCompanionDefinition()
)['tableDataCoordinator']['cacheAdapter'];
const cacheKeyMaker = cacheAdapter['makeCacheKey'].bind(cacheAdapter);
)['tableDataCoordinator']['cacheAdapter']['genericCacher'];
const cacheKeyMaker = genericCacher['makeCacheKey'].bind(genericCacher);
const entity1Created = await RedisTestEntity.creator(viewerContext)
.setField('name', 'blah')
@@ -2,8 +2,7 @@ import { CacheStatus, ViewerContext } from '@expo/entity';
import Redis from 'ioredis';
import { URL } from 'url';
import GenericRedisCacher from '../GenericRedisCacher';
import { RedisCacheAdapterContext } from '../RedisCacheAdapter';
import GenericRedisCacher, { RedisCacheAdapterContext } from '../GenericRedisCacher';
import RedisTestEntity, {
redisTestEntityConfiguration,
RedisTestEntityFields,
@@ -44,11 +43,7 @@ describe(GenericRedisCacher, () => {
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
);
const genericRedisCacher = new GenericRedisCacher(
{
redisClient: redisCacheAdapterContext.redisClient,
ttlSecondsNegative: redisCacheAdapterContext.ttlSecondsNegative,
ttlSecondsPositive: redisCacheAdapterContext.ttlSecondsPositive,
},
redisCacheAdapterContext,
redisTestEntityConfiguration
);
const date = new Date();
@@ -79,11 +74,7 @@ describe(GenericRedisCacher, () => {
});
it('has correct negative caching behaviour', async () => {
const genericRedisCacher = new GenericRedisCacher(
{
redisClient: redisCacheAdapterContext.redisClient,
ttlSecondsNegative: redisCacheAdapterContext.ttlSecondsNegative,
ttlSecondsPositive: redisCacheAdapterContext.ttlSecondsPositive,
},
redisCacheAdapterContext,
redisTestEntityConfiguration
);
@@ -98,11 +89,7 @@ describe(GenericRedisCacher, () => {
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
);
const genericRedisCacher = new GenericRedisCacher(
{
redisClient: redisCacheAdapterContext.redisClient,
ttlSecondsNegative: redisCacheAdapterContext.ttlSecondsNegative,
ttlSecondsPositive: redisCacheAdapterContext.ttlSecondsPositive,
},
redisCacheAdapterContext,
redisTestEntityConfiguration
);
const date = new Date();
@@ -0,0 +1,147 @@
import { ViewerContext } from '@expo/entity';
import Redis from 'ioredis';
import nullthrows from 'nullthrows';
import { URL } from 'url';
import ShardedGenericRedisCacher, {
ShardedRedisCacheAdapterContext,
} from '../ShardedGenericRedisCacher';
import RedisTestEntity from '../testfixtures/RedisTestEntity';
import { createShardedRedisIntegrationTestEntityCompanionProvider } from '../testfixtures/createShardedRedisIntegrationTestEntityCompanionProvider';
const ids = {
entity1: '7345999c-b97d-47af-873c-bb1e9c1876f3', // shard 0
nonexistent: 'c207c238-70b5-41bb-8f33-130a57a6f6c5', // shard 1
};
function getShardForKey(key: string): 0 | 1 {
if (key.includes(ids.entity1)) {
return 0;
}
if (key.includes(ids.nonexistent)) {
return 1;
}
// entity1
if (key.includes('name:blah')) {
return 1; // put other field cache for entity1 on different server than the id field cached value
}
throw Error('unknown id encountered: ' + key);
}
describe(ShardedGenericRedisCacher, () => {
let redis1: Redis;
let redis2: Redis;
let redisCacheAdapterContext: ShardedRedisCacheAdapterContext;
beforeAll(() => {
redis1 = new Redis(new URL(process.env['REDIS_URL']!).toString());
redis2 = new Redis(new URL(process.env['REDIS_URL_2']!).toString());
redisCacheAdapterContext = {
getShardGroupForKeysFn: (keys) => new Map(keys.map((k) => [k, getShardForKey(k)])),
getRedisInstanceForShardGroup: (shardGroup) => (shardGroup === 0 ? redis1 : redis2),
shardingSchemeVersion: 1,
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 Promise.all([redis1?.flushdb(), redis2?.flushdb()]);
});
afterAll(async () => {
redis1.disconnect();
redis2.disconnect();
});
it('has correct caching behavior', async () => {
const viewerContext = new ViewerContext(
createShardedRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
);
const genericCacher = viewerContext.entityCompanionProvider.getCompanionForEntity(
RedisTestEntity,
RedisTestEntity.getCompanionDefinition()
)['tableDataCoordinator']['cacheAdapter']['genericCacher'];
const cacheKeyMaker = genericCacher['makeCacheKey'].bind(genericCacher);
const entity1Created = await RedisTestEntity.creator(viewerContext)
.setField('id', ids.entity1)
.setField('name', 'blah')
.enforceCreateAsync();
// loading an entity should put it in cache. load by both cached fields (which are on separate redis instances).
const cacheKeyEntity1 = cacheKeyMaker('id', entity1Created.getID());
const cacheKeyEntity1ShardGroup = nullthrows(
redisCacheAdapterContext.getShardGroupForKeysFn([cacheKeyEntity1]).get(cacheKeyEntity1)
);
const cacheRedisClientForEntity =
redisCacheAdapterContext.getRedisInstanceForShardGroup(cacheKeyEntity1ShardGroup);
const entity1 = await RedisTestEntity.loader(viewerContext)
.enforcing()
.loadByIDAsync(entity1Created.getID());
const cachedJSON = await cacheRedisClientForEntity.get(cacheKeyEntity1);
const cachedValue = JSON.parse(cachedJSON!);
expect(cachedValue).toMatchObject({
id: entity1.getID(),
name: 'blah',
});
const cacheKeyEntity1NameField = cacheKeyMaker('name', entity1Created.getField('name'));
const cacheKeyEntity1NameFieldShardGroup = nullthrows(
redisCacheAdapterContext
.getShardGroupForKeysFn([cacheKeyEntity1NameField])
.get(cacheKeyEntity1NameField)
);
const cacheRedisClientForEntityNameField =
redisCacheAdapterContext.getRedisInstanceForShardGroup(cacheKeyEntity1NameFieldShardGroup);
await RedisTestEntity.loader(viewerContext)
.enforcing()
.loadByFieldEqualingAsync('name', entity1Created.getField('name'));
await expect(cacheRedisClientForEntityNameField.get(cacheKeyEntity1NameField)).resolves.toEqual(
cachedJSON
);
expect(cacheKeyEntity1ShardGroup).not.toEqual(cacheKeyEntity1NameFieldShardGroup);
// simulate non existent db fetch, should write negative result ('') to cache
const nonExistentId = ids.nonexistent;
const entityNonExistentResult = await RedisTestEntity.loader(viewerContext).loadByIDAsync(
nonExistentId
);
expect(entityNonExistentResult.ok).toBe(false);
const cacheKeyNonExistent = cacheKeyMaker('id', nonExistentId);
const cacheRedisClientForNonExistent = redisCacheAdapterContext.getRedisInstanceForShardGroup(
nullthrows(
redisCacheAdapterContext
.getShardGroupForKeysFn([cacheKeyNonExistent])
.get(cacheKeyNonExistent)
)
);
const nonExistentCachedValue = await cacheRedisClientForNonExistent.get(cacheKeyNonExistent);
expect(nonExistentCachedValue).toEqual('');
// load again through entities framework to ensure it reads negative result
const entityNonExistentResult2 = await RedisTestEntity.loader(viewerContext).loadByIDAsync(
nonExistentId
);
expect(entityNonExistentResult2.ok).toBe(false);
// invalidate from cache to ensure it invalidates correctly in both caches
await RedisTestEntity.loader(viewerContext).invalidateFieldsAsync(entity1.getAllFields());
await expect(cacheRedisClientForEntity.get(cacheKeyEntity1)).resolves.toBeNull();
await expect(
cacheRedisClientForEntityNameField.get(cacheKeyEntity1NameField)
).resolves.toBeNull();
});
});
@@ -2,13 +2,13 @@ import { EntityCacheAdapterTransientError, ViewerContext } from '@expo/entity';
import Redis from 'ioredis';
import { URL } from 'url';
import RedisCacheAdapter, { RedisCacheAdapterContext } from '../RedisCacheAdapter';
import GenericRedisCacher, { RedisCacheAdapterContext } from '../GenericRedisCacher';
import RedisTestEntity from '../testfixtures/RedisTestEntity';
import { createRedisIntegrationTestEntityCompanionProvider } from '../testfixtures/createRedisIntegrationTestEntityCompanionProvider';
class TestViewerContext extends ViewerContext {}
describe(RedisCacheAdapter, () => {
describe(GenericRedisCacher, () => {
let redisCacheAdapterContext: RedisCacheAdapterContext;
beforeAll(() => {
@@ -0,0 +1,199 @@
import { CacheStatus, UUIDField, EntityConfiguration } from '@expo/entity';
import { Redis, Pipeline } from 'ioredis';
import { mock, when, instance, anything, verify } from 'ts-mockito';
import GenericRedisCacher from '../GenericRedisCacher';
type BlahFields = {
id: string;
};
const entityConfiguration = new EntityConfiguration<BlahFields>({
idField: 'id',
tableName: 'blah',
schema: {
id: new UUIDField({ columnName: 'id', cache: true }),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
});
describe(GenericRedisCacher, () => {
describe('loadManyAsync', () => {
it('returns appropriate cache results', async () => {
const redisResults = new Map();
const mockRedisClient = mock<Redis>();
// need to have one anything() for each element of spread, in this case 3
when(mockRedisClient.mget(anything(), anything(), anything())).thenCall(async (...keys) =>
keys.map((k) => redisResults.get(k) ?? null)
);
const genericCacher = new GenericRedisCacher(
{
redisClient: instance(mockRedisClient),
makeKeyFn: (...parts) => parts.join(':'),
cacheKeyVersion: 1,
cacheKeyPrefix: 'hello-',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
},
entityConfiguration
);
const cacheKeyWat = genericCacher['makeCacheKey']('id', 'wat');
const cacheKeyWho = genericCacher['makeCacheKey']('id', 'who');
const cacheKeyWhy = genericCacher['makeCacheKey']('id', 'why');
redisResults.set(cacheKeyWat, JSON.stringify({ id: 'wat' }));
redisResults.set(cacheKeyWho, '');
const results = await genericCacher.loadManyAsync([cacheKeyWat, cacheKeyWho, cacheKeyWhy]);
expect(results.get(cacheKeyWat)).toMatchObject({
status: CacheStatus.HIT,
item: { id: 'wat' },
});
expect(results.get(cacheKeyWho)).toMatchObject({ status: CacheStatus.NEGATIVE });
expect(results.get(cacheKeyWhy)).toMatchObject({ status: CacheStatus.MISS });
expect(results.size).toBe(3);
});
it('returns empty map when passed empty array of fieldValues', async () => {
const genericCacher = new GenericRedisCacher(
{
redisClient: instance(mock<Redis>()),
makeKeyFn: (...parts) => parts.join(':'),
cacheKeyVersion: 1,
cacheKeyPrefix: 'hello-',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
},
entityConfiguration
);
const results = await genericCacher.loadManyAsync([]);
expect(results).toEqual(new Map());
});
});
describe('cacheManyAsync', () => {
it('correctly caches all objects', async () => {
const redisResults = new Map();
const mockPipeline = mock<Pipeline>();
when(mockPipeline.set(anything(), anything(), anything(), anything())).thenCall(
(key, value, code, ttl) => {
redisResults.set(key, { value, code, ttl });
return pipeline;
}
);
when(mockPipeline.exec()).thenResolve({} as any);
const pipeline = instance(mockPipeline);
const mockRedisClient = mock<Redis>();
when(mockRedisClient.multi()).thenReturn(pipeline);
const genericCacher = new GenericRedisCacher(
{
redisClient: instance(mockRedisClient),
makeKeyFn: (...parts) => parts.join(':'),
cacheKeyVersion: 1,
cacheKeyPrefix: 'hello-',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
},
entityConfiguration
);
const cacheKey = genericCacher['makeCacheKey']('id', 'wat');
await genericCacher.cacheManyAsync(new Map([[cacheKey, { id: 'wat' }]]));
expect(redisResults.get(cacheKey)).toMatchObject({
value: JSON.stringify({ id: 'wat' }),
code: 'EX',
ttl: 1,
});
});
});
describe('cacheDBMissesAsync', () => {
it('correctly caches misses', async () => {
const redisResults = new Map();
const mockPipeline = mock<Pipeline>();
when(mockPipeline.set(anything(), anything(), anything(), anything())).thenCall(
(key, value, code, ttl) => {
redisResults.set(key, { value, code, ttl });
return pipeline;
}
);
when(mockPipeline.exec()).thenResolve({} as any);
const pipeline = instance(mockPipeline);
const mockRedisClient = mock<Redis>();
when(mockRedisClient.multi()).thenReturn(pipeline);
const genericCacher = new GenericRedisCacher(
{
redisClient: instance(mockRedisClient),
makeKeyFn: (...parts) => parts.join(':'),
cacheKeyVersion: 1,
cacheKeyPrefix: 'hello-',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
},
entityConfiguration
);
const cacheKey = genericCacher['makeCacheKey']('id', 'wat');
await genericCacher.cacheDBMissesAsync([cacheKey]);
expect(redisResults.get(cacheKey)).toMatchObject({
value: '',
code: 'EX',
ttl: 2,
});
});
});
describe('invalidateManyAsync', () => {
it('invalidates correctly', async () => {
const mockRedisClient = mock<Redis>();
when(mockRedisClient.del()).thenResolve(1);
const genericCacher = new GenericRedisCacher(
{
redisClient: instance(mockRedisClient),
makeKeyFn: (...parts) => parts.join(':'),
cacheKeyVersion: 1,
cacheKeyPrefix: 'hello-',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
},
entityConfiguration
);
const cacheKey = genericCacher['makeCacheKey']('id', 'wat');
await genericCacher.invalidateManyAsync([cacheKey]);
verify(mockRedisClient.del(cacheKey)).once();
});
it('returns when passed empty array of fieldValues', async () => {
const genericCacher = new GenericRedisCacher(
{
redisClient: instance(mock<Redis>()),
makeKeyFn: (...parts) => parts.join(':'),
cacheKeyVersion: 1,
cacheKeyPrefix: 'hello-',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
},
entityConfiguration
);
await genericCacher.invalidateManyAsync([]);
});
});
});
@@ -1,169 +0,0 @@
import { CacheStatus, UUIDField, EntityConfiguration } from '@expo/entity';
import { Redis, Pipeline } from 'ioredis';
import { mock, when, instance, anything, verify } from 'ts-mockito';
import RedisCacheAdapter from '../RedisCacheAdapter';
type BlahFields = {
id: string;
};
const entityConfiguration = new EntityConfiguration<BlahFields>({
idField: 'id',
tableName: 'blah',
schema: {
id: new UUIDField({ columnName: 'id', cache: true }),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
});
describe(RedisCacheAdapter, () => {
describe('loadManyAsync', () => {
it('returns appropriate cache results', async () => {
const redisResults = new Map();
const mockRedisClient = mock<Redis>();
// need to have one anything() for each element of spread, in this case 3
when(mockRedisClient.mget(anything(), anything(), anything())).thenCall(async (...keys) =>
keys.map((k) => redisResults.get(k) ?? null)
);
const cacheAdapter = new RedisCacheAdapter(entityConfiguration, {
redisClient: instance(mockRedisClient),
makeKeyFn: (...parts) => parts.join(':'),
cacheKeyVersion: 1,
cacheKeyPrefix: 'hello-',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
});
redisResults.set(cacheAdapter['makeCacheKey']('id', 'wat'), JSON.stringify({ id: 'wat' }));
redisResults.set(cacheAdapter['makeCacheKey']('id', 'who'), '');
const results = await cacheAdapter.loadManyAsync('id', ['wat', 'who', 'why']);
expect(results.get('wat')).toMatchObject({ status: CacheStatus.HIT, item: { id: 'wat' } });
expect(results.get('who')).toMatchObject({ status: CacheStatus.NEGATIVE });
expect(results.get('why')).toMatchObject({ status: CacheStatus.MISS });
expect(results.size).toBe(3);
});
it('returns empty map when passed empty array of fieldValues', async () => {
const cacheAdapter = new RedisCacheAdapter(entityConfiguration, {
redisClient: instance(mock<Redis>()),
makeKeyFn: (...parts) => parts.join(':'),
cacheKeyVersion: 1,
cacheKeyPrefix: 'hello-',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
});
const results = await cacheAdapter.loadManyAsync('id', []);
expect(results).toEqual(new Map());
});
});
describe('cacheManyAsync', () => {
it('correctly caches all objects', async () => {
const redisResults = new Map();
const mockPipeline = mock<Pipeline>();
when(mockPipeline.set(anything(), anything(), anything(), anything())).thenCall(
(key, value, code, ttl) => {
redisResults.set(key, { value, code, ttl });
return pipeline;
}
);
when(mockPipeline.exec()).thenResolve({} as any);
const pipeline = instance(mockPipeline);
const mockRedisClient = mock<Redis>();
when(mockRedisClient.multi()).thenReturn(pipeline);
const cacheAdapter = new RedisCacheAdapter(entityConfiguration, {
redisClient: instance(mockRedisClient),
makeKeyFn: (...parts) => parts.join(':'),
cacheKeyVersion: 1,
cacheKeyPrefix: 'hello-',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
});
await cacheAdapter.cacheManyAsync('id', new Map([['wat', { id: 'wat' }]]));
const cacheKey = cacheAdapter['makeCacheKey']('id', 'wat');
expect(redisResults.get(cacheKey)).toMatchObject({
value: JSON.stringify({ id: 'wat' }),
code: 'EX',
ttl: 1,
});
});
});
describe('cacheDBMissesAsync', () => {
it('correctly caches misses', async () => {
const redisResults = new Map();
const mockPipeline = mock<Pipeline>();
when(mockPipeline.set(anything(), anything(), anything(), anything())).thenCall(
(key, value, code, ttl) => {
redisResults.set(key, { value, code, ttl });
return pipeline;
}
);
when(mockPipeline.exec()).thenResolve({} as any);
const pipeline = instance(mockPipeline);
const mockRedisClient = mock<Redis>();
when(mockRedisClient.multi()).thenReturn(pipeline);
const cacheAdapter = new RedisCacheAdapter(entityConfiguration, {
redisClient: instance(mockRedisClient),
makeKeyFn: (...parts) => parts.join(':'),
cacheKeyVersion: 1,
cacheKeyPrefix: 'hello-',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
});
await cacheAdapter.cacheDBMissesAsync('id', ['wat']);
const cacheKey = cacheAdapter['makeCacheKey']('id', 'wat');
expect(redisResults.get(cacheKey)).toMatchObject({
value: '',
code: 'EX',
ttl: 2,
});
});
});
describe('invalidateManyAsync', () => {
it('invalidates correctly', async () => {
const mockRedisClient = mock<Redis>();
when(mockRedisClient.del()).thenResolve(1);
const cacheAdapter = new RedisCacheAdapter(entityConfiguration, {
redisClient: instance(mockRedisClient),
makeKeyFn: (...parts) => parts.join(':'),
cacheKeyVersion: 1,
cacheKeyPrefix: 'hello-',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
});
await cacheAdapter.invalidateManyAsync('id', ['wat']);
const cacheKey = cacheAdapter['makeCacheKey']('id', 'wat');
verify(mockRedisClient.del(cacheKey)).once();
});
it('returns when passed empty array of fieldValues', async () => {
const cacheAdapter = new RedisCacheAdapter(entityConfiguration, {
redisClient: instance(mock<Redis>()),
makeKeyFn: (...parts) => parts.join(':'),
cacheKeyVersion: 1,
cacheKeyPrefix: 'hello-',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
});
await cacheAdapter.invalidateManyAsync('id', []);
});
});
});
@@ -0,0 +1,316 @@
import { CacheStatus, UUIDField, EntityConfiguration } from '@expo/entity';
import { Redis, Pipeline } from 'ioredis';
import { mock, when, instance, anything, verify } from 'ts-mockito';
import ShardedGenericRedisCacher from '../ShardedGenericRedisCacher';
type BlahFields = {
id: string;
};
const entityConfiguration = new EntityConfiguration<BlahFields>({
idField: 'id',
tableName: 'blah',
schema: {
id: new UUIDField({ columnName: 'id', cache: true }),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
});
describe(ShardedGenericRedisCacher, () => {
describe('loadManyAsync', () => {
it('returns appropriate cache results', async () => {
const makeMockRedis = (): { mockRedisClient: Redis; redisResults: Map<string, string> } => {
const redisResults = new Map();
const mockRedisClient = mock<Redis>();
// need to have one anything() for each element of spread, in this case 3
when(mockRedisClient.mget(anything(), anything(), anything())).thenCall(async (...keys) =>
keys.map((k) => redisResults.get(k) ?? null)
);
return { mockRedisClient, redisResults };
};
const [
{ mockRedisClient: mockRedisClient1, redisResults: redisResults1 },
{ mockRedisClient: mockRedisClient2, redisResults: redisResults2 },
] = [makeMockRedis(), makeMockRedis()];
const redisClient1 = instance(mockRedisClient1);
const redisClient2 = instance(mockRedisClient2);
const genericCacher = new ShardedGenericRedisCacher(
{
getShardGroupForKeysFn: (keys) =>
new Map(keys.map((k) => [k, k.includes('ins-1') ? 1 : 2])),
getRedisInstanceForShardGroup: (shardGroup) =>
shardGroup === 1 ? redisClient1 : redisClient2,
shardingSchemeVersion: 1,
makeKeyFn: (...parts) => parts.join(':'),
cacheKeyVersion: 1,
cacheKeyPrefix: 'hello-',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
},
entityConfiguration
);
const cacheKeyWat1 = genericCacher['makeCacheKey']('id', 'wat-ins-1');
const cacheKeyWho1 = genericCacher['makeCacheKey']('id', 'who-ins-1');
const cacheKeyWhy1 = genericCacher['makeCacheKey']('id', 'why-ins-1');
const cacheKeyWat2 = genericCacher['makeCacheKey']('id', 'wat-ins-2');
const cacheKeyWho2 = genericCacher['makeCacheKey']('id', 'who-ins-2');
const cacheKeyWhy2 = genericCacher['makeCacheKey']('id', 'why-ins-2');
redisResults1.set(cacheKeyWat1, JSON.stringify({ id: 'wat-ins-1' }));
redisResults1.set(cacheKeyWho1, '');
redisResults2.set(cacheKeyWat2, JSON.stringify({ id: 'wat-ins-2' }));
redisResults2.set(cacheKeyWho2, '');
const results = await genericCacher.loadManyAsync([
cacheKeyWat1,
cacheKeyWho1,
cacheKeyWhy1,
cacheKeyWat2,
cacheKeyWho2,
cacheKeyWhy2,
]);
expect(results.get(cacheKeyWat1)).toMatchObject({
status: CacheStatus.HIT,
item: { id: 'wat-ins-1' },
});
expect(results.get(cacheKeyWho1)).toMatchObject({ status: CacheStatus.NEGATIVE });
expect(results.get(cacheKeyWhy1)).toMatchObject({ status: CacheStatus.MISS });
expect(results.get(cacheKeyWat2)).toMatchObject({
status: CacheStatus.HIT,
item: { id: 'wat-ins-2' },
});
expect(results.get(cacheKeyWho2)).toMatchObject({ status: CacheStatus.NEGATIVE });
expect(results.get(cacheKeyWhy2)).toMatchObject({ status: CacheStatus.MISS });
expect(results.size).toBe(6);
});
it('returns empty map when passed empty array of fieldValues', async () => {
const mockRedisClient = mock<Redis>();
const redisClient = instance(mockRedisClient);
const genericCacher = new ShardedGenericRedisCacher(
{
getShardGroupForKeysFn: (keys) => new Map(keys.map((k) => [k, 1])),
getRedisInstanceForShardGroup: () => redisClient,
shardingSchemeVersion: 1,
makeKeyFn: (...parts) => parts.join(':'),
cacheKeyVersion: 1,
cacheKeyPrefix: 'hello-',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
},
entityConfiguration
);
const results = await genericCacher.loadManyAsync([]);
expect(results).toEqual(new Map());
});
});
describe('cacheManyAsync', () => {
it('correctly caches all objects', async () => {
const makeMockRedis = (): { mockRedisClient: Redis; redisResults: Map<string, string> } => {
const redisResults = new Map();
const mockPipeline = mock<Pipeline>();
when(mockPipeline.set(anything(), anything(), anything(), anything())).thenCall(
(key, value, code, ttl) => {
redisResults.set(key, { value, code, ttl });
return pipeline;
}
);
when(mockPipeline.exec()).thenResolve({} as any);
const pipeline = instance(mockPipeline);
const mockRedisClient = mock<Redis>();
when(mockRedisClient.multi()).thenReturn(pipeline);
return { mockRedisClient, redisResults };
};
const [
{ mockRedisClient: mockRedisClient1, redisResults: redisResults1 },
{ mockRedisClient: mockRedisClient2, redisResults: redisResults2 },
] = [makeMockRedis(), makeMockRedis()];
const redisClient1 = instance(mockRedisClient1);
const redisClient2 = instance(mockRedisClient2);
const genericCacher = new ShardedGenericRedisCacher(
{
getShardGroupForKeysFn: (keys) =>
new Map(keys.map((k) => [k, k.includes('ins-1') ? 1 : 2])),
getRedisInstanceForShardGroup: (shardGroup) =>
shardGroup === 1 ? redisClient1 : redisClient2,
shardingSchemeVersion: 1,
makeKeyFn: (...parts) => parts.join(':'),
cacheKeyVersion: 1,
cacheKeyPrefix: 'hello-',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
},
entityConfiguration
);
const cacheKey1 = genericCacher['makeCacheKey']('id', 'wat-ins-1');
const cacheKey2 = genericCacher['makeCacheKey']('id', 'wat-ins-2');
await genericCacher.cacheManyAsync(
new Map([
[cacheKey1, { id: 'wat-ins-1' }],
[cacheKey2, { id: 'wat-ins-2' }],
])
);
expect(redisResults1.get(cacheKey1)).toMatchObject({
value: JSON.stringify({ id: 'wat-ins-1' }),
code: 'EX',
ttl: 1,
});
expect(redisResults2.get(cacheKey2)).toMatchObject({
value: JSON.stringify({ id: 'wat-ins-2' }),
code: 'EX',
ttl: 1,
});
expect(redisResults1.get(cacheKey2)).toBeUndefined();
expect(redisResults2.get(cacheKey1)).toBeUndefined();
});
});
describe('cacheDBMissesAsync', () => {
it('correctly caches misses', async () => {
const makeMockRedis = (): { mockRedisClient: Redis; redisResults: Map<string, string> } => {
const redisResults = new Map();
const mockPipeline = mock<Pipeline>();
when(mockPipeline.set(anything(), anything(), anything(), anything())).thenCall(
(key, value, code, ttl) => {
redisResults.set(key, { value, code, ttl });
return pipeline;
}
);
when(mockPipeline.exec()).thenResolve({} as any);
const pipeline = instance(mockPipeline);
const mockRedisClient = mock<Redis>();
when(mockRedisClient.multi()).thenReturn(pipeline);
return { mockRedisClient, redisResults };
};
const [
{ mockRedisClient: mockRedisClient1, redisResults: redisResults1 },
{ mockRedisClient: mockRedisClient2, redisResults: redisResults2 },
] = [makeMockRedis(), makeMockRedis()];
const redisClient1 = instance(mockRedisClient1);
const redisClient2 = instance(mockRedisClient2);
const genericCacher = new ShardedGenericRedisCacher(
{
getShardGroupForKeysFn: (keys) =>
new Map(keys.map((k) => [k, k.includes('ins-1') ? 1 : 2])),
getRedisInstanceForShardGroup: (shardGroup) =>
shardGroup === 1 ? redisClient1 : redisClient2,
shardingSchemeVersion: 1,
makeKeyFn: (...parts) => parts.join(':'),
cacheKeyVersion: 1,
cacheKeyPrefix: 'hello-',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
},
entityConfiguration
);
const cacheKey1 = genericCacher['makeCacheKey']('id', 'wat-ins-1');
const cacheKey2 = genericCacher['makeCacheKey']('id', 'wat-ins-2');
await genericCacher.cacheDBMissesAsync([cacheKey1, cacheKey2]);
expect(redisResults1.get(cacheKey1)).toMatchObject({
value: '',
code: 'EX',
ttl: 2,
});
expect(redisResults2.get(cacheKey2)).toMatchObject({
value: '',
code: 'EX',
ttl: 2,
});
expect(redisResults1.get(cacheKey2)).toBeUndefined();
expect(redisResults2.get(cacheKey1)).toBeUndefined();
});
});
describe('invalidateManyAsync', () => {
it('invalidates correctly', async () => {
const makeMockRedis = (): { mockRedisClient: Redis } => {
const mockRedisClient = mock<Redis>();
return { mockRedisClient };
};
const [{ mockRedisClient: mockRedisClient1 }, { mockRedisClient: mockRedisClient2 }] = [
makeMockRedis(),
makeMockRedis(),
];
const redisClient1 = instance(mockRedisClient1);
const redisClient2 = instance(mockRedisClient2);
const genericCacher = new ShardedGenericRedisCacher(
{
getShardGroupForKeysFn: (keys) =>
new Map(keys.map((k) => [k, k.includes('ins-1') ? 1 : 2])),
getRedisInstanceForShardGroup: (shardGroup) =>
shardGroup === 1 ? redisClient1 : redisClient2,
shardingSchemeVersion: 1,
makeKeyFn: (...parts) => parts.join(':'),
cacheKeyVersion: 1,
cacheKeyPrefix: 'hello-',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
},
entityConfiguration
);
const cacheKey1 = genericCacher['makeCacheKey']('id', 'wat-ins-1');
const cacheKey2 = genericCacher['makeCacheKey']('id', 'wat-ins-2');
await genericCacher.invalidateManyAsync([cacheKey1, cacheKey2]);
verify(mockRedisClient1.del(cacheKey1)).once();
verify(mockRedisClient2.del(cacheKey2)).once();
verify(mockRedisClient1.del(cacheKey2)).never();
verify(mockRedisClient2.del(cacheKey1)).never();
});
it('returns when passed empty array of fieldValues', async () => {
const mockRedisClient = mock<Redis>();
const redisClient = instance(mockRedisClient);
const genericCacher = new ShardedGenericRedisCacher(
{
getShardGroupForKeysFn: (keys) => new Map(keys.map((k) => [k, 1])),
getRedisInstanceForShardGroup: () => redisClient,
shardingSchemeVersion: 1,
makeKeyFn: (...parts) => parts.join(':'),
cacheKeyVersion: 1,
cacheKeyPrefix: 'hello-',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
},
entityConfiguration
);
await genericCacher.invalidateManyAsync([]);
});
});
});
@@ -4,9 +4,10 @@
* @module @expo/entity-cache-adapter-redis
*/
export { default as RedisCacheAdapter } from './RedisCacheAdapter';
export * from './RedisCacheAdapter';
export { default as RedisCacheAdapterProvider } from './RedisCacheAdapterProvider';
export * from './RedisCommon';
export { default as GenericRedisCacher } from './GenericRedisCacher';
export * from './GenericRedisCacher';
export { default as ShardedRedisCacheAdapterProvider } from './ShardedRedisCacheAdapterProvider';
export { default as ShardedGenericRedisCacher } from './ShardedGenericRedisCacher';
export * from './ShardedGenericRedisCacher';
@@ -6,7 +6,7 @@ import {
StubDatabaseAdapterProvider,
} from '@expo/entity';
import { RedisCacheAdapterContext } from '../RedisCacheAdapter';
import { RedisCacheAdapterContext } from '../GenericRedisCacher';
import RedisCacheAdapterProvider from '../RedisCacheAdapterProvider';
export const createRedisIntegrationTestEntityCompanionProvider = (
@@ -0,0 +1,38 @@
import {
NoOpEntityMetricsAdapter,
IEntityMetricsAdapter,
EntityCompanionProvider,
StubQueryContextProvider,
StubDatabaseAdapterProvider,
} from '@expo/entity';
import { ShardedRedisCacheAdapterContext } from '../ShardedGenericRedisCacher';
import ShardedRedisCacheAdapterProvider from '../ShardedRedisCacheAdapterProvider';
export const createShardedRedisIntegrationTestEntityCompanionProvider = (
shardedRedisCacheAdapterContext: ShardedRedisCacheAdapterContext,
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter()
): EntityCompanionProvider => {
return new EntityCompanionProvider(
metricsAdapter,
new Map([
[
'postgres',
{
adapterProvider: new StubDatabaseAdapterProvider(),
queryContextProvider: StubQueryContextProvider,
},
],
]),
new Map([
[
'redis',
{
cacheAdapterProvider: new ShardedRedisCacheAdapterProvider(
shardedRedisCacheAdapterContext
),
},
],
])
);
};
@@ -3,6 +3,77 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.28.0](https://github.com/expo/entity/compare/v0.27.0...v0.28.0) (2022-09-13)
### Features
* add BigIntField ([#193](https://github.com/expo/entity/issues/193)) ([acbad82](https://github.com/expo/entity/commit/acbad826a37bb5a054b4dcf9be91d11aa5f7f29a))
* nested transactions ([#194](https://github.com/expo/entity/issues/194)) ([a77b914](https://github.com/expo/entity/commit/a77b914ad6dc018d1e309c9caf4971088a553ecf))
# [0.27.0](https://github.com/expo/entity/compare/v0.26.1...v0.27.0) (2022-08-09)
### Features
* add orderByRaw to loadManyByRawWhereClauseAsync ([#185](https://github.com/expo/entity/issues/185)) ([2817d78](https://github.com/expo/entity/commit/2817d78392dfecd0093111c24d581bdf01dba06d))
## [0.26.1](https://github.com/expo/entity/compare/v0.26.0...v0.26.1) (2022-07-28)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
# [0.26.0](https://github.com/expo/entity/compare/v0.25.3...v0.26.0) (2022-07-14)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
## [0.25.3](https://github.com/expo/entity/compare/v0.25.2...v0.25.3) (2022-03-12)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
## [0.25.2](https://github.com/expo/entity/compare/v0.25.1...v0.25.2) (2022-03-12)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
## [0.25.1](https://github.com/expo/entity/compare/v0.25.0...v0.25.1) (2022-03-10)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
# [0.25.0](https://github.com/expo/entity/compare/v0.24.0...v0.25.0) (2022-03-10)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
@@ -1,6 +1,6 @@
{
"name": "@expo/entity-database-adapter-knex",
"version": "0.24.0",
"version": "0.28.0",
"description": "Knex database adapter for @expo/entity",
"files": [
"build",
@@ -33,7 +33,7 @@
"knex": "^1.0.2"
},
"devDependencies": {
"@expo/entity": "^0.24.0",
"@expo/entity": "^0.28.0",
"pg": "^8.7.1"
}
}
@@ -0,0 +1,29 @@
import { EntityFieldDefinition } from '@expo/entity';
/**
* EntityFieldDefinition for a Postres column with a JS JSON array type.
*/
export class JSONArrayField extends EntityFieldDefinition<any[]> {
protected validateInputValueInternal(value: any[]): boolean {
return Array.isArray(value);
}
}
/**
* EntityFieldDefinition for a Postgres column that may be a JS JSON array type.
* Does not do any validation.
*/
export class MaybeJSONArrayField extends EntityFieldDefinition<any | any[]> {
protected validateInputValueInternal(_value: any): boolean {
return true;
}
}
/**
* EntityFieldDefinition for a Postgres BIGINT column.
*/
export class BigIntField extends EntityFieldDefinition<string> {
protected validateInputValueInternal(value: string): boolean {
return typeof value === 'string';
}
}
@@ -2,14 +2,14 @@ import {
EntityDatabaseAdapter,
FieldTransformerMap,
FieldTransformer,
JSONArrayField,
MaybeJSONArrayField,
TableQuerySelectionModifiers,
TableFieldSingleValueEqualityCondition,
TableFieldMultiValueEqualityCondition,
TableQuerySelectionModifiersWithOrderByRaw,
} from '@expo/entity';
import { Knex } from 'knex';
import { JSONArrayField, MaybeJSONArrayField } from './EntityFields';
import wrapNativePostgresCallAsync from './errors/wrapNativePostgresCallAsync';
export default class PostgresEntityDatabaseAdapter<TFields> extends EntityDatabaseAdapter<TFields> {
@@ -53,6 +53,20 @@ export default class PostgresEntityDatabaseAdapter<TFields> extends EntityDataba
);
}
private applyQueryModifiersToQueryOrderByRaw(
query: Knex.QueryBuilder,
querySelectionModifiers: TableQuerySelectionModifiersWithOrderByRaw
): Knex.QueryBuilder {
let ret = this.applyQueryModifiersToQuery(query, querySelectionModifiers);
const { orderByRaw } = querySelectionModifiers;
if (orderByRaw !== undefined) {
ret = ret.orderByRaw(orderByRaw);
}
return ret;
}
private applyQueryModifiersToQuery(
query: Knex.QueryBuilder,
querySelectionModifiers: TableQuerySelectionModifiers
@@ -129,13 +143,13 @@ export default class PostgresEntityDatabaseAdapter<TFields> extends EntityDataba
tableName: string,
rawWhereClause: string,
bindings: object | any[],
querySelectionModifiers: TableQuerySelectionModifiers
querySelectionModifiers: TableQuerySelectionModifiersWithOrderByRaw
): Promise<object[]> {
let query = queryInterface
.select()
.from(tableName)
.whereRaw(rawWhereClause, bindings as any);
query = this.applyQueryModifiersToQuery(query, querySelectionModifiers);
query = this.applyQueryModifiersToQueryOrderByRaw(query, querySelectionModifiers);
return await wrapNativePostgresCallAsync(() => query);
}
@@ -18,4 +18,10 @@ export default class PostgresEntityQueryContextProvider extends EntityQueryConte
) => Promise<T> {
return (transactionScope) => this.knexInstance.transaction(transactionScope);
}
protected createNestedTransactionRunner<T>(
outerQueryInterface: any
): (transactionScope: (queryInterface: any) => Promise<T>) => Promise<T> {
return (transactionScope) => (outerQueryInterface as Knex).transaction(transactionScope);
}
}
@@ -157,6 +157,27 @@ describe('postgres entity integration', () => {
});
});
describe('BIGINT fields', () => {
it('supports BIGINT fields', async () => {
const vc1 = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance));
let entity = await enforceAsyncResult(
PostgresTestEntity.creator(vc1).setField('bigintField', '72057594037928038').createAsync()
);
expect(entity.getField('bigintField')).toEqual('72057594037928038');
entity = await enforceAsyncResult(
PostgresTestEntity.updater(entity).setField('bigintField', '10').updateAsync()
);
expect(entity.getField('bigintField')).toEqual('10');
entity = await enforceAsyncResult(
PostgresTestEntity.updater(entity).setField('bigintField', '-10').updateAsync()
);
expect(entity.getField('bigintField')).toEqual('-10');
});
});
describe('conjunction field equality loading', () => {
it('supports single fieldValue and multiple fieldValues', async () => {
const vc1 = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance));
@@ -379,6 +400,15 @@ describe('postgres entity integration', () => {
expect(resultsMultipleOrderBy).toHaveLength(3);
expect(resultsMultipleOrderBy.map((e) => e.getField('name'))).toEqual(['c', 'b', 'a']);
const resultsOrderByRaw = await PostgresTestEntity.loader(vc1)
.enforcing()
.loadManyByRawWhereClauseAsync('has_a_dog = ?', [true], {
orderByRaw: 'has_a_dog ASC, name DESC',
});
expect(resultsOrderByRaw).toHaveLength(3);
expect(resultsOrderByRaw.map((e) => e.getField('name'))).toEqual(['c', 'b', 'a']);
});
});
@@ -606,6 +636,7 @@ describe('postgres entity integration', () => {
const vc1 = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance));
let preCommitCallCount = 0;
let preCommitInnerCallCount = 0;
let postCommitCallCount = 0;
await vc1.runInTransactionForDatabaseAdaptorFlavorAsync('postgres', async (queryContext) => {
@@ -615,6 +646,28 @@ describe('postgres entity integration', () => {
queryContext.appendPreCommitCallback(async () => {
preCommitCallCount++;
}, 0);
await queryContext.runInNestedTransactionAsync(async (innerQueryContext) => {
innerQueryContext.appendPostCommitCallback(async () => {
postCommitCallCount++;
});
innerQueryContext.appendPreCommitCallback(async () => {
preCommitInnerCallCount++;
}, 0);
});
// this one throws so its post commit shouldn't execute
try {
await queryContext.runInNestedTransactionAsync(async (innerQueryContext) => {
innerQueryContext.appendPostCommitCallback(async () => {
postCommitCallCount++;
});
innerQueryContext.appendPreCommitCallback(async () => {
preCommitInnerCallCount++;
throw Error('wat');
}, 0);
});
} catch {}
});
await expect(
@@ -630,7 +683,8 @@ describe('postgres entity integration', () => {
).rejects.toThrowError('wat');
expect(preCommitCallCount).toBe(2);
expect(postCommitCallCount).toBe(1);
expect(preCommitInnerCallCount).toBe(2);
expect(postCommitCallCount).toBe(2);
});
});
});
@@ -0,0 +1,101 @@
import { ViewerContext } from '@expo/entity';
import { knex, Knex } from 'knex';
import nullthrows from 'nullthrows';
import PostgresEntityQueryContextProvider from '../PostgresEntityQueryContextProvider';
import PostgresUniqueTestEntity from '../testfixtures/PostgresUniqueTestEntity';
import { createKnexIntegrationTestEntityCompanionProvider } from '../testfixtures/createKnexIntegrationTestEntityCompanionProvider';
describe(PostgresEntityQueryContextProvider, () => {
let knexInstance: Knex;
beforeAll(() => {
knexInstance = knex({
client: 'pg',
connection: {
user: nullthrows(process.env['PGUSER']),
password: nullthrows(process.env['PGPASSWORD']),
host: 'localhost',
port: parseInt(nullthrows(process.env['PGPORT']), 10),
database: nullthrows(process.env['PGDATABASE']),
},
});
});
beforeEach(async () => {
await PostgresUniqueTestEntity.createOrTruncatePostgresTable(knexInstance);
});
afterAll(async () => {
await PostgresUniqueTestEntity.dropPostgresTable(knexInstance);
await knexInstance.destroy();
});
it('supports nested transactions', async () => {
const vc1 = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance));
await PostgresUniqueTestEntity.creator(vc1).setField('name', 'unique').enforceCreateAsync();
const id = (
await PostgresUniqueTestEntity.creator(vc1).setField('name', 'wat').enforceCreateAsync()
).getID();
await vc1.runInTransactionForDatabaseAdaptorFlavorAsync('postgres', async (queryContext) => {
const entity = await PostgresUniqueTestEntity.loader(vc1, queryContext)
.enforcing()
.loadByIDAsync(id);
await PostgresUniqueTestEntity.updater(entity, queryContext)
.setField('name', 'wat2')
.enforceUpdateAsync();
// ensure the outer transaction is not aborted due to postgres error in inner transaction,
// in this case the error triggered is a unique constraint violation
try {
await queryContext.runInNestedTransactionAsync(async (innerQueryContext) => {
const entity = await PostgresUniqueTestEntity.loader(vc1, innerQueryContext)
.enforcing()
.loadByIDAsync(id);
await PostgresUniqueTestEntity.updater(entity, innerQueryContext)
.setField('name', 'unique')
.enforceUpdateAsync();
});
} catch {}
const entity2 = await PostgresUniqueTestEntity.loader(vc1, queryContext)
.enforcing()
.loadByIDAsync(id);
await PostgresUniqueTestEntity.updater(entity2, queryContext)
.setField('name', 'wat3')
.enforceUpdateAsync();
});
const entityLoaded = await PostgresUniqueTestEntity.loader(vc1).enforcing().loadByIDAsync(id);
expect(entityLoaded.getField('name')).toEqual('wat3');
});
it('supports multi-nested transactions', async () => {
const vc1 = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance));
const id = (
await PostgresUniqueTestEntity.creator(vc1).setField('name', 'wat').enforceCreateAsync()
).getID();
await vc1.runInTransactionForDatabaseAdaptorFlavorAsync('postgres', async (queryContext) => {
await queryContext.runInNestedTransactionAsync(async (innerQueryContext) => {
await innerQueryContext.runInNestedTransactionAsync(async (innerQueryContex2) => {
await innerQueryContex2.runInNestedTransactionAsync(async (innerQueryContex3) => {
const entity = await PostgresUniqueTestEntity.loader(vc1, innerQueryContex3)
.enforcing()
.loadByIDAsync(id);
await PostgresUniqueTestEntity.updater(entity, innerQueryContex3)
.setField('name', 'wat3')
.enforceUpdateAsync();
});
});
});
});
const entityLoaded = await PostgresUniqueTestEntity.loader(vc1).enforcing().loadByIDAsync(id);
expect(entityLoaded.getField('name')).toEqual('wat3');
});
});
@@ -0,0 +1,19 @@
import { describeFieldTestCase } from '@expo/entity';
import { BigIntField, JSONArrayField, MaybeJSONArrayField } from '../EntityFields';
describeFieldTestCase(
new JSONArrayField({ columnName: 'wat' }),
[[[1, 2]] as any, [['hello']] as any],
[1, 'hello']
);
describeFieldTestCase(
new MaybeJSONArrayField({ columnName: 'wat' }),
[1, 'hello', [['hello']]],
[]
);
describeFieldTestCase(
new BigIntField({ columnName: 'wat' }),
['123457682149816498126412896', '123', '-1', '-124147812641876482716841'],
[1, false, -1, 1e6, {}]
);
@@ -7,3 +7,4 @@
export { default as PostgresEntityDatabaseAdapter } from './PostgresEntityDatabaseAdapter';
export { default as PostgresEntityDatabaseAdapterProvider } from './PostgresEntityDatabaseAdapterProvider';
export { default as PostgresEntityQueryContextProvider } from './PostgresEntityQueryContextProvider';
export * from './EntityFields';
@@ -6,16 +6,16 @@ import {
StringField,
BooleanField,
StringArrayField,
JSONArrayField,
JSONObjectField,
DateField,
MaybeJSONArrayField,
EntityConfiguration,
EntityCompanionDefinition,
Entity,
} from '@expo/entity';
import { Knex } from 'knex';
import { BigIntField, JSONArrayField, MaybeJSONArrayField } from '../EntityFields';
type PostgresTestEntityFields = {
id: string;
name: string | null;
@@ -28,6 +28,7 @@ type PostgresTestEntityFields = {
} | null;
dateField: Date | null;
maybeJsonArrayField: string[] | { hello: string } | null;
bigintField: string | null;
};
export default class PostgresTestEntity extends Entity<
@@ -46,13 +47,11 @@ export default class PostgresTestEntity extends Entity<
}
public static async createOrTruncatePostgresTable(knex: Knex): Promise<void> {
await knex.raw('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"'); // for uuid_generate_v4()
const tableName = this.getCompanionDefinition().entityConfiguration.tableName;
const hasTable = await knex.schema.hasTable(tableName);
if (!hasTable) {
await knex.schema.createTable(tableName, (table) => {
table.uuid('id').defaultTo(knex.raw('uuid_generate_v4()')).primary();
table.uuid('id').defaultTo(knex.raw('gen_random_uuid()')).primary();
table.string('name');
table.boolean('has_a_dog');
table.boolean('has_a_cat');
@@ -61,6 +60,7 @@ export default class PostgresTestEntity extends Entity<
table.jsonb('json_object_field');
table.dateTime('date_field', { useTz: true });
table.jsonb('maybe_json_array_field');
table.bigint('bigint_field');
});
}
await knex.into(tableName).truncate();
@@ -147,6 +147,9 @@ export const postgresTestEntityConfiguration = new EntityConfiguration<PostgresT
maybeJsonArrayField: new MaybeJSONArrayField({
columnName: 'maybe_json_array_field',
}),
bigintField: new BigIntField({
columnName: 'bigint_field',
}),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
@@ -35,13 +35,11 @@ export default class PostgresTriggerTestEntity extends Entity<
}
public static async createOrTruncatePostgresTable(knex: Knex): Promise<void> {
await knex.raw('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"'); // for uuid_generate_v4()
const tableName = this.getCompanionDefinition().entityConfiguration.tableName;
const hasTable = await knex.schema.hasTable(tableName);
if (!hasTable) {
await knex.schema.createTable(tableName, (table) => {
table.uuid('id').defaultTo(knex.raw('uuid_generate_v4()')).primary();
table.uuid('id').defaultTo(knex.raw('gen_random_uuid()')).primary();
table.string('name');
});
}
@@ -0,0 +1,115 @@
import {
AlwaysAllowPrivacyPolicyRule,
EntityPrivacyPolicy,
ViewerContext,
UUIDField,
StringField,
EntityConfiguration,
EntityCompanionDefinition,
Entity,
} from '@expo/entity';
import { Knex } from 'knex';
type PostgresUniqueTestEntityFields = {
id: string;
name: string | null;
};
export default class PostgresUniqueTestEntity extends Entity<
PostgresUniqueTestEntityFields,
string,
ViewerContext
> {
static getCompanionDefinition(): EntityCompanionDefinition<
PostgresUniqueTestEntityFields,
string,
ViewerContext,
PostgresUniqueTestEntity,
PostgresUniqueTestEntityPrivacyPolicy
> {
return postgresTestEntityCompanionDefinition;
}
public static async createOrTruncatePostgresTable(knex: Knex): Promise<void> {
const tableName = this.getCompanionDefinition().entityConfiguration.tableName;
const hasTable = await knex.schema.hasTable(tableName);
if (!hasTable) {
await knex.schema.createTable(tableName, (table) => {
table.uuid('id').defaultTo(knex.raw('gen_random_uuid()')).primary();
table.string('name').unique();
});
}
await knex.into(tableName).truncate();
}
public static async dropPostgresTable(knex: Knex): Promise<void> {
const tableName = this.getCompanionDefinition().entityConfiguration.tableName;
const hasTable = await knex.schema.hasTable(tableName);
if (hasTable) {
await knex.schema.dropTable(tableName);
}
}
}
class PostgresUniqueTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
PostgresUniqueTestEntityFields,
string,
ViewerContext,
PostgresUniqueTestEntity
> {
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<
PostgresUniqueTestEntityFields,
string,
ViewerContext,
PostgresUniqueTestEntity
>(),
];
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<
PostgresUniqueTestEntityFields,
string,
ViewerContext,
PostgresUniqueTestEntity
>(),
];
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<
PostgresUniqueTestEntityFields,
string,
ViewerContext,
PostgresUniqueTestEntity
>(),
];
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<
PostgresUniqueTestEntityFields,
string,
ViewerContext,
PostgresUniqueTestEntity
>(),
];
}
export const postgresTestEntityConfiguration =
new EntityConfiguration<PostgresUniqueTestEntityFields>({
idField: 'id',
tableName: 'postgres_test_entities',
schema: {
id: new UUIDField({
columnName: 'id',
cache: true,
}),
name: new StringField({
columnName: 'name',
}),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
});
const postgresTestEntityCompanionDefinition = new EntityCompanionDefinition({
entityClass: PostgresUniqueTestEntity,
entityConfiguration: postgresTestEntityConfiguration,
privacyPolicyClass: PostgresUniqueTestEntityPrivacyPolicy,
});
@@ -34,13 +34,11 @@ export default class PostgresValidatorTestEntity extends Entity<
}
public static async createOrTruncatePostgresTable(knex: Knex): Promise<void> {
await knex.raw('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"'); // for uuid_generate_v4()
const tableName = this.getCompanionDefinition().entityConfiguration.tableName;
const hasTable = await knex.schema.hasTable(tableName);
if (!hasTable) {
await knex.schema.createTable(tableName, (table) => {
table.uuid('id').defaultTo(knex.raw('uuid_generate_v4()')).primary();
table.uuid('id').defaultTo(knex.raw('gen_random_uuid()')).primary();
table.string('name');
});
}
+70
View File
@@ -3,6 +3,76 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.28.0](https://github.com/expo/entity/compare/v0.27.0...v0.28.0) (2022-09-13)
### Features
* nested transactions ([#194](https://github.com/expo/entity/issues/194)) ([a77b914](https://github.com/expo/entity/commit/a77b914ad6dc018d1e309c9caf4971088a553ecf))
# [0.27.0](https://github.com/expo/entity/compare/v0.26.1...v0.27.0) (2022-08-09)
**Note:** Version bump only for package @expo/entity-example
## [0.26.1](https://github.com/expo/entity/compare/v0.26.0...v0.26.1) (2022-07-28)
**Note:** Version bump only for package @expo/entity-example
# [0.26.0](https://github.com/expo/entity/compare/v0.25.3...v0.26.0) (2022-07-14)
**Note:** Version bump only for package @expo/entity-example
## [0.25.3](https://github.com/expo/entity/compare/v0.25.2...v0.25.3) (2022-03-12)
**Note:** Version bump only for package @expo/entity-example
## [0.25.2](https://github.com/expo/entity/compare/v0.25.1...v0.25.2) (2022-03-12)
**Note:** Version bump only for package @expo/entity-example
## [0.25.1](https://github.com/expo/entity/compare/v0.25.0...v0.25.1) (2022-03-10)
**Note:** Version bump only for package @expo/entity-example
# [0.25.0](https://github.com/expo/entity/compare/v0.24.0...v0.25.0) (2022-03-10)
### Features
* privacy policy evaluation context ([#162](https://github.com/expo/entity/issues/162)) ([3a81a4c](https://github.com/expo/entity/commit/3a81a4c059e2e995eb800e35e76b3d432c22b23f))
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
**Note:** Version bump only for package @expo/entity-example
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@expo/entity-example",
"private": true,
"version": "0.24.0",
"version": "0.28.0",
"description": "An example integration of the @expo/entity framework",
"scripts": {
"tsc": "tsc",
@@ -23,7 +23,7 @@
"author": "Expo",
"license": "MIT",
"dependencies": {
"@expo/entity": "^0.24.0",
"@expo/entity": "^0.28.0",
"apollo-server-koa": "^2.16.1",
"graphql": "^15.3.0",
"koa": "^2.13.0",
@@ -10,4 +10,10 @@ export default class InMemoryQueryContextProvider extends EntityQueryContextProv
) => Promise<T> {
return (transactionScope) => Promise.resolve(transactionScope({}));
}
protected createNestedTransactionRunner<T>(
_outerQueryInterface: any
): (transactionScope: (queryInterface: any) => Promise<T>) => Promise<T> {
return (transactionScope) => Promise.resolve(transactionScope({}));
}
}
@@ -3,6 +3,7 @@ import {
ReadonlyEntity,
EntityQueryContext,
RuleEvaluationResult,
EntityPrivacyPolicyEvaluationContext,
} from '@expo/entity';
import { ExampleViewerContext } from '../viewerContexts';
@@ -34,6 +35,7 @@ export default class AllowIfUserOwnerPrivacyRule<
async evaluateAsync(
viewerContext: ExampleViewerContext,
_queryContext: EntityQueryContext,
_evaluationContext: EntityPrivacyPolicyEvaluationContext,
entity: TEntity
): Promise<RuleEvaluationResult> {
if (viewerContext.isUserViewerContext()) {
@@ -30,7 +30,7 @@ router.get('/', async (ctx: ExampleContext) => {
router.get('/:id', async (ctx: ExampleContext) => {
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) {
ctx.throw(403, noteResult.reason);
}
@@ -69,7 +69,7 @@ router.put('/:id', async (ctx: ExampleContext) => {
const viewerContext = ctx.state.viewerContext;
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) {
ctx.throw(403, noteLoadResult.reason);
}
@@ -90,7 +90,7 @@ router.put('/:id', async (ctx: ExampleContext) => {
router.delete('/:id', async (ctx: ExampleContext) => {
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) {
ctx.throw(403, noteLoadResult.reason);
}
@@ -3,6 +3,73 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.28.0](https://github.com/expo/entity/compare/v0.27.0...v0.28.0) (2022-09-13)
**Note:** Version bump only for package @expo/entity-full-integration-tests
# [0.27.0](https://github.com/expo/entity/compare/v0.26.1...v0.27.0) (2022-08-09)
**Note:** Version bump only for package @expo/entity-full-integration-tests
## [0.26.1](https://github.com/expo/entity/compare/v0.26.0...v0.26.1) (2022-07-28)
**Note:** Version bump only for package @expo/entity-full-integration-tests
# [0.26.0](https://github.com/expo/entity/compare/v0.25.3...v0.26.0) (2022-07-14)
### Features
* add EntityEdgeDeletionBehavior.SET_NULL_INVALIDATE_CACHE_ONLY ([#178](https://github.com/expo/entity/issues/178)) ([a0c35dd](https://github.com/expo/entity/commit/a0c35ddd7bca05d0c3f41dcf48e16d058918231c))
## [0.25.3](https://github.com/expo/entity/compare/v0.25.2...v0.25.3) (2022-03-12)
**Note:** Version bump only for package @expo/entity-full-integration-tests
## [0.25.2](https://github.com/expo/entity/compare/v0.25.1...v0.25.2) (2022-03-12)
**Note:** Version bump only for package @expo/entity-full-integration-tests
## [0.25.1](https://github.com/expo/entity/compare/v0.25.0...v0.25.1) (2022-03-10)
**Note:** Version bump only for package @expo/entity-full-integration-tests
# [0.25.0](https://github.com/expo/entity/compare/v0.24.0...v0.25.0) (2022-03-10)
**Note:** Version bump only for package @expo/entity-full-integration-tests
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
**Note:** Version bump only for package @expo/entity-full-integration-tests
@@ -1,7 +1,7 @@
{
"name": "@expo/entity-full-integration-tests",
"private": true,
"version": "0.24.0",
"version": "0.28.0",
"description": "Full redis and knex integration tests for the entity framework",
"scripts": {
"tsc": "tsc",
@@ -27,8 +27,8 @@
"@expo/entity-database-adapter-knex": "*"
},
"devDependencies": {
"@expo/entity": "^0.24.0",
"@expo/entity-cache-adapter-redis": "^0.24.0",
"@expo/entity-database-adapter-knex": "^0.24.0"
"@expo/entity": "^0.28.0",
"@expo/entity-cache-adapter-redis": "^0.28.0",
"@expo/entity-database-adapter-knex": "^0.28.0"
}
}
@@ -81,10 +81,8 @@ const testEntityCompanion = new EntityCompanionDefinition({
});
async function createOrTruncatePostgresTables(knex: Knex): Promise<void> {
await knex.raw('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"'); // for uuid_generate_v4()
await knex.schema.createTable('testentities', (table) => {
table.uuid('id').defaultTo(knex.raw('uuid_generate_v4()')).primary();
table.uuid('id').defaultTo(knex.raw('gen_random_uuid()')).primary();
table.string('other_string').notNullable();
table.string('third_string').notNullable();
});
@@ -92,11 +90,8 @@ async function createOrTruncatePostgresTables(knex: Knex): Promise<void> {
}
async function dropPostgresTable(knex: Knex): Promise<void> {
if (await knex.schema.hasTable('children')) {
await knex.schema.dropTable('children');
}
if (await knex.schema.hasTable('parents')) {
await knex.schema.dropTable('parents');
if (await knex.schema.hasTable('testentities')) {
await knex.schema.dropTable('testentities');
}
}
@@ -10,15 +10,13 @@ import ChildEntity from './entities/ChildEntity';
import ParentEntity from './entities/ParentEntity';
async function createOrTruncatePostgresTables(knex: Knex): Promise<void> {
await knex.raw('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"'); // for uuid_generate_v4()
await knex.schema.createTable('parents', (table) => {
table.uuid('id').defaultTo(knex.raw('uuid_generate_v4()')).primary();
table.uuid('id').defaultTo(knex.raw('gen_random_uuid()')).primary();
});
await knex.into('parents').truncate();
await knex.schema.createTable('children', (table) => {
table.uuid('id').defaultTo(knex.raw('uuid_generate_v4()')).primary();
table.uuid('id').defaultTo(knex.raw('gen_random_uuid()')).primary();
table.uuid('parent_id').references('id').inTable('parents').onDelete('cascade').unique();
});
await knex.into('children').truncate();
@@ -127,34 +127,46 @@ const makeEntityClasses = async (knex: Knex, edgeDeletionBehavior: EntityEdgeDel
privacyPolicyClass: PrivacyPolicy,
});
await knex.raw('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"'); // for uuid_generate_v4()
await knex.schema.createTable(categoriesTableName, (table) => {
table.uuid('id').defaultTo(knex.raw('uuid_generate_v4()')).primary();
table.uuid('id').defaultTo(knex.raw('gen_random_uuid()')).primary();
});
await knex.schema.createTable(othersTableName, (table) => {
table.uuid('id').defaultTo(knex.raw('uuid_generate_v4()')).primary();
if (edgeDeletionBehavior === EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE) {
table.uuid('id').defaultTo(knex.raw('gen_random_uuid()')).primary();
if (edgeDeletionBehavior === EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE_ONLY) {
table
.uuid('parent_category_id')
.references('id')
.inTable(categoriesTableName)
.unique()
.onDelete('cascade');
} else if (edgeDeletionBehavior === EntityEdgeDeletionBehavior.SET_NULL_INVALIDATE_CACHE_ONLY) {
table
.uuid('parent_category_id')
.references('id')
.inTable(categoriesTableName)
.unique()
.onDelete('set null');
} else {
table.uuid('parent_category_id').unique();
}
});
await knex.schema.alterTable(categoriesTableName, (table) => {
if (edgeDeletionBehavior === EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE) {
if (edgeDeletionBehavior === EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE_ONLY) {
table
.uuid('parent_other_id')
.references('id')
.inTable(othersTableName)
.unique()
.onDelete('cascade');
} else if (edgeDeletionBehavior === EntityEdgeDeletionBehavior.SET_NULL_INVALIDATE_CACHE_ONLY) {
table
.uuid('parent_other_id')
.references('id')
.inTable(othersTableName)
.unique()
.onDelete('set null');
} else {
table.uuid('parent_other_id').unique();
}
@@ -200,8 +212,9 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
it.each([
EntityEdgeDeletionBehavior.CASCADE_DELETE,
EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE,
EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE_ONLY,
EntityEdgeDeletionBehavior.SET_NULL,
EntityEdgeDeletionBehavior.SET_NULL_INVALIDATE_CACHE_ONLY,
])('behavior: %p', async (edgeDeletionBehavior) => {
const { CategoryEntity, OtherEntity } = await makeEntityClasses(
knexInstance,
@@ -230,6 +243,14 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
.enforcing()
.loadByIDNullableAsync(other1.getID());
expect(otherLoaded?.getField('parent_category_id')).toBeNull();
} else if (edgeDeletionBehavior === EntityEdgeDeletionBehavior.SET_NULL_INVALIDATE_CACHE_ONLY) {
await expect(
CategoryEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(category1.getID())
).resolves.toBeNull();
const otherLoaded = await OtherEntity.loader(viewerContext)
.enforcing()
.loadByIDNullableAsync(other1.getID());
expect(otherLoaded?.getField('parent_category_id')).toBeNull();
} else {
await expect(
CategoryEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(category1.getID())
@@ -56,7 +56,7 @@ const childEntityConfiguration = new EntityConfiguration<ChildFields>({
cache: true,
association: {
getAssociatedEntityClass: () => ParentEntity,
edgeDeletionBehavior: EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE,
edgeDeletionBehavior: EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE_ONLY,
},
}),
},
@@ -3,6 +3,70 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.28.0](https://github.com/expo/entity/compare/v0.27.0...v0.28.0) (2022-09-13)
**Note:** Version bump only for package @expo/entity-ip-address-field
# [0.27.0](https://github.com/expo/entity/compare/v0.26.1...v0.27.0) (2022-08-09)
**Note:** Version bump only for package @expo/entity-ip-address-field
## [0.26.1](https://github.com/expo/entity/compare/v0.26.0...v0.26.1) (2022-07-28)
**Note:** Version bump only for package @expo/entity-ip-address-field
# [0.26.0](https://github.com/expo/entity/compare/v0.25.3...v0.26.0) (2022-07-14)
**Note:** Version bump only for package @expo/entity-ip-address-field
## [0.25.3](https://github.com/expo/entity/compare/v0.25.2...v0.25.3) (2022-03-12)
**Note:** Version bump only for package @expo/entity-ip-address-field
## [0.25.2](https://github.com/expo/entity/compare/v0.25.1...v0.25.2) (2022-03-12)
**Note:** Version bump only for package @expo/entity-ip-address-field
## [0.25.1](https://github.com/expo/entity/compare/v0.25.0...v0.25.1) (2022-03-10)
**Note:** Version bump only for package @expo/entity-ip-address-field
# [0.25.0](https://github.com/expo/entity/compare/v0.24.0...v0.25.0) (2022-03-10)
**Note:** Version bump only for package @expo/entity-ip-address-field
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
**Note:** Version bump only for package @expo/entity-ip-address-field
@@ -1,6 +1,6 @@
{
"name": "@expo/entity-ip-address-field",
"version": "0.24.0",
"version": "0.28.0",
"description": "IP address EntityField definitions for @expo/entity",
"files": [
"build",
@@ -28,7 +28,7 @@
"ip-address": "^8.1.0"
},
"devDependencies": {
"@expo/entity": "^0.24.0",
"@expo/entity": "^0.28.0",
"@types/jsbn": "^1.2.29"
},
"peerDependencies": {
@@ -0,0 +1,67 @@
# Change Log
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.28.0](https://github.com/expo/entity/compare/v0.27.0...v0.28.0) (2022-09-13)
**Note:** Version bump only for package @expo/entity-secondary-cache-local-memory
# [0.27.0](https://github.com/expo/entity/compare/v0.26.1...v0.27.0) (2022-08-09)
**Note:** Version bump only for package @expo/entity-secondary-cache-local-memory
## [0.26.1](https://github.com/expo/entity/compare/v0.26.0...v0.26.1) (2022-07-28)
**Note:** Version bump only for package @expo/entity-secondary-cache-local-memory
# [0.26.0](https://github.com/expo/entity/compare/v0.25.3...v0.26.0) (2022-07-14)
**Note:** Version bump only for package @expo/entity-secondary-cache-local-memory
## [0.25.3](https://github.com/expo/entity/compare/v0.25.2...v0.25.3) (2022-03-12)
**Note:** Version bump only for package @expo/entity-secondary-cache-local-memory
## [0.25.2](https://github.com/expo/entity/compare/v0.25.1...v0.25.2) (2022-03-12)
**Note:** Version bump only for package @expo/entity-secondary-cache-local-memory
## [0.25.1](https://github.com/expo/entity/compare/v0.25.0...v0.25.1) (2022-03-10)
**Note:** Version bump only for package @expo/entity-secondary-cache-local-memory
# [0.25.0](https://github.com/expo/entity/compare/v0.24.0...v0.25.0) (2022-03-10)
### Features
* composed cache adapter, composed secondary cache, local memory secondary cache ([#164](https://github.com/expo/entity/issues/164)) ([6b6e70e](https://github.com/expo/entity/commit/6b6e70e75f7e42a4dce13edeb406a4d88d9264f0))
@@ -0,0 +1,24 @@
# `@expo/entity-secondary-cache-local-memory`
Cross-request [LRU](https://github.com/isaacs/node-lru-cache) secondary cache for `@expo/entity`. Use
this cache with caution - it is nonstandard. The cache is shared between requests in the node process.
[Documentation](https://expo.github.io/entity/modules/_expo_secondary_cache_local_memory.html)
## Usage
1. Create a concrete implementation of abstract class `EntitySecondaryCacheLoader`, in this example `TestEntitySecondaryCacheLoader`. The underlying data can come from anywhere, but an entity is constructed from the data and then authorized for the viewer.
2. Create an instance of your `EntitySecondaryCacheLoader`, passing in a `LocalMemorySecondaryEntityCache`.
```typescript
const secondaryCacheLoader = new TestSecondaryLocalMemoryCacheLoader(
new LocalMemorySecondaryEntityCache(
GenericLocalMemoryCacher.createLRUCache<LocalMemoryTestEntityFields>({})
),
LocalMemoryTestEntity.loader(viewerContext)
);
```
3. Load entities through it:
```typescript
const loadParams = { id: createdEntity.getID() };
const results = await secondaryCacheLoader.loadManyAsync([loadParams]);
```
@@ -0,0 +1,38 @@
{
"name": "@expo/entity-secondary-cache-local-memory",
"version": "0.28.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.28.0",
"@expo/entity-cache-adapter-local-memory": "^0.28.0",
"nullthrows": "^1.1.1"
}
}
@@ -0,0 +1,26 @@
import { EntityConfiguration, GenericSecondaryEntityCache } from '@expo/entity';
import {
GenericLocalMemoryCacher,
LocalMemoryCache,
} from '@expo/entity-cache-adapter-local-memory';
/**
* A local memory GenericSecondaryEntityCache.
*
* @remarks
*
* TLoadParams must be JSON stringifyable.
*/
export default class LocalMemorySecondaryEntityCache<
TFields,
TLoadParams
> extends GenericSecondaryEntityCache<TFields, TLoadParams> {
constructor(
entityConfiguration: EntityConfiguration<TFields>,
localMemoryCache: LocalMemoryCache<TFields>
) {
super(new GenericLocalMemoryCacher(entityConfiguration, localMemoryCache), (params) =>
JSON.stringify(params)
);
}
}
@@ -0,0 +1,230 @@
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(
localMemoryTestEntityConfiguration,
GenericLocalMemoryCacher.createLRUCache<LocalMemoryTestEntityFields>({})
),
LocalMemoryTestEntity.loader(viewerContext)
);
const loadParams = { id: createdEntity.getID() };
const results = await secondaryCacheLoader.loadManyAsync([loadParams]);
expect(nullthrows(results.get(loadParams)).enforceValue().getID()).toEqual(
createdEntity.getID()
);
expect(secondaryCacheLoader.databaseLoadCount).toEqual(1);
const results2 = await secondaryCacheLoader.loadManyAsync([loadParams]);
expect(nullthrows(results2.get(loadParams)).enforceValue().getID()).toEqual(
createdEntity.getID()
);
expect(secondaryCacheLoader.databaseLoadCount).toEqual(1);
await secondaryCacheLoader.invalidateManyAsync([loadParams]);
const results3 = await secondaryCacheLoader.loadManyAsync([loadParams]);
expect(nullthrows(results3.get(loadParams)).enforceValue().getID()).toEqual(
createdEntity.getID()
);
expect(secondaryCacheLoader.databaseLoadCount).toEqual(2);
});
it('correctly handles uncached and unfetchable load params', async () => {
const viewerContext = new TestViewerContext(createTestEntityCompanionProvider());
const secondaryCacheLoader = new TestSecondaryLocalMemoryCacheLoader(
new LocalMemorySecondaryEntityCache(
localMemoryTestEntityConfiguration,
GenericLocalMemoryCacher.createLRUCache<LocalMemoryTestEntityFields>({})
),
LocalMemoryTestEntity.loader(viewerContext)
);
const loadParams = { id: FAKE_ID };
const results = await secondaryCacheLoader.loadManyAsync([loadParams]);
expect(results.size).toBe(1);
expect(results.get(loadParams)).toBe(null);
});
});
@@ -0,0 +1,7 @@
/* eslint-disable tsdoc/syntax */
/**
* @packageDocumentation
* @module @expo/entity-secondary-cache-local-memory
*/
export { default as LocalMemorySecondaryEntityCache } from './LocalMemorySecondaryEntityCache';
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "build",
},
"include": ["src"]
}
@@ -3,6 +3,76 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.28.0](https://github.com/expo/entity/compare/v0.27.0...v0.28.0) (2022-09-13)
**Note:** Version bump only for package @expo/entity-secondary-cache-redis
# [0.27.0](https://github.com/expo/entity/compare/v0.26.1...v0.27.0) (2022-08-09)
**Note:** Version bump only for package @expo/entity-secondary-cache-redis
## [0.26.1](https://github.com/expo/entity/compare/v0.26.0...v0.26.1) (2022-07-28)
**Note:** Version bump only for package @expo/entity-secondary-cache-redis
# [0.26.0](https://github.com/expo/entity/compare/v0.25.3...v0.26.0) (2022-07-14)
### Features
* update ioredis to v5, make it a devDependency of the cache packages ([#172](https://github.com/expo/entity/issues/172)) ([9fcfbf7](https://github.com/expo/entity/commit/9fcfbf746a61554cefedf7fb0f1a3a9056b58ffe))
## [0.25.3](https://github.com/expo/entity/compare/v0.25.2...v0.25.3) (2022-03-12)
**Note:** Version bump only for package @expo/entity-secondary-cache-redis
## [0.25.2](https://github.com/expo/entity/compare/v0.25.1...v0.25.2) (2022-03-12)
**Note:** Version bump only for package @expo/entity-secondary-cache-redis
## [0.25.1](https://github.com/expo/entity/compare/v0.25.0...v0.25.1) (2022-03-10)
**Note:** Version bump only for package @expo/entity-secondary-cache-redis
# [0.25.0](https://github.com/expo/entity/compare/v0.24.0...v0.25.0) (2022-03-10)
### Features
* composed cache adapter, composed secondary cache, local memory secondary cache ([#164](https://github.com/expo/entity/issues/164)) ([6b6e70e](https://github.com/expo/entity/commit/6b6e70e75f7e42a4dce13edeb406a4d88d9264f0))
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
### Chores
@@ -1,6 +1,6 @@
{
"name": "@expo/entity-secondary-cache-redis",
"version": "0.24.0",
"version": "0.28.0",
"description": "Redis secondary cache for @expo/entity",
"files": [
"build",
@@ -30,12 +30,10 @@
"@expo/entity": "*",
"@expo/entity-cache-adapter-redis": "*"
},
"dependencies": {
"ioredis": "^4.17.3"
},
"devDependencies": {
"@expo/entity": "^0.24.0",
"@expo/entity-cache-adapter-redis": "^0.24.0",
"@expo/entity": "^0.28.0",
"@expo/entity-cache-adapter-redis": "^0.28.0",
"ioredis": "^5.0.4",
"nullthrows": "^1.1.1"
}
}
@@ -1,10 +1,8 @@
import { EntityConfiguration, GenericSecondaryEntityCache } from '@expo/entity';
import { GenericRedisCacheContext, GenericRedisCacher } from '@expo/entity-cache-adapter-redis';
import { RedisCacheAdapterContext, GenericRedisCacher } from '@expo/entity-cache-adapter-redis';
/**
* A custom secondary read-through entity cache is a way to add a custom second layer of caching for a particular
* single entity load. One common way this may be used is to add a second layer of caching in a hot path that makes
* a call to {@link EntityLoader.loadManyByFieldEqualityConjunctionAsync} is guaranteed to return at most one entity.
* A redis GenericSecondaryEntityCache.
*/
export default class RedisSecondaryEntityCache<
TFields,
@@ -12,7 +10,7 @@ export default class RedisSecondaryEntityCache<
> extends GenericSecondaryEntityCache<TFields, TLoadParams> {
constructor(
entityConfiguration: EntityConfiguration<TFields>,
genericRedisCacheContext: GenericRedisCacheContext,
genericRedisCacheContext: RedisCacheAdapterContext,
constructRedisKey: (params: Readonly<TLoadParams>) => string
) {
super(new GenericRedisCacher(genericRedisCacheContext, entityConfiguration), constructRedisKey);
+84
View File
@@ -3,6 +3,90 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.28.0](https://github.com/expo/entity/compare/v0.27.0...v0.28.0) (2022-09-13)
### Features
* add BigIntField ([#193](https://github.com/expo/entity/issues/193)) ([acbad82](https://github.com/expo/entity/commit/acbad826a37bb5a054b4dcf9be91d11aa5f7f29a))
* nested transactions ([#194](https://github.com/expo/entity/issues/194)) ([a77b914](https://github.com/expo/entity/commit/a77b914ad6dc018d1e309c9caf4971088a553ecf))
# [0.27.0](https://github.com/expo/entity/compare/v0.26.1...v0.27.0) (2022-08-09)
### Features
* add orderByRaw to loadManyByRawWhereClauseAsync ([#185](https://github.com/expo/entity/issues/185)) ([2817d78](https://github.com/expo/entity/commit/2817d78392dfecd0093111c24d581bdf01dba06d))
## [0.26.1](https://github.com/expo/entity/compare/v0.26.0...v0.26.1) (2022-07-28)
### Features
* recache to higher cache levels when loading from a composed cache ([#182](https://github.com/expo/entity/pull/182))
# [0.26.0](https://github.com/expo/entity/compare/v0.25.3...v0.26.0) (2022-07-14)
### Features
* add EntityEdgeDeletionBehavior.SET_NULL_INVALIDATE_CACHE_ONLY ([#178](https://github.com/expo/entity/issues/178)) ([a0c35dd](https://github.com/expo/entity/commit/a0c35ddd7bca05d0c3f41dcf48e16d058918231c))
## [0.25.3](https://github.com/expo/entity/compare/v0.25.2...v0.25.3) (2022-03-12)
**Note:** Version bump only for package @expo/entity
## [0.25.2](https://github.com/expo/entity/compare/v0.25.1...v0.25.2) (2022-03-12)
**Note:** Version bump only for package @expo/entity
## [0.25.1](https://github.com/expo/entity/compare/v0.25.0...v0.25.1) (2022-03-10)
### Bug Fixes
* add composed cache to index ([#166](https://github.com/expo/entity/issues/166)) ([5c82edc](https://github.com/expo/entity/commit/5c82edcd9f26854878d29950a05852e117bbd723))
# [0.25.0](https://github.com/expo/entity/compare/v0.24.0...v0.25.0) (2022-03-10)
### Features
* composed cache adapter, composed secondary cache, local memory secondary cache ([#164](https://github.com/expo/entity/issues/164)) ([6b6e70e](https://github.com/expo/entity/commit/6b6e70e75f7e42a4dce13edeb406a4d88d9264f0))
* privacy policy evaluation context ([#162](https://github.com/expo/entity/issues/162)) ([3a81a4c](https://github.com/expo/entity/commit/3a81a4c059e2e995eb800e35e76b3d432c22b23f))
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
**Note:** Version bump only for package @expo/entity
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/entity",
"version": "0.24.0",
"version": "0.28.0",
"description": "A privacy-first data model",
"files": [
"build",
@@ -0,0 +1,120 @@
import nullthrows from 'nullthrows';
import IEntityCacheAdapter from './IEntityCacheAdapter';
import { CacheStatus, CacheLoadResult } from './internal/ReadThroughEntityCache';
/**
* An IEntityCacheAdapter that composes other IEntityCacheAdapter instances.
*/
export default class ComposedEntityCacheAdapter<TFields> implements IEntityCacheAdapter<TFields> {
/**
* @param cacheAdapters - list of cache adapters to compose in order of precedence.
* Earlier cache adapters are read from first and written to (including invalidations) last.
* Typically, caches closer to the application should be ordered before caches closer to the database.
* A lower layer cache is closer to the database, while a higher layer cache is closer to the application.
*/
constructor(private readonly cacheAdapters: IEntityCacheAdapter<TFields>[]) {}
public async loadManyAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult<TFields>>> {
const retMap = new Map<NonNullable<TFields[N]>, CacheLoadResult<TFields>>();
const fulfilledFieldValuesByCacheIndex: NonNullable<TFields[N]>[][] = Array.from(
{ length: this.cacheAdapters.length },
() => []
);
let unfulfilledFieldValues = fieldValues;
for (let i = 0; i < this.cacheAdapters.length; i++) {
const cacheAdapter = nullthrows(this.cacheAdapters[i]);
const cacheResultsFromAdapter = await cacheAdapter.loadManyAsync(
fieldName,
unfulfilledFieldValues
);
const newUnfulfilledFieldValues = [];
for (const [fieldValue, cacheResult] of cacheResultsFromAdapter) {
if (cacheResult.status === CacheStatus.MISS) {
newUnfulfilledFieldValues.push(fieldValue);
} else {
retMap.set(fieldValue, cacheResult);
nullthrows(fulfilledFieldValuesByCacheIndex[i]).push(fieldValue);
}
}
unfulfilledFieldValues = newUnfulfilledFieldValues;
if (unfulfilledFieldValues.length === 0) {
break;
}
}
// Recache values from lower layers that were not found in higher layers
// Write to lower layers first
for (let i = this.cacheAdapters.length - 1; i >= 0; i--) {
const cacheAdapter = nullthrows(this.cacheAdapters[i]);
const hitsToCache = new Map<NonNullable<TFields[N]>, Readonly<TFields>>();
const negativesToCache: NonNullable<TFields[N]>[] = [];
// Loop over all lower layer caches to collect hits and misses
for (let j = i + 1; j < this.cacheAdapters.length; j++) {
const fulfilledFieldValues = nullthrows(fulfilledFieldValuesByCacheIndex[j]);
fulfilledFieldValues.forEach((fieldValue) => {
const cacheResult = nullthrows(retMap.get(fieldValue));
if (cacheResult.status === CacheStatus.HIT) {
hitsToCache.set(fieldValue, cacheResult.item);
} else if (cacheResult.status === CacheStatus.NEGATIVE) {
negativesToCache.push(fieldValue);
}
});
}
const promises = [];
if (hitsToCache.size > 0) {
promises.push(cacheAdapter.cacheManyAsync(fieldName, hitsToCache));
}
if (negativesToCache.length > 0) {
promises.push(cacheAdapter.cacheDBMissesAsync(fieldName, negativesToCache));
}
await Promise.all(promises);
}
for (const fieldValue of unfulfilledFieldValues) {
retMap.set(fieldValue, { status: CacheStatus.MISS });
}
return retMap;
}
public async cacheManyAsync<N extends keyof TFields>(
fieldName: N,
objectMap: ReadonlyMap<NonNullable<TFields[N]>, Readonly<TFields>>
): Promise<void> {
// write to lower layers first
for (let i = this.cacheAdapters.length - 1; i >= 0; i--) {
const cacheAdapter = nullthrows(this.cacheAdapters[i]);
await cacheAdapter.cacheManyAsync(fieldName, objectMap);
}
}
public async cacheDBMissesAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<void> {
// write to lower layers first
for (let i = this.cacheAdapters.length - 1; i >= 0; i--) {
const cacheAdapter = nullthrows(this.cacheAdapters[i]);
await cacheAdapter.cacheDBMissesAsync(fieldName, fieldValues);
}
}
public async invalidateManyAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<void> {
// delete from lower layers first
for (let i = this.cacheAdapters.length - 1; i >= 0; i--) {
const cacheAdapter = nullthrows(this.cacheAdapters[i]);
await cacheAdapter.invalidateManyAsync(fieldName, fieldValues);
}
}
}
@@ -0,0 +1,64 @@
import nullthrows from 'nullthrows';
import { ISecondaryEntityCache } from './EntitySecondaryCacheLoader';
/**
* A ISecondaryEntityCache that composes other ISecondaryEntityCache instances.
*/
export default class ComposedSecondaryEntityCache<TLoadParams, TFields>
implements ISecondaryEntityCache<TFields, TLoadParams>
{
/**
* @param secondaryEntityCaches - list of caches to compose in order of precedence.
* Earlier caches are read from first and written to (including invalidations) last.
* Typically, caches closer to the application should be ordered before caches closer to the database.
*/
constructor(
private readonly secondaryEntityCaches: ISecondaryEntityCache<TFields, TLoadParams>[]
) {}
async loadManyThroughAsync(
loadParamsArray: readonly Readonly<TLoadParams>[],
fetcher: (
fetcherLoadParamsArray: readonly Readonly<TLoadParams>[]
) => Promise<ReadonlyMap<Readonly<TLoadParams>, Readonly<TFields> | null>>
): Promise<ReadonlyMap<Readonly<TLoadParams>, Readonly<TFields> | null>> {
return await ComposedSecondaryEntityCache.loadManyThroughRecursivelyAsync(
this.secondaryEntityCaches,
loadParamsArray,
fetcher
);
}
private static async loadManyThroughRecursivelyAsync<TLoadParams, TFields>(
secondaryEntityCaches: ISecondaryEntityCache<TFields, TLoadParams>[],
loadParamsArray: readonly Readonly<TLoadParams>[],
fetcher: (
fetcherLoadParamsArray: readonly Readonly<TLoadParams>[]
) => Promise<ReadonlyMap<Readonly<TLoadParams>, Readonly<TFields> | null>>
): Promise<ReadonlyMap<Readonly<TLoadParams>, Readonly<TFields> | null>> {
if (secondaryEntityCaches.length === 0) {
return await fetcher(loadParamsArray);
}
const [firstCache, ...restCaches] = secondaryEntityCaches;
return await nullthrows(firstCache).loadManyThroughAsync(
loadParamsArray,
(fetcherLoadParamsArray) =>
ComposedSecondaryEntityCache.loadManyThroughRecursivelyAsync(
restCaches,
fetcherLoadParamsArray,
fetcher
)
);
}
async invalidateManyAsync(loadParamsArray: readonly Readonly<TLoadParams>[]): Promise<void> {
// invalidate lower layers first
for (let i = this.secondaryEntityCaches.length - 1; i >= 0; i--) {
const secondaryEntityCache = nullthrows(this.secondaryEntityCaches[i]);
await secondaryEntityCache.invalidateManyAsync(loadParamsArray);
}
}
}
+14 -10
View File
@@ -1,4 +1,8 @@
import { FieldEqualityCondition, QuerySelectionModifiers } from './EntityDatabaseAdapter';
import {
FieldEqualityCondition,
QuerySelectionModifiers,
QuerySelectionModifiersWithOrderByRaw,
} from './EntityDatabaseAdapter';
import EntityLoader from './EntityLoader';
import EntityPrivacyPolicy from './EntityPrivacyPolicy';
import ReadonlyEntity from './ReadonlyEntity';
@@ -36,7 +40,7 @@ export default class EnforcingEntityLoader<
/**
* Enforcing version of entity loader method by the same name.
* @throws {@link EntityNotAuthorizedError} when viewer is not authorized to view one or more of the returned entities
* @throws EntityNotAuthorizedError when viewer is not authorized to view one or more of the returned entities
*/
async loadManyByFieldEqualingManyAsync<N extends keyof Pick<TFields, TSelectedFields>>(
fieldName: N,
@@ -53,7 +57,7 @@ export default class EnforcingEntityLoader<
/**
* Enforcing version of entity loader method by the same name.
* @throws {@link EntityNotAuthorizedError} when viewer is not authorized to view one or more of the returned entities
* @throws EntityNotAuthorizedError when viewer is not authorized to view one or more of the returned entities
*/
async loadManyByFieldEqualingAsync<N extends keyof Pick<TFields, TSelectedFields>>(
fieldName: N,
@@ -68,7 +72,7 @@ export default class EnforcingEntityLoader<
/**
* Enforcing version of entity loader method by the same name.
* @throws {@link EntityNotAuthorizedError} when viewer is not authorized to view the returned entity
* @throws EntityNotAuthorizedError when viewer is not authorized to view the returned entity
* @throws when multiple entities are found matching the condition
*/
async loadByFieldEqualingAsync<N extends keyof Pick<TFields, TSelectedFields>>(
@@ -84,7 +88,7 @@ export default class EnforcingEntityLoader<
/**
* Enforcing version of entity loader method by the same name.
* @throws {@link EntityNotAuthorizedError} when viewer is not authorized to view the returned entity
* @throws EntityNotAuthorizedError when viewer is not authorized to view the returned entity
*/
async loadByIDAsync(id: TID): Promise<TEntity> {
const entityResult = await this.entityLoader.loadByIDAsync(id);
@@ -93,7 +97,7 @@ export default class EnforcingEntityLoader<
/**
* Enforcing version of entity loader method by the same name.
* @throws {@link EntityNotAuthorizedError} when viewer is not authorized to view the returned entity
* @throws EntityNotAuthorizedError when viewer is not authorized to view the returned entity
* @throws when multiple entities are found matching the condition
*/
async loadByIDNullableAsync(id: TID): Promise<TEntity | null> {
@@ -103,7 +107,7 @@ export default class EnforcingEntityLoader<
/**
* Enforcing version of entity loader method by the same name.
* @throws {@link EntityNotAuthorizedError} when viewer is not authorized to view one or more of the returned entities
* @throws EntityNotAuthorizedError when viewer is not authorized to view one or more of the returned entities
*/
async loadManyByIDsAsync(ids: readonly TID[]): Promise<ReadonlyMap<TID, TEntity>> {
const entityResults = await this.entityLoader.loadManyByIDsAsync(ids);
@@ -112,7 +116,7 @@ export default class EnforcingEntityLoader<
/**
* Enforcing version of entity loader method by the same name.
* @throws {@link EntityNotAuthorizedError} when viewer is not authorized to view one or more of the returned entities
* @throws EntityNotAuthorizedError when viewer is not authorized to view one or more of the returned entities
*/
async loadManyByFieldEqualityConjunctionAsync<N extends keyof Pick<TFields, TSelectedFields>>(
fieldEqualityOperands: FieldEqualityCondition<TFields, N>[],
@@ -127,12 +131,12 @@ export default class EnforcingEntityLoader<
/**
* Enforcing version of entity loader method by the same name.
* @throws {@link EntityNotAuthorizedError} when viewer is not authorized to view one or more of the returned entities
* @throws EntityNotAuthorizedError when viewer is not authorized to view one or more of the returned entities
*/
async loadManyByRawWhereClauseAsync(
rawWhereClause: string,
bindings: any[] | object,
querySelectionModifiers: QuerySelectionModifiers<TFields> = {}
querySelectionModifiers: QuerySelectionModifiersWithOrderByRaw<TFields> = {}
): Promise<readonly TEntity[]> {
const entityResults = await this.entityLoader.loadManyByRawWhereClauseAsync(
rawWhereClause,
+7 -5
View File
@@ -11,19 +11,19 @@ import ViewerContext from './ViewerContext';
* Entity is a privacy-first data model.
*
* A instance of an entity represents a single "row" of persisted data in a database that a
* viewer, represented by the corresponding {@link ViewerContext}, has permission to read.
* viewer, represented by the corresponding ViewerContext, has permission to read.
*
* Create, read, update, and delete permissions for an entity are declaratively defined using an
* {@link EntityPrivacyPolicy}.
* EntityPrivacyPolicy.
*
* Entites are loaded through an {@link EntityLoader}, which is responsible for
* Entites are loaded through an EntityLoader, which is responsible for
* orchestrating fetching, caching, and authorization of reading "rows".
*
* Entities are mutated and deleted through an {@link EntityMutator}, which is responsible for
* Entities are mutated and deleted through an EntityMutator, which is responsible for
* orchestrating database writes, cache invalidation, and authorization of writing "rows".
*
* All concrete entity implementations should extend this class and provide their
* own {@link EntityCompanionDefinition}.
* own EntityCompanionDefinition.
*/
export default abstract class Entity<
TFields,
@@ -251,6 +251,7 @@ export default abstract class Entity<
privacyPolicy.authorizeUpdateAsync(
existingEntity.getViewerContext(),
queryContext,
{ cascadingDeleteCause: null },
existingEntity,
companion.getMetricsAdapter()
)
@@ -304,6 +305,7 @@ export default abstract class Entity<
privacyPolicy.authorizeDeleteAsync(
existingEntity.getViewerContext(),
queryContext,
{ cascadingDeleteCause: null },
existingEntity,
companion.getMetricsAdapter()
)
+13 -8
View File
@@ -6,6 +6,11 @@ import { EntityQueryContext } from './EntityQueryContext';
import ReadonlyEntity from './ReadonlyEntity';
import ViewerContext from './ViewerContext';
/**
* An association loader is a set of convenience methods for loading entities
* associated with an entity. In relational databases, these entities are often referenced
* by foreign keys.
*/
export default class EntityAssociationLoader<
TFields,
TID extends NonNullable<TFields[TSelectedFields]>,
@@ -69,7 +74,7 @@ export default class EntityAssociationLoader<
.getViewerContext()
.getViewerScopedEntityCompanionForClass(associatedEntityClass)
.getLoaderFactory()
.forLoad(queryContext);
.forLoad(queryContext, { cascadingDeleteCause: null });
return (await loader.loadByIDAsync(associatedEntityID as unknown as TAssociatedID)) as Result<
null extends TFields[TIdentifyingField] ? TAssociatedEntity | null : TAssociatedEntity
@@ -123,7 +128,7 @@ export default class EntityAssociationLoader<
.getViewerContext()
.getViewerScopedEntityCompanionForClass(associatedEntityClass)
.getLoaderFactory()
.forLoad(queryContext);
.forLoad(queryContext, { cascadingDeleteCause: null });
return await loader.loadManyByFieldEqualingAsync(
associatedEntityFieldContainingThisID,
thisID as any
@@ -180,7 +185,7 @@ export default class EntityAssociationLoader<
.getViewerContext()
.getViewerScopedEntityCompanionForClass(associatedEntityClass)
.getLoaderFactory()
.forLoad(queryContext);
.forLoad(queryContext, { cascadingDeleteCause: null });
return await loader.loadByFieldEqualingAsync(
associatedEntityLookupByField,
associatedFieldValue as any
@@ -238,7 +243,7 @@ export default class EntityAssociationLoader<
.getViewerContext()
.getViewerScopedEntityCompanionForClass(associatedEntityClass)
.getLoaderFactory()
.forLoad(queryContext);
.forLoad(queryContext, { cascadingDeleteCause: null });
return await loader.loadManyByFieldEqualingAsync(
associatedEntityLookupByField,
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.
* @param loadDirectives - associated entity load directives instructing each step of the fold
* @param queryContext - query context in which to perform the loads
@@ -280,7 +285,7 @@ export default class EntityAssociationLoader<
): Promise<Result<TEntity2> | null>;
/**
* Load an associated entity by folding a sequence of {@link EntityLoadThroughDirective}. At each
* Load an associated entity by folding a sequence of EntityLoadThroughDirective. At each
* fold step, load an associated entity identified by a field value of the current fold value.
* @param loadDirectives - associated entity load directives instructing each step of the fold
* @param queryContext - query context in which to perform the loads
@@ -335,7 +340,7 @@ export default class EntityAssociationLoader<
): Promise<Result<TEntity3> | null>;
/**
* Load an associated entity by folding a sequence of {@link EntityLoadThroughDirective}. At each
* Load an associated entity by folding a sequence of EntityLoadThroughDirective. At each
* fold step, load an associated entity identified by a field value of the current fold value.
* @param loadDirectives - associated entity load directives instructing each step of the fold
* @param queryContext - query context in which to perform the loads
@@ -411,7 +416,7 @@ export default class EntityAssociationLoader<
): Promise<Result<TEntity4> | null>;
/**
* Load an associated entity by folding a sequence of {@link EntityLoadThroughDirective}. At each
* Load an associated entity by folding a sequence of EntityLoadThroughDirective. At each
* fold step, load an associated entity identified by a field value of the current fold value.
* @param loadDirectives - associated entity load directives instructing each step of the fold
* @param queryContext - query context in which to perform the loads
+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 {
return this.metricsAdapter;
@@ -109,15 +109,15 @@ export class EntityCompanionDefinition<
TSelectedFields
>;
/**
* The {@link EntityConfiguration} for this entity.
* The EntityConfiguration for this entity.
*/
entityConfiguration: EntityConfiguration<TFields>;
/**
* The {@link EntityPrivacyPolicy} class for this entity.
* The EntityPrivacyPolicy class for this entity.
*/
privacyPolicyClass: IPrivacyPolicyClass<TPrivacyPolicy>;
/**
* An optional list of {@link EntityMutationValidator} for this entity.
* An optional list of EntityMutationValidator for this entity.
*/
mutationValidators?: () => EntityMutationValidator<
TFields,
@@ -127,7 +127,7 @@ export class EntityCompanionDefinition<
TSelectedFields
>[];
/**
* An optional list of {@link EntityMutationTrigger} for this entity.
* An optional list of EntityMutationTrigger for this entity.
*/
mutationTriggers?: () => EntityMutationTriggerConfiguration<
TFields,
@@ -137,8 +137,8 @@ export class EntityCompanionDefinition<
TSelectedFields
>;
/**
* An optional subset of fields defined in the {@link EntityConfiguration} which belong to this entity.
* For use when multiple types of entities are backed by a single table ({@link EntityConfiguration}) yet
* An optional subset of fields defined in the EntityConfiguration which belong to this entity.
* For use when multiple types of entities are backed by a single table (EntityConfiguration) yet
* only expose a subset of the fields.
*/
entitySelectedFields?: TSelectedFields[];
@@ -155,11 +155,11 @@ export class EntityCompanionDefinition<
/**
* An instance of the Entity framework.
*
* Required to create a {@link ViewerContext}, which is the application entry point
* Required to create a ViewerContext, which is the application entry point
* into the framework.
*
* Internally, this is a lazy entity companion factory that instantiates and caches one
* {@link EntityCompanion} for each type of {@link Entity}.
* EntityCompanion for each type of Entity.
*/
export default class EntityCompanionProvider {
private readonly companionMap: Map<string, EntityCompanion<any, any, any, any, any, any>> =
@@ -169,7 +169,7 @@ export default class EntityCompanionProvider {
/**
* Instantiate an Entity framework.
* @param metricsAdapter - An {@link IEntityMetricsAdapter} for collecting metrics on this instance
* @param metricsAdapter - An IEntityMetricsAdapter for collecting metrics on this instance
* @param databaseAdapterFlavors - Database adapter configurations for this instance
* @param cacheAdapterFlavors - Cache adapter configurations for this instance
*/
+2 -2
View File
@@ -41,12 +41,12 @@ export default class EntityConfiguration<TFields> {
tableName: string;
/**
* Map from each entity field to an {@link EntityFieldDefinition} specifying information about the field.
* Map from each entity field to an EntityFieldDefinition specifying information about the field.
*/
schema: Record<keyof TFields, EntityFieldDefinition<any>>;
/**
* List of other entity types that reference this type in {@link EntityFieldDefinition} associations.
* List of other entity types that reference this type in EntityFieldDefinition associations.
*/
getInboundEdges?: () => IEntityClass<any, any, any, any, any, any>[];
+43 -6
View File
@@ -7,16 +7,32 @@ import {
FieldTransformerMap,
} from './internal/EntityFieldTransformationUtils';
interface SingleValueFieldEqualityCondition<TFields, N extends keyof TFields = keyof TFields> {
/**
* Equality operand that is used for selecting entities with a field with a single value.
*/
export interface SingleValueFieldEqualityCondition<
TFields,
N extends keyof TFields = keyof TFields
> {
fieldName: N;
fieldValue: TFields[N];
}
interface MultiValueFieldEqualityCondition<TFields, N extends keyof TFields = keyof TFields> {
/**
* Equality operand that is used for selecting entities with a field matching one of multiple values.
*/
export interface MultiValueFieldEqualityCondition<
TFields,
N extends keyof TFields = keyof TFields
> {
fieldName: N;
fieldValues: readonly TFields[N][];
}
/**
* A single equality operand for use in a selection clause.
* See EntityLoader.loadManyByFieldEqualityConjunctionAsync documentation for examples.
*/
export type FieldEqualityCondition<TFields, N extends keyof TFields = keyof TFields> =
| SingleValueFieldEqualityCondition<TFields, N>
| MultiValueFieldEqualityCondition<TFields, N>;
@@ -68,6 +84,14 @@ export interface QuerySelectionModifiers<TFields> {
limit?: number;
}
export interface QuerySelectionModifiersWithOrderByRaw<TFields>
extends QuerySelectionModifiers<TFields> {
/**
* Order the entities by a raw SQL `ORDER BY` clause.
*/
orderByRaw?: string;
}
export interface TableQuerySelectionModifiers {
orderBy:
| {
@@ -79,6 +103,10 @@ export interface TableQuerySelectionModifiers {
limit: number | undefined;
}
export interface TableQuerySelectionModifiersWithOrderByRaw extends TableQuerySelectionModifiers {
orderByRaw: string | undefined;
}
/**
* A database adapter is an interface by which entity objects can be
* fetched, inserted, updated, and deleted from a database. This base class
@@ -149,7 +177,7 @@ export default abstract class EntityDatabaseAdapter<TFields> {
*
* @param queryContext - query context with which to perform the fetch
* @param 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
*/
async fetchManyByFieldEqualityConjunctionAsync<N extends keyof TFields>(
@@ -207,14 +235,14 @@ export default abstract class EntityDatabaseAdapter<TFields> {
queryContext: EntityQueryContext,
rawWhereClause: string,
bindings: any[] | object,
querySelectionModifiers: QuerySelectionModifiers<TFields>
querySelectionModifiers: QuerySelectionModifiersWithOrderByRaw<TFields>
): Promise<readonly Readonly<TFields>[]> {
const results = await this.fetchManyByRawWhereClauseInternalAsync(
queryContext.getQueryInterface(),
this.entityConfiguration.tableName,
rawWhereClause,
bindings,
this.convertToTableQueryModifiers(querySelectionModifiers)
this.convertToTableQueryModifiersWithOrderByRaw(querySelectionModifiers)
);
return results.map((result) =>
@@ -227,7 +255,7 @@ export default abstract class EntityDatabaseAdapter<TFields> {
tableName: string,
rawWhereClause: string,
bindings: any[] | object,
querySelectionModifiers: TableQuerySelectionModifiers
querySelectionModifiers: TableQuerySelectionModifiersWithOrderByRaw
): Promise<object[]>;
/**
@@ -363,6 +391,15 @@ export default abstract class EntityDatabaseAdapter<TFields> {
id: any
): Promise<number>;
private convertToTableQueryModifiersWithOrderByRaw(
querySelectionModifiers: QuerySelectionModifiersWithOrderByRaw<TFields>
): TableQuerySelectionModifiersWithOrderByRaw {
return {
...this.convertToTableQueryModifiers(querySelectionModifiers),
orderByRaw: querySelectionModifiers.orderByRaw,
};
}
private convertToTableQueryModifiers(
querySelectionModifiers: QuerySelectionModifiers<TFields>
): TableQuerySelectionModifiers {
+15 -6
View File
@@ -5,13 +5,21 @@ import ViewerContext from './ViewerContext';
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.
* 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.
*/
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
@@ -74,16 +82,17 @@ export interface EntityAssociationDefinition<
*
* @remarks
* The entity framework doesn't prescribe a one-size-fits-all solution for referential
* integrity; instead it exposes mechanisms that supports both database foreign key constraints
* 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
* application requirements, and sometimes even a mix-and-match is the right choice.
*
* - If referential integrity is critical to your application, database foreign key constraints
* combined with {@link EntityEdgeDeletionBehavior.CASACDE_DELETE_INVALIDATE_CACHE} are recommended.
* 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
* integrity is recommended.
*/
edgeDeletionBehavior?: EntityEdgeDeletionBehavior;
edgeDeletionBehavior: EntityEdgeDeletionBehavior;
}
/**
+9 -28
View File
@@ -3,7 +3,7 @@ import { validate as validateUUID } from 'uuid';
import { EntityFieldDefinition } from './EntityFieldDefinition';
/**
* {@link EntityFieldDefinition} for a column with a JS string type.
* EntityFieldDefinition for a column with a JS string type.
*/
export class StringField extends EntityFieldDefinition<string> {
protected validateInputValueInternal(value: string): boolean {
@@ -12,7 +12,7 @@ export class StringField extends EntityFieldDefinition<string> {
}
/**
* {@link EntityFieldDefinition} for a column with a JS string type.
* EntityFieldDefinition for a column with a JS string type.
* Enforces that the string is a valid UUID.
*/
export class UUIDField extends StringField {
@@ -22,7 +22,7 @@ export class UUIDField extends StringField {
}
/**
* {@link EntityFieldDefinition} for a column with a JS Date type.
* EntityFieldDefinition for a column with a JS Date type.
*/
export class DateField extends EntityFieldDefinition<Date> {
protected validateInputValueInternal(value: Date): boolean {
@@ -31,7 +31,7 @@ export class DateField extends EntityFieldDefinition<Date> {
}
/**
* {@link EntityFieldDefinition} for a column with a JS boolean type.
* EntityFieldDefinition for a column with a JS boolean type.
*/
export class BooleanField extends EntityFieldDefinition<boolean> {
protected validateInputValueInternal(value: boolean): boolean {
@@ -40,7 +40,7 @@ export class BooleanField extends EntityFieldDefinition<boolean> {
}
/**
* {@link EntityFieldDefinition} for a column with a JS number type.
* EntityFieldDefinition for a column with a JS number type.
* Enforces that the number is an integer.
*/
export class IntField extends EntityFieldDefinition<number> {
@@ -50,7 +50,7 @@ export class IntField extends EntityFieldDefinition<number> {
}
/**
* {@link EntityFieldDefinition} for a column with a JS number type.
* 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> {
@@ -60,7 +60,7 @@ export class FloatField extends EntityFieldDefinition<number> {
}
/**
* {@link EntityFieldDefinition} for a column with a JS string array type.
* 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[]> {
@@ -70,7 +70,7 @@ export class StringArrayField extends EntityFieldDefinition<string[]> {
}
/**
* {@link EntityFieldDefinition} for a column with a JS JSON object type.
* EntityFieldDefinition for a column with a JS JSON object type.
*/
export class JSONObjectField extends EntityFieldDefinition<object> {
protected validateInputValueInternal(value: object): boolean {
@@ -79,29 +79,10 @@ export class JSONObjectField extends EntityFieldDefinition<object> {
}
/**
* {@link EntityFieldDefinition} for a enum column with a JS string or number type.
* EntityFieldDefinition for a enum column with a JS string or number type.
*/
export class EnumField extends EntityFieldDefinition<string | number> {
protected validateInputValueInternal(value: string | number): boolean {
return typeof value === 'number' || typeof value === 'string';
}
}
/**
* {@link EntityFieldDefinition} for a column with a JS JSON array type.
*/
export class JSONArrayField extends EntityFieldDefinition<any[]> {
protected validateInputValueInternal(value: any[]): boolean {
return Array.isArray(value);
}
}
/**
* {@link EntityFieldDefinition} for a column that may be a JS JSON array type.
* Does not do any validation.
*/
export class MaybeJSONArrayField extends EntityFieldDefinition<any | any[]> {
protected validateInputValueInternal(_value: any): boolean {
return true;
}
}
+19 -12
View File
@@ -8,8 +8,9 @@ import {
FieldEqualityCondition,
QuerySelectionModifiers,
isSingleValueFieldEqualityCondition,
QuerySelectionModifiersWithOrderByRaw,
} from './EntityDatabaseAdapter';
import EntityPrivacyPolicy from './EntityPrivacyPolicy';
import EntityPrivacyPolicy, { EntityPrivacyPolicyEvaluationContext } from './EntityPrivacyPolicy';
import { EntityQueryContext } from './EntityQueryContext';
import ReadonlyEntity from './ReadonlyEntity';
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,
* cached, and authorized against the entity's {@link EntityPrivacyPolicy}.
* cached, and authorized against the entity's EntityPrivacyPolicy.
*/
export default class EntityLoader<
TFields,
@@ -40,6 +41,7 @@ export default class EntityLoader<
constructor(
private readonly viewerContext: TViewerContext,
private readonly queryContext: EntityQueryContext,
private readonly privacyPolicyEvaluationContext: EntityPrivacyPolicyEvaluationContext,
private readonly entityConfiguration: EntityConfiguration<TFields>,
private readonly entityClass: IEntityClass<
TFields,
@@ -125,7 +127,9 @@ export default class EntityLoader<
const entityResults = await this.loadManyByFieldEqualingAsync(uniqueFieldName, fieldValue);
invariant(
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;
}
@@ -179,16 +183,16 @@ export default class EntityLoader<
}
/**
* Loads many entities matching the conjunction of WHERE clauses constructed from specified operands.
* Loads many entities matching the selection constructed from the conjunction of specified operands.
* Entities loaded using this method are not batched or cached.
*
* @example
* fieldEqualityOperands:
* `[{fieldName: 'hello', fieldValue: 1}, {fieldName: 'world', fieldValues: [2, 3]}]`
* Entities returned:
* Entities returned with a SQL EntityDatabaseAdapter:
* `WHERE hello = 1 AND world = ANY({2, 3})`
*
* @param fieldEqualityOperands - list of field equality WHERE clause operand specifications
* @param fieldEqualityOperands - list of field equality selection operand specifications
* @param querySelectionModifiers - limit, offset, and orderBy for the query
* @returns array of entity results that match the query, where result error can be UnauthorizedError
*/
@@ -218,6 +222,7 @@ export default class EntityLoader<
this.privacyPolicy.authorizeReadAsync(
this.viewerContext,
this.queryContext,
this.privacyPolicyEvaluationContext,
uncheckedEntityResult.value,
this.metricsAdapter
)
@@ -240,12 +245,12 @@ export default class EntityLoader<
* bindings: `[1]`
* Entites returned `WHERE id = 1`
*
* {@link http://knexjs.org/#Builder-whereRaw}
* {@link http://knexjs.org/#Raw-Bindings}
* http://knexjs.org/#Builder-whereRaw
* http://knexjs.org/#Raw-Bindings
*
* @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 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
* @throws Error when rawWhereClause or bindings are invalid
*
@@ -254,7 +259,7 @@ export default class EntityLoader<
async loadManyByRawWhereClauseAsync(
rawWhereClause: string,
bindings: any[] | object,
querySelectionModifiers: QuerySelectionModifiers<TFields> = {}
querySelectionModifiers: QuerySelectionModifiersWithOrderByRaw<TFields> = {}
): Promise<readonly Result<TEntity>[]> {
const fieldObjects = await this.dataManager.loadManyByRawWhereClauseAsync(
this.queryContext,
@@ -272,6 +277,7 @@ export default class EntityLoader<
this.privacyPolicy.authorizeReadAsync(
this.viewerContext,
this.queryContext,
this.privacyPolicyEvaluationContext,
uncheckedEntityResult.value,
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
*/
async invalidateFieldsAsync(objectFields: Readonly<TFields>): Promise<void> {
@@ -332,6 +338,7 @@ export default class EntityLoader<
this.privacyPolicy.authorizeReadAsync(
this.viewerContext,
this.queryContext,
this.privacyPolicyEvaluationContext,
uncheckedEntityResult.value,
this.metricsAdapter
)
@@ -346,7 +353,7 @@ export default class EntityLoader<
fieldValues: readonly TFields[N][]
): void {
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) {
const isInputValid = fieldDefinition.validateInputValue(fieldValue);
if (!isInputValid) {
+4 -2
View File
@@ -1,7 +1,7 @@
import { IEntityClass } from './Entity';
import EntityConfiguration from './EntityConfiguration';
import EntityLoader from './EntityLoader';
import EntityPrivacyPolicy from './EntityPrivacyPolicy';
import EntityPrivacyPolicy, { EntityPrivacyPolicyEvaluationContext } from './EntityPrivacyPolicy';
import { EntityQueryContext } from './EntityQueryContext';
import ReadonlyEntity from './ReadonlyEntity';
import ViewerContext from './ViewerContext';
@@ -47,11 +47,13 @@ export default class EntityLoaderFactory<
*/
forLoad(
viewerContext: TViewerContext,
queryContext: EntityQueryContext
queryContext: EntityQueryContext,
privacyPolicyEvaluationContext: EntityPrivacyPolicyEvaluationContext
): EntityLoader<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy, TSelectedFields> {
return new EntityLoader(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
this.entityConfiguration,
this.entityClass,
this.privacyPolicyClass,
+15 -3
View File
@@ -20,11 +20,22 @@ export type EntityValidatorMutationInfo<
| {
type: EntityMutationType.UPDATE;
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>;
cascadingDeleteCause: EntityMutationTriggerDeleteCascadeInfo | null;
/**
* The cascade deletion that caused this mutation.
*/
cascadingDeleteCause: EntityCascadingDeletionInfo | null;
};
export type EntityTriggerMutationInfo<
@@ -40,8 +51,9 @@ export type EntityTriggerMutationInfo<
| {
type: EntityMutationType.UPDATE;
previousValue: TEntity;
cascadingDeleteCause: EntityCascadingDeletionInfo | null;
}
| {
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.
*/
export abstract class EntityNonTransactionalMutationTrigger<
+113 -59
View File
@@ -11,7 +11,7 @@ import {
EntityValidatorMutationInfo,
EntityMutationType,
EntityTriggerMutationInfo,
EntityMutationTriggerDeleteCascadeInfo,
EntityCascadingDeletionInfo,
} from './EntityMutationInfo';
import EntityMutationTriggerConfiguration, {
EntityMutationTrigger,
@@ -182,7 +182,7 @@ export class CreateMutator<
/**
* 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
*/
async createAsync(): Promise<Result<TEntity>> {
@@ -220,6 +220,7 @@ export class CreateMutator<
this.privacyPolicy.authorizeCreateAsync(
this.viewerContext,
queryContext,
{ cascadingDeleteCause: null },
temporaryEntityForPrivacyCheck,
this.metricsAdapter
)
@@ -249,7 +250,9 @@ export class CreateMutator<
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(
entityLoader.invalidateFieldsAsync.bind(entityLoader, insertResult)
);
@@ -374,8 +377,7 @@ export class UpdateMutator<
/**
* 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
* transactional query context.
* Invalidates all caches for pre-update entity.
* @returns authorized updated entity result, where result error can be UnauthorizedError
*/
async updateAsync(): Promise<Result<TEntity>> {
@@ -383,7 +385,7 @@ export class UpdateMutator<
this.metricsAdapter,
EntityMetricsMutationType.UPDATE,
this.entityClass.name
)(this.updateInTransactionAsync());
)(this.updateInTransactionAsync(false, null));
}
/**
@@ -393,14 +395,27 @@ export class UpdateMutator<
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) =>
this.updateInternalAsync(innerQueryContext)
this.updateInternalAsync(innerQueryContext, skipDatabaseUpdate, cascadingDeleteCause)
);
}
private async updateInternalAsync(
queryContext: EntityTransactionalQueryContext
queryContext: EntityTransactionalQueryContext,
skipDatabaseUpdate: boolean,
cascadingDeleteCause: EntityCascadingDeletionInfo | null
): Promise<Result<TEntity>> {
this.validateFields(this.updatedFields);
@@ -409,6 +424,7 @@ export class UpdateMutator<
this.privacyPolicy.authorizeUpdateAsync(
this.viewerContext,
queryContext,
{ cascadingDeleteCause },
entityAboutToBeUpdated,
this.metricsAdapter
)
@@ -421,29 +437,34 @@ export class UpdateMutator<
this.mutationValidators,
queryContext,
entityAboutToBeUpdated,
{ type: EntityMutationType.UPDATE, previousValue: this.originalEntity }
{ type: EntityMutationType.UPDATE, previousValue: this.originalEntity, cascadingDeleteCause }
);
await this.executeMutationTriggersAsync(
this.mutationTriggers.beforeAll,
queryContext,
entityAboutToBeUpdated,
{ type: EntityMutationType.UPDATE, previousValue: this.originalEntity }
{ type: EntityMutationType.UPDATE, previousValue: this.originalEntity, cascadingDeleteCause }
);
await this.executeMutationTriggersAsync(
this.mutationTriggers.beforeUpdate,
queryContext,
entityAboutToBeUpdated,
{ type: EntityMutationType.UPDATE, previousValue: this.originalEntity }
{ type: EntityMutationType.UPDATE, previousValue: this.originalEntity, cascadingDeleteCause }
);
const updateResult = await this.databaseAdapter.updateAsync(
queryContext,
this.entityConfiguration.idField,
entityAboutToBeUpdated.getID(),
this.updatedFields
);
// skip the database update when specified
const updateResult = skipDatabaseUpdate
? null
: await this.databaseAdapter.updateAsync(
queryContext,
this.entityConfiguration.idField,
entityAboutToBeUpdated.getID(),
this.updatedFields
);
const entityLoader = this.entityLoaderFactory.forLoad(this.viewerContext, queryContext);
const entityLoader = this.entityLoaderFactory.forLoad(this.viewerContext, queryContext, {
cascadingDeleteCause,
});
queryContext.appendPostCommitInvalidationCallback(
entityLoader.invalidateFieldsAsync.bind(
@@ -455,22 +476,25 @@ export class UpdateMutator<
entityLoader.invalidateFieldsAsync.bind(entityLoader, this.fieldsForEntity)
);
const unauthorizedEntityAfterUpdate = new this.entityClass(this.viewerContext, updateResult);
const updatedEntity = await entityLoader
.enforcing()
.loadByIDAsync(unauthorizedEntityAfterUpdate.getID());
// when the database update was skipped, assume it succeeded and use the optimistic
// entity to execute triggers
const updatedEntity = updateResult
? await entityLoader
.enforcing()
.loadByIDAsync(new this.entityClass(this.viewerContext, updateResult).getID())
: entityAboutToBeUpdated;
await this.executeMutationTriggersAsync(
this.mutationTriggers.afterUpdate,
queryContext,
updatedEntity,
{ type: EntityMutationType.UPDATE, previousValue: this.originalEntity }
{ type: EntityMutationType.UPDATE, previousValue: this.originalEntity, cascadingDeleteCause }
);
await this.executeMutationTriggersAsync(
this.mutationTriggers.afterAll,
queryContext,
updatedEntity,
{ type: EntityMutationType.UPDATE, previousValue: this.originalEntity }
{ type: EntityMutationType.UPDATE, previousValue: this.originalEntity, cascadingDeleteCause }
);
queryContext.appendPostCommitCallback(
@@ -478,7 +502,11 @@ export class UpdateMutator<
this,
this.mutationTriggers.afterCommit,
updatedEntity,
{ type: EntityMutationType.UPDATE, previousValue: this.originalEntity }
{
type: EntityMutationType.UPDATE,
previousValue: this.originalEntity,
cascadingDeleteCause,
}
)
);
@@ -578,7 +606,7 @@ export class DeleteMutator<
private async deleteInTransactionAsync(
processedEntityIdentifiersFromTransitiveDeletions: Set<string>,
skipDatabaseDeletion: boolean,
cascadingDeleteCause: EntityMutationTriggerDeleteCascadeInfo | null
cascadingDeleteCause: EntityCascadingDeletionInfo | null
): Promise<Result<void>> {
return await this.queryContext.runInTransactionIfNotInTransactionAsync((innerQueryContext) =>
this.deleteInternalAsync(
@@ -594,12 +622,13 @@ export class DeleteMutator<
queryContext: EntityTransactionalQueryContext,
processedEntityIdentifiersFromTransitiveDeletions: Set<string>,
skipDatabaseDeletion: boolean,
cascadingDeleteCause: EntityMutationTriggerDeleteCascadeInfo | null
cascadingDeleteCause: EntityCascadingDeletionInfo | null
): Promise<Result<void>> {
const authorizeDeleteResult = await asyncResult(
this.privacyPolicy.authorizeDeleteAsync(
this.viewerContext,
queryContext,
{ cascadingDeleteCause },
this.entity,
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(
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
* 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
* This works by doing reverse fan-out queries:
@@ -684,7 +715,7 @@ export class DeleteMutator<
entity: TEntity,
queryContext: EntityTransactionalQueryContext,
processedEntityIdentifiers: Set<string>,
cascadingDeleteCause: EntityMutationTriggerDeleteCascadeInfo | null
cascadingDeleteCause: EntityCascadingDeletionInfo | null
): Promise<void> {
// prevent infinite reference cycles by keeping track of entities already processed
if (processedEntityIdentifiers.has(entity.getUniqueIdentifier())) {
@@ -732,10 +763,15 @@ export class DeleteMutator<
.getViewerScopedEntityCompanionForClass(entityClass)
.getMutatorFactory();
const newCascadingDeleteCause = {
entity,
cascadingDeleteCause,
};
let inboundReferenceEntities: readonly ReadonlyEntity<any, any, any, any>[];
if (associatedEntityLookupByField) {
inboundReferenceEntities = await loaderFactory
.forLoad(queryContext)
.forLoad(queryContext, { cascadingDeleteCause: newCascadingDeleteCause })
.enforcing()
.loadManyByFieldEqualingAsync(
fieldName,
@@ -743,26 +779,40 @@ export class DeleteMutator<
);
} else {
inboundReferenceEntities = await loaderFactory
.forLoad(queryContext)
.forLoad(queryContext, { cascadingDeleteCause: newCascadingDeleteCause })
.enforcing()
.loadManyByFieldEqualingAsync(fieldName, entity.getID());
}
switch (association.edgeDeletionBehavior) {
case undefined:
case EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE: {
case EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE_ONLY: {
await Promise.all(
inboundReferenceEntities.map((inboundReferenceEntity) =>
mutatorFactory
.forDelete(inboundReferenceEntity, queryContext)
.deleteInTransactionAsync(
processedEntityIdentifiers,
/* skipDatabaseDeletion */ true, // deletion is handled by DB
{
entity,
cascadingDeleteCause,
}
)
enforceAsyncResult(
mutatorFactory
.forDelete(inboundReferenceEntity, queryContext)
.deleteInTransactionAsync(
processedEntityIdentifiers,
/* skipDatabaseDeletion */ true, // deletion is handled by DB
newCascadingDeleteCause
)
)
)
);
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
)
)
)
);
break;
@@ -770,10 +820,15 @@ export class DeleteMutator<
case EntityEdgeDeletionBehavior.SET_NULL: {
await Promise.all(
inboundReferenceEntities.map((inboundReferenceEntity) =>
mutatorFactory
.forUpdate(inboundReferenceEntity, queryContext)
.setField(fieldName, null)
.enforceUpdateAsync()
enforceAsyncResult(
mutatorFactory
.forUpdate(inboundReferenceEntity, queryContext)
.setField(fieldName, null)
['updateInTransactionAsync'](
/* skipDatabaseUpdate */ false,
newCascadingDeleteCause
)
)
)
);
break;
@@ -781,16 +836,15 @@ export class DeleteMutator<
case EntityEdgeDeletionBehavior.CASCADE_DELETE: {
await Promise.all(
inboundReferenceEntities.map((inboundReferenceEntity) =>
mutatorFactory
.forDelete(inboundReferenceEntity, queryContext)
.deleteInTransactionAsync(
processedEntityIdentifiers,
/* skipDatabaseDeletion */ false,
{
entity,
cascadingDeleteCause,
}
)
enforceAsyncResult(
mutatorFactory
.forDelete(inboundReferenceEntity, queryContext)
.deleteInTransactionAsync(
processedEntityIdentifiers,
/* skipDatabaseDeletion */ false,
newCascadingDeleteCause
)
)
)
);
}
+51 -6
View File
@@ -1,3 +1,4 @@
import { EntityCascadingDeletionInfo } from './EntityMutationInfo';
import { EntityQueryContext } from './EntityQueryContext';
import ReadonlyEntity from './ReadonlyEntity';
import ViewerContext from './ViewerContext';
@@ -7,9 +8,35 @@ import IEntityMetricsAdapter, {
} from './metrics/IEntityMetricsAdapter';
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 {
/**
* Enforce this privacy policy. Throw upon denial.
*/
ENFORCE,
/**
* Do not enforce this privacy policy. Always allow but log when it would have denied.
*/
DRY_RUN,
/**
* Enforce this privacy policy. Throw and log upon denial.
*/
ENFORCE_AND_LOG,
}
@@ -48,7 +75,7 @@ export enum EntityAuthorizationAction {
*
* @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.
*
* 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 entity - entity to authorize
* @returns entity if authorized
* @throws {@link EntityNotAuthorizedError} when not authorized
* @throws EntityNotAuthorizedError when not authorized
*/
async authorizeCreateAsync(
viewerContext: TViewerContext,
queryContext: EntityQueryContext,
evaluationContext: EntityPrivacyPolicyEvaluationContext,
entity: TEntity,
metricsAdapter: IEntityMetricsAdapter
): Promise<TEntity> {
@@ -134,6 +162,7 @@ export default abstract class EntityPrivacyPolicy<
this.createRules,
viewerContext,
queryContext,
evaluationContext,
entity,
EntityAuthorizationAction.CREATE,
metricsAdapter
@@ -146,11 +175,12 @@ export default abstract class EntityPrivacyPolicy<
* @param queryContext - query context in which to perform the read authorization
* @param entity - entity to authorize
* @returns entity if authorized
* @throws {@link EntityNotAuthorizedError} when not authorized
* @throws EntityNotAuthorizedError when not authorized
*/
async authorizeReadAsync(
viewerContext: TViewerContext,
queryContext: EntityQueryContext,
evaluationContext: EntityPrivacyPolicyEvaluationContext,
entity: TEntity,
metricsAdapter: IEntityMetricsAdapter
): Promise<TEntity> {
@@ -158,6 +188,7 @@ export default abstract class EntityPrivacyPolicy<
this.readRules,
viewerContext,
queryContext,
evaluationContext,
entity,
EntityAuthorizationAction.READ,
metricsAdapter
@@ -170,11 +201,12 @@ export default abstract class EntityPrivacyPolicy<
* @param queryContext - query context in which to perform the update authorization
* @param entity - entity to authorize
* @returns entity if authorized
* @throws {@link EntityNotAuthorizedError} when not authorized
* @throws EntityNotAuthorizedError when not authorized
*/
async authorizeUpdateAsync(
viewerContext: TViewerContext,
queryContext: EntityQueryContext,
evaluationContext: EntityPrivacyPolicyEvaluationContext,
entity: TEntity,
metricsAdapter: IEntityMetricsAdapter
): Promise<TEntity> {
@@ -182,6 +214,7 @@ export default abstract class EntityPrivacyPolicy<
this.updateRules,
viewerContext,
queryContext,
evaluationContext,
entity,
EntityAuthorizationAction.UPDATE,
metricsAdapter
@@ -194,11 +227,12 @@ export default abstract class EntityPrivacyPolicy<
* @param queryContext - query context in which to perform the delete authorization
* @param entity - entity to authorize
* @returns entity if authorized
* @throws {@link EntityNotAuthorizedError} when not authorized
* @throws EntityNotAuthorizedError when not authorized
*/
async authorizeDeleteAsync(
viewerContext: TViewerContext,
queryContext: EntityQueryContext,
evaluationContext: EntityPrivacyPolicyEvaluationContext,
entity: TEntity,
metricsAdapter: IEntityMetricsAdapter
): Promise<TEntity> {
@@ -206,6 +240,7 @@ export default abstract class EntityPrivacyPolicy<
this.deleteRules,
viewerContext,
queryContext,
evaluationContext,
entity,
EntityAuthorizationAction.DELETE,
metricsAdapter
@@ -216,6 +251,7 @@ export default abstract class EntityPrivacyPolicy<
ruleset: readonly PrivacyPolicyRule<TFields, TID, TViewerContext, TEntity, TSelectedFields>[],
viewerContext: TViewerContext,
queryContext: EntityQueryContext,
evaluationContext: EntityPrivacyPolicyEvaluationContext,
entity: TEntity,
action: EntityAuthorizationAction,
metricsAdapter: IEntityMetricsAdapter
@@ -228,6 +264,7 @@ export default abstract class EntityPrivacyPolicy<
ruleset,
viewerContext,
queryContext,
evaluationContext,
entity,
action
);
@@ -256,6 +293,7 @@ export default abstract class EntityPrivacyPolicy<
ruleset,
viewerContext,
queryContext,
evaluationContext,
entity,
action
);
@@ -285,6 +323,7 @@ export default abstract class EntityPrivacyPolicy<
ruleset,
viewerContext,
queryContext,
evaluationContext,
entity,
action
);
@@ -315,12 +354,18 @@ export default abstract class EntityPrivacyPolicy<
ruleset: readonly PrivacyPolicyRule<TFields, TID, TViewerContext, TEntity, TSelectedFields>[],
viewerContext: TViewerContext,
queryContext: EntityQueryContext,
evaluationContext: EntityPrivacyPolicyEvaluationContext,
entity: TEntity,
action: EntityAuthorizationAction
): Promise<TEntity> {
for (let i = 0; i < ruleset.length; i++) {
const rule = ruleset[i]!;
const ruleEvaluationResult = await rule.evaluateAsync(viewerContext, queryContext, entity);
const ruleEvaluationResult = await rule.evaluateAsync(
viewerContext,
queryContext,
evaluationContext,
entity
);
switch (ruleEvaluationResult) {
case RuleEvaluationResult.DENY:
throw new EntityNotAuthorizedError<
+72 -1
View File
@@ -12,7 +12,7 @@ export type PreCommitCallback = (
* Entity framework representation of transactional and non-transactional database
* query execution units.
*
* The behavior of {@link EntityMutator} and {@link EntityLoader}
* The behavior of EntityMutator and EntityLoader
* differs when in a transactional context.
*/
export abstract class EntityQueryContext {
@@ -29,6 +29,12 @@ export abstract class EntityQueryContext {
): 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 {
constructor(
queryInterface: any,
@@ -48,12 +54,24 @@ 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 {
private readonly postCommitInvalidationCallbacks: PostCommitCallback[] = [];
private readonly postCommitCallbacks: PostCommitCallback[] = [];
private readonly preCommitCallbacks: { callback: PreCommitCallback; order: number }[] = [];
constructor(
queryInterface: any,
private readonly entityQueryContextProvider: EntityQueryContextProvider
) {
super(queryInterface);
}
/**
* Schedule a pre-commit callback. These will be run within the transaction right before it is
* committed, and will be run in the order specified. Ordering of callbacks scheduled with the
@@ -118,4 +136,57 @@ export class EntityTransactionalQueryContext extends EntityQueryContext {
): Promise<T> {
return await transactionScope(this);
}
async runInNestedTransactionAsync<T>(
transactionScope: (innerQueryContext: EntityTransactionalQueryContext) => Promise<T>
): Promise<T> {
return await this.entityQueryContextProvider.runInNestedTransactionAsync(
this,
transactionScope
);
}
}
/**
* Entity framework representation of a nested transactional query execution unit. When supplied
* to EntityMutator and EntityLoader methods, those methods and their
* dependent triggers and validators will run within the nested transaction.
*
* This exists to forward post-commit callbacks to the parent query context.
*/
export class EntityNestedTransactionalQueryContext extends EntityTransactionalQueryContext {
private readonly postCommitInvalidationCallbacksToTransfer: PostCommitCallback[] = [];
private readonly postCommitCallbacksToTransfer: PostCommitCallback[] = [];
constructor(
queryInterface: any,
private readonly parentQueryContext: EntityTransactionalQueryContext,
entityQueryContextProvider: EntityQueryContextProvider
) {
super(queryInterface, entityQueryContextProvider);
}
public override appendPostCommitCallback(callback: PostCommitCallback): void {
this.postCommitInvalidationCallbacksToTransfer.push(callback);
}
public override appendPostCommitInvalidationCallback(callback: PostCommitCallback): void {
this.postCommitCallbacksToTransfer.push(callback);
}
public override runPostCommitCallbacksAsync(): Promise<void> {
throw new Error(
'Must not call runPostCommitCallbacksAsync on EntityNestedTransactionalQueryContext'
);
}
public transferPostCommitCallbacksToParent(): void {
for (const callback of this.postCommitInvalidationCallbacksToTransfer) {
this.parentQueryContext.appendPostCommitInvalidationCallback(callback);
}
for (const callback of this.postCommitCallbacksToTransfer) {
this.parentQueryContext.appendPostCommitCallback(callback);
}
}
}
@@ -1,6 +1,7 @@
import {
EntityTransactionalQueryContext,
EntityNonTransactionalQueryContext,
EntityNestedTransactionalQueryContext,
} from './EntityQueryContext';
/**
@@ -20,12 +21,16 @@ export default abstract class EntityQueryContextProvider {
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>(): (
transactionScope: (queryInterface: any) => Promise<T>
) => Promise<T>;
protected abstract createNestedTransactionRunner<T>(
outerQueryInterface: any
): (transactionScope: (queryInterface: any) => Promise<T>) => Promise<T>;
/**
* Start a transaction and execute the provided transaction-scoped closure within the transaction.
* @param transactionScope - async callback to execute within the transaction
@@ -36,7 +41,7 @@ export default abstract class EntityQueryContextProvider {
const [returnedValue, queryContext] = await this.createTransactionRunner<
[T, EntityTransactionalQueryContext]
>()(async (queryInterface) => {
const queryContext = new EntityTransactionalQueryContext(queryInterface);
const queryContext = new EntityTransactionalQueryContext(queryInterface, this);
const result = await transactionScope(queryContext);
await queryContext.runPreCommitCallbacksAsync();
return [result, queryContext];
@@ -44,4 +49,31 @@ export default abstract class EntityQueryContextProvider {
await queryContext.runPostCommitCallbacksAsync();
return returnedValue;
}
/**
* Start a nested transaction from the specified parent transaction and execure the
* provided nested-transaction-scoped closure within the nested transaction.
* @param outerQueryContext - the query context of the parent transaction
* @param transactionScope - async callback to execute within the nested transaction
*/
async runInNestedTransactionAsync<T>(
outerQueryContext: EntityTransactionalQueryContext,
transactionScope: (innerQueryContext: EntityNestedTransactionalQueryContext) => Promise<T>
): Promise<T> {
const [returnedValue, innerQueryContext] = await this.createNestedTransactionRunner<
[T, EntityNestedTransactionalQueryContext]
>(outerQueryContext.getQueryInterface())(async (innerQueryInterface) => {
const innerQueryContext = new EntityNestedTransactionalQueryContext(
innerQueryInterface,
outerQueryContext,
this
);
const result = await transactionScope(innerQueryContext);
await innerQueryContext.runPreCommitCallbacksAsync();
return [result, innerQueryContext];
});
// post-commit callbacks are appended to parent transaction instead of run, but only after the transaction has succeeded
innerQueryContext.transferPostCommitCallbacksToParent();
return returnedValue;
}
}
@@ -40,7 +40,7 @@ export interface ISecondaryEntityCache<TFields, TLoadParams> {
* when the underlying data of a cache key could be stale.
*
* This is most commonly used to further optimize hot paths that cannot make use of normal entity cache loading
* due to use of a non-unique-field-based {@link EntityLoader} method like `loadManyByFieldEqualityConjunctionAsync` or
* due to use of a non-unique-field-based EntityLoader method like `loadManyByFieldEqualityConjunctionAsync` or
* `loadManyByRawWhereClauseAsync`.
*/
export default abstract class EntitySecondaryCacheLoader<
@@ -0,0 +1,65 @@
import invariant from 'invariant';
import IEntityCacheAdapter from './IEntityCacheAdapter';
import IEntityGenericCacher from './IEntityGenericCacher';
import { CacheLoadResult } from './internal/ReadThroughEntityCache';
import { mapKeys } from './utils/collections/maps';
/**
* A standard IEntityCacheAdapter that coordinates caching through an IEntityGenericCacher.
*/
export default class GenericEntityCacheAdapter<TFields> implements IEntityCacheAdapter<TFields> {
constructor(private readonly genericCacher: IEntityGenericCacher<TFields>) {}
public async loadManyAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult<TFields>>> {
const redisCacheKeyToFieldValueMapping = new Map(
fieldValues.map((fieldValue) => [
this.genericCacher.makeCacheKey(fieldName, fieldValue),
fieldValue,
])
);
const cacheResults = await this.genericCacher.loadManyAsync(
Array.from(redisCacheKeyToFieldValueMapping.keys())
);
return mapKeys(cacheResults, (redisCacheKey) => {
const fieldValue = redisCacheKeyToFieldValueMapping.get(redisCacheKey);
invariant(
fieldValue !== undefined,
'Unspecified cache key %s returned from generic cacher',
redisCacheKey
);
return fieldValue;
});
}
public async cacheManyAsync<N extends keyof TFields>(
fieldName: N,
objectMap: ReadonlyMap<NonNullable<TFields[N]>, Readonly<TFields>>
): Promise<void> {
await this.genericCacher.cacheManyAsync(
mapKeys(objectMap, (fieldValue) => this.genericCacher.makeCacheKey(fieldName, fieldValue))
);
}
public async cacheDBMissesAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<void> {
await this.genericCacher.cacheDBMissesAsync(
fieldValues.map((fieldValue) => this.genericCacher.makeCacheKey(fieldName, fieldValue))
);
}
public async invalidateManyAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<void> {
await this.genericCacher.invalidateManyAsync(
fieldValues.map((fieldValue) => this.genericCacher.makeCacheKey(fieldName, fieldValue))
);
}
}
@@ -8,7 +8,7 @@ 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 {@link EntityLoader.loadManyByFieldEqualityConjunctionAsync} is guaranteed to return at most one entity.
* a call to EntityLoader.loadManyByFieldEqualityConjunctionAsync is guaranteed to return at most one entity.
*/
export default abstract class GenericSecondaryEntityCache<TFields, TLoadParams>
implements ISecondaryEntityCache<TFields, TLoadParams>
@@ -1,20 +1,17 @@
import EntityConfiguration from './EntityConfiguration';
import { CacheLoadResult } from './internal/ReadThroughEntityCache';
/**
* A cache adapter is an interface by which objects can be
* cached, fetched from cache, and removed from cache (invalidated).
*/
export default abstract class EntityCacheAdapter<TFields> {
constructor(protected readonly entityConfiguration: EntityConfiguration<TFields>) {}
export default interface IEntityCacheAdapter<TFields> {
/**
* Load many objects from cache.
* @param fieldName - object field being queried
* @param fieldValues - fieldName field values being queried
* @returns map from all field values to a CacheLoadResult for each input value
*/
public abstract loadManyAsync<N extends keyof TFields>(
loadManyAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult<TFields>>>;
@@ -24,7 +21,7 @@ export default abstract class EntityCacheAdapter<TFields> {
* @param fieldName - object field being queried
* @param objectMap - map from field value to object to cache
*/
public abstract cacheManyAsync<N extends keyof TFields>(
cacheManyAsync<N extends keyof TFields>(
fieldName: N,
objectMap: ReadonlyMap<NonNullable<TFields[N]>, Readonly<TFields>>
): Promise<void>;
@@ -32,10 +29,10 @@ export default abstract class EntityCacheAdapter<TFields> {
/**
* Negatively cache objects that could not be found in the cache or DB.
* @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.
*/
public abstract cacheDBMissesAsync<N extends keyof TFields>(
cacheDBMissesAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<void>;
@@ -45,7 +42,7 @@ export default abstract class EntityCacheAdapter<TFields> {
* @param fieldName - object field being queried
* @param fieldValues - fieldName field values to be invalidated
*/
public abstract invalidateManyAsync<N extends keyof TFields>(
invalidateManyAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<void>;
@@ -1,5 +1,5 @@
import EntityCacheAdapter from './EntityCacheAdapter';
import EntityConfiguration from './EntityConfiguration';
import IEntityCacheAdapter from './IEntityCacheAdapter';
/**
* A cache adapter provider vends cache adapters for a particular cache adapter type.
@@ -11,5 +11,5 @@ export default interface IEntityCacheAdapterProvider {
*/
getCacheAdapter<TFields>(
entityConfiguration: EntityConfiguration<TFields>
): EntityCacheAdapter<TFields>;
): IEntityCacheAdapter<TFields>;
}
@@ -12,4 +12,6 @@ export default interface IEntityGenericCacher<TFields> {
cacheDBMissesAsync(keys: readonly string[]): Promise<void>;
invalidateManyAsync(keys: readonly string[]): Promise<void>;
makeCacheKey<N extends keyof TFields>(fieldName: N, fieldValue: NonNullable<TFields[N]>): string;
}
+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<
TFields,
@@ -156,6 +156,6 @@ export default abstract class ReadonlyEntity<
return viewerContext
.getViewerScopedEntityCompanionForClass(this)
.getLoaderFactory()
.forLoad(queryContext);
.forLoad(queryContext, { cascadingDeleteCause: null });
}
}
+2 -2
View File
@@ -7,9 +7,9 @@ import ViewerScopedEntityCompanion from './ViewerScopedEntityCompanion';
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.
*/
export default class ViewerContext {
@@ -8,7 +8,7 @@ import ViewerScopedEntityMutatorFactory from './ViewerScopedEntityMutatorFactory
import IEntityMetricsAdapter from './metrics/IEntityMetricsAdapter';
/**
* Provides a simpler API for loading and mutating entities by injecting the {@link ViewerContext}
* Provides a simpler API for loading and mutating entities by injecting the ViewerContext
* from the viewer-scoped entity companion provider.
*/
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 {
return this.entityCompanion.getMetricsAdapter();
@@ -1,12 +1,12 @@
import EntityLoader from './EntityLoader';
import EntityLoaderFactory from './EntityLoaderFactory';
import EntityPrivacyPolicy from './EntityPrivacyPolicy';
import EntityPrivacyPolicy, { EntityPrivacyPolicyEvaluationContext } from './EntityPrivacyPolicy';
import { EntityQueryContext } from './EntityQueryContext';
import ReadonlyEntity from './ReadonlyEntity';
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<
TFields,
@@ -35,8 +35,13 @@ export default class ViewerScopedEntityLoaderFactory<
) {}
forLoad(
queryContext: EntityQueryContext
queryContext: EntityQueryContext,
privacyPolicyEvaluationContext: EntityPrivacyPolicyEvaluationContext
): EntityLoader<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy, TSelectedFields> {
return this.entityLoaderFactory.forLoad(this.viewerContext, queryContext);
return this.entityLoaderFactory.forLoad(
this.viewerContext,
queryContext,
privacyPolicyEvaluationContext
);
}
}
Loaded 100 of 141 files, more files were not shown because too many files have changed in this diff. Show more