Compare commits

..
Author SHA1 Message Date
Will Schurman 7f102ef29d feat: add EntityField transformation 2022-07-01 13:00:43 -04:00
James Ide 0dbf5e98bb [tests] Run tests on Node 18 instead of Node 12 (#174)
Node 16 is LTS now and 12 is unsupported. 18 is also now released and we will one day run on it so this makes sure one of our major dependencies works on Node 18.

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

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

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

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

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

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

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

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

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

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

No files matched your search

+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
ref: ${{ github.event.pull_request.head.sha }}
- uses: actions/setup-node@v1
with:
node-version: 12.x
node-version: 18.x
- name: Cache Node.js modules
uses: actions/cache@v1
with:
+45
View File
@@ -3,6 +3,51 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [0.25.3](https://github.com/expo/entity/compare/v0.25.2...v0.25.3) (2022-03-12)
### Bug Fixes
* lock lru-cache to v6.0.0 ([#170](https://github.com/expo/entity/issues/170)) ([293868b](https://github.com/expo/entity/commit/293868b1a73df9f749216af68b1ae94ac22ac2b9))
## [0.25.2](https://github.com/expo/entity/compare/v0.25.1...v0.25.2) (2022-03-12)
### Bug Fixes
* lru-cache yarn workspace version resolution ([#168](https://github.com/expo/entity/issues/168)) ([e76ca8c](https://github.com/expo/entity/commit/e76ca8c2f72da84aa372a64ef1898ade220ed0a8))
## [0.25.1](https://github.com/expo/entity/compare/v0.25.0...v0.25.1) (2022-03-10)
### Bug Fixes
* add composed cache to index ([#166](https://github.com/expo/entity/issues/166)) ([5c82edc](https://github.com/expo/entity/commit/5c82edcd9f26854878d29950a05852e117bbd723))
# [0.25.0](https://github.com/expo/entity/compare/v0.24.0...v0.25.0) (2022-03-10)
### Features
* composed cache adapter, composed secondary cache, local memory secondary cache ([#164](https://github.com/expo/entity/issues/164)) ([6b6e70e](https://github.com/expo/entity/commit/6b6e70e75f7e42a4dce13edeb406a4d88d9264f0))
* privacy policy evaluation context ([#162](https://github.com/expo/entity/issues/162)) ([3a81a4c](https://github.com/expo/entity/commit/3a81a4c059e2e995eb800e35e76b3d432c22b23f))
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
+1 -1
View File
@@ -4,5 +4,5 @@
],
"npmClient": "yarn",
"useWorkspaces": true,
"version": "0.24.0"
"version": "0.25.3"
}
+7 -5
View File
@@ -18,11 +18,10 @@
"devDependencies": {
"@expo/entity": "file:packages/entity",
"@expo/results": "^1.0.0",
"@types/lru-cache": "^5.1.1",
"@types/invariant": "^2.2.33",
"@types/ioredis": "^4.26.4",
"@types/jest": "^26.0.10",
"@types/jsbn": "^1.2.29",
"@types/lru-cache": "^5.1.1",
"@types/node": "^14.6.0",
"@types/uuid": "^8.3.0",
"@typescript-eslint/eslint-plugin": "^4.14.0",
@@ -31,15 +30,17 @@
"eslint": "^7.7.0",
"eslint-config-universe": "^7.0.0",
"eslint-plugin-tsdoc": "^0.2.11",
"ioredis": "^5.0.4",
"jest": "^26.6.3",
"lerna": "^4.0.0",
"lru-cache": "^6.0.0",
"nullthrows": "^1.1.1",
"pg": "^8.6.0",
"prettier": "^2.3.2",
"ts-jest": "~26.4.4",
"ts-mockito": "^2.6.1",
"typedoc": "^0.21.9",
"typescript": "^4.4.2",
"typescript": "^4.6.3",
"uuid": "^8.3.0"
},
"dependencies": {
@@ -47,7 +48,8 @@
"@expo/entity-cache-adapter-local-memory": "file:packages/entity-cache-adapter-local-memory",
"@expo/entity-cache-adapter-redis": "file:packages/entity-cache-adapter-redis",
"@expo/entity-database-adapter-knex": "file:packages/entity-database-adapter-knex",
"@expo/entity-secondary-cache-redis": "file:packages/entity-secondary-cache-redis",
"@expo/entity-ip-address-field": "file:packages/entity-ip-address-field"
"@expo/entity-ip-address-field": "file:packages/entity-ip-address-field",
"@expo/entity-secondary-cache-local-memory": "file:packages/entity-secondary-cache-local-memory",
"@expo/entity-secondary-cache-redis": "file:packages/entity-secondary-cache-redis"
}
}
@@ -3,6 +3,47 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [0.25.3](https://github.com/expo/entity/compare/v0.25.2...v0.25.3) (2022-03-12)
### Bug Fixes
* lock lru-cache to v6.0.0 ([#170](https://github.com/expo/entity/issues/170)) ([293868b](https://github.com/expo/entity/commit/293868b1a73df9f749216af68b1ae94ac22ac2b9))
## [0.25.2](https://github.com/expo/entity/compare/v0.25.1...v0.25.2) (2022-03-12)
### Bug Fixes
* lru-cache yarn workspace version resolution ([#168](https://github.com/expo/entity/issues/168)) ([e76ca8c](https://github.com/expo/entity/commit/e76ca8c2f72da84aa372a64ef1898ade220ed0a8))
## [0.25.1](https://github.com/expo/entity/compare/v0.25.0...v0.25.1) (2022-03-10)
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
# [0.25.0](https://github.com/expo/entity/compare/v0.24.0...v0.25.0) (2022-03-10)
### Features
* composed cache adapter, composed secondary cache, local memory secondary cache ([#164](https://github.com/expo/entity/issues/164)) ([6b6e70e](https://github.com/expo/entity/commit/6b6e70e75f7e42a4dce13edeb406a4d88d9264f0))
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
@@ -3,21 +3,26 @@
Cross-request [LRU](https://github.com/isaacs/node-lru-cache) cache adapter for `@expo/entity`. Use
this cache with caution - it is nonstandard. The cache is shared between requests in the node process.
Note: This uses version 6.0.0 of `node-lru-cache` since it the most tuned version for our use case (low TTL + LRU). Upgrading
to 7.x will cause high memory usage for the entity cache adapter use case since it allocates fixed-size data structures up front to tune for the non-TTL use case: https://github.com/isaacs/node-lru-cache/issues/208.
[Documentation](https://expo.github.io/entity/modules/_expo_cache_adapter_local_memory.html)
## Why NOT use this cache
Because this is an in-memory cache, cross-box invalidation is not possible. Do not use this cache
Because this is an in-memory cache, cross-machine invalidation is not possible. Do not use this cache
if you have the following use cases:
- The objects stored have high mutability
- The objects stored are mutable
- Reading a stale object from the cache is not acceptable in your application
- Cross-box invalidation is not possible
- Cross-machine invalidation is not possible
## Typical use cases
If your application sees many requests fetching the same objects, you can save a trip to your cache
cluster and backing datastore by using this in-memory cache. Here are some good use cases:
- The objects stored are mostly immutable
- The objects stored are mostly immutable, and reading a stale object for a short TTL is acceptable
- You have a low TTL setting in your cache
## Usage
@@ -1,6 +1,6 @@
{
"name": "@expo/entity-cache-adapter-local-memory",
"version": "0.24.0",
"version": "0.25.3",
"description": "Cross-request local memory cache adapter for @expo/entity",
"files": [
"build",
@@ -30,9 +30,9 @@
"@expo/entity": "*"
},
"dependencies": {
"lru-cache": "^7.3.1"
"lru-cache": "^6.0.0"
},
"devDependencies": {
"@expo/entity": "^0.24.0"
"@expo/entity": "^0.25.3"
}
}
@@ -7,76 +7,6 @@ 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>) {}
@@ -86,13 +16,11 @@ export default class GenericLocalMemoryCacher<TFields> implements IEntityGeneric
const DEFAULT_LRU_CACHE_MAX_AGE_SECONDS = 10;
const DEFAULT_LRU_CACHE_SIZE = 10000;
const maxAgeSeconds = options.ttlSeconds ?? DEFAULT_LRU_CACHE_MAX_AGE_SECONDS;
const lruCacheOptions: LRUCacheOptionsV7<string, TFields> = {
return new LRUCache<string, LocalMemoryCacheValue<TFields>>({
max: options.maxSize ?? DEFAULT_LRU_CACHE_SIZE,
sizeCalculation: (value: LocalMemoryCacheValue<TFields>) =>
value === DOES_NOT_EXIST_LOCAL_MEMORY_CACHE ? 0 : 1,
ttl: maxAgeSeconds * 1000, // convert to ms
};
return new LRUCache<string, LocalMemoryCacheValue<TFields>>(lruCacheOptions as any);
length: (value) => (value === DOES_NOT_EXIST_LOCAL_MEMORY_CACHE ? 0 : 1),
maxAge: maxAgeSeconds * 1000, // convert to ms
});
}
static createNoOpCache<TFields>(): LocalMemoryCache<TFields> {
@@ -143,4 +71,12 @@ export default class GenericLocalMemoryCacher<TFields> implements IEntityGeneric
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);
}
}
@@ -1,28 +1,80 @@
import { EntityConfiguration, PartsCacheAdapter, Parts } from '@expo/entity';
import SimplePartsCacher from '@expo/entity/build/SimplePartsCacher';
import { EntityCacheAdapter, CacheLoadResult, EntityConfiguration, mapKeys } from '@expo/entity';
import invariant from 'invariant';
import GenericLocalMemoryCacher, { LocalMemoryCache } from './GenericLocalMemoryCacher';
export default class LocalMemoryCacheAdapter<TFields> extends PartsCacheAdapter<TFields> {
export default class LocalMemoryCacheAdapter<TFields> extends EntityCacheAdapter<TFields> {
private readonly genericLocalMemoryCacher: GenericLocalMemoryCacher<TFields>;
constructor(
entityConfiguration: EntityConfiguration<TFields>,
localMemoryCache: LocalMemoryCache<TFields>
) {
super(
entityConfiguration,
new SimplePartsCacher(new GenericLocalMemoryCacher(localMemoryCache))
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))
);
}
getParts<N extends keyof TFields>(fieldName: N, fieldValue: NonNullable<TFields[N]>): Parts {
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}`);
return [
invariant(columnName, `database field mapping missing for ${String(fieldName)}`);
const parts = [
this.entityConfiguration.tableName,
`${this.entityConfiguration.cacheKeyVersion}`,
columnName,
String(fieldValue),
];
return this.genericLocalMemoryCacher.makeCacheKey(parts);
}
}
@@ -8,7 +8,7 @@ import {
import GenericLocalMemoryCacher, { LocalMemoryCache } from './GenericLocalMemoryCacher';
import LocalMemoryCacheAdapter from './LocalMemoryCacheAdapter';
export class LocalMemoryCacheAdapterProvider implements IEntityCacheAdapterProvider {
export default class LocalMemoryCacheAdapterProvider implements IEntityCacheAdapterProvider {
// local memory cache adapters should be shared/reused across requests
static localMemoryCacheAdapterMap = new Map<string, LocalMemoryCacheAdapter<any>>();
@@ -0,0 +1,28 @@
import GenericLocalMemoryCacher from '../GenericLocalMemoryCacher';
describe(GenericLocalMemoryCacher, () => {
describe(GenericLocalMemoryCacher.createLRUCache, () => {
it('creates a cache with default options', () => {
const cache = GenericLocalMemoryCacher.createLRUCache();
expect(cache.max).toBe(10000);
expect(cache.maxAge).toBe(10000);
});
it('respects specified options', () => {
const cache = GenericLocalMemoryCacher.createLRUCache({
ttlSeconds: 3,
maxSize: 10,
});
expect(cache.max).toBe(10);
expect(cache.maxAge).toBe(3000);
});
});
describe(GenericLocalMemoryCacher.createNoOpCache, () => {
it('creates a no-op cache', () => {
const cache = GenericLocalMemoryCacher.createNoOpCache<{ hello: 'world' }>();
cache.set('a', { hello: 'world' });
expect(cache.get('a')).toBeUndefined();
});
});
});
@@ -3,12 +3,12 @@ import { v4 as uuidv4 } from 'uuid';
import GenericLocalMemoryCacher from '../GenericLocalMemoryCacher';
import LocalMemoryCacheAdapter from '../LocalMemoryCacheAdapter';
import { LocalMemoryCacheAdapterProvider } from '../LocalMemoryCacheAdapterProvider';
import LocalMemoryCacheAdapterProvider from '../LocalMemoryCacheAdapterProvider';
import LocalMemoryTestEntity from '../testfixtures/LocalMemoryTestEntity';
import {
createLocalMemoryIntegrationTestEntityCompanionProvider,
createLocalMemoryTestEntityCompanionProvider,
createNoopLocalMemoryIntegrationTestEntityCompanionProvider,
} from '../testfixtures/createLocalMemoryIntegrationTestEntityCompanionProvider';
} from '../testfixtures/createLocalMemoryTestEntityCompanionProvider';
class TestViewerContext extends ViewerContext {}
@@ -18,9 +18,7 @@ describe(LocalMemoryCacheAdapter, () => {
});
it('has correct caching behavior', async () => {
const viewerContext = new TestViewerContext(
createLocalMemoryIntegrationTestEntityCompanionProvider()
);
const viewerContext = new TestViewerContext(createLocalMemoryTestEntityCompanionProvider());
const cacheAdapter = viewerContext.entityCompanionProvider.getCompanionForEntity(
LocalMemoryTestEntity,
LocalMemoryTestEntity.getCompanionDefinition()
@@ -41,7 +39,7 @@ describe(LocalMemoryCacheAdapter, () => {
const entitySpecificGenericCacher =
LocalMemoryCacheAdapterProvider.localMemoryCacheAdapterMap.get(
LocalMemoryTestEntity.getCompanionDefinition().entityConfiguration.tableName
)!['partsCacher']['cacher'];
)!['genericLocalMemoryCacher'];
const cachedResult = await entitySpecificGenericCacher.loadManyAsync([
cacheKeyMaker('id', entity1.getID()),
]);
@@ -90,9 +88,7 @@ describe(LocalMemoryCacheAdapter, () => {
GenericLocalMemoryCacher.prototype as unknown as any,
'loadManyAsync'
);
const viewerContext = new TestViewerContext(
createLocalMemoryIntegrationTestEntityCompanionProvider()
);
const viewerContext = new TestViewerContext(createLocalMemoryTestEntityCompanionProvider());
const date = new Date();
const entity1Created = await LocalMemoryTestEntity.creator(viewerContext)
@@ -106,9 +102,7 @@ describe(LocalMemoryCacheAdapter, () => {
.loadByIDAsync(entity1Created.getID());
// load entity with a different request
const viewerContext2 = new TestViewerContext(
createLocalMemoryIntegrationTestEntityCompanionProvider()
);
const viewerContext2 = new TestViewerContext(createLocalMemoryTestEntityCompanionProvider());
const entity1WithVc2 = await LocalMemoryTestEntity.loader(viewerContext2)
.enforcing()
.loadByIDAsync(entity1Created.getID());
@@ -153,7 +147,7 @@ describe(LocalMemoryCacheAdapter, () => {
const entitySpecificGenericCacher =
LocalMemoryCacheAdapterProvider.localMemoryCacheAdapterMap.get(
LocalMemoryTestEntity.getCompanionDefinition().entityConfiguration.tableName
)!['partsCacher']['cacher'];
)!['genericLocalMemoryCacher'];
const cachedResult = await entitySpecificGenericCacher.loadManyAsync([
cacheKeyMaker('id', entity1.getID()),
]);
@@ -24,10 +24,7 @@ describe(LocalMemoryCacheAdapter, () => {
it('returns appropriate cache results', async () => {
const cacheAdapter = new LocalMemoryCacheAdapter(
entityConfiguration,
GenericLocalMemoryCacher.createLRUCache({
maxSize: Number.MAX_SAFE_INTEGER,
ttlSeconds: Number.MAX_SAFE_INTEGER,
})
GenericLocalMemoryCacher.createLRUCache()
);
const cacheHits = new Map<string, Readonly<BlahFields>>([['test-id-1', { id: 'test-id-1' }]]);
@@ -52,10 +49,7 @@ describe(LocalMemoryCacheAdapter, () => {
it('returns empty map when passed empty array of fieldValues', async () => {
const cacheAdapter = new LocalMemoryCacheAdapter(
entityConfiguration,
GenericLocalMemoryCacher.createLRUCache({
maxSize: Number.MAX_SAFE_INTEGER,
ttlSeconds: Number.MAX_SAFE_INTEGER,
})
GenericLocalMemoryCacher.createLRUCache()
);
const results = await cacheAdapter.loadManyAsync('id', []);
expect(results).toEqual(new Map());
@@ -64,15 +58,12 @@ describe(LocalMemoryCacheAdapter, () => {
describe('cacheManyAsync', () => {
it('correctly caches all objects', async () => {
const localMemoryCache = GenericLocalMemoryCacher.createLRUCache<BlahFields>({
maxSize: Number.MAX_SAFE_INTEGER,
ttlSeconds: Number.MAX_SAFE_INTEGER,
});
const localMemoryCache = GenericLocalMemoryCacher.createLRUCache<BlahFields>({});
const cacheAdapter = new LocalMemoryCacheAdapter(entityConfiguration, localMemoryCache);
await cacheAdapter.cacheManyAsync('id', new Map([['test-id-1', { id: 'test-id-1' }]]));
const cacheKey = cacheAdapter['partsCacher']['makeCacheKey']('id', 'test-id-1');
const cacheKey = cacheAdapter['makeCacheKey']('id', 'test-id-1');
expect(localMemoryCache.get(cacheKey)).toMatchObject({
id: 'test-id-1',
});
@@ -81,25 +72,19 @@ describe(LocalMemoryCacheAdapter, () => {
describe('cacheDBMissesAsync', () => {
it('correctly caches misses', async () => {
const localMemoryCache = GenericLocalMemoryCacher.createLRUCache<BlahFields>({
maxSize: Number.MAX_SAFE_INTEGER,
ttlSeconds: Number.MAX_SAFE_INTEGER,
});
const localMemoryCache = GenericLocalMemoryCacher.createLRUCache<BlahFields>({});
const cacheAdapter = new LocalMemoryCacheAdapter(entityConfiguration, localMemoryCache);
await cacheAdapter.cacheDBMissesAsync('id', ['test-id-1']);
const cacheKey = cacheAdapter['partsCacher']['makeCacheKey']('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 localMemoryCache = GenericLocalMemoryCacher.createLRUCache<BlahFields>({});
const cacheAdapter = new LocalMemoryCacheAdapter(entityConfiguration, localMemoryCache);
await cacheAdapter.cacheManyAsync('id', new Map([['test-id-1', { id: 'test-id-1' }]]));
@@ -114,10 +99,7 @@ describe(LocalMemoryCacheAdapter, () => {
it('returns when passed empty array of fieldValues', async () => {
const cacheAdapter = new LocalMemoryCacheAdapter(
entityConfiguration,
GenericLocalMemoryCacher.createLRUCache<BlahFields>({
maxSize: Number.MAX_SAFE_INTEGER,
ttlSeconds: Number.MAX_SAFE_INTEGER,
})
GenericLocalMemoryCacher.createLRUCache<BlahFields>({})
);
await cacheAdapter.invalidateManyAsync('id', []);
});
@@ -0,0 +1,10 @@
/* eslint-disable tsdoc/syntax */
/**
* @packageDocumentation
* @module @expo/entity-cache-adapter-local-memory
*/
export { default as GenericLocalMemoryCacher } from './GenericLocalMemoryCacher';
export * from './GenericLocalMemoryCacher';
export { default as LocalMemoryCacheAdapter } from './LocalMemoryCacheAdapter';
export { default as LocalMemoryCacheAdapterProvider } from './LocalMemoryCacheAdapterProvider';
@@ -6,9 +6,9 @@ import {
StubDatabaseAdapterProvider,
} from '@expo/entity';
import { LocalMemoryCacheAdapterProvider } from '../LocalMemoryCacheAdapterProvider';
import LocalMemoryCacheAdapterProvider from '../LocalMemoryCacheAdapterProvider';
export const createLocalMemoryIntegrationTestEntityCompanionProvider = (
export const createLocalMemoryTestEntityCompanionProvider = (
localMemoryOptions: { maxSize?: number; ttlSeconds?: number } = {},
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter()
): EntityCompanionProvider => {
@@ -41,7 +41,7 @@ export const createLocalMemoryIntegrationTestEntityCompanionProvider = (
export const createNoopLocalMemoryIntegrationTestEntityCompanionProvider = (
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter()
): EntityCompanionProvider => {
return createLocalMemoryIntegrationTestEntityCompanionProvider(
return createLocalMemoryTestEntityCompanionProvider(
{ maxSize: 0, ttlSeconds: 0 },
metricsAdapter
);
@@ -3,6 +3,38 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [0.25.3](https://github.com/expo/entity/compare/v0.25.2...v0.25.3) (2022-03-12)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
## [0.25.2](https://github.com/expo/entity/compare/v0.25.1...v0.25.2) (2022-03-12)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
## [0.25.1](https://github.com/expo/entity/compare/v0.25.0...v0.25.1) (2022-03-10)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.25.0](https://github.com/expo/entity/compare/v0.24.0...v0.25.0) (2022-03-10)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
### Chores
@@ -1,6 +1,6 @@
{
"name": "@expo/entity-cache-adapter-redis",
"version": "0.24.0",
"version": "0.25.3",
"description": "Redis cache adapter for @expo/entity",
"files": [
"build",
@@ -27,12 +27,11 @@
"author": "Expo",
"license": "MIT",
"peerDependencies": {
"@expo/entity": "*"
},
"dependencies": {
"ioredis": "^4.27.3"
"@expo/entity": "*",
"ioredis": ">=5"
},
"devDependencies": {
"@expo/entity": "^0.24.0"
"@expo/entity": "^0.25.3",
"ioredis": "^5.0.4"
}
}
@@ -1,7 +1,6 @@
import { EntityConfiguration, PartsCacheAdapter, Parts } from '@expo/entity';
import SimplePartsCacher from '@expo/entity/build/SimplePartsCacher';
import { EntityCacheAdapter, EntityConfiguration, CacheLoadResult, mapKeys } from '@expo/entity';
import invariant from 'invariant';
import { Redis } from 'ioredis';
import type { Redis } from 'ioredis';
import GenericRedisCacher from './GenericRedisCacher';
@@ -42,36 +41,85 @@ export interface RedisCacheAdapterContext {
ttlSecondsNegative: number;
}
export default class RedisCacheAdapter<TFields> extends PartsCacheAdapter<TFields> {
export default class RedisCacheAdapter<TFields> extends EntityCacheAdapter<TFields> {
private readonly genericRedisCacher: GenericRedisCacher<TFields>;
constructor(
entityConfiguration: EntityConfiguration<TFields>,
private readonly context: RedisCacheAdapterContext
) {
super(
entityConfiguration,
new SimplePartsCacher(
new GenericRedisCacher(
{
redisClient: context.redisClient,
ttlSecondsNegative: context.ttlSecondsNegative,
ttlSecondsPositive: context.ttlSecondsPositive,
},
entityConfiguration
),
context.makeKeyFn
)
super(entityConfiguration);
this.genericRedisCacher = new GenericRedisCacher(
{
redisClient: context.redisClient,
ttlSecondsNegative: context.ttlSecondsNegative,
ttlSecondsPositive: context.ttlSecondsPositive,
},
entityConfiguration
);
}
getParts<N extends keyof TFields>(fieldName: N, fieldValue: NonNullable<TFields[N]>): Parts {
public async loadManyAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult<TFields>>> {
const redisCacheKeyToFieldValueMapping = new Map(
fieldValues.map((fieldValue) => [this.makeCacheKey(fieldName, fieldValue), fieldValue])
);
const cacheResults = await this.genericRedisCacher.loadManyAsync(
Array.from(redisCacheKeyToFieldValueMapping.keys())
);
return mapKeys(cacheResults, (redisCacheKey) => {
const fieldValue = redisCacheKeyToFieldValueMapping.get(redisCacheKey);
invariant(
fieldValue !== undefined,
'Unspecified cache key %s returned from generic Redis cacher',
redisCacheKey
);
return fieldValue;
});
}
public async cacheManyAsync<N extends keyof TFields>(
fieldName: N,
objectMap: ReadonlyMap<NonNullable<TFields[N]>, Readonly<TFields>>
): Promise<void> {
await this.genericRedisCacher.cacheManyAsync(
mapKeys(objectMap, (fieldValue) => this.makeCacheKey(fieldName, fieldValue))
);
}
public async cacheDBMissesAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<void> {
await this.genericRedisCacher.cacheDBMissesAsync(
fieldValues.map((fieldValue) => this.makeCacheKey(fieldName, fieldValue))
);
}
public async invalidateManyAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<void> {
await this.genericRedisCacher.invalidateManyAsync(
fieldValues.map((fieldValue) => this.makeCacheKey(fieldName, fieldValue))
);
}
private makeCacheKey<N extends keyof TFields>(
fieldName: N,
fieldValue: NonNullable<TFields[N]>
): string {
const columnName = this.entityConfiguration.entityToDBFieldsKeyMapping.get(fieldName);
invariant(columnName, `database field mapping missing for ${fieldName}`);
return [
invariant(columnName, `database field mapping missing for ${String(fieldName)}`);
return this.context.makeKeyFn(
this.context.cacheKeyPrefix,
this.entityConfiguration.tableName,
`v2.${this.entityConfiguration.cacheKeyVersion}`,
columnName,
String(fieldValue),
];
String(fieldValue)
);
}
}
@@ -1,4 +1,4 @@
import { DateField } from '@expo/entity';
import { DateField, LowercaseStringField } from '@expo/entity';
export const redisTransformerMap = new Map([
[
@@ -22,4 +22,10 @@ export const redisTransformerMap = new Map([
read: (val: any) => (val ? new Date(val) : val),
},
],
[
LowercaseStringField.name,
{
write: (val: any) => (typeof val === 'string' ? val.toLowerCase() : val),
},
],
]);
@@ -39,11 +39,8 @@ describe(RedisCacheAdapter, () => {
ttlSecondsNegative: 2,
});
redisResults.set(
cacheAdapter['partsCacher']['makeCacheKey']('id', 'wat'),
JSON.stringify({ id: 'wat' })
);
redisResults.set(cacheAdapter['partsCacher']['makeCacheKey']('id', 'who'), '');
redisResults.set(cacheAdapter['makeCacheKey']('id', 'wat'), JSON.stringify({ id: 'wat' }));
redisResults.set(cacheAdapter['makeCacheKey']('id', 'who'), '');
const results = await cacheAdapter.loadManyAsync('id', ['wat', 'who', 'why']);
@@ -94,7 +91,7 @@ describe(RedisCacheAdapter, () => {
});
await cacheAdapter.cacheManyAsync('id', new Map([['wat', { id: 'wat' }]]));
const cacheKey = cacheAdapter['partsCacher']['makeCacheKey']('id', 'wat');
const cacheKey = cacheAdapter['makeCacheKey']('id', 'wat');
expect(redisResults.get(cacheKey)).toMatchObject({
value: JSON.stringify({ id: 'wat' }),
code: 'EX',
@@ -129,7 +126,7 @@ describe(RedisCacheAdapter, () => {
});
await cacheAdapter.cacheDBMissesAsync('id', ['wat']);
const cacheKey = cacheAdapter['partsCacher']['makeCacheKey']('id', 'wat');
const cacheKey = cacheAdapter['makeCacheKey']('id', 'wat');
expect(redisResults.get(cacheKey)).toMatchObject({
value: '',
code: 'EX',
@@ -153,7 +150,7 @@ describe(RedisCacheAdapter, () => {
});
await cacheAdapter.invalidateManyAsync('id', ['wat']);
const cacheKey = cacheAdapter['partsCacher']['makeCacheKey']('id', 'wat');
const cacheKey = cacheAdapter['makeCacheKey']('id', 'wat');
verify(mockRedisClient.del(cacheKey)).once();
});
@@ -3,6 +3,38 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [0.25.3](https://github.com/expo/entity/compare/v0.25.2...v0.25.3) (2022-03-12)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
## [0.25.2](https://github.com/expo/entity/compare/v0.25.1...v0.25.2) (2022-03-12)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
## [0.25.1](https://github.com/expo/entity/compare/v0.25.0...v0.25.1) (2022-03-10)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
# [0.25.0](https://github.com/expo/entity/compare/v0.24.0...v0.25.0) (2022-03-10)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
@@ -1,6 +1,6 @@
{
"name": "@expo/entity-database-adapter-knex",
"version": "0.24.0",
"version": "0.25.3",
"description": "Knex database adapter for @expo/entity",
"files": [
"build",
@@ -33,7 +33,7 @@
"knex": "^1.0.2"
},
"devDependencies": {
"@expo/entity": "^0.24.0",
"@expo/entity": "^0.25.3",
"pg": "^8.7.1"
}
}
@@ -7,6 +7,7 @@ import {
TableQuerySelectionModifiers,
TableFieldSingleValueEqualityCondition,
TableFieldMultiValueEqualityCondition,
LowercaseStringField,
} from '@expo/entity';
import { Knex } from 'knex';
@@ -36,6 +37,12 @@ export default class PostgresEntityDatabaseAdapter<TFields> extends EntityDataba
write: (val: any[] | any) => (Array.isArray(val) ? JSON.stringify(val) : val),
},
],
[
LowercaseStringField.name,
{
write: (val: any) => (typeof val === 'string' ? val.toLowerCase() : val),
},
],
]);
}
+35
View File
@@ -3,6 +3,41 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [0.25.3](https://github.com/expo/entity/compare/v0.25.2...v0.25.3) (2022-03-12)
**Note:** Version bump only for package @expo/entity-example
## [0.25.2](https://github.com/expo/entity/compare/v0.25.1...v0.25.2) (2022-03-12)
**Note:** Version bump only for package @expo/entity-example
## [0.25.1](https://github.com/expo/entity/compare/v0.25.0...v0.25.1) (2022-03-10)
**Note:** Version bump only for package @expo/entity-example
# [0.25.0](https://github.com/expo/entity/compare/v0.24.0...v0.25.0) (2022-03-10)
### Features
* privacy policy evaluation context ([#162](https://github.com/expo/entity/issues/162)) ([3a81a4c](https://github.com/expo/entity/commit/3a81a4c059e2e995eb800e35e76b3d432c22b23f))
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
**Note:** Version bump only for package @expo/entity-example
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@expo/entity-example",
"private": true,
"version": "0.24.0",
"version": "0.25.3",
"description": "An example integration of the @expo/entity framework",
"scripts": {
"tsc": "tsc",
@@ -23,7 +23,7 @@
"author": "Expo",
"license": "MIT",
"dependencies": {
"@expo/entity": "^0.24.0",
"@expo/entity": "^0.25.3",
"apollo-server-koa": "^2.16.1",
"graphql": "^15.3.0",
"koa": "^2.13.0",
@@ -3,6 +3,7 @@ import {
ReadonlyEntity,
EntityQueryContext,
RuleEvaluationResult,
EntityPrivacyPolicyEvaluationContext,
} from '@expo/entity';
import { ExampleViewerContext } from '../viewerContexts';
@@ -34,6 +35,7 @@ export default class AllowIfUserOwnerPrivacyRule<
async evaluateAsync(
viewerContext: ExampleViewerContext,
_queryContext: EntityQueryContext,
_evaluationContext: EntityPrivacyPolicyEvaluationContext,
entity: TEntity
): Promise<RuleEvaluationResult> {
if (viewerContext.isUserViewerContext()) {
@@ -30,7 +30,7 @@ router.get('/', async (ctx: ExampleContext) => {
router.get('/:id', async (ctx: ExampleContext) => {
const viewerContext = ctx.state.viewerContext;
const noteResult = await NoteEntity.loader(viewerContext).loadByIDAsync(ctx.params.id);
const noteResult = await NoteEntity.loader(viewerContext).loadByIDAsync(ctx.params['id']!);
if (!noteResult.ok) {
ctx.throw(403, noteResult.reason);
}
@@ -69,7 +69,7 @@ router.put('/:id', async (ctx: ExampleContext) => {
const viewerContext = ctx.state.viewerContext;
const { title, body } = ctx.request.body;
const noteLoadResult = await NoteEntity.loader(viewerContext).loadByIDAsync(ctx.params.id);
const noteLoadResult = await NoteEntity.loader(viewerContext).loadByIDAsync(ctx.params['id']!);
if (!noteLoadResult.ok) {
ctx.throw(403, noteLoadResult.reason);
}
@@ -90,7 +90,7 @@ router.put('/:id', async (ctx: ExampleContext) => {
router.delete('/:id', async (ctx: ExampleContext) => {
const viewerContext = ctx.state.viewerContext;
const noteLoadResult = await NoteEntity.loader(viewerContext).loadByIDAsync(ctx.params.id);
const noteLoadResult = await NoteEntity.loader(viewerContext).loadByIDAsync(ctx.params['id']!);
if (!noteLoadResult.ok) {
ctx.throw(403, noteLoadResult.reason);
}
@@ -3,6 +3,38 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [0.25.3](https://github.com/expo/entity/compare/v0.25.2...v0.25.3) (2022-03-12)
**Note:** Version bump only for package @expo/entity-full-integration-tests
## [0.25.2](https://github.com/expo/entity/compare/v0.25.1...v0.25.2) (2022-03-12)
**Note:** Version bump only for package @expo/entity-full-integration-tests
## [0.25.1](https://github.com/expo/entity/compare/v0.25.0...v0.25.1) (2022-03-10)
**Note:** Version bump only for package @expo/entity-full-integration-tests
# [0.25.0](https://github.com/expo/entity/compare/v0.24.0...v0.25.0) (2022-03-10)
**Note:** Version bump only for package @expo/entity-full-integration-tests
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
**Note:** Version bump only for package @expo/entity-full-integration-tests
@@ -1,7 +1,7 @@
{
"name": "@expo/entity-full-integration-tests",
"private": true,
"version": "0.24.0",
"version": "0.25.3",
"description": "Full redis and knex integration tests for the entity framework",
"scripts": {
"tsc": "tsc",
@@ -27,8 +27,8 @@
"@expo/entity-database-adapter-knex": "*"
},
"devDependencies": {
"@expo/entity": "^0.24.0",
"@expo/entity-cache-adapter-redis": "^0.24.0",
"@expo/entity-database-adapter-knex": "^0.24.0"
"@expo/entity": "^0.25.3",
"@expo/entity-cache-adapter-redis": "^0.25.3",
"@expo/entity-database-adapter-knex": "^0.25.3"
}
}
@@ -3,6 +3,38 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [0.25.3](https://github.com/expo/entity/compare/v0.25.2...v0.25.3) (2022-03-12)
**Note:** Version bump only for package @expo/entity-ip-address-field
## [0.25.2](https://github.com/expo/entity/compare/v0.25.1...v0.25.2) (2022-03-12)
**Note:** Version bump only for package @expo/entity-ip-address-field
## [0.25.1](https://github.com/expo/entity/compare/v0.25.0...v0.25.1) (2022-03-10)
**Note:** Version bump only for package @expo/entity-ip-address-field
# [0.25.0](https://github.com/expo/entity/compare/v0.24.0...v0.25.0) (2022-03-10)
**Note:** Version bump only for package @expo/entity-ip-address-field
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
**Note:** Version bump only for package @expo/entity-ip-address-field
@@ -1,6 +1,6 @@
{
"name": "@expo/entity-ip-address-field",
"version": "0.24.0",
"version": "0.25.3",
"description": "IP address EntityField definitions for @expo/entity",
"files": [
"build",
@@ -28,7 +28,7 @@
"ip-address": "^8.1.0"
},
"devDependencies": {
"@expo/entity": "^0.24.0",
"@expo/entity": "^0.25.3",
"@types/jsbn": "^1.2.29"
},
"peerDependencies": {
@@ -1,8 +0,0 @@
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,12 @@
import { EntityFieldDefinition } from '@expo/entity';
import { Address4, Address6 } from 'ip-address';
export class IPAddressField extends EntityFieldDefinition<string> {
protected validateAndTransformInputValueInternal(
value: string
): { isValid: false } | { isValid: true; transformedValue: string } {
return Address4.isValid(value) || Address6.isValid(value)
? { isValid: true, transformedValue: value }
: { isValid: false };
}
}
@@ -1,6 +1,6 @@
import { describeFieldTestCase } from '@expo/entity';
import { IPAddressField } from '../EntityFields';
import { IPAddressField } from '../IPAddressField';
describeFieldTestCase(
new IPAddressField({ columnName: 'wat' }),
@@ -12,5 +12,6 @@ describeFieldTestCase(
'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']
['', 'abs', '0.0.0.x/0', '123.21.23', '1:2:3:4:5:6', '198.10/8'],
[{ in: '192.168.1.1', out: '192.168.1.1' }]
);
@@ -4,4 +4,4 @@
* @module @expo/entity-ip-address-field
*/
export * from './EntityFields';
export * from './IPAddressField';
@@ -0,0 +1,35 @@
# Change Log
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [0.25.3](https://github.com/expo/entity/compare/v0.25.2...v0.25.3) (2022-03-12)
**Note:** Version bump only for package @expo/entity-secondary-cache-local-memory
## [0.25.2](https://github.com/expo/entity/compare/v0.25.1...v0.25.2) (2022-03-12)
**Note:** Version bump only for package @expo/entity-secondary-cache-local-memory
## [0.25.1](https://github.com/expo/entity/compare/v0.25.0...v0.25.1) (2022-03-10)
**Note:** Version bump only for package @expo/entity-secondary-cache-local-memory
# [0.25.0](https://github.com/expo/entity/compare/v0.24.0...v0.25.0) (2022-03-10)
### Features
* composed cache adapter, composed secondary cache, local memory secondary cache ([#164](https://github.com/expo/entity/issues/164)) ([6b6e70e](https://github.com/expo/entity/commit/6b6e70e75f7e42a4dce13edeb406a4d88d9264f0))
@@ -0,0 +1,24 @@
# `@expo/entity-secondary-cache-local-memory`
Cross-request [LRU](https://github.com/isaacs/node-lru-cache) secondary cache for `@expo/entity`. Use
this cache with caution - it is nonstandard. The cache is shared between requests in the node process.
[Documentation](https://expo.github.io/entity/modules/_expo_secondary_cache_local_memory.html)
## Usage
1. Create a concrete implementation of abstract class `EntitySecondaryCacheLoader`, in this example `TestEntitySecondaryCacheLoader`. The underlying data can come from anywhere, but an entity is constructed from the data and then authorized for the viewer.
2. Create an instance of your `EntitySecondaryCacheLoader`, passing in a `LocalMemorySecondaryEntityCache`.
```typescript
const secondaryCacheLoader = new TestSecondaryLocalMemoryCacheLoader(
new LocalMemorySecondaryEntityCache(
GenericLocalMemoryCacher.createLRUCache<LocalMemoryTestEntityFields>({})
),
LocalMemoryTestEntity.loader(viewerContext)
);
```
3. Load entities through it:
```typescript
const loadParams = { id: createdEntity.getID() };
const results = await secondaryCacheLoader.loadManyAsync([loadParams]);
```
@@ -0,0 +1,38 @@
{
"name": "@expo/entity-secondary-cache-local-memory",
"version": "0.25.3",
"description": "Local memory secondary cache for @expo/entity",
"files": [
"build",
"src"
],
"main": "build/index.js",
"types": "build/index.d.ts",
"scripts": {
"tsc": "tsc",
"clean": "rm -rf build coverage coverage-integration",
"lint": "eslint src",
"lint-fix": "eslint src --fix",
"test": "jest --rootDir . --config ../../resources/jest.config.js --passWithNoTests",
"integration": "../../resources/run-with-docker yarn integration-no-setup",
"integration-no-setup": "jest --config ../../resources/jest-integration.config.js --rootDir . --runInBand --passWithNoTests",
"barrelsby": "barrelsby --directory src --location top --exclude tests__ --singleQuotes --exportDefault --delete"
},
"engines": {
"node": ">=12"
},
"keywords": [
"entity"
],
"author": "Expo",
"license": "MIT",
"peerDependencies": {
"@expo/entity": "*",
"@expo/entity-cache-adapter-local-memory": "*"
},
"devDependencies": {
"@expo/entity": "^0.25.3",
"@expo/entity-cache-adapter-local-memory": "^0.25.3",
"nullthrows": "^1.1.1"
}
}
@@ -0,0 +1,21 @@
import { GenericSecondaryEntityCache } from '@expo/entity';
import {
GenericLocalMemoryCacher,
LocalMemoryCache,
} from '@expo/entity-cache-adapter-local-memory';
/**
* A local memory {@link GenericSecondaryEntityCache}.
*
* @remarks
*
* TLoadParams must be JSON stringifyable.
*/
export default class LocalMemorySecondaryEntityCache<
TFields,
TLoadParams
> extends GenericSecondaryEntityCache<TFields, TLoadParams> {
constructor(localMemoryCache: LocalMemoryCache<TFields>) {
super(new GenericLocalMemoryCacher(localMemoryCache), (params) => JSON.stringify(params));
}
}
@@ -0,0 +1,228 @@
import {
EntitySecondaryCacheLoader,
mapMapAsync,
ViewerContext,
NoOpEntityMetricsAdapter,
IEntityMetricsAdapter,
EntityCompanionProvider,
StubQueryContextProvider,
StubDatabaseAdapterProvider,
AlwaysAllowPrivacyPolicyRule,
EntityPrivacyPolicy,
UUIDField,
StringField,
EntityConfiguration,
EntityCompanionDefinition,
Entity,
} from '@expo/entity';
import {
GenericLocalMemoryCacher,
LocalMemoryCacheAdapterProvider,
} from '@expo/entity-cache-adapter-local-memory';
import nullthrows from 'nullthrows';
import LocalMemorySecondaryEntityCache from '../LocalMemorySecondaryEntityCache';
export type LocalMemoryTestEntityFields = {
id: string;
name: string;
};
export default class LocalMemoryTestEntity extends Entity<
LocalMemoryTestEntityFields,
string,
ViewerContext
> {
static getCompanionDefinition(): EntityCompanionDefinition<
LocalMemoryTestEntityFields,
string,
ViewerContext,
LocalMemoryTestEntity,
LocalMemoryTestEntityPrivacyPolicy
> {
return localMemoryTestEntityCompanionDefinition;
}
}
export class LocalMemoryTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
LocalMemoryTestEntityFields,
string,
ViewerContext,
LocalMemoryTestEntity
> {
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<
LocalMemoryTestEntityFields,
string,
ViewerContext,
LocalMemoryTestEntity
>(),
];
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<
LocalMemoryTestEntityFields,
string,
ViewerContext,
LocalMemoryTestEntity
>(),
];
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<
LocalMemoryTestEntityFields,
string,
ViewerContext,
LocalMemoryTestEntity
>(),
];
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<
LocalMemoryTestEntityFields,
string,
ViewerContext,
LocalMemoryTestEntity
>(),
];
}
export const localMemoryTestEntityConfiguration =
new EntityConfiguration<LocalMemoryTestEntityFields>({
idField: 'id',
tableName: 'local_memory_test_entities',
schema: {
id: new UUIDField({
columnName: 'id',
}),
name: new StringField({
columnName: 'name',
}),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'local-memory',
});
const localMemoryTestEntityCompanionDefinition = new EntityCompanionDefinition({
entityClass: LocalMemoryTestEntity,
entityConfiguration: localMemoryTestEntityConfiguration,
privacyPolicyClass: LocalMemoryTestEntityPrivacyPolicy,
});
export const createTestEntityCompanionProvider = (
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter()
): EntityCompanionProvider => {
return new EntityCompanionProvider(
metricsAdapter,
new Map([
[
'postgres',
{
adapterProvider: new StubDatabaseAdapterProvider(),
queryContextProvider: StubQueryContextProvider,
},
],
]),
new Map([
[
'local-memory',
{
cacheAdapterProvider: LocalMemoryCacheAdapterProvider.getProvider(),
},
],
])
);
};
class TestViewerContext extends ViewerContext {}
type TestLoadParams = { id: string };
const FAKE_ID = 'fake';
class TestSecondaryLocalMemoryCacheLoader extends EntitySecondaryCacheLoader<
TestLoadParams,
LocalMemoryTestEntityFields,
string,
TestViewerContext,
LocalMemoryTestEntity,
LocalMemoryTestEntityPrivacyPolicy
> {
public databaseLoadCount = 0;
protected async fetchObjectsFromDatabaseAsync(
loadParamsArray: readonly Readonly<TestLoadParams>[]
): Promise<ReadonlyMap<Readonly<TestLoadParams>, Readonly<LocalMemoryTestEntityFields> | null>> {
this.databaseLoadCount += loadParamsArray.length;
const emptyMap = new Map(loadParamsArray.map((p) => [p, null]));
return await mapMapAsync(emptyMap, async (_value, loadParams) => {
if (loadParams.id === FAKE_ID) {
return null;
}
return nullthrows(
(
await this.entityLoader
.enforcing()
.loadManyByFieldEqualityConjunctionAsync([
{ fieldName: 'id', fieldValue: loadParams.id },
])
)[0]
).getAllFields();
});
}
}
describe(LocalMemorySecondaryEntityCache, () => {
it('Loads through secondary loader, caches, and invalidates', async () => {
const viewerContext = new TestViewerContext(createTestEntityCompanionProvider());
const createdEntity = await LocalMemoryTestEntity.creator(viewerContext)
.setField('name', 'wat')
.enforceCreateAsync();
const secondaryCacheLoader = new TestSecondaryLocalMemoryCacheLoader(
new LocalMemorySecondaryEntityCache(
GenericLocalMemoryCacher.createLRUCache<LocalMemoryTestEntityFields>({})
),
LocalMemoryTestEntity.loader(viewerContext)
);
const loadParams = { id: createdEntity.getID() };
const results = await secondaryCacheLoader.loadManyAsync([loadParams]);
expect(nullthrows(results.get(loadParams)).enforceValue().getID()).toEqual(
createdEntity.getID()
);
expect(secondaryCacheLoader.databaseLoadCount).toEqual(1);
const results2 = await secondaryCacheLoader.loadManyAsync([loadParams]);
expect(nullthrows(results2.get(loadParams)).enforceValue().getID()).toEqual(
createdEntity.getID()
);
expect(secondaryCacheLoader.databaseLoadCount).toEqual(1);
await secondaryCacheLoader.invalidateManyAsync([loadParams]);
const results3 = await secondaryCacheLoader.loadManyAsync([loadParams]);
expect(nullthrows(results3.get(loadParams)).enforceValue().getID()).toEqual(
createdEntity.getID()
);
expect(secondaryCacheLoader.databaseLoadCount).toEqual(2);
});
it('correctly handles uncached and unfetchable load params', async () => {
const viewerContext = new TestViewerContext(createTestEntityCompanionProvider());
const secondaryCacheLoader = new TestSecondaryLocalMemoryCacheLoader(
new LocalMemorySecondaryEntityCache(
GenericLocalMemoryCacher.createLRUCache<LocalMemoryTestEntityFields>({})
),
LocalMemoryTestEntity.loader(viewerContext)
);
const loadParams = { id: FAKE_ID };
const results = await secondaryCacheLoader.loadManyAsync([loadParams]);
expect(results.size).toBe(1);
expect(results.get(loadParams)).toBe(null);
});
});
@@ -0,0 +1,7 @@
/* eslint-disable tsdoc/syntax */
/**
* @packageDocumentation
* @module @expo/entity-secondary-cache-local-memory
*/
export { default as LocalMemorySecondaryEntityCache } from './LocalMemorySecondaryEntityCache';
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "build",
},
"include": ["src"]
}
@@ -3,6 +3,41 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [0.25.3](https://github.com/expo/entity/compare/v0.25.2...v0.25.3) (2022-03-12)
**Note:** Version bump only for package @expo/entity-secondary-cache-redis
## [0.25.2](https://github.com/expo/entity/compare/v0.25.1...v0.25.2) (2022-03-12)
**Note:** Version bump only for package @expo/entity-secondary-cache-redis
## [0.25.1](https://github.com/expo/entity/compare/v0.25.0...v0.25.1) (2022-03-10)
**Note:** Version bump only for package @expo/entity-secondary-cache-redis
# [0.25.0](https://github.com/expo/entity/compare/v0.24.0...v0.25.0) (2022-03-10)
### Features
* composed cache adapter, composed secondary cache, local memory secondary cache ([#164](https://github.com/expo/entity/issues/164)) ([6b6e70e](https://github.com/expo/entity/commit/6b6e70e75f7e42a4dce13edeb406a4d88d9264f0))
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
### Chores
@@ -1,6 +1,6 @@
{
"name": "@expo/entity-secondary-cache-redis",
"version": "0.24.0",
"version": "0.25.3",
"description": "Redis secondary cache for @expo/entity",
"files": [
"build",
@@ -30,12 +30,10 @@
"@expo/entity": "*",
"@expo/entity-cache-adapter-redis": "*"
},
"dependencies": {
"ioredis": "^4.17.3"
},
"devDependencies": {
"@expo/entity": "^0.24.0",
"@expo/entity-cache-adapter-redis": "^0.24.0",
"@expo/entity": "^0.25.3",
"@expo/entity-cache-adapter-redis": "^0.25.3",
"ioredis": "^5.0.4",
"nullthrows": "^1.1.1"
}
}
@@ -1,15 +1,8 @@
import {
EntityConfiguration,
GenericSecondaryEntityCache,
Parts,
SimplePartsCacher,
} from '@expo/entity';
import { EntityConfiguration, GenericSecondaryEntityCache } from '@expo/entity';
import { GenericRedisCacheContext, GenericRedisCacher } from '@expo/entity-cache-adapter-redis';
/**
* A custom secondary read-through entity cache is a way to add a custom second layer of caching for a particular
* single entity load. One common way this may be used is to add a second layer of caching in a hot path that makes
* a call to {@link EntityLoader.loadManyByFieldEqualityConjunctionAsync} is guaranteed to return at most one entity.
* A redis {@link GenericSecondaryEntityCache}.
*/
export default class RedisSecondaryEntityCache<
TFields,
@@ -18,15 +11,8 @@ export default class RedisSecondaryEntityCache<
constructor(
entityConfiguration: EntityConfiguration<TFields>,
genericRedisCacheContext: GenericRedisCacheContext,
makeRedisSpecificKey: (...parts: Parts) => string,
getParts: (params: Readonly<TLoadParams>) => Parts
constructRedisKey: (params: Readonly<TLoadParams>) => string
) {
super(
new SimplePartsCacher(
new GenericRedisCacher(genericRedisCacheContext, entityConfiguration),
makeRedisSpecificKey
),
getParts
);
super(new GenericRedisCacher(genericRedisCacheContext, entityConfiguration), constructRedisKey);
}
}
@@ -87,8 +87,7 @@ describe(RedisSecondaryEntityCache, () => {
new RedisSecondaryEntityCache(
redisTestEntityConfiguration,
redisCacheAdapterContext,
(...parts) => parts.join(':redis-specific-delimiter:'),
(loadParams) => [`test-key-${loadParams.id}`]
(loadParams) => `test-key-${loadParams.id}`
),
RedisTestEntity.loader(viewerContext)
);
@@ -127,8 +126,7 @@ describe(RedisSecondaryEntityCache, () => {
new RedisSecondaryEntityCache(
redisTestEntityConfiguration,
redisCacheAdapterContext,
(...parts) => parts.join(':redis-specific-delimiter:'),
(loadParams) => [`test-key-${loadParams.id}`]
(loadParams) => `test-key-${loadParams.id}`
),
RedisTestEntity.loader(viewerContext)
);
+39
View File
@@ -3,6 +3,45 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [0.25.3](https://github.com/expo/entity/compare/v0.25.2...v0.25.3) (2022-03-12)
**Note:** Version bump only for package @expo/entity
## [0.25.2](https://github.com/expo/entity/compare/v0.25.1...v0.25.2) (2022-03-12)
**Note:** Version bump only for package @expo/entity
## [0.25.1](https://github.com/expo/entity/compare/v0.25.0...v0.25.1) (2022-03-10)
### Bug Fixes
* add composed cache to index ([#166](https://github.com/expo/entity/issues/166)) ([5c82edc](https://github.com/expo/entity/commit/5c82edcd9f26854878d29950a05852e117bbd723))
# [0.25.0](https://github.com/expo/entity/compare/v0.24.0...v0.25.0) (2022-03-10)
### Features
* composed cache adapter, composed secondary cache, local memory secondary cache ([#164](https://github.com/expo/entity/issues/164)) ([6b6e70e](https://github.com/expo/entity/commit/6b6e70e75f7e42a4dce13edeb406a4d88d9264f0))
* privacy policy evaluation context ([#162](https://github.com/expo/entity/issues/162)) ([3a81a4c](https://github.com/expo/entity/commit/3a81a4c059e2e995eb800e35e76b3d432c22b23f))
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
**Note:** Version bump only for package @expo/entity
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/entity",
"version": "0.24.0",
"version": "0.25.3",
"description": "A privacy-first data model",
"files": [
"build",
@@ -0,0 +1,86 @@
import nullthrows from 'nullthrows';
import EntityCacheAdapter from './EntityCacheAdapter';
import EntityConfiguration from './EntityConfiguration';
import { CacheStatus, CacheLoadResult } from './internal/ReadThroughEntityCache';
export default class ComposedEntityCacheAdapter<TFields> extends EntityCacheAdapter<TFields> {
/**
* A {@link EntityCacheAdapter} that composes other {@link EntityCacheAdapter} instances.
*
* @param entityConfiguration - configuration for entity being loaded
* @param cacheAdapters - list of cache adapters to compose in order of precedence.
* Earlier cache adapters are read from first and written to (including invalidations) last.
* Typically, caches closer to the application should be ordered before caches closer to the database.
*/
constructor(
entityConfiguration: EntityConfiguration<TFields>,
private readonly cacheAdapters: EntityCacheAdapter<TFields>[]
) {
super(entityConfiguration);
}
public async loadManyAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult<TFields>>> {
const retMap = new Map<NonNullable<TFields[N]>, CacheLoadResult<TFields>>();
let unfulfilledFieldValues = fieldValues;
for (const cacheAdapter of this.cacheAdapters) {
const cacheResultsFromAdapter = await cacheAdapter.loadManyAsync(
fieldName,
unfulfilledFieldValues
);
const newUnfulfilledFieldValues = [];
for (const [fieldValue, cacheResult] of cacheResultsFromAdapter) {
if (cacheResult.status === CacheStatus.MISS) {
newUnfulfilledFieldValues.push(fieldValue);
} else {
retMap.set(fieldValue, cacheResult);
}
}
unfulfilledFieldValues = newUnfulfilledFieldValues;
}
for (const fieldValue of unfulfilledFieldValues) {
retMap.set(fieldValue, { status: CacheStatus.MISS });
}
return retMap;
}
public async cacheManyAsync<N extends keyof TFields>(
fieldName: N,
objectMap: ReadonlyMap<NonNullable<TFields[N]>, Readonly<TFields>>
): Promise<void> {
// write to lower layers first
for (let i = this.cacheAdapters.length - 1; i >= 0; i--) {
const cacheAdapter = nullthrows(this.cacheAdapters[i]);
await cacheAdapter.cacheManyAsync(fieldName, objectMap);
}
}
public async cacheDBMissesAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<void> {
// write to lower layers first
for (let i = this.cacheAdapters.length - 1; i >= 0; i--) {
const cacheAdapter = nullthrows(this.cacheAdapters[i]);
await cacheAdapter.cacheDBMissesAsync(fieldName, fieldValues);
}
}
public async invalidateManyAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<void> {
// delete from lower layers first
for (let i = this.cacheAdapters.length - 1; i >= 0; i--) {
const cacheAdapter = nullthrows(this.cacheAdapters[i]);
await cacheAdapter.invalidateManyAsync(fieldName, fieldValues);
}
}
}
@@ -1,82 +0,0 @@
import { CacheLoadResult } from '@expo/entity/src/internal/ReadThroughEntityCache';
import invariant from 'invariant';
import PartsCacher, { Parts, PartsKey } from './PartsCacher';
import { CacheStatus } from './internal/ReadThroughEntityCache';
/**
* A composed cacher by which objects are stored, fetched and removed in a series of caches
* */
export default class ComposedPartsCacher<TFields> extends PartsCacher<TFields> {
constructor(
/**
* The series of cachers to use, in order of evaluation
* For example, [cacheAdapterEvaluatedFirst, cacheAdapterEvaluatedSecond, cacheAdapterEvaluatedThird]
*/
private readonly partsCachers: PartsCacher<TFields>[]
) {
super();
}
public async loadManyAsync(
partsList: readonly Parts[]
): Promise<ReadonlyMap<PartsKey, CacheLoadResult<TFields>>> {
const resultsFromAllCachers = new Map<PartsKey, CacheLoadResult<TFields>>();
const partsKeysToParts = new Map(
partsList.map((parts) => [PartsCacher.getPartsKey(...parts), parts])
);
let partsKeysToLookup: PartsKey[] = partsList.map((parts) => PartsCacher.getPartsKey(...parts));
for (const partsCacher of this.partsCachers) {
const partsListToLookup = partsKeysToLookup.map((partsKey) => {
const parts = partsKeysToParts.get(partsKey);
invariant(parts, 'parts cannot be undefined');
return parts;
});
const cacheResults = await partsCacher.loadManyAsync(partsListToLookup);
const cacheMisses = [];
for (const [partsKey, cacheResult] of cacheResults) {
if (cacheResult.status === CacheStatus.MISS) {
cacheMisses.push(partsKey);
} else {
resultsFromAllCachers.set(partsKey, cacheResult);
}
}
partsKeysToLookup = cacheMisses;
}
// None of the cachers have these values, so they are misses
for (const partsKey of partsKeysToLookup) {
resultsFromAllCachers.set(partsKey, {
status: CacheStatus.MISS,
});
}
return resultsFromAllCachers;
}
public async cacheManyAsync(objectMap: ReadonlyMap<PartsKey, Readonly<TFields>>): Promise<void> {
// perform action on lower layers first
for (let i = this.partsCachers.length - 1; i >= 0; i--) {
const partsCacher = this.partsCachers[i];
invariant(partsCacher, 'parts cacher cannot be undefined');
await partsCacher.cacheManyAsync(objectMap);
}
}
public async cacheDBMissesAsync(partsList: readonly Parts[]): Promise<void> {
// perform action on lower layers first
for (let i = this.partsCachers.length - 1; i >= 0; i--) {
const partsCacher = this.partsCachers[i];
invariant(partsCacher, 'parts cacher cannot be undefined');
await partsCacher.cacheDBMissesAsync(partsList);
}
}
public async invalidateManyAsync(partsList: readonly Parts[]): Promise<void> {
// perform action on lower layers first
for (let i = this.partsCachers.length - 1; i >= 0; i--) {
const cacheAdapter = this.partsCachers[i];
invariant(cacheAdapter, 'parts cacher cannot be undefined');
await cacheAdapter.invalidateManyAsync(partsList);
}
}
}
@@ -0,0 +1,63 @@
import nullthrows from 'nullthrows';
import { ISecondaryEntityCache } from './EntitySecondaryCacheLoader';
export default class ComposedSecondaryEntityCache<TLoadParams, TFields>
implements ISecondaryEntityCache<TFields, TLoadParams>
{
/**
* A {@link ISecondaryEntityCache} that composes other {@link ISecondaryEntityCache} instances.
*
* @param secondaryEntityCaches - list of caches to compose in order of precedence.
* Earlier caches are read from first and written to (including invalidations) last.
* Typically, caches closer to the application should be ordered before caches closer to the database.
*/
constructor(
private readonly secondaryEntityCaches: ISecondaryEntityCache<TFields, TLoadParams>[]
) {}
async loadManyThroughAsync(
loadParamsArray: readonly Readonly<TLoadParams>[],
fetcher: (
fetcherLoadParamsArray: readonly Readonly<TLoadParams>[]
) => Promise<ReadonlyMap<Readonly<TLoadParams>, Readonly<TFields> | null>>
): Promise<ReadonlyMap<Readonly<TLoadParams>, Readonly<TFields> | null>> {
return await ComposedSecondaryEntityCache.loadManyThroughRecursivelyAsync(
this.secondaryEntityCaches,
loadParamsArray,
fetcher
);
}
private static async loadManyThroughRecursivelyAsync<TLoadParams, TFields>(
secondaryEntityCaches: ISecondaryEntityCache<TFields, TLoadParams>[],
loadParamsArray: readonly Readonly<TLoadParams>[],
fetcher: (
fetcherLoadParamsArray: readonly Readonly<TLoadParams>[]
) => Promise<ReadonlyMap<Readonly<TLoadParams>, Readonly<TFields> | null>>
): Promise<ReadonlyMap<Readonly<TLoadParams>, Readonly<TFields> | null>> {
if (secondaryEntityCaches.length === 0) {
return await fetcher(loadParamsArray);
}
const [firstCache, ...restCaches] = secondaryEntityCaches;
return await nullthrows(firstCache).loadManyThroughAsync(
loadParamsArray,
(fetcherLoadParamsArray) =>
ComposedSecondaryEntityCache.loadManyThroughRecursivelyAsync(
restCaches,
fetcherLoadParamsArray,
fetcher
)
);
}
async invalidateManyAsync(loadParamsArray: readonly Readonly<TLoadParams>[]): Promise<void> {
// invalidate lower layers first
for (let i = this.secondaryEntityCaches.length - 1; i >= 0; i--) {
const secondaryEntityCache = nullthrows(this.secondaryEntityCaches[i]);
await secondaryEntityCache.invalidateManyAsync(loadParamsArray);
}
}
}
+6 -4
View File
@@ -69,7 +69,7 @@ export default abstract class Entity<
return viewerContext
.getViewerScopedEntityCompanionForClass(this)
.getMutatorFactory()
.forCreate(queryContext);
.forCreate(queryContext, { cascadingDeleteCause: null });
}
/**
@@ -111,7 +111,7 @@ export default abstract class Entity<
.getViewerContext()
.getViewerScopedEntityCompanionForClass(this)
.getMutatorFactory()
.forUpdate(existingEntity, queryContext);
.forUpdate(existingEntity, queryContext, { cascadingDeleteCause: null });
}
/**
@@ -152,7 +152,7 @@ export default abstract class Entity<
.getViewerContext()
.getViewerScopedEntityCompanionForClass(this)
.getMutatorFactory()
.forDelete(existingEntity, queryContext)
.forDelete(existingEntity, queryContext, { cascadingDeleteCause: null })
.deleteAsync();
}
@@ -194,7 +194,7 @@ export default abstract class Entity<
.getViewerContext()
.getViewerScopedEntityCompanionForClass(this)
.getMutatorFactory()
.forDelete(existingEntity, queryContext)
.forDelete(existingEntity, queryContext, { cascadingDeleteCause: null })
.enforceDeleteAsync();
}
@@ -251,6 +251,7 @@ export default abstract class Entity<
privacyPolicy.authorizeUpdateAsync(
existingEntity.getViewerContext(),
queryContext,
{ cascadingDeleteCause: null },
existingEntity,
companion.getMetricsAdapter()
)
@@ -304,6 +305,7 @@ export default abstract class Entity<
privacyPolicy.authorizeDeleteAsync(
existingEntity.getViewerContext(),
queryContext,
{ cascadingDeleteCause: null },
existingEntity,
companion.getMetricsAdapter()
)
@@ -69,7 +69,7 @@ export default class EntityAssociationLoader<
.getViewerContext()
.getViewerScopedEntityCompanionForClass(associatedEntityClass)
.getLoaderFactory()
.forLoad(queryContext);
.forLoad(queryContext, { cascadingDeleteCause: null });
return (await loader.loadByIDAsync(associatedEntityID as unknown as TAssociatedID)) as Result<
null extends TFields[TIdentifyingField] ? TAssociatedEntity | null : TAssociatedEntity
@@ -123,7 +123,7 @@ export default class EntityAssociationLoader<
.getViewerContext()
.getViewerScopedEntityCompanionForClass(associatedEntityClass)
.getLoaderFactory()
.forLoad(queryContext);
.forLoad(queryContext, { cascadingDeleteCause: null });
return await loader.loadManyByFieldEqualingAsync(
associatedEntityFieldContainingThisID,
thisID as any
@@ -180,7 +180,7 @@ export default class EntityAssociationLoader<
.getViewerContext()
.getViewerScopedEntityCompanionForClass(associatedEntityClass)
.getLoaderFactory()
.forLoad(queryContext);
.forLoad(queryContext, { cascadingDeleteCause: null });
return await loader.loadByFieldEqualingAsync(
associatedEntityLookupByField,
associatedFieldValue as any
@@ -238,7 +238,7 @@ export default class EntityAssociationLoader<
.getViewerContext()
.getViewerScopedEntityCompanionForClass(associatedEntityClass)
.getLoaderFactory()
.forLoad(queryContext);
.forLoad(queryContext, { cascadingDeleteCause: null });
return await loader.loadManyByFieldEqualingAsync(
associatedEntityLookupByField,
associatedFieldValue as any
+12 -4
View File
@@ -121,12 +121,20 @@ export abstract class EntityFieldDefinition<T> {
* - 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 {
public validateAndTransformInputValue(
value: T | null | undefined
): { isValid: false } | { isValid: true; transformedValue: T | null | undefined } {
if (value === null || value === undefined) {
return true;
return { isValid: true, transformedValue: value };
}
return this.validateInputValueInternal(value);
return this.validateAndTransformInputValueInternal(value);
}
protected abstract validateAndTransformInputValueInternal(
value: T
): { isValid: false } | { isValid: true; transformedValue: T };
public transformInputValue(value: T): T {
return value;
}
protected abstract validateInputValueInternal(value: T): boolean;
}
+78 -22
View File
@@ -6,8 +6,28 @@ 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';
protected validateAndTransformInputValueInternal(
value: string
): { isValid: false } | { isValid: true; transformedValue: string } {
return typeof value === 'string'
? { isValid: true, transformedValue: value }
: { isValid: false };
}
}
/**
* {@link EntityFieldDefinition} for a column with a JS string type in which
* stored values are lower-cased.
*/
export class LowercaseStringField extends StringField {
protected override validateAndTransformInputValueInternal(
value: string
): { isValid: false } | { isValid: true; transformedValue: string } {
const superValue = super.validateAndTransformInputValueInternal(value);
if (superValue.isValid) {
return { isValid: true, transformedValue: superValue.transformedValue.toLowerCase() };
}
return { isValid: false };
}
}
@@ -16,8 +36,14 @@ export class StringField extends EntityFieldDefinition<string> {
* Enforces that the string is a valid UUID.
*/
export class UUIDField extends StringField {
protected override validateInputValueInternal(value: string): boolean {
return validateUUID(value);
public override validateAndTransformInputValueInternal(
value: string
): { isValid: false } | { isValid: true; transformedValue: string } {
const superValue = super.validateAndTransformInputValueInternal(value);
if (superValue.isValid && validateUUID(superValue.transformedValue)) {
return { isValid: true, transformedValue: superValue.transformedValue };
}
return { isValid: false };
}
}
@@ -25,8 +51,10 @@ export class UUIDField extends StringField {
* {@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;
protected validateAndTransformInputValueInternal(
value: Date
): { isValid: false } | { isValid: true; transformedValue: Date } {
return value instanceof Date ? { isValid: true, transformedValue: value } : { isValid: false };
}
}
@@ -34,8 +62,12 @@ export class DateField extends EntityFieldDefinition<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';
protected validateAndTransformInputValueInternal(
value: boolean
): { isValid: false } | { isValid: true; transformedValue: boolean } {
return typeof value === 'boolean'
? { isValid: true, transformedValue: value }
: { isValid: false };
}
}
@@ -44,8 +76,12 @@ export class BooleanField extends EntityFieldDefinition<boolean> {
* 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);
protected validateAndTransformInputValueInternal(
value: number
): { isValid: false } | { isValid: true; transformedValue: number } {
return typeof value === 'number' && Number.isInteger(value)
? { isValid: true, transformedValue: value }
: { isValid: false };
}
}
@@ -54,8 +90,12 @@ export class IntField extends EntityFieldDefinition<number> {
* 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';
protected validateAndTransformInputValueInternal(
value: number
): { isValid: false } | { isValid: true; transformedValue: number } {
return typeof value === 'number'
? { isValid: true, transformedValue: value }
: { isValid: false };
}
}
@@ -64,8 +104,12 @@ export class FloatField extends EntityFieldDefinition<number> {
* 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');
protected validateAndTransformInputValueInternal(
value: string[]
): { isValid: false } | { isValid: true; transformedValue: string[] } {
return Array.isArray(value) && value.every((subValue) => typeof subValue === 'string')
? { isValid: true, transformedValue: value }
: { isValid: false };
}
}
@@ -73,8 +117,12 @@ export class StringArrayField extends EntityFieldDefinition<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);
protected validateAndTransformInputValueInternal(
value: object
): { isValid: false } | { isValid: true; transformedValue: object } {
return typeof value === 'object' && !Array.isArray(value)
? { isValid: true, transformedValue: value }
: { isValid: false };
}
}
@@ -82,8 +130,12 @@ export class JSONObjectField extends EntityFieldDefinition<object> {
* {@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';
protected validateAndTransformInputValueInternal(
value: string | number
): { isValid: false } | { isValid: true; transformedValue: string | number } {
return typeof value === 'number' || typeof value === 'string'
? { isValid: true, transformedValue: value }
: { isValid: false };
}
}
@@ -91,8 +143,10 @@ export class EnumField extends EntityFieldDefinition<string | number> {
* {@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);
protected validateAndTransformInputValueInternal(
value: any[]
): { isValid: false } | { isValid: true; transformedValue: any[] } {
return Array.isArray(value) ? { isValid: true, transformedValue: value } : { isValid: false };
}
}
@@ -101,7 +155,9 @@ export class JSONArrayField extends EntityFieldDefinition<any[]> {
* Does not do any validation.
*/
export class MaybeJSONArrayField extends EntityFieldDefinition<any | any[]> {
protected validateInputValueInternal(_value: any): boolean {
return true;
protected validateAndTransformInputValueInternal(
value: any
): { isValid: false } | { isValid: true; transformedValue: any } {
return { isValid: true, transformedValue: value };
}
}
+44 -20
View File
@@ -9,7 +9,7 @@ import {
QuerySelectionModifiers,
isSingleValueFieldEqualityCondition,
} from './EntityDatabaseAdapter';
import EntityPrivacyPolicy from './EntityPrivacyPolicy';
import EntityPrivacyPolicy, { EntityPrivacyPolicyEvaluationContext } from './EntityPrivacyPolicy';
import { EntityQueryContext } from './EntityQueryContext';
import ReadonlyEntity from './ReadonlyEntity';
import ViewerContext from './ViewerContext';
@@ -40,6 +40,7 @@ export default class EntityLoader<
constructor(
private readonly viewerContext: TViewerContext,
private readonly queryContext: EntityQueryContext,
private readonly privacyPolicyEvaluationContext: EntityPrivacyPolicyEvaluationContext,
private readonly entityConfiguration: EntityConfiguration<TFields>,
private readonly entityClass: IEntityClass<
TFields,
@@ -81,12 +82,15 @@ export default class EntityLoader<
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<ReadonlyMap<NonNullable<TFields[N]>, readonly Result<TEntity>[]>> {
this.validateFieldValues(fieldName, fieldValues);
const validatedAndTransformedFieldValues = this.validateAndTransformFieldValues(
fieldName,
fieldValues
);
const fieldValuesToFieldObjects = await this.dataManager.loadManyByFieldEqualingAsync(
this.queryContext,
fieldName,
fieldValues
validatedAndTransformedFieldValues
);
return await this.constructAndAuthorizeEntitiesAsync(fieldValuesToFieldObjects);
@@ -125,7 +129,9 @@ export default class EntityLoader<
const entityResults = await this.loadManyByFieldEqualingAsync(uniqueFieldName, fieldValue);
invariant(
entityResults.length <= 1,
`loadByFieldEqualing: Multiple entities of type ${this.entityClass.name} found for ${uniqueFieldName}=${fieldValue}`
`loadByFieldEqualing: Multiple entities of type ${this.entityClass.name} found for ${String(
uniqueFieldName
)}=${fieldValue}`
);
return entityResults[0] ?? null;
}
@@ -196,16 +202,29 @@ export default class EntityLoader<
fieldEqualityOperands: FieldEqualityCondition<TFields, N>[],
querySelectionModifiers: QuerySelectionModifiers<TFields> = {}
): Promise<readonly Result<TEntity>[]> {
for (const fieldEqualityOperand of fieldEqualityOperands) {
const fieldValues = isSingleValueFieldEqualityCondition(fieldEqualityOperand)
? [fieldEqualityOperand.fieldValue]
: fieldEqualityOperand.fieldValues;
this.validateFieldValues(fieldEqualityOperand.fieldName, fieldValues);
}
const validatedAndTransformedFieldEqualityOperands: FieldEqualityCondition<TFields, N>[] =
fieldEqualityOperands.map((fieldEqualityOperand) => {
if (isSingleValueFieldEqualityCondition(fieldEqualityOperand)) {
return {
...fieldEqualityOperand,
fieldValue: this.validateAndTransformFieldValues(fieldEqualityOperand.fieldName, [
fieldEqualityOperand.fieldValue,
])[0]!,
};
} else {
return {
...fieldEqualityOperand,
fieldValues: this.validateAndTransformFieldValues(
fieldEqualityOperand.fieldName,
fieldEqualityOperand.fieldValues
),
};
}
});
const fieldObjects = await this.dataManager.loadManyByFieldEqualityConjunctionAsync(
this.queryContext,
fieldEqualityOperands,
validatedAndTransformedFieldEqualityOperands,
querySelectionModifiers
);
const uncheckedEntityResults = this.tryConstructEntities(fieldObjects);
@@ -218,6 +237,7 @@ export default class EntityLoader<
this.privacyPolicy.authorizeReadAsync(
this.viewerContext,
this.queryContext,
this.privacyPolicyEvaluationContext,
uncheckedEntityResult.value,
this.metricsAdapter
)
@@ -272,6 +292,7 @@ export default class EntityLoader<
this.privacyPolicy.authorizeReadAsync(
this.viewerContext,
this.queryContext,
this.privacyPolicyEvaluationContext,
uncheckedEntityResult.value,
this.metricsAdapter
)
@@ -332,6 +353,7 @@ export default class EntityLoader<
this.privacyPolicy.authorizeReadAsync(
this.viewerContext,
this.queryContext,
this.privacyPolicyEvaluationContext,
uncheckedEntityResult.value,
this.metricsAdapter
)
@@ -341,17 +363,19 @@ export default class EntityLoader<
});
}
private validateFieldValues<N extends keyof Pick<TFields, TSelectedFields>>(
fieldName: N,
fieldValues: readonly TFields[N][]
): void {
private validateAndTransformFieldValues<
N extends keyof Pick<TFields, TSelectedFields>,
T extends TFields[N]
>(fieldName: N, fieldValues: readonly T[]): readonly T[] {
const fieldDefinition = this.entityConfiguration.schema.get(fieldName);
invariant(fieldDefinition, `must have field definition for field = ${fieldName}`);
for (const fieldValue of fieldValues) {
const isInputValid = fieldDefinition.validateInputValue(fieldValue);
if (!isInputValid) {
invariant(fieldDefinition, `must have field definition for field = ${String(fieldName)}`);
return fieldValues.map((fieldValue) => {
const result = fieldDefinition.validateAndTransformInputValue(fieldValue);
if (result.isValid) {
return result.transformedValue;
} else {
throw new EntityInvalidFieldValueError(this.entityClass, fieldName, fieldValue);
}
}
});
}
}
+4 -2
View File
@@ -1,7 +1,7 @@
import { IEntityClass } from './Entity';
import EntityConfiguration from './EntityConfiguration';
import EntityLoader from './EntityLoader';
import EntityPrivacyPolicy from './EntityPrivacyPolicy';
import EntityPrivacyPolicy, { EntityPrivacyPolicyEvaluationContext } from './EntityPrivacyPolicy';
import { EntityQueryContext } from './EntityQueryContext';
import ReadonlyEntity from './ReadonlyEntity';
import ViewerContext from './ViewerContext';
@@ -47,11 +47,13 @@ export default class EntityLoaderFactory<
*/
forLoad(
viewerContext: TViewerContext,
queryContext: EntityQueryContext
queryContext: EntityQueryContext,
privacyPolicyEvaluationContext: EntityPrivacyPolicyEvaluationContext
): EntityLoader<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy, TSelectedFields> {
return new EntityLoader(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
this.entityConfiguration,
this.entityClass,
this.privacyPolicyClass,
+13 -3
View File
@@ -22,9 +22,19 @@ export type EntityValidatorMutationInfo<
previousValue: TEntity;
};
export type EntityMutationTriggerDeleteCascadeInfo<> = {
/**
* Information about a cascading deletion.
*/
export type EntityCascadingDeletionInfo = {
/**
* The entity that is being mutated at this step in the cascaded deletion.
*/
entity: Entity<any, any, any, any>;
cascadingDeleteCause: EntityMutationTriggerDeleteCascadeInfo | null;
/**
* The cascade deletion that caused this mutation.
*/
cascadingDeleteCause: EntityCascadingDeletionInfo | null;
};
export type EntityTriggerMutationInfo<
@@ -43,5 +53,5 @@ export type EntityTriggerMutationInfo<
}
| {
type: EntityMutationType.DELETE;
cascadingDeleteCause: EntityMutationTriggerDeleteCascadeInfo | null;
cascadingDeleteCause: EntityCascadingDeletionInfo | null;
};
+71 -33
View File
@@ -11,14 +11,14 @@ import {
EntityValidatorMutationInfo,
EntityMutationType,
EntityTriggerMutationInfo,
EntityMutationTriggerDeleteCascadeInfo,
EntityCascadingDeletionInfo,
} from './EntityMutationInfo';
import EntityMutationTriggerConfiguration, {
EntityMutationTrigger,
EntityNonTransactionalMutationTrigger,
} from './EntityMutationTriggerConfiguration';
import EntityMutationValidator from './EntityMutationValidator';
import EntityPrivacyPolicy from './EntityPrivacyPolicy';
import EntityPrivacyPolicy, { EntityPrivacyPolicyEvaluationContext } from './EntityPrivacyPolicy';
import { EntityQueryContext, EntityTransactionalQueryContext } from './EntityQueryContext';
import ReadonlyEntity from './ReadonlyEntity';
import ViewerContext from './ViewerContext';
@@ -44,6 +44,7 @@ abstract class BaseMutator<
constructor(
protected readonly viewerContext: TViewerContext,
protected readonly queryContext: EntityQueryContext,
protected readonly privacyPolicyEvaluationContext: EntityPrivacyPolicyEvaluationContext,
protected readonly entityConfiguration: EntityConfiguration<TFields>,
protected readonly entityClass: IEntityClass<
TFields,
@@ -80,16 +81,22 @@ abstract class BaseMutator<
protected readonly metricsAdapter: IEntityMetricsAdapter
) {}
protected validateFields(fields: Partial<TFields>): void {
protected validateAndTransformFields(fields: Partial<TFields>): Partial<TFields> {
const result: Partial<TFields> = {};
for (const fieldName in fields) {
const fieldValue = fields[fieldName];
const fieldDefinition = this.entityConfiguration.schema.get(fieldName);
invariant(fieldDefinition, `must have field definition for field = ${fieldName}`);
const isInputValid = fieldDefinition.validateInputValue(fieldValue);
if (!isInputValid) {
const validationResult = fieldDefinition.validateAndTransformInputValue(fieldValue);
if (validationResult.isValid) {
result[fieldName] = validationResult.transformedValue;
} else {
throw new EntityInvalidFieldValueError(this.entityClass, fieldName, fieldValue);
}
}
return result;
}
protected async executeMutationValidatorsAsync(
@@ -182,7 +189,7 @@ export class CreateMutator<
/**
* Commit the new entity after authorizing against creation privacy rules. Invalidates all caches for
* queries that would return new entity and caches the new entity if not in a transactional query context.
* queries that would return new entity.
* @returns authorized, cached, newly-created entity result, where result error can be UnauthorizedError
*/
async createAsync(): Promise<Result<TEntity>> {
@@ -209,17 +216,20 @@ export class CreateMutator<
private async createInternalAsync(
queryContext: EntityTransactionalQueryContext
): Promise<Result<TEntity>> {
this.validateFields(this.fieldsForEntity);
const transformedAndValidatedFieldsForEntity = this.validateAndTransformFields(
this.fieldsForEntity
);
const temporaryEntityForPrivacyCheck = new this.entityClass(this.viewerContext, {
[this.entityConfiguration.idField]: '00000000-0000-0000-0000-000000000000', // zero UUID
...this.fieldsForEntity,
...transformedAndValidatedFieldsForEntity,
} as unknown as TFields);
const authorizeCreateResult = await asyncResult(
this.privacyPolicy.authorizeCreateAsync(
this.viewerContext,
queryContext,
this.privacyPolicyEvaluationContext,
temporaryEntityForPrivacyCheck,
this.metricsAdapter
)
@@ -247,9 +257,16 @@ export class CreateMutator<
{ type: EntityMutationType.CREATE }
);
const insertResult = await this.databaseAdapter.insertAsync(queryContext, this.fieldsForEntity);
const insertResult = await this.databaseAdapter.insertAsync(
queryContext,
transformedAndValidatedFieldsForEntity
);
const entityLoader = this.entityLoaderFactory.forLoad(this.viewerContext, queryContext);
const entityLoader = this.entityLoaderFactory.forLoad(
this.viewerContext,
queryContext,
this.privacyPolicyEvaluationContext
);
queryContext.appendPostCommitInvalidationCallback(
entityLoader.invalidateFieldsAsync.bind(entityLoader, insertResult)
);
@@ -309,6 +326,7 @@ export class UpdateMutator<
constructor(
viewerContext: TViewerContext,
queryContext: EntityQueryContext,
privacyPolicyEvaluationContext: EntityPrivacyPolicyEvaluationContext,
entityConfiguration: EntityConfiguration<TFields>,
entityClass: IEntityClass<
TFields,
@@ -348,6 +366,7 @@ export class UpdateMutator<
super(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
entityConfiguration,
entityClass,
privacyPolicy,
@@ -374,8 +393,7 @@ export class UpdateMutator<
/**
* Commit the changes to the entity after authorizing against update privacy rules.
* Invalidates all caches for pre-update entity and caches the updated entity if not in a
* transactional query context.
* Invalidates all caches for pre-update entity.
* @returns authorized updated entity result, where result error can be UnauthorizedError
*/
async updateAsync(): Promise<Result<TEntity>> {
@@ -402,13 +420,19 @@ export class UpdateMutator<
private async updateInternalAsync(
queryContext: EntityTransactionalQueryContext
): Promise<Result<TEntity>> {
this.validateFields(this.updatedFields);
const transformedAndValidatedFieldsForEntity = this.validateAndTransformFields(
this.fieldsForEntity
) as TFields;
const entityAboutToBeUpdated = new this.entityClass(this.viewerContext, this.fieldsForEntity);
const entityAboutToBeUpdated = new this.entityClass(
this.viewerContext,
transformedAndValidatedFieldsForEntity
);
const authorizeUpdateResult = await asyncResult(
this.privacyPolicy.authorizeUpdateAsync(
this.viewerContext,
queryContext,
this.privacyPolicyEvaluationContext,
entityAboutToBeUpdated,
this.metricsAdapter
)
@@ -443,7 +467,11 @@ export class UpdateMutator<
this.updatedFields
);
const entityLoader = this.entityLoaderFactory.forLoad(this.viewerContext, queryContext);
const entityLoader = this.entityLoaderFactory.forLoad(
this.viewerContext,
queryContext,
this.privacyPolicyEvaluationContext
);
queryContext.appendPostCommitInvalidationCallback(
entityLoader.invalidateFieldsAsync.bind(
@@ -452,7 +480,7 @@ export class UpdateMutator<
)
);
queryContext.appendPostCommitInvalidationCallback(
entityLoader.invalidateFieldsAsync.bind(entityLoader, this.fieldsForEntity)
entityLoader.invalidateFieldsAsync.bind(entityLoader, transformedAndValidatedFieldsForEntity)
);
const unauthorizedEntityAfterUpdate = new this.entityClass(this.viewerContext, updateResult);
@@ -506,6 +534,7 @@ export class DeleteMutator<
constructor(
viewerContext: TViewerContext,
queryContext: EntityQueryContext,
privacyPolicyEvaluationContext: EntityPrivacyPolicyEvaluationContext,
entityConfiguration: EntityConfiguration<TFields>,
entityClass: IEntityClass<
TFields,
@@ -545,6 +574,7 @@ export class DeleteMutator<
super(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
entityConfiguration,
entityClass,
privacyPolicy,
@@ -578,7 +608,7 @@ export class DeleteMutator<
private async deleteInTransactionAsync(
processedEntityIdentifiersFromTransitiveDeletions: Set<string>,
skipDatabaseDeletion: boolean,
cascadingDeleteCause: EntityMutationTriggerDeleteCascadeInfo | null
cascadingDeleteCause: EntityCascadingDeletionInfo | null
): Promise<Result<void>> {
return await this.queryContext.runInTransactionIfNotInTransactionAsync((innerQueryContext) =>
this.deleteInternalAsync(
@@ -594,12 +624,13 @@ export class DeleteMutator<
queryContext: EntityTransactionalQueryContext,
processedEntityIdentifiersFromTransitiveDeletions: Set<string>,
skipDatabaseDeletion: boolean,
cascadingDeleteCause: EntityMutationTriggerDeleteCascadeInfo | null
cascadingDeleteCause: EntityCascadingDeletionInfo | null
): Promise<Result<void>> {
const authorizeDeleteResult = await asyncResult(
this.privacyPolicy.authorizeDeleteAsync(
this.viewerContext,
queryContext,
{ cascadingDeleteCause },
this.entity,
this.metricsAdapter
)
@@ -636,7 +667,9 @@ export class DeleteMutator<
);
}
const entityLoader = this.entityLoaderFactory.forLoad(this.viewerContext, queryContext);
const entityLoader = this.entityLoaderFactory.forLoad(this.viewerContext, queryContext, {
cascadingDeleteCause,
});
queryContext.appendPostCommitInvalidationCallback(
entityLoader.invalidateFieldsAsync.bind(entityLoader, this.entity.getAllDatabaseFields())
);
@@ -684,7 +717,7 @@ export class DeleteMutator<
entity: TEntity,
queryContext: EntityTransactionalQueryContext,
processedEntityIdentifiers: Set<string>,
cascadingDeleteCause: EntityMutationTriggerDeleteCascadeInfo | null
cascadingDeleteCause: EntityCascadingDeletionInfo | null
): Promise<void> {
// prevent infinite reference cycles by keeping track of entities already processed
if (processedEntityIdentifiers.has(entity.getUniqueIdentifier())) {
@@ -732,10 +765,15 @@ export class DeleteMutator<
.getViewerScopedEntityCompanionForClass(entityClass)
.getMutatorFactory();
const newCascadingDeleteCause = {
entity,
cascadingDeleteCause,
};
let inboundReferenceEntities: readonly ReadonlyEntity<any, any, any, any>[];
if (associatedEntityLookupByField) {
inboundReferenceEntities = await loaderFactory
.forLoad(queryContext)
.forLoad(queryContext, { cascadingDeleteCause: newCascadingDeleteCause })
.enforcing()
.loadManyByFieldEqualingAsync(
fieldName,
@@ -743,7 +781,7 @@ export class DeleteMutator<
);
} else {
inboundReferenceEntities = await loaderFactory
.forLoad(queryContext)
.forLoad(queryContext, { cascadingDeleteCause: newCascadingDeleteCause })
.enforcing()
.loadManyByFieldEqualingAsync(fieldName, entity.getID());
}
@@ -754,14 +792,13 @@ export class DeleteMutator<
await Promise.all(
inboundReferenceEntities.map((inboundReferenceEntity) =>
mutatorFactory
.forDelete(inboundReferenceEntity, queryContext)
.forDelete(inboundReferenceEntity, queryContext, {
cascadingDeleteCause: newCascadingDeleteCause,
})
.deleteInTransactionAsync(
processedEntityIdentifiers,
/* skipDatabaseDeletion */ true, // deletion is handled by DB
{
entity,
cascadingDeleteCause,
}
newCascadingDeleteCause
)
)
);
@@ -771,7 +808,9 @@ export class DeleteMutator<
await Promise.all(
inboundReferenceEntities.map((inboundReferenceEntity) =>
mutatorFactory
.forUpdate(inboundReferenceEntity, queryContext)
.forUpdate(inboundReferenceEntity, queryContext, {
cascadingDeleteCause: newCascadingDeleteCause,
})
.setField(fieldName, null)
.enforceUpdateAsync()
)
@@ -782,14 +821,13 @@ export class DeleteMutator<
await Promise.all(
inboundReferenceEntities.map((inboundReferenceEntity) =>
mutatorFactory
.forDelete(inboundReferenceEntity, queryContext)
.forDelete(inboundReferenceEntity, queryContext, {
cascadingDeleteCause: newCascadingDeleteCause,
})
.deleteInTransactionAsync(
processedEntityIdentifiers,
/* skipDatabaseDeletion */ false,
{
entity,
cascadingDeleteCause,
}
newCascadingDeleteCause
)
)
);
+10 -4
View File
@@ -5,7 +5,7 @@ import EntityLoaderFactory from './EntityLoaderFactory';
import EntityMutationTriggerConfiguration from './EntityMutationTriggerConfiguration';
import EntityMutationValidator from './EntityMutationValidator';
import { CreateMutator, UpdateMutator, DeleteMutator } from './EntityMutator';
import EntityPrivacyPolicy from './EntityPrivacyPolicy';
import EntityPrivacyPolicy, { EntityPrivacyPolicyEvaluationContext } from './EntityPrivacyPolicy';
import { EntityQueryContext } from './EntityQueryContext';
import ViewerContext from './ViewerContext';
import IEntityMetricsAdapter from './metrics/IEntityMetricsAdapter';
@@ -72,11 +72,13 @@ export default class EntityMutatorFactory<
*/
forCreate(
viewerContext: TViewerContext,
queryContext: EntityQueryContext
queryContext: EntityQueryContext,
privacyPolicyEvaluationContext: EntityPrivacyPolicyEvaluationContext
): CreateMutator<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy, TSelectedFields> {
return new CreateMutator(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
this.entityConfiguration,
this.entityClass,
this.privacyPolicy,
@@ -96,11 +98,13 @@ export default class EntityMutatorFactory<
*/
forUpdate(
existingEntity: TEntity,
queryContext: EntityQueryContext
queryContext: EntityQueryContext,
privacyPolicyEvaluationContext: EntityPrivacyPolicyEvaluationContext
): UpdateMutator<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy, TSelectedFields> {
return new UpdateMutator(
existingEntity.getViewerContext(),
queryContext,
privacyPolicyEvaluationContext,
this.entityConfiguration,
this.entityClass,
this.privacyPolicy,
@@ -120,11 +124,13 @@ export default class EntityMutatorFactory<
*/
forDelete(
existingEntity: TEntity,
queryContext: EntityQueryContext
queryContext: EntityQueryContext,
privacyPolicyEvaluationContext: EntityPrivacyPolicyEvaluationContext
): DeleteMutator<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy, TSelectedFields> {
return new DeleteMutator(
existingEntity.getViewerContext(),
queryContext,
privacyPolicyEvaluationContext,
this.entityConfiguration,
this.entityClass,
this.privacyPolicy,
+46 -1
View File
@@ -1,3 +1,4 @@
import { EntityCascadingDeletionInfo } from './EntityMutationInfo';
import { EntityQueryContext } from './EntityQueryContext';
import ReadonlyEntity from './ReadonlyEntity';
import ViewerContext from './ViewerContext';
@@ -7,9 +8,35 @@ import IEntityMetricsAdapter, {
} from './metrics/IEntityMetricsAdapter';
import PrivacyPolicyRule, { RuleEvaluationResult } from './rules/PrivacyPolicyRule';
/**
* Information about the reason this privacy policy is being evaluated.
*/
export type EntityPrivacyPolicyEvaluationContext = {
/**
* When this privacy policy is being evaluated as a result of a cascading deletion, this will be populated
* with information on the cascading delete.
*/
cascadingDeleteCause: EntityCascadingDeletionInfo | null;
};
/**
* Evaluation mode for a {@link EntityPrivacyPolicy}. Useful when transitioning to
* using Entity for privacy.
*/
export enum EntityPrivacyPolicyEvaluationMode {
/**
* Enforce this privacy policy. Throw upon denial.
*/
ENFORCE,
/**
* Do not enforce this privacy policy. Always allow but log when it would have denied.
*/
DRY_RUN,
/**
* Enforce this privacy policy. Throw and log upon denial.
*/
ENFORCE_AND_LOG,
}
@@ -127,6 +154,7 @@ export default abstract class EntityPrivacyPolicy<
async authorizeCreateAsync(
viewerContext: TViewerContext,
queryContext: EntityQueryContext,
evaluationContext: EntityPrivacyPolicyEvaluationContext,
entity: TEntity,
metricsAdapter: IEntityMetricsAdapter
): Promise<TEntity> {
@@ -134,6 +162,7 @@ export default abstract class EntityPrivacyPolicy<
this.createRules,
viewerContext,
queryContext,
evaluationContext,
entity,
EntityAuthorizationAction.CREATE,
metricsAdapter
@@ -151,6 +180,7 @@ export default abstract class EntityPrivacyPolicy<
async authorizeReadAsync(
viewerContext: TViewerContext,
queryContext: EntityQueryContext,
evaluationContext: EntityPrivacyPolicyEvaluationContext,
entity: TEntity,
metricsAdapter: IEntityMetricsAdapter
): Promise<TEntity> {
@@ -158,6 +188,7 @@ export default abstract class EntityPrivacyPolicy<
this.readRules,
viewerContext,
queryContext,
evaluationContext,
entity,
EntityAuthorizationAction.READ,
metricsAdapter
@@ -175,6 +206,7 @@ export default abstract class EntityPrivacyPolicy<
async authorizeUpdateAsync(
viewerContext: TViewerContext,
queryContext: EntityQueryContext,
evaluationContext: EntityPrivacyPolicyEvaluationContext,
entity: TEntity,
metricsAdapter: IEntityMetricsAdapter
): Promise<TEntity> {
@@ -182,6 +214,7 @@ export default abstract class EntityPrivacyPolicy<
this.updateRules,
viewerContext,
queryContext,
evaluationContext,
entity,
EntityAuthorizationAction.UPDATE,
metricsAdapter
@@ -199,6 +232,7 @@ export default abstract class EntityPrivacyPolicy<
async authorizeDeleteAsync(
viewerContext: TViewerContext,
queryContext: EntityQueryContext,
evaluationContext: EntityPrivacyPolicyEvaluationContext,
entity: TEntity,
metricsAdapter: IEntityMetricsAdapter
): Promise<TEntity> {
@@ -206,6 +240,7 @@ export default abstract class EntityPrivacyPolicy<
this.deleteRules,
viewerContext,
queryContext,
evaluationContext,
entity,
EntityAuthorizationAction.DELETE,
metricsAdapter
@@ -216,6 +251,7 @@ export default abstract class EntityPrivacyPolicy<
ruleset: readonly PrivacyPolicyRule<TFields, TID, TViewerContext, TEntity, TSelectedFields>[],
viewerContext: TViewerContext,
queryContext: EntityQueryContext,
evaluationContext: EntityPrivacyPolicyEvaluationContext,
entity: TEntity,
action: EntityAuthorizationAction,
metricsAdapter: IEntityMetricsAdapter
@@ -228,6 +264,7 @@ export default abstract class EntityPrivacyPolicy<
ruleset,
viewerContext,
queryContext,
evaluationContext,
entity,
action
);
@@ -256,6 +293,7 @@ export default abstract class EntityPrivacyPolicy<
ruleset,
viewerContext,
queryContext,
evaluationContext,
entity,
action
);
@@ -285,6 +323,7 @@ export default abstract class EntityPrivacyPolicy<
ruleset,
viewerContext,
queryContext,
evaluationContext,
entity,
action
);
@@ -315,12 +354,18 @@ export default abstract class EntityPrivacyPolicy<
ruleset: readonly PrivacyPolicyRule<TFields, TID, TViewerContext, TEntity, TSelectedFields>[],
viewerContext: TViewerContext,
queryContext: EntityQueryContext,
evaluationContext: EntityPrivacyPolicyEvaluationContext,
entity: TEntity,
action: EntityAuthorizationAction
): Promise<TEntity> {
for (let i = 0; i < ruleset.length; i++) {
const rule = ruleset[i]!;
const ruleEvaluationResult = await rule.evaluateAsync(viewerContext, queryContext, entity);
const ruleEvaluationResult = await rule.evaluateAsync(
viewerContext,
queryContext,
evaluationContext,
entity
);
switch (ruleEvaluationResult) {
case RuleEvaluationResult.DENY:
throw new EntityNotAuthorizedError<
+11
View File
@@ -29,6 +29,12 @@ export abstract class EntityQueryContext {
): Promise<T>;
}
/**
* Entity framework representation of a non-transactional query execution unit.
* When supplied to {@link EntityMutator} and {@link EntityLoader} methods, they will be
* run independently of any running transaction (though mutations start their own
* independent transactions internally when not being run in a transaction).
*/
export class EntityNonTransactionalQueryContext extends EntityQueryContext {
constructor(
queryInterface: any,
@@ -48,6 +54,11 @@ export class EntityNonTransactionalQueryContext extends EntityQueryContext {
}
}
/**
* Entity framework representation of a transactional query execution unit. When supplied
* to {@link EntityMutator} and {@link EntityLoader} methods, those methods and their
* dependent triggers and validators will run within the transaction.
*/
export class EntityTransactionalQueryContext extends EntityQueryContext {
private readonly postCommitInvalidationCallbacks: PostCommitCallback[] = [];
private readonly postCommitCallbacks: PostCommitCallback[] = [];
@@ -1,9 +1,9 @@
import invariant from 'invariant';
import { ISecondaryEntityCache } from './EntitySecondaryCacheLoader';
import PartsCacher, { Parts, PartsKey } from './PartsCacher';
import IEntityGenericCacher from './IEntityGenericCacher';
import { CacheStatus } from './internal/ReadThroughEntityCache';
import { filterMap } from './utils/collections/maps';
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
@@ -14,8 +14,8 @@ export default abstract class GenericSecondaryEntityCache<TFields, TLoadParams>
implements ISecondaryEntityCache<TFields, TLoadParams>
{
constructor(
protected readonly partsCacher: PartsCacher<TFields>,
protected readonly getParts: (params: Readonly<TLoadParams>) => Parts
protected readonly cacher: IEntityGenericCacher<TFields>,
protected readonly constructCacheKey: (params: Readonly<TLoadParams>) => string
) {}
public async loadManyThroughAsync(
@@ -24,22 +24,17 @@ export default abstract class GenericSecondaryEntityCache<TFields, TLoadParams>
fetcherLoadParamsArray: readonly Readonly<TLoadParams>[]
) => Promise<ReadonlyMap<Readonly<TLoadParams>, Readonly<TFields> | null>>
): Promise<ReadonlyMap<Readonly<TLoadParams>, Readonly<TFields> | null>> {
const partsList = loadParamsArray.map(this.getParts);
const partsKeysToLoadParamsMap = new Map(
loadParamsArray.map((loadParams) => {
const parts = this.getParts(loadParams);
return [PartsCacher.getPartsKey(...parts), loadParams];
})
);
const cacheKeys = loadParamsArray.map(this.constructCacheKey);
const cacheKeyToLoadParamsMap = zipToMap(cacheKeys, loadParamsArray);
const cacheLoadResults = await this.partsCacher.loadManyAsync(partsList);
const cacheLoadResults = await this.cacher.loadManyAsync(cacheKeys);
invariant(
cacheLoadResults.size === loadParamsArray.length,
`${this.constructor.name} loadMany should return a result for each key`
);
const partsKeysToFetch = Array.from(
const cacheKeysToFetch = Array.from(
filterMap(
cacheLoadResults,
(cacheLoadResult) => cacheLoadResult.status === CacheStatus.MISS
@@ -48,18 +43,18 @@ export default abstract class GenericSecondaryEntityCache<TFields, TLoadParams>
// put cache hits in result map
const results: Map<Readonly<TLoadParams>, Readonly<TFields> | null> = new Map();
cacheLoadResults.forEach((cacheLoadResult, partsKey) => {
cacheLoadResults.forEach((cacheLoadResult, cacheKey) => {
if (cacheLoadResult.status === CacheStatus.HIT) {
const loadParams = partsKeysToLoadParamsMap.get(partsKey);
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 (partsKeysToFetch.length > 0) {
const loadParamsToFetch = partsKeysToFetch.map((partKey) => {
const loadParams = partsKeysToLoadParamsMap.get(partKey);
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;
});
@@ -74,20 +69,17 @@ export default abstract class GenericSecondaryEntityCache<TFields, TLoadParams>
results.set(fetchMiss, null);
}
const objectsToCache: Map<PartsKey, Readonly<TFields>> = new Map();
const objectsToCache: Map<string, Readonly<TFields>> = new Map();
for (const [loadParams, object] of fetchResults.entries()) {
if (object) {
const parts = this.getParts(loadParams);
objectsToCache.set(PartsCacher.getPartsKey(...parts), object);
objectsToCache.set(this.constructCacheKey(loadParams), object);
results.set(loadParams, object);
}
}
await Promise.all([
this.partsCacher.cacheManyAsync(objectsToCache),
this.partsCacher.cacheDBMissesAsync(
fetchMisses.map((loadParams) => this.getParts(loadParams))
),
this.cacher.cacheManyAsync(objectsToCache),
this.cacher.cacheDBMissesAsync(fetchMisses.map(this.constructCacheKey)),
]);
}
@@ -100,7 +92,7 @@ export default abstract class GenericSecondaryEntityCache<TFields, TLoadParams>
* @param loadParamsArray - load params to invalidate
*/
public invalidateManyAsync(loadParamsArray: readonly Readonly<TLoadParams>[]): Promise<void> {
const partsList = loadParamsArray.map(this.getParts);
return this.partsCacher.invalidateManyAsync(partsList);
const cacheKeys = loadParamsArray.map(this.constructCacheKey);
return this.cacher.invalidateManyAsync(cacheKeys);
}
}
@@ -1,41 +0,0 @@
import { RedisCacheAdapterContext } from '@expo/entity-cache-adapter-redis';
import invariant from 'invariant';
import ComposedPartsCacher from './ComposedPartsCacher';
import EntityConfiguration from './EntityConfiguration';
import PartsCacheAdapter from './PartsCacheAdapter';
import PartsCacher, { Parts } from './PartsCacher';
/**
* TODO: put this in www
*/
export default class LocalMemoryAndRedisPartsCacheAdapter<
TFields
> extends PartsCacheAdapter<TFields> {
constructor(
entityConfiguration: EntityConfiguration<TFields>,
localMemoryPartsCacher: PartsCacher<TFields>,
redisPartsCacher: PartsCacher<TFields>,
private readonly redisCacheAdapterContext: RedisCacheAdapterContext
) {
super(
entityConfiguration,
new ComposedPartsCacher<TFields>([localMemoryPartsCacher, redisPartsCacher])
);
}
/**
* TODO: this seems wrong - look at moving the redis specific bits into the makeCacheSpecificKey of the SimplePartsCacher
*/
getParts<N extends keyof TFields>(fieldName: N, fieldValue: NonNullable<TFields[N]>): Parts {
const columnName = this.entityConfiguration.entityToDBFieldsKeyMapping.get(fieldName);
invariant(columnName, `database field mapping missing for ${fieldName}`);
return [
this.redisCacheAdapterContext.cacheKeyPrefix,
this.entityConfiguration.tableName,
`v2.${this.entityConfiguration.cacheKeyVersion}`,
columnName,
String(fieldValue),
];
}
}
@@ -1,19 +0,0 @@
import ComposedPartsCacher from './ComposedPartsCacher';
import GenericSecondaryEntityCache from './GenericSecondaryEntityCache';
import PartsCacher, { Parts } from './PartsCacher';
/**
* TODO: put this in www
*/
export default class LocalMemoryAndRedisSecondaryEntityCache<
TFields,
TLoadParams
> extends GenericSecondaryEntityCache<TFields, TLoadParams> {
constructor(
localMemoryPartsCacher: PartsCacher<TFields>,
redisPartsCacher: PartsCacher<TFields>,
getParts: (params: Readonly<TLoadParams>) => Parts
) {
super(new ComposedPartsCacher([localMemoryPartsCacher, redisPartsCacher]), getParts);
}
}
-74
View File
@@ -1,74 +0,0 @@
import invariant from 'invariant';
import EntityCacheAdapter from './EntityCacheAdapter';
import EntityConfiguration from './EntityConfiguration';
import PartsCacher, { Parts } from './PartsCacher';
import { CacheLoadResult } from './internal/ReadThroughEntityCache';
import { mapKeys } from './utils/collections/maps';
export default abstract class PartsCacheAdapter<TFields> extends EntityCacheAdapter<TFields> {
constructor(
entityConfiguration: EntityConfiguration<TFields>,
private readonly partsCacher: PartsCacher<TFields>
) {
super(entityConfiguration);
}
public async loadManyAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult<TFields>>> {
const partsList = fieldValues.map((fieldValue) => this.getParts(fieldName, fieldValue));
const partsKeysToFieldValueMapping = new Map(
fieldValues.map((fieldValue) => {
const parts = this.getParts(fieldName, fieldValue);
return [PartsCacher.getPartsKey(...parts), fieldValue];
})
);
const cacheResults = await this.partsCacher.loadManyAsync(partsList);
return mapKeys(cacheResults, (partsKey) => {
const fieldValue = partsKeysToFieldValueMapping.get(partsKey);
invariant(
fieldValue !== undefined,
'Unspecified cache key %s returned from Redis parts cacher',
partsKey
);
return fieldValue;
});
}
public async cacheManyAsync<N extends keyof TFields>(
fieldName: N,
objectMap: ReadonlyMap<NonNullable<TFields[N]>, Readonly<TFields>>
): Promise<void> {
const partsKeyToObject = mapKeys(objectMap, (fieldValue) => {
const parts = this.getParts(fieldName, fieldValue);
return PartsCacher.getPartsKey(...parts);
});
await this.partsCacher.cacheManyAsync(partsKeyToObject);
}
public async cacheDBMissesAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<void> {
await this.partsCacher.cacheDBMissesAsync(
fieldValues.map((fieldValue) => this.getParts(fieldName, fieldValue))
);
}
public async invalidateManyAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<void> {
await this.partsCacher.invalidateManyAsync(
fieldValues.map((fieldValue) => this.getParts(fieldName, fieldValue))
);
}
abstract getParts<N extends keyof TFields>(
fieldName: N,
fieldValue: NonNullable<TFields[N]>
): Parts;
}
-25
View File
@@ -1,25 +0,0 @@
import { CacheLoadResult } from '@expo/entity/src/internal/ReadThroughEntityCache';
export type Parts = string[];
export type PartsKey = string;
export default abstract class PartsCacher<TFields> {
public abstract loadManyAsync(
partsList: readonly Parts[]
): Promise<ReadonlyMap<PartsKey, CacheLoadResult<TFields>>>;
public abstract cacheManyAsync(
objectMap: ReadonlyMap<PartsKey, Readonly<TFields>>
): Promise<void>;
public abstract cacheDBMissesAsync(partsList: readonly Parts[]): Promise<void>;
public abstract invalidateManyAsync(partsList: readonly Parts[]): Promise<void>;
public static getPartsKey(...parts: Parts): PartsKey {
return JSON.stringify(parts);
}
public static getPartsFromKey(partKey: PartsKey): Parts {
return JSON.parse(partKey);
}
}
+1 -1
View File
@@ -156,6 +156,6 @@ export default abstract class ReadonlyEntity<
return viewerContext
.getViewerScopedEntityCompanionForClass(this)
.getLoaderFactory()
.forLoad(queryContext);
.forLoad(queryContext, { cascadingDeleteCause: null });
}
}
-59
View File
@@ -1,59 +0,0 @@
import IEntityGenericCacher from '@expo/entity/src/IEntityGenericCacher';
import { CacheLoadResult } from '@expo/entity/src/internal/ReadThroughEntityCache';
import { mapKeys } from '@expo/entity/src/utils/collections/maps';
import invariant from 'invariant';
import PartsCacher, { Parts, PartsKey } from './PartsCacher';
export default class SimplePartsCacher<TFields> extends PartsCacher<TFields> {
constructor(
private readonly cacher: IEntityGenericCacher<TFields>,
private readonly makeCacheSpecificKey: (...parts: Parts) => string = PartsCacher.getPartsKey
) {
super();
}
public async loadManyAsync(
partsList: readonly Parts[]
): Promise<ReadonlyMap<PartsKey, CacheLoadResult<TFields>>> {
const cacheSpecificKeyToPartKeyMapping = new Map(
partsList.map((parts) => [
this.makeCacheSpecificKey(...parts),
PartsCacher.getPartsKey(...parts),
])
);
const cacheResults = await this.cacher.loadManyAsync(
Array.from(cacheSpecificKeyToPartKeyMapping.keys())
);
return mapKeys(cacheResults, (cacheKey) => {
const partKey = cacheSpecificKeyToPartKeyMapping.get(cacheKey);
invariant(
partKey !== undefined,
'Unspecified cache key %s returned from generic local memory cacher',
cacheKey
);
return partKey;
});
}
public async cacheManyAsync(objectMap: ReadonlyMap<PartsKey, Readonly<TFields>>): Promise<void> {
await this.cacher.cacheManyAsync(
mapKeys(objectMap, (partKey) =>
this.makeCacheSpecificKey(...PartsCacher.getPartsFromKey(partKey))
)
);
}
public async cacheDBMissesAsync(partsList: readonly Parts[]): Promise<void> {
await this.cacher.cacheDBMissesAsync(
partsList.map((parts) => this.makeCacheSpecificKey(...parts))
);
}
public async invalidateManyAsync(partsList: readonly Parts[]): Promise<void> {
await this.cacher.invalidateManyAsync(
partsList.map((parts) => this.makeCacheSpecificKey(...parts))
);
}
}
@@ -1,6 +1,6 @@
import EntityLoader from './EntityLoader';
import EntityLoaderFactory from './EntityLoaderFactory';
import EntityPrivacyPolicy from './EntityPrivacyPolicy';
import EntityPrivacyPolicy, { EntityPrivacyPolicyEvaluationContext } from './EntityPrivacyPolicy';
import { EntityQueryContext } from './EntityQueryContext';
import ReadonlyEntity from './ReadonlyEntity';
import ViewerContext from './ViewerContext';
@@ -35,8 +35,13 @@ export default class ViewerScopedEntityLoaderFactory<
) {}
forLoad(
queryContext: EntityQueryContext
queryContext: EntityQueryContext,
privacyPolicyEvaluationContext: EntityPrivacyPolicyEvaluationContext
): EntityLoader<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy, TSelectedFields> {
return this.entityLoaderFactory.forLoad(this.viewerContext, queryContext);
return this.entityLoaderFactory.forLoad(
this.viewerContext,
queryContext,
privacyPolicyEvaluationContext
);
}
}
@@ -1,6 +1,6 @@
import { CreateMutator, UpdateMutator, DeleteMutator } from './EntityMutator';
import EntityMutatorFactory from './EntityMutatorFactory';
import EntityPrivacyPolicy from './EntityPrivacyPolicy';
import EntityPrivacyPolicy, { EntityPrivacyPolicyEvaluationContext } from './EntityPrivacyPolicy';
import { EntityQueryContext } from './EntityQueryContext';
import ReadonlyEntity from './ReadonlyEntity';
import ViewerContext from './ViewerContext';
@@ -35,22 +35,37 @@ export default class ViewerScopedEntityMutatorFactory<
) {}
forCreate(
queryContext: EntityQueryContext
queryContext: EntityQueryContext,
privacyPolicyEvaluationContext: EntityPrivacyPolicyEvaluationContext
): CreateMutator<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy, TSelectedFields> {
return this.entityMutatorFactory.forCreate(this.viewerContext, queryContext);
return this.entityMutatorFactory.forCreate(
this.viewerContext,
queryContext,
privacyPolicyEvaluationContext
);
}
forUpdate(
existingEntity: TEntity,
queryContext: EntityQueryContext
queryContext: EntityQueryContext,
privacyPolicyEvaluationContext: EntityPrivacyPolicyEvaluationContext
): UpdateMutator<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy, TSelectedFields> {
return this.entityMutatorFactory.forUpdate(existingEntity, queryContext);
return this.entityMutatorFactory.forUpdate(
existingEntity,
queryContext,
privacyPolicyEvaluationContext
);
}
forDelete(
existingEntity: TEntity,
queryContext: EntityQueryContext
queryContext: EntityQueryContext,
privacyPolicyEvaluationContext: EntityPrivacyPolicyEvaluationContext
): DeleteMutator<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy, TSelectedFields> {
return this.entityMutatorFactory.forDelete(existingEntity, queryContext);
return this.entityMutatorFactory.forDelete(
existingEntity,
queryContext,
privacyPolicyEvaluationContext
);
}
}
@@ -0,0 +1,280 @@
import invariant from 'invariant';
import ComposedEntityCacheAdapter from '../ComposedEntityCacheAdapter';
import EntityCacheAdapter from '../EntityCacheAdapter';
import EntityConfiguration from '../EntityConfiguration';
import { UUIDField } from '../EntityFields';
import { CacheLoadResult, CacheStatus } from '../internal/ReadThroughEntityCache';
type BlahFields = {
id: string;
};
const entityConfiguration = new EntityConfiguration<BlahFields>({
idField: 'id',
tableName: 'blah',
schema: {
id: new UUIDField({ columnName: 'id', cache: true }),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'local-memory-and-redis',
});
export const DOES_NOT_EXIST_LOCAL_MEMORY_CACHE = Symbol('doesNotExist');
type LocalMemoryCacheValue<TFields> = Readonly<TFields> | typeof DOES_NOT_EXIST_LOCAL_MEMORY_CACHE;
class TestLocalCacheAdapter<TFields> extends EntityCacheAdapter<TFields> {
constructor(
entityConfiguration: EntityConfiguration<TFields>,
private readonly cache: Map<string, LocalMemoryCacheValue<TFields>>
) {
super(entityConfiguration);
}
public async loadManyAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult<TFields>>> {
const localMemoryCacheKeyToFieldValueMapping = new Map(
fieldValues.map((fieldValue) => [this.makeCacheKey(fieldName, fieldValue), fieldValue])
);
const cacheResults = new Map<NonNullable<TFields[N]>, CacheLoadResult<TFields>>();
for (const [cacheKey, fieldValue] of localMemoryCacheKeyToFieldValueMapping) {
const cacheResult = this.cache.get(cacheKey);
if (cacheResult === DOES_NOT_EXIST_LOCAL_MEMORY_CACHE) {
cacheResults.set(fieldValue, {
status: CacheStatus.NEGATIVE,
});
} else if (cacheResult) {
cacheResults.set(fieldValue, {
status: CacheStatus.HIT,
item: cacheResult,
});
} else {
cacheResults.set(fieldValue, {
status: CacheStatus.MISS,
});
}
}
return cacheResults;
}
public async cacheManyAsync<N extends keyof TFields>(
fieldName: N,
objectMap: ReadonlyMap<NonNullable<TFields[N]>, Readonly<TFields>>
): Promise<void> {
for (const [fieldValue, item] of objectMap) {
const cacheKey = this.makeCacheKey(fieldName, fieldValue);
this.cache.set(cacheKey, item);
}
}
public async cacheDBMissesAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<void> {
for (const fieldValue of fieldValues) {
const cacheKey = this.makeCacheKey(fieldName, fieldValue);
this.cache.set(cacheKey, DOES_NOT_EXIST_LOCAL_MEMORY_CACHE);
}
}
public async invalidateManyAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<void> {
for (const fieldValue of fieldValues) {
const cacheKey = this.makeCacheKey(fieldName, fieldValue);
this.cache.delete(cacheKey);
}
}
private makeCacheKey<N extends keyof TFields>(
fieldName: N,
fieldValue: NonNullable<TFields[N]>
): string {
const columnName = this.entityConfiguration.entityToDBFieldsKeyMapping.get(fieldName);
invariant(columnName, `database field mapping missing for ${String(fieldName)}`);
const parts = [
this.entityConfiguration.tableName,
`${this.entityConfiguration.cacheKeyVersion}`,
columnName,
String(fieldValue),
];
const delimiter = ':';
const escapedParts = parts.map((part) =>
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`)
);
return escapedParts.join(delimiter);
}
}
function makeTestCacheAdapters(): {
primaryCache: Map<string, LocalMemoryCacheValue<BlahFields>>;
primaryCacheAdapter: TestLocalCacheAdapter<BlahFields>;
fallbackCache: Map<string, LocalMemoryCacheValue<BlahFields>>;
fallbackCacheAdapter: TestLocalCacheAdapter<BlahFields>;
cacheAdapter: ComposedEntityCacheAdapter<BlahFields>;
} {
const primaryCache = new Map();
const primaryCacheAdapter = new TestLocalCacheAdapter(entityConfiguration, primaryCache);
const fallbackCache = new Map();
const fallbackCacheAdapter = new TestLocalCacheAdapter(entityConfiguration, fallbackCache);
const cacheAdapter = new ComposedEntityCacheAdapter(entityConfiguration, [
primaryCacheAdapter,
fallbackCacheAdapter,
]);
return {
primaryCache,
primaryCacheAdapter,
fallbackCache,
fallbackCacheAdapter,
cacheAdapter,
};
}
describe(ComposedEntityCacheAdapter, () => {
describe('loadManyAsync', () => {
it('returns primary results when populated', async () => {
const { primaryCacheAdapter, cacheAdapter } = makeTestCacheAdapters();
const cacheHits = new Map<string, Readonly<BlahFields>>([['test-id-1', { id: 'test-id-1' }]]);
await primaryCacheAdapter.cacheManyAsync('id', cacheHits);
await primaryCacheAdapter.cacheDBMissesAsync('id', ['test-id-2']);
const results = await cacheAdapter.loadManyAsync('id', [
'test-id-1',
'test-id-2',
'test-id-3',
]);
expect(results.get('test-id-1')).toMatchObject({
status: CacheStatus.HIT,
item: { id: 'test-id-1' },
});
expect(results.get('test-id-2')).toMatchObject({ status: CacheStatus.NEGATIVE });
expect(results.get('test-id-3')).toMatchObject({ status: CacheStatus.MISS });
expect(results.size).toBe(3);
});
it('returns fallback adapter results primary is empty', async () => {
const { primaryCacheAdapter, cacheAdapter } = makeTestCacheAdapters();
const cacheHits = new Map<string, Readonly<BlahFields>>([['test-id-1', { id: 'test-id-1' }]]);
await primaryCacheAdapter.cacheManyAsync('id', cacheHits);
await primaryCacheAdapter.cacheDBMissesAsync('id', ['test-id-2']);
const results = await cacheAdapter.loadManyAsync('id', [
'test-id-1',
'test-id-2',
'test-id-3',
]);
expect(results.get('test-id-1')).toMatchObject({
status: CacheStatus.HIT,
item: { id: 'test-id-1' },
});
expect(results.get('test-id-2')).toMatchObject({ status: CacheStatus.NEGATIVE });
expect(results.get('test-id-3')).toMatchObject({ status: CacheStatus.MISS });
expect(results.size).toBe(3);
});
it('returns empty map when passed empty array of fieldValues', async () => {
const { cacheAdapter } = makeTestCacheAdapters();
const results = await cacheAdapter.loadManyAsync('id', []);
expect(results).toEqual(new Map());
});
it('handles 0 cache adapter compose case', async () => {
const cacheAdapter = new ComposedEntityCacheAdapter(entityConfiguration, []);
const results = await cacheAdapter.loadManyAsync('id', []);
expect(results).toEqual(new Map());
});
});
describe('cacheManyAsync', () => {
it('correctly caches all objects', async () => {
const {
primaryCache,
primaryCacheAdapter,
fallbackCache,
fallbackCacheAdapter,
cacheAdapter,
} = makeTestCacheAdapters();
await cacheAdapter.cacheManyAsync('id', new Map([['test-id-1', { id: 'test-id-1' }]]));
const primaryLocalMemoryCacheKey = primaryCacheAdapter['makeCacheKey']('id', 'test-id-1');
expect(primaryCache.get(primaryLocalMemoryCacheKey)).toMatchObject({
id: 'test-id-1',
});
const fallbackLocalMemoryCacheKey = fallbackCacheAdapter['makeCacheKey']('id', 'test-id-1');
expect(fallbackCache.get(fallbackLocalMemoryCacheKey)).toMatchObject({
id: 'test-id-1',
});
});
});
describe('cacheDBMissesAsync', () => {
it('correctly caches misses', async () => {
const {
primaryCache,
primaryCacheAdapter,
fallbackCache,
fallbackCacheAdapter,
cacheAdapter,
} = makeTestCacheAdapters();
await cacheAdapter.cacheDBMissesAsync('id', ['test-id-1']);
const primaryLocalMemoryCacheKey = primaryCacheAdapter['makeCacheKey']('id', 'test-id-1');
expect(primaryCache.get(primaryLocalMemoryCacheKey)).toBe(DOES_NOT_EXIST_LOCAL_MEMORY_CACHE);
const fallbackLocalMemoryCacheKey = fallbackCacheAdapter['makeCacheKey']('id', 'test-id-1');
expect(fallbackCache.get(fallbackLocalMemoryCacheKey)).toBe(
DOES_NOT_EXIST_LOCAL_MEMORY_CACHE
);
});
});
describe('invalidateManyAsync', () => {
it('invalidates correctly', async () => {
const {
primaryCache,
primaryCacheAdapter,
fallbackCache,
fallbackCacheAdapter,
cacheAdapter,
} = makeTestCacheAdapters();
const cacheHits = new Map<string, Readonly<BlahFields>>([['test-id-1', { id: 'test-id-1' }]]);
await primaryCacheAdapter.cacheManyAsync('id', cacheHits);
await primaryCacheAdapter.cacheDBMissesAsync('id', ['test-id-2']);
await fallbackCacheAdapter.cacheManyAsync('id', cacheHits);
await fallbackCacheAdapter.cacheDBMissesAsync('id', ['test-id-2']);
await cacheAdapter.invalidateManyAsync('id', ['test-id-1', 'test-id-2']);
const primaryLocalMemoryCacheKey1 = primaryCacheAdapter['makeCacheKey']('id', 'test-id-1');
expect(primaryCache.get(primaryLocalMemoryCacheKey1)).toBe(undefined);
const primaryLocalMemoryCacheKey2 = primaryCacheAdapter['makeCacheKey']('id', 'test-id-1');
expect(primaryCache.get(primaryLocalMemoryCacheKey2)).toBe(undefined);
const fallbackLocalMemoryCacheKey1 = fallbackCacheAdapter['makeCacheKey']('id', 'test-id-1');
expect(fallbackCache.get(fallbackLocalMemoryCacheKey1)).toBe(undefined);
const fallbackLocalMemoryCacheKey2 = fallbackCacheAdapter['makeCacheKey']('id', 'test-id-1');
expect(fallbackCache.get(fallbackLocalMemoryCacheKey2)).toBe(undefined);
});
it('returns when passed empty array of fieldValues', async () => {
const { cacheAdapter } = makeTestCacheAdapters();
await cacheAdapter.invalidateManyAsync('id', []);
});
});
});
@@ -0,0 +1,101 @@
import invariant from 'invariant';
import nullthrows from 'nullthrows';
import ComposedSecondaryEntityCache from '../ComposedSecondaryEntityCache';
import { ISecondaryEntityCache } from '../EntitySecondaryCacheLoader';
type TestFields = { id: string };
type TestLoadParams = { lp: string };
class TestEntitySecondaryCache implements ISecondaryEntityCache<TestFields, TestLoadParams> {
constructor(
private readonly prefilledResults: Map<Readonly<TestLoadParams>, Readonly<TestFields>>
) {}
async loadManyThroughAsync(
loadParamsArray: readonly Readonly<TestLoadParams>[],
fetcher: (
fetcherLoadParamsArray: readonly Readonly<TestLoadParams>[]
) => Promise<ReadonlyMap<Readonly<TestLoadParams>, Readonly<TestFields> | null>>
): Promise<ReadonlyMap<Readonly<TestLoadParams>, Readonly<TestFields> | null>> {
// this does an unusual method of calling fetcher, but there's no constraint that says fetcher can only be called once
// so this tests that
const retMap = new Map<Readonly<TestLoadParams>, Readonly<TestFields> | null>();
for (const loadParams of loadParamsArray) {
if (this.prefilledResults.has(loadParams)) {
retMap.set(loadParams, nullthrows(this.prefilledResults.get(loadParams)));
} else {
const fetcherResult = await fetcher([loadParams]);
const toSet = fetcherResult.get(loadParams);
invariant(toSet !== undefined, 'should be set');
retMap.set(loadParams, toSet);
}
}
return retMap;
}
async invalidateManyAsync(loadParamsArray: readonly Readonly<TestLoadParams>[]): Promise<void> {
for (const loadParams of loadParamsArray) {
this.prefilledResults.delete(loadParams);
}
}
}
describe(ComposedSecondaryEntityCache, () => {
it('composes correctly', async () => {
// TODO(wschurman): investigate whether we can use immutable or something to do better object equality for the map keys
const lp1 = { lp: '1' };
const lp2 = { lp: '2' };
const lp3 = { lp: '3' };
const primarySecondaryEntityCache = new TestEntitySecondaryCache(
new Map([[lp1, { id: 'primary-1' }]])
);
const fallbackSecondaryEntityCache = new TestEntitySecondaryCache(
new Map([[lp2, { id: 'fallback-2' }]])
);
const composedSecondaryEntityCache = new ComposedSecondaryEntityCache([
primarySecondaryEntityCache,
fallbackSecondaryEntityCache,
]);
const results = await composedSecondaryEntityCache.loadManyThroughAsync(
[lp1, lp2, lp3],
async (fetcherLoadParamsArray) =>
new Map(fetcherLoadParamsArray.map((flp) => [flp, { id: `db-fetched-${flp.lp}` }]))
);
expect(results.get(lp1)).toEqual({ id: 'primary-1' });
expect(results.get(lp2)).toEqual({ id: 'fallback-2' });
expect(results.get(lp3)).toEqual({ id: 'db-fetched-3' });
await composedSecondaryEntityCache.invalidateManyAsync([lp1, lp2, lp3]);
const resultsAfterInvalidate = await composedSecondaryEntityCache.loadManyThroughAsync(
[lp1, lp2, lp3],
async (fetcherLoadParamsArray) =>
new Map(fetcherLoadParamsArray.map((flp) => [flp, { id: `db-fetched-${flp.lp}` }]))
);
expect(resultsAfterInvalidate.get(lp1)).toEqual({ id: 'db-fetched-1' });
expect(resultsAfterInvalidate.get(lp2)).toEqual({ id: 'db-fetched-2' });
expect(resultsAfterInvalidate.get(lp3)).toEqual({ id: 'db-fetched-3' });
});
it('handles n=0 compose case', async () => {
const lp1 = { lp: '1' };
const composedSecondaryEntityCache = new ComposedSecondaryEntityCache<
TestLoadParams,
TestFields
>([]);
const results = await composedSecondaryEntityCache.loadManyThroughAsync(
[lp1],
async (fetcherLoadParamsArray) =>
new Map(fetcherLoadParamsArray.map((flp) => [flp, { id: `db-fetched-${flp.lp}` }]))
);
expect(results.get(lp1)).toEqual({ id: 'db-fetched-1' });
});
});
@@ -288,7 +288,7 @@ describe(EnforcingEntityLoader, () => {
'invalidateFieldsAsync',
'invalidateEntityAsync',
'tryConstructEntities',
'validateFieldValues',
'validateAndTransformFieldValues',
'constructAndAuthorizeEntitiesAsync',
];
expect(loaderProperties).toEqual(expect.arrayContaining(knownLoaderOnlyDifferences));
@@ -5,7 +5,7 @@ import Entity from '../Entity';
import EntityCompanionProvider, { EntityCompanionDefinition } from '../EntityCompanionProvider';
import EntityConfiguration from '../EntityConfiguration';
import { UUIDField } from '../EntityFields';
import EntityPrivacyPolicy from '../EntityPrivacyPolicy';
import EntityPrivacyPolicy, { EntityPrivacyPolicyEvaluationContext } from '../EntityPrivacyPolicy';
import { EntityQueryContext } from '../EntityQueryContext';
import ViewerContext from '../ViewerContext';
import { enforceResultsAsync } from '../entityUtils';
@@ -51,6 +51,7 @@ class DenyIfNotOwnerPrivacyPolicyRule extends PrivacyPolicyRule<
async evaluateAsync(
viewerContext: TestUserViewerContext,
_queryContext: EntityQueryContext,
_evaluationContext: EntityPrivacyPolicyEvaluationContext,
entity: BlahEntity
): Promise<RuleEvaluationResult> {
if (viewerContext.getUserID() === entity.getField('ownerID')) {
+286 -45
View File
@@ -5,10 +5,13 @@ 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 EntityPrivacyPolicy, {
EntityPrivacyPolicyEvaluationContext,
EntityAuthorizationAction,
} from '../EntityPrivacyPolicy';
import { EntityTransactionalQueryContext, EntityQueryContext } from '../EntityQueryContext';
import { CacheStatus } from '../internal/ReadThroughEntityCache';
import AlwaysAllowPrivacyPolicyRule from '../rules/AlwaysAllowPrivacyPolicyRule';
import PrivacyPolicyRule, { RuleEvaluationResult } from '../rules/PrivacyPolicyRule';
import TestViewerContext from '../testfixtures/TestViewerContext';
import { InMemoryFullCacheStubCacheAdapter } from '../utils/testing/StubCacheAdapter';
import { createUnitTestEntityCompanionProvider } from '../utils/testing/createUnitTestEntityCompanionProvider';
@@ -27,35 +30,83 @@ interface GrandChildFields {
parent_id: string;
}
class TestEntityPrivacyPolicy extends EntityPrivacyPolicy<
any,
string,
TestViewerContext,
any,
any
> {
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, TestViewerContext, any, any>(),
];
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, TestViewerContext, any, any>(),
];
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, TestViewerContext, any, any>(),
];
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,
ParentEntity: 0,
ChildEntity: 0,
GrandChildEntity: 0,
};
const privacyPolicyEvaluationRecords = {
shouldRecord: false,
ParentEntity: {
[EntityAuthorizationAction.CREATE]: [],
[EntityAuthorizationAction.READ]: [],
[EntityAuthorizationAction.UPDATE]: [],
[EntityAuthorizationAction.DELETE]: [],
},
ChildEntity: {
[EntityAuthorizationAction.CREATE]: [],
[EntityAuthorizationAction.READ]: [],
[EntityAuthorizationAction.UPDATE]: [],
[EntityAuthorizationAction.DELETE]: [],
},
GrandChildEntity: {
[EntityAuthorizationAction.CREATE]: [],
[EntityAuthorizationAction.READ]: [],
[EntityAuthorizationAction.UPDATE]: [],
[EntityAuthorizationAction.DELETE]: [],
},
};
class AlwaysAllowPrivacyPolicyRuleThatRecords extends PrivacyPolicyRule<
any,
string,
TestViewerContext,
any,
any
> {
constructor(private readonly action: EntityAuthorizationAction) {
super();
}
async evaluateAsync(
_viewerContext: TestViewerContext,
_queryContext: EntityQueryContext,
evaluationContext: EntityPrivacyPolicyEvaluationContext,
entity: any
): Promise<RuleEvaluationResult> {
if (privacyPolicyEvaluationRecords.shouldRecord) {
(privacyPolicyEvaluationRecords as any)[entity.constructor.name][this.action].push(
evaluationContext
);
}
return RuleEvaluationResult.ALLOW;
}
}
class TestEntityPrivacyPolicy extends EntityPrivacyPolicy<
any,
string,
TestViewerContext,
any,
any
> {
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRuleThatRecords(EntityAuthorizationAction.READ),
];
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRuleThatRecords(EntityAuthorizationAction.CREATE),
];
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRuleThatRecords(EntityAuthorizationAction.UPDATE),
];
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRuleThatRecords(EntityAuthorizationAction.DELETE),
];
}
class ParentCheckInfoTrigger extends EntityMutationTrigger<
ParentFields,
string,
@@ -76,7 +127,7 @@ const makeEntityClasses = (edgeDeletionBehavior: EntityEdgeDeletionBehavior) =>
throw new Error('Parent entity should not have casade delete cause');
}
triggerExecutionCounts.parent++;
triggerExecutionCounts.ParentEntity++;
}
}
@@ -111,7 +162,7 @@ const makeEntityClasses = (edgeDeletionBehavior: EntityEdgeDeletionBehavior) =>
throw new Error('Child entity should not have two-level casade delete cause');
}
triggerExecutionCounts.child++;
triggerExecutionCounts.ChildEntity++;
}
}
@@ -165,7 +216,7 @@ const makeEntityClasses = (edgeDeletionBehavior: EntityEdgeDeletionBehavior) =>
throw new Error('GrandChild entity should not have three-level casade delete cause');
}
triggerExecutionCounts.grandchild++;
triggerExecutionCounts.GrandChildEntity++;
}
}
@@ -297,14 +348,20 @@ const makeEntityClasses = (edgeDeletionBehavior: EntityEdgeDeletionBehavior) =>
ChildEntity,
GrandChildEntity,
triggerExecutionCounts,
privacyPolicyEvaluationRecords,
};
};
describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
describe('EntityEdgeDeletionBehavior.CASCADE_DELETE', () => {
it('deletes', async () => {
const { ParentEntity, ChildEntity, GrandChildEntity, triggerExecutionCounts } =
makeEntityClasses(EntityEdgeDeletionBehavior.CASCADE_DELETE);
const {
ParentEntity,
ChildEntity,
GrandChildEntity,
triggerExecutionCounts,
privacyPolicyEvaluationRecords,
} = makeEntityClasses(EntityEdgeDeletionBehavior.CASCADE_DELETE);
const companionProvider = createUnitTestEntityCompanionProvider();
const viewerContext = new TestViewerContext(companionProvider);
@@ -326,7 +383,9 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
GrandChildEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(grandchild.getID())
).resolves.not.toBeNull();
privacyPolicyEvaluationRecords.shouldRecord = true;
await ParentEntity.enforceDeleteAsync(parent);
privacyPolicyEvaluationRecords.shouldRecord = false;
await expect(
ParentEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(parent.getID())
@@ -340,17 +399,82 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
// two calls for each trigger, one beforeDelete, one afterDelete
expect(triggerExecutionCounts).toMatchObject({
parent: 2,
child: 2,
grandchild: 2,
ParentEntity: 2,
ChildEntity: 2,
GrandChildEntity: 2,
});
expect(privacyPolicyEvaluationRecords).toMatchObject({
ParentEntity: {
[EntityAuthorizationAction.CREATE]: [],
[EntityAuthorizationAction.READ]: [],
[EntityAuthorizationAction.UPDATE]: [],
// one DELETE auth action for parent (since it's being deleted)
[EntityAuthorizationAction.DELETE]: [{ cascadingDeleteCause: null }],
},
ChildEntity: {
[EntityAuthorizationAction.CREATE]: [],
// one READ auth action for child in order to delete via cascade
[EntityAuthorizationAction.READ]: [
{
cascadingDeleteCause: {
entity: expect.any(ParentEntity),
cascadingDeleteCause: null,
},
},
],
[EntityAuthorizationAction.UPDATE]: [],
// one DELETE auth action for child (since it's being deleted via cascade)
[EntityAuthorizationAction.DELETE]: [
{
cascadingDeleteCause: {
entity: expect.any(ParentEntity),
cascadingDeleteCause: null,
},
},
],
},
GrandChildEntity: {
[EntityAuthorizationAction.CREATE]: [],
// one READ auth action for grandchild in order to delete via cascade
[EntityAuthorizationAction.READ]: [
{
cascadingDeleteCause: {
entity: expect.any(ChildEntity),
cascadingDeleteCause: {
entity: expect.any(ParentEntity),
cascadingDeleteCause: null,
},
},
},
],
[EntityAuthorizationAction.UPDATE]: [],
// one DELETE auth action for grandchild (since it's being deleted via cascade)
[EntityAuthorizationAction.DELETE]: [
{
cascadingDeleteCause: {
entity: expect.any(ChildEntity),
cascadingDeleteCause: {
entity: expect.any(ParentEntity),
cascadingDeleteCause: null,
},
},
},
],
},
});
});
});
describe('EntityEdgeDeletionBehavior.SET_NULL', () => {
it('sets null', async () => {
const { ParentEntity, ChildEntity, GrandChildEntity, triggerExecutionCounts } =
makeEntityClasses(EntityEdgeDeletionBehavior.SET_NULL);
const {
ParentEntity,
ChildEntity,
GrandChildEntity,
triggerExecutionCounts,
privacyPolicyEvaluationRecords,
} = makeEntityClasses(EntityEdgeDeletionBehavior.SET_NULL);
const companionProvider = createUnitTestEntityCompanionProvider();
const viewerContext = new TestViewerContext(companionProvider);
@@ -373,7 +497,9 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
GrandChildEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(grandchild.getID())
).resolves.not.toBeNull();
privacyPolicyEvaluationRecords.shouldRecord = true;
await ParentEntity.enforceDeleteAsync(parent);
privacyPolicyEvaluationRecords.shouldRecord = false;
await expect(
ParentEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(parent.getID())
@@ -392,17 +518,70 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
// 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,
ParentEntity: 2,
ChildEntity: 0,
GrandChildEntity: 0,
});
expect(privacyPolicyEvaluationRecords).toMatchObject({
ParentEntity: {
[EntityAuthorizationAction.CREATE]: [],
[EntityAuthorizationAction.READ]: [],
[EntityAuthorizationAction.UPDATE]: [],
// one DELETE auth action for parent (since it's being deleted)
[EntityAuthorizationAction.DELETE]: [{ cascadingDeleteCause: null }],
},
ChildEntity: {
[EntityAuthorizationAction.CREATE]: [],
// two READs auth action for Child during parent deletion:
// 1. Read to initiate the SET_NULL (to update the entity)
// 1. Read automatically post-update
// no other entities are read since it is not cascaded past first entity
[EntityAuthorizationAction.READ]: [
{
cascadingDeleteCause: {
entity: expect.any(ParentEntity),
cascadingDeleteCause: null,
},
},
{
cascadingDeleteCause: {
entity: expect.any(ParentEntity),
cascadingDeleteCause: null,
},
},
],
// one UPDATE to set null
[EntityAuthorizationAction.UPDATE]: [
{
cascadingDeleteCause: {
entity: expect.any(ParentEntity),
cascadingDeleteCause: null,
},
},
],
[EntityAuthorizationAction.DELETE]: [],
},
GrandChildEntity: {
[EntityAuthorizationAction.CREATE]: [],
[EntityAuthorizationAction.READ]: [],
[EntityAuthorizationAction.UPDATE]: [],
[EntityAuthorizationAction.DELETE]: [],
},
});
});
});
describe('EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE', () => {
it('invalidates the cache', async () => {
const { ParentEntity, ChildEntity, GrandChildEntity, triggerExecutionCounts } =
makeEntityClasses(EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE);
const {
ParentEntity,
ChildEntity,
GrandChildEntity,
triggerExecutionCounts,
privacyPolicyEvaluationRecords,
} = makeEntityClasses(EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE);
const companionProvider = createUnitTestEntityCompanionProvider();
const viewerContext = new TestViewerContext(companionProvider);
@@ -447,7 +626,9 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
]);
expect(grandChildCachedBefore.get(child.getID())?.status).toEqual(CacheStatus.HIT);
privacyPolicyEvaluationRecords.shouldRecord = true;
await ParentEntity.enforceDeleteAsync(parent);
privacyPolicyEvaluationRecords.shouldRecord = false;
const childCachedAfter = await childCacheAdapter.loadManyAsync('parent_id', [parent.getID()]);
expect(childCachedAfter.get(parent.getID())?.status).toEqual(CacheStatus.MISS);
@@ -469,9 +650,69 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
// two calls for each trigger, one beforeDelete, one afterDelete
expect(triggerExecutionCounts).toMatchObject({
parent: 2,
child: 2,
grandchild: 2,
ParentEntity: 2,
ChildEntity: 2,
GrandChildEntity: 2,
});
expect(privacyPolicyEvaluationRecords).toMatchObject({
ParentEntity: {
[EntityAuthorizationAction.CREATE]: [],
[EntityAuthorizationAction.READ]: [],
[EntityAuthorizationAction.UPDATE]: [],
// one DELETE auth action for parent (since it's being deleted)
[EntityAuthorizationAction.DELETE]: [{ cascadingDeleteCause: null }],
},
ChildEntity: {
[EntityAuthorizationAction.CREATE]: [],
// one READ auth action for child in order to delete via cascade
[EntityAuthorizationAction.READ]: [
{
cascadingDeleteCause: {
entity: expect.any(ParentEntity),
cascadingDeleteCause: null,
},
},
],
[EntityAuthorizationAction.UPDATE]: [],
// one DELETE auth action for child (since it's being deleted via cascade)
[EntityAuthorizationAction.DELETE]: [
{
cascadingDeleteCause: {
entity: expect.any(ParentEntity),
cascadingDeleteCause: null,
},
},
],
},
GrandChildEntity: {
[EntityAuthorizationAction.CREATE]: [],
// one READ auth action for grandchild in order to delete via cascade
[EntityAuthorizationAction.READ]: [
{
cascadingDeleteCause: {
entity: expect.any(ChildEntity),
cascadingDeleteCause: {
entity: expect.any(ParentEntity),
cascadingDeleteCause: null,
},
},
},
],
[EntityAuthorizationAction.UPDATE]: [],
// one DELETE auth action for grandchild (since it's being deleted via cascade)
[EntityAuthorizationAction.DELETE]: [
{
cascadingDeleteCause: {
entity: expect.any(ChildEntity),
cascadingDeleteCause: {
entity: expect.any(ParentEntity),
cascadingDeleteCause: null,
},
},
},
],
},
});
});
});
@@ -13,12 +13,15 @@ import {
EnumField,
JSONArrayField,
MaybeJSONArrayField,
LowercaseStringField,
} from '../EntityFields';
import describeFieldTestCase from '../utils/testing/describeFieldTestCase';
class TestFieldDefinition extends EntityFieldDefinition<string> {
protected validateInputValueInternal(value: string): boolean {
return value === 'helloworld';
protected validateAndTransformInputValueInternal(
value: string
): { isValid: false } | { isValid: true; transformedValue: string } {
return value === 'helloworld' ? { isValid: true, transformedValue: value } : { isValid: false };
}
}
@@ -35,49 +38,97 @@ describe(EntityFieldDefinition, () => {
test('validator returns true when value is null', () => {
const fieldDefinition = new TestFieldDefinition({ columnName: 'wat', cache: true });
expect(fieldDefinition.validateInputValue(null)).toBe(true);
expect(fieldDefinition.validateAndTransformInputValue(null).isValid).toBe(true);
});
test('validator returns true when value is undefined', () => {
const fieldDefinition = new TestFieldDefinition({ columnName: 'wat', cache: true });
expect(fieldDefinition.validateInputValue(undefined)).toBe(true);
expect(fieldDefinition.validateAndTransformInputValue(undefined).isValid).toBe(true);
});
test('validator returns false when value is invalid', () => {
const fieldDefinition = new TestFieldDefinition({ columnName: 'wat', cache: true });
expect(fieldDefinition.validateInputValue('nothelloworld')).toBe(false);
expect(fieldDefinition.validateAndTransformInputValue('nothelloworld').isValid).toBe(false);
});
test('validator returns true when value is valid', () => {
const fieldDefinition = new TestFieldDefinition({ columnName: 'wat', cache: true });
expect(fieldDefinition.validateInputValue('helloworld')).toBe(true);
expect(fieldDefinition.validateAndTransformInputValue('helloworld').isValid).toBe(true);
});
});
describeFieldTestCase(new StringField({ columnName: 'wat' }), ['hello', ''], [1, true, {}, [[]]]);
describeFieldTestCase(
new StringField({ columnName: 'wat' }),
['hello', ''],
[1, true, {}, [[]]],
[{ in: 'hello', out: 'hello' }]
);
describeFieldTestCase(
new LowercaseStringField({ columnName: 'wat' }),
['HELLO', 'hello'],
[1, true, {}, [[]]],
[
{ in: 'low', out: 'low' },
{ in: 'UP', out: 'up' },
]
);
describeFieldTestCase(
new UUIDField({ columnName: 'wat' }),
[uuidv1(), uuidv3('wat', uuidv3.DNS), uuidv4(), uuidv5('wat', uuidv5.DNS)],
[uuidv4().replace('-', ''), '', 'hello']
[uuidv4().replace('-', ''), '', 'hello'],
[uuidv4()].map((u) => ({ in: u, out: u }))
);
describeFieldTestCase(
new DateField({ columnName: 'wat' }),
[new Date()],
[Date.now()],
[new Date()].map((u) => ({ in: u, out: u }))
);
describeFieldTestCase(
new BooleanField({ columnName: 'wat' }),
[true, false],
[0, 1, ''],
[{ in: true, out: true }]
);
describeFieldTestCase(
new IntField({ columnName: 'wat' }),
[1],
['1', 0.5, true],
[{ in: 1, out: 1 }]
);
describeFieldTestCase(
new FloatField({ columnName: 'wat' }),
[1, 0.5, -0.5],
['1'],
[{ in: 3.5, out: 3.5 }]
);
describeFieldTestCase(new DateField({ columnName: 'wat' }), [new Date()], [Date.now()]);
describeFieldTestCase(new BooleanField({ columnName: 'wat' }), [true, false], [0, 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
['hello']
['hello'],
[{ in: ['hello'], out: ['hello'] }]
);
describeFieldTestCase(
new JSONObjectField({ columnName: 'wat' }),
[{}],
[true, 'hello'],
[{ in: { hello: 1 }, out: { hello: 1 } }]
);
describeFieldTestCase(
new EnumField({ columnName: 'wat' }),
['hello', 1],
[true],
[{ in: 1, out: 1 }]
);
describeFieldTestCase(new JSONObjectField({ columnName: 'wat' }), [{}], [true, 'hello']);
describeFieldTestCase(new EnumField({ columnName: 'wat' }), ['hello', 1], [true]);
describeFieldTestCase(
new JSONArrayField({ columnName: 'wat' }),
[[[1, 2]] as any, [['hello']] as any], // jest test cases need extra wrapping array
[1, 'hello']
[1, 'hello'],
[{ in: [1, 2], out: [1, 2] }]
);
describeFieldTestCase(
new MaybeJSONArrayField({ columnName: 'wat' }),
[1, 'hello', [['hello']]], // jest test cases need extra wrapping array
[]
[],
[{ in: 1, out: 1 }]
);
@@ -5,7 +5,7 @@ import { EntityCompanionDefinition } from '../EntityCompanionProvider';
import EntityConfiguration from '../EntityConfiguration';
import { StringField } from '../EntityFields';
import EntityLoader from '../EntityLoader';
import EntityPrivacyPolicy from '../EntityPrivacyPolicy';
import EntityPrivacyPolicy, { EntityPrivacyPolicyEvaluationContext } from '../EntityPrivacyPolicy';
import ViewerContext from '../ViewerContext';
import EntityDataManager from '../internal/EntityDataManager';
import ReadThroughEntityCache from '../internal/ReadThroughEntityCache';
@@ -118,6 +118,7 @@ export const testEntityCompanion = new EntityCompanionDefinition({
describe(EntityLoader, () => {
it('handles thrown errors and literals from constructor', async () => {
const viewerContext = instance(mock(ViewerContext));
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const metricsAdapter = instance(mock<IEntityMetricsAdapter>());
const queryContext = StubQueryContextProvider.getQueryContext();
@@ -154,6 +155,7 @@ describe(EntityLoader, () => {
const entityLoader = new EntityLoader(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
testEntityConfiguration,
TestEntity,
privacyPolicy,
@@ -3,6 +3,7 @@ import { mock, instance, verify, spy, deepEqual, anyOfClass, anything, when } fr
import { v4 as uuidv4 } from 'uuid';
import EntityLoader from '../EntityLoader';
import { EntityPrivacyPolicyEvaluationContext } from '../EntityPrivacyPolicy';
import ViewerContext from '../ViewerContext';
import { enforceResultsAsync } from '../entityUtils';
import EntityDataManager from '../internal/EntityDataManager';
@@ -21,6 +22,7 @@ describe(EntityLoader, () => {
it('loads entities', async () => {
const dateToInsert = new Date();
const viewerContext = instance(mock(ViewerContext));
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const metricsAdapter = instance(mock<IEntityMetricsAdapter>());
const queryContext = StubQueryContextProvider.getQueryContext();
@@ -39,6 +41,7 @@ describe(EntityLoader, () => {
testIndexedField: 'h1',
intField: 5,
stringField: 'huh',
lowercasedField: 'lw1',
dateField: dateToInsert,
nullableField: null,
},
@@ -47,6 +50,7 @@ describe(EntityLoader, () => {
testIndexedField: 'h2',
intField: 3,
stringField: 'huh',
lowercasedField: 'lw2',
dateField: dateToInsert,
nullableField: null,
},
@@ -69,6 +73,7 @@ describe(EntityLoader, () => {
const entityLoader = new EntityLoader(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
testEntityConfiguration,
TestEntity,
privacyPolicy,
@@ -84,6 +89,11 @@ describe(EntityLoader, () => {
);
expect(entities.map((m) => m.getID())).toEqual([id1, id2]);
const entitiesLoadedTransformedField = await enforceResultsAsync(
entityLoader.loadManyByFieldEqualingAsync('lowercasedField', 'LW1')
);
expect(entitiesLoadedTransformedField.map((m) => m.getID())).toEqual([id1]);
const entityResultNumber3 = await entityLoader.loadByFieldEqualingAsync('intField', 3);
expect(entityResultNumber3).not.toBeNull();
expect(entityResultNumber3!.enforceValue().getID()).toEqual(id2);
@@ -113,6 +123,7 @@ describe(EntityLoader, () => {
const privacyPolicy = new TestEntityPrivacyPolicy();
const spiedPrivacyPolicy = spy(privacyPolicy);
const viewerContext = instance(mock(ViewerContext));
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const metricsAdapter = instance(mock<IEntityMetricsAdapter>());
const queryContext = StubQueryContextProvider.getQueryContext();
@@ -130,6 +141,7 @@ describe(EntityLoader, () => {
{
customIdField: id1,
stringField: 'huh',
lowercasedField: 'lw1',
intField: 4,
testIndexedField: '4',
dateField: new Date(),
@@ -138,6 +150,7 @@ describe(EntityLoader, () => {
{
customIdField: id2,
stringField: 'huh',
lowercasedField: 'lw2',
intField: 4,
testIndexedField: '5',
dateField: new Date(),
@@ -146,6 +159,7 @@ describe(EntityLoader, () => {
{
customIdField: id3,
stringField: 'huh2',
lowercasedField: 'lw3',
intField: 4,
testIndexedField: '6',
dateField: new Date(),
@@ -169,6 +183,7 @@ describe(EntityLoader, () => {
const entityLoader = new EntityLoader(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
testEntityConfiguration,
TestEntity,
privacyPolicy,
@@ -192,6 +207,7 @@ describe(EntityLoader, () => {
spiedPrivacyPolicy.authorizeReadAsync(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
anyOfClass(TestEntity),
anything()
)
@@ -209,6 +225,7 @@ describe(EntityLoader, () => {
const spiedPrivacyPolicy = spy(privacyPolicy);
const viewerContext = instance(mock(ViewerContext));
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const metricsAdapter = instance(mock<IEntityMetricsAdapter>());
const queryContext = StubQueryContextProvider.getQueryContext();
@@ -224,6 +241,7 @@ describe(EntityLoader, () => {
{
customIdField: id1,
stringField: 'huh',
lowercasedField: 'lw1',
testIndexedField: '1',
intField: 3,
dateField: new Date(),
@@ -247,6 +265,7 @@ describe(EntityLoader, () => {
const entityLoader = new EntityLoader(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
testEntityConfiguration,
TestEntity,
privacyPolicy,
@@ -255,12 +274,19 @@ describe(EntityLoader, () => {
);
const entity = await enforceAsyncResult(entityLoader.loadByIDAsync(id1));
verify(
spiedPrivacyPolicy.authorizeReadAsync(viewerContext, queryContext, entity, anything())
spiedPrivacyPolicy.authorizeReadAsync(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
entity,
anything()
)
).once();
});
it('invalidates upon invalidate one', async () => {
const viewerContext = instance(mock(ViewerContext));
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const metricsAdapter = instance(mock<IEntityMetricsAdapter>());
const queryContext = StubQueryContextProvider.getQueryContext();
const privacyPolicy = instance(mock(TestEntityPrivacyPolicy));
@@ -271,6 +297,7 @@ describe(EntityLoader, () => {
const entityLoader = new EntityLoader(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
testEntityConfiguration,
TestEntity,
privacyPolicy,
@@ -286,6 +313,7 @@ describe(EntityLoader, () => {
it('invalidates upon invalidate by field', async () => {
const viewerContext = instance(mock(ViewerContext));
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const metricsAdapter = instance(mock<IEntityMetricsAdapter>());
const queryContext = StubQueryContextProvider.getQueryContext();
const privacyPolicy = instance(mock(TestEntityPrivacyPolicy));
@@ -296,6 +324,7 @@ describe(EntityLoader, () => {
const entityLoader = new EntityLoader(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
testEntityConfiguration,
TestEntity,
privacyPolicy,
@@ -310,6 +339,7 @@ describe(EntityLoader, () => {
it('invalidates upon invalidate by entity', async () => {
const viewerContext = instance(mock(ViewerContext));
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const metricsAdapter = instance(mock<IEntityMetricsAdapter>());
const queryContext = StubQueryContextProvider.getQueryContext();
const privacyPolicy = instance(mock(TestEntityPrivacyPolicy));
@@ -324,6 +354,7 @@ describe(EntityLoader, () => {
const entityLoader = new EntityLoader(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
testEntityConfiguration,
TestEntity,
privacyPolicy,
@@ -338,6 +369,7 @@ describe(EntityLoader, () => {
it('returns error result when not allowed', async () => {
const viewerContext = instance(mock(ViewerContext));
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const metricsAdapter = instance(mock<IEntityMetricsAdapter>());
const queryContext = StubQueryContextProvider.getQueryContext();
const privacyPolicyMock = mock(TestEntityPrivacyPolicy);
@@ -354,6 +386,7 @@ describe(EntityLoader, () => {
privacyPolicyMock.authorizeReadAsync(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
anyOfClass(TestEntity),
anything()
)
@@ -365,6 +398,7 @@ describe(EntityLoader, () => {
const entityLoader = new EntityLoader(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
testEntityConfiguration,
TestEntity,
privacyPolicy,
@@ -380,6 +414,7 @@ describe(EntityLoader, () => {
it('throws upon database adapter error', async () => {
const viewerContext = instance(mock(ViewerContext));
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const metricsAdapter = instance(mock<IEntityMetricsAdapter>());
const queryContext = StubQueryContextProvider.getQueryContext();
const privacyPolicy = instance(mock(TestEntityPrivacyPolicy));
@@ -396,6 +431,7 @@ describe(EntityLoader, () => {
const entityLoader = new EntityLoader(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
testEntityConfiguration,
TestEntity,
privacyPolicy,
@@ -25,6 +25,7 @@ import EntityMutationTriggerConfiguration, {
} from '../EntityMutationTriggerConfiguration';
import EntityMutationValidator from '../EntityMutationValidator';
import EntityMutatorFactory from '../EntityMutatorFactory';
import { EntityPrivacyPolicyEvaluationContext } from '../EntityPrivacyPolicy';
import { EntityTransactionalQueryContext, EntityQueryContext } from '../EntityQueryContext';
import ViewerContext from '../ViewerContext';
import { enforceResultsAsync } from '../entityUtils';
@@ -340,6 +341,7 @@ describe(EntityMutatorFactory, () => {
describe('forCreate', () => {
it('creates entities', async () => {
const viewerContext = mock<ViewerContext>();
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const queryContext = StubQueryContextProvider.getQueryContext();
const id1 = uuidv4();
@@ -363,7 +365,7 @@ describe(EntityMutatorFactory, () => {
},
]);
const newEntity = await entityMutatorFactory
.forCreate(viewerContext, queryContext)
.forCreate(viewerContext, queryContext, privacyPolicyEvaluationContext)
.setField('stringField', 'huh')
.enforceCreateAsync();
expect(newEntity).toBeTruthy();
@@ -371,6 +373,7 @@ describe(EntityMutatorFactory, () => {
it('checks privacy', async () => {
const viewerContext = mock<ViewerContext>();
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const queryContext = StubQueryContextProvider.getQueryContext();
const id1 = uuidv4();
@@ -397,7 +400,7 @@ describe(EntityMutatorFactory, () => {
const spiedPrivacyPolicy = spy(privacyPolicy);
await entityMutatorFactory
.forCreate(viewerContext, queryContext)
.forCreate(viewerContext, queryContext, privacyPolicyEvaluationContext)
.setField('stringField', 'huh')
.enforceCreateAsync();
@@ -405,6 +408,7 @@ describe(EntityMutatorFactory, () => {
spiedPrivacyPolicy.authorizeCreateAsync(
viewerContext,
anyOfClass(EntityTransactionalQueryContext),
privacyPolicyEvaluationContext,
anyOfClass(TestEntity),
anything()
)
@@ -413,6 +417,7 @@ describe(EntityMutatorFactory, () => {
it('executes triggers', async () => {
const viewerContext = mock<ViewerContext>();
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const queryContext = StubQueryContextProvider.getQueryContext();
const id1 = uuidv4();
@@ -439,7 +444,7 @@ describe(EntityMutatorFactory, () => {
const triggerSpies = setUpMutationTriggerSpies(mutationTriggers);
await entityMutatorFactory
.forCreate(viewerContext, queryContext)
.forCreate(viewerContext, queryContext, privacyPolicyEvaluationContext)
.setField('stringField', 'huh')
.enforceCreateAsync();
@@ -460,6 +465,7 @@ describe(EntityMutatorFactory, () => {
it('executes validators', async () => {
const viewerContext = mock<ViewerContext>();
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const queryContext = StubQueryContextProvider.getQueryContext();
const id1 = uuidv4();
@@ -486,7 +492,7 @@ describe(EntityMutatorFactory, () => {
const validatorSpies = setUpMutationValidatorSpies(mutationValidators);
await entityMutatorFactory
.forCreate(viewerContext, queryContext)
.forCreate(viewerContext, queryContext, privacyPolicyEvaluationContext)
.setField('stringField', 'huh')
.enforceCreateAsync();
@@ -497,6 +503,7 @@ describe(EntityMutatorFactory, () => {
describe('forUpdate', () => {
it('updates entities', async () => {
const viewerContext = mock<ViewerContext>();
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const queryContext = StubQueryContextProvider.getQueryContext();
const id1 = uuidv4();
@@ -521,11 +528,13 @@ describe(EntityMutatorFactory, () => {
]);
const existingEntity = await enforceAsyncResult(
entityLoaderFactory.forLoad(viewerContext, queryContext).loadByIDAsync(id2)
entityLoaderFactory
.forLoad(viewerContext, queryContext, privacyPolicyEvaluationContext)
.loadByIDAsync(id2)
);
const updatedEntity = await entityMutatorFactory
.forUpdate(existingEntity, queryContext)
.forUpdate(existingEntity, queryContext, privacyPolicyEvaluationContext)
.setField('stringField', 'huh2')
.enforceUpdateAsync();
@@ -534,13 +543,16 @@ describe(EntityMutatorFactory, () => {
expect(updatedEntity.getField('stringField')).toEqual('huh2');
const reloadedEntity = await enforceAsyncResult(
entityLoaderFactory.forLoad(viewerContext, queryContext).loadByIDAsync(id2)
entityLoaderFactory
.forLoad(viewerContext, queryContext, privacyPolicyEvaluationContext)
.loadByIDAsync(id2)
);
expect(reloadedEntity.getAllFields()).toMatchObject(updatedEntity.getAllFields());
});
it('checks privacy', async () => {
const viewerContext = mock<ViewerContext>();
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const queryContext = StubQueryContextProvider.getQueryContext();
const id1 = uuidv4();
@@ -568,11 +580,13 @@ describe(EntityMutatorFactory, () => {
const spiedPrivacyPolicy = spy(privacyPolicy);
const existingEntity = await enforceAsyncResult(
entityLoaderFactory.forLoad(viewerContext, queryContext).loadByIDAsync(id2)
entityLoaderFactory
.forLoad(viewerContext, queryContext, privacyPolicyEvaluationContext)
.loadByIDAsync(id2)
);
await entityMutatorFactory
.forUpdate(existingEntity, queryContext)
.forUpdate(existingEntity, queryContext, privacyPolicyEvaluationContext)
.setField('stringField', 'huh2')
.enforceUpdateAsync();
@@ -580,6 +594,7 @@ describe(EntityMutatorFactory, () => {
spiedPrivacyPolicy.authorizeUpdateAsync(
viewerContext,
anyOfClass(EntityTransactionalQueryContext),
privacyPolicyEvaluationContext,
anyOfClass(TestEntity),
anything()
)
@@ -588,6 +603,7 @@ describe(EntityMutatorFactory, () => {
it('executes triggers', async () => {
const viewerContext = mock<ViewerContext>();
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const queryContext = StubQueryContextProvider.getQueryContext();
const id1 = uuidv4();
@@ -615,11 +631,13 @@ describe(EntityMutatorFactory, () => {
const triggerSpies = setUpMutationTriggerSpies(mutationTriggers);
const existingEntity = await enforceAsyncResult(
entityLoaderFactory.forLoad(viewerContext, queryContext).loadByIDAsync(id2)
entityLoaderFactory
.forLoad(viewerContext, queryContext, privacyPolicyEvaluationContext)
.loadByIDAsync(id2)
);
await entityMutatorFactory
.forUpdate(existingEntity, queryContext)
.forUpdate(existingEntity, queryContext, privacyPolicyEvaluationContext)
.setField('stringField', 'huh2')
.enforceUpdateAsync();
@@ -639,6 +657,7 @@ describe(EntityMutatorFactory, () => {
});
it('executes validators', async () => {
const viewerContext = mock<ViewerContext>();
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const queryContext = StubQueryContextProvider.getQueryContext();
const id1 = uuidv4();
@@ -666,11 +685,13 @@ describe(EntityMutatorFactory, () => {
const validatorSpies = setUpMutationValidatorSpies(mutationValidators);
const existingEntity = await enforceAsyncResult(
entityLoaderFactory.forLoad(viewerContext, queryContext).loadByIDAsync(id2)
entityLoaderFactory
.forLoad(viewerContext, queryContext, privacyPolicyEvaluationContext)
.loadByIDAsync(id2)
);
await entityMutatorFactory
.forUpdate(existingEntity, queryContext)
.forUpdate(existingEntity, queryContext, privacyPolicyEvaluationContext)
.setField('stringField', 'huh2')
.enforceUpdateAsync();
@@ -684,6 +705,7 @@ describe(EntityMutatorFactory, () => {
describe('forDelete', () => {
it('deletes entities', async () => {
const viewerContext = mock<ViewerContext>();
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const queryContext = StubQueryContextProvider.getQueryContext();
const id1 = uuidv4();
@@ -699,21 +721,28 @@ describe(EntityMutatorFactory, () => {
]);
const existingEntity = await enforceAsyncResult(
entityLoaderFactory.forLoad(viewerContext, queryContext).loadByIDAsync(id1)
entityLoaderFactory
.forLoad(viewerContext, queryContext, privacyPolicyEvaluationContext)
.loadByIDAsync(id1)
);
expect(existingEntity).toBeTruthy();
await entityMutatorFactory.forDelete(existingEntity, queryContext).enforceDeleteAsync();
await entityMutatorFactory
.forDelete(existingEntity, queryContext, privacyPolicyEvaluationContext)
.enforceDeleteAsync();
await expect(
enforceAsyncResult(
entityLoaderFactory.forLoad(viewerContext, queryContext).loadByIDAsync(id1)
entityLoaderFactory
.forLoad(viewerContext, queryContext, privacyPolicyEvaluationContext)
.loadByIDAsync(id1)
)
).rejects.toBeInstanceOf(Error);
});
it('checks privacy', async () => {
const viewerContext = mock<ViewerContext>();
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const queryContext = StubQueryContextProvider.getQueryContext();
const id1 = uuidv4();
@@ -732,15 +761,20 @@ describe(EntityMutatorFactory, () => {
const spiedPrivacyPolicy = spy(privacyPolicy);
const existingEntity = await enforceAsyncResult(
entityLoaderFactory.forLoad(viewerContext, queryContext).loadByIDAsync(id1)
entityLoaderFactory
.forLoad(viewerContext, queryContext, privacyPolicyEvaluationContext)
.loadByIDAsync(id1)
);
await entityMutatorFactory.forDelete(existingEntity, queryContext).enforceDeleteAsync();
await entityMutatorFactory
.forDelete(existingEntity, queryContext, privacyPolicyEvaluationContext)
.enforceDeleteAsync();
verify(
spiedPrivacyPolicy.authorizeDeleteAsync(
viewerContext,
anyOfClass(EntityTransactionalQueryContext),
anything(),
anyOfClass(TestEntity),
anything()
)
@@ -749,6 +783,7 @@ describe(EntityMutatorFactory, () => {
it('executes triggers', async () => {
const viewerContext = mock<ViewerContext>();
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const queryContext = StubQueryContextProvider.getQueryContext();
const id1 = uuidv4();
@@ -767,10 +802,14 @@ describe(EntityMutatorFactory, () => {
const triggerSpies = setUpMutationTriggerSpies(mutationTriggers);
const existingEntity = await enforceAsyncResult(
entityLoaderFactory.forLoad(viewerContext, queryContext).loadByIDAsync(id1)
entityLoaderFactory
.forLoad(viewerContext, queryContext, privacyPolicyEvaluationContext)
.loadByIDAsync(id1)
);
await entityMutatorFactory.forDelete(existingEntity, queryContext).enforceDeleteAsync();
await entityMutatorFactory
.forDelete(existingEntity, queryContext, privacyPolicyEvaluationContext)
.enforceDeleteAsync();
verifyTriggerCounts(
viewerContext,
@@ -789,6 +828,7 @@ describe(EntityMutatorFactory, () => {
it('does not execute validators', async () => {
const viewerContext = mock<ViewerContext>();
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const queryContext = StubQueryContextProvider.getQueryContext();
const id1 = uuidv4();
@@ -807,10 +847,14 @@ describe(EntityMutatorFactory, () => {
const validatorSpies = setUpMutationValidatorSpies(mutationValidators);
const existingEntity = await enforceAsyncResult(
entityLoaderFactory.forLoad(viewerContext, queryContext).loadByIDAsync(id1)
entityLoaderFactory
.forLoad(viewerContext, queryContext, privacyPolicyEvaluationContext)
.loadByIDAsync(id1)
);
await entityMutatorFactory.forDelete(existingEntity, queryContext).enforceDeleteAsync();
await entityMutatorFactory
.forDelete(existingEntity, queryContext, privacyPolicyEvaluationContext)
.enforceDeleteAsync();
verifyValidatorCounts(viewerContext, validatorSpies, 0, {
type: EntityMutationType.DELETE as any,
@@ -820,6 +864,7 @@ describe(EntityMutatorFactory, () => {
it('invalidates cache for fields upon create', async () => {
const viewerContext = mock<ViewerContext>();
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const queryContext = StubQueryContextProvider.getQueryContext();
const id1 = uuidv4();
@@ -836,21 +881,21 @@ describe(EntityMutatorFactory, () => {
const entites1 = await enforceResultsAsync(
entityLoaderFactory
.forLoad(viewerContext, queryContext)
.forLoad(viewerContext, queryContext, privacyPolicyEvaluationContext)
.loadManyByFieldEqualingAsync('stringField', 'huh')
);
expect(entites1).toHaveLength(1);
await enforceAsyncResult(
entityMutatorFactory
.forCreate(viewerContext, queryContext)
.forCreate(viewerContext, queryContext, privacyPolicyEvaluationContext)
.setField('stringField', 'huh')
.createAsync()
);
const entities2 = await enforceResultsAsync(
entityLoaderFactory
.forLoad(viewerContext, queryContext)
.forLoad(viewerContext, queryContext, privacyPolicyEvaluationContext)
.loadManyByFieldEqualingAsync('stringField', 'huh')
);
expect(entities2).toHaveLength(2);
@@ -858,6 +903,7 @@ describe(EntityMutatorFactory, () => {
it('throws error when field not valid', async () => {
const viewerContext = mock<ViewerContext>();
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const queryContext = StubQueryContextProvider.getQueryContext();
const id1 = uuidv4();
const { entityMutatorFactory } = createEntityMutatorFactory([
@@ -873,19 +919,19 @@ describe(EntityMutatorFactory, () => {
await expect(
entityMutatorFactory
.forCreate(viewerContext, queryContext)
.forCreate(viewerContext, queryContext, privacyPolicyEvaluationContext)
.setField('stringField', 10 as any)
.createAsync()
).rejects.toThrowError('Entity field not valid: TestEntity (stringField = 10)');
const createdEntity = await entityMutatorFactory
.forCreate(viewerContext, queryContext)
.forCreate(viewerContext, queryContext, privacyPolicyEvaluationContext)
.setField('stringField', 'hello')
.enforceCreateAsync();
await expect(
entityMutatorFactory
.forUpdate(createdEntity, queryContext)
.forUpdate(createdEntity, queryContext, privacyPolicyEvaluationContext)
.setField('stringField', 10 as any)
.updateAsync()
).rejects.toThrowError('Entity field not valid: TestEntity (stringField = 10)');
@@ -893,6 +939,7 @@ describe(EntityMutatorFactory, () => {
it('returns error result when not authorized to create', async () => {
const viewerContext = instance(mock(ViewerContext));
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const queryContext = StubQueryContextProvider.getQueryContext();
const privacyPolicyMock = mock(SimpleTestEntityPrivacyPolicy);
const databaseAdapter = instance(mock<EntityDatabaseAdapter<SimpleTestFields>>());
@@ -916,6 +963,7 @@ describe(EntityMutatorFactory, () => {
privacyPolicyMock.authorizeCreateAsync(
viewerContext,
anyOfClass(EntityTransactionalQueryContext),
anything(),
anyOfClass(SimpleTestEntity),
anything()
)
@@ -924,6 +972,7 @@ describe(EntityMutatorFactory, () => {
privacyPolicyMock.authorizeUpdateAsync(
viewerContext,
anyOfClass(EntityTransactionalQueryContext),
anything(),
anyOfClass(SimpleTestEntity),
anything()
)
@@ -932,6 +981,7 @@ describe(EntityMutatorFactory, () => {
privacyPolicyMock.authorizeDeleteAsync(
viewerContext,
anyOfClass(EntityTransactionalQueryContext),
anything(),
anyOfClass(SimpleTestEntity),
anything()
)
@@ -949,7 +999,7 @@ describe(EntityMutatorFactory, () => {
);
const entityCreateResult = await entityMutatorFactory
.forCreate(viewerContext, queryContext)
.forCreate(viewerContext, queryContext, privacyPolicyEvaluationContext)
.createAsync();
expect(entityCreateResult.ok).toBe(false);
expect(entityCreateResult.reason).toEqual(rejectionError);
@@ -961,14 +1011,14 @@ describe(EntityMutatorFactory, () => {
});
const entityUpdateResult = await entityMutatorFactory
.forUpdate(fakeEntity, queryContext)
.forUpdate(fakeEntity, queryContext, privacyPolicyEvaluationContext)
.updateAsync();
expect(entityUpdateResult.ok).toBe(false);
expect(entityUpdateResult.reason).toEqual(rejectionError);
expect(entityUpdateResult.value).toBe(undefined);
const entityDeleteResult = await entityMutatorFactory
.forDelete(fakeEntity, queryContext)
.forDelete(fakeEntity, queryContext, privacyPolicyEvaluationContext)
.deleteAsync();
expect(entityDeleteResult.ok).toBe(false);
expect(entityDeleteResult.reason).toEqual(rejectionError);
@@ -977,6 +1027,7 @@ describe(EntityMutatorFactory, () => {
it('throws error when db adapter throws', async () => {
const viewerContext = instance(mock(ViewerContext));
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const queryContext = StubQueryContextProvider.getQueryContext();
const privacyPolicy = instance(mock(SimpleTestEntityPrivacyPolicy));
const databaseAdapterMock = mock<EntityDatabaseAdapter<SimpleTestFields>>();
@@ -1032,37 +1083,48 @@ describe(EntityMutatorFactory, () => {
});
await expect(
entityMutatorFactory.forCreate(viewerContext, queryContext).createAsync()
entityMutatorFactory
.forCreate(viewerContext, queryContext, privacyPolicyEvaluationContext)
.createAsync()
).rejects.toEqual(rejectionError);
await expect(
entityMutatorFactory.forUpdate(fakeEntity, queryContext).updateAsync()
entityMutatorFactory
.forUpdate(fakeEntity, queryContext, privacyPolicyEvaluationContext)
.updateAsync()
).rejects.toEqual(rejectionError);
await expect(
entityMutatorFactory.forDelete(fakeEntity, queryContext).deleteAsync()
entityMutatorFactory
.forDelete(fakeEntity, queryContext, privacyPolicyEvaluationContext)
.deleteAsync()
).rejects.toEqual(rejectionError);
});
it('records metrics appropriately', async () => {
const viewerContext = mock<ViewerContext>();
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const queryContext = StubQueryContextProvider.getQueryContext();
const { entityMutatorFactory, metricsAdapter } = createEntityMutatorFactory([]);
const spiedMetricsAdapter = spy(metricsAdapter);
const newEntity = await enforceAsyncResult(
entityMutatorFactory
.forCreate(viewerContext, queryContext)
.forCreate(viewerContext, queryContext, privacyPolicyEvaluationContext)
.setField('stringField', 'huh')
.createAsync()
);
await enforceAsyncResult(
entityMutatorFactory
.forUpdate(newEntity, queryContext)
.forUpdate(newEntity, queryContext, privacyPolicyEvaluationContext)
.setField('stringField', 'wat')
.updateAsync()
);
await enforceAsyncResult(entityMutatorFactory.forDelete(newEntity, queryContext).deleteAsync());
await enforceAsyncResult(
entityMutatorFactory
.forDelete(newEntity, queryContext, privacyPolicyEvaluationContext)
.deleteAsync()
);
verify(
spiedMetricsAdapter.logMutatorMutationEvent(
@@ -8,6 +8,7 @@ import EntityPrivacyPolicy, {
EntityPrivacyPolicyEvaluator,
EntityAuthorizationAction,
EntityPrivacyPolicyEvaluationMode,
EntityPrivacyPolicyEvaluationContext,
} from '../EntityPrivacyPolicy';
import { EntityQueryContext } from '../EntityQueryContext';
import ViewerContext from '../ViewerContext';
@@ -166,6 +167,7 @@ class AlwaysThrowPrivacyPolicyRule extends PrivacyPolicyRule<
evaluateAsync(
_viewerContext: ViewerContext,
_queryContext: EntityQueryContext,
_evaluationContext: EntityPrivacyPolicyEvaluationContext,
_entity: BlahEntity
): Promise<RuleEvaluationResult> {
throw new Error('WooHoo!');
@@ -245,12 +247,19 @@ describe(EntityPrivacyPolicy, () => {
it('throws EntityNotAuthorizedError when deny', async () => {
const viewerContext = instance(mock(ViewerContext));
const queryContext = instance(mock(EntityQueryContext));
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
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)
policy.authorizeCreateAsync(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
entity,
metricsAdapter
)
).rejects.toBeInstanceOf(EntityNotAuthorizedError);
verify(
metricsAdapterMock.logAuthorizationEvent(
@@ -267,6 +276,7 @@ describe(EntityPrivacyPolicy, () => {
it('returns entity when allowed', async () => {
const viewerContext = instance(mock(ViewerContext));
const queryContext = instance(mock(EntityQueryContext));
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const metricsAdapterMock = mock<IEntityMetricsAdapter>();
const metricsAdapter = instance(metricsAdapterMock);
const entity = new BlahEntity(viewerContext, { id: '1' });
@@ -274,6 +284,7 @@ describe(EntityPrivacyPolicy, () => {
const approvedEntity = await policy.authorizeCreateAsync(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
entity,
metricsAdapter
);
@@ -293,12 +304,19 @@ describe(EntityPrivacyPolicy, () => {
it('throws EntityNotAuthorizedError when all skipped', async () => {
const viewerContext = instance(mock(ViewerContext));
const queryContext = instance(mock(EntityQueryContext));
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
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)
policy.authorizeCreateAsync(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
entity,
metricsAdapter
)
).rejects.toBeInstanceOf(EntityNotAuthorizedError);
verify(
metricsAdapterMock.logAuthorizationEvent(
@@ -315,12 +333,19 @@ describe(EntityPrivacyPolicy, () => {
it('throws EntityNotAuthorizedError when empty policy', async () => {
const viewerContext = instance(mock(ViewerContext));
const queryContext = instance(mock(EntityQueryContext));
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
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)
policy.authorizeCreateAsync(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
entity,
metricsAdapter
)
).rejects.toBeInstanceOf(EntityNotAuthorizedError);
verify(
metricsAdapterMock.logAuthorizationEvent(
@@ -337,12 +362,19 @@ describe(EntityPrivacyPolicy, () => {
it('throws when rule throws', async () => {
const viewerContext = instance(mock(ViewerContext));
const queryContext = instance(mock(EntityQueryContext));
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
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)
policy.authorizeCreateAsync(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
entity,
metricsAdapter
)
).rejects.toThrowError('WooHoo!');
verify(metricsAdapterMock.logAuthorizationEvent(anything())).never();
});
@@ -352,6 +384,7 @@ describe(EntityPrivacyPolicy, () => {
it('returns entity when denied but calls denialHandler', async () => {
const viewerContext = instance(mock(ViewerContext));
const queryContext = instance(mock(EntityQueryContext));
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const metricsAdapterMock = mock<IEntityMetricsAdapter>();
const metricsAdapter = instance(metricsAdapterMock);
const entity = new BlahEntity(viewerContext, { id: '1' });
@@ -362,6 +395,7 @@ describe(EntityPrivacyPolicy, () => {
const approvedEntity = await policy.authorizeCreateAsync(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
entity,
metricsAdapter
);
@@ -384,6 +418,7 @@ describe(EntityPrivacyPolicy, () => {
it('does not log when not denied', async () => {
const viewerContext = instance(mock(ViewerContext));
const queryContext = instance(mock(EntityQueryContext));
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const metricsAdapterMock = mock<IEntityMetricsAdapter>();
const metricsAdapter = instance(metricsAdapterMock);
const entity = new BlahEntity(viewerContext, { id: '1' });
@@ -394,6 +429,7 @@ describe(EntityPrivacyPolicy, () => {
const approvedEntity = await policy.authorizeCreateAsync(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
entity,
metricsAdapter
);
@@ -416,6 +452,7 @@ describe(EntityPrivacyPolicy, () => {
it('passes through other errors', async () => {
const viewerContext = instance(mock(ViewerContext));
const queryContext = instance(mock(EntityQueryContext));
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const metricsAdapterMock = mock<IEntityMetricsAdapter>();
const metricsAdapter = instance(metricsAdapterMock);
const entity = new BlahEntity(viewerContext, { id: '1' });
@@ -424,7 +461,13 @@ describe(EntityPrivacyPolicy, () => {
const policySpy = spy(policy);
await expect(
policy.authorizeCreateAsync(viewerContext, queryContext, entity, metricsAdapter)
policy.authorizeCreateAsync(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
entity,
metricsAdapter
)
).rejects.toThrowError('WooHoo!');
verify(policySpy.denyHandler(anyOfClass(EntityNotAuthorizedError))).never();
@@ -437,6 +480,7 @@ describe(EntityPrivacyPolicy, () => {
it('denies when denied but calls denialHandler', async () => {
const viewerContext = instance(mock(ViewerContext));
const queryContext = instance(mock(EntityQueryContext));
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const metricsAdapterMock = mock<IEntityMetricsAdapter>();
const metricsAdapter = instance(metricsAdapterMock);
const entity = new BlahEntity(viewerContext, { id: '1' });
@@ -445,7 +489,13 @@ describe(EntityPrivacyPolicy, () => {
const policySpy = spy(policy);
await expect(
policy.authorizeCreateAsync(viewerContext, queryContext, entity, metricsAdapter)
policy.authorizeCreateAsync(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
entity,
metricsAdapter
)
).rejects.toBeInstanceOf(EntityNotAuthorizedError);
verify(policySpy.denyHandler(anyOfClass(EntityNotAuthorizedError))).once();
@@ -465,6 +515,7 @@ describe(EntityPrivacyPolicy, () => {
it('does not log when not denied', async () => {
const viewerContext = instance(mock(ViewerContext));
const queryContext = instance(mock(EntityQueryContext));
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const metricsAdapterMock = mock<IEntityMetricsAdapter>();
const metricsAdapter = instance(metricsAdapterMock);
const entity = new BlahEntity(viewerContext, { id: '1' });
@@ -475,6 +526,7 @@ describe(EntityPrivacyPolicy, () => {
const approvedEntity = await policy.authorizeCreateAsync(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
entity,
metricsAdapter
);
@@ -497,6 +549,7 @@ describe(EntityPrivacyPolicy, () => {
it('passes through other errors', async () => {
const viewerContext = instance(mock(ViewerContext));
const queryContext = instance(mock(EntityQueryContext));
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const metricsAdapterMock = mock<IEntityMetricsAdapter>();
const metricsAdapter = instance(metricsAdapterMock);
const entity = new BlahEntity(viewerContext, { id: '1' });
@@ -505,7 +558,13 @@ describe(EntityPrivacyPolicy, () => {
const policySpy = spy(policy);
await expect(
policy.authorizeCreateAsync(viewerContext, queryContext, entity, metricsAdapter)
policy.authorizeCreateAsync(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
entity,
metricsAdapter
)
).rejects.toThrowError('WooHoo!');
verify(policySpy.denyHandler(anyOfClass(EntityNotAuthorizedError))).never();
@@ -79,6 +79,7 @@ describe(EntitySecondaryCacheLoader, () => {
vc1,
anyOfClass(EntityNonTransactionalQueryContext),
anything(),
anything(),
anything()
)
).once();
@@ -1,6 +1,7 @@
import { mock, verify, instance } from 'ts-mockito';
import EntityLoaderFactory from '../EntityLoaderFactory';
import { EntityPrivacyPolicyEvaluationContext } from '../EntityPrivacyPolicy';
import { EntityQueryContext } from '../EntityQueryContext';
import ViewerContext from '../ViewerContext';
import ViewerScopedEntityLoaderFactory from '../ViewerScopedEntityLoaderFactory';
@@ -8,6 +9,7 @@ import ViewerScopedEntityLoaderFactory from '../ViewerScopedEntityLoaderFactory'
describe(ViewerScopedEntityLoaderFactory, () => {
it('correctly scopes viewer to entity loads', async () => {
const viewerContext = instance(mock(ViewerContext));
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const queryContext = instance(mock(EntityQueryContext));
const baseLoader = mock<EntityLoaderFactory<any, any, any, any, any, any>>(EntityLoaderFactory);
const baseLoaderInstance = instance(baseLoader);
@@ -21,8 +23,8 @@ describe(ViewerScopedEntityLoaderFactory, () => {
any
>(baseLoaderInstance, viewerContext);
viewerScopedEntityLoader.forLoad(queryContext);
viewerScopedEntityLoader.forLoad(queryContext, privacyPolicyEvaluationContext);
verify(baseLoader.forLoad(viewerContext, queryContext)).once();
verify(baseLoader.forLoad(viewerContext, queryContext, privacyPolicyEvaluationContext)).once();
});
});
@@ -1,6 +1,7 @@
import { mock, instance, verify } from 'ts-mockito';
import EntityMutatorFactory from '../EntityMutatorFactory';
import { EntityPrivacyPolicyEvaluationContext } from '../EntityPrivacyPolicy';
import { EntityQueryContext } from '../EntityQueryContext';
import ViewerContext from '../ViewerContext';
import ViewerScopedEntityMutatorFactory from '../ViewerScopedEntityMutatorFactory';
@@ -9,6 +10,7 @@ import TestEntity, { TestFields, TestEntityPrivacyPolicy } from '../testfixtures
describe(ViewerScopedEntityMutatorFactory, () => {
it('correctly scopes viewer to entity mutations', async () => {
const viewerContext = instance(mock(ViewerContext));
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const queryContext = instance(mock(EntityQueryContext));
const baseMutatorFactory =
mock<
@@ -25,8 +27,10 @@ describe(ViewerScopedEntityMutatorFactory, () => {
keyof TestFields
>(baseMutatorFactoryInstance, viewerContext);
viewerScopedEntityLoader.forCreate(queryContext);
viewerScopedEntityLoader.forCreate(queryContext, privacyPolicyEvaluationContext);
verify(baseMutatorFactory.forCreate(viewerContext, queryContext)).once();
verify(
baseMutatorFactory.forCreate(viewerContext, queryContext, privacyPolicyEvaluationContext)
).once();
});
});
@@ -34,6 +34,6 @@ export default class EntityInvalidFieldValueError<
fieldName: N,
fieldValue?: TFields[N]
) {
super(`Entity field not valid: ${entityClass.name} (${fieldName} = ${fieldValue})`);
super(`Entity field not valid: ${entityClass.name} (${String(fieldName)} = ${fieldValue})`);
}
}
@@ -34,6 +34,6 @@ export default class EntityNotFoundError<
fieldName: N,
fieldValue: TFields[N]
) {
super(`Entity not found: ${entityClass.name} (${fieldName} = ${fieldValue})`);
super(`Entity not found: ${entityClass.name} (${String(fieldName)} = ${fieldValue})`);
}
}
+2 -5
View File
@@ -4,6 +4,8 @@
* @module @expo/entity
*/
export { default as ComposedEntityCacheAdapter } from './ComposedEntityCacheAdapter';
export { default as ComposedSecondaryEntityCache } from './ComposedSecondaryEntityCache';
export { default as GenericSecondaryEntityCache } from './GenericSecondaryEntityCache';
export { default as EnforcingEntityLoader } from './EnforcingEntityLoader';
export { default as Entity } from './Entity';
@@ -45,11 +47,6 @@ export { default as IEntityCacheAdapterProvider } from './IEntityCacheAdapterPro
export { default as IEntityDatabaseAdapterProvider } from './IEntityDatabaseAdapterProvider';
export { default as EntityQueryContextProvider } from './EntityQueryContextProvider';
export { default as IEntityGenericCacher } from './IEntityGenericCacher';
export { default as SimplePartsCacher } from './SimplePartsCacher';
export { default as PartsCacher } from './PartsCacher';
export * from './PartsCacher';
export { default as PartsCacheAdapter } from './PartsCacheAdapter';
export { default as ComposedPartsCacher } from './ComposedPartsCacher';
export { default as ReadonlyEntity } from './ReadonlyEntity';
export { default as ViewerContext } from './ViewerContext';
export { default as ViewerScopedEntityCompanion } from './ViewerScopedEntityCompanion';
@@ -11,7 +11,10 @@ import {
timeAndLogLoadEventAsync,
timeAndLogLoadMapEventAsync,
} from '../metrics/EntityMetricsUtils';
import IEntityMetricsAdapter, { EntityMetricsLoadType } from '../metrics/IEntityMetricsAdapter';
import IEntityMetricsAdapter, {
EntityMetricsLoadType,
IncrementLoadCountEventType,
} from '../metrics/IEntityMetricsAdapter';
import { computeIfAbsent, zipToMap } from '../utils/collections/maps';
import ReadThroughEntityCache from './ReadThroughEntityCache';
@@ -57,7 +60,8 @@ export default class EntityDataManager<TFields> {
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<ReadonlyMap<NonNullable<TFields[N]>, readonly Readonly<TFields>[]>> {
this.metricsAdapter.incrementDataManagerCacheLoadCount({
this.metricsAdapter.incrementDataManagerLoadCount({
type: IncrementLoadCountEventType.CACHE,
fieldValueCount: fieldValues.length,
entityClassName: this.entityClassName,
});
@@ -65,7 +69,8 @@ export default class EntityDataManager<TFields> {
fieldName,
fieldValues,
async (fetcherValues) => {
this.metricsAdapter.incrementDataManagerDatabaseLoadCount({
this.metricsAdapter.incrementDataManagerLoadCount({
type: IncrementLoadCountEventType.DATABASE,
fieldValueCount: fieldValues.length,
entityClassName: this.entityClassName,
});
@@ -108,7 +113,9 @@ export default class EntityDataManager<TFields> {
);
if (nullOrUndefinedValueIndex >= 0) {
throw new Error(
`Invalid load: ${this.entityClassName} (${fieldName} = ${fieldValues[nullOrUndefinedValueIndex]})`
`Invalid load: ${this.entityClassName} (${String(fieldName)} = ${
fieldValues[nullOrUndefinedValueIndex]
})`
);
}
@@ -117,7 +124,8 @@ export default class EntityDataManager<TFields> {
return await this.databaseAdapter.fetchManyWhereAsync(queryContext, fieldName, fieldValues);
}
this.metricsAdapter.incrementDataManagerDataloaderLoadCount({
this.metricsAdapter.incrementDataManagerLoadCount({
type: IncrementLoadCountEventType.DATALOADER,
fieldValueCount: fieldValues.length,
entityClassName: this.entityClassName,
});
@@ -24,7 +24,7 @@ export const getDatabaseFieldForEntityField = <TFields>(
entityField: keyof TFields
): string => {
const databaseField = entityConfiguration.entityToDBFieldsKeyMapping.get(entityField);
invariant(databaseField, `database field mapping missing for ${entityField}`);
invariant(databaseField, `database field mapping missing for ${String(entityField)}`);
return databaseField!;
};
@@ -102,7 +102,9 @@ export default class ReadThroughEntityCache<TFields> {
// TODO(wschurman): emit or throw here since console may not be available
// eslint-disable-next-line no-console
console.warn(
`unique key ${fieldName} in ${this.entityConfiguration.tableName} returned multiple rows for ${fieldValue}`
`unique key ${String(fieldName)} in ${
this.entityConfiguration.tableName
} returned multiple rows for ${fieldValue}`
);
continue;
}
@@ -4,7 +4,6 @@ import {
when,
anything,
verify,
anyNumber,
objectContaining,
spy,
anyString,
@@ -13,7 +12,10 @@ import {
} from 'ts-mockito';
import EntityDatabaseAdapter from '../../EntityDatabaseAdapter';
import IEntityMetricsAdapter, { EntityMetricsLoadType } from '../../metrics/IEntityMetricsAdapter';
import IEntityMetricsAdapter, {
EntityMetricsLoadType,
IncrementLoadCountEventType,
} from '../../metrics/IEntityMetricsAdapter';
import NoOpEntityMetricsAdapter from '../../metrics/NoOpEntityMetricsAdapter';
import TestEntity, { testEntityConfiguration, TestFields } from '../../testfixtures/TestEntity';
import {
@@ -519,24 +521,27 @@ describe(EntityDataManager, () => {
).once();
verify(
metricsAdapterMock.incrementDataManagerDataloaderLoadCount(
metricsAdapterMock.incrementDataManagerLoadCount(
deepEqual({
type: IncrementLoadCountEventType.DATALOADER,
fieldValueCount: 1,
entityClassName: TestEntity.name,
})
)
).once();
verify(
metricsAdapterMock.incrementDataManagerCacheLoadCount(
metricsAdapterMock.incrementDataManagerLoadCount(
deepEqual({
type: IncrementLoadCountEventType.CACHE,
fieldValueCount: 1,
entityClassName: TestEntity.name,
})
)
).once();
verify(
metricsAdapterMock.incrementDataManagerDatabaseLoadCount(
metricsAdapterMock.incrementDataManagerLoadCount(
deepEqual({
type: IncrementLoadCountEventType.DATABASE,
fieldValueCount: 1,
entityClassName: TestEntity.name,
})
@@ -565,9 +570,7 @@ describe(EntityDataManager, () => {
)
).once();
verify(metricsAdapterMock.incrementDataManagerDataloaderLoadCount(anyNumber())).never();
verify(metricsAdapterMock.incrementDataManagerCacheLoadCount(anyNumber())).never();
verify(metricsAdapterMock.incrementDataManagerDatabaseLoadCount(anyNumber())).never();
verify(metricsAdapterMock.incrementDataManagerLoadCount(anything())).never();
});
it('throws when a load-by value is null or undefined', async () => {
@@ -9,10 +9,28 @@ export enum EntityMetricsLoadType {
LOAD_MANY_RAW,
}
/**
* Event about a single call to an {@link EntityLoader} method.
*/
export interface EntityMetricsLoadEvent {
/**
* {@link EntityMetricsLoadType} for this load.
*/
type: EntityMetricsLoadType;
/**
* Class name of the {@link Entity} being loaded.
*/
entityClassName: string;
/**
* Total duration of this load, including fetch and construction of entities.
*/
duration: number;
/**
* Number of entities returned for this load.
*/
count: number;
}
@@ -23,13 +41,57 @@ export enum EntityMetricsMutationType {
}
export interface EntityMetricsMutationEvent {
/**
* {@link EntityMetricsMutationType} for this mutation.
*/
type: EntityMetricsMutationType;
/**
* Class name of the {@link Entity} being mutated.
*/
entityClassName: string;
/**
* Total duration of this mutation.
*/
duration: number;
}
export enum IncrementLoadCountEventType {
/**
* Type for when a dataloader load is initiated via the standard load methods
* since all loads go through a dataloader.
*/
DATALOADER,
/**
* Type for when a cache load is initiated due to a dataloader miss.
*/
CACHE,
/**
* Type for when a database load is initiated due to a dataloader and cache miss, when an entity query doesn't support caching, or during a transaction.
*/
DATABASE,
}
/**
* Event used to record dataloader, cache, and database load counts in {@link EntityDataManager}.
*/
export interface IncrementLoadCountEvent {
/**
* Type of this event.
*/
type: IncrementLoadCountEventType;
/**
* Number of field values being loaded for this call.
*/
fieldValueCount: number;
/**
* Class name of the {@link Entity} being loaded.
*/
entityClassName: string;
}
@@ -38,7 +100,13 @@ export enum EntityMetricsAuthorizationResult {
ALLOW,
}
/**
* Event used to record a singe {@link EntityPrivacyPolicy} authorization.
*/
export interface EntityMetricsAuthorizationEvent {
/**
* Class name of the {@link Entity} being authorized.
*/
entityClassName: string;
action: EntityAuthorizationAction;
evaluationResult: EntityMetricsAuthorizationResult;
@@ -69,25 +137,10 @@ export default interface IEntityMetricsAdapter {
logMutatorMutationEvent(mutationEvent: EntityMetricsMutationEvent): void;
/**
* Called when a dataloader load is initiated via the standard
* load methods (not equality conjunction or raw).
* Called when a dataloader, cache, or database load is initiated via the standard
* load methods (not equality conjunction or raw). Most commonly used for logging
* a waterfall to determine dataloader and cache hit rates and ratios.
* @param fieldValueCount - count of field values being loaded for a field
*/
incrementDataManagerDataloaderLoadCount(incrementLoadCountEvent: IncrementLoadCountEvent): void;
/**
* Called when a cache load is initiated via the standard
* load methods (not equality conjunction or raw). Occurs upon a dataloader
* miss.
* @param fieldValueCount - count of field values being loaded for a field
*/
incrementDataManagerCacheLoadCount(incrementLoadCountEvent: IncrementLoadCountEvent): void;
/**
* Called when a database load is initiated via the standard
* load methods (not equality conjunction or raw). Occurs upon a cache
* miss or when fetching an uncacheable field.
* @param fieldValueCount - count of field values being loaded for a field
*/
incrementDataManagerDatabaseLoadCount(incrementLoadCountEvent: IncrementLoadCountEvent): void;
incrementDataManagerLoadCount(incrementLoadCountEvent: IncrementLoadCountEvent): void;
}
@@ -9,9 +9,5 @@ export default class NoOpEntityMetricsAdapter implements IEntityMetricsAdapter {
logAuthorizationEvent(_authorizationEvent: EntityMetricsAuthorizationEvent): void {}
logDataManagerLoadEvent(_loadEvent: EntityMetricsLoadEvent): void {}
logMutatorMutationEvent(_mutationEvent: EntityMetricsMutationEvent): void {}
incrementDataManagerDataloaderLoadCount(
_incrementLoadCountEvent: IncrementLoadCountEvent
): void {}
incrementDataManagerCacheLoadCount(_incrementLoadCountEvent: IncrementLoadCountEvent): void {}
incrementDataManagerDatabaseLoadCount(_incrementLoadCountEvent: IncrementLoadCountEvent): void {}
incrementDataManagerLoadCount(_incrementLoadCountEvent: IncrementLoadCountEvent): void {}
}
@@ -1,3 +1,4 @@
import { EntityPrivacyPolicyEvaluationContext } from '../EntityPrivacyPolicy';
import { EntityQueryContext } from '../EntityQueryContext';
import ReadonlyEntity from '../ReadonlyEntity';
import ViewerContext from '../ViewerContext';
@@ -16,6 +17,7 @@ export default class AlwaysAllowPrivacyPolicyRule<
async evaluateAsync(
_viewerContext: TViewerContext,
_queryContext: EntityQueryContext,
_evaluationContext: EntityPrivacyPolicyEvaluationContext,
_entity: TEntity
): Promise<RuleEvaluationResult> {
return RuleEvaluationResult.ALLOW;
@@ -1,3 +1,4 @@
import { EntityPrivacyPolicyEvaluationContext } from '../EntityPrivacyPolicy';
import { EntityQueryContext } from '../EntityQueryContext';
import ReadonlyEntity from '../ReadonlyEntity';
import ViewerContext from '../ViewerContext';
@@ -16,6 +17,7 @@ export default class AlwaysDenyPrivacyPolicyRule<
async evaluateAsync(
_viewerContext: TViewerContext,
_queryContext: EntityQueryContext,
_evaluationContext: EntityPrivacyPolicyEvaluationContext,
_entity: TEntity
): Promise<RuleEvaluationResult> {
return RuleEvaluationResult.DENY;
@@ -1,3 +1,4 @@
import { EntityPrivacyPolicyEvaluationContext } from '../EntityPrivacyPolicy';
import { EntityQueryContext } from '../EntityQueryContext';
import ReadonlyEntity from '../ReadonlyEntity';
import ViewerContext from '../ViewerContext';
@@ -16,6 +17,7 @@ export default class AlwaysSkipPrivacyPolicyRule<
async evaluateAsync(
_viewerContext: TViewerContext,
_queryContext: EntityQueryContext,
_evaluationContext: EntityPrivacyPolicyEvaluationContext,
_entity: TEntity
): Promise<RuleEvaluationResult> {
return RuleEvaluationResult.SKIP;
@@ -1,3 +1,4 @@
import { EntityPrivacyPolicyEvaluationContext } from '../EntityPrivacyPolicy';
import { EntityQueryContext } from '../EntityQueryContext';
import ReadonlyEntity from '../ReadonlyEntity';
import ViewerContext from '../ViewerContext';
@@ -45,6 +46,7 @@ export default abstract class PrivacyPolicyRule<
abstract evaluateAsync(
viewerContext: TViewerContext,
queryContext: EntityQueryContext,
evaluationContext: EntityPrivacyPolicyEvaluationContext,
entity: TEntity
): Promise<RuleEvaluationResult>;
}
@@ -1,5 +1,6 @@
import { mock, instance, anything } from 'ts-mockito';
import { EntityPrivacyPolicyEvaluationContext } from '../../EntityPrivacyPolicy';
import { EntityQueryContext } from '../../EntityQueryContext';
import ViewerContext from '../../ViewerContext';
import { describePrivacyPolicyRule } from '../../utils/testing/PrivacyPolicyRuleTestUtils';
@@ -10,6 +11,7 @@ describePrivacyPolicyRule(new AlwaysAllowPrivacyPolicyRule(), {
{
viewerContext: instance(mock(ViewerContext)),
queryContext: instance(mock(EntityQueryContext)),
evaluationContext: instance(mock<EntityPrivacyPolicyEvaluationContext>()),
entity: anything(),
},
],
@@ -1,5 +1,6 @@
import { mock, instance, anything } from 'ts-mockito';
import { EntityPrivacyPolicyEvaluationContext } from '../../EntityPrivacyPolicy';
import { EntityQueryContext } from '../../EntityQueryContext';
import ViewerContext from '../../ViewerContext';
import { describePrivacyPolicyRule } from '../../utils/testing/PrivacyPolicyRuleTestUtils';
@@ -10,6 +11,7 @@ describePrivacyPolicyRule(new AlwaysDenyPrivacyPolicyRule(), {
{
viewerContext: instance(mock(ViewerContext)),
queryContext: instance(mock(EntityQueryContext)),
evaluationContext: instance(mock<EntityPrivacyPolicyEvaluationContext>()),
entity: anything(),
},
],
@@ -1,5 +1,6 @@
import { mock, instance, anything } from 'ts-mockito';
import { EntityPrivacyPolicyEvaluationContext } from '../../EntityPrivacyPolicy';
import { EntityQueryContext } from '../../EntityQueryContext';
import ViewerContext from '../../ViewerContext';
import { describePrivacyPolicyRule } from '../../utils/testing/PrivacyPolicyRuleTestUtils';
@@ -10,6 +11,7 @@ describePrivacyPolicyRule(new AlwaysSkipPrivacyPolicyRule(), {
{
viewerContext: instance(mock(ViewerContext)),
queryContext: instance(mock(EntityQueryContext)),
evaluationContext: instance(mock<EntityPrivacyPolicyEvaluationContext>()),
entity: anything(),
},
],
Loaded 100 of 105 files, more files were not shown because too many files have changed in this diff. Show more