Compare commits

..
Author SHA1 Message Date
Quinlan Jung b61341057b [cache-adapter-local-memory] export classes 2022-03-09 17:53:23 -08:00
Quinlan Jung bc378b6c79 chore: better variable and method naming (#160) 2022-02-16 18:37:51 -08:00
Quinlan Jung fefa4100fc v0.24.0 (#159)
* v0.24.0

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

* some pr feedback

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

* fix lint

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

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

* integ tests

* fix codecov
2022-02-07 15:04:02 -08:00
James Ide a2c6dd7488 v0.22.0 (#151) 2022-02-03 18:52:25 -08:00
James Ide e1b0ee5281 feat: Upgrade knex to 1.x in @expo/entity-database-adapter-knex (#150)
Upgrades knex to 1.x internally. This changes the behavior of `returning` but not in a way that affects how the knex adapter uses it: `returning('*')` behaves the same as before.

This does, however, change the TypeScript declarations for knex, which changed from:
```
 Knex<TRecord extends {} = any, TResult = unknown[]>
```
to:
```
 Knex<TRecord extends {} = any, TResult = Record<string, any>[]>
```
2022-02-03 18:35:55 -08:00
Will Schurman 5d39fa928f v0.21.0 (#148) 2022-01-03 11:04:02 -08:00
Will Schurman f1d9847210 feat: add pre-commit callbacks on EntityQueryContext (#147) 2022-01-03 09:37:19 -08:00
Will Schurman 99657196f8 v0.20.0 (#146) 2021-12-29 10:50:58 -08:00
Will Schurman 3727191767 feat: add cascading deletion info (#145) 2021-12-29 10:39:22 -08:00
Will Schurman efa131ef29 fix: separate post-commit invalidation callbacks from trigger and user callbacks (#144) 2021-12-23 12:41:29 -08:00
Will Schurman c4df889d50 v0.19.0 (#143) 2021-10-28 14:35:19 -07:00
Will Schurman 7cf54d83c5 feat: add authorization metrics (#142) 2021-10-24 17:26:40 -07:00
kgc00 1405a9126e feat: add IP support (#138)
* Add @expo/entity-ip-address-field

- adds an entity field for IP addresses, validates both v4 and v6 type IPs
- ip address field functionality moved to its own package
- specified `@expo/entity` as a dev & peer dependency
2021-10-15 10:40:20 -07:00
Will Schurman 677d15174c v0.18.0 (#140) 2021-10-14 14:51:59 -07:00
Will Schurman 1220477aa3 feat: expose describeFieldTestCase for use in other packages (#139) 2021-10-14 14:41:26 -07:00
Will Schurman 5c7efe1678 feat: use better QueryContext types for transactional triggers and validators (#136) 2021-09-17 17:24:48 -07:00
Will Schurman 4263cb9b8b feat: enable noImplicitOverride tsc setting (#135) 2021-09-07 15:18:22 -07:00
Will Schurman 7612392dbf feat: upgrade TypeScript to 4.4 (#134) 2021-09-07 15:17:39 -07:00
Will Schurman 1201cc065c v0.17.0 (#133) 2021-08-16 11:52:43 -06:00
Hayden Daly 2f2d963cf9 feat: Add IntField and FloatField, deprecate NumberField (#131)
Why
To better represent Postgres' type system, deprecated the type NumberField and replaced with IntField and FloatField.

How
Added the new types to EntityFields.ts. To release, will need to bump minor version so breaking changes aren't propagated.

Test Plan
Update tests using the NumberField to use IntField.
2021-07-08 08:50:10 -07:00
125 changed files with 4022 additions and 1244 deletions

No files matched your search

+99
View File
@@ -3,6 +3,105 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
### Features
* add local memory cache package ([#158](https://github.com/expo/entity/issues/158)) ([85d3575](https://github.com/expo/entity/commit/85d35752fa0919a62efb776a762a6829e83ca7af))
### Chores
* Refactor cachers to use an abstract class and interface ([#157](https://github.com/expo/entity/pull/157))
# [0.23.0](https://github.com/expo/entity/compare/v0.22.0...v0.23.0) (2022-02-09)
### Breaking changes
* Move data transforming responsibilities to the CacheAdapter instead of the ReadThroughEntityCache. Affects any classes extending the EntityCacheAdapter and relying on the transformation behavior of the ReadThroughEntityCache. ([#153](https://github.com/expo/entity/pull/153))
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
### Features
* Upgrade knex to 1.x in `@expo/entity-database-adapter-knex` ([#150](https://github.com/expo/entity/issues/150)) ([e1b0ee5](https://github.com/expo/entity/commit/e1b0ee528175646239d88aa71ffce73983eeb52f))
# [0.21.0](https://github.com/expo/entity/compare/v0.20.0...v0.21.0) (2022-01-03)
### Features
* add pre-commit callbacks on EntityQueryContext ([#147](https://github.com/expo/entity/issues/147)) ([f1d9847](https://github.com/expo/entity/commit/f1d9847210f5775de964509e44ce493e44482b21))
# [0.20.0](https://github.com/expo/entity/compare/v0.19.0...v0.20.0) (2021-12-29)
### Bug Fixes
* separate post-commit invalidation callbacks from trigger and user callbacks ([#144](https://github.com/expo/entity/issues/144)) ([efa131e](https://github.com/expo/entity/commit/efa131ef29532accf0e51b6d867ffe15a27b4c90))
### Features
* add cascading deletion info ([#145](https://github.com/expo/entity/issues/145)) ([3727191](https://github.com/expo/entity/commit/372719176742d3ead6b7cbca29cb66b09b3fe09c))
# [0.19.0](https://github.com/expo/entity/compare/v0.18.0...v0.19.0) (2021-10-28)
### Features
* add authorization metrics ([#142](https://github.com/expo/entity/issues/142)) ([7cf54d8](https://github.com/expo/entity/commit/7cf54d83c52e104159a3e2e675cdda2d29afe204))
* add IP support ([#138](https://github.com/expo/entity/issues/138)) ([1405a91](https://github.com/expo/entity/commit/1405a9126ee3a56c3e2a38bf3b697283744f5b5d))
# [0.18.0](https://github.com/expo/entity/compare/v0.17.0...v0.18.0) (2021-10-14)
### Features
* enable noImplicitOverride tsc setting ([#135](https://github.com/expo/entity/issues/135)) ([4263cb9](https://github.com/expo/entity/commit/4263cb9b8b69fe4fb68c74ec1c7aba04508a1555))
* expose describeFieldTestCase for use in other packages ([#139](https://github.com/expo/entity/issues/139)) ([1220477](https://github.com/expo/entity/commit/1220477aa331349b482ad015b44f76fa2bda7b90))
* upgrade TypeScript to 4.4 ([#134](https://github.com/expo/entity/issues/134)) ([7612392](https://github.com/expo/entity/commit/7612392dbfd9778d25c465c1626c372d75a6d05a))
* use better QueryContext types for transactional triggers and validators ([#136](https://github.com/expo/entity/issues/136)) ([5c7efe1](https://github.com/expo/entity/commit/5c7efe1678424ff907687e9327175bdc2538c3e0))
# [0.17.0](https://github.com/expo/entity/compare/v0.16.0...v0.17.0) (2021-08-16)
### Features
* Add IntField and FloatField, deprecate NumberField ([#131](https://github.com/expo/entity/issues/131)) ([2f2d963](https://github.com/expo/entity/commit/2f2d963cf92c6575d78f8eeca344a4aadcc9b8d9))
# [0.16.0](https://github.com/expo/entity/compare/v0.15.0...v0.16.0) (2021-07-07)
+1 -1
View File
@@ -4,5 +4,5 @@
],
"npmClient": "yarn",
"useWorkspaces": true,
"version": "0.16.0"
"version": "0.24.0"
}
+9 -4
View File
@@ -8,6 +8,7 @@
"tsc": "lerna run tsc",
"clean": "lerna run clean",
"lint": "lerna run lint",
"lint-fix": "lerna run lint-fix",
"test": "lerna run test",
"integration": "./resources/run-with-docker lerna run integration-no-setup",
"prepublish": "lerna link && lerna run clean",
@@ -17,9 +18,11 @@
"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/node": "^14.6.0",
"@types/uuid": "^8.3.0",
"@typescript-eslint/eslint-plugin": "^4.14.0",
@@ -32,17 +35,19 @@
"lerna": "^4.0.0",
"nullthrows": "^1.1.1",
"pg": "^8.6.0",
"prettier": "^2.1.0",
"prettier": "^2.3.2",
"ts-jest": "~26.4.4",
"ts-mockito": "^2.6.1",
"typedoc": "^0.20.36",
"typescript": "^4.1.3",
"typedoc": "^0.21.9",
"typescript": "^4.4.2",
"uuid": "^8.3.0"
},
"dependencies": {
"@expo/entity": "file:packages/entity",
"@expo/entity-cache-adapter-local-memory": "file:packages/entity-cache-adapter-local-memory",
"@expo/entity-cache-adapter-redis": "file:packages/entity-cache-adapter-redis",
"@expo/entity-database-adapter-knex": "file:packages/entity-database-adapter-knex",
"@expo/entity-secondary-cache-redis": "file:packages/entity-secondary-cache-redis"
"@expo/entity-secondary-cache-redis": "file:packages/entity-secondary-cache-redis",
"@expo/entity-ip-address-field": "file:packages/entity-ip-address-field"
}
}
@@ -0,0 +1,11 @@
# Change Log
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
### Features
* add local memory cache package ([#158](https://github.com/expo/entity/issues/158)) ([85d3575](https://github.com/expo/entity/commit/85d35752fa0919a62efb776a762a6829e83ca7af))
@@ -0,0 +1,44 @@
# `@expo/entity-cache-adapter-local-memory`
Cross-request [LRU](https://github.com/isaacs/node-lru-cache) cache adapter for `@expo/entity`. Use
this cache with caution - it is nonstandard. The cache is shared between requests in the node process.
## Why NOT use this cache
Because this is an in-memory cache, cross-box invalidation is not possible. Do not use this cache
if you have the following use cases:
- The objects stored have high mutability
- Reading a stale object from the cache is not acceptable in your application
- Cross-box 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
- You have a low TTL setting in your cache
## Usage
During `EntityCompanionProvider` instantiation:
```typescript
export const createDefaultEntityCompanionProvider = (
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter()
): EntityCompanionProvider => {
return new EntityCompanionProvider(
metricsAdapter,
{
...
},
{
['local-memory']: {
cacheAdapterProvider: new LocalMemoryCacheAdapterProvider.getProvider(),
},
}
);
};
```
@@ -0,0 +1,38 @@
{
"name": "@expo/entity-cache-adapter-local-memory",
"version": "0.24.0",
"description": "Cross-request local memory cache adapter for @expo/entity",
"files": [
"build",
"src"
],
"main": "build/index.js",
"types": "build/index.d.ts",
"scripts": {
"tsc": "tsc",
"clean": "rm -rf build coverage coverage-integration",
"lint": "eslint src",
"lint-fix": "eslint src --fix",
"test": "jest --rootDir . --config ../../resources/jest.config.js",
"integration": "../../resources/run-with-docker yarn integration-no-setup",
"integration-no-setup": "jest --config ../../resources/jest-integration.config.js --rootDir . --runInBand --passWithNoTests",
"barrelsby": "barrelsby --directory src --location top --exclude tests__ --singleQuotes --exportDefault --delete"
},
"engines": {
"node": ">=12"
},
"keywords": [
"entity"
],
"author": "Expo",
"license": "MIT",
"peerDependencies": {
"@expo/entity": "*"
},
"dependencies": {
"lru-cache": "^7.3.1"
},
"devDependencies": {
"@expo/entity": "^0.24.0"
}
}
@@ -0,0 +1,154 @@
import { CacheLoadResult, CacheStatus, IEntityGenericCacher } from '@expo/entity';
import LRUCache from 'lru-cache';
// Sentinel value we store in local memory to negatively cache a database miss.
// The sentinel value is distinct from any (positively) cached value.
export const DOES_NOT_EXIST_LOCAL_MEMORY_CACHE = Symbol('doesNotExist');
type LocalMemoryCacheValue<TFields> = Readonly<TFields> | typeof DOES_NOT_EXIST_LOCAL_MEMORY_CACHE;
export type LocalMemoryCache<TFields> = LRUCache<string, LocalMemoryCacheValue<TFields>>;
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>) {}
static createLRUCache<TFields>(
options: { maxSize?: number; ttlSeconds?: number } = {}
): LocalMemoryCache<TFields> {
const DEFAULT_LRU_CACHE_MAX_AGE_SECONDS = 10;
const DEFAULT_LRU_CACHE_SIZE = 10000;
const maxAgeSeconds = options.ttlSeconds ?? DEFAULT_LRU_CACHE_MAX_AGE_SECONDS;
const lruCacheOptions: LRUCacheOptionsV7<string, 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);
}
static createNoOpCache<TFields>(): LocalMemoryCache<TFields> {
return new LRUCache<string, LocalMemoryCacheValue<TFields>>({
max: 0,
maxAge: -1,
});
}
public async loadManyAsync(
keys: readonly string[]
): Promise<ReadonlyMap<string, CacheLoadResult<TFields>>> {
const cacheResults = new Map<string, CacheLoadResult<TFields>>();
for (const key of keys) {
const cacheResult = this.localMemoryCache.get(key);
if (cacheResult === DOES_NOT_EXIST_LOCAL_MEMORY_CACHE) {
cacheResults.set(key, {
status: CacheStatus.NEGATIVE,
});
} else if (cacheResult) {
cacheResults.set(key, {
status: CacheStatus.HIT,
item: cacheResult as unknown as TFields,
});
} else {
cacheResults.set(key, {
status: CacheStatus.MISS,
});
}
}
return cacheResults;
}
public async cacheManyAsync(objectMap: ReadonlyMap<string, Readonly<TFields>>): Promise<void> {
for (const [key, item] of objectMap) {
this.localMemoryCache.set(key, item);
}
}
public async cacheDBMissesAsync(keys: readonly string[]): Promise<void> {
for (const key of keys) {
this.localMemoryCache.set(key, DOES_NOT_EXIST_LOCAL_MEMORY_CACHE);
}
}
public async invalidateManyAsync(keys: readonly string[]): Promise<void> {
for (const key of keys) {
this.localMemoryCache.del(key);
}
}
public makeCacheKey(parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`)
);
return escapedParts.join(delimiter);
}
}
@@ -0,0 +1,80 @@
import { EntityCacheAdapter, CacheLoadResult, EntityConfiguration, mapKeys } from '@expo/entity';
import invariant from 'invariant';
import GenericLocalMemoryCacher, { LocalMemoryCache } from './GenericLocalMemoryCacher';
export default class LocalMemoryCacheAdapter<TFields> extends EntityCacheAdapter<TFields> {
private readonly genericLocalMemoryCacher: GenericLocalMemoryCacher<TFields>;
constructor(
entityConfiguration: EntityConfiguration<TFields>,
localMemoryCache: LocalMemoryCache<TFields>
) {
super(entityConfiguration);
this.genericLocalMemoryCacher = new GenericLocalMemoryCacher(localMemoryCache);
}
public async loadManyAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult<TFields>>> {
const localMemoryCacheKeyToFieldValueMapping = new Map(
fieldValues.map((fieldValue) => [this.makeCacheKey(fieldName, fieldValue), fieldValue])
);
const cacheResults = await this.genericLocalMemoryCacher.loadManyAsync(
Array.from(localMemoryCacheKeyToFieldValueMapping.keys())
);
return mapKeys(cacheResults, (cacheKey) => {
const fieldValue = localMemoryCacheKeyToFieldValueMapping.get(cacheKey);
invariant(
fieldValue !== undefined,
'Unspecified cache key %s returned from generic local memory cacher',
cacheKey
);
return fieldValue;
});
}
public async cacheManyAsync<N extends keyof TFields>(
fieldName: N,
objectMap: ReadonlyMap<NonNullable<TFields[N]>, Readonly<TFields>>
): Promise<void> {
await this.genericLocalMemoryCacher.cacheManyAsync(
mapKeys(objectMap, (fieldValue) => this.makeCacheKey(fieldName, fieldValue))
);
}
public async cacheDBMissesAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<void> {
await this.genericLocalMemoryCacher.cacheDBMissesAsync(
fieldValues.map((fieldValue) => this.makeCacheKey(fieldName, fieldValue))
);
}
public async invalidateManyAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<void> {
await this.genericLocalMemoryCacher.invalidateManyAsync(
fieldValues.map((fieldValue) => this.makeCacheKey(fieldName, fieldValue))
);
}
private makeCacheKey<N extends keyof TFields>(
fieldName: N,
fieldValue: NonNullable<TFields[N]>
): string {
const columnName = this.entityConfiguration.entityToDBFieldsKeyMapping.get(fieldName);
invariant(columnName, `database field mapping missing for ${fieldName}`);
const parts = [
this.entityConfiguration.tableName,
`${this.entityConfiguration.cacheKeyVersion}`,
columnName,
String(fieldValue),
];
return this.genericLocalMemoryCacher.makeCacheKey(parts);
}
}
@@ -0,0 +1,45 @@
import {
computeIfAbsent,
EntityCacheAdapter,
EntityConfiguration,
IEntityCacheAdapterProvider,
} from '@expo/entity';
import GenericLocalMemoryCacher, { LocalMemoryCache } from './GenericLocalMemoryCacher';
import LocalMemoryCacheAdapter from './LocalMemoryCacheAdapter';
export default class LocalMemoryCacheAdapterProvider implements IEntityCacheAdapterProvider {
// local memory cache adapters should be shared/reused across requests
static localMemoryCacheAdapterMap = new Map<string, LocalMemoryCacheAdapter<any>>();
static getNoOpProvider(): IEntityCacheAdapterProvider {
return new LocalMemoryCacheAdapterProvider(<TFields>() =>
GenericLocalMemoryCacher.createNoOpCache<TFields>()
);
}
static getProvider(
options: { maxSize?: number; ttlSeconds?: number } = {}
): IEntityCacheAdapterProvider {
return new LocalMemoryCacheAdapterProvider(<TFields>() =>
GenericLocalMemoryCacher.createLRUCache<TFields>(options)
);
}
private constructor(
private readonly localMemoryCacheCreator: <TFields>() => LocalMemoryCache<TFields>
) {}
public getCacheAdapter<TFields>(
entityConfiguration: EntityConfiguration<TFields>
): EntityCacheAdapter<TFields> {
return computeIfAbsent(
LocalMemoryCacheAdapterProvider.localMemoryCacheAdapterMap,
entityConfiguration.tableName,
() => {
const localMemoryCache = this.localMemoryCacheCreator<TFields>();
return new LocalMemoryCacheAdapter(entityConfiguration, localMemoryCache);
}
);
}
}
@@ -0,0 +1,180 @@
import { CacheStatus, ViewerContext } from '@expo/entity';
import { v4 as uuidv4 } from 'uuid';
import GenericLocalMemoryCacher from '../GenericLocalMemoryCacher';
import LocalMemoryCacheAdapter from '../LocalMemoryCacheAdapter';
import { LocalMemoryCacheAdapterProvider } from '../LocalMemoryCacheAdapterProvider';
import LocalMemoryTestEntity from '../testfixtures/LocalMemoryTestEntity';
import {
createLocalMemoryIntegrationTestEntityCompanionProvider,
createNoopLocalMemoryIntegrationTestEntityCompanionProvider,
} from '../testfixtures/createLocalMemoryIntegrationTestEntityCompanionProvider';
class TestViewerContext extends ViewerContext {}
describe(LocalMemoryCacheAdapter, () => {
beforeEach(async () => {
LocalMemoryCacheAdapterProvider.localMemoryCacheAdapterMap.clear();
});
it('has correct caching behavior', async () => {
const viewerContext = new TestViewerContext(
createLocalMemoryIntegrationTestEntityCompanionProvider()
);
const cacheAdapter = viewerContext.entityCompanionProvider.getCompanionForEntity(
LocalMemoryTestEntity,
LocalMemoryTestEntity.getCompanionDefinition()
)['tableDataCoordinator']['cacheAdapter'];
const cacheKeyMaker = cacheAdapter['makeCacheKey'].bind(cacheAdapter);
const date = new Date();
const entity1Created = await LocalMemoryTestEntity.creator(viewerContext)
.setField('name', 'blah')
.setField('dateField', date)
.enforceCreateAsync();
// loading an entity should put it in cache
const entity1 = await LocalMemoryTestEntity.loader(viewerContext)
.enforcing()
.loadByIDAsync(entity1Created.getID());
const entitySpecificGenericCacher =
LocalMemoryCacheAdapterProvider.localMemoryCacheAdapterMap.get(
LocalMemoryTestEntity.getCompanionDefinition().entityConfiguration.tableName
)!['genericLocalMemoryCacher'];
const cachedResult = await entitySpecificGenericCacher.loadManyAsync([
cacheKeyMaker('id', entity1.getID()),
]);
const cachedValue = cachedResult.get(cacheKeyMaker('id', entity1.getID()))!;
expect(cachedValue).toMatchObject({
status: CacheStatus.HIT,
item: {
id: entity1.getID(),
name: 'blah',
dateField: date,
},
});
// simulate non existent db fetch, should write negative result ('') to cache
const nonExistentId = uuidv4();
const entityNonExistentResult = await LocalMemoryTestEntity.loader(viewerContext).loadByIDAsync(
nonExistentId
);
expect(entityNonExistentResult.ok).toBe(false);
const nonExistentCachedResult = await entitySpecificGenericCacher.loadManyAsync([
cacheKeyMaker('id', nonExistentId),
]);
expect(nonExistentCachedResult.get(cacheKeyMaker('id', nonExistentId))).toMatchObject({
status: CacheStatus.NEGATIVE,
});
// load again through entities framework to ensure it reads negative result
const entityNonExistentResult2 = await LocalMemoryTestEntity.loader(
viewerContext
).loadByIDAsync(nonExistentId);
expect(entityNonExistentResult2.ok).toBe(false);
// invalidate from cache to ensure it invalidates correctly
await LocalMemoryTestEntity.loader(viewerContext).invalidateFieldsAsync(entity1.getAllFields());
const cachedResultMiss = await entitySpecificGenericCacher.loadManyAsync([
cacheKeyMaker('id', entity1.getID()),
]);
const cachedValueMiss = cachedResultMiss.get(cacheKeyMaker('id', entity1.getID()));
expect(cachedValueMiss).toMatchObject({ status: CacheStatus.MISS });
});
it('shares the cache between different requests', async () => {
const genericLocalMemoryCacherLoadManySpy = jest.spyOn(
GenericLocalMemoryCacher.prototype as unknown as any,
'loadManyAsync'
);
const viewerContext = new TestViewerContext(
createLocalMemoryIntegrationTestEntityCompanionProvider()
);
const date = new Date();
const entity1Created = await LocalMemoryTestEntity.creator(viewerContext)
.setField('name', 'blah')
.setField('dateField', date)
.enforceCreateAsync();
// loading an entity should put it in cache
await LocalMemoryTestEntity.loader(viewerContext)
.enforcing()
.loadByIDAsync(entity1Created.getID());
// load entity with a different request
const viewerContext2 = new TestViewerContext(
createLocalMemoryIntegrationTestEntityCompanionProvider()
);
const entity1WithVc2 = await LocalMemoryTestEntity.loader(viewerContext2)
.enforcing()
.loadByIDAsync(entity1Created.getID());
const cacheAdapter = viewerContext.entityCompanionProvider.getCompanionForEntity(
LocalMemoryTestEntity,
LocalMemoryTestEntity.getCompanionDefinition()
)['tableDataCoordinator']['cacheAdapter'];
const cacheKeyMaker = cacheAdapter['makeCacheKey'].bind(cacheAdapter);
expect(entity1WithVc2.getAllFields()).toMatchObject({
id: entity1WithVc2.getID(),
name: 'blah',
dateField: date,
});
expect(genericLocalMemoryCacherLoadManySpy).toBeCalledWith([
cacheKeyMaker('id', entity1WithVc2.getID()),
]);
expect(genericLocalMemoryCacherLoadManySpy).toBeCalledTimes(2);
});
it('respects the parameters of a noop cache', async () => {
const viewerContext = new TestViewerContext(
createNoopLocalMemoryIntegrationTestEntityCompanionProvider()
);
const cacheAdapter = viewerContext.entityCompanionProvider.getCompanionForEntity(
LocalMemoryTestEntity,
LocalMemoryTestEntity.getCompanionDefinition()
)['tableDataCoordinator']['cacheAdapter'];
const cacheKeyMaker = cacheAdapter['makeCacheKey'].bind(cacheAdapter);
const date = new Date();
const entity1Created = await LocalMemoryTestEntity.creator(viewerContext)
.setField('name', 'blah')
.setField('dateField', date)
.enforceCreateAsync();
// loading an entity will try to put it in cache but it's a noop cache, so it should be a miss
const entity1 = await LocalMemoryTestEntity.loader(viewerContext)
.enforcing()
.loadByIDAsync(entity1Created.getID());
const entitySpecificGenericCacher =
LocalMemoryCacheAdapterProvider.localMemoryCacheAdapterMap.get(
LocalMemoryTestEntity.getCompanionDefinition().entityConfiguration.tableName
)!['genericLocalMemoryCacher'];
const cachedResult = await entitySpecificGenericCacher.loadManyAsync([
cacheKeyMaker('id', entity1.getID()),
]);
const cachedValue = cachedResult.get(cacheKeyMaker('id', entity1.getID()))!;
expect(cachedValue).toMatchObject({
status: CacheStatus.MISS,
});
// a non existent db fetch should try to write negative result ('') but it's a noop cache, so it should be a miss
const nonExistentId = uuidv4();
const entityNonExistentResult = await LocalMemoryTestEntity.loader(viewerContext).loadByIDAsync(
nonExistentId
);
expect(entityNonExistentResult.ok).toBe(false);
const nonExistentCachedResult = await entitySpecificGenericCacher.loadManyAsync([
cacheKeyMaker('id', nonExistentId),
]);
expect(nonExistentCachedResult.get(cacheKeyMaker('id', nonExistentId))).toMatchObject({
status: CacheStatus.MISS,
});
});
});
@@ -0,0 +1,125 @@
import { CacheStatus, UUIDField, EntityConfiguration } from '@expo/entity';
import GenericLocalMemoryCacher, {
DOES_NOT_EXIST_LOCAL_MEMORY_CACHE,
} from '../GenericLocalMemoryCacher';
import LocalMemoryCacheAdapter from '../LocalMemoryCacheAdapter';
type BlahFields = {
id: string;
};
const entityConfiguration = new EntityConfiguration<BlahFields>({
idField: 'id',
tableName: 'blah',
schema: {
id: new UUIDField({ columnName: 'id', cache: true }),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'local-memory',
});
describe(LocalMemoryCacheAdapter, () => {
describe('loadManyAsync', () => {
it('returns appropriate cache results', async () => {
const cacheAdapter = new LocalMemoryCacheAdapter(
entityConfiguration,
GenericLocalMemoryCacher.createLRUCache({
maxSize: Number.MAX_SAFE_INTEGER,
ttlSeconds: Number.MAX_SAFE_INTEGER,
})
);
const cacheHits = new Map<string, Readonly<BlahFields>>([['test-id-1', { id: 'test-id-1' }]]);
await cacheAdapter.cacheManyAsync('id', cacheHits);
await cacheAdapter.cacheDBMissesAsync('id', ['test-id-2']);
const results = await cacheAdapter.loadManyAsync('id', [
'test-id-1',
'test-id-2',
'test-id-3',
]);
expect(results.get('test-id-1')).toMatchObject({
status: CacheStatus.HIT,
item: { id: 'test-id-1' },
});
expect(results.get('test-id-2')).toMatchObject({ status: CacheStatus.NEGATIVE });
expect(results.get('test-id-3')).toMatchObject({ status: CacheStatus.MISS });
expect(results.size).toBe(3);
});
it('returns empty map when passed empty array of fieldValues', async () => {
const cacheAdapter = new LocalMemoryCacheAdapter(
entityConfiguration,
GenericLocalMemoryCacher.createLRUCache({
maxSize: Number.MAX_SAFE_INTEGER,
ttlSeconds: Number.MAX_SAFE_INTEGER,
})
);
const results = await cacheAdapter.loadManyAsync('id', []);
expect(results).toEqual(new Map());
});
});
describe('cacheManyAsync', () => {
it('correctly caches all objects', async () => {
const localMemoryCache = GenericLocalMemoryCacher.createLRUCache<BlahFields>({
maxSize: Number.MAX_SAFE_INTEGER,
ttlSeconds: Number.MAX_SAFE_INTEGER,
});
const cacheAdapter = new LocalMemoryCacheAdapter(entityConfiguration, localMemoryCache);
await cacheAdapter.cacheManyAsync('id', new Map([['test-id-1', { id: 'test-id-1' }]]));
const cacheKey = cacheAdapter['makeCacheKey']('id', 'test-id-1');
expect(localMemoryCache.get(cacheKey)).toMatchObject({
id: 'test-id-1',
});
});
});
describe('cacheDBMissesAsync', () => {
it('correctly caches misses', async () => {
const localMemoryCache = GenericLocalMemoryCacher.createLRUCache<BlahFields>({
maxSize: Number.MAX_SAFE_INTEGER,
ttlSeconds: Number.MAX_SAFE_INTEGER,
});
const cacheAdapter = new LocalMemoryCacheAdapter(entityConfiguration, localMemoryCache);
await cacheAdapter.cacheDBMissesAsync('id', ['test-id-1']);
const cacheKey = cacheAdapter['makeCacheKey']('id', 'test-id-1');
expect(localMemoryCache.get(cacheKey)).toEqual(DOES_NOT_EXIST_LOCAL_MEMORY_CACHE);
});
});
describe('invalidateManyAsync', () => {
it('invalidates correctly', async () => {
const localMemoryCache = GenericLocalMemoryCacher.createLRUCache<BlahFields>({
maxSize: Number.MAX_SAFE_INTEGER,
ttlSeconds: Number.MAX_SAFE_INTEGER,
});
const cacheAdapter = new LocalMemoryCacheAdapter(entityConfiguration, localMemoryCache);
await cacheAdapter.cacheManyAsync('id', new Map([['test-id-1', { id: 'test-id-1' }]]));
await cacheAdapter.cacheDBMissesAsync('id', ['test-id-2']);
await cacheAdapter.invalidateManyAsync('id', ['test-id-1', 'test-id-2']);
const results = await cacheAdapter.loadManyAsync('id', ['test-id-1', 'test-id-2']);
expect(results.get('test-id-1')).toMatchObject({ status: CacheStatus.MISS });
expect(results.get('test-id-2')).toMatchObject({ status: CacheStatus.MISS });
});
it('returns when passed empty array of fieldValues', async () => {
const cacheAdapter = new LocalMemoryCacheAdapter(
entityConfiguration,
GenericLocalMemoryCacher.createLRUCache<BlahFields>({
maxSize: Number.MAX_SAFE_INTEGER,
ttlSeconds: Number.MAX_SAFE_INTEGER,
})
);
await cacheAdapter.invalidateManyAsync('id', []);
});
});
});
@@ -0,0 +1,10 @@
/* eslint-disable tsdoc/syntax */
/**
* @packageDocumentation
* @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';
@@ -0,0 +1,100 @@
import {
AlwaysAllowPrivacyPolicyRule,
EntityPrivacyPolicy,
ViewerContext,
UUIDField,
DateField,
StringField,
EntityConfiguration,
EntityCompanionDefinition,
Entity,
} from '@expo/entity';
export type LocalMemoryTestEntityFields = {
id: string;
name: string;
dateField: Date | null;
};
export default class LocalMemoryTestEntity extends Entity<
LocalMemoryTestEntityFields,
string,
ViewerContext
> {
static getCompanionDefinition(): EntityCompanionDefinition<
LocalMemoryTestEntityFields,
string,
ViewerContext,
LocalMemoryTestEntity,
LocalMemoryTestEntityPrivacyPolicy
> {
return localMemoryTestEntityCompanionDefinition;
}
}
export class LocalMemoryTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
LocalMemoryTestEntityFields,
string,
ViewerContext,
LocalMemoryTestEntity
> {
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<
LocalMemoryTestEntityFields,
string,
ViewerContext,
LocalMemoryTestEntity
>(),
];
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<
LocalMemoryTestEntityFields,
string,
ViewerContext,
LocalMemoryTestEntity
>(),
];
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<
LocalMemoryTestEntityFields,
string,
ViewerContext,
LocalMemoryTestEntity
>(),
];
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<
LocalMemoryTestEntityFields,
string,
ViewerContext,
LocalMemoryTestEntity
>(),
];
}
export const localMemoryTestEntityConfiguration =
new EntityConfiguration<LocalMemoryTestEntityFields>({
idField: 'id',
tableName: 'local_memory_test_entities',
schema: {
id: new UUIDField({
columnName: 'id',
cache: true,
}),
name: new StringField({
columnName: 'name',
cache: true,
}),
dateField: new DateField({
columnName: 'date_field',
}),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'local-memory',
});
const localMemoryTestEntityCompanionDefinition = new EntityCompanionDefinition({
entityClass: LocalMemoryTestEntity,
entityConfiguration: localMemoryTestEntityConfiguration,
privacyPolicyClass: LocalMemoryTestEntityPrivacyPolicy,
});
@@ -0,0 +1,48 @@
import {
NoOpEntityMetricsAdapter,
IEntityMetricsAdapter,
EntityCompanionProvider,
StubQueryContextProvider,
StubDatabaseAdapterProvider,
} from '@expo/entity';
import { LocalMemoryCacheAdapterProvider } from '../LocalMemoryCacheAdapterProvider';
export const createLocalMemoryIntegrationTestEntityCompanionProvider = (
localMemoryOptions: { maxSize?: number; ttlSeconds?: number } = {},
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter()
): EntityCompanionProvider => {
const localMemoryCacheAdapterProvider =
localMemoryOptions.maxSize === 0 && localMemoryOptions.ttlSeconds === 0
? LocalMemoryCacheAdapterProvider.getNoOpProvider()
: LocalMemoryCacheAdapterProvider.getProvider(localMemoryOptions);
return new EntityCompanionProvider(
metricsAdapter,
new Map([
[
'postgres',
{
adapterProvider: new StubDatabaseAdapterProvider(),
queryContextProvider: StubQueryContextProvider,
},
],
]),
new Map([
[
'local-memory',
{
cacheAdapterProvider: localMemoryCacheAdapterProvider,
},
],
])
);
};
export const createNoopLocalMemoryIntegrationTestEntityCompanionProvider = (
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter()
): EntityCompanionProvider => {
return createLocalMemoryIntegrationTestEntityCompanionProvider(
{ maxSize: 0, ttlSeconds: 0 },
metricsAdapter
);
};
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "build",
},
"include": ["src"]
}
@@ -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.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
### Chores
* Refactor cachers to use an abstract class and interface ([#157](https://github.com/expo/entity/pull/157))
# [0.23.0](https://github.com/expo/entity/compare/v0.22.0...v0.23.0) (2022-02-09)
### Chores
* Move data transforming responsibilities to the CacheAdapter instead of the ReadThroughEntityCache. ([#153](https://github.com/expo/entity/pull/153))
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.21.0](https://github.com/expo/entity/compare/v0.20.0...v0.21.0) (2022-01-03)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.20.0](https://github.com/expo/entity/compare/v0.19.0...v0.20.0) (2021-12-29)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.19.0](https://github.com/expo/entity/compare/v0.18.0...v0.19.0) (2021-10-28)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.18.0](https://github.com/expo/entity/compare/v0.17.0...v0.18.0) (2021-10-14)
### Features
* enable noImplicitOverride tsc setting ([#135](https://github.com/expo/entity/issues/135)) ([4263cb9](https://github.com/expo/entity/commit/4263cb9b8b69fe4fb68c74ec1c7aba04508a1555))
* upgrade TypeScript to 4.4 ([#134](https://github.com/expo/entity/issues/134)) ([7612392](https://github.com/expo/entity/commit/7612392dbfd9778d25c465c1626c372d75a6d05a))
# [0.17.0](https://github.com/expo/entity/compare/v0.16.0...v0.17.0) (2021-08-16)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.16.0](https://github.com/expo/entity/compare/v0.15.0...v0.16.0) (2021-07-07)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
@@ -12,7 +12,7 @@ During `EntityCompanionProvider` instantiation:
import Redis from 'ioredis';
const redisCacheAdapterContext = {
redisClient: new Redis(new URL(process.env.REDIS_URL!).toString()),
redisClient: new Redis(new URL(process.env['REDIS_URL']!).toString()),
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
@@ -1,6 +1,6 @@
{
"name": "@expo/entity-cache-adapter-redis",
"version": "0.16.0",
"version": "0.24.0",
"description": "Redis cache adapter for @expo/entity",
"files": [
"build",
@@ -12,6 +12,7 @@
"tsc": "tsc",
"clean": "rm -rf build coverage coverage-integration",
"lint": "eslint src",
"lint-fix": "eslint src --fix",
"test": "jest --rootDir . --config ../../resources/jest.config.js",
"integration": "../../resources/run-with-docker yarn integration-no-setup",
"integration-no-setup": "jest --config ../../resources/jest-integration.config.js --rootDir . --runInBand --passWithNoTests",
@@ -32,6 +33,6 @@
"ioredis": "^4.27.3"
},
"devDependencies": {
"@expo/entity": "^0.16.0"
"@expo/entity": "^0.24.0"
}
}
@@ -1,7 +1,15 @@
import { CacheLoadResult, CacheStatus } from '@expo/entity';
import {
CacheLoadResult,
CacheStatus,
EntityConfiguration,
transformCacheObjectToFields,
transformFieldsToCacheObject,
IEntityGenericCacher,
} from '@expo/entity';
import { Redis } from 'ioredis';
import wrapNativeRedisCall from './errors/wrapNativeRedisCall';
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.
@@ -26,21 +34,26 @@ export interface GenericRedisCacheContext {
ttlSecondsNegative: number;
}
export default class GenericRedisCacher {
constructor(private readonly context: GenericRedisCacheContext) {}
export default class GenericRedisCacher<TFields> implements IEntityGenericCacher<TFields> {
constructor(
private readonly context: GenericRedisCacheContext,
private readonly entityConfiguration: EntityConfiguration<TFields>
) {}
public async loadManyAsync(
keys: readonly string[]
): Promise<ReadonlyMap<string, CacheLoadResult>> {
): Promise<ReadonlyMap<string, CacheLoadResult<TFields>>> {
if (keys.length === 0) {
return new Map();
}
const redisResults = await wrapNativeRedisCall(() => this.context.redisClient.mget(...keys));
const redisResults = await wrapNativeRedisCallAsync(() =>
this.context.redisClient.mget(...keys)
);
const results = new Map<string, CacheLoadResult>();
const results = new Map<string, CacheLoadResult<TFields>>();
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
const key = keys[i]!;
const redisResult = redisResults[i];
if (redisResult === DOES_NOT_EXIST_REDIS) {
@@ -50,7 +63,11 @@ export default class GenericRedisCacher {
} else if (redisResult) {
results.set(key, {
status: CacheStatus.HIT,
item: JSON.parse(redisResult),
item: transformCacheObjectToFields(
this.entityConfiguration,
redisTransformerMap,
JSON.parse(redisResult)
),
});
} else {
results.set(key, {
@@ -61,7 +78,7 @@ export default class GenericRedisCacher {
return results;
}
public async cacheManyAsync(objectMap: ReadonlyMap<string, object>): Promise<void> {
public async cacheManyAsync(objectMap: ReadonlyMap<string, Readonly<TFields>>): Promise<void> {
if (objectMap.size === 0) {
return;
}
@@ -70,15 +87,17 @@ export default class GenericRedisCacher {
objectMap.forEach((object, key) => {
redisTransaction = redisTransaction.set(
key,
JSON.stringify(object),
JSON.stringify(
transformFieldsToCacheObject(this.entityConfiguration, redisTransformerMap, object)
),
'EX',
this.context.ttlSecondsPositive
);
});
await wrapNativeRedisCall(() => redisTransaction.exec());
await wrapNativeRedisCallAsync(() => redisTransaction.exec());
}
public async cacheDBMissesAsync(keys: string[]): Promise<void> {
public async cacheDBMissesAsync(keys: readonly string[]): Promise<void> {
if (keys.length === 0) {
return;
}
@@ -92,14 +111,14 @@ export default class GenericRedisCacher {
this.context.ttlSecondsNegative
);
});
await wrapNativeRedisCall(() => redisTransaction.exec());
await wrapNativeRedisCallAsync(() => redisTransaction.exec());
}
public async invalidateManyAsync(keys: string[]): Promise<void> {
public async invalidateManyAsync(keys: readonly string[]): Promise<void> {
if (keys.length === 0) {
return;
}
await wrapNativeRedisCall(() => this.context.redisClient.del(...keys));
await wrapNativeRedisCallAsync(() => this.context.redisClient.del(...keys));
}
}
@@ -1,15 +1,8 @@
import {
EntityCacheAdapter,
EntityConfiguration,
CacheLoadResult,
FieldTransformerMap,
mapKeys,
} from '@expo/entity';
import { EntityCacheAdapter, EntityConfiguration, CacheLoadResult, mapKeys } from '@expo/entity';
import invariant from 'invariant';
import { Redis } from 'ioredis';
import GenericRedisCacher from './GenericRedisCacher';
import { redisTransformerMap } from './RedisCommon';
export interface RedisCacheAdapterContext {
/**
@@ -49,28 +42,27 @@ export interface RedisCacheAdapterContext {
}
export default class RedisCacheAdapter<TFields> extends EntityCacheAdapter<TFields> {
private readonly genericRedisCacher: GenericRedisCacher;
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,
});
}
public getFieldTransformerMap(): FieldTransformerMap {
return redisTransformerMap;
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>> {
): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult<TFields>>> {
const redisCacheKeyToFieldValueMapping = new Map(
fieldValues.map((fieldValue) => [this.makeCacheKey(fieldName, fieldValue), fieldValue])
);
@@ -91,7 +83,7 @@ export default class RedisCacheAdapter<TFields> extends EntityCacheAdapter<TFiel
public async cacheManyAsync<N extends keyof TFields>(
fieldName: N,
objectMap: ReadonlyMap<NonNullable<TFields[N]>, object>
objectMap: ReadonlyMap<NonNullable<TFields[N]>, Readonly<TFields>>
): Promise<void> {
await this.genericRedisCacher.cacheManyAsync(
mapKeys(objectMap, (fieldValue) => this.makeCacheKey(fieldName, fieldValue))
@@ -0,0 +1,125 @@
import { CacheStatus, ViewerContext } from '@expo/entity';
import Redis from 'ioredis';
import { URL } from 'url';
import GenericRedisCacher from '../GenericRedisCacher';
import { RedisCacheAdapterContext } from '../RedisCacheAdapter';
import RedisTestEntity, {
redisTestEntityConfiguration,
RedisTestEntityFields,
} from '../testfixtures/RedisTestEntity';
import { createRedisIntegrationTestEntityCompanionProvider } from '../testfixtures/createRedisIntegrationTestEntityCompanionProvider';
class TestViewerContext extends ViewerContext {}
describe(GenericRedisCacher, () => {
let redisCacheAdapterContext: RedisCacheAdapterContext;
beforeAll(() => {
redisCacheAdapterContext = {
redisClient: new Redis(new URL(process.env['REDIS_URL']!).toString()),
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`)
);
return escapedParts.join(delimiter);
},
cacheKeyPrefix: 'test-',
cacheKeyVersion: 1,
ttlSecondsPositive: 86400, // 1 day
ttlSecondsNegative: 600, // 10 minutes
};
});
beforeEach(async () => {
await redisCacheAdapterContext.redisClient.flushdb();
});
afterAll(async () => {
redisCacheAdapterContext.redisClient.disconnect();
});
it('has correct caching and loading behavior', async () => {
const viewerContext = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
);
const genericRedisCacher = new GenericRedisCacher(
{
redisClient: redisCacheAdapterContext.redisClient,
ttlSecondsNegative: redisCacheAdapterContext.ttlSecondsNegative,
ttlSecondsPositive: redisCacheAdapterContext.ttlSecondsPositive,
},
redisTestEntityConfiguration
);
const date = new Date();
const entity1Created = await RedisTestEntity.creator(viewerContext)
.setField('name', 'blah')
.setField('dateField', date)
.enforceCreateAsync();
const testKey = `test-id-key-${entity1Created.getID()}`;
const objectMap = new Map<string, Readonly<RedisTestEntityFields>>([
[testKey, entity1Created.getAllFields()],
]);
await genericRedisCacher.cacheManyAsync(objectMap);
const cachedJSON = await redisCacheAdapterContext.redisClient.get(testKey);
const cachedValue = JSON.parse(cachedJSON!);
expect(cachedValue).toMatchObject({
id: entity1Created.getID(),
dateField: date.toISOString(),
});
const loadedObjectMap = await genericRedisCacher.loadManyAsync([testKey]);
const cacheLoadResult = loadedObjectMap.get(testKey)!;
expect(cacheLoadResult).toMatchObject({
status: CacheStatus.HIT,
item: entity1Created.getAllFields(),
});
expect(loadedObjectMap.size).toBe(1);
});
it('has correct negative caching behaviour', async () => {
const genericRedisCacher = new GenericRedisCacher(
{
redisClient: redisCacheAdapterContext.redisClient,
ttlSecondsNegative: redisCacheAdapterContext.ttlSecondsNegative,
ttlSecondsPositive: redisCacheAdapterContext.ttlSecondsPositive,
},
redisTestEntityConfiguration
);
const testKey = `test-id-key-non-existent-id`;
await genericRedisCacher.cacheDBMissesAsync([testKey]);
const loadedObjectMap = await genericRedisCacher.loadManyAsync([testKey]);
const cacheLoadResult = loadedObjectMap.get(testKey)!;
expect(cacheLoadResult.status).toBe(CacheStatus.NEGATIVE);
});
it('has correct invalidation behaviour', async () => {
const viewerContext = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
);
const genericRedisCacher = new GenericRedisCacher(
{
redisClient: redisCacheAdapterContext.redisClient,
ttlSecondsNegative: redisCacheAdapterContext.ttlSecondsNegative,
ttlSecondsPositive: redisCacheAdapterContext.ttlSecondsPositive,
},
redisTestEntityConfiguration
);
const date = new Date();
const entity1Created = await RedisTestEntity.creator(viewerContext)
.setField('name', 'blah')
.setField('dateField', date)
.enforceCreateAsync();
const testKey = `test-id-key-${entity1Created.getID()}`;
const objectMap = new Map<string, Readonly<RedisTestEntityFields>>([
[testKey, entity1Created.getAllFields()],
]);
await genericRedisCacher.cacheManyAsync(objectMap);
await genericRedisCacher.invalidateManyAsync([testKey]);
const loadedObjectMap = await genericRedisCacher.loadManyAsync([testKey]);
const cacheLoadResult = loadedObjectMap.get(testKey)!;
expect(cacheLoadResult.status).toBe(CacheStatus.MISS);
});
});
@@ -15,7 +15,7 @@ describe(RedisCacheAdapter, () => {
beforeAll(() => {
redisCacheAdapterContext = {
redisClient: new Redis(new URL(process.env.REDIS_URL!).toString()),
redisClient: new Redis(new URL(process.env['REDIS_URL']!).toString()),
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
@@ -13,7 +13,7 @@ describe(RedisCacheAdapter, () => {
beforeAll(() => {
redisCacheAdapterContext = {
redisClient: new Redis(new URL(process.env.REDIS_URL!).toString()),
redisClient: new Redis(new URL(process.env['REDIS_URL']!).toString()),
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
@@ -0,0 +1,39 @@
import { EntityCacheAdapterTransientError } from '@expo/entity';
import wrapNativeRedisCallAsync from '../wrapNativeRedisCallAsync';
describe(wrapNativeRedisCallAsync, () => {
it('rethrows literals', async () => {
const throwingFn = async (): Promise<void> => {
// eslint-disable-next-line no-throw-literal,@typescript-eslint/no-throw-literal
throw 'hello';
};
let capturedThrownThing: any;
try {
await wrapNativeRedisCallAsync(throwingFn);
} catch (e) {
capturedThrownThing = e;
}
expect(capturedThrownThing).not.toBeInstanceOf(Error);
expect(capturedThrownThing).toEqual('hello');
});
it('wraps errors with stacks', async () => {
const throwingFn = async (): Promise<void> => {
const e = new Error('hello');
e.stack = 'world';
throw e;
};
let capturedThrownThing: any;
try {
await wrapNativeRedisCallAsync(throwingFn);
} catch (e) {
capturedThrownThing = e;
}
expect(capturedThrownThing).toBeInstanceOf(EntityCacheAdapterTransientError);
expect(capturedThrownThing.message).toEqual('hello');
expect(capturedThrownThing.stack).toEqual('world');
});
});
@@ -1,11 +1,17 @@
import { EntityCacheAdapterTransientError } from '@expo/entity';
export default async function wrapNativeRedisCall<T>(fn: () => Promise<T>): Promise<T> {
export default async function wrapNativeRedisCallAsync<T>(fn: () => Promise<T>): Promise<T> {
try {
return await fn();
} catch (e) {
if (!(e instanceof Error)) {
throw e;
}
const error = new EntityCacheAdapterTransientError(e.message, e);
error.stack = e.stack;
if (e.stack) {
error.stack = e.stack;
}
throw error;
}
}
@@ -34,7 +34,7 @@ export class RedisTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
ViewerContext,
RedisTestEntity
> {
protected readonly createRules = [
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<
RedisTestEntityFields,
string,
@@ -42,7 +42,7 @@ export class RedisTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
RedisTestEntity
>(),
];
protected readonly readRules = [
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<
RedisTestEntityFields,
string,
@@ -50,7 +50,7 @@ export class RedisTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
RedisTestEntity
>(),
];
protected readonly updateRules = [
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<
RedisTestEntityFields,
string,
@@ -58,7 +58,7 @@ export class RedisTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
RedisTestEntity
>(),
];
protected readonly deleteRules = [
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<
RedisTestEntityFields,
string,
@@ -3,6 +3,86 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
# [0.23.0](https://github.com/expo/entity/compare/v0.22.0...v0.23.0) (2022-02-09)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
### Features
* Upgrade knex to 1.x in `@expo/entity-database-adapter-knex` ([#150](https://github.com/expo/entity/issues/150)) ([e1b0ee5](https://github.com/expo/entity/commit/e1b0ee528175646239d88aa71ffce73983eeb52f))
# [0.21.0](https://github.com/expo/entity/compare/v0.20.0...v0.21.0) (2022-01-03)
### Features
* add pre-commit callbacks on EntityQueryContext ([#147](https://github.com/expo/entity/issues/147)) ([f1d9847](https://github.com/expo/entity/commit/f1d9847210f5775de964509e44ce493e44482b21))
# [0.20.0](https://github.com/expo/entity/compare/v0.19.0...v0.20.0) (2021-12-29)
### Features
* add cascading deletion info ([#145](https://github.com/expo/entity/issues/145)) ([3727191](https://github.com/expo/entity/commit/372719176742d3ead6b7cbca29cb66b09b3fe09c))
# [0.19.0](https://github.com/expo/entity/compare/v0.18.0...v0.19.0) (2021-10-28)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
# [0.18.0](https://github.com/expo/entity/compare/v0.17.0...v0.18.0) (2021-10-14)
### Features
* enable noImplicitOverride tsc setting ([#135](https://github.com/expo/entity/issues/135)) ([4263cb9](https://github.com/expo/entity/commit/4263cb9b8b69fe4fb68c74ec1c7aba04508a1555))
* upgrade TypeScript to 4.4 ([#134](https://github.com/expo/entity/issues/134)) ([7612392](https://github.com/expo/entity/commit/7612392dbfd9778d25c465c1626c372d75a6d05a))
# [0.17.0](https://github.com/expo/entity/compare/v0.16.0...v0.17.0) (2021-08-16)
### Features
* Add IntField and FloatField, deprecate NumberField ([#131](https://github.com/expo/entity/issues/131)) ([2f2d963](https://github.com/expo/entity/commit/2f2d963cf92c6575d78f8eeca344a4aadcc9b8d9))
# [0.16.0](https://github.com/expo/entity/compare/v0.15.0...v0.16.0) (2021-07-07)
@@ -14,11 +14,11 @@ import { knex, Knex } from 'knex';
const knexInstance = knex({
client: 'pg',
connection: {
user: process.env.PGUSER,
password: process.env.PGPASSWORD,
host: process.env.PGHOST,
port: parseInt(process.env.PGPORT!, 10),
database: process.env.PGDATABASE,
user: process.env['PGUSER'],
password: process.env['PGPASSWORD'],
host: process.env['PGHOST'],
port: parseInt(nullthrows(process.env['PGPORT']), 10),
database: process.env['PGDATABASE'],
},
});
@@ -1,6 +1,6 @@
{
"name": "@expo/entity-database-adapter-knex",
"version": "0.16.0",
"version": "0.24.0",
"description": "Knex database adapter for @expo/entity",
"files": [
"build",
@@ -12,6 +12,7 @@
"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",
@@ -29,9 +30,10 @@
"@expo/entity": "*"
},
"dependencies": {
"knex": "^0.95.6"
"knex": "^1.0.2"
},
"devDependencies": {
"@expo/entity": "^0.16.0"
"@expo/entity": "^0.24.0",
"pg": "^8.7.1"
}
}
@@ -10,7 +10,7 @@ import {
} from '@expo/entity';
import { Knex } from 'knex';
import wrapNativePostgresCall from './errors/wrapNativePostgresCall';
import wrapNativePostgresCallAsync from './errors/wrapNativePostgresCallAsync';
export default class PostgresEntityDatabaseAdapter<TFields> extends EntityDatabaseAdapter<TFields> {
protected getFieldTransformerMap(): FieldTransformerMap {
@@ -45,7 +45,7 @@ export default class PostgresEntityDatabaseAdapter<TFields> extends EntityDataba
tableField: string,
tableValues: readonly any[]
): Promise<object[]> {
return await wrapNativePostgresCall(() =>
return await wrapNativePostgresCallAsync(() =>
queryInterface
.select()
.from(tableName)
@@ -89,12 +89,10 @@ export default class PostgresEntityDatabaseAdapter<TFields> extends EntityDataba
if (tableFieldSingleValueEqualityOperands.length > 0) {
const whereObject: { [key: string]: any } = {};
const nonNullTableFieldSingleValueEqualityOperands = tableFieldSingleValueEqualityOperands.filter(
({ tableValue }) => tableValue !== null
);
const nullTableFieldSingleValueEqualityOperands = tableFieldSingleValueEqualityOperands.filter(
({ tableValue }) => tableValue === null
);
const nonNullTableFieldSingleValueEqualityOperands =
tableFieldSingleValueEqualityOperands.filter(({ tableValue }) => tableValue !== null);
const nullTableFieldSingleValueEqualityOperands =
tableFieldSingleValueEqualityOperands.filter(({ tableValue }) => tableValue === null);
if (nonNullTableFieldSingleValueEqualityOperands.length > 0) {
for (const { tableField, tableValue } of nonNullTableFieldSingleValueEqualityOperands) {
@@ -123,7 +121,7 @@ export default class PostgresEntityDatabaseAdapter<TFields> extends EntityDataba
}
query = this.applyQueryModifiersToQuery(query, querySelectionModifiers);
return await wrapNativePostgresCall(() => query);
return await wrapNativePostgresCallAsync(() => query);
}
protected async fetchManyByRawWhereClauseInternalAsync(
@@ -138,7 +136,7 @@ export default class PostgresEntityDatabaseAdapter<TFields> extends EntityDataba
.from(tableName)
.whereRaw(rawWhereClause, bindings as any);
query = this.applyQueryModifiersToQuery(query, querySelectionModifiers);
return await wrapNativePostgresCall(() => query);
return await wrapNativePostgresCallAsync(() => query);
}
protected async insertInternalAsync(
@@ -146,7 +144,7 @@ export default class PostgresEntityDatabaseAdapter<TFields> extends EntityDataba
tableName: string,
object: object
): Promise<object[]> {
return await wrapNativePostgresCall(() =>
return await wrapNativePostgresCallAsync(() =>
queryInterface.insert(object).into(tableName).returning('*')
);
}
@@ -158,7 +156,7 @@ export default class PostgresEntityDatabaseAdapter<TFields> extends EntityDataba
id: any,
object: object
): Promise<object[]> {
return await wrapNativePostgresCall(() =>
return await wrapNativePostgresCallAsync(() =>
queryInterface.update(object).into(tableName).where(tableIdField, id).returning('*')
);
}
@@ -169,7 +167,7 @@ export default class PostgresEntityDatabaseAdapter<TFields> extends EntityDataba
tableIdField: string,
id: any
): Promise<number> {
return await wrapNativePostgresCall(() =>
return await wrapNativePostgresCallAsync(() =>
queryInterface.into(tableName).where(tableIdField, id).del()
);
}
@@ -7,7 +7,8 @@ import {
import PostgresEntityDatabaseAdapter from './PostgresEntityDatabaseAdapter';
export default class PostgresEntityDatabaseAdapterProvider
implements IEntityDatabaseAdapterProvider {
implements IEntityDatabaseAdapterProvider
{
getDatabaseAdapter<TFields>(
entityConfiguration: EntityConfiguration<TFields>
): EntityDatabaseAdapter<TFields> {
@@ -6,6 +6,7 @@ import {
} from '@expo/entity';
import { enforceAsyncResult } from '@expo/results';
import { knex, Knex } from 'knex';
import nullthrows from 'nullthrows';
import PostgresTestEntity from '../testfixtures/PostgresTestEntity';
import PostgresTriggerTestEntity from '../testfixtures/PostgresTriggerTestEntity';
@@ -19,11 +20,11 @@ describe('postgres entity integration', () => {
knexInstance = knex({
client: 'pg',
connection: {
user: process.env.PGUSER,
password: process.env.PGPASSWORD,
user: nullthrows(process.env['PGUSER']),
password: nullthrows(process.env['PGPASSWORD']),
host: 'localhost',
port: parseInt(process.env.PGPORT!, 10),
database: process.env.PGDATABASE,
port: parseInt(nullthrows(process.env['PGPORT']), 10),
database: nullthrows(process.env['PGDATABASE']),
},
});
});
@@ -263,7 +264,7 @@ describe('postgres entity integration', () => {
.enforcing()
.loadManyByFieldEqualityConjunctionAsync([{ fieldName: 'name', fieldValue: null }]);
expect(results).toHaveLength(2);
expect(results[0].getField('name')).toBeNull();
expect(results[0]!.getField('name')).toBeNull();
const results2 = await PostgresTestEntity.loader(vc1)
.enforcing()
@@ -599,4 +600,37 @@ describe('postgres entity integration', () => {
});
});
});
describe('queryContext callback behavior', () => {
it('calls callbacks correctly', async () => {
const vc1 = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance));
let preCommitCallCount = 0;
let postCommitCallCount = 0;
await vc1.runInTransactionForDatabaseAdaptorFlavorAsync('postgres', async (queryContext) => {
queryContext.appendPostCommitCallback(async () => {
postCommitCallCount++;
});
queryContext.appendPreCommitCallback(async () => {
preCommitCallCount++;
}, 0);
});
await expect(
vc1.runInTransactionForDatabaseAdaptorFlavorAsync('postgres', async (queryContext) => {
queryContext.appendPostCommitCallback(async () => {
postCommitCallCount++;
});
queryContext.appendPreCommitCallback(async () => {
preCommitCallCount++;
throw Error('wat');
}, 0);
})
).rejects.toThrowError('wat');
expect(preCommitCallCount).toBe(2);
expect(postCommitCallCount).toBe(1);
});
});
});
@@ -1,6 +1,7 @@
import { ViewerContext } from '@expo/entity';
import { enforceAsyncResult } from '@expo/results';
import { knex, Knex } from 'knex';
import nullthrows from 'nullthrows';
import InvalidTestEntity from '../testfixtures/InvalidTestEntity';
import { createKnexIntegrationTestEntityCompanionProvider } from '../testfixtures/createKnexIntegrationTestEntityCompanionProvider';
@@ -12,11 +13,11 @@ describe('postgres entity integration', () => {
knexInstance = knex({
client: 'pg',
connection: {
user: process.env.PGUSER,
password: process.env.PGPASSWORD,
user: nullthrows(process.env['PGUSER']),
password: nullthrows(process.env['PGPASSWORD']),
host: 'localhost',
port: parseInt(process.env.PGPORT!, 10),
database: process.env.PGDATABASE,
port: parseInt(nullthrows(process.env['PGPORT']), 10),
database: nullthrows(process.env['PGDATABASE']),
},
});
});
@@ -9,6 +9,7 @@ import {
EntityDatabaseAdapterUnknownError,
} from '@expo/entity';
import { knex, Knex } from 'knex';
import nullthrows from 'nullthrows';
import ErrorsTestEntity from '../testfixtures/ErrorsTestEntity';
import { createKnexIntegrationTestEntityCompanionProvider } from '../testfixtures/createKnexIntegrationTestEntityCompanionProvider';
@@ -20,11 +21,11 @@ describe('postgres errors', () => {
knexInstance = knex({
client: 'pg',
connection: {
user: process.env.PGUSER,
password: process.env.PGPASSWORD,
user: nullthrows(process.env['PGUSER']),
password: nullthrows(process.env['PGPASSWORD']),
host: 'localhost',
port: parseInt(process.env.PGPORT!, 10),
database: process.env.PGDATABASE,
port: parseInt(nullthrows(process.env['PGPORT']), 10),
database: nullthrows(process.env['PGDATABASE']),
},
});
});
@@ -48,11 +49,11 @@ describe('postgres errors', () => {
const shortTimeoutKnexInstance = knex({
client: 'pg',
connection: {
user: process.env.PGUSER,
password: process.env.PGPASSWORD,
user: nullthrows(process.env['PGUSER']),
password: nullthrows(process.env['PGPASSWORD']),
host: 'localhost',
port: parseInt(process.env.PGPORT!, 10),
database: process.env.PGDATABASE,
port: parseInt(nullthrows(process.env['PGPORT']), 10),
database: nullthrows(process.env['PGDATABASE']),
},
acquireConnectionTimeout: 1,
});
@@ -0,0 +1,19 @@
import wrapNativePostgresCallAsync from '../wrapNativePostgresCallAsync';
describe(wrapNativePostgresCallAsync, () => {
it('rethrows literals', async () => {
const throwingFn = async (): Promise<void> => {
// eslint-disable-next-line no-throw-literal,@typescript-eslint/no-throw-literal
throw 'hello';
};
let capturedThrownThing: any;
try {
await wrapNativePostgresCallAsync(throwingFn);
} catch (e) {
capturedThrownThing = e;
}
expect(capturedThrownThing).not.toBeInstanceOf(Error);
expect(capturedThrownThing).toEqual('hello');
});
});
@@ -11,15 +11,17 @@ import {
import { knex } from 'knex';
function wrapNativePostgresError(
error: Error & { code: string | undefined }
error: Error & { code?: string }
): EntityDatabaseAdapterError & Error {
const ret = translatePostgresError(error);
ret.stack = error.stack;
if (error.stack) {
ret.stack = error.stack;
}
return ret;
}
function translatePostgresError(
error: Error & { code: string | undefined }
error: Error & { code?: string }
): EntityDatabaseAdapterError & Error {
if (error instanceof knex.KnexTimeoutError) {
return new EntityDatabaseAdapterTransientError(error.message, error);
@@ -41,10 +43,13 @@ function translatePostgresError(
}
}
export default async function wrapNativePostgresCall<T>(fn: () => Promise<T>): Promise<T> {
export default async function wrapNativePostgresCallAsync<T>(fn: () => Promise<T>): Promise<T> {
try {
return await fn();
} catch (e) {
if (!(e instanceof Error)) {
throw e;
}
throw wrapNativePostgresError(e);
}
}
@@ -6,7 +6,7 @@ import {
EntityConfiguration,
EntityCompanionDefinition,
Entity,
NumberField,
IntField,
} from '@expo/entity';
import { Knex } from 'knex';
@@ -106,7 +106,7 @@ class ErrorsTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
ViewerContext,
ErrorsTestEntity
> {
protected readonly createRules = [
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<
ErrorsTestEntityFields,
number,
@@ -114,7 +114,7 @@ class ErrorsTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
ErrorsTestEntity
>(),
];
protected readonly readRules = [
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<
ErrorsTestEntityFields,
number,
@@ -122,7 +122,7 @@ class ErrorsTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
ErrorsTestEntity
>(),
];
protected readonly updateRules = [
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<
ErrorsTestEntityFields,
number,
@@ -130,7 +130,7 @@ class ErrorsTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
ErrorsTestEntity
>(),
];
protected readonly deleteRules = [
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<
ErrorsTestEntityFields,
number,
@@ -144,19 +144,19 @@ export const ErrorsTestEntityConfiguration = new EntityConfiguration<ErrorsTestE
idField: 'id',
tableName: 'postgres_test_entities',
schema: {
id: new NumberField({
id: new IntField({
columnName: 'id',
}),
fieldNonNull: new StringField({
columnName: 'field_non_null',
}),
fieldForeignKey: new NumberField({
fieldForeignKey: new IntField({
columnName: 'field_foreign_key',
}),
fieldUnique: new StringField({
columnName: 'field_unique',
}),
checkLessThan5: new NumberField({
checkLessThan5: new IntField({
columnName: 'check_less_than_5',
}),
fieldExclusion: new StringField({
@@ -6,7 +6,7 @@ import {
EntityConfiguration,
EntityCompanionDefinition,
Entity,
NumberField,
IntField,
} from '@expo/entity';
import { Knex } from 'knex';
@@ -57,7 +57,7 @@ class InvalidTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
ViewerContext,
InvalidTestEntity
> {
protected readonly createRules = [
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<
InvalidTestEntityFields,
number,
@@ -65,7 +65,7 @@ class InvalidTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
InvalidTestEntity
>(),
];
protected readonly readRules = [
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<
InvalidTestEntityFields,
number,
@@ -73,7 +73,7 @@ class InvalidTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
InvalidTestEntity
>(),
];
protected readonly updateRules = [
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<
InvalidTestEntityFields,
number,
@@ -81,7 +81,7 @@ class InvalidTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
InvalidTestEntity
>(),
];
protected readonly deleteRules = [
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<
InvalidTestEntityFields,
number,
@@ -95,7 +95,7 @@ export const invalidTestEntityConfiguration = new EntityConfiguration<InvalidTes
idField: 'id',
tableName: 'postgres_test_entities',
schema: {
id: new NumberField({
id: new IntField({
columnName: 'id',
}),
name: new StringField({
@@ -81,7 +81,7 @@ class PostgresTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
ViewerContext,
PostgresTestEntity
> {
protected readonly createRules = [
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<
PostgresTestEntityFields,
string,
@@ -89,7 +89,7 @@ class PostgresTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
PostgresTestEntity
>(),
];
protected readonly readRules = [
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<
PostgresTestEntityFields,
string,
@@ -97,7 +97,7 @@ class PostgresTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
PostgresTestEntity
>(),
];
protected readonly updateRules = [
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<
PostgresTestEntityFields,
string,
@@ -105,7 +105,7 @@ class PostgresTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
PostgresTestEntity
>(),
];
protected readonly deleteRules = [
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<
PostgresTestEntityFields,
string,
@@ -10,7 +10,7 @@ import {
EntityMutationTrigger,
EntityQueryContext,
EntityNonTransactionalMutationTrigger,
EntityMutationInfo,
EntityTriggerMutationInfo,
} from '@expo/entity';
import { Knex } from 'knex';
@@ -63,7 +63,7 @@ class PostgresTriggerTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
ViewerContext,
PostgresTriggerTestEntity
> {
protected readonly createRules = [
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<
PostgresTriggerTestEntityFields,
string,
@@ -71,7 +71,7 @@ class PostgresTriggerTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
PostgresTriggerTestEntity
>(),
];
protected readonly readRules = [
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<
PostgresTriggerTestEntityFields,
string,
@@ -79,7 +79,7 @@ class PostgresTriggerTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
PostgresTriggerTestEntity
>(),
];
protected readonly updateRules = [
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<
PostgresTriggerTestEntityFields,
string,
@@ -87,7 +87,7 @@ class PostgresTriggerTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
PostgresTriggerTestEntity
>(),
];
protected readonly deleteRules = [
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<
PostgresTriggerTestEntityFields,
string,
@@ -111,7 +111,7 @@ class ThrowConditionallyTrigger extends EntityMutationTrigger<
_viewerContext: ViewerContext,
_queryContext: EntityQueryContext,
entity: PostgresTriggerTestEntity,
_mutationInfo: EntityMutationInfo<
_mutationInfo: EntityTriggerMutationInfo<
PostgresTriggerTestEntityFields,
string,
ViewerContext,
@@ -144,23 +144,22 @@ class ThrowConditionallyNonTransactionalTrigger extends EntityNonTransactionalMu
}
}
export const postgresTestEntityConfiguration = new EntityConfiguration<
PostgresTriggerTestEntityFields
>({
idField: 'id',
tableName: 'postgres_test_entities',
schema: {
id: new UUIDField({
columnName: 'id',
cache: true,
}),
name: new StringField({
columnName: 'name',
}),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
});
export const postgresTestEntityConfiguration =
new EntityConfiguration<PostgresTriggerTestEntityFields>({
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: PostgresTriggerTestEntity,
@@ -9,7 +9,7 @@ import {
Entity,
EntityMutationTrigger,
EntityQueryContext,
EntityMutationInfo,
EntityValidatorMutationInfo,
} from '@expo/entity';
import { Knex } from 'knex';
@@ -62,7 +62,7 @@ class PostgresValidatorTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
ViewerContext,
PostgresValidatorTestEntity
> {
protected readonly createRules = [
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<
PostgresValidatorTestEntityFields,
string,
@@ -70,7 +70,7 @@ class PostgresValidatorTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
PostgresValidatorTestEntity
>(),
];
protected readonly readRules = [
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<
PostgresValidatorTestEntityFields,
string,
@@ -78,7 +78,7 @@ class PostgresValidatorTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
PostgresValidatorTestEntity
>(),
];
protected readonly updateRules = [
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<
PostgresValidatorTestEntityFields,
string,
@@ -86,7 +86,7 @@ class PostgresValidatorTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
PostgresValidatorTestEntity
>(),
];
protected readonly deleteRules = [
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<
PostgresValidatorTestEntityFields,
string,
@@ -113,7 +113,7 @@ class ThrowConditionallyTrigger extends EntityMutationTrigger<
_viewerContext: ViewerContext,
_queryContext: EntityQueryContext,
entity: PostgresValidatorTestEntity,
_mutationInfo: EntityMutationInfo<
_mutationInfo: EntityValidatorMutationInfo<
PostgresValidatorTestEntityFields,
string,
ViewerContext,
@@ -126,23 +126,22 @@ class ThrowConditionallyTrigger extends EntityMutationTrigger<
}
}
export const postgresTestEntityConfiguration = new EntityConfiguration<
PostgresValidatorTestEntityFields
>({
idField: 'id',
tableName: 'postgres_test_entities',
schema: {
id: new UUIDField({
columnName: 'id',
cache: true,
}),
name: new StringField({
columnName: 'name',
}),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
});
export const postgresTestEntityConfiguration =
new EntityConfiguration<PostgresValidatorTestEntityFields>({
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: PostgresValidatorTestEntity,
+68
View File
@@ -3,6 +3,74 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
**Note:** Version bump only for package @expo/entity-example
# [0.23.0](https://github.com/expo/entity/compare/v0.22.0...v0.23.0) (2022-02-09)
**Note:** Version bump only for package @expo/entity-example
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
**Note:** Version bump only for package @expo/entity-example
# [0.21.0](https://github.com/expo/entity/compare/v0.20.0...v0.21.0) (2022-01-03)
**Note:** Version bump only for package @expo/entity-example
# [0.20.0](https://github.com/expo/entity/compare/v0.19.0...v0.20.0) (2021-12-29)
**Note:** Version bump only for package @expo/entity-example
# [0.19.0](https://github.com/expo/entity/compare/v0.18.0...v0.19.0) (2021-10-28)
**Note:** Version bump only for package @expo/entity-example
# [0.18.0](https://github.com/expo/entity/compare/v0.17.0...v0.18.0) (2021-10-14)
### Features
* enable noImplicitOverride tsc setting ([#135](https://github.com/expo/entity/issues/135)) ([4263cb9](https://github.com/expo/entity/commit/4263cb9b8b69fe4fb68c74ec1c7aba04508a1555))
* upgrade TypeScript to 4.4 ([#134](https://github.com/expo/entity/issues/134)) ([7612392](https://github.com/expo/entity/commit/7612392dbfd9778d25c465c1626c372d75a6d05a))
# [0.17.0](https://github.com/expo/entity/compare/v0.16.0...v0.17.0) (2021-08-16)
**Note:** Version bump only for package @expo/entity-example
# [0.16.0](https://github.com/expo/entity/compare/v0.15.0...v0.16.0) (2021-07-07)
**Note:** Version bump only for package @expo/entity-example
+3 -2
View File
@@ -1,12 +1,13 @@
{
"name": "@expo/entity-example",
"private": true,
"version": "0.16.0",
"version": "0.24.0",
"description": "An example integration of the @expo/entity framework",
"scripts": {
"tsc": "tsc",
"clean": "rm -rf build coverage coverage-integration",
"lint": "eslint src",
"lint-fix": "eslint src --fix",
"test": "jest --rootDir . --config ../../resources/jest.config.js",
"integration": "../../resources/run-with-docker yarn integration-no-setup",
"integration-no-setup": "jest --config ../../resources/jest-integration.config.js --rootDir . --runInBand --passWithNoTests",
@@ -22,7 +23,7 @@
"author": "Expo",
"license": "MIT",
"dependencies": {
"@expo/entity": "^0.16.0",
"@expo/entity": "^0.24.0",
"apollo-server-koa": "^2.16.1",
"graphql": "^15.3.0",
"koa": "^2.13.0",
@@ -58,7 +58,7 @@ class InMemoryDatabaseAdapter<T> extends EntityDatabaseAdapter<T> {
return 0;
}
const currentOrderBy = orderBys[0];
const currentOrderBy = orderBys[0]!;
const aField = objectA[currentOrderBy.columnName];
const bField = objectB[currentOrderBy.columnName];
switch (currentOrderBy.order) {
@@ -161,7 +161,7 @@ class InMemoryDatabaseAdapter<T> extends EntityDatabaseAdapter<T> {
...dbObjects[objectIndex],
...object,
};
return [dbObjects[objectIndex]];
return [dbObjects[objectIndex]!];
}
protected async deleteInternalAsync(
@@ -13,16 +13,16 @@ export default class NotePrivacyPolicy extends EntityPrivacyPolicy<
ExampleViewerContext,
NoteEntity
> {
protected readonly readRules = [
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<NoteFields, string, ExampleViewerContext, NoteEntity>(),
];
protected readonly createRules = [
protected override readonly createRules = [
new AllowIfUserOwnerPrivacyRule<NoteFields, string, NoteEntity>('userID'),
];
protected readonly updateRules = [
protected override readonly updateRules = [
new AllowIfUserOwnerPrivacyRule<NoteFields, string, NoteEntity>('userID'),
];
protected readonly deleteRules = [
protected override readonly deleteRules = [
new AllowIfUserOwnerPrivacyRule<NoteFields, string, NoteEntity>('userID'),
];
}
@@ -3,6 +3,74 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
**Note:** Version bump only for package @expo/entity-full-integration-tests
# [0.23.0](https://github.com/expo/entity/compare/v0.22.0...v0.23.0) (2022-02-09)
**Note:** Version bump only for package @expo/entity-full-integration-tests
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
**Note:** Version bump only for package @expo/entity-full-integration-tests
# [0.21.0](https://github.com/expo/entity/compare/v0.20.0...v0.21.0) (2022-01-03)
**Note:** Version bump only for package @expo/entity-full-integration-tests
# [0.20.0](https://github.com/expo/entity/compare/v0.19.0...v0.20.0) (2021-12-29)
**Note:** Version bump only for package @expo/entity-full-integration-tests
# [0.19.0](https://github.com/expo/entity/compare/v0.18.0...v0.19.0) (2021-10-28)
**Note:** Version bump only for package @expo/entity-full-integration-tests
# [0.18.0](https://github.com/expo/entity/compare/v0.17.0...v0.18.0) (2021-10-14)
### Features
* enable noImplicitOverride tsc setting ([#135](https://github.com/expo/entity/issues/135)) ([4263cb9](https://github.com/expo/entity/commit/4263cb9b8b69fe4fb68c74ec1c7aba04508a1555))
* upgrade TypeScript to 4.4 ([#134](https://github.com/expo/entity/issues/134)) ([7612392](https://github.com/expo/entity/commit/7612392dbfd9778d25c465c1626c372d75a6d05a))
# [0.17.0](https://github.com/expo/entity/compare/v0.16.0...v0.17.0) (2021-08-16)
**Note:** Version bump only for package @expo/entity-full-integration-tests
# [0.16.0](https://github.com/expo/entity/compare/v0.15.0...v0.16.0) (2021-07-07)
**Note:** Version bump only for package @expo/entity-full-integration-tests
@@ -1,12 +1,13 @@
{
"name": "@expo/entity-full-integration-tests",
"private": true,
"version": "0.16.0",
"version": "0.24.0",
"description": "Full redis and knex integration tests for the entity framework",
"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",
@@ -26,8 +27,8 @@
"@expo/entity-database-adapter-knex": "*"
},
"devDependencies": {
"@expo/entity": "^0.16.0",
"@expo/entity-cache-adapter-redis": "^0.16.0",
"@expo/entity-database-adapter-knex": "^0.16.0"
"@expo/entity": "^0.24.0",
"@expo/entity-cache-adapter-redis": "^0.24.0",
"@expo/entity-database-adapter-knex": "^0.24.0"
}
}
@@ -11,6 +11,7 @@ import {
import { RedisCacheAdapterContext } from '@expo/entity-cache-adapter-redis';
import Redis from 'ioredis';
import { knex, Knex } from 'knex';
import nullthrows from 'nullthrows';
import { URL } from 'url';
import { createFullIntegrationTestEntityCompanionProvider } from '../testfixtures/createFullIntegrationTestEntityCompanionProvider';
@@ -27,16 +28,16 @@ class TestEntityPrivacyPolicy extends EntityPrivacyPolicy<
ViewerContext,
TestEntity
> {
protected readonly readRules = [
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<TestFields, string, ViewerContext, TestEntity>(),
];
protected readonly createRules = [
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<TestFields, string, ViewerContext, TestEntity>(),
];
protected readonly updateRules = [
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<TestFields, string, ViewerContext, TestEntity>(),
];
protected readonly deleteRules = [
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<TestFields, string, ViewerContext, TestEntity>(),
];
}
@@ -107,15 +108,15 @@ describe('Entity cache inconsistency', () => {
knexInstance = knex({
client: 'pg',
connection: {
user: process.env.PGUSER,
password: process.env.PGPASSWORD,
user: nullthrows(process.env['PGUSER']),
password: nullthrows(process.env['PGPASSWORD']),
host: 'localhost',
port: parseInt(process.env.PGPORT!, 10),
database: process.env.PGDATABASE,
port: parseInt(nullthrows(process.env['PGPORT']), 10),
database: nullthrows(process.env['PGDATABASE']),
},
});
redisCacheAdapterContext = {
redisClient: new Redis(new URL(process.env.REDIS_URL!).toString()),
redisClient: new Redis(new URL(process.env['REDIS_URL']!).toString()),
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
@@ -2,6 +2,7 @@ import { ViewerContext } from '@expo/entity';
import { RedisCacheAdapterContext } from '@expo/entity-cache-adapter-redis';
import Redis from 'ioredis';
import { knex, Knex } from 'knex';
import nullthrows from 'nullthrows';
import { URL } from 'url';
import { createFullIntegrationTestEntityCompanionProvider } from '../testfixtures/createFullIntegrationTestEntityCompanionProvider';
@@ -40,15 +41,15 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
knexInstance = knex({
client: 'pg',
connection: {
user: process.env.PGUSER,
password: process.env.PGPASSWORD,
user: nullthrows(process.env['PGUSER']),
password: nullthrows(process.env['PGPASSWORD']),
host: 'localhost',
port: parseInt(process.env.PGPORT!, 10),
database: process.env.PGDATABASE,
port: parseInt(nullthrows(process.env['PGPORT']), 10),
database: nullthrows(process.env['PGDATABASE']),
},
});
redisCacheAdapterContext = {
redisClient: new Redis(new URL(process.env.REDIS_URL!).toString()),
redisClient: new Redis(new URL(process.env['REDIS_URL']!).toString()),
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
@@ -11,6 +11,7 @@ import {
import { RedisCacheAdapterContext } from '@expo/entity-cache-adapter-redis';
import Redis from 'ioredis';
import { knex, Knex } from 'knex';
import nullthrows from 'nullthrows';
import { URL } from 'url';
import { v4 as uuidv4 } from 'uuid';
@@ -27,16 +28,16 @@ interface OtherFields {
}
class PrivacyPolicy extends EntityPrivacyPolicy<any, string, ViewerContext, any> {
protected readonly readRules = [
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, ViewerContext, any>(),
];
protected readonly createRules = [
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, ViewerContext, any>(),
];
protected readonly updateRules = [
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, ViewerContext, any>(),
];
protected readonly deleteRules = [
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, ViewerContext, any>(),
];
}
@@ -169,15 +170,15 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
knexInstance = knex({
client: 'pg',
connection: {
user: process.env.PGUSER,
password: process.env.PGPASSWORD,
user: nullthrows(process.env['PGUSER']),
password: nullthrows(process.env['PGPASSWORD']),
host: 'localhost',
port: parseInt(process.env.PGPORT!, 10),
database: process.env.PGDATABASE,
port: parseInt(nullthrows(process.env['PGPORT']), 10),
database: nullthrows(process.env['PGDATABASE']),
},
});
redisCacheAdapterContext = {
redisClient: new Redis(new URL(process.env.REDIS_URL!).toString()),
redisClient: new Redis(new URL(process.env['REDIS_URL']!).toString()),
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
@@ -17,16 +17,16 @@ interface ChildFields {
}
class TestEntityPrivacyPolicy extends EntityPrivacyPolicy<any, string, ViewerContext, any, any> {
protected readonly readRules = [
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, ViewerContext, any, any>(),
];
protected readonly createRules = [
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, ViewerContext, any, any>(),
];
protected readonly updateRules = [
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, ViewerContext, any, any>(),
];
protected readonly deleteRules = [
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, ViewerContext, any, any>(),
];
}
@@ -15,16 +15,16 @@ interface ParentFields {
}
class TestEntityPrivacyPolicy extends EntityPrivacyPolicy<any, string, ViewerContext, any, any> {
protected readonly readRules = [
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, ViewerContext, any, any>(),
];
protected readonly createRules = [
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, ViewerContext, any, any>(),
];
protected readonly updateRules = [
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, ViewerContext, any, any>(),
];
protected readonly deleteRules = [
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, ViewerContext, any, any>(),
];
}
@@ -0,0 +1,66 @@
# Change Log
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
**Note:** Version bump only for package @expo/entity-ip-address-field
# [0.23.0](https://github.com/expo/entity/compare/v0.22.0...v0.23.0) (2022-02-09)
**Note:** Version bump only for package @expo/entity-ip-address-field
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
**Note:** Version bump only for package @expo/entity-ip-address-field
# [0.21.0](https://github.com/expo/entity/compare/v0.20.0...v0.21.0) (2022-01-03)
**Note:** Version bump only for package @expo/entity-ip-address-field
# [0.20.0](https://github.com/expo/entity/compare/v0.19.0...v0.20.0) (2021-12-29)
**Note:** Version bump only for package @expo/entity-ip-address-field
# [0.19.0](https://github.com/expo/entity/compare/v0.18.0...v0.19.0) (2021-10-28)
### Features
* add IP support ([#138](https://github.com/expo/entity/issues/138)) ([1405a91](https://github.com/expo/entity/commit/1405a9126ee3a56c3e2a38bf3b697283744f5b5d))
# [0.18.0](https://github.com/expo/entity/compare/v0.17.0...v0.18.0) (2021-10-14)
### Features
* lands @expo/entity-ip-address-field ([#138](https://github.com/expo/entity/pull/138))
* enable noImplicitOverride tsc setting ([#135](https://github.com/expo/entity/issues/135)) ([4263cb9](https://github.com/expo/entity/commit/4263cb9b8b69fe4fb68c74ec1c7aba04508a1555))
* expose describeFieldTestCase for use in other packages ([#139](https://github.com/expo/entity/issues/139)) ([1220477](https://github.com/expo/entity/commit/1220477aa331349b482ad015b44f76fa2bda7b90))
* upgrade TypeScript to 4.4 ([#134](https://github.com/expo/entity/issues/134)) ([7612392](https://github.com/expo/entity/commit/7612392dbfd9778d25c465c1626c372d75a6d05a))
* use better QueryContext types for transactional triggers and validators ([#136](https://github.com/expo/entity/issues/136)) ([5c7efe1](https://github.com/expo/entity/commit/5c7efe1678424ff907687e9327175bdc2538c3e0))
@@ -0,0 +1,3 @@
# @expo/entity-ip-address-field
A package containing the IP address EntityField definitions for @expo/entity. Supports addresses with either IPv4 or IPv6 syntax.
@@ -0,0 +1,37 @@
{
"name": "@expo/entity-ip-address-field",
"version": "0.24.0",
"description": "IP address EntityField definitions for @expo/entity",
"files": [
"build",
"src"
],
"main": "build/index.js",
"types": "build/index.d.ts",
"scripts": {
"tsc": "tsc",
"clean": "rm -rf build coverage coverage-integration",
"lint": "eslint src",
"lint-fix": "eslint src --fix",
"test": "jest --rootDir . --config ../../resources/jest.config.js",
"barrelsby": "barrelsby --directory src --location top --exclude tests__ --singleQuotes --delete"
},
"engines": {
"node": ">=12"
},
"keywords": [
"entity"
],
"author": "Expo",
"license": "MIT",
"dependencies": {
"ip-address": "^8.1.0"
},
"devDependencies": {
"@expo/entity": "^0.24.0",
"@types/jsbn": "^1.2.29"
},
"peerDependencies": {
"@expo/entity": "*"
}
}
@@ -0,0 +1,8 @@
import { EntityFieldDefinition } from '@expo/entity';
import { Address4, Address6 } from 'ip-address';
export class IPAddressField extends EntityFieldDefinition<string> {
protected validateInputValueInternal(value: string): boolean {
return Address4.isValid(value) || Address6.isValid(value);
}
}
@@ -0,0 +1,16 @@
import { describeFieldTestCase } from '@expo/entity';
import { IPAddressField } from '../EntityFields';
describeFieldTestCase(
new IPAddressField({ columnName: 'wat' }),
[
'192.168.1.1',
'198.24.10.0/24',
'10.0.0.0',
'0:1:2:3:4:5:6:7',
'1:2:3:4:5:6:7:8/64',
'fedc:ba98:7654:3210:fedc:ba98:7654:3210',
],
['', 'abs', '0.0.0.x/0', '123.21.23', '1:2:3:4:5:6', '198.10/8']
);
@@ -0,0 +1,7 @@
/* eslint-disable tsdoc/syntax */
/**
* @packageDocumentation
* @module @expo/entity-ip-address-field
*/
export * from './EntityFields';
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "build",
},
"include": ["src"]
}
@@ -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.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
### Chores
* Refactor cachers to use an abstract class and interface ([#157](https://github.com/expo/entity/pull/157))
# [0.23.0](https://github.com/expo/entity/compare/v0.22.0...v0.23.0) (2022-02-09)
### Chores
* Move data transforming responsibilities to the CacheAdapter instead of the RedisSecondaryEntityCache. ([#153](https://github.com/expo/entity/pull/153))
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
**Note:** Version bump only for package @expo/entity-secondary-cache-redis
# [0.21.0](https://github.com/expo/entity/compare/v0.20.0...v0.21.0) (2022-01-03)
**Note:** Version bump only for package @expo/entity-secondary-cache-redis
# [0.20.0](https://github.com/expo/entity/compare/v0.19.0...v0.20.0) (2021-12-29)
**Note:** Version bump only for package @expo/entity-secondary-cache-redis
# [0.19.0](https://github.com/expo/entity/compare/v0.18.0...v0.19.0) (2021-10-28)
**Note:** Version bump only for package @expo/entity-secondary-cache-redis
# [0.18.0](https://github.com/expo/entity/compare/v0.17.0...v0.18.0) (2021-10-14)
### Features
* enable noImplicitOverride tsc setting ([#135](https://github.com/expo/entity/issues/135)) ([4263cb9](https://github.com/expo/entity/commit/4263cb9b8b69fe4fb68c74ec1c7aba04508a1555))
* upgrade TypeScript to 4.4 ([#134](https://github.com/expo/entity/issues/134)) ([7612392](https://github.com/expo/entity/commit/7612392dbfd9778d25c465c1626c372d75a6d05a))
# [0.17.0](https://github.com/expo/entity/compare/v0.16.0...v0.17.0) (2021-08-16)
**Note:** Version bump only for package @expo/entity-secondary-cache-redis
# [0.16.0](https://github.com/expo/entity/compare/v0.15.0...v0.16.0) (2021-07-07)
**Note:** Version bump only for package @expo/entity-secondary-cache-redis
@@ -1,6 +1,6 @@
{
"name": "@expo/entity-secondary-cache-redis",
"version": "0.16.0",
"version": "0.24.0",
"description": "Redis secondary cache for @expo/entity",
"files": [
"build",
@@ -12,6 +12,7 @@
"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",
@@ -33,8 +34,8 @@
"ioredis": "^4.17.3"
},
"devDependencies": {
"@expo/entity": "^0.16.0",
"@expo/entity-cache-adapter-redis": "^0.16.0",
"@expo/entity": "^0.24.0",
"@expo/entity-cache-adapter-redis": "^0.24.0",
"nullthrows": "^1.1.1"
}
}
@@ -1,135 +1,20 @@
import {
CacheStatus,
EntityConfiguration,
filterMap,
ISecondaryEntityCache,
transformCacheObjectToFields,
transformFieldsToCacheObject,
zipToMap,
} from '@expo/entity';
import {
GenericRedisCacheContext,
GenericRedisCacher,
redisTransformerMap,
} from '@expo/entity-cache-adapter-redis';
import invariant from 'invariant';
import { EntityConfiguration, GenericSecondaryEntityCache } from '@expo/entity';
import { GenericRedisCacheContext, GenericRedisCacher } from '@expo/entity-cache-adapter-redis';
/**
* A custom secondary read-through entity cache is a way to add a custom second layer of caching for a particular
* single entity load. One common way this may be used is to add a second layer of caching in a hot path that makes
* a call to {@link EntityLoader.loadManyByFieldEqualityConjunctionAsync} is guaranteed to return at most one entity.
*/
export default class RedisSecondaryEntityCache<TFields, TLoadParams>
implements ISecondaryEntityCache<TFields, TLoadParams> {
private readonly genericRedisCacher: GenericRedisCacher;
export default class RedisSecondaryEntityCache<
TFields,
TLoadParams
> extends GenericSecondaryEntityCache<TFields, TLoadParams> {
constructor(
private readonly entityConfiguration: EntityConfiguration<TFields>,
entityConfiguration: EntityConfiguration<TFields>,
genericRedisCacheContext: GenericRedisCacheContext,
private readonly constructRedisKey: (params: Readonly<TLoadParams>) => string
constructRedisKey: (params: Readonly<TLoadParams>) => string
) {
this.genericRedisCacher = new GenericRedisCacher(genericRedisCacheContext);
}
/**
* Read-through cache function. Steps:
*
* 1. Check for cached objects
* 2. Query the fetcher for loadParams not in the cache
* 3. Cache the results from the fetcher
* 4. Negatively cache anything missing from the fetcher
* 5. Return the full set of data for the query.
*
* @param loadParamsArray - array of loadParams to load from the cache
* @param fetcher - closure used to provide underlying data source objects for loadParams
* @returns map from loadParams to the entity field object
*/
public async loadManyThroughAsync(
loadParamsArray: readonly Readonly<TLoadParams>[],
fetcher: (
fetcherLoadParamsArray: readonly Readonly<TLoadParams>[]
) => Promise<ReadonlyMap<Readonly<TLoadParams>, Readonly<TFields> | null>>
): Promise<ReadonlyMap<Readonly<TLoadParams>, Readonly<TFields> | null>> {
const redisKeys = loadParamsArray.map(this.constructRedisKey);
const redisKeyToLoadParamsMap = zipToMap(redisKeys, loadParamsArray);
const cacheLoadResults = await this.genericRedisCacher.loadManyAsync(redisKeys);
invariant(
cacheLoadResults.size === loadParamsArray.length,
`${this.constructor.name} loadMany should return a result for each key`
);
const redisKeysToFetch = Array.from(
filterMap(
cacheLoadResults,
(cacheLoadResult) => cacheLoadResult.status === CacheStatus.MISS
).keys()
);
// put transformed cache hits in result map
const results: Map<Readonly<TLoadParams>, Readonly<TFields> | null> = new Map();
cacheLoadResults.forEach((cacheLoadResult, redisKey) => {
if (cacheLoadResult.status === CacheStatus.HIT) {
const loadParams = redisKeyToLoadParamsMap.get(redisKey);
invariant(loadParams !== undefined, 'load params should be in redis key map');
results.set(
loadParams,
transformCacheObjectToFields(
this.entityConfiguration,
redisTransformerMap,
cacheLoadResult.item
)
);
}
});
// fetch any misses from DB, add DB objects to results, cache DB results, inform cache of any missing DB results
if (redisKeysToFetch.length > 0) {
const loadParamsToFetch = redisKeysToFetch.map((redisKey) => {
const loadParams = redisKeyToLoadParamsMap.get(redisKey);
invariant(loadParams !== undefined, 'load params should be in redis key map');
return loadParams;
});
const fetchResults = await fetcher(loadParamsToFetch);
const fetchMisses = loadParamsToFetch.filter((loadParams) => {
// all values of fetchResults should be field objects or undefined
return !fetchResults.get(loadParams);
});
for (const fetchMiss of fetchMisses) {
results.set(fetchMiss, null);
}
const objectsToCache: Map<string, object> = new Map();
for (const [loadParams, object] of fetchResults.entries()) {
if (object) {
objectsToCache.set(
this.constructRedisKey(loadParams),
transformFieldsToCacheObject(this.entityConfiguration, redisTransformerMap, object)
);
results.set(loadParams, object);
}
}
await Promise.all([
this.genericRedisCacher.cacheManyAsync(objectsToCache),
this.genericRedisCacher.cacheDBMissesAsync(fetchMisses.map(this.constructRedisKey)),
]);
}
return results;
}
/**
* Invalidate the cache for objects cached by constructRedisKey(loadParams).
*
* @param loadParamsArray - load params to invalidate
*/
public async invalidateManyAsync(
loadParamsArray: readonly Readonly<TLoadParams>[]
): Promise<void> {
await this.genericRedisCacher.invalidateManyAsync(loadParamsArray.map(this.constructRedisKey));
super(new GenericRedisCacher(genericRedisCacheContext, entityConfiguration), constructRedisKey);
}
}
@@ -38,11 +38,15 @@ class TestSecondaryRedisCacheLoader extends EntitySecondaryCacheLoader<
if (loadParams.id === FAKE_ID) {
return null;
}
return (
await this.entityLoader
.enforcing()
.loadManyByFieldEqualityConjunctionAsync([{ fieldName: 'id', fieldValue: loadParams.id }])
)[0].getAllFields();
return nullthrows(
(
await this.entityLoader
.enforcing()
.loadManyByFieldEqualityConjunctionAsync([
{ fieldName: 'id', fieldValue: loadParams.id },
])
)[0]
).getAllFields();
});
}
}
@@ -52,7 +56,7 @@ describe(RedisSecondaryEntityCache, () => {
beforeAll(() => {
redisCacheAdapterContext = {
redisClient: new Redis(new URL(process.env.REDIS_URL!).toString()),
redisClient: new Redis(new URL(process.env['REDIS_URL']!).toString()),
makeKeyFn(..._parts: string[]): string {
throw new Error('should not be used by this test');
},
@@ -32,7 +32,7 @@ export class RedisTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
ViewerContext,
RedisTestEntity
> {
protected readonly createRules = [
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<
RedisTestEntityFields,
string,
@@ -40,7 +40,7 @@ export class RedisTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
RedisTestEntity
>(),
];
protected readonly readRules = [
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<
RedisTestEntityFields,
string,
@@ -48,7 +48,7 @@ export class RedisTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
RedisTestEntity
>(),
];
protected readonly updateRules = [
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<
RedisTestEntityFields,
string,
@@ -56,7 +56,7 @@ export class RedisTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
RedisTestEntity
>(),
];
protected readonly deleteRules = [
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<
RedisTestEntityFields,
string,
+92
View File
@@ -3,6 +3,98 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
**Note:** Version bump only for package @expo/entity
### Chores
* Refactor cachers to use an abstract class and interface ([#157](https://github.com/expo/entity/pull/157))
# [0.23.0](https://github.com/expo/entity/compare/v0.22.0...v0.23.0) (2022-02-09)
### Breaking changes
* Move data transforming responsibilities to the CacheAdapter instead of the ReadThroughEntityCache. Affects any classes extending the EntityCacheAdapter and relying on the transformation behavior of the ReadThroughEntityCache. ([#153](https://github.com/expo/entity/pull/153))
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
**Note:** Version bump only for package @expo/entity
# [0.21.0](https://github.com/expo/entity/compare/v0.20.0...v0.21.0) (2022-01-03)
### Features
* add pre-commit callbacks on EntityQueryContext ([#147](https://github.com/expo/entity/issues/147)) ([f1d9847](https://github.com/expo/entity/commit/f1d9847210f5775de964509e44ce493e44482b21))
# [0.20.0](https://github.com/expo/entity/compare/v0.19.0...v0.20.0) (2021-12-29)
### Bug Fixes
* separate post-commit invalidation callbacks from trigger and user callbacks ([#144](https://github.com/expo/entity/issues/144)) ([efa131e](https://github.com/expo/entity/commit/efa131ef29532accf0e51b6d867ffe15a27b4c90))
### Features
* add cascading deletion info ([#145](https://github.com/expo/entity/issues/145)) ([3727191](https://github.com/expo/entity/commit/372719176742d3ead6b7cbca29cb66b09b3fe09c))
# [0.19.0](https://github.com/expo/entity/compare/v0.18.0...v0.19.0) (2021-10-28)
### Features
* add authorization metrics ([#142](https://github.com/expo/entity/issues/142)) ([7cf54d8](https://github.com/expo/entity/commit/7cf54d83c52e104159a3e2e675cdda2d29afe204))
# [0.18.0](https://github.com/expo/entity/compare/v0.17.0...v0.18.0) (2021-10-14)
### Features
* enable noImplicitOverride tsc setting ([#135](https://github.com/expo/entity/issues/135)) ([4263cb9](https://github.com/expo/entity/commit/4263cb9b8b69fe4fb68c74ec1c7aba04508a1555))
* expose describeFieldTestCase for use in other packages ([#139](https://github.com/expo/entity/issues/139)) ([1220477](https://github.com/expo/entity/commit/1220477aa331349b482ad015b44f76fa2bda7b90))
* upgrade TypeScript to 4.4 ([#134](https://github.com/expo/entity/issues/134)) ([7612392](https://github.com/expo/entity/commit/7612392dbfd9778d25c465c1626c372d75a6d05a))
* use better QueryContext types for transactional triggers and validators ([#136](https://github.com/expo/entity/issues/136)) ([5c7efe1](https://github.com/expo/entity/commit/5c7efe1678424ff907687e9327175bdc2538c3e0))
# [0.17.0](https://github.com/expo/entity/compare/v0.16.0...v0.17.0) (2021-08-16)
### Features
* Add IntField and FloatField, deprecate NumberField ([#131](https://github.com/expo/entity/issues/131)) ([2f2d963](https://github.com/expo/entity/commit/2f2d963cf92c6575d78f8eeca344a4aadcc9b8d9))
# [0.16.0](https://github.com/expo/entity/compare/v0.15.0...v0.16.0) (2021-07-07)
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/entity",
"version": "0.16.0",
"version": "0.24.0",
"description": "A privacy-first data model",
"files": [
"build",
@@ -12,6 +12,7 @@
"tsc": "tsc",
"clean": "rm -rf build coverage coverage-integration",
"lint": "eslint src",
"lint-fix": "eslint src --fix",
"test": "jest --rootDir . --config ../../resources/jest.config.js",
"integration": "../../resources/run-with-docker yarn integration-no-setup",
"integration-no-setup": "jest --config ../../resources/jest-integration.config.js --rootDir . --runInBand --passWithNoTests",
+10 -2
View File
@@ -243,12 +243,16 @@ export default abstract class Entity<
.getQueryContextProvider()
.getQueryContext()
): Promise<boolean> {
const companion = existingEntity
.getViewerContext()
.getViewerScopedEntityCompanionForClass(this);
const privacyPolicy = new (this.getCompanionDefinition().privacyPolicyClass)();
const evaluationResult = await asyncResult(
privacyPolicy.authorizeUpdateAsync(
existingEntity.getViewerContext(),
queryContext,
existingEntity
existingEntity,
companion.getMetricsAdapter()
)
);
return evaluationResult.ok;
@@ -292,12 +296,16 @@ export default abstract class Entity<
.getQueryContextProvider()
.getQueryContext()
): Promise<boolean> {
const companion = existingEntity
.getViewerContext()
.getViewerScopedEntityCompanionForClass(this);
const privacyPolicy = new (this.getCompanionDefinition().privacyPolicyClass)();
const evaluationResult = await asyncResult(
privacyPolicy.authorizeDeleteAsync(
existingEntity.getViewerContext(),
queryContext,
existingEntity
existingEntity,
companion.getMetricsAdapter()
)
);
return evaluationResult.ok;
@@ -71,7 +71,7 @@ export default class EntityAssociationLoader<
.getLoaderFactory()
.forLoad(queryContext);
return (await loader.loadByIDAsync((associatedEntityID as unknown) as TAssociatedID)) as Result<
return (await loader.loadByIDAsync(associatedEntityID as unknown as TAssociatedID)) as Result<
null extends TFields[TIdentifyingField] ? TAssociatedEntity | null : TAssociatedEntity
>;
}
+2 -10
View File
@@ -1,5 +1,4 @@
import EntityConfiguration from './EntityConfiguration';
import { FieldTransformerMap } from './internal/EntityFieldTransformationUtils';
import { CacheLoadResult } from './internal/ReadThroughEntityCache';
/**
@@ -9,13 +8,6 @@ import { CacheLoadResult } from './internal/ReadThroughEntityCache';
export default abstract class EntityCacheAdapter<TFields> {
constructor(protected readonly entityConfiguration: EntityConfiguration<TFields>) {}
/**
* Transformer definitions for field types. Used to modify values as they are read from or written to
* the cache. Override in concrete subclasses to change transformation behavior.
* If a field type is not present in the map, then fields of that type will not be transformed.
*/
public abstract getFieldTransformerMap(): FieldTransformerMap;
/**
* Load many objects from cache.
* @param fieldName - object field being queried
@@ -25,7 +17,7 @@ export default abstract class EntityCacheAdapter<TFields> {
public abstract loadManyAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult>>;
): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult<TFields>>>;
/**
* Cache many objects fetched from the DB.
@@ -34,7 +26,7 @@ export default abstract class EntityCacheAdapter<TFields> {
*/
public abstract cacheManyAsync<N extends keyof TFields>(
fieldName: N,
objectMap: ReadonlyMap<NonNullable<TFields[N]>, object>
objectMap: ReadonlyMap<NonNullable<TFields[N]>, Readonly<TFields>>
): Promise<void>;
/**
+10 -2
View File
@@ -73,7 +73,7 @@ export default class EntityCompanion<
TEntity,
TSelectedFields
>,
metricsAdapter: IEntityMetricsAdapter
private readonly metricsAdapter: IEntityMetricsAdapter
) {
const privacyPolicy = new PrivacyPolicyClass();
this.entityLoaderFactory = new EntityLoaderFactory<
@@ -87,7 +87,8 @@ export default class EntityCompanion<
tableDataCoordinator.entityConfiguration,
entityClass,
privacyPolicy,
tableDataCoordinator.dataManager
tableDataCoordinator.dataManager,
metricsAdapter
);
this.entityMutatorFactory = new EntityMutatorFactory(
tableDataCoordinator.entityConfiguration,
@@ -129,4 +130,11 @@ export default class EntityCompanion<
getQueryContextProvider(): EntityQueryContextProvider {
return this.tableDataCoordinator.getQueryContextProvider();
}
/**
* Get the {@link IEntityMetricsAdapter} for this companion.
*/
getMetricsAdapter(): IEntityMetricsAdapter {
return this.metricsAdapter;
}
}
@@ -162,14 +162,10 @@ export class EntityCompanionDefinition<
* {@link EntityCompanion} for each type of {@link Entity}.
*/
export default class EntityCompanionProvider {
private readonly companionMap: Map<
string,
EntityCompanion<any, any, any, any, any, any>
> = new Map();
private readonly tableDataCoordinatorMap: Map<
string,
EntityTableDataCoordinator<any>
> = new Map();
private readonly companionMap: Map<string, EntityCompanion<any, any, any, any, any, any>> =
new Map();
private readonly tableDataCoordinatorMap: Map<string, EntityTableDataCoordinator<any>> =
new Map();
/**
* Instantiate an Entity framework.
@@ -178,7 +174,7 @@ export default class EntityCompanionProvider {
* @param cacheAdapterFlavors - Cache adapter configurations for this instance
*/
constructor(
private metricsAdapter: IEntityMetricsAdapter,
public readonly metricsAdapter: IEntityMetricsAdapter,
private databaseAdapterFlavors: ReadonlyMap<
DatabaseAdapterFlavor,
DatabaseAdapterFlavorDefinition
+1 -1
View File
@@ -1,6 +1,6 @@
import { IEntityClass } from './Entity';
import { DatabaseAdapterFlavor, CacheAdapterFlavor } from './EntityCompanionProvider';
import { EntityFieldDefinition } from './EntityFields';
import { EntityFieldDefinition } from './EntityFieldDefinition';
import { mapMap, invertMap, reduceMap } from './utils/collections/maps';
/**
+10 -8
View File
@@ -69,12 +69,14 @@ export interface QuerySelectionModifiers<TFields> {
}
export interface TableQuerySelectionModifiers {
orderBy?: {
columnName: string;
order: OrderByOrdering;
}[];
offset?: number;
limit?: number;
orderBy:
| {
columnName: string;
order: OrderByOrdering;
}[]
| undefined;
offset: number | undefined;
limit: number | undefined;
}
/**
@@ -263,7 +265,7 @@ export default abstract class EntityDatabaseAdapter<TFields> {
return transformDatabaseObjectToFields(
this.entityConfiguration,
this.fieldTransformerMap,
results[0]
results[0]!
);
}
@@ -315,7 +317,7 @@ export default abstract class EntityDatabaseAdapter<TFields> {
return transformDatabaseObjectToFields(
this.entityConfiguration,
this.fieldTransformerMap,
results[0]
results[0]!
);
}
@@ -0,0 +1,132 @@
import { IEntityClass } from './Entity';
import EntityPrivacyPolicy from './EntityPrivacyPolicy';
import ReadonlyEntity from './ReadonlyEntity';
import ViewerContext from './ViewerContext';
export enum EntityEdgeDeletionBehavior {
/**
* Default. Invalidate the cache for all entities that reference the entity
* being deleted through this field, and transitively run deletions on those entities.
* This is most useful when the database itself expresses foreign
* keys and cascading deletes or set nulls and the entity framework just needs to
* be kept consistent with the state of the database.
*/
CASCADE_DELETE_INVALIDATE_CACHE,
/**
* Delete all entities that reference the entity being deleted through this field. This is very similar
* to SQL `ON DELETE CASCADE` but is done in the Entity framework instead of at the underlying level.
* This will also invalidate the cached referencing entities.
*/
CASCADE_DELETE,
/**
* Set this field to null when the referenced entity is deleted. This is very similar
* to SQL `ON DELETE SET NULL` but is done in the Entity framework instead of at the underlying level.
* This will also invalidate the cached referencing entities.
*/
SET_NULL,
}
/**
* Defines an association between entities. An association is primarily used to define cascading deletion behavior.
*/
export interface EntityAssociationDefinition<
TViewerContext extends ViewerContext,
TAssociatedFields,
TAssociatedID extends NonNullable<TAssociatedFields[TAssociatedSelectedFields]>,
TAssociatedEntity extends ReadonlyEntity<
TAssociatedFields,
TAssociatedID,
TViewerContext,
TAssociatedSelectedFields
>,
TAssociatedPrivacyPolicy extends EntityPrivacyPolicy<
TAssociatedFields,
TAssociatedID,
TViewerContext,
TAssociatedEntity,
TAssociatedSelectedFields
>,
TAssociatedSelectedFields extends keyof TAssociatedFields = keyof TAssociatedFields
> {
/**
* Class of entity on the other end of this edge.
*/
getAssociatedEntityClass: () => IEntityClass<
TAssociatedFields,
TAssociatedID,
TViewerContext,
TAssociatedEntity,
TAssociatedPrivacyPolicy,
TAssociatedSelectedFields
>;
/**
* Field by which to load the instance of associatedEntityClass. If not provided, the
* associatedEntityClass instance is fetched by its ID.
*/
associatedEntityLookupByField?: keyof TAssociatedFields;
/**
* What action to perform on the current entity when the entity on the referencing end of
* this edge is deleted.
*
* @remarks
* The entity framework doesn't prescribe a one-size-fits-all solution for referential
* integrity; instead it exposes mechanisms that supports both database foreign key constraints
* and implicit entity-specified foreign keys. Choosing which approach to use often depends on
* application requirements, and sometimes even a mix-and-match is the right choice.
*
* - If referential integrity is critical to your application, database foreign key constraints
* combined with {@link EntityEdgeDeletionBehavior.CASACDE_DELETE_INVALIDATE_CACHE} are recommended.
* - If the database being used doesn't support foreign keys, then using the entity framework for referential
* integrity is recommended.
*/
edgeDeletionBehavior?: EntityEdgeDeletionBehavior;
}
/**
* Definition for a field referencing a column in the underlying database. Specifies things like
* cache behavior and associations, and handles input validation.
*/
export abstract class EntityFieldDefinition<T> {
readonly columnName: string;
readonly cache: boolean;
readonly association: EntityAssociationDefinition<any, any, any, any, any, any> | undefined;
/**
*
* @param columnName - Column name in the database.
* @param cache - Whether or not to cache loaded instances of the entity by this field. The column name is
* used to derive a cache key for the cache entry. If true, this column must be able uniquely
* identify the entity.
* @param association - Defines the association behavior for an entity that this column references.
*/
constructor({
columnName,
cache = false,
association,
}: {
columnName: string;
cache?: boolean;
association?: EntityAssociationDefinition<any, any, any, any, any, any>;
}) {
this.columnName = columnName;
this.cache = cache;
this.association = association;
}
/**
* Validates input value for a field of this type. Null and undefined are considered valid by default. This is used for things like:
* - EntityLoader.loadByFieldValue - to ensure the value being loaded by is a valid value
* - EntityMutator.setField - to ensure the value being set is a valid value
*/
public validateInputValue(value: T | null | undefined): boolean {
if (value === null || value === undefined) {
return true;
}
return this.validateInputValueInternal(value);
}
protected abstract validateInputValueInternal(value: T): boolean;
}
+56 -126
View File
@@ -1,175 +1,105 @@
import { validate as validateUUID } from 'uuid';
import { IEntityClass } from './Entity';
import EntityPrivacyPolicy from './EntityPrivacyPolicy';
import ReadonlyEntity from './ReadonlyEntity';
import ViewerContext from './ViewerContext';
export enum EntityEdgeDeletionBehavior {
/**
* Default. Invalidate the cache for all entities that reference the entity
* being deleted through this field, and transitively run deletions on those entities.
* This is most useful when the database itself expresses foreign
* keys and cascading deletes or set nulls and the entity framework just needs to
* be kept consistent with the state of the database.
*/
CASCADE_DELETE_INVALIDATE_CACHE,
/**
* Delete all entities that reference the entity being deleted through this field. This is very similar
* to SQL `ON DELETE CASCADE` but is done in the Entity framework instead of at the underlying level.
* This will also invalidate the cached referencing entities.
*/
CASCADE_DELETE,
/**
* Set this field to null when the referenced entity is deleted. This is very similar
* to SQL `ON DELETE SET NULL` but is done in the Entity framework instead of at the underlying level.
* This will also invalidate the cached referencing entities.
*/
SET_NULL,
}
export interface EntityAssociationDefinition<
TViewerContext extends ViewerContext,
TAssociatedFields,
TAssociatedID extends NonNullable<TAssociatedFields[TAssociatedSelectedFields]>,
TAssociatedEntity extends ReadonlyEntity<
TAssociatedFields,
TAssociatedID,
TViewerContext,
TAssociatedSelectedFields
>,
TAssociatedPrivacyPolicy extends EntityPrivacyPolicy<
TAssociatedFields,
TAssociatedID,
TViewerContext,
TAssociatedEntity,
TAssociatedSelectedFields
>,
TAssociatedSelectedFields extends keyof TAssociatedFields = keyof TAssociatedFields
> {
/**
* Class of entity on the other end of this edge.
*/
getAssociatedEntityClass: () => IEntityClass<
TAssociatedFields,
TAssociatedID,
TViewerContext,
TAssociatedEntity,
TAssociatedPrivacyPolicy,
TAssociatedSelectedFields
>;
/**
* Field by which to load the instance of associatedEntityClass. If not provided, the
* associatedEntityClass instance is fetched by its ID.
*/
associatedEntityLookupByField?: keyof TAssociatedFields;
/**
* What action to perform on the current entity when the entity on the referencing end of
* this edge is deleted.
*
* @remarks
* The entity framework doesn't prescribe a one-size-fits-all solution for referential
* integrity; instead it exposes mechanisms that supports both database foreign key constraints
* and implicit entity-specified foreign keys. Choosing which approach to use often depends on
* application requirements, and sometimes even a mix-and-match is the right choice.
*
* - If referential integrity is critical to your application, database foreign key constraints
* combined with {@link EntityEdgeDeletionBehavior.CASACDE_DELETE_INVALIDATE_CACHE} are recommended.
* - If the database being used doesn't support foreign keys, then using the entity framework for referential
* integrity is recommended.
*/
edgeDeletionBehavior?: EntityEdgeDeletionBehavior;
}
export abstract class EntityFieldDefinition<T> {
readonly columnName: string;
readonly cache: boolean;
readonly association?: EntityAssociationDefinition<any, any, any, any, any, any>;
/**
*
* @param columnName - Column name in the database.
* @param cache - Whether or not to cache loaded instances of the entity by this field. The column name is
* used to derive a cache key for the cache entry. If true, this column must be able uniquely
* identify the entity.
*/
constructor({
columnName,
cache = false,
association,
}: {
columnName: string;
cache?: boolean;
association?: EntityAssociationDefinition<any, any, any, any, any, any>;
}) {
this.columnName = columnName;
this.cache = cache;
this.association = association;
}
/**
* Validates input value for a field of this type. Null and undefined are considered valid by default. This is used for things like:
* - EntityLoader.loadByFieldValue - to ensure the value being loaded by is a valid value
* - EntityMutator.setField - to ensure the value being set is a valid value
*/
public validateInputValue(value: T | null | undefined): boolean {
if (value === null || value === undefined) {
return true;
}
return this.validateInputValueInternal(value);
}
protected abstract validateInputValueInternal(value: T): boolean;
}
import { EntityFieldDefinition } from './EntityFieldDefinition';
/**
* {@link EntityFieldDefinition} for a column with a JS string type.
*/
export class StringField extends EntityFieldDefinition<string> {
protected validateInputValueInternal(value: string): boolean {
return typeof value === 'string';
}
}
/**
* {@link EntityFieldDefinition} for a column with a JS string type.
* Enforces that the string is a valid UUID.
*/
export class UUIDField extends StringField {
protected validateInputValueInternal(value: string): boolean {
protected override validateInputValueInternal(value: string): boolean {
return validateUUID(value);
}
}
/**
* {@link EntityFieldDefinition} for a column with a JS Date type.
*/
export class DateField extends EntityFieldDefinition<Date> {
protected validateInputValueInternal(value: Date): boolean {
return value instanceof Date;
}
}
/**
* {@link EntityFieldDefinition} for a column with a JS boolean type.
*/
export class BooleanField extends EntityFieldDefinition<boolean> {
protected validateInputValueInternal(value: boolean): boolean {
return typeof value === 'boolean';
}
}
export class NumberField extends EntityFieldDefinition<number> {
/**
* {@link EntityFieldDefinition} for a column with a JS number type.
* Enforces that the number is an integer.
*/
export class IntField extends EntityFieldDefinition<number> {
protected validateInputValueInternal(value: number): boolean {
return typeof value === 'number' && Number.isInteger(value);
}
}
/**
* {@link 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> {
protected validateInputValueInternal(value: number): boolean {
return typeof value === 'number';
}
}
/**
* {@link 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[]> {
protected validateInputValueInternal(value: string[]): boolean {
return Array.isArray(value) && value.every((subValue) => typeof subValue === 'string');
}
}
/**
* {@link EntityFieldDefinition} for a column with a JS JSON object type.
*/
export class JSONObjectField extends EntityFieldDefinition<object> {
protected validateInputValueInternal(value: object): boolean {
return typeof value === 'object' && !Array.isArray(value);
}
}
/**
* {@link 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;
+12 -4
View File
@@ -16,6 +16,7 @@ import ViewerContext from './ViewerContext';
import EntityInvalidFieldValueError from './errors/EntityInvalidFieldValueError';
import EntityNotFoundError from './errors/EntityNotFoundError';
import EntityDataManager from './internal/EntityDataManager';
import IEntityMetricsAdapter from './metrics/IEntityMetricsAdapter';
import { mapMap, mapMapAsync } from './utils/collections/maps';
/**
@@ -49,7 +50,8 @@ export default class EntityLoader<
TSelectedFields
>,
private readonly privacyPolicy: TPrivacyPolicy,
private readonly dataManager: EntityDataManager<TFields>
private readonly dataManager: EntityDataManager<TFields>,
protected readonly metricsAdapter: IEntityMetricsAdapter
) {}
/**
@@ -216,7 +218,8 @@ export default class EntityLoader<
this.privacyPolicy.authorizeReadAsync(
this.viewerContext,
this.queryContext,
uncheckedEntityResult.value
uncheckedEntityResult.value,
this.metricsAdapter
)
);
})
@@ -269,7 +272,8 @@ export default class EntityLoader<
this.privacyPolicy.authorizeReadAsync(
this.viewerContext,
this.queryContext,
uncheckedEntityResult.value
uncheckedEntityResult.value,
this.metricsAdapter
)
);
})
@@ -298,6 +302,9 @@ export default class EntityLoader<
try {
return result(new this.entityClass(this.viewerContext, fieldsObject));
} catch (e) {
if (!(e instanceof Error)) {
throw e;
}
return result(e);
}
});
@@ -325,7 +332,8 @@ export default class EntityLoader<
this.privacyPolicy.authorizeReadAsync(
this.viewerContext,
this.queryContext,
uncheckedEntityResult.value
uncheckedEntityResult.value,
this.metricsAdapter
)
);
})
+5 -2
View File
@@ -6,6 +6,7 @@ import { EntityQueryContext } from './EntityQueryContext';
import ReadonlyEntity from './ReadonlyEntity';
import ViewerContext from './ViewerContext';
import EntityDataManager from './internal/EntityDataManager';
import IEntityMetricsAdapter from './metrics/IEntityMetricsAdapter';
/**
* The primary entry point for loading entities.
@@ -35,7 +36,8 @@ export default class EntityLoaderFactory<
TSelectedFields
>,
private readonly privacyPolicyClass: TPrivacyPolicy,
private readonly dataManager: EntityDataManager<TFields>
private readonly dataManager: EntityDataManager<TFields>,
protected readonly metricsAdapter: IEntityMetricsAdapter
) {}
/**
@@ -53,7 +55,8 @@ export default class EntityLoaderFactory<
this.entityConfiguration,
this.entityClass,
this.privacyPolicyClass,
this.dataManager
this.dataManager,
this.metricsAdapter
);
}
}
+47
View File
@@ -0,0 +1,47 @@
import Entity from './Entity';
import ViewerContext from './ViewerContext';
export enum EntityMutationType {
CREATE,
UPDATE,
DELETE,
}
export type EntityValidatorMutationInfo<
TFields,
TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext,
TEntity extends Entity<TFields, TID, TViewerContext, TSelectedFields>,
TSelectedFields extends keyof TFields = keyof TFields
> =
| {
type: EntityMutationType.CREATE;
}
| {
type: EntityMutationType.UPDATE;
previousValue: TEntity;
};
export type EntityMutationTriggerDeleteCascadeInfo<> = {
entity: Entity<any, any, any, any>;
cascadingDeleteCause: EntityMutationTriggerDeleteCascadeInfo | null;
};
export type EntityTriggerMutationInfo<
TFields,
TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext,
TEntity extends Entity<TFields, TID, TViewerContext, TSelectedFields>,
TSelectedFields extends keyof TFields = keyof TFields
> =
| {
type: EntityMutationType.CREATE;
}
| {
type: EntityMutationType.UPDATE;
previousValue: TEntity;
}
| {
type: EntityMutationType.DELETE;
cascadingDeleteCause: EntityMutationTriggerDeleteCascadeInfo | null;
};
@@ -1,5 +1,5 @@
import { EntityMutationInfo } from './EntityMutator';
import { EntityQueryContext } from './EntityQueryContext';
import { EntityTriggerMutationInfo } from './EntityMutationInfo';
import { EntityTransactionalQueryContext } from './EntityQueryContext';
import ReadonlyEntity from './ReadonlyEntity';
import ViewerContext from './ViewerContext';
@@ -78,9 +78,9 @@ export abstract class EntityMutationTrigger<
> {
abstract executeAsync(
viewerContext: TViewerContext,
queryContext: EntityQueryContext,
queryContext: EntityTransactionalQueryContext,
entity: TEntity,
mutationInfo: EntityMutationInfo<TFields, TID, TViewerContext, TEntity, TSelectedFields>
mutationInfo: EntityTriggerMutationInfo<TFields, TID, TViewerContext, TEntity, TSelectedFields>
): Promise<void>;
}
@@ -98,6 +98,6 @@ export abstract class EntityNonTransactionalMutationTrigger<
abstract executeAsync(
viewerContext: TViewerContext,
entity: TEntity,
mutationInfo: EntityMutationInfo<TFields, TID, TViewerContext, TEntity, TSelectedFields>
mutationInfo: EntityTriggerMutationInfo<TFields, TID, TViewerContext, TEntity, TSelectedFields>
): Promise<void>;
}
+10 -4
View File
@@ -1,5 +1,5 @@
import { EntityMutationInfo } from './EntityMutator';
import { EntityQueryContext } from './EntityQueryContext';
import { EntityValidatorMutationInfo } from './EntityMutationInfo';
import { EntityTransactionalQueryContext } from './EntityQueryContext';
import ReadonlyEntity from './ReadonlyEntity';
import ViewerContext from './ViewerContext';
@@ -16,8 +16,14 @@ export default abstract class EntityMutationValidator<
> {
abstract executeAsync(
viewerContext: TViewerContext,
queryContext: EntityQueryContext,
queryContext: EntityTransactionalQueryContext,
entity: TEntity,
mutationInfo: EntityMutationInfo<TFields, TID, TViewerContext, TEntity, TSelectedFields>
mutationInfo: EntityValidatorMutationInfo<
TFields,
TID,
TViewerContext,
TEntity,
TSelectedFields
>
): Promise<void>;
}
+102 -80
View File
@@ -5,8 +5,14 @@ import Entity, { IEntityClass } from './Entity';
import { EntityCompanionDefinition } from './EntityCompanionProvider';
import EntityConfiguration from './EntityConfiguration';
import EntityDatabaseAdapter from './EntityDatabaseAdapter';
import { EntityEdgeDeletionBehavior } from './EntityFields';
import { EntityEdgeDeletionBehavior } from './EntityFieldDefinition';
import EntityLoaderFactory from './EntityLoaderFactory';
import {
EntityValidatorMutationInfo,
EntityMutationType,
EntityTriggerMutationInfo,
EntityMutationTriggerDeleteCascadeInfo,
} from './EntityMutationInfo';
import EntityMutationTriggerConfiguration, {
EntityMutationTrigger,
EntityNonTransactionalMutationTrigger,
@@ -21,30 +27,6 @@ import { timeAndLogMutationEventAsync } from './metrics/EntityMetricsUtils';
import IEntityMetricsAdapter, { EntityMetricsMutationType } from './metrics/IEntityMetricsAdapter';
import { mapMapAsync } from './utils/collections/maps';
export enum EntityMutationType {
CREATE,
UPDATE,
DELETE,
}
export type EntityMutationInfo<
TFields,
TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext,
TEntity extends Entity<TFields, TID, TViewerContext, TSelectedFields>,
TSelectedFields extends keyof TFields = keyof TFields
> =
| {
type: EntityMutationType.CREATE;
}
| {
type: EntityMutationType.UPDATE;
previousValue: TEntity;
}
| {
type: EntityMutationType.DELETE;
};
abstract class BaseMutator<
TFields,
TID extends NonNullable<TFields[TSelectedFields]>,
@@ -110,26 +92,44 @@ abstract class BaseMutator<
}
}
protected async executeMutationTriggersOrValidatorsAsync(
triggersOrValidators:
| EntityMutationTrigger<TFields, TID, TViewerContext, TEntity, TSelectedFields>[]
| EntityMutationValidator<TFields, TID, TViewerContext, TEntity, TSelectedFields>[]
| undefined,
queryContext: EntityQueryContext,
protected async executeMutationValidatorsAsync(
validators: EntityMutationValidator<TFields, TID, TViewerContext, TEntity, TSelectedFields>[],
queryContext: EntityTransactionalQueryContext,
entity: TEntity,
mutationInfo: EntityMutationInfo<TFields, TID, TViewerContext, TEntity, TSelectedFields>
mutationInfo: EntityValidatorMutationInfo<
TFields,
TID,
TViewerContext,
TEntity,
TSelectedFields
>
): Promise<void> {
if (!triggersOrValidators) {
return;
}
await Promise.all(
triggersOrValidators.map((triggerOrValidator) =>
triggerOrValidator.executeAsync(this.viewerContext, queryContext, entity, mutationInfo)
validators.map((validator) =>
validator.executeAsync(this.viewerContext, queryContext, entity, mutationInfo)
)
);
}
protected async executeNonTransactionalMutationTriggersOrValidatorsAsync(
protected async executeMutationTriggersAsync(
triggers:
| EntityMutationTrigger<TFields, TID, TViewerContext, TEntity, TSelectedFields>[]
| undefined,
queryContext: EntityTransactionalQueryContext,
entity: TEntity,
mutationInfo: EntityTriggerMutationInfo<TFields, TID, TViewerContext, TEntity, TSelectedFields>
): Promise<void> {
if (!triggers) {
return;
}
await Promise.all(
triggers.map((trigger) =>
trigger.executeAsync(this.viewerContext, queryContext, entity, mutationInfo)
)
);
}
protected async executeNonTransactionalMutationTriggersAsync(
triggers:
| EntityNonTransactionalMutationTrigger<
TFields,
@@ -140,7 +140,7 @@ abstract class BaseMutator<
>[]
| undefined,
entity: TEntity,
mutationInfo: EntityMutationInfo<TFields, TID, TViewerContext, TEntity, TSelectedFields>
mutationInfo: EntityTriggerMutationInfo<TFields, TID, TViewerContext, TEntity, TSelectedFields>
): Promise<void> {
if (!triggers) {
return;
@@ -211,35 +211,36 @@ export class CreateMutator<
): Promise<Result<TEntity>> {
this.validateFields(this.fieldsForEntity);
const temporaryEntityForPrivacyCheck = new this.entityClass(this.viewerContext, ({
const temporaryEntityForPrivacyCheck = new this.entityClass(this.viewerContext, {
[this.entityConfiguration.idField]: '00000000-0000-0000-0000-000000000000', // zero UUID
...this.fieldsForEntity,
} as unknown) as TFields);
} as unknown as TFields);
const authorizeCreateResult = await asyncResult(
this.privacyPolicy.authorizeCreateAsync(
this.viewerContext,
queryContext,
temporaryEntityForPrivacyCheck
temporaryEntityForPrivacyCheck,
this.metricsAdapter
)
);
if (!authorizeCreateResult.ok) {
return authorizeCreateResult;
}
await this.executeMutationTriggersOrValidatorsAsync(
await this.executeMutationValidatorsAsync(
this.mutationValidators,
queryContext,
temporaryEntityForPrivacyCheck,
{ type: EntityMutationType.CREATE }
);
await this.executeMutationTriggersOrValidatorsAsync(
await this.executeMutationTriggersAsync(
this.mutationTriggers.beforeAll,
queryContext,
temporaryEntityForPrivacyCheck,
{ type: EntityMutationType.CREATE }
);
await this.executeMutationTriggersOrValidatorsAsync(
await this.executeMutationTriggersAsync(
this.mutationTriggers.beforeCreate,
queryContext,
temporaryEntityForPrivacyCheck,
@@ -249,7 +250,7 @@ export class CreateMutator<
const insertResult = await this.databaseAdapter.insertAsync(queryContext, this.fieldsForEntity);
const entityLoader = this.entityLoaderFactory.forLoad(this.viewerContext, queryContext);
queryContext.appendPostCommitCallback(
queryContext.appendPostCommitInvalidationCallback(
entityLoader.invalidateFieldsAsync.bind(entityLoader, insertResult)
);
@@ -258,13 +259,13 @@ export class CreateMutator<
.enforcing()
.loadByIDAsync(unauthorizedEntityAfterInsert.getID());
await this.executeMutationTriggersOrValidatorsAsync(
await this.executeMutationTriggersAsync(
this.mutationTriggers.afterCreate,
queryContext,
newEntity,
{ type: EntityMutationType.CREATE }
);
await this.executeMutationTriggersOrValidatorsAsync(
await this.executeMutationTriggersAsync(
this.mutationTriggers.afterAll,
queryContext,
newEntity,
@@ -272,7 +273,7 @@ export class CreateMutator<
);
queryContext.appendPostCommitCallback(
this.executeNonTransactionalMutationTriggersOrValidatorsAsync.bind(
this.executeNonTransactionalMutationTriggersAsync.bind(
this,
this.mutationTriggers.afterCommit,
newEntity,
@@ -408,26 +409,27 @@ export class UpdateMutator<
this.privacyPolicy.authorizeUpdateAsync(
this.viewerContext,
queryContext,
entityAboutToBeUpdated
entityAboutToBeUpdated,
this.metricsAdapter
)
);
if (!authorizeUpdateResult.ok) {
return authorizeUpdateResult;
}
await this.executeMutationTriggersOrValidatorsAsync(
await this.executeMutationValidatorsAsync(
this.mutationValidators,
queryContext,
entityAboutToBeUpdated,
{ type: EntityMutationType.UPDATE, previousValue: this.originalEntity }
);
await this.executeMutationTriggersOrValidatorsAsync(
await this.executeMutationTriggersAsync(
this.mutationTriggers.beforeAll,
queryContext,
entityAboutToBeUpdated,
{ type: EntityMutationType.UPDATE, previousValue: this.originalEntity }
);
await this.executeMutationTriggersOrValidatorsAsync(
await this.executeMutationTriggersAsync(
this.mutationTriggers.beforeUpdate,
queryContext,
entityAboutToBeUpdated,
@@ -443,13 +445,13 @@ export class UpdateMutator<
const entityLoader = this.entityLoaderFactory.forLoad(this.viewerContext, queryContext);
queryContext.appendPostCommitCallback(
queryContext.appendPostCommitInvalidationCallback(
entityLoader.invalidateFieldsAsync.bind(
entityLoader,
this.originalEntity.getAllDatabaseFields()
)
);
queryContext.appendPostCommitCallback(
queryContext.appendPostCommitInvalidationCallback(
entityLoader.invalidateFieldsAsync.bind(entityLoader, this.fieldsForEntity)
);
@@ -458,13 +460,13 @@ export class UpdateMutator<
.enforcing()
.loadByIDAsync(unauthorizedEntityAfterUpdate.getID());
await this.executeMutationTriggersOrValidatorsAsync(
await this.executeMutationTriggersAsync(
this.mutationTriggers.afterUpdate,
queryContext,
updatedEntity,
{ type: EntityMutationType.UPDATE, previousValue: this.originalEntity }
);
await this.executeMutationTriggersOrValidatorsAsync(
await this.executeMutationTriggersAsync(
this.mutationTriggers.afterAll,
queryContext,
updatedEntity,
@@ -472,7 +474,7 @@ export class UpdateMutator<
);
queryContext.appendPostCommitCallback(
this.executeNonTransactionalMutationTriggersOrValidatorsAsync.bind(
this.executeNonTransactionalMutationTriggersAsync.bind(
this,
this.mutationTriggers.afterCommit,
updatedEntity,
@@ -563,7 +565,7 @@ export class DeleteMutator<
this.metricsAdapter,
EntityMetricsMutationType.DELETE,
this.entityClass.name
)(this.deleteInTransactionAsync());
)(this.deleteInTransactionAsync(new Set(), false, null));
}
/**
@@ -574,14 +576,16 @@ export class DeleteMutator<
}
private async deleteInTransactionAsync(
processedEntityIdentifiersFromTransitiveDeletions: Set<string> = new Set(),
skipDatabaseDeletion: boolean = false
processedEntityIdentifiersFromTransitiveDeletions: Set<string>,
skipDatabaseDeletion: boolean,
cascadingDeleteCause: EntityMutationTriggerDeleteCascadeInfo | null
): Promise<Result<void>> {
return await this.queryContext.runInTransactionIfNotInTransactionAsync((innerQueryContext) =>
this.deleteInternalAsync(
innerQueryContext,
processedEntityIdentifiersFromTransitiveDeletions,
skipDatabaseDeletion
skipDatabaseDeletion,
cascadingDeleteCause
)
);
}
@@ -589,10 +593,16 @@ export class DeleteMutator<
private async deleteInternalAsync(
queryContext: EntityTransactionalQueryContext,
processedEntityIdentifiersFromTransitiveDeletions: Set<string>,
skipDatabaseDeletion: boolean
skipDatabaseDeletion: boolean,
cascadingDeleteCause: EntityMutationTriggerDeleteCascadeInfo | null
): Promise<Result<void>> {
const authorizeDeleteResult = await asyncResult(
this.privacyPolicy.authorizeDeleteAsync(this.viewerContext, queryContext, this.entity)
this.privacyPolicy.authorizeDeleteAsync(
this.viewerContext,
queryContext,
this.entity,
this.metricsAdapter
)
);
if (!authorizeDeleteResult.ok) {
return authorizeDeleteResult;
@@ -601,20 +611,21 @@ export class DeleteMutator<
await this.processEntityDeletionForInboundEdgesAsync(
this.entity,
queryContext,
processedEntityIdentifiersFromTransitiveDeletions
processedEntityIdentifiersFromTransitiveDeletions,
cascadingDeleteCause
);
await this.executeMutationTriggersOrValidatorsAsync(
await this.executeMutationTriggersAsync(
this.mutationTriggers.beforeAll,
queryContext,
this.entity,
{ type: EntityMutationType.DELETE }
{ type: EntityMutationType.DELETE, cascadingDeleteCause }
);
await this.executeMutationTriggersOrValidatorsAsync(
await this.executeMutationTriggersAsync(
this.mutationTriggers.beforeDelete,
queryContext,
this.entity,
{ type: EntityMutationType.DELETE }
{ type: EntityMutationType.DELETE, cascadingDeleteCause }
);
if (!skipDatabaseDeletion) {
@@ -626,29 +637,29 @@ export class DeleteMutator<
}
const entityLoader = this.entityLoaderFactory.forLoad(this.viewerContext, queryContext);
queryContext.appendPostCommitCallback(
queryContext.appendPostCommitInvalidationCallback(
entityLoader.invalidateFieldsAsync.bind(entityLoader, this.entity.getAllDatabaseFields())
);
await this.executeMutationTriggersOrValidatorsAsync(
await this.executeMutationTriggersAsync(
this.mutationTriggers.afterDelete,
queryContext,
this.entity,
{ type: EntityMutationType.DELETE }
{ type: EntityMutationType.DELETE, cascadingDeleteCause }
);
await this.executeMutationTriggersOrValidatorsAsync(
await this.executeMutationTriggersAsync(
this.mutationTriggers.afterAll,
queryContext,
this.entity,
{ type: EntityMutationType.DELETE }
{ type: EntityMutationType.DELETE, cascadingDeleteCause }
);
queryContext.appendPostCommitCallback(
this.executeNonTransactionalMutationTriggersOrValidatorsAsync.bind(
this.executeNonTransactionalMutationTriggersAsync.bind(
this,
this.mutationTriggers.afterCommit,
this.entity,
{ type: EntityMutationType.DELETE }
{ type: EntityMutationType.DELETE, cascadingDeleteCause }
)
);
@@ -672,7 +683,8 @@ export class DeleteMutator<
private async processEntityDeletionForInboundEdgesAsync(
entity: TEntity,
queryContext: EntityTransactionalQueryContext,
processedEntityIdentifiers: Set<string>
processedEntityIdentifiers: Set<string>,
cascadingDeleteCause: EntityMutationTriggerDeleteCascadeInfo | null
): Promise<void> {
// prevent infinite reference cycles by keeping track of entities already processed
if (processedEntityIdentifiers.has(entity.getUniqueIdentifier())) {
@@ -680,7 +692,9 @@ export class DeleteMutator<
}
processedEntityIdentifiers.add(entity.getUniqueIdentifier());
const companionDefinition = (entity.constructor as any).getCompanionDefinition() as EntityCompanionDefinition<
const companionDefinition = (
entity.constructor as any
).getCompanionDefinition() as EntityCompanionDefinition<
TFields,
TID,
TViewerContext,
@@ -743,7 +757,11 @@ export class DeleteMutator<
.forDelete(inboundReferenceEntity, queryContext)
.deleteInTransactionAsync(
processedEntityIdentifiers,
/* skipDatabaseDeletion */ true // deletion is handled by DB
/* skipDatabaseDeletion */ true, // deletion is handled by DB
{
entity,
cascadingDeleteCause,
}
)
)
);
@@ -767,7 +785,11 @@ export class DeleteMutator<
.forDelete(inboundReferenceEntity, queryContext)
.deleteInTransactionAsync(
processedEntityIdentifiers,
/* skipDatabaseDeletion */ false
/* skipDatabaseDeletion */ false,
{
entity,
cascadingDeleteCause,
}
)
)
);
+78 -20
View File
@@ -2,6 +2,9 @@ import { EntityQueryContext } from './EntityQueryContext';
import ReadonlyEntity from './ReadonlyEntity';
import ViewerContext from './ViewerContext';
import EntityNotAuthorizedError from './errors/EntityNotAuthorizedError';
import IEntityMetricsAdapter, {
EntityMetricsAuthorizationResult,
} from './metrics/IEntityMetricsAdapter';
import PrivacyPolicyRule, { RuleEvaluationResult } from './rules/PrivacyPolicyRule';
export enum EntityPrivacyPolicyEvaluationMode {
@@ -124,14 +127,16 @@ export default abstract class EntityPrivacyPolicy<
async authorizeCreateAsync(
viewerContext: TViewerContext,
queryContext: EntityQueryContext,
entity: TEntity
entity: TEntity,
metricsAdapter: IEntityMetricsAdapter
): Promise<TEntity> {
return await this.authorizeForRulesetAsync(
this.createRules,
viewerContext,
queryContext,
entity,
EntityAuthorizationAction.CREATE
EntityAuthorizationAction.CREATE,
metricsAdapter
);
}
@@ -146,14 +151,16 @@ export default abstract class EntityPrivacyPolicy<
async authorizeReadAsync(
viewerContext: TViewerContext,
queryContext: EntityQueryContext,
entity: TEntity
entity: TEntity,
metricsAdapter: IEntityMetricsAdapter
): Promise<TEntity> {
return await this.authorizeForRulesetAsync(
this.readRules,
viewerContext,
queryContext,
entity,
EntityAuthorizationAction.READ
EntityAuthorizationAction.READ,
metricsAdapter
);
}
@@ -168,14 +175,16 @@ export default abstract class EntityPrivacyPolicy<
async authorizeUpdateAsync(
viewerContext: TViewerContext,
queryContext: EntityQueryContext,
entity: TEntity
entity: TEntity,
metricsAdapter: IEntityMetricsAdapter
): Promise<TEntity> {
return await this.authorizeForRulesetAsync(
this.updateRules,
viewerContext,
queryContext,
entity,
EntityAuthorizationAction.UPDATE
EntityAuthorizationAction.UPDATE,
metricsAdapter
);
}
@@ -190,14 +199,16 @@ export default abstract class EntityPrivacyPolicy<
async authorizeDeleteAsync(
viewerContext: TViewerContext,
queryContext: EntityQueryContext,
entity: TEntity
entity: TEntity,
metricsAdapter: IEntityMetricsAdapter
): Promise<TEntity> {
return await this.authorizeForRulesetAsync(
this.deleteRules,
viewerContext,
queryContext,
entity,
EntityAuthorizationAction.DELETE
EntityAuthorizationAction.DELETE,
metricsAdapter
);
}
@@ -206,48 +217,95 @@ export default abstract class EntityPrivacyPolicy<
viewerContext: TViewerContext,
queryContext: EntityQueryContext,
entity: TEntity,
action: EntityAuthorizationAction
action: EntityAuthorizationAction,
metricsAdapter: IEntityMetricsAdapter
): Promise<TEntity> {
const privacyPolicyEvaluator = this.getPrivacyPolicyEvaluator(viewerContext);
switch (privacyPolicyEvaluator.mode) {
case EntityPrivacyPolicyEvaluationMode.ENFORCE:
return await this.authorizeForRulesetInnerAsync(
ruleset,
viewerContext,
queryContext,
entity,
action
);
case EntityPrivacyPolicyEvaluationMode.ENFORCE_AND_LOG:
try {
return await this.authorizeForRulesetInnerAsync(
const result = await this.authorizeForRulesetInnerAsync(
ruleset,
viewerContext,
queryContext,
entity,
action
);
metricsAdapter.logAuthorizationEvent({
entityClassName: entity.constructor.name,
action,
evaluationResult: EntityMetricsAuthorizationResult.ALLOW,
privacyPolicyEvaluationMode: privacyPolicyEvaluator.mode,
});
return result;
} catch (e) {
if (!(e instanceof EntityNotAuthorizedError)) {
throw e;
}
metricsAdapter.logAuthorizationEvent({
entityClassName: entity.constructor.name,
action,
evaluationResult: EntityMetricsAuthorizationResult.DENY,
privacyPolicyEvaluationMode: privacyPolicyEvaluator.mode,
});
throw e;
}
case EntityPrivacyPolicyEvaluationMode.ENFORCE_AND_LOG:
try {
const result = await this.authorizeForRulesetInnerAsync(
ruleset,
viewerContext,
queryContext,
entity,
action
);
metricsAdapter.logAuthorizationEvent({
entityClassName: entity.constructor.name,
action,
evaluationResult: EntityMetricsAuthorizationResult.ALLOW,
privacyPolicyEvaluationMode: privacyPolicyEvaluator.mode,
});
return result;
} catch (e) {
if (!(e instanceof EntityNotAuthorizedError)) {
throw e;
}
privacyPolicyEvaluator.denyHandler(e);
metricsAdapter.logAuthorizationEvent({
entityClassName: entity.constructor.name,
action,
evaluationResult: EntityMetricsAuthorizationResult.DENY,
privacyPolicyEvaluationMode: privacyPolicyEvaluator.mode,
});
throw e;
}
case EntityPrivacyPolicyEvaluationMode.DRY_RUN:
try {
return await this.authorizeForRulesetInnerAsync(
const result = await this.authorizeForRulesetInnerAsync(
ruleset,
viewerContext,
queryContext,
entity,
action
);
metricsAdapter.logAuthorizationEvent({
entityClassName: entity.constructor.name,
action,
evaluationResult: EntityMetricsAuthorizationResult.ALLOW,
privacyPolicyEvaluationMode: privacyPolicyEvaluator.mode,
});
return result;
} catch (e) {
if (!(e instanceof EntityNotAuthorizedError)) {
throw e;
}
privacyPolicyEvaluator.denyHandler(e);
metricsAdapter.logAuthorizationEvent({
entityClassName: entity.constructor.name,
action,
evaluationResult: EntityMetricsAuthorizationResult.DENY,
privacyPolicyEvaluationMode: privacyPolicyEvaluator.mode,
});
return entity;
}
}
@@ -261,7 +319,7 @@ export default abstract class EntityPrivacyPolicy<
action: EntityAuthorizationAction
): Promise<TEntity> {
for (let i = 0; i < ruleset.length; i++) {
const rule = ruleset[i];
const rule = ruleset[i]!;
const ruleEvaluationResult = await rule.evaluateAsync(viewerContext, queryContext, entity);
switch (ruleEvaluationResult) {
case RuleEvaluationResult.DENY:
+54
View File
@@ -1,6 +1,12 @@
import assert from 'assert';
import EntityQueryContextProvider from './EntityQueryContextProvider';
export type PostCommitCallback = (...args: any) => Promise<any>;
export type PreCommitCallback = (
queryContext: EntityTransactionalQueryContext,
...args: any
) => Promise<any>;
/**
* Entity framework representation of transactional and non-transactional database
@@ -43,13 +49,61 @@ export class EntityNonTransactionalQueryContext extends EntityQueryContext {
}
export class EntityTransactionalQueryContext extends EntityQueryContext {
private readonly postCommitInvalidationCallbacks: PostCommitCallback[] = [];
private readonly postCommitCallbacks: PostCommitCallback[] = [];
private readonly preCommitCallbacks: { callback: PreCommitCallback; order: number }[] = [];
/**
* Schedule a pre-commit callback. These will be run within the transaction right before it is
* committed, and will be run in the order specified. Ordering of callbacks scheduled with the
* same value for the order parameter is undefined within that ordering group.
* @param callback - callback to schedule
* @param order - order in which this should be run relative to other scheduled pre-commit callbacks,
* with higher numbers running later than lower numbers.
*/
public appendPreCommitCallback(callback: PreCommitCallback, order: number): void {
assert(
order >= Number.MIN_SAFE_INTEGER && order <= Number.MAX_SAFE_INTEGER,
`Invalid order specified: ${order}`
);
this.preCommitCallbacks.push({ callback, order });
}
/**
* Schedule a post-commit cache invalidation callback. These are run before normal
* post-commit callbacks in order to have cache consistency in normal post-commit callbacks.
* @param callback - callback to schedule
*/
public appendPostCommitInvalidationCallback(callback: PostCommitCallback): void {
this.postCommitInvalidationCallbacks.push(callback);
}
/**
* Schedule a post-commit callback. These will be run after the transaction has
* been committed.
* @param callback - callback to schedule
*/
public appendPostCommitCallback(callback: PostCommitCallback): void {
this.postCommitCallbacks.push(callback);
}
public async runPreCommitCallbacksAsync(): Promise<void> {
const callbacks = [...this.preCommitCallbacks]
.sort((a, b) => a.order - b.order)
.map((c) => c.callback);
this.preCommitCallbacks.length = 0;
for (const callback of callbacks) {
await callback(this);
}
}
public async runPostCommitCallbacksAsync(): Promise<void> {
const invalidationCallbacks = [...this.postCommitInvalidationCallbacks];
this.postCommitInvalidationCallbacks.length = 0;
await Promise.all(invalidationCallbacks.map((callback) => callback()));
const callbacks = [...this.postCommitCallbacks];
this.postCommitCallbacks.length = 0;
await Promise.all(callbacks.map((callback) => callback()));
@@ -38,6 +38,7 @@ export default abstract class EntityQueryContextProvider {
>()(async (queryInterface) => {
const queryContext = new EntityTransactionalQueryContext(queryInterface);
const result = await transactionScope(queryContext);
await queryContext.runPreCommitCallbacksAsync();
return [result, queryContext];
});
await queryContext.runPostCommitCallbacksAsync();
@@ -0,0 +1,98 @@
import invariant from 'invariant';
import { ISecondaryEntityCache } from './EntitySecondaryCacheLoader';
import IEntityGenericCacher from './IEntityGenericCacher';
import { CacheStatus } from './internal/ReadThroughEntityCache';
import { filterMap, zipToMap } from './utils/collections/maps';
/**
* A custom secondary read-through entity cache is a way to add a custom second layer of caching for a particular
* single entity load. One common way this may be used is to add a second layer of caching in a hot path that makes
* a call to {@link EntityLoader.loadManyByFieldEqualityConjunctionAsync} is guaranteed to return at most one entity.
*/
export default abstract class GenericSecondaryEntityCache<TFields, TLoadParams>
implements ISecondaryEntityCache<TFields, TLoadParams>
{
constructor(
protected readonly cacher: IEntityGenericCacher<TFields>,
protected readonly constructCacheKey: (params: Readonly<TLoadParams>) => string
) {}
public async loadManyThroughAsync(
loadParamsArray: readonly Readonly<TLoadParams>[],
fetcher: (
fetcherLoadParamsArray: readonly Readonly<TLoadParams>[]
) => Promise<ReadonlyMap<Readonly<TLoadParams>, Readonly<TFields> | null>>
): Promise<ReadonlyMap<Readonly<TLoadParams>, Readonly<TFields> | null>> {
const cacheKeys = loadParamsArray.map(this.constructCacheKey);
const cacheKeyToLoadParamsMap = zipToMap(cacheKeys, loadParamsArray);
const cacheLoadResults = await this.cacher.loadManyAsync(cacheKeys);
invariant(
cacheLoadResults.size === loadParamsArray.length,
`${this.constructor.name} loadMany should return a result for each key`
);
const cacheKeysToFetch = Array.from(
filterMap(
cacheLoadResults,
(cacheLoadResult) => cacheLoadResult.status === CacheStatus.MISS
).keys()
);
// put cache hits in result map
const results: Map<Readonly<TLoadParams>, Readonly<TFields> | null> = new Map();
cacheLoadResults.forEach((cacheLoadResult, cacheKey) => {
if (cacheLoadResult.status === CacheStatus.HIT) {
const loadParams = cacheKeyToLoadParamsMap.get(cacheKey);
invariant(loadParams !== undefined, 'load params should be in cache key map');
results.set(loadParams, cacheLoadResult.item);
}
});
// fetch any misses from DB, add DB objects to results, cache DB results, inform cache of any missing DB results
if (cacheKeysToFetch.length > 0) {
const loadParamsToFetch = cacheKeysToFetch.map((cacheKey) => {
const loadParams = cacheKeyToLoadParamsMap.get(cacheKey);
invariant(loadParams !== undefined, 'load params should be in cache key map');
return loadParams;
});
const fetchResults = await fetcher(loadParamsToFetch);
const fetchMisses = loadParamsToFetch.filter((loadParams) => {
// all values of fetchResults should be field objects or undefined
return !fetchResults.get(loadParams);
});
for (const fetchMiss of fetchMisses) {
results.set(fetchMiss, null);
}
const objectsToCache: Map<string, Readonly<TFields>> = new Map();
for (const [loadParams, object] of fetchResults.entries()) {
if (object) {
objectsToCache.set(this.constructCacheKey(loadParams), object);
results.set(loadParams, object);
}
}
await Promise.all([
this.cacher.cacheManyAsync(objectsToCache),
this.cacher.cacheDBMissesAsync(fetchMisses.map(this.constructCacheKey)),
]);
}
return results;
}
/**
* Invalidate the cache for objects cached by constructCacheKey(loadParams).
*
* @param loadParamsArray - load params to invalidate
*/
public invalidateManyAsync(loadParamsArray: readonly Readonly<TLoadParams>[]): Promise<void> {
const cacheKeys = loadParamsArray.map(this.constructCacheKey);
return this.cacher.invalidateManyAsync(cacheKeys);
}
}
@@ -0,0 +1,15 @@
import { CacheLoadResult } from './internal/ReadThroughEntityCache';
/**
* A cacher stores and loads key-value pairs. It also supports negative caching - it stores the absence
* of keys that don't exist in the backing datastore.
*/
export default interface IEntityGenericCacher<TFields> {
loadManyAsync(keys: readonly string[]): Promise<ReadonlyMap<string, CacheLoadResult<TFields>>>;
cacheManyAsync(objectMap: ReadonlyMap<string, Readonly<TFields>>): Promise<void>;
cacheDBMissesAsync(keys: readonly string[]): Promise<void>;
invalidateManyAsync(keys: readonly string[]): Promise<void>;
}
+3 -2
View File
@@ -37,8 +37,9 @@ export default abstract class ReadonlyEntity<
private readonly viewerContext: TViewerContext,
private readonly databaseFields: Readonly<TFields>
) {
const companionDefinition = (this
.constructor as any).getCompanionDefinition() as EntityCompanionDefinition<
const companionDefinition = (
this.constructor as any
).getCompanionDefinition() as EntityCompanionDefinition<
TFields,
TID,
TViewerContext,
@@ -5,6 +5,7 @@ import ReadonlyEntity from './ReadonlyEntity';
import ViewerContext from './ViewerContext';
import ViewerScopedEntityLoaderFactory from './ViewerScopedEntityLoaderFactory';
import ViewerScopedEntityMutatorFactory from './ViewerScopedEntityMutatorFactory';
import IEntityMetricsAdapter from './metrics/IEntityMetricsAdapter';
/**
* Provides a simpler API for loading and mutating entities by injecting the {@link ViewerContext}
@@ -76,4 +77,11 @@ export default class ViewerScopedEntityCompanion<
getQueryContextProvider(): EntityQueryContextProvider {
return this.entityCompanion.getQueryContextProvider();
}
/**
* Get the {@link IEntityMetricsAdapter} for this companion.
*/
getMetricsAdapter(): IEntityMetricsAdapter {
return this.entityCompanion.getMetricsAdapter();
}
}
+8 -8
View File
@@ -102,7 +102,7 @@ class SimpleTestDenyUpdateEntityPrivacyPolicy extends EntityPrivacyPolicy<
ViewerContext,
SimpleTestDenyUpdateEntity
> {
protected readonly readRules = [
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<
TestEntityFields,
string,
@@ -110,7 +110,7 @@ class SimpleTestDenyUpdateEntityPrivacyPolicy extends EntityPrivacyPolicy<
SimpleTestDenyUpdateEntity
>(),
];
protected readonly createRules = [
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<
TestEntityFields,
string,
@@ -118,7 +118,7 @@ class SimpleTestDenyUpdateEntityPrivacyPolicy extends EntityPrivacyPolicy<
SimpleTestDenyUpdateEntity
>(),
];
protected readonly updateRules = [
protected override readonly updateRules = [
new AlwaysDenyPrivacyPolicyRule<
TestEntityFields,
string,
@@ -126,7 +126,7 @@ class SimpleTestDenyUpdateEntityPrivacyPolicy extends EntityPrivacyPolicy<
SimpleTestDenyUpdateEntity
>(),
];
protected readonly deleteRules = [
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<
TestEntityFields,
string,
@@ -142,7 +142,7 @@ class SimpleTestDenyDeleteEntityPrivacyPolicy extends EntityPrivacyPolicy<
ViewerContext,
SimpleTestDenyDeleteEntity
> {
protected readonly readRules = [
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<
TestEntityFields,
string,
@@ -150,7 +150,7 @@ class SimpleTestDenyDeleteEntityPrivacyPolicy extends EntityPrivacyPolicy<
SimpleTestDenyDeleteEntity
>(),
];
protected readonly createRules = [
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<
TestEntityFields,
string,
@@ -158,7 +158,7 @@ class SimpleTestDenyDeleteEntityPrivacyPolicy extends EntityPrivacyPolicy<
SimpleTestDenyDeleteEntity
>(),
];
protected readonly updateRules = [
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<
TestEntityFields,
string,
@@ -166,7 +166,7 @@ class SimpleTestDenyDeleteEntityPrivacyPolicy extends EntityPrivacyPolicy<
SimpleTestDenyDeleteEntity
>(),
];
protected readonly deleteRules = [
protected override readonly deleteRules = [
new AlwaysDenyPrivacyPolicyRule<
TestEntityFields,
string,
@@ -66,19 +66,19 @@ class BlahEntityPrivacyPolicy extends EntityPrivacyPolicy<
ViewerContext,
BlahEntity
> {
protected readonly createRules = [
protected override readonly createRules = [
new DenyIfNotOwnerPrivacyPolicyRule(),
new AlwaysAllowPrivacyPolicyRule<BlahFields, string, ViewerContext, BlahEntity>(),
];
protected readonly readRules = [
protected override readonly readRules = [
new DenyIfNotOwnerPrivacyPolicyRule(),
new AlwaysAllowPrivacyPolicyRule<BlahFields, string, ViewerContext, BlahEntity>(),
];
protected readonly updateRules = [
protected override readonly updateRules = [
new DenyIfNotOwnerPrivacyPolicyRule(),
new AlwaysAllowPrivacyPolicyRule<BlahFields, string, ViewerContext, BlahEntity>(),
];
protected readonly deleteRules = [
protected override readonly deleteRules = [
new AlwaysDenyPrivacyPolicyRule<BlahFields, string, ViewerContext, BlahEntity>(),
];
}
+169 -14
View File
@@ -1,8 +1,12 @@
import Entity from '../Entity';
import { EntityCompanionDefinition } from '../EntityCompanionProvider';
import EntityConfiguration from '../EntityConfiguration';
import { UUIDField, EntityEdgeDeletionBehavior } from '../EntityFields';
import { EntityEdgeDeletionBehavior } from '../EntityFieldDefinition';
import { UUIDField } from '../EntityFields';
import { EntityTriggerMutationInfo, EntityMutationType } from '../EntityMutationInfo';
import { EntityMutationTrigger } from '../EntityMutationTriggerConfiguration';
import EntityPrivacyPolicy from '../EntityPrivacyPolicy';
import { EntityTransactionalQueryContext } from '../EntityQueryContext';
import { CacheStatus } from '../internal/ReadThroughEntityCache';
import AlwaysAllowPrivacyPolicyRule from '../rules/AlwaysAllowPrivacyPolicyRule';
import TestViewerContext from '../testfixtures/TestViewerContext';
@@ -30,22 +34,141 @@ class TestEntityPrivacyPolicy extends EntityPrivacyPolicy<
any,
any
> {
protected readonly readRules = [
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, TestViewerContext, any, any>(),
];
protected readonly createRules = [
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, TestViewerContext, any, any>(),
];
protected readonly updateRules = [
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, TestViewerContext, any, any>(),
];
protected readonly deleteRules = [
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, TestViewerContext, any, any>(),
];
}
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
const makeEntityClasses = (edgeDeletionBehavior: EntityEdgeDeletionBehavior) => {
const triggerExecutionCounts = {
parent: 0,
child: 0,
grandchild: 0,
};
class ParentCheckInfoTrigger extends EntityMutationTrigger<
ParentFields,
string,
TestViewerContext,
ParentEntity
> {
async executeAsync(
_viewerContext: TestViewerContext,
_queryContext: EntityTransactionalQueryContext,
_entity: ParentEntity,
mutationInfo: EntityTriggerMutationInfo<ParentFields, string, TestViewerContext, ParentEntity>
): Promise<void> {
if (mutationInfo.type !== EntityMutationType.DELETE) {
return;
}
if (mutationInfo.cascadingDeleteCause !== null) {
throw new Error('Parent entity should not have casade delete cause');
}
triggerExecutionCounts.parent++;
}
}
class ChildCheckInfoTrigger extends EntityMutationTrigger<
ChildFields,
string,
TestViewerContext,
ChildEntity
> {
async executeAsync(
_viewerContext: TestViewerContext,
_queryContext: EntityTransactionalQueryContext,
_entity: ChildEntity,
mutationInfo: EntityTriggerMutationInfo<ChildFields, string, TestViewerContext, ChildEntity>
): Promise<void> {
if (mutationInfo.type !== EntityMutationType.DELETE) {
return;
}
if (mutationInfo.cascadingDeleteCause === null) {
throw new Error('Child entity should have casade delete cause');
}
const cascadingDeleteCauseEntity = mutationInfo.cascadingDeleteCause.entity;
if (!(cascadingDeleteCauseEntity instanceof ParentEntity)) {
throw new Error('Child entity should have casade delete cause entity of type ParentEntity');
}
const secondLevelCascadingDeleteCause =
mutationInfo.cascadingDeleteCause.cascadingDeleteCause;
if (secondLevelCascadingDeleteCause) {
throw new Error('Child entity should not have two-level casade delete cause');
}
triggerExecutionCounts.child++;
}
}
class GrandChildCheckInfoTrigger extends EntityMutationTrigger<
GrandChildFields,
string,
TestViewerContext,
GrandChildEntity
> {
async executeAsync(
_viewerContext: TestViewerContext,
_queryContext: EntityTransactionalQueryContext,
_entity: GrandChildEntity,
mutationInfo: EntityTriggerMutationInfo<
GrandChildFields,
string,
TestViewerContext,
GrandChildEntity
>
): Promise<void> {
if (mutationInfo.type !== EntityMutationType.DELETE) {
return;
}
if (mutationInfo.cascadingDeleteCause === null) {
throw new Error('GrandChild entity should have cascade delete cause');
}
const cascadingDeleteCauseEntity = mutationInfo.cascadingDeleteCause.entity;
if (!(cascadingDeleteCauseEntity instanceof ChildEntity)) {
throw new Error(
'GrandChild entity should have cascade delete cause entity of type ChildEntity'
);
}
const secondLevelCascadingDeleteCause =
mutationInfo.cascadingDeleteCause.cascadingDeleteCause;
if (!secondLevelCascadingDeleteCause) {
throw new Error('GrandChild entity should have two-level casade delete cause');
}
const secondLevelCascadingDeleteCauseEntity = secondLevelCascadingDeleteCause.entity;
if (!(secondLevelCascadingDeleteCauseEntity instanceof ParentEntity)) {
throw new Error(
'GrandChild entity should have second level casade delete cause entity of type ParentEntity'
);
}
const thirdLevelCascadingDeleteCause = secondLevelCascadingDeleteCause.cascadingDeleteCause;
if (thirdLevelCascadingDeleteCause) {
throw new Error('GrandChild entity should not have three-level casade delete cause');
}
triggerExecutionCounts.grandchild++;
}
}
class ParentEntity extends Entity<ParentFields, string, TestViewerContext> {
static getCompanionDefinition(): EntityCompanionDefinition<
ParentFields,
@@ -143,33 +266,45 @@ const makeEntityClasses = (edgeDeletionBehavior: EntityEdgeDeletionBehavior) =>
entityClass: ParentEntity,
entityConfiguration: parentEntityConfiguration,
privacyPolicyClass: TestEntityPrivacyPolicy,
mutationTriggers: () => ({
beforeDelete: [new ParentCheckInfoTrigger()],
afterDelete: [new ParentCheckInfoTrigger()],
}),
});
const childEntityCompanion = new EntityCompanionDefinition({
entityClass: ChildEntity,
entityConfiguration: childEntityConfiguration,
privacyPolicyClass: TestEntityPrivacyPolicy,
mutationTriggers: () => ({
beforeDelete: [new ChildCheckInfoTrigger()],
afterDelete: [new ChildCheckInfoTrigger()],
}),
});
const grandChildEntityCompanion = new EntityCompanionDefinition({
entityClass: GrandChildEntity,
entityConfiguration: grandChildEntityConfiguration,
privacyPolicyClass: TestEntityPrivacyPolicy,
mutationTriggers: () => ({
beforeDelete: [new GrandChildCheckInfoTrigger()],
afterDelete: [new GrandChildCheckInfoTrigger()],
}),
});
return {
ParentEntity,
ChildEntity,
GrandChildEntity,
triggerExecutionCounts,
};
};
describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
describe('EntityEdgeDeletionBehavior.CASCADE_DELETE', () => {
it('deletes', async () => {
const { ParentEntity, ChildEntity, GrandChildEntity } = makeEntityClasses(
EntityEdgeDeletionBehavior.CASCADE_DELETE
);
const { ParentEntity, ChildEntity, GrandChildEntity, triggerExecutionCounts } =
makeEntityClasses(EntityEdgeDeletionBehavior.CASCADE_DELETE);
const companionProvider = createUnitTestEntityCompanionProvider();
const viewerContext = new TestViewerContext(companionProvider);
@@ -202,14 +337,20 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
await expect(
GrandChildEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(grandchild.getID())
).resolves.toBeNull();
// two calls for each trigger, one beforeDelete, one afterDelete
expect(triggerExecutionCounts).toMatchObject({
parent: 2,
child: 2,
grandchild: 2,
});
});
});
describe('EntityEdgeDeletionBehavior.SET_NULL', () => {
it('sets null', async () => {
const { ParentEntity, ChildEntity, GrandChildEntity } = makeEntityClasses(
EntityEdgeDeletionBehavior.SET_NULL
);
const { ParentEntity, ChildEntity, GrandChildEntity, triggerExecutionCounts } =
makeEntityClasses(EntityEdgeDeletionBehavior.SET_NULL);
const companionProvider = createUnitTestEntityCompanionProvider();
const viewerContext = new TestViewerContext(companionProvider);
@@ -247,14 +388,21 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
.enforcing()
.loadByIDAsync(grandchild.getID());
expect(loadedGrandchild.getField('parent_id')).toEqual(loadedChild.getID());
// two calls for only parent trigger, one beforeDelete, one afterDelete
// when using set null the descendants aren't deleted
expect(triggerExecutionCounts).toMatchObject({
parent: 2,
child: 0,
grandchild: 0,
});
});
});
describe('EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE', () => {
it('invalidates the cache', async () => {
const { ParentEntity, ChildEntity, GrandChildEntity } = makeEntityClasses(
EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE
);
const { ParentEntity, ChildEntity, GrandChildEntity, triggerExecutionCounts } =
makeEntityClasses(EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE);
const companionProvider = createUnitTestEntityCompanionProvider();
const viewerContext = new TestViewerContext(companionProvider);
@@ -318,6 +466,13 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
await expect(
GrandChildEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(grandchild.getID())
).resolves.not.toBeNull();
// two calls for each trigger, one beforeDelete, one afterDelete
expect(triggerExecutionCounts).toMatchObject({
parent: 2,
child: 2,
grandchild: 2,
});
});
});
});
@@ -1,18 +1,20 @@
import { v1 as uuidv1, v3 as uuidv3, v4 as uuidv4, v5 as uuidv5 } from 'uuid';
import { EntityFieldDefinition } from '../EntityFieldDefinition';
import {
EntityFieldDefinition,
StringField,
UUIDField,
DateField,
BooleanField,
NumberField,
IntField,
FloatField,
StringArrayField,
JSONObjectField,
EnumField,
JSONArrayField,
MaybeJSONArrayField,
} from '../EntityFields';
import describeFieldTestCase from '../utils/testing/describeFieldTestCase';
class TestFieldDefinition extends EntityFieldDefinition<string> {
protected validateInputValueInternal(value: string): boolean {
@@ -52,26 +54,6 @@ describe(EntityFieldDefinition, () => {
});
});
const describeFieldTestCase = <T>(
fieldDefinition: EntityFieldDefinition<T>,
validValues: T[],
invalidValues: any[]
): void => {
describe(fieldDefinition.constructor.name, () => {
if (validValues.length > 0) {
test.each(validValues)(`${fieldDefinition.constructor.name}.valid %p`, (value) => {
expect(fieldDefinition.validateInputValue(value)).toBe(true);
});
}
if (invalidValues.length > 0) {
test.each(invalidValues)(`${fieldDefinition.constructor.name}.invalid %p`, (value) => {
expect(fieldDefinition.validateInputValue(value)).toBe(false);
});
}
});
};
describeFieldTestCase(new StringField({ columnName: 'wat' }), ['hello', ''], [1, true, {}, [[]]]);
describeFieldTestCase(
new UUIDField({ columnName: 'wat' }),
@@ -80,7 +62,8 @@ describeFieldTestCase(
);
describeFieldTestCase(new DateField({ columnName: 'wat' }), [new Date()], [Date.now()]);
describeFieldTestCase(new BooleanField({ columnName: 'wat' }), [true, false], [0, 1, '']);
describeFieldTestCase(new NumberField({ columnName: 'wat' }), [1, 0.5, -0.5], ['1']);
describeFieldTestCase(new IntField({ columnName: 'wat' }), [1], ['1', 0.5, true]);
describeFieldTestCase(new FloatField({ columnName: 'wat' }), [1, 0.5, -0.5], ['1']);
describeFieldTestCase(
new StringArrayField({ columnName: 'wat' }),
[[['what']] as any, [[]] as any], // jest test cases need extra wrapping array
@@ -0,0 +1,177 @@
import { instance, mock } from 'ts-mockito';
import Entity from '../Entity';
import { EntityCompanionDefinition } from '../EntityCompanionProvider';
import EntityConfiguration from '../EntityConfiguration';
import { StringField } from '../EntityFields';
import EntityLoader from '../EntityLoader';
import EntityPrivacyPolicy from '../EntityPrivacyPolicy';
import ViewerContext from '../ViewerContext';
import EntityDataManager from '../internal/EntityDataManager';
import ReadThroughEntityCache from '../internal/ReadThroughEntityCache';
import IEntityMetricsAdapter from '../metrics/IEntityMetricsAdapter';
import AlwaysAllowPrivacyPolicyRule from '../rules/AlwaysAllowPrivacyPolicyRule';
import { NoCacheStubCacheAdapterProvider } from '../utils/testing/StubCacheAdapter';
import StubDatabaseAdapter from '../utils/testing/StubDatabaseAdapter';
import StubQueryContextProvider from '../utils/testing/StubQueryContextProvider';
export type TestFields = {
id: string;
};
export type TestFieldSelection = keyof TestFields;
export const testEntityConfiguration = new EntityConfiguration<TestFields>({
idField: 'id',
tableName: 'test_entity_should_not_write_to_db',
schema: {
id: new StringField({
columnName: 'id',
}),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
});
export class TestEntityPrivacyPolicy extends EntityPrivacyPolicy<
TestFields,
string,
ViewerContext,
TestEntity,
TestFieldSelection
> {
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<
TestFields,
string,
ViewerContext,
TestEntity,
TestFieldSelection
>(),
];
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<
TestFields,
string,
ViewerContext,
TestEntity,
TestFieldSelection
>(),
];
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<
TestFields,
string,
ViewerContext,
TestEntity,
TestFieldSelection
>(),
];
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<
TestFields,
string,
ViewerContext,
TestEntity,
TestFieldSelection
>(),
];
}
const ID_SENTINEL_THROW_LITERAL = 'throw_literal';
const ID_SENTINEL_THROW_ERROR = 'throw_error';
export default class TestEntity extends Entity<
TestFields,
string,
ViewerContext,
TestFieldSelection
> {
constructor(viewerContext: ViewerContext, rawFields: Readonly<TestFields>) {
if (rawFields.id === ID_SENTINEL_THROW_LITERAL) {
// eslint-disable-next-line no-throw-literal,@typescript-eslint/no-throw-literal
throw 'hello';
} else if (rawFields.id === ID_SENTINEL_THROW_ERROR) {
throw new Error('world');
}
super(viewerContext, rawFields);
}
static getCompanionDefinition(): EntityCompanionDefinition<
TestFields,
string,
ViewerContext,
TestEntity,
TestEntityPrivacyPolicy,
TestFieldSelection
> {
return testEntityCompanion;
}
}
export const testEntityCompanion = new EntityCompanionDefinition({
entityClass: TestEntity,
entityConfiguration: testEntityConfiguration,
privacyPolicyClass: TestEntityPrivacyPolicy,
});
describe(EntityLoader, () => {
it('handles thrown errors and literals from constructor', async () => {
const viewerContext = instance(mock(ViewerContext));
const metricsAdapter = instance(mock<IEntityMetricsAdapter>());
const queryContext = StubQueryContextProvider.getQueryContext();
const databaseAdapter = new StubDatabaseAdapter<TestFields>(
testEntityConfiguration,
StubDatabaseAdapter.convertFieldObjectsToDataStore(
testEntityConfiguration,
new Map([
[
testEntityConfiguration.tableName,
[
{
id: ID_SENTINEL_THROW_LITERAL,
},
{
id: ID_SENTINEL_THROW_ERROR,
},
],
],
])
)
);
const privacyPolicy = new TestEntityPrivacyPolicy();
const cacheAdapterProvider = new NoCacheStubCacheAdapterProvider();
const cacheAdapter = cacheAdapterProvider.getCacheAdapter(testEntityConfiguration);
const entityCache = new ReadThroughEntityCache(testEntityConfiguration, cacheAdapter);
const dataManager = new EntityDataManager(
databaseAdapter,
entityCache,
StubQueryContextProvider,
metricsAdapter,
TestEntity.name
);
const entityLoader = new EntityLoader(
viewerContext,
queryContext,
testEntityConfiguration,
TestEntity,
privacyPolicy,
dataManager,
metricsAdapter
);
let capturedThrownThing1: any;
try {
await entityLoader.loadByIDAsync(ID_SENTINEL_THROW_LITERAL);
} catch (e) {
capturedThrownThing1 = e;
}
expect(capturedThrownThing1).not.toBeInstanceOf(Error);
expect(capturedThrownThing1).toEqual('hello');
const result = await entityLoader.loadByIDAsync(ID_SENTINEL_THROW_ERROR);
expect(result.ok).toBe(false);
expect(result.enforceError().message).toEqual('world');
});
});
@@ -21,6 +21,7 @@ describe(EntityLoader, () => {
it('loads entities', async () => {
const dateToInsert = new Date();
const viewerContext = instance(mock(ViewerContext));
const metricsAdapter = instance(mock<IEntityMetricsAdapter>());
const queryContext = StubQueryContextProvider.getQueryContext();
const id1 = uuidv4();
@@ -36,7 +37,7 @@ describe(EntityLoader, () => {
{
customIdField: id1,
testIndexedField: 'h1',
numberField: 5,
intField: 5,
stringField: 'huh',
dateField: dateToInsert,
nullableField: null,
@@ -44,7 +45,7 @@ describe(EntityLoader, () => {
{
customIdField: id2,
testIndexedField: 'h2',
numberField: 3,
intField: 3,
stringField: 'huh',
dateField: dateToInsert,
nullableField: null,
@@ -71,7 +72,8 @@ describe(EntityLoader, () => {
testEntityConfiguration,
TestEntity,
privacyPolicy,
dataManager
dataManager,
metricsAdapter
);
const entity = await enforceAsyncResult(entityLoader.loadByIDAsync(id1));
expect(entity.getID()).toEqual(id1);
@@ -82,11 +84,11 @@ describe(EntityLoader, () => {
);
expect(entities.map((m) => m.getID())).toEqual([id1, id2]);
const entityResultNumber3 = await entityLoader.loadByFieldEqualingAsync('numberField', 3);
const entityResultNumber3 = await entityLoader.loadByFieldEqualingAsync('intField', 3);
expect(entityResultNumber3).not.toBeNull();
expect(entityResultNumber3!.enforceValue().getID()).toEqual(id2);
const entityResultNumber4 = await entityLoader.loadByFieldEqualingAsync('numberField', 4);
const entityResultNumber4 = await entityLoader.loadByFieldEqualingAsync('intField', 4);
expect(entityResultNumber4).toBeNull();
const entityResultDuplicateValues = await entityLoader
@@ -111,6 +113,7 @@ describe(EntityLoader, () => {
const privacyPolicy = new TestEntityPrivacyPolicy();
const spiedPrivacyPolicy = spy(privacyPolicy);
const viewerContext = instance(mock(ViewerContext));
const metricsAdapter = instance(mock<IEntityMetricsAdapter>());
const queryContext = StubQueryContextProvider.getQueryContext();
const id1 = uuidv4();
@@ -127,7 +130,7 @@ describe(EntityLoader, () => {
{
customIdField: id1,
stringField: 'huh',
numberField: 4,
intField: 4,
testIndexedField: '4',
dateField: new Date(),
nullableField: null,
@@ -135,7 +138,7 @@ describe(EntityLoader, () => {
{
customIdField: id2,
stringField: 'huh',
numberField: 4,
intField: 4,
testIndexedField: '5',
dateField: new Date(),
nullableField: null,
@@ -143,7 +146,7 @@ describe(EntityLoader, () => {
{
customIdField: id3,
stringField: 'huh2',
numberField: 4,
intField: 4,
testIndexedField: '6',
dateField: new Date(),
nullableField: null,
@@ -169,7 +172,8 @@ describe(EntityLoader, () => {
testEntityConfiguration,
TestEntity,
privacyPolicy,
dataManager
dataManager,
metricsAdapter
);
const entities = await enforceResultsAsync(
entityLoader.loadManyByFieldEqualityConjunctionAsync([
@@ -178,14 +182,19 @@ describe(EntityLoader, () => {
fieldValue: 'huh',
},
{
fieldName: 'numberField',
fieldName: 'intField',
fieldValue: 4,
},
])
);
expect(entities).toHaveLength(2);
verify(
spiedPrivacyPolicy.authorizeReadAsync(viewerContext, queryContext, anyOfClass(TestEntity))
spiedPrivacyPolicy.authorizeReadAsync(
viewerContext,
queryContext,
anyOfClass(TestEntity),
anything()
)
).twice();
await expect(
@@ -200,6 +209,7 @@ describe(EntityLoader, () => {
const spiedPrivacyPolicy = spy(privacyPolicy);
const viewerContext = instance(mock(ViewerContext));
const metricsAdapter = instance(mock<IEntityMetricsAdapter>());
const queryContext = StubQueryContextProvider.getQueryContext();
const id1 = uuidv4();
@@ -215,7 +225,7 @@ describe(EntityLoader, () => {
customIdField: id1,
stringField: 'huh',
testIndexedField: '1',
numberField: 3,
intField: 3,
dateField: new Date(),
nullableField: null,
},
@@ -240,14 +250,18 @@ describe(EntityLoader, () => {
testEntityConfiguration,
TestEntity,
privacyPolicy,
dataManager
dataManager,
metricsAdapter
);
const entity = await enforceAsyncResult(entityLoader.loadByIDAsync(id1));
verify(spiedPrivacyPolicy.authorizeReadAsync(viewerContext, queryContext, entity)).once();
verify(
spiedPrivacyPolicy.authorizeReadAsync(viewerContext, queryContext, entity, anything())
).once();
});
it('invalidates upon invalidate one', async () => {
const viewerContext = instance(mock(ViewerContext));
const metricsAdapter = instance(mock<IEntityMetricsAdapter>());
const queryContext = StubQueryContextProvider.getQueryContext();
const privacyPolicy = instance(mock(TestEntityPrivacyPolicy));
const dataManagerMock = mock<EntityDataManager<TestFields>>();
@@ -260,7 +274,8 @@ describe(EntityLoader, () => {
testEntityConfiguration,
TestEntity,
privacyPolicy,
dataManagerInstance
dataManagerInstance,
metricsAdapter
);
await entityLoader.invalidateFieldsAsync({ customIdField: id1 } as any);
@@ -271,6 +286,7 @@ describe(EntityLoader, () => {
it('invalidates upon invalidate by field', async () => {
const viewerContext = instance(mock(ViewerContext));
const metricsAdapter = instance(mock<IEntityMetricsAdapter>());
const queryContext = StubQueryContextProvider.getQueryContext();
const privacyPolicy = instance(mock(TestEntityPrivacyPolicy));
const dataManagerMock = mock<EntityDataManager<TestFields>>();
@@ -283,7 +299,8 @@ describe(EntityLoader, () => {
testEntityConfiguration,
TestEntity,
privacyPolicy,
dataManagerInstance
dataManagerInstance,
metricsAdapter
);
await entityLoader.invalidateFieldsAsync({ customIdField: id1 } as any);
verify(
@@ -293,6 +310,7 @@ describe(EntityLoader, () => {
it('invalidates upon invalidate by entity', async () => {
const viewerContext = instance(mock(ViewerContext));
const metricsAdapter = instance(mock<IEntityMetricsAdapter>());
const queryContext = StubQueryContextProvider.getQueryContext();
const privacyPolicy = instance(mock(TestEntityPrivacyPolicy));
const dataManagerMock = mock<EntityDataManager<TestFields>>();
@@ -309,7 +327,8 @@ describe(EntityLoader, () => {
testEntityConfiguration,
TestEntity,
privacyPolicy,
dataManagerInstance
dataManagerInstance,
metricsAdapter
);
await entityLoader.invalidateEntityAsync(entityInstance);
verify(
@@ -319,6 +338,7 @@ describe(EntityLoader, () => {
it('returns error result when not allowed', async () => {
const viewerContext = instance(mock(ViewerContext));
const metricsAdapter = instance(mock<IEntityMetricsAdapter>());
const queryContext = StubQueryContextProvider.getQueryContext();
const privacyPolicyMock = mock(TestEntityPrivacyPolicy);
const dataManagerMock = mock<EntityDataManager<TestFields>>();
@@ -331,7 +351,12 @@ describe(EntityLoader, () => {
const rejectionError = new Error();
when(
privacyPolicyMock.authorizeReadAsync(viewerContext, queryContext, anyOfClass(TestEntity))
privacyPolicyMock.authorizeReadAsync(
viewerContext,
queryContext,
anyOfClass(TestEntity),
anything()
)
).thenReject(rejectionError);
const privacyPolicy = instance(privacyPolicyMock);
@@ -343,7 +368,8 @@ describe(EntityLoader, () => {
testEntityConfiguration,
TestEntity,
privacyPolicy,
dataManagerInstance
dataManagerInstance,
metricsAdapter
);
const entityResult = await entityLoader.loadByIDAsync(id1);
@@ -354,6 +380,7 @@ describe(EntityLoader, () => {
it('throws upon database adapter error', async () => {
const viewerContext = instance(mock(ViewerContext));
const metricsAdapter = instance(mock<IEntityMetricsAdapter>());
const queryContext = StubQueryContextProvider.getQueryContext();
const privacyPolicy = instance(mock(TestEntityPrivacyPolicy));
const dataManagerMock = mock<EntityDataManager<TestFields>>();
@@ -372,7 +399,8 @@ describe(EntityLoader, () => {
testEntityConfiguration,
TestEntity,
privacyPolicy,
dataManagerInstance
dataManagerInstance,
metricsAdapter
);
const loadByValue = uuidv4();
@@ -0,0 +1,105 @@
import Entity from '../Entity';
import { EntityCompanionDefinition } from '../EntityCompanionProvider';
import EntityConfiguration from '../EntityConfiguration';
import { UUIDField } from '../EntityFields';
import { EntityMutationType, EntityTriggerMutationInfo } from '../EntityMutationInfo';
import { EntityNonTransactionalMutationTrigger } from '../EntityMutationTriggerConfiguration';
import EntityPrivacyPolicy from '../EntityPrivacyPolicy';
import ViewerContext from '../ViewerContext';
import AlwaysAllowPrivacyPolicyRule from '../rules/AlwaysAllowPrivacyPolicyRule';
import { createUnitTestEntityCompanionProvider } from '../utils/testing/createUnitTestEntityCompanionProvider';
type BlahFields = {
id: string;
};
class BlahEntityPrivacyPolicy extends EntityPrivacyPolicy<
BlahFields,
string,
ViewerContext,
BlahEntity
> {
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<BlahFields, string, ViewerContext, BlahEntity>(),
];
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<BlahFields, string, ViewerContext, BlahEntity>(),
];
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<BlahFields, string, ViewerContext, BlahEntity>(),
];
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<BlahFields, string, ViewerContext, BlahEntity>(),
];
}
class BlahEntity extends Entity<BlahFields, string, ViewerContext> {
static getCompanionDefinition(): EntityCompanionDefinition<
BlahFields,
string,
ViewerContext,
BlahEntity,
BlahEntityPrivacyPolicy
> {
return blahCompanion;
}
}
const blahCompanion = new EntityCompanionDefinition({
entityClass: BlahEntity,
entityConfiguration: new EntityConfiguration<BlahFields>({
idField: 'id',
tableName: 'blah_table',
schema: {
id: new UUIDField({
columnName: 'id',
cache: true,
}),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
}),
privacyPolicyClass: BlahEntityPrivacyPolicy,
mutationTriggers: () => ({
afterCommit: [new TestNonTransactionalMutationTrigger()],
}),
});
class TestNonTransactionalMutationTrigger extends EntityNonTransactionalMutationTrigger<
BlahFields,
string,
ViewerContext,
BlahEntity
> {
async executeAsync(
viewerContext: ViewerContext,
entity: BlahEntity,
mutationInfo: EntityTriggerMutationInfo<BlahFields, string, ViewerContext, BlahEntity>
): Promise<void> {
if (mutationInfo.type === EntityMutationType.DELETE) {
const entityLoaded = await BlahEntity.loader(viewerContext)
.enforcing()
.loadByIDNullableAsync(entity.getID());
if (entityLoaded) {
throw new Error(
'should not have been able to re-load the entity after delete. this means the cache has not been cleared'
);
}
}
}
}
describe('EntityMutator', () => {
test('cache consistency with post-commit callbacks', async () => {
const companionProvider = createUnitTestEntityCompanionProvider();
const viewerContext = new ViewerContext(companionProvider);
// put it in cache
const entity = await BlahEntity.creator(viewerContext).enforceCreateAsync();
const entityLoaded = await BlahEntity.loader(viewerContext)
.enforcing()
.loadByIDAsync(entity.getID());
await BlahEntity.enforceDeleteAsync(entityLoaded);
});
});
@@ -14,12 +14,16 @@ import { v4 as uuidv4 } from 'uuid';
import EntityDatabaseAdapter from '../EntityDatabaseAdapter';
import EntityLoaderFactory from '../EntityLoaderFactory';
import {
EntityMutationType,
EntityTriggerMutationInfo,
EntityValidatorMutationInfo,
} from '../EntityMutationInfo';
import EntityMutationTriggerConfiguration, {
EntityMutationTrigger,
EntityNonTransactionalMutationTrigger,
} from '../EntityMutationTriggerConfiguration';
import EntityMutationValidator from '../EntityMutationValidator';
import { EntityMutationInfo, EntityMutationType } from '../EntityMutator';
import EntityMutatorFactory from '../EntityMutatorFactory';
import { EntityTransactionalQueryContext, EntityQueryContext } from '../EntityQueryContext';
import ViewerContext from '../ViewerContext';
@@ -53,7 +57,7 @@ class TestMutationTrigger extends EntityMutationTrigger<
_viewerContext: ViewerContext,
_queryContext: EntityQueryContext,
_entity: TestEntity,
_mutationInfo: EntityMutationInfo<
_mutationInfo: EntityTriggerMutationInfo<
TestFields,
string,
ViewerContext,
@@ -95,7 +99,13 @@ const verifyValidatorCounts = (
keyof TestFields
>[],
expectedCalls: number,
mutationInfo: EntityMutationInfo<TestFields, string, ViewerContext, TestEntity, keyof TestFields>
mutationInfo: EntityValidatorMutationInfo<
TestFields,
string,
ViewerContext,
TestEntity,
keyof TestFields
>
): void => {
for (const validator of mutationValidatorSpies) {
verify(
@@ -125,15 +135,15 @@ const setUpMutationTriggerSpies = (
keyof TestFields
> => {
return {
beforeCreate: [spy(mutationTriggers.beforeCreate![0])],
afterCreate: [spy(mutationTriggers.afterCreate![0])],
beforeUpdate: [spy(mutationTriggers.beforeUpdate![0])],
afterUpdate: [spy(mutationTriggers.afterUpdate![0])],
beforeDelete: [spy(mutationTriggers.beforeDelete![0])],
afterDelete: [spy(mutationTriggers.afterDelete![0])],
beforeAll: [spy(mutationTriggers.beforeAll![0])],
afterAll: [spy(mutationTriggers.afterAll![0])],
afterCommit: [spy(mutationTriggers.afterCommit![0])],
beforeCreate: [spy(mutationTriggers.beforeCreate![0]!)],
afterCreate: [spy(mutationTriggers.afterCreate![0]!)],
beforeUpdate: [spy(mutationTriggers.beforeUpdate![0]!)],
afterUpdate: [spy(mutationTriggers.afterUpdate![0]!)],
beforeDelete: [spy(mutationTriggers.beforeDelete![0]!)],
afterDelete: [spy(mutationTriggers.afterDelete![0]!)],
beforeAll: [spy(mutationTriggers.beforeAll![0]!)],
afterAll: [spy(mutationTriggers.afterAll![0]!)],
afterCommit: [spy(mutationTriggers.afterCommit![0]!)],
};
};
@@ -164,7 +174,13 @@ const verifyTriggerCounts = (
>,
boolean
>,
mutationInfo: EntityMutationInfo<TestFields, string, ViewerContext, TestEntity, keyof TestFields>
mutationInfo: EntityTriggerMutationInfo<
TestFields,
string,
ViewerContext,
TestEntity,
keyof TestFields
>
): void => {
Object.keys(executed).forEach((s) => {
if ((executed as any)[s]) {
@@ -189,7 +205,7 @@ const verifyTriggerCounts = (
});
verify(
mutationTriggerSpies.beforeAll![0].executeAsync(
mutationTriggerSpies.beforeAll![0]!.executeAsync(
viewerContext,
anyOfClass(EntityTransactionalQueryContext),
anyOfClass(TestEntity),
@@ -198,7 +214,7 @@ const verifyTriggerCounts = (
).once();
verify(
mutationTriggerSpies.afterAll![0].executeAsync(
mutationTriggerSpies.afterAll![0]!.executeAsync(
viewerContext,
anyOfClass(EntityTransactionalQueryContext),
anyOfClass(TestEntity),
@@ -207,7 +223,7 @@ const verifyTriggerCounts = (
).once();
verify(
mutationTriggerSpies.afterCommit![0].executeAsync(
mutationTriggerSpies.afterCommit![0]!.executeAsync(
viewerContext,
anyOfClass(TestEntity),
deepEqual(mutationInfo)
@@ -297,7 +313,8 @@ const createEntityMutatorFactory = (
testEntityConfiguration,
TestEntity,
privacyPolicy,
dataManager
dataManager,
metricsAdapter
);
const entityMutatorFactory = new EntityMutatorFactory(
testEntityConfiguration,
@@ -332,7 +349,7 @@ describe(EntityMutatorFactory, () => {
customIdField: id1,
stringField: 'huh',
testIndexedField: '4',
numberField: 1,
intField: 1,
dateField: new Date(),
nullableField: null,
},
@@ -340,7 +357,7 @@ describe(EntityMutatorFactory, () => {
customIdField: id2,
stringField: 'huh',
testIndexedField: '5',
numberField: 1,
intField: 1,
dateField: new Date(),
nullableField: null,
},
@@ -363,7 +380,7 @@ describe(EntityMutatorFactory, () => {
customIdField: id1,
stringField: 'huh',
testIndexedField: '4',
numberField: 1,
intField: 1,
dateField: new Date(),
nullableField: null,
},
@@ -371,7 +388,7 @@ describe(EntityMutatorFactory, () => {
customIdField: id2,
stringField: 'huh',
testIndexedField: '5',
numberField: 1,
intField: 1,
dateField: new Date(),
nullableField: null,
},
@@ -388,7 +405,8 @@ describe(EntityMutatorFactory, () => {
spiedPrivacyPolicy.authorizeCreateAsync(
viewerContext,
anyOfClass(EntityTransactionalQueryContext),
anyOfClass(TestEntity)
anyOfClass(TestEntity),
anything()
)
).once();
});
@@ -404,7 +422,7 @@ describe(EntityMutatorFactory, () => {
customIdField: id1,
stringField: 'huh',
testIndexedField: '4',
numberField: 1,
intField: 1,
dateField: new Date(),
nullableField: null,
},
@@ -412,7 +430,7 @@ describe(EntityMutatorFactory, () => {
customIdField: id2,
stringField: 'huh',
testIndexedField: '5',
numberField: 1,
intField: 1,
dateField: new Date(),
nullableField: null,
},
@@ -451,7 +469,7 @@ describe(EntityMutatorFactory, () => {
customIdField: id1,
stringField: 'huh',
testIndexedField: '4',
numberField: 1,
intField: 1,
dateField: new Date(),
nullableField: null,
},
@@ -459,7 +477,7 @@ describe(EntityMutatorFactory, () => {
customIdField: id2,
stringField: 'huh',
testIndexedField: '5',
numberField: 1,
intField: 1,
dateField: new Date(),
nullableField: null,
},
@@ -488,7 +506,7 @@ describe(EntityMutatorFactory, () => {
customIdField: id1,
stringField: 'huh',
testIndexedField: '3',
numberField: 3,
intField: 3,
dateField: new Date(),
nullableField: null,
},
@@ -496,7 +514,7 @@ describe(EntityMutatorFactory, () => {
customIdField: id2,
stringField: 'huh',
testIndexedField: '4',
numberField: 3,
intField: 3,
dateField: new Date(),
nullableField: null,
},
@@ -527,28 +545,25 @@ describe(EntityMutatorFactory, () => {
const id1 = uuidv4();
const id2 = uuidv4();
const {
privacyPolicy,
entityMutatorFactory,
entityLoaderFactory,
} = createEntityMutatorFactory([
{
customIdField: id1,
stringField: 'huh',
testIndexedField: '3',
numberField: 3,
dateField: new Date(),
nullableField: null,
},
{
customIdField: id2,
stringField: 'huh',
testIndexedField: '4',
numberField: 3,
dateField: new Date(),
nullableField: null,
},
]);
const { privacyPolicy, entityMutatorFactory, entityLoaderFactory } =
createEntityMutatorFactory([
{
customIdField: id1,
stringField: 'huh',
testIndexedField: '3',
intField: 3,
dateField: new Date(),
nullableField: null,
},
{
customIdField: id2,
stringField: 'huh',
testIndexedField: '4',
intField: 3,
dateField: new Date(),
nullableField: null,
},
]);
const spiedPrivacyPolicy = spy(privacyPolicy);
@@ -565,7 +580,8 @@ describe(EntityMutatorFactory, () => {
spiedPrivacyPolicy.authorizeUpdateAsync(
viewerContext,
anyOfClass(EntityTransactionalQueryContext),
anyOfClass(TestEntity)
anyOfClass(TestEntity),
anything()
)
).once();
});
@@ -576,28 +592,25 @@ describe(EntityMutatorFactory, () => {
const id1 = uuidv4();
const id2 = uuidv4();
const {
mutationTriggers,
entityMutatorFactory,
entityLoaderFactory,
} = createEntityMutatorFactory([
{
customIdField: id1,
stringField: 'huh',
testIndexedField: '3',
numberField: 3,
dateField: new Date(),
nullableField: null,
},
{
customIdField: id2,
stringField: 'huh',
testIndexedField: '4',
numberField: 3,
dateField: new Date(),
nullableField: null,
},
]);
const { mutationTriggers, entityMutatorFactory, entityLoaderFactory } =
createEntityMutatorFactory([
{
customIdField: id1,
stringField: 'huh',
testIndexedField: '3',
intField: 3,
dateField: new Date(),
nullableField: null,
},
{
customIdField: id2,
stringField: 'huh',
testIndexedField: '4',
intField: 3,
dateField: new Date(),
nullableField: null,
},
]);
const triggerSpies = setUpMutationTriggerSpies(mutationTriggers);
@@ -630,28 +643,25 @@ describe(EntityMutatorFactory, () => {
const id1 = uuidv4();
const id2 = uuidv4();
const {
mutationValidators,
entityMutatorFactory,
entityLoaderFactory,
} = createEntityMutatorFactory([
{
customIdField: id1,
stringField: 'huh',
testIndexedField: '3',
numberField: 3,
dateField: new Date(),
nullableField: null,
},
{
customIdField: id2,
stringField: 'huh',
testIndexedField: '4',
numberField: 3,
dateField: new Date(),
nullableField: null,
},
]);
const { mutationValidators, entityMutatorFactory, entityLoaderFactory } =
createEntityMutatorFactory([
{
customIdField: id1,
stringField: 'huh',
testIndexedField: '3',
intField: 3,
dateField: new Date(),
nullableField: null,
},
{
customIdField: id2,
stringField: 'huh',
testIndexedField: '4',
intField: 3,
dateField: new Date(),
nullableField: null,
},
]);
const validatorSpies = setUpMutationValidatorSpies(mutationValidators);
@@ -682,7 +692,7 @@ describe(EntityMutatorFactory, () => {
customIdField: id1,
stringField: 'huh',
testIndexedField: '3',
numberField: 3,
intField: 3,
dateField: new Date(),
nullableField: null,
},
@@ -707,20 +717,17 @@ describe(EntityMutatorFactory, () => {
const queryContext = StubQueryContextProvider.getQueryContext();
const id1 = uuidv4();
const {
privacyPolicy,
entityMutatorFactory,
entityLoaderFactory,
} = createEntityMutatorFactory([
{
customIdField: id1,
stringField: 'huh',
testIndexedField: '3',
numberField: 3,
dateField: new Date(),
nullableField: null,
},
]);
const { privacyPolicy, entityMutatorFactory, entityLoaderFactory } =
createEntityMutatorFactory([
{
customIdField: id1,
stringField: 'huh',
testIndexedField: '3',
intField: 3,
dateField: new Date(),
nullableField: null,
},
]);
const spiedPrivacyPolicy = spy(privacyPolicy);
@@ -734,7 +741,8 @@ describe(EntityMutatorFactory, () => {
spiedPrivacyPolicy.authorizeDeleteAsync(
viewerContext,
anyOfClass(EntityTransactionalQueryContext),
anyOfClass(TestEntity)
anyOfClass(TestEntity),
anything()
)
).once();
});
@@ -744,20 +752,17 @@ describe(EntityMutatorFactory, () => {
const queryContext = StubQueryContextProvider.getQueryContext();
const id1 = uuidv4();
const {
mutationTriggers,
entityMutatorFactory,
entityLoaderFactory,
} = createEntityMutatorFactory([
{
customIdField: id1,
stringField: 'huh',
testIndexedField: '3',
numberField: 3,
dateField: new Date(),
nullableField: null,
},
]);
const { mutationTriggers, entityMutatorFactory, entityLoaderFactory } =
createEntityMutatorFactory([
{
customIdField: id1,
stringField: 'huh',
testIndexedField: '3',
intField: 3,
dateField: new Date(),
nullableField: null,
},
]);
const triggerSpies = setUpMutationTriggerSpies(mutationTriggers);
@@ -778,7 +783,7 @@ describe(EntityMutatorFactory, () => {
beforeDelete: true,
afterDelete: true,
},
{ type: EntityMutationType.DELETE }
{ type: EntityMutationType.DELETE, cascadingDeleteCause: null }
);
});
@@ -787,20 +792,17 @@ describe(EntityMutatorFactory, () => {
const queryContext = StubQueryContextProvider.getQueryContext();
const id1 = uuidv4();
const {
mutationValidators,
entityMutatorFactory,
entityLoaderFactory,
} = createEntityMutatorFactory([
{
customIdField: id1,
stringField: 'huh',
testIndexedField: '3',
numberField: 3,
dateField: new Date(),
nullableField: null,
},
]);
const { mutationValidators, entityMutatorFactory, entityLoaderFactory } =
createEntityMutatorFactory([
{
customIdField: id1,
stringField: 'huh',
testIndexedField: '3',
intField: 3,
dateField: new Date(),
nullableField: null,
},
]);
const validatorSpies = setUpMutationValidatorSpies(mutationValidators);
@@ -810,7 +812,9 @@ describe(EntityMutatorFactory, () => {
await entityMutatorFactory.forDelete(existingEntity, queryContext).enforceDeleteAsync();
verifyValidatorCounts(viewerContext, validatorSpies, 0, { type: EntityMutationType.DELETE });
verifyValidatorCounts(viewerContext, validatorSpies, 0, {
type: EntityMutationType.DELETE as any,
});
});
});
@@ -824,7 +828,7 @@ describe(EntityMutatorFactory, () => {
customIdField: id1,
stringField: 'huh',
testIndexedField: '3',
numberField: 3,
intField: 3,
dateField: new Date(),
nullableField: null,
},
@@ -861,7 +865,7 @@ describe(EntityMutatorFactory, () => {
customIdField: id1,
stringField: 'huh',
testIndexedField: '3',
numberField: 3,
intField: 3,
dateField: new Date(),
nullableField: null,
},
@@ -912,21 +916,24 @@ describe(EntityMutatorFactory, () => {
privacyPolicyMock.authorizeCreateAsync(
viewerContext,
anyOfClass(EntityTransactionalQueryContext),
anyOfClass(SimpleTestEntity)
anyOfClass(SimpleTestEntity),
anything()
)
).thenReject(rejectionError);
when(
privacyPolicyMock.authorizeUpdateAsync(
viewerContext,
anyOfClass(EntityTransactionalQueryContext),
anyOfClass(SimpleTestEntity)
anyOfClass(SimpleTestEntity),
anything()
)
).thenReject(rejectionError);
when(
privacyPolicyMock.authorizeDeleteAsync(
viewerContext,
anyOfClass(EntityTransactionalQueryContext),
anyOfClass(SimpleTestEntity)
anyOfClass(SimpleTestEntity),
anything()
)
).thenReject(rejectionError);
@@ -1,4 +1,4 @@
import { mock, instance, spy, verify, anyOfClass } from 'ts-mockito';
import { mock, instance, spy, verify, anyOfClass, anything, objectContaining } from 'ts-mockito';
import Entity from '../Entity';
import { EntityCompanionDefinition } from '../EntityCompanionProvider';
@@ -6,11 +6,15 @@ import EntityConfiguration from '../EntityConfiguration';
import { UUIDField } from '../EntityFields';
import EntityPrivacyPolicy, {
EntityPrivacyPolicyEvaluator,
EntityAuthorizationAction,
EntityPrivacyPolicyEvaluationMode,
} from '../EntityPrivacyPolicy';
import { EntityQueryContext } from '../EntityQueryContext';
import ViewerContext from '../ViewerContext';
import EntityNotAuthorizedError from '../errors/EntityNotAuthorizedError';
import IEntityMetricsAdapter, {
EntityMetricsAuthorizationResult,
} from '../metrics/IEntityMetricsAdapter';
import AlwaysAllowPrivacyPolicyRule from '../rules/AlwaysAllowPrivacyPolicyRule';
import AlwaysDenyPrivacyPolicyRule from '../rules/AlwaysDenyPrivacyPolicyRule';
import AlwaysSkipPrivacyPolicyRule from '../rules/AlwaysSkipPrivacyPolicyRule';
@@ -33,16 +37,16 @@ class BlahEntity extends Entity<BlahFields, string, ViewerContext> {
}
class AlwaysDenyPolicy extends EntityPrivacyPolicy<BlahFields, string, ViewerContext, BlahEntity> {
protected readonly createRules = [
protected override readonly createRules = [
new AlwaysDenyPrivacyPolicyRule<BlahFields, string, ViewerContext, BlahEntity>(),
];
protected readonly readRules = [
protected override readonly readRules = [
new AlwaysDenyPrivacyPolicyRule<BlahFields, string, ViewerContext, BlahEntity>(),
];
protected readonly updateRules = [
protected override readonly updateRules = [
new AlwaysDenyPrivacyPolicyRule<BlahFields, string, ViewerContext, BlahEntity>(),
];
protected readonly deleteRules = [
protected override readonly deleteRules = [
new AlwaysDenyPrivacyPolicyRule<BlahFields, string, ViewerContext, BlahEntity>(),
];
}
@@ -53,7 +57,7 @@ class DryRunAlwaysDenyPolicy extends AlwaysDenyPolicy {
_error: EntityNotAuthorizedError<BlahFields, string, ViewerContext, BlahEntity>
): void {}
protected getPrivacyPolicyEvaluator(): EntityPrivacyPolicyEvaluator<
protected override getPrivacyPolicyEvaluator(): EntityPrivacyPolicyEvaluator<
BlahFields,
string,
ViewerContext,
@@ -72,7 +76,7 @@ class LoggingEnforceAlwaysDenyPolicy extends AlwaysDenyPolicy {
_error: EntityNotAuthorizedError<BlahFields, string, ViewerContext, BlahEntity>
): void {}
protected getPrivacyPolicyEvaluator(): EntityPrivacyPolicyEvaluator<
protected override getPrivacyPolicyEvaluator(): EntityPrivacyPolicyEvaluator<
BlahFields,
string,
ViewerContext,
@@ -86,16 +90,16 @@ class LoggingEnforceAlwaysDenyPolicy extends AlwaysDenyPolicy {
}
class AlwaysAllowPolicy extends EntityPrivacyPolicy<BlahFields, string, ViewerContext, BlahEntity> {
protected readonly createRules = [
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<BlahFields, string, ViewerContext, BlahEntity>(),
];
protected readonly readRules = [
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<BlahFields, string, ViewerContext, BlahEntity>(),
];
protected readonly updateRules = [
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<BlahFields, string, ViewerContext, BlahEntity>(),
];
protected readonly deleteRules = [
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<BlahFields, string, ViewerContext, BlahEntity>(),
];
}
@@ -106,7 +110,7 @@ class DryRunAlwaysAllowPolicy extends AlwaysAllowPolicy {
_error: EntityNotAuthorizedError<BlahFields, string, ViewerContext, BlahEntity>
): void {}
protected getPrivacyPolicyEvaluator(): EntityPrivacyPolicyEvaluator<
protected override getPrivacyPolicyEvaluator(): EntityPrivacyPolicyEvaluator<
BlahFields,
string,
ViewerContext,
@@ -125,7 +129,7 @@ class LoggingEnforceAlwaysAllowPolicy extends AlwaysAllowPolicy {
_error: EntityNotAuthorizedError<BlahFields, string, ViewerContext, BlahEntity>
): void {}
protected getPrivacyPolicyEvaluator(): EntityPrivacyPolicyEvaluator<
protected override getPrivacyPolicyEvaluator(): EntityPrivacyPolicyEvaluator<
BlahFields,
string,
ViewerContext,
@@ -139,16 +143,16 @@ class LoggingEnforceAlwaysAllowPolicy extends AlwaysAllowPolicy {
}
class SkipAllPolicy extends EntityPrivacyPolicy<BlahFields, string, ViewerContext, BlahEntity> {
protected readonly createRules = [
protected override readonly createRules = [
new AlwaysSkipPrivacyPolicyRule<BlahFields, string, ViewerContext, BlahEntity>(),
];
protected readonly readRules = [
protected override readonly readRules = [
new AlwaysSkipPrivacyPolicyRule<BlahFields, string, ViewerContext, BlahEntity>(),
];
protected readonly updateRules = [
protected override readonly updateRules = [
new AlwaysSkipPrivacyPolicyRule<BlahFields, string, ViewerContext, BlahEntity>(),
];
protected readonly deleteRules = [
protected override readonly deleteRules = [
new AlwaysSkipPrivacyPolicyRule<BlahFields, string, ViewerContext, BlahEntity>(),
];
}
@@ -169,10 +173,10 @@ class AlwaysThrowPrivacyPolicyRule extends PrivacyPolicyRule<
}
class ThrowAllPolicy extends EntityPrivacyPolicy<BlahFields, string, ViewerContext, BlahEntity> {
protected readonly createRules = [new AlwaysThrowPrivacyPolicyRule()];
protected readonly readRules = [new AlwaysThrowPrivacyPolicyRule()];
protected readonly updateRules = [new AlwaysThrowPrivacyPolicyRule()];
protected readonly deleteRules = [new AlwaysThrowPrivacyPolicyRule()];
protected override readonly createRules = [new AlwaysThrowPrivacyPolicyRule()];
protected override readonly readRules = [new AlwaysThrowPrivacyPolicyRule()];
protected override readonly updateRules = [new AlwaysThrowPrivacyPolicyRule()];
protected override readonly deleteRules = [new AlwaysThrowPrivacyPolicyRule()];
}
class DryRunThrowAllPolicy extends ThrowAllPolicy {
@@ -181,7 +185,7 @@ class DryRunThrowAllPolicy extends ThrowAllPolicy {
_error: EntityNotAuthorizedError<BlahFields, string, ViewerContext, BlahEntity>
): void {}
protected getPrivacyPolicyEvaluator(): EntityPrivacyPolicyEvaluator<
protected override getPrivacyPolicyEvaluator(): EntityPrivacyPolicyEvaluator<
BlahFields,
string,
ViewerContext,
@@ -200,7 +204,7 @@ class LoggingEnforceThrowAllPolicy extends ThrowAllPolicy {
_error: EntityNotAuthorizedError<BlahFields, string, ViewerContext, BlahEntity>
): void {}
protected getPrivacyPolicyEvaluator(): EntityPrivacyPolicyEvaluator<
protected override getPrivacyPolicyEvaluator(): EntityPrivacyPolicyEvaluator<
BlahFields,
string,
ViewerContext,
@@ -214,10 +218,10 @@ class LoggingEnforceThrowAllPolicy extends ThrowAllPolicy {
}
class EmptyPolicy extends EntityPrivacyPolicy<BlahFields, string, ViewerContext, BlahEntity> {
protected readonly createRules = [];
protected readonly readRules = [];
protected readonly updateRules = [];
protected readonly deleteRules = [];
protected override readonly createRules = [];
protected override readonly readRules = [];
protected override readonly updateRules = [];
protected override readonly deleteRules = [];
}
const blahEntityCompanionDefinition = new EntityCompanionDefinition({
@@ -237,143 +241,276 @@ const blahEntityCompanionDefinition = new EntityCompanionDefinition({
});
describe(EntityPrivacyPolicy, () => {
it('throws EntityNotAuthorizedError when deny', async () => {
const viewerContext = instance(mock(ViewerContext));
const queryContext = instance(mock(EntityQueryContext));
const entity = new BlahEntity(viewerContext, { id: '1' });
const policy = new AlwaysDenyPolicy();
await expect(
policy.authorizeCreateAsync(viewerContext, queryContext, entity)
).rejects.toBeInstanceOf(EntityNotAuthorizedError);
describe(EntityPrivacyPolicyEvaluationMode.ENFORCE.toString(), () => {
it('throws EntityNotAuthorizedError when deny', async () => {
const viewerContext = instance(mock(ViewerContext));
const queryContext = instance(mock(EntityQueryContext));
const metricsAdapterMock = mock<IEntityMetricsAdapter>();
const metricsAdapter = instance(metricsAdapterMock);
const entity = new BlahEntity(viewerContext, { id: '1' });
const policy = new AlwaysDenyPolicy();
await expect(
policy.authorizeCreateAsync(viewerContext, queryContext, entity, metricsAdapter)
).rejects.toBeInstanceOf(EntityNotAuthorizedError);
verify(
metricsAdapterMock.logAuthorizationEvent(
objectContaining({
entityClassName: entity.constructor.name,
action: EntityAuthorizationAction.CREATE,
evaluationResult: EntityMetricsAuthorizationResult.DENY,
privacyPolicyEvaluationMode: EntityPrivacyPolicyEvaluationMode.ENFORCE,
})
)
).once();
});
it('returns entity when allowed', async () => {
const viewerContext = instance(mock(ViewerContext));
const queryContext = instance(mock(EntityQueryContext));
const metricsAdapterMock = mock<IEntityMetricsAdapter>();
const metricsAdapter = instance(metricsAdapterMock);
const entity = new BlahEntity(viewerContext, { id: '1' });
const policy = new AlwaysAllowPolicy();
const approvedEntity = await policy.authorizeCreateAsync(
viewerContext,
queryContext,
entity,
metricsAdapter
);
expect(approvedEntity).toEqual(entity);
verify(
metricsAdapterMock.logAuthorizationEvent(
objectContaining({
entityClassName: entity.constructor.name,
action: EntityAuthorizationAction.CREATE,
evaluationResult: EntityMetricsAuthorizationResult.ALLOW,
privacyPolicyEvaluationMode: EntityPrivacyPolicyEvaluationMode.ENFORCE,
})
)
).once();
});
it('throws EntityNotAuthorizedError when all skipped', async () => {
const viewerContext = instance(mock(ViewerContext));
const queryContext = instance(mock(EntityQueryContext));
const metricsAdapterMock = mock<IEntityMetricsAdapter>();
const metricsAdapter = instance(metricsAdapterMock);
const entity = new BlahEntity(viewerContext, { id: '1' });
const policy = new SkipAllPolicy();
await expect(
policy.authorizeCreateAsync(viewerContext, queryContext, entity, metricsAdapter)
).rejects.toBeInstanceOf(EntityNotAuthorizedError);
verify(
metricsAdapterMock.logAuthorizationEvent(
objectContaining({
entityClassName: entity.constructor.name,
action: EntityAuthorizationAction.CREATE,
evaluationResult: EntityMetricsAuthorizationResult.DENY,
privacyPolicyEvaluationMode: EntityPrivacyPolicyEvaluationMode.ENFORCE,
})
)
).once();
});
it('throws EntityNotAuthorizedError when empty policy', async () => {
const viewerContext = instance(mock(ViewerContext));
const queryContext = instance(mock(EntityQueryContext));
const metricsAdapterMock = mock<IEntityMetricsAdapter>();
const metricsAdapter = instance(metricsAdapterMock);
const entity = new BlahEntity(viewerContext, { id: '1' });
const policy = new EmptyPolicy();
await expect(
policy.authorizeCreateAsync(viewerContext, queryContext, entity, metricsAdapter)
).rejects.toBeInstanceOf(EntityNotAuthorizedError);
verify(
metricsAdapterMock.logAuthorizationEvent(
objectContaining({
entityClassName: entity.constructor.name,
action: EntityAuthorizationAction.CREATE,
evaluationResult: EntityMetricsAuthorizationResult.DENY,
privacyPolicyEvaluationMode: EntityPrivacyPolicyEvaluationMode.ENFORCE,
})
)
).once();
});
it('throws when rule throws', async () => {
const viewerContext = instance(mock(ViewerContext));
const queryContext = instance(mock(EntityQueryContext));
const metricsAdapterMock = mock<IEntityMetricsAdapter>();
const metricsAdapter = instance(metricsAdapterMock);
const entity = new BlahEntity(viewerContext, { id: '1' });
const policy = new ThrowAllPolicy();
await expect(
policy.authorizeCreateAsync(viewerContext, queryContext, entity, metricsAdapter)
).rejects.toThrowError('WooHoo!');
verify(metricsAdapterMock.logAuthorizationEvent(anything())).never();
});
});
it('returns entity when allowed', async () => {
const viewerContext = instance(mock(ViewerContext));
const queryContext = instance(mock(EntityQueryContext));
const entity = new BlahEntity(viewerContext, { id: '1' });
const policy = new AlwaysAllowPolicy();
const approvedEntity = await policy.authorizeCreateAsync(viewerContext, queryContext, entity);
expect(approvedEntity).toEqual(entity);
});
it('throws EntityNotAuthorizedError when all skipped', async () => {
const viewerContext = instance(mock(ViewerContext));
const queryContext = instance(mock(EntityQueryContext));
const entity = new BlahEntity(viewerContext, { id: '1' });
const policy = new SkipAllPolicy();
await expect(
policy.authorizeCreateAsync(viewerContext, queryContext, entity)
).rejects.toBeInstanceOf(EntityNotAuthorizedError);
});
it('throws EntityNotAuthorizedError when empty policy', async () => {
const viewerContext = instance(mock(ViewerContext));
const queryContext = instance(mock(EntityQueryContext));
const entity = new BlahEntity(viewerContext, { id: '1' });
const policy = new EmptyPolicy();
await expect(
policy.authorizeCreateAsync(viewerContext, queryContext, entity)
).rejects.toBeInstanceOf(EntityNotAuthorizedError);
});
it('throws when rule throws', async () => {
const viewerContext = instance(mock(ViewerContext));
const queryContext = instance(mock(EntityQueryContext));
const entity = new BlahEntity(viewerContext, { id: '1' });
const policy = new ThrowAllPolicy();
await expect(
policy.authorizeCreateAsync(viewerContext, queryContext, entity)
).rejects.toThrowError('WooHoo!');
});
describe('dry run', () => {
describe(EntityPrivacyPolicyEvaluationMode.DRY_RUN.toString(), () => {
it('returns entity when denied but calls denialHandler', async () => {
const viewerContext = instance(mock(ViewerContext));
const queryContext = instance(mock(EntityQueryContext));
const metricsAdapterMock = mock<IEntityMetricsAdapter>();
const metricsAdapter = instance(metricsAdapterMock);
const entity = new BlahEntity(viewerContext, { id: '1' });
const policy = new DryRunAlwaysDenyPolicy();
const policySpy = spy(policy);
const approvedEntity = await policy.authorizeCreateAsync(viewerContext, queryContext, entity);
const approvedEntity = await policy.authorizeCreateAsync(
viewerContext,
queryContext,
entity,
metricsAdapter
);
expect(approvedEntity).toEqual(entity);
verify(policySpy.denyHandler(anyOfClass(EntityNotAuthorizedError))).once();
verify(
metricsAdapterMock.logAuthorizationEvent(
objectContaining({
entityClassName: entity.constructor.name,
action: EntityAuthorizationAction.CREATE,
evaluationResult: EntityMetricsAuthorizationResult.DENY,
privacyPolicyEvaluationMode: EntityPrivacyPolicyEvaluationMode.DRY_RUN,
})
)
).once();
});
it('does not log when not denied', async () => {
const viewerContext = instance(mock(ViewerContext));
const queryContext = instance(mock(EntityQueryContext));
const metricsAdapterMock = mock<IEntityMetricsAdapter>();
const metricsAdapter = instance(metricsAdapterMock);
const entity = new BlahEntity(viewerContext, { id: '1' });
const policy = new DryRunAlwaysAllowPolicy();
const policySpy = spy(policy);
const approvedEntity = await policy.authorizeCreateAsync(viewerContext, queryContext, entity);
const approvedEntity = await policy.authorizeCreateAsync(
viewerContext,
queryContext,
entity,
metricsAdapter
);
expect(approvedEntity).toEqual(entity);
verify(policySpy.denyHandler(anyOfClass(EntityNotAuthorizedError))).never();
verify(
metricsAdapterMock.logAuthorizationEvent(
objectContaining({
entityClassName: entity.constructor.name,
action: EntityAuthorizationAction.CREATE,
evaluationResult: EntityMetricsAuthorizationResult.ALLOW,
privacyPolicyEvaluationMode: EntityPrivacyPolicyEvaluationMode.DRY_RUN,
})
)
).once();
});
it('passes through other errors', async () => {
const viewerContext = instance(mock(ViewerContext));
const queryContext = instance(mock(EntityQueryContext));
const metricsAdapterMock = mock<IEntityMetricsAdapter>();
const metricsAdapter = instance(metricsAdapterMock);
const entity = new BlahEntity(viewerContext, { id: '1' });
const policy = new DryRunThrowAllPolicy();
const policySpy = spy(policy);
await expect(
policy.authorizeCreateAsync(viewerContext, queryContext, entity)
policy.authorizeCreateAsync(viewerContext, queryContext, entity, metricsAdapter)
).rejects.toThrowError('WooHoo!');
verify(policySpy.denyHandler(anyOfClass(EntityNotAuthorizedError))).never();
verify(metricsAdapterMock.logAuthorizationEvent(anything())).never();
});
});
describe('logging enforce', () => {
describe(EntityPrivacyPolicyEvaluationMode.ENFORCE_AND_LOG.toString(), () => {
it('denies when denied but calls denialHandler', async () => {
const viewerContext = instance(mock(ViewerContext));
const queryContext = instance(mock(EntityQueryContext));
const metricsAdapterMock = mock<IEntityMetricsAdapter>();
const metricsAdapter = instance(metricsAdapterMock);
const entity = new BlahEntity(viewerContext, { id: '1' });
const policy = new LoggingEnforceAlwaysDenyPolicy();
const policySpy = spy(policy);
await expect(
policy.authorizeCreateAsync(viewerContext, queryContext, entity)
policy.authorizeCreateAsync(viewerContext, queryContext, entity, metricsAdapter)
).rejects.toBeInstanceOf(EntityNotAuthorizedError);
verify(policySpy.denyHandler(anyOfClass(EntityNotAuthorizedError))).once();
verify(
metricsAdapterMock.logAuthorizationEvent(
objectContaining({
entityClassName: entity.constructor.name,
action: EntityAuthorizationAction.CREATE,
evaluationResult: EntityMetricsAuthorizationResult.DENY,
privacyPolicyEvaluationMode: EntityPrivacyPolicyEvaluationMode.ENFORCE_AND_LOG,
})
)
).once();
});
it('does not log when not denied', async () => {
const viewerContext = instance(mock(ViewerContext));
const queryContext = instance(mock(EntityQueryContext));
const metricsAdapterMock = mock<IEntityMetricsAdapter>();
const metricsAdapter = instance(metricsAdapterMock);
const entity = new BlahEntity(viewerContext, { id: '1' });
const policy = new LoggingEnforceAlwaysAllowPolicy();
const policySpy = spy(policy);
const approvedEntity = await policy.authorizeCreateAsync(viewerContext, queryContext, entity);
const approvedEntity = await policy.authorizeCreateAsync(
viewerContext,
queryContext,
entity,
metricsAdapter
);
expect(approvedEntity).toEqual(entity);
verify(policySpy.denyHandler(anyOfClass(EntityNotAuthorizedError))).never();
verify(
metricsAdapterMock.logAuthorizationEvent(
objectContaining({
entityClassName: entity.constructor.name,
action: EntityAuthorizationAction.CREATE,
evaluationResult: EntityMetricsAuthorizationResult.ALLOW,
privacyPolicyEvaluationMode: EntityPrivacyPolicyEvaluationMode.ENFORCE_AND_LOG,
})
)
).once();
});
it('passes through other errors', async () => {
const viewerContext = instance(mock(ViewerContext));
const queryContext = instance(mock(EntityQueryContext));
const metricsAdapterMock = mock<IEntityMetricsAdapter>();
const metricsAdapter = instance(metricsAdapterMock);
const entity = new BlahEntity(viewerContext, { id: '1' });
const policy = new LoggingEnforceThrowAllPolicy();
const policySpy = spy(policy);
await expect(
policy.authorizeCreateAsync(viewerContext, queryContext, entity)
policy.authorizeCreateAsync(viewerContext, queryContext, entity, metricsAdapter)
).rejects.toThrowError('WooHoo!');
verify(policySpy.denyHandler(anyOfClass(EntityNotAuthorizedError))).never();
verify(metricsAdapterMock.logAuthorizationEvent(anything())).never();
});
});
});
@@ -0,0 +1,82 @@
import invariant from 'invariant';
import { EntityQueryContext } from '../EntityQueryContext';
import ViewerContext from '../ViewerContext';
import { createUnitTestEntityCompanionProvider } from '../utils/testing/createUnitTestEntityCompanionProvider';
describe(EntityQueryContext, () => {
describe('callbacks', () => {
it('calls all callbacks, and calls invalidation first', async () => {
const companionProvider = createUnitTestEntityCompanionProvider();
const viewerContext = new ViewerContext(companionProvider);
const preCommitFirstCallback = jest.fn(async (): Promise<void> => {});
const preCommitSecondCallback = jest.fn(async (): Promise<void> => {});
const postCommitInvalidationCallback = jest.fn(async (): Promise<void> => {
invariant(
preCommitFirstCallback.mock.calls.length === 1,
'preCommit should be called before postCommitInvalidation'
);
invariant(
preCommitSecondCallback.mock.calls.length === 1,
'preCommit should be called before postCommitInvalidation'
);
});
const postCommitCallback = jest.fn(async (): Promise<void> => {
invariant(
preCommitFirstCallback.mock.calls.length === 1,
'preCommit should be called before postCommit'
);
invariant(
preCommitSecondCallback.mock.calls.length === 1,
'preCommit should be called before postCommit'
);
invariant(
postCommitInvalidationCallback.mock.calls.length === 1,
'postCommitInvalidation should be called before postCommit'
);
});
await viewerContext.runInTransactionForDatabaseAdaptorFlavorAsync(
'postgres',
async (queryContext) => {
queryContext.appendPostCommitCallback(postCommitCallback);
queryContext.appendPostCommitInvalidationCallback(postCommitInvalidationCallback);
queryContext.appendPreCommitCallback(preCommitSecondCallback, 2);
queryContext.appendPreCommitCallback(preCommitFirstCallback, 1);
}
);
expect(preCommitFirstCallback).toHaveBeenCalledTimes(1);
expect(preCommitSecondCallback).toHaveBeenCalledTimes(1);
expect(postCommitCallback).toHaveBeenCalledTimes(1);
expect(postCommitInvalidationCallback).toHaveBeenCalledTimes(1);
});
it('prevents transaction from finishing when precommit throws (post commit callbacks are not called)', async () => {
const companionProvider = createUnitTestEntityCompanionProvider();
const viewerContext = new ViewerContext(companionProvider);
const preCommitCallback = jest.fn(async (): Promise<void> => {
throw new Error('wat');
});
const postCommitInvalidationCallback = jest.fn(async (): Promise<void> => {});
const postCommitCallback = jest.fn(async (): Promise<void> => {});
await expect(
viewerContext.runInTransactionForDatabaseAdaptorFlavorAsync(
'postgres',
async (queryContext) => {
queryContext.appendPostCommitCallback(postCommitCallback);
queryContext.appendPostCommitInvalidationCallback(postCommitInvalidationCallback);
queryContext.appendPreCommitCallback(preCommitCallback, 0);
}
)
).rejects.toThrowError('wat');
expect(preCommitCallback).toHaveBeenCalledTimes(1);
expect(postCommitCallback).toHaveBeenCalledTimes(0);
expect(postCommitInvalidationCallback).toHaveBeenCalledTimes(0);
});
});
});
@@ -35,9 +35,8 @@ describe(EntitySecondaryCacheLoader, () => {
const createdEntity = await SimpleTestEntity.creator(vc1).enforceCreateAsync();
const loadParams = { id: createdEntity.getID() };
const secondaryEntityCacheMock = mock<
ISecondaryEntityCache<SimpleTestFields, TestLoadParams>
>();
const secondaryEntityCacheMock =
mock<ISecondaryEntityCache<SimpleTestFields, TestLoadParams>>();
when(
secondaryEntityCacheMock.loadManyThroughAsync(deepEqual([loadParams]), anything())
).thenResolve(new Map());
@@ -61,9 +60,8 @@ describe(EntitySecondaryCacheLoader, () => {
const createdEntity = await SimpleTestEntity.creator(vc1).enforceCreateAsync();
const loadParams = { id: createdEntity.getID() };
const secondaryEntityCacheMock = mock<
ISecondaryEntityCache<SimpleTestFields, TestLoadParams>
>();
const secondaryEntityCacheMock =
mock<ISecondaryEntityCache<SimpleTestFields, TestLoadParams>>();
when(
secondaryEntityCacheMock.loadManyThroughAsync(deepEqual([loadParams]), anything())
).thenResolve(new Map([[loadParams, createdEntity.getAllFields()]]));
@@ -80,6 +78,7 @@ describe(EntitySecondaryCacheLoader, () => {
spiedPrivacyPolicy.authorizeReadAsync(
vc1,
anyOfClass(EntityNonTransactionalQueryContext),
anything(),
anything()
)
).once();
@@ -93,9 +92,8 @@ describe(EntitySecondaryCacheLoader, () => {
const createdEntity = await SimpleTestEntity.creator(vc1).enforceCreateAsync();
const loadParams = { id: createdEntity.getID() };
const secondaryEntityCacheMock = mock<
ISecondaryEntityCache<SimpleTestFields, TestLoadParams>
>();
const secondaryEntityCacheMock =
mock<ISecondaryEntityCache<SimpleTestFields, TestLoadParams>>();
const secondaryEntityCache = instance(secondaryEntityCacheMock);
const loader = SimpleTestEntity.loader(vc1);
const secondaryCacheLoader = new TestSecondaryRedisCacheLoader(secondaryEntityCache, loader);
@@ -1,7 +1,8 @@
import Entity from '../Entity';
import { EntityCompanionDefinition } from '../EntityCompanionProvider';
import EntityConfiguration from '../EntityConfiguration';
import { UUIDField, EntityEdgeDeletionBehavior } from '../EntityFields';
import { EntityEdgeDeletionBehavior } from '../EntityFieldDefinition';
import { UUIDField } from '../EntityFields';
import EntityPrivacyPolicy from '../EntityPrivacyPolicy';
import { CacheStatus } from '../internal/ReadThroughEntityCache';
import AlwaysAllowPrivacyPolicyRule from '../rules/AlwaysAllowPrivacyPolicyRule';
@@ -21,16 +22,16 @@ class CategoryPrivacyPolicy extends EntityPrivacyPolicy<
any,
any
> {
protected readonly readRules = [
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<CategoryFields, string, TestViewerContext, any, any>(),
];
protected readonly createRules = [
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<CategoryFields, string, TestViewerContext, any, any>(),
];
protected readonly updateRules = [
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<CategoryFields, string, TestViewerContext, any, any>(),
];
protected readonly deleteRules = [
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<CategoryFields, string, TestViewerContext, any, any>(),
];
}
Loaded 100 of 125 files, more files were not shown because too many files have changed in this diff. Show more