Compare commits

..
Author SHA1 Message Date
Will Schurman e0704a8249 [entity][entity-cache-adapter-redis] move transformer responsibility 2022-02-04 10:01:09 -08:00
James Ide a2c6dd7488 v0.22.0 (#151) 2022-02-03 18:52:25 -08:00
James Ide e1b0ee5281 feat: Upgrade knex to 1.x in @expo/entity-database-adapter-knex (#150)
Upgrades knex to 1.x internally. This changes the behavior of `returning` but not in a way that affects how the knex adapter uses it: `returning('*')` behaves the same as before.

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

- adds an entity field for IP addresses, validates both v4 and v6 type IPs
- ip address field functionality moved to its own package
- specified `@expo/entity` as a dev & peer dependency
2021-10-15 10:40:20 -07:00
Will Schurman 677d15174c v0.18.0 (#140) 2021-10-14 14:51:59 -07:00
Will Schurman 1220477aa3 feat: expose describeFieldTestCase for use in other packages (#139) 2021-10-14 14:41:26 -07:00
Will Schurman 5c7efe1678 feat: use better QueryContext types for transactional triggers and validators (#136) 2021-09-17 17:24:48 -07:00
Will Schurman 4263cb9b8b feat: enable noImplicitOverride tsc setting (#135) 2021-09-07 15:18:22 -07:00
Will Schurman 7612392dbf feat: upgrade TypeScript to 4.4 (#134) 2021-09-07 15:17:39 -07:00
109 changed files with 2585 additions and 1081 deletions

No files matched your search

+64
View File
@@ -3,6 +3,70 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
### Features
* Upgrade knex to 1.x in `@expo/entity-database-adapter-knex` ([#150](https://github.com/expo/entity/issues/150)) ([e1b0ee5](https://github.com/expo/entity/commit/e1b0ee528175646239d88aa71ffce73983eeb52f))
# [0.21.0](https://github.com/expo/entity/compare/v0.20.0...v0.21.0) (2022-01-03)
### Features
* add pre-commit callbacks on EntityQueryContext ([#147](https://github.com/expo/entity/issues/147)) ([f1d9847](https://github.com/expo/entity/commit/f1d9847210f5775de964509e44ce493e44482b21))
# [0.20.0](https://github.com/expo/entity/compare/v0.19.0...v0.20.0) (2021-12-29)
### Bug Fixes
* separate post-commit invalidation callbacks from trigger and user callbacks ([#144](https://github.com/expo/entity/issues/144)) ([efa131e](https://github.com/expo/entity/commit/efa131ef29532accf0e51b6d867ffe15a27b4c90))
### Features
* add cascading deletion info ([#145](https://github.com/expo/entity/issues/145)) ([3727191](https://github.com/expo/entity/commit/372719176742d3ead6b7cbca29cb66b09b3fe09c))
# [0.19.0](https://github.com/expo/entity/compare/v0.18.0...v0.19.0) (2021-10-28)
### Features
* add authorization metrics ([#142](https://github.com/expo/entity/issues/142)) ([7cf54d8](https://github.com/expo/entity/commit/7cf54d83c52e104159a3e2e675cdda2d29afe204))
* add IP support ([#138](https://github.com/expo/entity/issues/138)) ([1405a91](https://github.com/expo/entity/commit/1405a9126ee3a56c3e2a38bf3b697283744f5b5d))
# [0.18.0](https://github.com/expo/entity/compare/v0.17.0...v0.18.0) (2021-10-14)
### Features
* enable noImplicitOverride tsc setting ([#135](https://github.com/expo/entity/issues/135)) ([4263cb9](https://github.com/expo/entity/commit/4263cb9b8b69fe4fb68c74ec1c7aba04508a1555))
* expose describeFieldTestCase for use in other packages ([#139](https://github.com/expo/entity/issues/139)) ([1220477](https://github.com/expo/entity/commit/1220477aa331349b482ad015b44f76fa2bda7b90))
* upgrade TypeScript to 4.4 ([#134](https://github.com/expo/entity/issues/134)) ([7612392](https://github.com/expo/entity/commit/7612392dbfd9778d25c465c1626c372d75a6d05a))
* use better QueryContext types for transactional triggers and validators ([#136](https://github.com/expo/entity/issues/136)) ([5c7efe1](https://github.com/expo/entity/commit/5c7efe1678424ff907687e9327175bdc2538c3e0))
# [0.17.0](https://github.com/expo/entity/compare/v0.16.0...v0.17.0) (2021-08-16)
+1 -1
View File
@@ -4,5 +4,5 @@
],
"npmClient": "yarn",
"useWorkspaces": true,
"version": "0.17.0"
"version": "0.22.0"
}
+7 -4
View File
@@ -8,6 +8,7 @@
"tsc": "lerna run tsc",
"clean": "lerna run clean",
"lint": "lerna run lint",
"lint-fix": "lerna run lint-fix",
"test": "lerna run test",
"integration": "./resources/run-with-docker lerna run integration-no-setup",
"prepublish": "lerna link && lerna run clean",
@@ -20,6 +21,7 @@
"@types/invariant": "^2.2.33",
"@types/ioredis": "^4.26.4",
"@types/jest": "^26.0.10",
"@types/jsbn": "^1.2.29",
"@types/node": "^14.6.0",
"@types/uuid": "^8.3.0",
"@typescript-eslint/eslint-plugin": "^4.14.0",
@@ -32,17 +34,18 @@
"lerna": "^4.0.0",
"nullthrows": "^1.1.1",
"pg": "^8.6.0",
"prettier": "^2.1.0",
"prettier": "^2.3.2",
"ts-jest": "~26.4.4",
"ts-mockito": "^2.6.1",
"typedoc": "^0.20.36",
"typescript": "^4.1.3",
"typedoc": "^0.21.9",
"typescript": "^4.4.2",
"uuid": "^8.3.0"
},
"dependencies": {
"@expo/entity": "file:packages/entity",
"@expo/entity-cache-adapter-redis": "file:packages/entity-cache-adapter-redis",
"@expo/entity-database-adapter-knex": "file:packages/entity-database-adapter-knex",
"@expo/entity-secondary-cache-redis": "file:packages/entity-secondary-cache-redis"
"@expo/entity-secondary-cache-redis": "file:packages/entity-secondary-cache-redis",
"@expo/entity-ip-address-field": "file:packages/entity-ip-address-field"
}
}
@@ -3,6 +3,50 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.21.0](https://github.com/expo/entity/compare/v0.20.0...v0.21.0) (2022-01-03)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.20.0](https://github.com/expo/entity/compare/v0.19.0...v0.20.0) (2021-12-29)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.19.0](https://github.com/expo/entity/compare/v0.18.0...v0.19.0) (2021-10-28)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.18.0](https://github.com/expo/entity/compare/v0.17.0...v0.18.0) (2021-10-14)
### Features
* enable noImplicitOverride tsc setting ([#135](https://github.com/expo/entity/issues/135)) ([4263cb9](https://github.com/expo/entity/commit/4263cb9b8b69fe4fb68c74ec1c7aba04508a1555))
* upgrade TypeScript to 4.4 ([#134](https://github.com/expo/entity/issues/134)) ([7612392](https://github.com/expo/entity/commit/7612392dbfd9778d25c465c1626c372d75a6d05a))
# [0.17.0](https://github.com/expo/entity/compare/v0.16.0...v0.17.0) (2021-08-16)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
@@ -12,7 +12,7 @@ During `EntityCompanionProvider` instantiation:
import Redis from 'ioredis';
const redisCacheAdapterContext = {
redisClient: new Redis(new URL(process.env.REDIS_URL!).toString()),
redisClient: new Redis(new URL(process.env['REDIS_URL']!).toString()),
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
@@ -1,6 +1,6 @@
{
"name": "@expo/entity-cache-adapter-redis",
"version": "0.17.0",
"version": "0.22.0",
"description": "Redis cache adapter for @expo/entity",
"files": [
"build",
@@ -12,6 +12,7 @@
"tsc": "tsc",
"clean": "rm -rf build coverage coverage-integration",
"lint": "eslint src",
"lint-fix": "eslint src --fix",
"test": "jest --rootDir . --config ../../resources/jest.config.js",
"integration": "../../resources/run-with-docker yarn integration-no-setup",
"integration-no-setup": "jest --config ../../resources/jest-integration.config.js --rootDir . --runInBand --passWithNoTests",
@@ -32,6 +33,6 @@
"ioredis": "^4.27.3"
},
"devDependencies": {
"@expo/entity": "^0.17.0"
"@expo/entity": "^0.22.0"
}
}
@@ -1,7 +1,14 @@
import { CacheLoadResult, CacheStatus } from '@expo/entity';
import {
CacheLoadResult,
CacheStatus,
EntityConfiguration,
transformCacheObjectToFields,
transformFieldsToCacheObject,
} from '@expo/entity';
import { Redis } from 'ioredis';
import wrapNativeRedisCall from './errors/wrapNativeRedisCall';
import { redisTransformerMap } from './RedisCommon';
import wrapNativeRedisCallAsync from './errors/wrapNativeRedisCallAsync';
// Sentinel value we store in Redis to negatively cache a database miss.
// The sentinel value is distinct from any (positively) cached value.
@@ -26,21 +33,26 @@ export interface GenericRedisCacheContext {
ttlSecondsNegative: number;
}
export default class GenericRedisCacher {
constructor(private readonly context: GenericRedisCacheContext) {}
export default class GenericRedisCacher<TFields> {
constructor(
private readonly context: GenericRedisCacheContext,
private readonly entityConfiguration: EntityConfiguration<TFields>
) {}
public async loadManyAsync(
keys: readonly string[]
): Promise<ReadonlyMap<string, CacheLoadResult>> {
): Promise<ReadonlyMap<string, CacheLoadResult<TFields>>> {
if (keys.length === 0) {
return new Map();
}
const redisResults = await wrapNativeRedisCall(() => this.context.redisClient.mget(...keys));
const redisResults = await wrapNativeRedisCallAsync(() =>
this.context.redisClient.mget(...keys)
);
const results = new Map<string, CacheLoadResult>();
const results = new Map<string, CacheLoadResult<TFields>>();
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
const key = keys[i]!;
const redisResult = redisResults[i];
if (redisResult === DOES_NOT_EXIST_REDIS) {
@@ -50,7 +62,11 @@ export default class GenericRedisCacher {
} else if (redisResult) {
results.set(key, {
status: CacheStatus.HIT,
item: JSON.parse(redisResult),
item: transformCacheObjectToFields(
this.entityConfiguration,
redisTransformerMap,
JSON.parse(redisResult)
),
});
} else {
results.set(key, {
@@ -61,7 +77,7 @@ export default class GenericRedisCacher {
return results;
}
public async cacheManyAsync(objectMap: ReadonlyMap<string, object>): Promise<void> {
public async cacheManyAsync(objectMap: ReadonlyMap<string, Readonly<TFields>>): Promise<void> {
if (objectMap.size === 0) {
return;
}
@@ -70,12 +86,14 @@ export default class GenericRedisCacher {
objectMap.forEach((object, key) => {
redisTransaction = redisTransaction.set(
key,
JSON.stringify(object),
JSON.stringify(
transformFieldsToCacheObject(this.entityConfiguration, redisTransformerMap, object)
),
'EX',
this.context.ttlSecondsPositive
);
});
await wrapNativeRedisCall(() => redisTransaction.exec());
await wrapNativeRedisCallAsync(() => redisTransaction.exec());
}
public async cacheDBMissesAsync(keys: string[]): Promise<void> {
@@ -92,7 +110,7 @@ export default class GenericRedisCacher {
this.context.ttlSecondsNegative
);
});
await wrapNativeRedisCall(() => redisTransaction.exec());
await wrapNativeRedisCallAsync(() => redisTransaction.exec());
}
public async invalidateManyAsync(keys: string[]): Promise<void> {
@@ -100,6 +118,6 @@ export default class GenericRedisCacher {
return;
}
await wrapNativeRedisCall(() => this.context.redisClient.del(...keys));
await wrapNativeRedisCallAsync(() => this.context.redisClient.del(...keys));
}
}
@@ -1,15 +1,8 @@
import {
EntityCacheAdapter,
EntityConfiguration,
CacheLoadResult,
FieldTransformerMap,
mapKeys,
} from '@expo/entity';
import { EntityCacheAdapter, EntityConfiguration, CacheLoadResult, mapKeys } from '@expo/entity';
import invariant from 'invariant';
import { Redis } from 'ioredis';
import GenericRedisCacher from './GenericRedisCacher';
import { redisTransformerMap } from './RedisCommon';
export interface RedisCacheAdapterContext {
/**
@@ -49,28 +42,27 @@ export interface RedisCacheAdapterContext {
}
export default class RedisCacheAdapter<TFields> extends EntityCacheAdapter<TFields> {
private readonly genericRedisCacher: GenericRedisCacher;
private readonly genericRedisCacher: GenericRedisCacher<TFields>;
constructor(
entityConfiguration: EntityConfiguration<TFields>,
private readonly context: RedisCacheAdapterContext
) {
super(entityConfiguration);
this.genericRedisCacher = new GenericRedisCacher({
redisClient: context.redisClient,
ttlSecondsNegative: context.ttlSecondsNegative,
ttlSecondsPositive: context.ttlSecondsPositive,
});
}
public getFieldTransformerMap(): FieldTransformerMap {
return redisTransformerMap;
this.genericRedisCacher = new GenericRedisCacher(
{
redisClient: context.redisClient,
ttlSecondsNegative: context.ttlSecondsNegative,
ttlSecondsPositive: context.ttlSecondsPositive,
},
entityConfiguration
);
}
public async loadManyAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult>> {
): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult<TFields>>> {
const redisCacheKeyToFieldValueMapping = new Map(
fieldValues.map((fieldValue) => [this.makeCacheKey(fieldName, fieldValue), fieldValue])
);
@@ -91,7 +83,7 @@ export default class RedisCacheAdapter<TFields> extends EntityCacheAdapter<TFiel
public async cacheManyAsync<N extends keyof TFields>(
fieldName: N,
objectMap: ReadonlyMap<NonNullable<TFields[N]>, object>
objectMap: ReadonlyMap<NonNullable<TFields[N]>, Readonly<TFields>>
): Promise<void> {
await this.genericRedisCacher.cacheManyAsync(
mapKeys(objectMap, (fieldValue) => this.makeCacheKey(fieldName, fieldValue))
@@ -15,7 +15,7 @@ describe(RedisCacheAdapter, () => {
beforeAll(() => {
redisCacheAdapterContext = {
redisClient: new Redis(new URL(process.env.REDIS_URL!).toString()),
redisClient: new Redis(new URL(process.env['REDIS_URL']!).toString()),
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
@@ -13,7 +13,7 @@ describe(RedisCacheAdapter, () => {
beforeAll(() => {
redisCacheAdapterContext = {
redisClient: new Redis(new URL(process.env.REDIS_URL!).toString()),
redisClient: new Redis(new URL(process.env['REDIS_URL']!).toString()),
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
@@ -0,0 +1,39 @@
import { EntityCacheAdapterTransientError } from '@expo/entity';
import wrapNativeRedisCallAsync from '../wrapNativeRedisCallAsync';
describe(wrapNativeRedisCallAsync, () => {
it('rethrows literals', async () => {
const throwingFn = async (): Promise<void> => {
// eslint-disable-next-line no-throw-literal,@typescript-eslint/no-throw-literal
throw 'hello';
};
let capturedThrownThing: any;
try {
await wrapNativeRedisCallAsync(throwingFn);
} catch (e) {
capturedThrownThing = e;
}
expect(capturedThrownThing).not.toBeInstanceOf(Error);
expect(capturedThrownThing).toEqual('hello');
});
it('wraps errors with stacks', async () => {
const throwingFn = async (): Promise<void> => {
const e = new Error('hello');
e.stack = 'world';
throw e;
};
let capturedThrownThing: any;
try {
await wrapNativeRedisCallAsync(throwingFn);
} catch (e) {
capturedThrownThing = e;
}
expect(capturedThrownThing).toBeInstanceOf(EntityCacheAdapterTransientError);
expect(capturedThrownThing.message).toEqual('hello');
expect(capturedThrownThing.stack).toEqual('world');
});
});
@@ -1,11 +1,17 @@
import { EntityCacheAdapterTransientError } from '@expo/entity';
export default async function wrapNativeRedisCall<T>(fn: () => Promise<T>): Promise<T> {
export default async function wrapNativeRedisCallAsync<T>(fn: () => Promise<T>): Promise<T> {
try {
return await fn();
} catch (e) {
if (!(e instanceof Error)) {
throw e;
}
const error = new EntityCacheAdapterTransientError(e.message, e);
error.stack = e.stack;
if (e.stack) {
error.stack = e.stack;
}
throw error;
}
}
@@ -34,7 +34,7 @@ export class RedisTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
ViewerContext,
RedisTestEntity
> {
protected readonly createRules = [
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<
RedisTestEntityFields,
string,
@@ -42,7 +42,7 @@ export class RedisTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
RedisTestEntity
>(),
];
protected readonly readRules = [
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<
RedisTestEntityFields,
string,
@@ -50,7 +50,7 @@ export class RedisTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
RedisTestEntity
>(),
];
protected readonly updateRules = [
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<
RedisTestEntityFields,
string,
@@ -58,7 +58,7 @@ export class RedisTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
RedisTestEntity
>(),
];
protected readonly deleteRules = [
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<
RedisTestEntityFields,
string,
@@ -3,6 +3,59 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
### Features
* Upgrade knex to 1.x in `@expo/entity-database-adapter-knex` ([#150](https://github.com/expo/entity/issues/150)) ([e1b0ee5](https://github.com/expo/entity/commit/e1b0ee528175646239d88aa71ffce73983eeb52f))
# [0.21.0](https://github.com/expo/entity/compare/v0.20.0...v0.21.0) (2022-01-03)
### Features
* add pre-commit callbacks on EntityQueryContext ([#147](https://github.com/expo/entity/issues/147)) ([f1d9847](https://github.com/expo/entity/commit/f1d9847210f5775de964509e44ce493e44482b21))
# [0.20.0](https://github.com/expo/entity/compare/v0.19.0...v0.20.0) (2021-12-29)
### Features
* add cascading deletion info ([#145](https://github.com/expo/entity/issues/145)) ([3727191](https://github.com/expo/entity/commit/372719176742d3ead6b7cbca29cb66b09b3fe09c))
# [0.19.0](https://github.com/expo/entity/compare/v0.18.0...v0.19.0) (2021-10-28)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
# [0.18.0](https://github.com/expo/entity/compare/v0.17.0...v0.18.0) (2021-10-14)
### Features
* enable noImplicitOverride tsc setting ([#135](https://github.com/expo/entity/issues/135)) ([4263cb9](https://github.com/expo/entity/commit/4263cb9b8b69fe4fb68c74ec1c7aba04508a1555))
* upgrade TypeScript to 4.4 ([#134](https://github.com/expo/entity/issues/134)) ([7612392](https://github.com/expo/entity/commit/7612392dbfd9778d25c465c1626c372d75a6d05a))
# [0.17.0](https://github.com/expo/entity/compare/v0.16.0...v0.17.0) (2021-08-16)
@@ -14,11 +14,11 @@ import { knex, Knex } from 'knex';
const knexInstance = knex({
client: 'pg',
connection: {
user: process.env.PGUSER,
password: process.env.PGPASSWORD,
host: process.env.PGHOST,
port: parseInt(process.env.PGPORT!, 10),
database: process.env.PGDATABASE,
user: process.env['PGUSER'],
password: process.env['PGPASSWORD'],
host: process.env['PGHOST'],
port: parseInt(nullthrows(process.env['PGPORT']), 10),
database: process.env['PGDATABASE'],
},
});
@@ -1,6 +1,6 @@
{
"name": "@expo/entity-database-adapter-knex",
"version": "0.17.0",
"version": "0.22.0",
"description": "Knex database adapter for @expo/entity",
"files": [
"build",
@@ -12,6 +12,7 @@
"tsc": "tsc",
"clean": "rm -rf build coverage coverage-integration",
"lint": "eslint src",
"lint-fix": "eslint src --fix",
"test": "jest --rootDir . --config ../../resources/jest.config.js --passWithNoTests",
"integration": "../../resources/run-with-docker yarn integration-no-setup",
"integration-no-setup": "jest --config ../../resources/jest-integration.config.js --rootDir . --runInBand --passWithNoTests",
@@ -29,9 +30,10 @@
"@expo/entity": "*"
},
"dependencies": {
"knex": "^0.95.6"
"knex": "^1.0.2"
},
"devDependencies": {
"@expo/entity": "^0.17.0"
"@expo/entity": "^0.22.0",
"pg": "^8.7.1"
}
}
@@ -10,7 +10,7 @@ import {
} from '@expo/entity';
import { Knex } from 'knex';
import wrapNativePostgresCall from './errors/wrapNativePostgresCall';
import wrapNativePostgresCallAsync from './errors/wrapNativePostgresCallAsync';
export default class PostgresEntityDatabaseAdapter<TFields> extends EntityDatabaseAdapter<TFields> {
protected getFieldTransformerMap(): FieldTransformerMap {
@@ -45,7 +45,7 @@ export default class PostgresEntityDatabaseAdapter<TFields> extends EntityDataba
tableField: string,
tableValues: readonly any[]
): Promise<object[]> {
return await wrapNativePostgresCall(() =>
return await wrapNativePostgresCallAsync(() =>
queryInterface
.select()
.from(tableName)
@@ -89,12 +89,10 @@ export default class PostgresEntityDatabaseAdapter<TFields> extends EntityDataba
if (tableFieldSingleValueEqualityOperands.length > 0) {
const whereObject: { [key: string]: any } = {};
const nonNullTableFieldSingleValueEqualityOperands = tableFieldSingleValueEqualityOperands.filter(
({ tableValue }) => tableValue !== null
);
const nullTableFieldSingleValueEqualityOperands = tableFieldSingleValueEqualityOperands.filter(
({ tableValue }) => tableValue === null
);
const nonNullTableFieldSingleValueEqualityOperands =
tableFieldSingleValueEqualityOperands.filter(({ tableValue }) => tableValue !== null);
const nullTableFieldSingleValueEqualityOperands =
tableFieldSingleValueEqualityOperands.filter(({ tableValue }) => tableValue === null);
if (nonNullTableFieldSingleValueEqualityOperands.length > 0) {
for (const { tableField, tableValue } of nonNullTableFieldSingleValueEqualityOperands) {
@@ -123,7 +121,7 @@ export default class PostgresEntityDatabaseAdapter<TFields> extends EntityDataba
}
query = this.applyQueryModifiersToQuery(query, querySelectionModifiers);
return await wrapNativePostgresCall(() => query);
return await wrapNativePostgresCallAsync(() => query);
}
protected async fetchManyByRawWhereClauseInternalAsync(
@@ -138,7 +136,7 @@ export default class PostgresEntityDatabaseAdapter<TFields> extends EntityDataba
.from(tableName)
.whereRaw(rawWhereClause, bindings as any);
query = this.applyQueryModifiersToQuery(query, querySelectionModifiers);
return await wrapNativePostgresCall(() => query);
return await wrapNativePostgresCallAsync(() => query);
}
protected async insertInternalAsync(
@@ -146,7 +144,7 @@ export default class PostgresEntityDatabaseAdapter<TFields> extends EntityDataba
tableName: string,
object: object
): Promise<object[]> {
return await wrapNativePostgresCall(() =>
return await wrapNativePostgresCallAsync(() =>
queryInterface.insert(object).into(tableName).returning('*')
);
}
@@ -158,7 +156,7 @@ export default class PostgresEntityDatabaseAdapter<TFields> extends EntityDataba
id: any,
object: object
): Promise<object[]> {
return await wrapNativePostgresCall(() =>
return await wrapNativePostgresCallAsync(() =>
queryInterface.update(object).into(tableName).where(tableIdField, id).returning('*')
);
}
@@ -169,7 +167,7 @@ export default class PostgresEntityDatabaseAdapter<TFields> extends EntityDataba
tableIdField: string,
id: any
): Promise<number> {
return await wrapNativePostgresCall(() =>
return await wrapNativePostgresCallAsync(() =>
queryInterface.into(tableName).where(tableIdField, id).del()
);
}
@@ -7,7 +7,8 @@ import {
import PostgresEntityDatabaseAdapter from './PostgresEntityDatabaseAdapter';
export default class PostgresEntityDatabaseAdapterProvider
implements IEntityDatabaseAdapterProvider {
implements IEntityDatabaseAdapterProvider
{
getDatabaseAdapter<TFields>(
entityConfiguration: EntityConfiguration<TFields>
): EntityDatabaseAdapter<TFields> {
@@ -6,6 +6,7 @@ import {
} from '@expo/entity';
import { enforceAsyncResult } from '@expo/results';
import { knex, Knex } from 'knex';
import nullthrows from 'nullthrows';
import PostgresTestEntity from '../testfixtures/PostgresTestEntity';
import PostgresTriggerTestEntity from '../testfixtures/PostgresTriggerTestEntity';
@@ -19,11 +20,11 @@ describe('postgres entity integration', () => {
knexInstance = knex({
client: 'pg',
connection: {
user: process.env.PGUSER,
password: process.env.PGPASSWORD,
user: nullthrows(process.env['PGUSER']),
password: nullthrows(process.env['PGPASSWORD']),
host: 'localhost',
port: parseInt(process.env.PGPORT!, 10),
database: process.env.PGDATABASE,
port: parseInt(nullthrows(process.env['PGPORT']), 10),
database: nullthrows(process.env['PGDATABASE']),
},
});
});
@@ -263,7 +264,7 @@ describe('postgres entity integration', () => {
.enforcing()
.loadManyByFieldEqualityConjunctionAsync([{ fieldName: 'name', fieldValue: null }]);
expect(results).toHaveLength(2);
expect(results[0].getField('name')).toBeNull();
expect(results[0]!.getField('name')).toBeNull();
const results2 = await PostgresTestEntity.loader(vc1)
.enforcing()
@@ -599,4 +600,37 @@ describe('postgres entity integration', () => {
});
});
});
describe('queryContext callback behavior', () => {
it('calls callbacks correctly', async () => {
const vc1 = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance));
let preCommitCallCount = 0;
let postCommitCallCount = 0;
await vc1.runInTransactionForDatabaseAdaptorFlavorAsync('postgres', async (queryContext) => {
queryContext.appendPostCommitCallback(async () => {
postCommitCallCount++;
});
queryContext.appendPreCommitCallback(async () => {
preCommitCallCount++;
}, 0);
});
await expect(
vc1.runInTransactionForDatabaseAdaptorFlavorAsync('postgres', async (queryContext) => {
queryContext.appendPostCommitCallback(async () => {
postCommitCallCount++;
});
queryContext.appendPreCommitCallback(async () => {
preCommitCallCount++;
throw Error('wat');
}, 0);
})
).rejects.toThrowError('wat');
expect(preCommitCallCount).toBe(2);
expect(postCommitCallCount).toBe(1);
});
});
});
@@ -1,6 +1,7 @@
import { ViewerContext } from '@expo/entity';
import { enforceAsyncResult } from '@expo/results';
import { knex, Knex } from 'knex';
import nullthrows from 'nullthrows';
import InvalidTestEntity from '../testfixtures/InvalidTestEntity';
import { createKnexIntegrationTestEntityCompanionProvider } from '../testfixtures/createKnexIntegrationTestEntityCompanionProvider';
@@ -12,11 +13,11 @@ describe('postgres entity integration', () => {
knexInstance = knex({
client: 'pg',
connection: {
user: process.env.PGUSER,
password: process.env.PGPASSWORD,
user: nullthrows(process.env['PGUSER']),
password: nullthrows(process.env['PGPASSWORD']),
host: 'localhost',
port: parseInt(process.env.PGPORT!, 10),
database: process.env.PGDATABASE,
port: parseInt(nullthrows(process.env['PGPORT']), 10),
database: nullthrows(process.env['PGDATABASE']),
},
});
});
@@ -9,6 +9,7 @@ import {
EntityDatabaseAdapterUnknownError,
} from '@expo/entity';
import { knex, Knex } from 'knex';
import nullthrows from 'nullthrows';
import ErrorsTestEntity from '../testfixtures/ErrorsTestEntity';
import { createKnexIntegrationTestEntityCompanionProvider } from '../testfixtures/createKnexIntegrationTestEntityCompanionProvider';
@@ -20,11 +21,11 @@ describe('postgres errors', () => {
knexInstance = knex({
client: 'pg',
connection: {
user: process.env.PGUSER,
password: process.env.PGPASSWORD,
user: nullthrows(process.env['PGUSER']),
password: nullthrows(process.env['PGPASSWORD']),
host: 'localhost',
port: parseInt(process.env.PGPORT!, 10),
database: process.env.PGDATABASE,
port: parseInt(nullthrows(process.env['PGPORT']), 10),
database: nullthrows(process.env['PGDATABASE']),
},
});
});
@@ -48,11 +49,11 @@ describe('postgres errors', () => {
const shortTimeoutKnexInstance = knex({
client: 'pg',
connection: {
user: process.env.PGUSER,
password: process.env.PGPASSWORD,
user: nullthrows(process.env['PGUSER']),
password: nullthrows(process.env['PGPASSWORD']),
host: 'localhost',
port: parseInt(process.env.PGPORT!, 10),
database: process.env.PGDATABASE,
port: parseInt(nullthrows(process.env['PGPORT']), 10),
database: nullthrows(process.env['PGDATABASE']),
},
acquireConnectionTimeout: 1,
});
@@ -0,0 +1,19 @@
import wrapNativePostgresCallAsync from '../wrapNativePostgresCallAsync';
describe(wrapNativePostgresCallAsync, () => {
it('rethrows literals', async () => {
const throwingFn = async (): Promise<void> => {
// eslint-disable-next-line no-throw-literal,@typescript-eslint/no-throw-literal
throw 'hello';
};
let capturedThrownThing: any;
try {
await wrapNativePostgresCallAsync(throwingFn);
} catch (e) {
capturedThrownThing = e;
}
expect(capturedThrownThing).not.toBeInstanceOf(Error);
expect(capturedThrownThing).toEqual('hello');
});
});
@@ -11,15 +11,17 @@ import {
import { knex } from 'knex';
function wrapNativePostgresError(
error: Error & { code: string | undefined }
error: Error & { code?: string }
): EntityDatabaseAdapterError & Error {
const ret = translatePostgresError(error);
ret.stack = error.stack;
if (error.stack) {
ret.stack = error.stack;
}
return ret;
}
function translatePostgresError(
error: Error & { code: string | undefined }
error: Error & { code?: string }
): EntityDatabaseAdapterError & Error {
if (error instanceof knex.KnexTimeoutError) {
return new EntityDatabaseAdapterTransientError(error.message, error);
@@ -41,10 +43,13 @@ function translatePostgresError(
}
}
export default async function wrapNativePostgresCall<T>(fn: () => Promise<T>): Promise<T> {
export default async function wrapNativePostgresCallAsync<T>(fn: () => Promise<T>): Promise<T> {
try {
return await fn();
} catch (e) {
if (!(e instanceof Error)) {
throw e;
}
throw wrapNativePostgresError(e);
}
}
@@ -106,7 +106,7 @@ class ErrorsTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
ViewerContext,
ErrorsTestEntity
> {
protected readonly createRules = [
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<
ErrorsTestEntityFields,
number,
@@ -114,7 +114,7 @@ class ErrorsTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
ErrorsTestEntity
>(),
];
protected readonly readRules = [
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<
ErrorsTestEntityFields,
number,
@@ -122,7 +122,7 @@ class ErrorsTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
ErrorsTestEntity
>(),
];
protected readonly updateRules = [
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<
ErrorsTestEntityFields,
number,
@@ -130,7 +130,7 @@ class ErrorsTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
ErrorsTestEntity
>(),
];
protected readonly deleteRules = [
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<
ErrorsTestEntityFields,
number,
@@ -57,7 +57,7 @@ class InvalidTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
ViewerContext,
InvalidTestEntity
> {
protected readonly createRules = [
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<
InvalidTestEntityFields,
number,
@@ -65,7 +65,7 @@ class InvalidTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
InvalidTestEntity
>(),
];
protected readonly readRules = [
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<
InvalidTestEntityFields,
number,
@@ -73,7 +73,7 @@ class InvalidTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
InvalidTestEntity
>(),
];
protected readonly updateRules = [
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<
InvalidTestEntityFields,
number,
@@ -81,7 +81,7 @@ class InvalidTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
InvalidTestEntity
>(),
];
protected readonly deleteRules = [
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<
InvalidTestEntityFields,
number,
@@ -81,7 +81,7 @@ class PostgresTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
ViewerContext,
PostgresTestEntity
> {
protected readonly createRules = [
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<
PostgresTestEntityFields,
string,
@@ -89,7 +89,7 @@ class PostgresTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
PostgresTestEntity
>(),
];
protected readonly readRules = [
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<
PostgresTestEntityFields,
string,
@@ -97,7 +97,7 @@ class PostgresTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
PostgresTestEntity
>(),
];
protected readonly updateRules = [
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<
PostgresTestEntityFields,
string,
@@ -105,7 +105,7 @@ class PostgresTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
PostgresTestEntity
>(),
];
protected readonly deleteRules = [
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<
PostgresTestEntityFields,
string,
@@ -10,7 +10,7 @@ import {
EntityMutationTrigger,
EntityQueryContext,
EntityNonTransactionalMutationTrigger,
EntityMutationInfo,
EntityTriggerMutationInfo,
} from '@expo/entity';
import { Knex } from 'knex';
@@ -63,7 +63,7 @@ class PostgresTriggerTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
ViewerContext,
PostgresTriggerTestEntity
> {
protected readonly createRules = [
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<
PostgresTriggerTestEntityFields,
string,
@@ -71,7 +71,7 @@ class PostgresTriggerTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
PostgresTriggerTestEntity
>(),
];
protected readonly readRules = [
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<
PostgresTriggerTestEntityFields,
string,
@@ -79,7 +79,7 @@ class PostgresTriggerTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
PostgresTriggerTestEntity
>(),
];
protected readonly updateRules = [
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<
PostgresTriggerTestEntityFields,
string,
@@ -87,7 +87,7 @@ class PostgresTriggerTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
PostgresTriggerTestEntity
>(),
];
protected readonly deleteRules = [
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<
PostgresTriggerTestEntityFields,
string,
@@ -111,7 +111,7 @@ class ThrowConditionallyTrigger extends EntityMutationTrigger<
_viewerContext: ViewerContext,
_queryContext: EntityQueryContext,
entity: PostgresTriggerTestEntity,
_mutationInfo: EntityMutationInfo<
_mutationInfo: EntityTriggerMutationInfo<
PostgresTriggerTestEntityFields,
string,
ViewerContext,
@@ -144,23 +144,22 @@ class ThrowConditionallyNonTransactionalTrigger extends EntityNonTransactionalMu
}
}
export const postgresTestEntityConfiguration = new EntityConfiguration<
PostgresTriggerTestEntityFields
>({
idField: 'id',
tableName: 'postgres_test_entities',
schema: {
id: new UUIDField({
columnName: 'id',
cache: true,
}),
name: new StringField({
columnName: 'name',
}),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
});
export const postgresTestEntityConfiguration =
new EntityConfiguration<PostgresTriggerTestEntityFields>({
idField: 'id',
tableName: 'postgres_test_entities',
schema: {
id: new UUIDField({
columnName: 'id',
cache: true,
}),
name: new StringField({
columnName: 'name',
}),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
});
const postgresTestEntityCompanionDefinition = new EntityCompanionDefinition({
entityClass: PostgresTriggerTestEntity,
@@ -9,7 +9,7 @@ import {
Entity,
EntityMutationTrigger,
EntityQueryContext,
EntityMutationInfo,
EntityValidatorMutationInfo,
} from '@expo/entity';
import { Knex } from 'knex';
@@ -62,7 +62,7 @@ class PostgresValidatorTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
ViewerContext,
PostgresValidatorTestEntity
> {
protected readonly createRules = [
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<
PostgresValidatorTestEntityFields,
string,
@@ -70,7 +70,7 @@ class PostgresValidatorTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
PostgresValidatorTestEntity
>(),
];
protected readonly readRules = [
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<
PostgresValidatorTestEntityFields,
string,
@@ -78,7 +78,7 @@ class PostgresValidatorTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
PostgresValidatorTestEntity
>(),
];
protected readonly updateRules = [
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<
PostgresValidatorTestEntityFields,
string,
@@ -86,7 +86,7 @@ class PostgresValidatorTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
PostgresValidatorTestEntity
>(),
];
protected readonly deleteRules = [
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<
PostgresValidatorTestEntityFields,
string,
@@ -113,7 +113,7 @@ class ThrowConditionallyTrigger extends EntityMutationTrigger<
_viewerContext: ViewerContext,
_queryContext: EntityQueryContext,
entity: PostgresValidatorTestEntity,
_mutationInfo: EntityMutationInfo<
_mutationInfo: EntityValidatorMutationInfo<
PostgresValidatorTestEntityFields,
string,
ViewerContext,
@@ -126,23 +126,22 @@ class ThrowConditionallyTrigger extends EntityMutationTrigger<
}
}
export const postgresTestEntityConfiguration = new EntityConfiguration<
PostgresValidatorTestEntityFields
>({
idField: 'id',
tableName: 'postgres_test_entities',
schema: {
id: new UUIDField({
columnName: 'id',
cache: true,
}),
name: new StringField({
columnName: 'name',
}),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
});
export const postgresTestEntityConfiguration =
new EntityConfiguration<PostgresValidatorTestEntityFields>({
idField: 'id',
tableName: 'postgres_test_entities',
schema: {
id: new UUIDField({
columnName: 'id',
cache: true,
}),
name: new StringField({
columnName: 'name',
}),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
});
const postgresTestEntityCompanionDefinition = new EntityCompanionDefinition({
entityClass: PostgresValidatorTestEntity,
+44
View File
@@ -3,6 +3,50 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
**Note:** Version bump only for package @expo/entity-example
# [0.21.0](https://github.com/expo/entity/compare/v0.20.0...v0.21.0) (2022-01-03)
**Note:** Version bump only for package @expo/entity-example
# [0.20.0](https://github.com/expo/entity/compare/v0.19.0...v0.20.0) (2021-12-29)
**Note:** Version bump only for package @expo/entity-example
# [0.19.0](https://github.com/expo/entity/compare/v0.18.0...v0.19.0) (2021-10-28)
**Note:** Version bump only for package @expo/entity-example
# [0.18.0](https://github.com/expo/entity/compare/v0.17.0...v0.18.0) (2021-10-14)
### Features
* enable noImplicitOverride tsc setting ([#135](https://github.com/expo/entity/issues/135)) ([4263cb9](https://github.com/expo/entity/commit/4263cb9b8b69fe4fb68c74ec1c7aba04508a1555))
* upgrade TypeScript to 4.4 ([#134](https://github.com/expo/entity/issues/134)) ([7612392](https://github.com/expo/entity/commit/7612392dbfd9778d25c465c1626c372d75a6d05a))
# [0.17.0](https://github.com/expo/entity/compare/v0.16.0...v0.17.0) (2021-08-16)
**Note:** Version bump only for package @expo/entity-example
+3 -2
View File
@@ -1,12 +1,13 @@
{
"name": "@expo/entity-example",
"private": true,
"version": "0.17.0",
"version": "0.22.0",
"description": "An example integration of the @expo/entity framework",
"scripts": {
"tsc": "tsc",
"clean": "rm -rf build coverage coverage-integration",
"lint": "eslint src",
"lint-fix": "eslint src --fix",
"test": "jest --rootDir . --config ../../resources/jest.config.js",
"integration": "../../resources/run-with-docker yarn integration-no-setup",
"integration-no-setup": "jest --config ../../resources/jest-integration.config.js --rootDir . --runInBand --passWithNoTests",
@@ -22,7 +23,7 @@
"author": "Expo",
"license": "MIT",
"dependencies": {
"@expo/entity": "^0.17.0",
"@expo/entity": "^0.22.0",
"apollo-server-koa": "^2.16.1",
"graphql": "^15.3.0",
"koa": "^2.13.0",
@@ -58,7 +58,7 @@ class InMemoryDatabaseAdapter<T> extends EntityDatabaseAdapter<T> {
return 0;
}
const currentOrderBy = orderBys[0];
const currentOrderBy = orderBys[0]!;
const aField = objectA[currentOrderBy.columnName];
const bField = objectB[currentOrderBy.columnName];
switch (currentOrderBy.order) {
@@ -161,7 +161,7 @@ class InMemoryDatabaseAdapter<T> extends EntityDatabaseAdapter<T> {
...dbObjects[objectIndex],
...object,
};
return [dbObjects[objectIndex]];
return [dbObjects[objectIndex]!];
}
protected async deleteInternalAsync(
@@ -13,16 +13,16 @@ export default class NotePrivacyPolicy extends EntityPrivacyPolicy<
ExampleViewerContext,
NoteEntity
> {
protected readonly readRules = [
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<NoteFields, string, ExampleViewerContext, NoteEntity>(),
];
protected readonly createRules = [
protected override readonly createRules = [
new AllowIfUserOwnerPrivacyRule<NoteFields, string, NoteEntity>('userID'),
];
protected readonly updateRules = [
protected override readonly updateRules = [
new AllowIfUserOwnerPrivacyRule<NoteFields, string, NoteEntity>('userID'),
];
protected readonly deleteRules = [
protected override readonly deleteRules = [
new AllowIfUserOwnerPrivacyRule<NoteFields, string, NoteEntity>('userID'),
];
}
@@ -3,6 +3,50 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
**Note:** Version bump only for package @expo/entity-full-integration-tests
# [0.21.0](https://github.com/expo/entity/compare/v0.20.0...v0.21.0) (2022-01-03)
**Note:** Version bump only for package @expo/entity-full-integration-tests
# [0.20.0](https://github.com/expo/entity/compare/v0.19.0...v0.20.0) (2021-12-29)
**Note:** Version bump only for package @expo/entity-full-integration-tests
# [0.19.0](https://github.com/expo/entity/compare/v0.18.0...v0.19.0) (2021-10-28)
**Note:** Version bump only for package @expo/entity-full-integration-tests
# [0.18.0](https://github.com/expo/entity/compare/v0.17.0...v0.18.0) (2021-10-14)
### Features
* enable noImplicitOverride tsc setting ([#135](https://github.com/expo/entity/issues/135)) ([4263cb9](https://github.com/expo/entity/commit/4263cb9b8b69fe4fb68c74ec1c7aba04508a1555))
* upgrade TypeScript to 4.4 ([#134](https://github.com/expo/entity/issues/134)) ([7612392](https://github.com/expo/entity/commit/7612392dbfd9778d25c465c1626c372d75a6d05a))
# [0.17.0](https://github.com/expo/entity/compare/v0.16.0...v0.17.0) (2021-08-16)
**Note:** Version bump only for package @expo/entity-full-integration-tests
@@ -1,12 +1,13 @@
{
"name": "@expo/entity-full-integration-tests",
"private": true,
"version": "0.17.0",
"version": "0.22.0",
"description": "Full redis and knex integration tests for the entity framework",
"scripts": {
"tsc": "tsc",
"clean": "rm -rf build coverage coverage-integration",
"lint": "eslint src",
"lint-fix": "eslint src --fix",
"test": "jest --rootDir . --config ../../resources/jest.config.js --passWithNoTests",
"integration": "../../resources/run-with-docker yarn integration-no-setup",
"integration-no-setup": "jest --config ../../resources/jest-integration.config.js --rootDir . --runInBand --passWithNoTests",
@@ -26,8 +27,8 @@
"@expo/entity-database-adapter-knex": "*"
},
"devDependencies": {
"@expo/entity": "^0.17.0",
"@expo/entity-cache-adapter-redis": "^0.17.0",
"@expo/entity-database-adapter-knex": "^0.17.0"
"@expo/entity": "^0.22.0",
"@expo/entity-cache-adapter-redis": "^0.22.0",
"@expo/entity-database-adapter-knex": "^0.22.0"
}
}
@@ -11,6 +11,7 @@ import {
import { RedisCacheAdapterContext } from '@expo/entity-cache-adapter-redis';
import Redis from 'ioredis';
import { knex, Knex } from 'knex';
import nullthrows from 'nullthrows';
import { URL } from 'url';
import { createFullIntegrationTestEntityCompanionProvider } from '../testfixtures/createFullIntegrationTestEntityCompanionProvider';
@@ -27,16 +28,16 @@ class TestEntityPrivacyPolicy extends EntityPrivacyPolicy<
ViewerContext,
TestEntity
> {
protected readonly readRules = [
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<TestFields, string, ViewerContext, TestEntity>(),
];
protected readonly createRules = [
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<TestFields, string, ViewerContext, TestEntity>(),
];
protected readonly updateRules = [
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<TestFields, string, ViewerContext, TestEntity>(),
];
protected readonly deleteRules = [
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<TestFields, string, ViewerContext, TestEntity>(),
];
}
@@ -107,15 +108,15 @@ describe('Entity cache inconsistency', () => {
knexInstance = knex({
client: 'pg',
connection: {
user: process.env.PGUSER,
password: process.env.PGPASSWORD,
user: nullthrows(process.env['PGUSER']),
password: nullthrows(process.env['PGPASSWORD']),
host: 'localhost',
port: parseInt(process.env.PGPORT!, 10),
database: process.env.PGDATABASE,
port: parseInt(nullthrows(process.env['PGPORT']), 10),
database: nullthrows(process.env['PGDATABASE']),
},
});
redisCacheAdapterContext = {
redisClient: new Redis(new URL(process.env.REDIS_URL!).toString()),
redisClient: new Redis(new URL(process.env['REDIS_URL']!).toString()),
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
@@ -2,6 +2,7 @@ import { ViewerContext } from '@expo/entity';
import { RedisCacheAdapterContext } from '@expo/entity-cache-adapter-redis';
import Redis from 'ioredis';
import { knex, Knex } from 'knex';
import nullthrows from 'nullthrows';
import { URL } from 'url';
import { createFullIntegrationTestEntityCompanionProvider } from '../testfixtures/createFullIntegrationTestEntityCompanionProvider';
@@ -40,15 +41,15 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
knexInstance = knex({
client: 'pg',
connection: {
user: process.env.PGUSER,
password: process.env.PGPASSWORD,
user: nullthrows(process.env['PGUSER']),
password: nullthrows(process.env['PGPASSWORD']),
host: 'localhost',
port: parseInt(process.env.PGPORT!, 10),
database: process.env.PGDATABASE,
port: parseInt(nullthrows(process.env['PGPORT']), 10),
database: nullthrows(process.env['PGDATABASE']),
},
});
redisCacheAdapterContext = {
redisClient: new Redis(new URL(process.env.REDIS_URL!).toString()),
redisClient: new Redis(new URL(process.env['REDIS_URL']!).toString()),
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
@@ -11,6 +11,7 @@ import {
import { RedisCacheAdapterContext } from '@expo/entity-cache-adapter-redis';
import Redis from 'ioredis';
import { knex, Knex } from 'knex';
import nullthrows from 'nullthrows';
import { URL } from 'url';
import { v4 as uuidv4 } from 'uuid';
@@ -27,16 +28,16 @@ interface OtherFields {
}
class PrivacyPolicy extends EntityPrivacyPolicy<any, string, ViewerContext, any> {
protected readonly readRules = [
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, ViewerContext, any>(),
];
protected readonly createRules = [
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, ViewerContext, any>(),
];
protected readonly updateRules = [
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, ViewerContext, any>(),
];
protected readonly deleteRules = [
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, ViewerContext, any>(),
];
}
@@ -169,15 +170,15 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
knexInstance = knex({
client: 'pg',
connection: {
user: process.env.PGUSER,
password: process.env.PGPASSWORD,
user: nullthrows(process.env['PGUSER']),
password: nullthrows(process.env['PGPASSWORD']),
host: 'localhost',
port: parseInt(process.env.PGPORT!, 10),
database: process.env.PGDATABASE,
port: parseInt(nullthrows(process.env['PGPORT']), 10),
database: nullthrows(process.env['PGDATABASE']),
},
});
redisCacheAdapterContext = {
redisClient: new Redis(new URL(process.env.REDIS_URL!).toString()),
redisClient: new Redis(new URL(process.env['REDIS_URL']!).toString()),
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
@@ -17,16 +17,16 @@ interface ChildFields {
}
class TestEntityPrivacyPolicy extends EntityPrivacyPolicy<any, string, ViewerContext, any, any> {
protected readonly readRules = [
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, ViewerContext, any, any>(),
];
protected readonly createRules = [
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, ViewerContext, any, any>(),
];
protected readonly updateRules = [
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, ViewerContext, any, any>(),
];
protected readonly deleteRules = [
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, ViewerContext, any, any>(),
];
}
@@ -15,16 +15,16 @@ interface ParentFields {
}
class TestEntityPrivacyPolicy extends EntityPrivacyPolicy<any, string, ViewerContext, any, any> {
protected readonly readRules = [
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, ViewerContext, any, any>(),
];
protected readonly createRules = [
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, ViewerContext, any, any>(),
];
protected readonly updateRules = [
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, ViewerContext, any, any>(),
];
protected readonly deleteRules = [
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, ViewerContext, any, any>(),
];
}
@@ -0,0 +1,50 @@
# Change Log
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
**Note:** Version bump only for package @expo/entity-ip-address-field
# [0.21.0](https://github.com/expo/entity/compare/v0.20.0...v0.21.0) (2022-01-03)
**Note:** Version bump only for package @expo/entity-ip-address-field
# [0.20.0](https://github.com/expo/entity/compare/v0.19.0...v0.20.0) (2021-12-29)
**Note:** Version bump only for package @expo/entity-ip-address-field
# [0.19.0](https://github.com/expo/entity/compare/v0.18.0...v0.19.0) (2021-10-28)
### Features
* add IP support ([#138](https://github.com/expo/entity/issues/138)) ([1405a91](https://github.com/expo/entity/commit/1405a9126ee3a56c3e2a38bf3b697283744f5b5d))
# [0.18.0](https://github.com/expo/entity/compare/v0.17.0...v0.18.0) (2021-10-14)
### Features
* lands @expo/entity-ip-address-field ([#138](https://github.com/expo/entity/pull/138))
* enable noImplicitOverride tsc setting ([#135](https://github.com/expo/entity/issues/135)) ([4263cb9](https://github.com/expo/entity/commit/4263cb9b8b69fe4fb68c74ec1c7aba04508a1555))
* expose describeFieldTestCase for use in other packages ([#139](https://github.com/expo/entity/issues/139)) ([1220477](https://github.com/expo/entity/commit/1220477aa331349b482ad015b44f76fa2bda7b90))
* upgrade TypeScript to 4.4 ([#134](https://github.com/expo/entity/issues/134)) ([7612392](https://github.com/expo/entity/commit/7612392dbfd9778d25c465c1626c372d75a6d05a))
* use better QueryContext types for transactional triggers and validators ([#136](https://github.com/expo/entity/issues/136)) ([5c7efe1](https://github.com/expo/entity/commit/5c7efe1678424ff907687e9327175bdc2538c3e0))
@@ -0,0 +1,3 @@
# @expo/entity-ip-address-field
A package containing the IP address EntityField definitions for @expo/entity. Supports addresses with either IPv4 or IPv6 syntax.
@@ -0,0 +1,37 @@
{
"name": "@expo/entity-ip-address-field",
"version": "0.22.0",
"description": "IP address EntityField definitions for @expo/entity",
"files": [
"build",
"src"
],
"main": "build/index.js",
"types": "build/index.d.ts",
"scripts": {
"tsc": "tsc",
"clean": "rm -rf build coverage coverage-integration",
"lint": "eslint src",
"lint-fix": "eslint src --fix",
"test": "jest --rootDir . --config ../../resources/jest.config.js",
"barrelsby": "barrelsby --directory src --location top --exclude tests__ --singleQuotes --delete"
},
"engines": {
"node": ">=12"
},
"keywords": [
"entity"
],
"author": "Expo",
"license": "MIT",
"dependencies": {
"ip-address": "^8.1.0"
},
"devDependencies": {
"@expo/entity": "^0.22.0",
"@types/jsbn": "^1.2.29"
},
"peerDependencies": {
"@expo/entity": "*"
}
}
@@ -0,0 +1,8 @@
import { EntityFieldDefinition } from '@expo/entity';
import { Address4, Address6 } from 'ip-address';
export class IPAddressField extends EntityFieldDefinition<string> {
protected validateInputValueInternal(value: string): boolean {
return Address4.isValid(value) || Address6.isValid(value);
}
}
@@ -0,0 +1,16 @@
import { describeFieldTestCase } from '@expo/entity';
import { IPAddressField } from '../EntityFields';
describeFieldTestCase(
new IPAddressField({ columnName: 'wat' }),
[
'192.168.1.1',
'198.24.10.0/24',
'10.0.0.0',
'0:1:2:3:4:5:6:7',
'1:2:3:4:5:6:7:8/64',
'fedc:ba98:7654:3210:fedc:ba98:7654:3210',
],
['', 'abs', '0.0.0.x/0', '123.21.23', '1:2:3:4:5:6', '198.10/8']
);
@@ -0,0 +1,7 @@
/* eslint-disable tsdoc/syntax */
/**
* @packageDocumentation
* @module @expo/entity-ip-address-field
*/
export * from './EntityFields';
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "build",
},
"include": ["src"]
}
@@ -3,6 +3,50 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
**Note:** Version bump only for package @expo/entity-secondary-cache-redis
# [0.21.0](https://github.com/expo/entity/compare/v0.20.0...v0.21.0) (2022-01-03)
**Note:** Version bump only for package @expo/entity-secondary-cache-redis
# [0.20.0](https://github.com/expo/entity/compare/v0.19.0...v0.20.0) (2021-12-29)
**Note:** Version bump only for package @expo/entity-secondary-cache-redis
# [0.19.0](https://github.com/expo/entity/compare/v0.18.0...v0.19.0) (2021-10-28)
**Note:** Version bump only for package @expo/entity-secondary-cache-redis
# [0.18.0](https://github.com/expo/entity/compare/v0.17.0...v0.18.0) (2021-10-14)
### Features
* enable noImplicitOverride tsc setting ([#135](https://github.com/expo/entity/issues/135)) ([4263cb9](https://github.com/expo/entity/commit/4263cb9b8b69fe4fb68c74ec1c7aba04508a1555))
* upgrade TypeScript to 4.4 ([#134](https://github.com/expo/entity/issues/134)) ([7612392](https://github.com/expo/entity/commit/7612392dbfd9778d25c465c1626c372d75a6d05a))
# [0.17.0](https://github.com/expo/entity/compare/v0.16.0...v0.17.0) (2021-08-16)
**Note:** Version bump only for package @expo/entity-secondary-cache-redis
@@ -1,6 +1,6 @@
{
"name": "@expo/entity-secondary-cache-redis",
"version": "0.17.0",
"version": "0.22.0",
"description": "Redis secondary cache for @expo/entity",
"files": [
"build",
@@ -12,6 +12,7 @@
"tsc": "tsc",
"clean": "rm -rf build coverage coverage-integration",
"lint": "eslint src",
"lint-fix": "eslint src --fix",
"test": "jest --rootDir . --config ../../resources/jest.config.js --passWithNoTests",
"integration": "../../resources/run-with-docker yarn integration-no-setup",
"integration-no-setup": "jest --config ../../resources/jest-integration.config.js --rootDir . --runInBand --passWithNoTests",
@@ -33,8 +34,8 @@
"ioredis": "^4.17.3"
},
"devDependencies": {
"@expo/entity": "^0.17.0",
"@expo/entity-cache-adapter-redis": "^0.17.0",
"@expo/entity": "^0.22.0",
"@expo/entity-cache-adapter-redis": "^0.22.0",
"nullthrows": "^1.1.1"
}
}
@@ -3,15 +3,9 @@ import {
EntityConfiguration,
filterMap,
ISecondaryEntityCache,
transformCacheObjectToFields,
transformFieldsToCacheObject,
zipToMap,
} from '@expo/entity';
import {
GenericRedisCacheContext,
GenericRedisCacher,
redisTransformerMap,
} from '@expo/entity-cache-adapter-redis';
import { GenericRedisCacheContext, GenericRedisCacher } from '@expo/entity-cache-adapter-redis';
import invariant from 'invariant';
/**
@@ -20,15 +14,16 @@ import invariant from 'invariant';
* a call to {@link EntityLoader.loadManyByFieldEqualityConjunctionAsync} is guaranteed to return at most one entity.
*/
export default class RedisSecondaryEntityCache<TFields, TLoadParams>
implements ISecondaryEntityCache<TFields, TLoadParams> {
private readonly genericRedisCacher: GenericRedisCacher;
implements ISecondaryEntityCache<TFields, TLoadParams>
{
private readonly genericRedisCacher: GenericRedisCacher<TFields>;
constructor(
private readonly entityConfiguration: EntityConfiguration<TFields>,
entityConfiguration: EntityConfiguration<TFields>,
genericRedisCacheContext: GenericRedisCacheContext,
private readonly constructRedisKey: (params: Readonly<TLoadParams>) => string
) {
this.genericRedisCacher = new GenericRedisCacher(genericRedisCacheContext);
this.genericRedisCacher = new GenericRedisCacher(genericRedisCacheContext, entityConfiguration);
}
/**
@@ -73,14 +68,7 @@ export default class RedisSecondaryEntityCache<TFields, TLoadParams>
if (cacheLoadResult.status === CacheStatus.HIT) {
const loadParams = redisKeyToLoadParamsMap.get(redisKey);
invariant(loadParams !== undefined, 'load params should be in redis key map');
results.set(
loadParams,
transformCacheObjectToFields(
this.entityConfiguration,
redisTransformerMap,
cacheLoadResult.item
)
);
results.set(loadParams, cacheLoadResult.item);
}
});
@@ -102,13 +90,10 @@ export default class RedisSecondaryEntityCache<TFields, TLoadParams>
results.set(fetchMiss, null);
}
const objectsToCache: Map<string, object> = new Map();
const objectsToCache: Map<string, Readonly<TFields>> = new Map();
for (const [loadParams, object] of fetchResults.entries()) {
if (object) {
objectsToCache.set(
this.constructRedisKey(loadParams),
transformFieldsToCacheObject(this.entityConfiguration, redisTransformerMap, object)
);
objectsToCache.set(this.constructRedisKey(loadParams), object);
results.set(loadParams, object);
}
}
@@ -38,11 +38,15 @@ class TestSecondaryRedisCacheLoader extends EntitySecondaryCacheLoader<
if (loadParams.id === FAKE_ID) {
return null;
}
return (
await this.entityLoader
.enforcing()
.loadManyByFieldEqualityConjunctionAsync([{ fieldName: 'id', fieldValue: loadParams.id }])
)[0].getAllFields();
return nullthrows(
(
await this.entityLoader
.enforcing()
.loadManyByFieldEqualityConjunctionAsync([
{ fieldName: 'id', fieldValue: loadParams.id },
])
)[0]
).getAllFields();
});
}
}
@@ -52,7 +56,7 @@ describe(RedisSecondaryEntityCache, () => {
beforeAll(() => {
redisCacheAdapterContext = {
redisClient: new Redis(new URL(process.env.REDIS_URL!).toString()),
redisClient: new Redis(new URL(process.env['REDIS_URL']!).toString()),
makeKeyFn(..._parts: string[]): string {
throw new Error('should not be used by this test');
},
@@ -32,7 +32,7 @@ export class RedisTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
ViewerContext,
RedisTestEntity
> {
protected readonly createRules = [
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<
RedisTestEntityFields,
string,
@@ -40,7 +40,7 @@ export class RedisTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
RedisTestEntity
>(),
];
protected readonly readRules = [
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<
RedisTestEntityFields,
string,
@@ -48,7 +48,7 @@ export class RedisTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
RedisTestEntity
>(),
];
protected readonly updateRules = [
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<
RedisTestEntityFields,
string,
@@ -56,7 +56,7 @@ export class RedisTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
RedisTestEntity
>(),
];
protected readonly deleteRules = [
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<
RedisTestEntityFields,
string,
+60
View File
@@ -3,6 +3,66 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
**Note:** Version bump only for package @expo/entity
# [0.21.0](https://github.com/expo/entity/compare/v0.20.0...v0.21.0) (2022-01-03)
### Features
* add pre-commit callbacks on EntityQueryContext ([#147](https://github.com/expo/entity/issues/147)) ([f1d9847](https://github.com/expo/entity/commit/f1d9847210f5775de964509e44ce493e44482b21))
# [0.20.0](https://github.com/expo/entity/compare/v0.19.0...v0.20.0) (2021-12-29)
### Bug Fixes
* separate post-commit invalidation callbacks from trigger and user callbacks ([#144](https://github.com/expo/entity/issues/144)) ([efa131e](https://github.com/expo/entity/commit/efa131ef29532accf0e51b6d867ffe15a27b4c90))
### Features
* add cascading deletion info ([#145](https://github.com/expo/entity/issues/145)) ([3727191](https://github.com/expo/entity/commit/372719176742d3ead6b7cbca29cb66b09b3fe09c))
# [0.19.0](https://github.com/expo/entity/compare/v0.18.0...v0.19.0) (2021-10-28)
### Features
* add authorization metrics ([#142](https://github.com/expo/entity/issues/142)) ([7cf54d8](https://github.com/expo/entity/commit/7cf54d83c52e104159a3e2e675cdda2d29afe204))
# [0.18.0](https://github.com/expo/entity/compare/v0.17.0...v0.18.0) (2021-10-14)
### Features
* enable noImplicitOverride tsc setting ([#135](https://github.com/expo/entity/issues/135)) ([4263cb9](https://github.com/expo/entity/commit/4263cb9b8b69fe4fb68c74ec1c7aba04508a1555))
* expose describeFieldTestCase for use in other packages ([#139](https://github.com/expo/entity/issues/139)) ([1220477](https://github.com/expo/entity/commit/1220477aa331349b482ad015b44f76fa2bda7b90))
* upgrade TypeScript to 4.4 ([#134](https://github.com/expo/entity/issues/134)) ([7612392](https://github.com/expo/entity/commit/7612392dbfd9778d25c465c1626c372d75a6d05a))
* use better QueryContext types for transactional triggers and validators ([#136](https://github.com/expo/entity/issues/136)) ([5c7efe1](https://github.com/expo/entity/commit/5c7efe1678424ff907687e9327175bdc2538c3e0))
# [0.17.0](https://github.com/expo/entity/compare/v0.16.0...v0.17.0) (2021-08-16)
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/entity",
"version": "0.17.0",
"version": "0.22.0",
"description": "A privacy-first data model",
"files": [
"build",
@@ -12,6 +12,7 @@
"tsc": "tsc",
"clean": "rm -rf build coverage coverage-integration",
"lint": "eslint src",
"lint-fix": "eslint src --fix",
"test": "jest --rootDir . --config ../../resources/jest.config.js",
"integration": "../../resources/run-with-docker yarn integration-no-setup",
"integration-no-setup": "jest --config ../../resources/jest-integration.config.js --rootDir . --runInBand --passWithNoTests",
+10 -2
View File
@@ -243,12 +243,16 @@ export default abstract class Entity<
.getQueryContextProvider()
.getQueryContext()
): Promise<boolean> {
const companion = existingEntity
.getViewerContext()
.getViewerScopedEntityCompanionForClass(this);
const privacyPolicy = new (this.getCompanionDefinition().privacyPolicyClass)();
const evaluationResult = await asyncResult(
privacyPolicy.authorizeUpdateAsync(
existingEntity.getViewerContext(),
queryContext,
existingEntity
existingEntity,
companion.getMetricsAdapter()
)
);
return evaluationResult.ok;
@@ -292,12 +296,16 @@ export default abstract class Entity<
.getQueryContextProvider()
.getQueryContext()
): Promise<boolean> {
const companion = existingEntity
.getViewerContext()
.getViewerScopedEntityCompanionForClass(this);
const privacyPolicy = new (this.getCompanionDefinition().privacyPolicyClass)();
const evaluationResult = await asyncResult(
privacyPolicy.authorizeDeleteAsync(
existingEntity.getViewerContext(),
queryContext,
existingEntity
existingEntity,
companion.getMetricsAdapter()
)
);
return evaluationResult.ok;
@@ -71,7 +71,7 @@ export default class EntityAssociationLoader<
.getLoaderFactory()
.forLoad(queryContext);
return (await loader.loadByIDAsync((associatedEntityID as unknown) as TAssociatedID)) as Result<
return (await loader.loadByIDAsync(associatedEntityID as unknown as TAssociatedID)) as Result<
null extends TFields[TIdentifyingField] ? TAssociatedEntity | null : TAssociatedEntity
>;
}
+2 -10
View File
@@ -1,5 +1,4 @@
import EntityConfiguration from './EntityConfiguration';
import { FieldTransformerMap } from './internal/EntityFieldTransformationUtils';
import { CacheLoadResult } from './internal/ReadThroughEntityCache';
/**
@@ -9,13 +8,6 @@ import { CacheLoadResult } from './internal/ReadThroughEntityCache';
export default abstract class EntityCacheAdapter<TFields> {
constructor(protected readonly entityConfiguration: EntityConfiguration<TFields>) {}
/**
* Transformer definitions for field types. Used to modify values as they are read from or written to
* the cache. Override in concrete subclasses to change transformation behavior.
* If a field type is not present in the map, then fields of that type will not be transformed.
*/
public abstract getFieldTransformerMap(): FieldTransformerMap;
/**
* Load many objects from cache.
* @param fieldName - object field being queried
@@ -25,7 +17,7 @@ export default abstract class EntityCacheAdapter<TFields> {
public abstract loadManyAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult>>;
): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult<TFields>>>;
/**
* Cache many objects fetched from the DB.
@@ -34,7 +26,7 @@ export default abstract class EntityCacheAdapter<TFields> {
*/
public abstract cacheManyAsync<N extends keyof TFields>(
fieldName: N,
objectMap: ReadonlyMap<NonNullable<TFields[N]>, object>
objectMap: ReadonlyMap<NonNullable<TFields[N]>, Readonly<TFields>>
): Promise<void>;
/**
+10 -2
View File
@@ -73,7 +73,7 @@ export default class EntityCompanion<
TEntity,
TSelectedFields
>,
metricsAdapter: IEntityMetricsAdapter
private readonly metricsAdapter: IEntityMetricsAdapter
) {
const privacyPolicy = new PrivacyPolicyClass();
this.entityLoaderFactory = new EntityLoaderFactory<
@@ -87,7 +87,8 @@ export default class EntityCompanion<
tableDataCoordinator.entityConfiguration,
entityClass,
privacyPolicy,
tableDataCoordinator.dataManager
tableDataCoordinator.dataManager,
metricsAdapter
);
this.entityMutatorFactory = new EntityMutatorFactory(
tableDataCoordinator.entityConfiguration,
@@ -129,4 +130,11 @@ export default class EntityCompanion<
getQueryContextProvider(): EntityQueryContextProvider {
return this.tableDataCoordinator.getQueryContextProvider();
}
/**
* Get the {@link IEntityMetricsAdapter} for this companion.
*/
getMetricsAdapter(): IEntityMetricsAdapter {
return this.metricsAdapter;
}
}
@@ -162,14 +162,10 @@ export class EntityCompanionDefinition<
* {@link EntityCompanion} for each type of {@link Entity}.
*/
export default class EntityCompanionProvider {
private readonly companionMap: Map<
string,
EntityCompanion<any, any, any, any, any, any>
> = new Map();
private readonly tableDataCoordinatorMap: Map<
string,
EntityTableDataCoordinator<any>
> = new Map();
private readonly companionMap: Map<string, EntityCompanion<any, any, any, any, any, any>> =
new Map();
private readonly tableDataCoordinatorMap: Map<string, EntityTableDataCoordinator<any>> =
new Map();
/**
* Instantiate an Entity framework.
@@ -178,7 +174,7 @@ export default class EntityCompanionProvider {
* @param cacheAdapterFlavors - Cache adapter configurations for this instance
*/
constructor(
private metricsAdapter: IEntityMetricsAdapter,
public readonly metricsAdapter: IEntityMetricsAdapter,
private databaseAdapterFlavors: ReadonlyMap<
DatabaseAdapterFlavor,
DatabaseAdapterFlavorDefinition
+1 -1
View File
@@ -1,6 +1,6 @@
import { IEntityClass } from './Entity';
import { DatabaseAdapterFlavor, CacheAdapterFlavor } from './EntityCompanionProvider';
import { EntityFieldDefinition } from './EntityFields';
import { EntityFieldDefinition } from './EntityFieldDefinition';
import { mapMap, invertMap, reduceMap } from './utils/collections/maps';
/**
+10 -8
View File
@@ -69,12 +69,14 @@ export interface QuerySelectionModifiers<TFields> {
}
export interface TableQuerySelectionModifiers {
orderBy?: {
columnName: string;
order: OrderByOrdering;
}[];
offset?: number;
limit?: number;
orderBy:
| {
columnName: string;
order: OrderByOrdering;
}[]
| undefined;
offset: number | undefined;
limit: number | undefined;
}
/**
@@ -263,7 +265,7 @@ export default abstract class EntityDatabaseAdapter<TFields> {
return transformDatabaseObjectToFields(
this.entityConfiguration,
this.fieldTransformerMap,
results[0]
results[0]!
);
}
@@ -315,7 +317,7 @@ export default abstract class EntityDatabaseAdapter<TFields> {
return transformDatabaseObjectToFields(
this.entityConfiguration,
this.fieldTransformerMap,
results[0]
results[0]!
);
}
@@ -0,0 +1,124 @@
import { IEntityClass } from './Entity';
import EntityPrivacyPolicy from './EntityPrivacyPolicy';
import ReadonlyEntity from './ReadonlyEntity';
import ViewerContext from './ViewerContext';
export enum EntityEdgeDeletionBehavior {
/**
* Default. Invalidate the cache for all entities that reference the entity
* being deleted through this field, and transitively run deletions on those entities.
* This is most useful when the database itself expresses foreign
* keys and cascading deletes or set nulls and the entity framework just needs to
* be kept consistent with the state of the database.
*/
CASCADE_DELETE_INVALIDATE_CACHE,
/**
* Delete all entities that reference the entity being deleted through this field. This is very similar
* to SQL `ON DELETE CASCADE` but is done in the Entity framework instead of at the underlying level.
* This will also invalidate the cached referencing entities.
*/
CASCADE_DELETE,
/**
* Set this field to null when the referenced entity is deleted. This is very similar
* to SQL `ON DELETE SET NULL` but is done in the Entity framework instead of at the underlying level.
* This will also invalidate the cached referencing entities.
*/
SET_NULL,
}
export interface EntityAssociationDefinition<
TViewerContext extends ViewerContext,
TAssociatedFields,
TAssociatedID extends NonNullable<TAssociatedFields[TAssociatedSelectedFields]>,
TAssociatedEntity extends ReadonlyEntity<
TAssociatedFields,
TAssociatedID,
TViewerContext,
TAssociatedSelectedFields
>,
TAssociatedPrivacyPolicy extends EntityPrivacyPolicy<
TAssociatedFields,
TAssociatedID,
TViewerContext,
TAssociatedEntity,
TAssociatedSelectedFields
>,
TAssociatedSelectedFields extends keyof TAssociatedFields = keyof TAssociatedFields
> {
/**
* Class of entity on the other end of this edge.
*/
getAssociatedEntityClass: () => IEntityClass<
TAssociatedFields,
TAssociatedID,
TViewerContext,
TAssociatedEntity,
TAssociatedPrivacyPolicy,
TAssociatedSelectedFields
>;
/**
* Field by which to load the instance of associatedEntityClass. If not provided, the
* associatedEntityClass instance is fetched by its ID.
*/
associatedEntityLookupByField?: keyof TAssociatedFields;
/**
* What action to perform on the current entity when the entity on the referencing end of
* this edge is deleted.
*
* @remarks
* The entity framework doesn't prescribe a one-size-fits-all solution for referential
* integrity; instead it exposes mechanisms that supports both database foreign key constraints
* and implicit entity-specified foreign keys. Choosing which approach to use often depends on
* application requirements, and sometimes even a mix-and-match is the right choice.
*
* - If referential integrity is critical to your application, database foreign key constraints
* combined with {@link EntityEdgeDeletionBehavior.CASACDE_DELETE_INVALIDATE_CACHE} are recommended.
* - If the database being used doesn't support foreign keys, then using the entity framework for referential
* integrity is recommended.
*/
edgeDeletionBehavior?: EntityEdgeDeletionBehavior;
}
export abstract class EntityFieldDefinition<T> {
readonly columnName: string;
readonly cache: boolean;
readonly association: EntityAssociationDefinition<any, any, any, any, any, any> | undefined;
/**
*
* @param columnName - Column name in the database.
* @param cache - Whether or not to cache loaded instances of the entity by this field. The column name is
* used to derive a cache key for the cache entry. If true, this column must be able uniquely
* identify the entity.
*/
constructor({
columnName,
cache = false,
association,
}: {
columnName: string;
cache?: boolean;
association?: EntityAssociationDefinition<any, any, any, any, any, any>;
}) {
this.columnName = columnName;
this.cache = cache;
this.association = association;
}
/**
* Validates input value for a field of this type. Null and undefined are considered valid by default. This is used for things like:
* - EntityLoader.loadByFieldValue - to ensure the value being loaded by is a valid value
* - EntityMutator.setField - to ensure the value being set is a valid value
*/
public validateInputValue(value: T | null | undefined): boolean {
if (value === null || value === undefined) {
return true;
}
return this.validateInputValueInternal(value);
}
protected abstract validateInputValueInternal(value: T): boolean;
}
+2 -125
View File
@@ -1,129 +1,6 @@
import { validate as validateUUID } from 'uuid';
import { IEntityClass } from './Entity';
import EntityPrivacyPolicy from './EntityPrivacyPolicy';
import ReadonlyEntity from './ReadonlyEntity';
import ViewerContext from './ViewerContext';
export enum EntityEdgeDeletionBehavior {
/**
* Default. Invalidate the cache for all entities that reference the entity
* being deleted through this field, and transitively run deletions on those entities.
* This is most useful when the database itself expresses foreign
* keys and cascading deletes or set nulls and the entity framework just needs to
* be kept consistent with the state of the database.
*/
CASCADE_DELETE_INVALIDATE_CACHE,
/**
* Delete all entities that reference the entity being deleted through this field. This is very similar
* to SQL `ON DELETE CASCADE` but is done in the Entity framework instead of at the underlying level.
* This will also invalidate the cached referencing entities.
*/
CASCADE_DELETE,
/**
* Set this field to null when the referenced entity is deleted. This is very similar
* to SQL `ON DELETE SET NULL` but is done in the Entity framework instead of at the underlying level.
* This will also invalidate the cached referencing entities.
*/
SET_NULL,
}
export interface EntityAssociationDefinition<
TViewerContext extends ViewerContext,
TAssociatedFields,
TAssociatedID extends NonNullable<TAssociatedFields[TAssociatedSelectedFields]>,
TAssociatedEntity extends ReadonlyEntity<
TAssociatedFields,
TAssociatedID,
TViewerContext,
TAssociatedSelectedFields
>,
TAssociatedPrivacyPolicy extends EntityPrivacyPolicy<
TAssociatedFields,
TAssociatedID,
TViewerContext,
TAssociatedEntity,
TAssociatedSelectedFields
>,
TAssociatedSelectedFields extends keyof TAssociatedFields = keyof TAssociatedFields
> {
/**
* Class of entity on the other end of this edge.
*/
getAssociatedEntityClass: () => IEntityClass<
TAssociatedFields,
TAssociatedID,
TViewerContext,
TAssociatedEntity,
TAssociatedPrivacyPolicy,
TAssociatedSelectedFields
>;
/**
* Field by which to load the instance of associatedEntityClass. If not provided, the
* associatedEntityClass instance is fetched by its ID.
*/
associatedEntityLookupByField?: keyof TAssociatedFields;
/**
* What action to perform on the current entity when the entity on the referencing end of
* this edge is deleted.
*
* @remarks
* The entity framework doesn't prescribe a one-size-fits-all solution for referential
* integrity; instead it exposes mechanisms that supports both database foreign key constraints
* and implicit entity-specified foreign keys. Choosing which approach to use often depends on
* application requirements, and sometimes even a mix-and-match is the right choice.
*
* - If referential integrity is critical to your application, database foreign key constraints
* combined with {@link EntityEdgeDeletionBehavior.CASACDE_DELETE_INVALIDATE_CACHE} are recommended.
* - If the database being used doesn't support foreign keys, then using the entity framework for referential
* integrity is recommended.
*/
edgeDeletionBehavior?: EntityEdgeDeletionBehavior;
}
export abstract class EntityFieldDefinition<T> {
readonly columnName: string;
readonly cache: boolean;
readonly association?: EntityAssociationDefinition<any, any, any, any, any, any>;
/**
*
* @param columnName - Column name in the database.
* @param cache - Whether or not to cache loaded instances of the entity by this field. The column name is
* used to derive a cache key for the cache entry. If true, this column must be able uniquely
* identify the entity.
*/
constructor({
columnName,
cache = false,
association,
}: {
columnName: string;
cache?: boolean;
association?: EntityAssociationDefinition<any, any, any, any, any, any>;
}) {
this.columnName = columnName;
this.cache = cache;
this.association = association;
}
/**
* Validates input value for a field of this type. Null and undefined are considered valid by default. This is used for things like:
* - EntityLoader.loadByFieldValue - to ensure the value being loaded by is a valid value
* - EntityMutator.setField - to ensure the value being set is a valid value
*/
public validateInputValue(value: T | null | undefined): boolean {
if (value === null || value === undefined) {
return true;
}
return this.validateInputValueInternal(value);
}
protected abstract validateInputValueInternal(value: T): boolean;
}
import { EntityFieldDefinition } from './EntityFieldDefinition';
export class StringField extends EntityFieldDefinition<string> {
protected validateInputValueInternal(value: string): boolean {
@@ -131,7 +8,7 @@ export class StringField extends EntityFieldDefinition<string> {
}
}
export class UUIDField extends StringField {
protected validateInputValueInternal(value: string): boolean {
protected override validateInputValueInternal(value: string): boolean {
return validateUUID(value);
}
}
+12 -4
View File
@@ -16,6 +16,7 @@ import ViewerContext from './ViewerContext';
import EntityInvalidFieldValueError from './errors/EntityInvalidFieldValueError';
import EntityNotFoundError from './errors/EntityNotFoundError';
import EntityDataManager from './internal/EntityDataManager';
import IEntityMetricsAdapter from './metrics/IEntityMetricsAdapter';
import { mapMap, mapMapAsync } from './utils/collections/maps';
/**
@@ -49,7 +50,8 @@ export default class EntityLoader<
TSelectedFields
>,
private readonly privacyPolicy: TPrivacyPolicy,
private readonly dataManager: EntityDataManager<TFields>
private readonly dataManager: EntityDataManager<TFields>,
protected readonly metricsAdapter: IEntityMetricsAdapter
) {}
/**
@@ -216,7 +218,8 @@ export default class EntityLoader<
this.privacyPolicy.authorizeReadAsync(
this.viewerContext,
this.queryContext,
uncheckedEntityResult.value
uncheckedEntityResult.value,
this.metricsAdapter
)
);
})
@@ -269,7 +272,8 @@ export default class EntityLoader<
this.privacyPolicy.authorizeReadAsync(
this.viewerContext,
this.queryContext,
uncheckedEntityResult.value
uncheckedEntityResult.value,
this.metricsAdapter
)
);
})
@@ -298,6 +302,9 @@ export default class EntityLoader<
try {
return result(new this.entityClass(this.viewerContext, fieldsObject));
} catch (e) {
if (!(e instanceof Error)) {
throw e;
}
return result(e);
}
});
@@ -325,7 +332,8 @@ export default class EntityLoader<
this.privacyPolicy.authorizeReadAsync(
this.viewerContext,
this.queryContext,
uncheckedEntityResult.value
uncheckedEntityResult.value,
this.metricsAdapter
)
);
})
+5 -2
View File
@@ -6,6 +6,7 @@ import { EntityQueryContext } from './EntityQueryContext';
import ReadonlyEntity from './ReadonlyEntity';
import ViewerContext from './ViewerContext';
import EntityDataManager from './internal/EntityDataManager';
import IEntityMetricsAdapter from './metrics/IEntityMetricsAdapter';
/**
* The primary entry point for loading entities.
@@ -35,7 +36,8 @@ export default class EntityLoaderFactory<
TSelectedFields
>,
private readonly privacyPolicyClass: TPrivacyPolicy,
private readonly dataManager: EntityDataManager<TFields>
private readonly dataManager: EntityDataManager<TFields>,
protected readonly metricsAdapter: IEntityMetricsAdapter
) {}
/**
@@ -53,7 +55,8 @@ export default class EntityLoaderFactory<
this.entityConfiguration,
this.entityClass,
this.privacyPolicyClass,
this.dataManager
this.dataManager,
this.metricsAdapter
);
}
}
+47
View File
@@ -0,0 +1,47 @@
import Entity from './Entity';
import ViewerContext from './ViewerContext';
export enum EntityMutationType {
CREATE,
UPDATE,
DELETE,
}
export type EntityValidatorMutationInfo<
TFields,
TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext,
TEntity extends Entity<TFields, TID, TViewerContext, TSelectedFields>,
TSelectedFields extends keyof TFields = keyof TFields
> =
| {
type: EntityMutationType.CREATE;
}
| {
type: EntityMutationType.UPDATE;
previousValue: TEntity;
};
export type EntityMutationTriggerDeleteCascadeInfo<> = {
entity: Entity<any, any, any, any>;
cascadingDeleteCause: EntityMutationTriggerDeleteCascadeInfo | null;
};
export type EntityTriggerMutationInfo<
TFields,
TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext,
TEntity extends Entity<TFields, TID, TViewerContext, TSelectedFields>,
TSelectedFields extends keyof TFields = keyof TFields
> =
| {
type: EntityMutationType.CREATE;
}
| {
type: EntityMutationType.UPDATE;
previousValue: TEntity;
}
| {
type: EntityMutationType.DELETE;
cascadingDeleteCause: EntityMutationTriggerDeleteCascadeInfo | null;
};
@@ -1,5 +1,5 @@
import { EntityMutationInfo } from './EntityMutator';
import { EntityQueryContext } from './EntityQueryContext';
import { EntityTriggerMutationInfo } from './EntityMutationInfo';
import { EntityTransactionalQueryContext } from './EntityQueryContext';
import ReadonlyEntity from './ReadonlyEntity';
import ViewerContext from './ViewerContext';
@@ -78,9 +78,9 @@ export abstract class EntityMutationTrigger<
> {
abstract executeAsync(
viewerContext: TViewerContext,
queryContext: EntityQueryContext,
queryContext: EntityTransactionalQueryContext,
entity: TEntity,
mutationInfo: EntityMutationInfo<TFields, TID, TViewerContext, TEntity, TSelectedFields>
mutationInfo: EntityTriggerMutationInfo<TFields, TID, TViewerContext, TEntity, TSelectedFields>
): Promise<void>;
}
@@ -98,6 +98,6 @@ export abstract class EntityNonTransactionalMutationTrigger<
abstract executeAsync(
viewerContext: TViewerContext,
entity: TEntity,
mutationInfo: EntityMutationInfo<TFields, TID, TViewerContext, TEntity, TSelectedFields>
mutationInfo: EntityTriggerMutationInfo<TFields, TID, TViewerContext, TEntity, TSelectedFields>
): Promise<void>;
}
+10 -4
View File
@@ -1,5 +1,5 @@
import { EntityMutationInfo } from './EntityMutator';
import { EntityQueryContext } from './EntityQueryContext';
import { EntityValidatorMutationInfo } from './EntityMutationInfo';
import { EntityTransactionalQueryContext } from './EntityQueryContext';
import ReadonlyEntity from './ReadonlyEntity';
import ViewerContext from './ViewerContext';
@@ -16,8 +16,14 @@ export default abstract class EntityMutationValidator<
> {
abstract executeAsync(
viewerContext: TViewerContext,
queryContext: EntityQueryContext,
queryContext: EntityTransactionalQueryContext,
entity: TEntity,
mutationInfo: EntityMutationInfo<TFields, TID, TViewerContext, TEntity, TSelectedFields>
mutationInfo: EntityValidatorMutationInfo<
TFields,
TID,
TViewerContext,
TEntity,
TSelectedFields
>
): Promise<void>;
}
+102 -80
View File
@@ -5,8 +5,14 @@ import Entity, { IEntityClass } from './Entity';
import { EntityCompanionDefinition } from './EntityCompanionProvider';
import EntityConfiguration from './EntityConfiguration';
import EntityDatabaseAdapter from './EntityDatabaseAdapter';
import { EntityEdgeDeletionBehavior } from './EntityFields';
import { EntityEdgeDeletionBehavior } from './EntityFieldDefinition';
import EntityLoaderFactory from './EntityLoaderFactory';
import {
EntityValidatorMutationInfo,
EntityMutationType,
EntityTriggerMutationInfo,
EntityMutationTriggerDeleteCascadeInfo,
} from './EntityMutationInfo';
import EntityMutationTriggerConfiguration, {
EntityMutationTrigger,
EntityNonTransactionalMutationTrigger,
@@ -21,30 +27,6 @@ import { timeAndLogMutationEventAsync } from './metrics/EntityMetricsUtils';
import IEntityMetricsAdapter, { EntityMetricsMutationType } from './metrics/IEntityMetricsAdapter';
import { mapMapAsync } from './utils/collections/maps';
export enum EntityMutationType {
CREATE,
UPDATE,
DELETE,
}
export type EntityMutationInfo<
TFields,
TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext,
TEntity extends Entity<TFields, TID, TViewerContext, TSelectedFields>,
TSelectedFields extends keyof TFields = keyof TFields
> =
| {
type: EntityMutationType.CREATE;
}
| {
type: EntityMutationType.UPDATE;
previousValue: TEntity;
}
| {
type: EntityMutationType.DELETE;
};
abstract class BaseMutator<
TFields,
TID extends NonNullable<TFields[TSelectedFields]>,
@@ -110,26 +92,44 @@ abstract class BaseMutator<
}
}
protected async executeMutationTriggersOrValidatorsAsync(
triggersOrValidators:
| EntityMutationTrigger<TFields, TID, TViewerContext, TEntity, TSelectedFields>[]
| EntityMutationValidator<TFields, TID, TViewerContext, TEntity, TSelectedFields>[]
| undefined,
queryContext: EntityQueryContext,
protected async executeMutationValidatorsAsync(
validators: EntityMutationValidator<TFields, TID, TViewerContext, TEntity, TSelectedFields>[],
queryContext: EntityTransactionalQueryContext,
entity: TEntity,
mutationInfo: EntityMutationInfo<TFields, TID, TViewerContext, TEntity, TSelectedFields>
mutationInfo: EntityValidatorMutationInfo<
TFields,
TID,
TViewerContext,
TEntity,
TSelectedFields
>
): Promise<void> {
if (!triggersOrValidators) {
return;
}
await Promise.all(
triggersOrValidators.map((triggerOrValidator) =>
triggerOrValidator.executeAsync(this.viewerContext, queryContext, entity, mutationInfo)
validators.map((validator) =>
validator.executeAsync(this.viewerContext, queryContext, entity, mutationInfo)
)
);
}
protected async executeNonTransactionalMutationTriggersOrValidatorsAsync(
protected async executeMutationTriggersAsync(
triggers:
| EntityMutationTrigger<TFields, TID, TViewerContext, TEntity, TSelectedFields>[]
| undefined,
queryContext: EntityTransactionalQueryContext,
entity: TEntity,
mutationInfo: EntityTriggerMutationInfo<TFields, TID, TViewerContext, TEntity, TSelectedFields>
): Promise<void> {
if (!triggers) {
return;
}
await Promise.all(
triggers.map((trigger) =>
trigger.executeAsync(this.viewerContext, queryContext, entity, mutationInfo)
)
);
}
protected async executeNonTransactionalMutationTriggersAsync(
triggers:
| EntityNonTransactionalMutationTrigger<
TFields,
@@ -140,7 +140,7 @@ abstract class BaseMutator<
>[]
| undefined,
entity: TEntity,
mutationInfo: EntityMutationInfo<TFields, TID, TViewerContext, TEntity, TSelectedFields>
mutationInfo: EntityTriggerMutationInfo<TFields, TID, TViewerContext, TEntity, TSelectedFields>
): Promise<void> {
if (!triggers) {
return;
@@ -211,35 +211,36 @@ export class CreateMutator<
): Promise<Result<TEntity>> {
this.validateFields(this.fieldsForEntity);
const temporaryEntityForPrivacyCheck = new this.entityClass(this.viewerContext, ({
const temporaryEntityForPrivacyCheck = new this.entityClass(this.viewerContext, {
[this.entityConfiguration.idField]: '00000000-0000-0000-0000-000000000000', // zero UUID
...this.fieldsForEntity,
} as unknown) as TFields);
} as unknown as TFields);
const authorizeCreateResult = await asyncResult(
this.privacyPolicy.authorizeCreateAsync(
this.viewerContext,
queryContext,
temporaryEntityForPrivacyCheck
temporaryEntityForPrivacyCheck,
this.metricsAdapter
)
);
if (!authorizeCreateResult.ok) {
return authorizeCreateResult;
}
await this.executeMutationTriggersOrValidatorsAsync(
await this.executeMutationValidatorsAsync(
this.mutationValidators,
queryContext,
temporaryEntityForPrivacyCheck,
{ type: EntityMutationType.CREATE }
);
await this.executeMutationTriggersOrValidatorsAsync(
await this.executeMutationTriggersAsync(
this.mutationTriggers.beforeAll,
queryContext,
temporaryEntityForPrivacyCheck,
{ type: EntityMutationType.CREATE }
);
await this.executeMutationTriggersOrValidatorsAsync(
await this.executeMutationTriggersAsync(
this.mutationTriggers.beforeCreate,
queryContext,
temporaryEntityForPrivacyCheck,
@@ -249,7 +250,7 @@ export class CreateMutator<
const insertResult = await this.databaseAdapter.insertAsync(queryContext, this.fieldsForEntity);
const entityLoader = this.entityLoaderFactory.forLoad(this.viewerContext, queryContext);
queryContext.appendPostCommitCallback(
queryContext.appendPostCommitInvalidationCallback(
entityLoader.invalidateFieldsAsync.bind(entityLoader, insertResult)
);
@@ -258,13 +259,13 @@ export class CreateMutator<
.enforcing()
.loadByIDAsync(unauthorizedEntityAfterInsert.getID());
await this.executeMutationTriggersOrValidatorsAsync(
await this.executeMutationTriggersAsync(
this.mutationTriggers.afterCreate,
queryContext,
newEntity,
{ type: EntityMutationType.CREATE }
);
await this.executeMutationTriggersOrValidatorsAsync(
await this.executeMutationTriggersAsync(
this.mutationTriggers.afterAll,
queryContext,
newEntity,
@@ -272,7 +273,7 @@ export class CreateMutator<
);
queryContext.appendPostCommitCallback(
this.executeNonTransactionalMutationTriggersOrValidatorsAsync.bind(
this.executeNonTransactionalMutationTriggersAsync.bind(
this,
this.mutationTriggers.afterCommit,
newEntity,
@@ -408,26 +409,27 @@ export class UpdateMutator<
this.privacyPolicy.authorizeUpdateAsync(
this.viewerContext,
queryContext,
entityAboutToBeUpdated
entityAboutToBeUpdated,
this.metricsAdapter
)
);
if (!authorizeUpdateResult.ok) {
return authorizeUpdateResult;
}
await this.executeMutationTriggersOrValidatorsAsync(
await this.executeMutationValidatorsAsync(
this.mutationValidators,
queryContext,
entityAboutToBeUpdated,
{ type: EntityMutationType.UPDATE, previousValue: this.originalEntity }
);
await this.executeMutationTriggersOrValidatorsAsync(
await this.executeMutationTriggersAsync(
this.mutationTriggers.beforeAll,
queryContext,
entityAboutToBeUpdated,
{ type: EntityMutationType.UPDATE, previousValue: this.originalEntity }
);
await this.executeMutationTriggersOrValidatorsAsync(
await this.executeMutationTriggersAsync(
this.mutationTriggers.beforeUpdate,
queryContext,
entityAboutToBeUpdated,
@@ -443,13 +445,13 @@ export class UpdateMutator<
const entityLoader = this.entityLoaderFactory.forLoad(this.viewerContext, queryContext);
queryContext.appendPostCommitCallback(
queryContext.appendPostCommitInvalidationCallback(
entityLoader.invalidateFieldsAsync.bind(
entityLoader,
this.originalEntity.getAllDatabaseFields()
)
);
queryContext.appendPostCommitCallback(
queryContext.appendPostCommitInvalidationCallback(
entityLoader.invalidateFieldsAsync.bind(entityLoader, this.fieldsForEntity)
);
@@ -458,13 +460,13 @@ export class UpdateMutator<
.enforcing()
.loadByIDAsync(unauthorizedEntityAfterUpdate.getID());
await this.executeMutationTriggersOrValidatorsAsync(
await this.executeMutationTriggersAsync(
this.mutationTriggers.afterUpdate,
queryContext,
updatedEntity,
{ type: EntityMutationType.UPDATE, previousValue: this.originalEntity }
);
await this.executeMutationTriggersOrValidatorsAsync(
await this.executeMutationTriggersAsync(
this.mutationTriggers.afterAll,
queryContext,
updatedEntity,
@@ -472,7 +474,7 @@ export class UpdateMutator<
);
queryContext.appendPostCommitCallback(
this.executeNonTransactionalMutationTriggersOrValidatorsAsync.bind(
this.executeNonTransactionalMutationTriggersAsync.bind(
this,
this.mutationTriggers.afterCommit,
updatedEntity,
@@ -563,7 +565,7 @@ export class DeleteMutator<
this.metricsAdapter,
EntityMetricsMutationType.DELETE,
this.entityClass.name
)(this.deleteInTransactionAsync());
)(this.deleteInTransactionAsync(new Set(), false, null));
}
/**
@@ -574,14 +576,16 @@ export class DeleteMutator<
}
private async deleteInTransactionAsync(
processedEntityIdentifiersFromTransitiveDeletions: Set<string> = new Set(),
skipDatabaseDeletion: boolean = false
processedEntityIdentifiersFromTransitiveDeletions: Set<string>,
skipDatabaseDeletion: boolean,
cascadingDeleteCause: EntityMutationTriggerDeleteCascadeInfo | null
): Promise<Result<void>> {
return await this.queryContext.runInTransactionIfNotInTransactionAsync((innerQueryContext) =>
this.deleteInternalAsync(
innerQueryContext,
processedEntityIdentifiersFromTransitiveDeletions,
skipDatabaseDeletion
skipDatabaseDeletion,
cascadingDeleteCause
)
);
}
@@ -589,10 +593,16 @@ export class DeleteMutator<
private async deleteInternalAsync(
queryContext: EntityTransactionalQueryContext,
processedEntityIdentifiersFromTransitiveDeletions: Set<string>,
skipDatabaseDeletion: boolean
skipDatabaseDeletion: boolean,
cascadingDeleteCause: EntityMutationTriggerDeleteCascadeInfo | null
): Promise<Result<void>> {
const authorizeDeleteResult = await asyncResult(
this.privacyPolicy.authorizeDeleteAsync(this.viewerContext, queryContext, this.entity)
this.privacyPolicy.authorizeDeleteAsync(
this.viewerContext,
queryContext,
this.entity,
this.metricsAdapter
)
);
if (!authorizeDeleteResult.ok) {
return authorizeDeleteResult;
@@ -601,20 +611,21 @@ export class DeleteMutator<
await this.processEntityDeletionForInboundEdgesAsync(
this.entity,
queryContext,
processedEntityIdentifiersFromTransitiveDeletions
processedEntityIdentifiersFromTransitiveDeletions,
cascadingDeleteCause
);
await this.executeMutationTriggersOrValidatorsAsync(
await this.executeMutationTriggersAsync(
this.mutationTriggers.beforeAll,
queryContext,
this.entity,
{ type: EntityMutationType.DELETE }
{ type: EntityMutationType.DELETE, cascadingDeleteCause }
);
await this.executeMutationTriggersOrValidatorsAsync(
await this.executeMutationTriggersAsync(
this.mutationTriggers.beforeDelete,
queryContext,
this.entity,
{ type: EntityMutationType.DELETE }
{ type: EntityMutationType.DELETE, cascadingDeleteCause }
);
if (!skipDatabaseDeletion) {
@@ -626,29 +637,29 @@ export class DeleteMutator<
}
const entityLoader = this.entityLoaderFactory.forLoad(this.viewerContext, queryContext);
queryContext.appendPostCommitCallback(
queryContext.appendPostCommitInvalidationCallback(
entityLoader.invalidateFieldsAsync.bind(entityLoader, this.entity.getAllDatabaseFields())
);
await this.executeMutationTriggersOrValidatorsAsync(
await this.executeMutationTriggersAsync(
this.mutationTriggers.afterDelete,
queryContext,
this.entity,
{ type: EntityMutationType.DELETE }
{ type: EntityMutationType.DELETE, cascadingDeleteCause }
);
await this.executeMutationTriggersOrValidatorsAsync(
await this.executeMutationTriggersAsync(
this.mutationTriggers.afterAll,
queryContext,
this.entity,
{ type: EntityMutationType.DELETE }
{ type: EntityMutationType.DELETE, cascadingDeleteCause }
);
queryContext.appendPostCommitCallback(
this.executeNonTransactionalMutationTriggersOrValidatorsAsync.bind(
this.executeNonTransactionalMutationTriggersAsync.bind(
this,
this.mutationTriggers.afterCommit,
this.entity,
{ type: EntityMutationType.DELETE }
{ type: EntityMutationType.DELETE, cascadingDeleteCause }
)
);
@@ -672,7 +683,8 @@ export class DeleteMutator<
private async processEntityDeletionForInboundEdgesAsync(
entity: TEntity,
queryContext: EntityTransactionalQueryContext,
processedEntityIdentifiers: Set<string>
processedEntityIdentifiers: Set<string>,
cascadingDeleteCause: EntityMutationTriggerDeleteCascadeInfo | null
): Promise<void> {
// prevent infinite reference cycles by keeping track of entities already processed
if (processedEntityIdentifiers.has(entity.getUniqueIdentifier())) {
@@ -680,7 +692,9 @@ export class DeleteMutator<
}
processedEntityIdentifiers.add(entity.getUniqueIdentifier());
const companionDefinition = (entity.constructor as any).getCompanionDefinition() as EntityCompanionDefinition<
const companionDefinition = (
entity.constructor as any
).getCompanionDefinition() as EntityCompanionDefinition<
TFields,
TID,
TViewerContext,
@@ -743,7 +757,11 @@ export class DeleteMutator<
.forDelete(inboundReferenceEntity, queryContext)
.deleteInTransactionAsync(
processedEntityIdentifiers,
/* skipDatabaseDeletion */ true // deletion is handled by DB
/* skipDatabaseDeletion */ true, // deletion is handled by DB
{
entity,
cascadingDeleteCause,
}
)
)
);
@@ -767,7 +785,11 @@ export class DeleteMutator<
.forDelete(inboundReferenceEntity, queryContext)
.deleteInTransactionAsync(
processedEntityIdentifiers,
/* skipDatabaseDeletion */ false
/* skipDatabaseDeletion */ false,
{
entity,
cascadingDeleteCause,
}
)
)
);
+78 -20
View File
@@ -2,6 +2,9 @@ import { EntityQueryContext } from './EntityQueryContext';
import ReadonlyEntity from './ReadonlyEntity';
import ViewerContext from './ViewerContext';
import EntityNotAuthorizedError from './errors/EntityNotAuthorizedError';
import IEntityMetricsAdapter, {
EntityMetricsAuthorizationResult,
} from './metrics/IEntityMetricsAdapter';
import PrivacyPolicyRule, { RuleEvaluationResult } from './rules/PrivacyPolicyRule';
export enum EntityPrivacyPolicyEvaluationMode {
@@ -124,14 +127,16 @@ export default abstract class EntityPrivacyPolicy<
async authorizeCreateAsync(
viewerContext: TViewerContext,
queryContext: EntityQueryContext,
entity: TEntity
entity: TEntity,
metricsAdapter: IEntityMetricsAdapter
): Promise<TEntity> {
return await this.authorizeForRulesetAsync(
this.createRules,
viewerContext,
queryContext,
entity,
EntityAuthorizationAction.CREATE
EntityAuthorizationAction.CREATE,
metricsAdapter
);
}
@@ -146,14 +151,16 @@ export default abstract class EntityPrivacyPolicy<
async authorizeReadAsync(
viewerContext: TViewerContext,
queryContext: EntityQueryContext,
entity: TEntity
entity: TEntity,
metricsAdapter: IEntityMetricsAdapter
): Promise<TEntity> {
return await this.authorizeForRulesetAsync(
this.readRules,
viewerContext,
queryContext,
entity,
EntityAuthorizationAction.READ
EntityAuthorizationAction.READ,
metricsAdapter
);
}
@@ -168,14 +175,16 @@ export default abstract class EntityPrivacyPolicy<
async authorizeUpdateAsync(
viewerContext: TViewerContext,
queryContext: EntityQueryContext,
entity: TEntity
entity: TEntity,
metricsAdapter: IEntityMetricsAdapter
): Promise<TEntity> {
return await this.authorizeForRulesetAsync(
this.updateRules,
viewerContext,
queryContext,
entity,
EntityAuthorizationAction.UPDATE
EntityAuthorizationAction.UPDATE,
metricsAdapter
);
}
@@ -190,14 +199,16 @@ export default abstract class EntityPrivacyPolicy<
async authorizeDeleteAsync(
viewerContext: TViewerContext,
queryContext: EntityQueryContext,
entity: TEntity
entity: TEntity,
metricsAdapter: IEntityMetricsAdapter
): Promise<TEntity> {
return await this.authorizeForRulesetAsync(
this.deleteRules,
viewerContext,
queryContext,
entity,
EntityAuthorizationAction.DELETE
EntityAuthorizationAction.DELETE,
metricsAdapter
);
}
@@ -206,48 +217,95 @@ export default abstract class EntityPrivacyPolicy<
viewerContext: TViewerContext,
queryContext: EntityQueryContext,
entity: TEntity,
action: EntityAuthorizationAction
action: EntityAuthorizationAction,
metricsAdapter: IEntityMetricsAdapter
): Promise<TEntity> {
const privacyPolicyEvaluator = this.getPrivacyPolicyEvaluator(viewerContext);
switch (privacyPolicyEvaluator.mode) {
case EntityPrivacyPolicyEvaluationMode.ENFORCE:
return await this.authorizeForRulesetInnerAsync(
ruleset,
viewerContext,
queryContext,
entity,
action
);
case EntityPrivacyPolicyEvaluationMode.ENFORCE_AND_LOG:
try {
return await this.authorizeForRulesetInnerAsync(
const result = await this.authorizeForRulesetInnerAsync(
ruleset,
viewerContext,
queryContext,
entity,
action
);
metricsAdapter.logAuthorizationEvent({
entityClassName: entity.constructor.name,
action,
evaluationResult: EntityMetricsAuthorizationResult.ALLOW,
privacyPolicyEvaluationMode: privacyPolicyEvaluator.mode,
});
return result;
} catch (e) {
if (!(e instanceof EntityNotAuthorizedError)) {
throw e;
}
metricsAdapter.logAuthorizationEvent({
entityClassName: entity.constructor.name,
action,
evaluationResult: EntityMetricsAuthorizationResult.DENY,
privacyPolicyEvaluationMode: privacyPolicyEvaluator.mode,
});
throw e;
}
case EntityPrivacyPolicyEvaluationMode.ENFORCE_AND_LOG:
try {
const result = await this.authorizeForRulesetInnerAsync(
ruleset,
viewerContext,
queryContext,
entity,
action
);
metricsAdapter.logAuthorizationEvent({
entityClassName: entity.constructor.name,
action,
evaluationResult: EntityMetricsAuthorizationResult.ALLOW,
privacyPolicyEvaluationMode: privacyPolicyEvaluator.mode,
});
return result;
} catch (e) {
if (!(e instanceof EntityNotAuthorizedError)) {
throw e;
}
privacyPolicyEvaluator.denyHandler(e);
metricsAdapter.logAuthorizationEvent({
entityClassName: entity.constructor.name,
action,
evaluationResult: EntityMetricsAuthorizationResult.DENY,
privacyPolicyEvaluationMode: privacyPolicyEvaluator.mode,
});
throw e;
}
case EntityPrivacyPolicyEvaluationMode.DRY_RUN:
try {
return await this.authorizeForRulesetInnerAsync(
const result = await this.authorizeForRulesetInnerAsync(
ruleset,
viewerContext,
queryContext,
entity,
action
);
metricsAdapter.logAuthorizationEvent({
entityClassName: entity.constructor.name,
action,
evaluationResult: EntityMetricsAuthorizationResult.ALLOW,
privacyPolicyEvaluationMode: privacyPolicyEvaluator.mode,
});
return result;
} catch (e) {
if (!(e instanceof EntityNotAuthorizedError)) {
throw e;
}
privacyPolicyEvaluator.denyHandler(e);
metricsAdapter.logAuthorizationEvent({
entityClassName: entity.constructor.name,
action,
evaluationResult: EntityMetricsAuthorizationResult.DENY,
privacyPolicyEvaluationMode: privacyPolicyEvaluator.mode,
});
return entity;
}
}
@@ -261,7 +319,7 @@ export default abstract class EntityPrivacyPolicy<
action: EntityAuthorizationAction
): Promise<TEntity> {
for (let i = 0; i < ruleset.length; i++) {
const rule = ruleset[i];
const rule = ruleset[i]!;
const ruleEvaluationResult = await rule.evaluateAsync(viewerContext, queryContext, entity);
switch (ruleEvaluationResult) {
case RuleEvaluationResult.DENY:
+54
View File
@@ -1,6 +1,12 @@
import assert from 'assert';
import EntityQueryContextProvider from './EntityQueryContextProvider';
export type PostCommitCallback = (...args: any) => Promise<any>;
export type PreCommitCallback = (
queryContext: EntityTransactionalQueryContext,
...args: any
) => Promise<any>;
/**
* Entity framework representation of transactional and non-transactional database
@@ -43,13 +49,61 @@ export class EntityNonTransactionalQueryContext extends EntityQueryContext {
}
export class EntityTransactionalQueryContext extends EntityQueryContext {
private readonly postCommitInvalidationCallbacks: PostCommitCallback[] = [];
private readonly postCommitCallbacks: PostCommitCallback[] = [];
private readonly preCommitCallbacks: { callback: PreCommitCallback; order: number }[] = [];
/**
* Schedule a pre-commit callback. These will be run within the transaction right before it is
* committed, and will be run in the order specified. Ordering of callbacks scheduled with the
* same value for the order parameter is undefined within that ordering group.
* @param callback - callback to schedule
* @param order - order in which this should be run relative to other scheduled pre-commit callbacks,
* with higher numbers running later than lower numbers.
*/
public appendPreCommitCallback(callback: PreCommitCallback, order: number): void {
assert(
order >= Number.MIN_SAFE_INTEGER && order <= Number.MAX_SAFE_INTEGER,
`Invalid order specified: ${order}`
);
this.preCommitCallbacks.push({ callback, order });
}
/**
* Schedule a post-commit cache invalidation callback. These are run before normal
* post-commit callbacks in order to have cache consistency in normal post-commit callbacks.
* @param callback - callback to schedule
*/
public appendPostCommitInvalidationCallback(callback: PostCommitCallback): void {
this.postCommitInvalidationCallbacks.push(callback);
}
/**
* Schedule a post-commit callback. These will be run after the transaction has
* been committed.
* @param callback - callback to schedule
*/
public appendPostCommitCallback(callback: PostCommitCallback): void {
this.postCommitCallbacks.push(callback);
}
public async runPreCommitCallbacksAsync(): Promise<void> {
const callbacks = [...this.preCommitCallbacks]
.sort((a, b) => a.order - b.order)
.map((c) => c.callback);
this.preCommitCallbacks.length = 0;
for (const callback of callbacks) {
await callback(this);
}
}
public async runPostCommitCallbacksAsync(): Promise<void> {
const invalidationCallbacks = [...this.postCommitInvalidationCallbacks];
this.postCommitInvalidationCallbacks.length = 0;
await Promise.all(invalidationCallbacks.map((callback) => callback()));
const callbacks = [...this.postCommitCallbacks];
this.postCommitCallbacks.length = 0;
await Promise.all(callbacks.map((callback) => callback()));
@@ -38,6 +38,7 @@ export default abstract class EntityQueryContextProvider {
>()(async (queryInterface) => {
const queryContext = new EntityTransactionalQueryContext(queryInterface);
const result = await transactionScope(queryContext);
await queryContext.runPreCommitCallbacksAsync();
return [result, queryContext];
});
await queryContext.runPostCommitCallbacksAsync();
+3 -2
View File
@@ -37,8 +37,9 @@ export default abstract class ReadonlyEntity<
private readonly viewerContext: TViewerContext,
private readonly databaseFields: Readonly<TFields>
) {
const companionDefinition = (this
.constructor as any).getCompanionDefinition() as EntityCompanionDefinition<
const companionDefinition = (
this.constructor as any
).getCompanionDefinition() as EntityCompanionDefinition<
TFields,
TID,
TViewerContext,
@@ -5,6 +5,7 @@ import ReadonlyEntity from './ReadonlyEntity';
import ViewerContext from './ViewerContext';
import ViewerScopedEntityLoaderFactory from './ViewerScopedEntityLoaderFactory';
import ViewerScopedEntityMutatorFactory from './ViewerScopedEntityMutatorFactory';
import IEntityMetricsAdapter from './metrics/IEntityMetricsAdapter';
/**
* Provides a simpler API for loading and mutating entities by injecting the {@link ViewerContext}
@@ -76,4 +77,11 @@ export default class ViewerScopedEntityCompanion<
getQueryContextProvider(): EntityQueryContextProvider {
return this.entityCompanion.getQueryContextProvider();
}
/**
* Get the {@link IEntityMetricsAdapter} for this companion.
*/
getMetricsAdapter(): IEntityMetricsAdapter {
return this.entityCompanion.getMetricsAdapter();
}
}
+8 -8
View File
@@ -102,7 +102,7 @@ class SimpleTestDenyUpdateEntityPrivacyPolicy extends EntityPrivacyPolicy<
ViewerContext,
SimpleTestDenyUpdateEntity
> {
protected readonly readRules = [
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<
TestEntityFields,
string,
@@ -110,7 +110,7 @@ class SimpleTestDenyUpdateEntityPrivacyPolicy extends EntityPrivacyPolicy<
SimpleTestDenyUpdateEntity
>(),
];
protected readonly createRules = [
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<
TestEntityFields,
string,
@@ -118,7 +118,7 @@ class SimpleTestDenyUpdateEntityPrivacyPolicy extends EntityPrivacyPolicy<
SimpleTestDenyUpdateEntity
>(),
];
protected readonly updateRules = [
protected override readonly updateRules = [
new AlwaysDenyPrivacyPolicyRule<
TestEntityFields,
string,
@@ -126,7 +126,7 @@ class SimpleTestDenyUpdateEntityPrivacyPolicy extends EntityPrivacyPolicy<
SimpleTestDenyUpdateEntity
>(),
];
protected readonly deleteRules = [
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<
TestEntityFields,
string,
@@ -142,7 +142,7 @@ class SimpleTestDenyDeleteEntityPrivacyPolicy extends EntityPrivacyPolicy<
ViewerContext,
SimpleTestDenyDeleteEntity
> {
protected readonly readRules = [
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<
TestEntityFields,
string,
@@ -150,7 +150,7 @@ class SimpleTestDenyDeleteEntityPrivacyPolicy extends EntityPrivacyPolicy<
SimpleTestDenyDeleteEntity
>(),
];
protected readonly createRules = [
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<
TestEntityFields,
string,
@@ -158,7 +158,7 @@ class SimpleTestDenyDeleteEntityPrivacyPolicy extends EntityPrivacyPolicy<
SimpleTestDenyDeleteEntity
>(),
];
protected readonly updateRules = [
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<
TestEntityFields,
string,
@@ -166,7 +166,7 @@ class SimpleTestDenyDeleteEntityPrivacyPolicy extends EntityPrivacyPolicy<
SimpleTestDenyDeleteEntity
>(),
];
protected readonly deleteRules = [
protected override readonly deleteRules = [
new AlwaysDenyPrivacyPolicyRule<
TestEntityFields,
string,
@@ -66,19 +66,19 @@ class BlahEntityPrivacyPolicy extends EntityPrivacyPolicy<
ViewerContext,
BlahEntity
> {
protected readonly createRules = [
protected override readonly createRules = [
new DenyIfNotOwnerPrivacyPolicyRule(),
new AlwaysAllowPrivacyPolicyRule<BlahFields, string, ViewerContext, BlahEntity>(),
];
protected readonly readRules = [
protected override readonly readRules = [
new DenyIfNotOwnerPrivacyPolicyRule(),
new AlwaysAllowPrivacyPolicyRule<BlahFields, string, ViewerContext, BlahEntity>(),
];
protected readonly updateRules = [
protected override readonly updateRules = [
new DenyIfNotOwnerPrivacyPolicyRule(),
new AlwaysAllowPrivacyPolicyRule<BlahFields, string, ViewerContext, BlahEntity>(),
];
protected readonly deleteRules = [
protected override readonly deleteRules = [
new AlwaysDenyPrivacyPolicyRule<BlahFields, string, ViewerContext, BlahEntity>(),
];
}
+169 -14
View File
@@ -1,8 +1,12 @@
import Entity from '../Entity';
import { EntityCompanionDefinition } from '../EntityCompanionProvider';
import EntityConfiguration from '../EntityConfiguration';
import { UUIDField, EntityEdgeDeletionBehavior } from '../EntityFields';
import { EntityEdgeDeletionBehavior } from '../EntityFieldDefinition';
import { UUIDField } from '../EntityFields';
import { EntityTriggerMutationInfo, EntityMutationType } from '../EntityMutationInfo';
import { EntityMutationTrigger } from '../EntityMutationTriggerConfiguration';
import EntityPrivacyPolicy from '../EntityPrivacyPolicy';
import { EntityTransactionalQueryContext } from '../EntityQueryContext';
import { CacheStatus } from '../internal/ReadThroughEntityCache';
import AlwaysAllowPrivacyPolicyRule from '../rules/AlwaysAllowPrivacyPolicyRule';
import TestViewerContext from '../testfixtures/TestViewerContext';
@@ -30,22 +34,141 @@ class TestEntityPrivacyPolicy extends EntityPrivacyPolicy<
any,
any
> {
protected readonly readRules = [
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, TestViewerContext, any, any>(),
];
protected readonly createRules = [
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, TestViewerContext, any, any>(),
];
protected readonly updateRules = [
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, TestViewerContext, any, any>(),
];
protected readonly deleteRules = [
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, TestViewerContext, any, any>(),
];
}
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
const makeEntityClasses = (edgeDeletionBehavior: EntityEdgeDeletionBehavior) => {
const triggerExecutionCounts = {
parent: 0,
child: 0,
grandchild: 0,
};
class ParentCheckInfoTrigger extends EntityMutationTrigger<
ParentFields,
string,
TestViewerContext,
ParentEntity
> {
async executeAsync(
_viewerContext: TestViewerContext,
_queryContext: EntityTransactionalQueryContext,
_entity: ParentEntity,
mutationInfo: EntityTriggerMutationInfo<ParentFields, string, TestViewerContext, ParentEntity>
): Promise<void> {
if (mutationInfo.type !== EntityMutationType.DELETE) {
return;
}
if (mutationInfo.cascadingDeleteCause !== null) {
throw new Error('Parent entity should not have casade delete cause');
}
triggerExecutionCounts.parent++;
}
}
class ChildCheckInfoTrigger extends EntityMutationTrigger<
ChildFields,
string,
TestViewerContext,
ChildEntity
> {
async executeAsync(
_viewerContext: TestViewerContext,
_queryContext: EntityTransactionalQueryContext,
_entity: ChildEntity,
mutationInfo: EntityTriggerMutationInfo<ChildFields, string, TestViewerContext, ChildEntity>
): Promise<void> {
if (mutationInfo.type !== EntityMutationType.DELETE) {
return;
}
if (mutationInfo.cascadingDeleteCause === null) {
throw new Error('Child entity should have casade delete cause');
}
const cascadingDeleteCauseEntity = mutationInfo.cascadingDeleteCause.entity;
if (!(cascadingDeleteCauseEntity instanceof ParentEntity)) {
throw new Error('Child entity should have casade delete cause entity of type ParentEntity');
}
const secondLevelCascadingDeleteCause =
mutationInfo.cascadingDeleteCause.cascadingDeleteCause;
if (secondLevelCascadingDeleteCause) {
throw new Error('Child entity should not have two-level casade delete cause');
}
triggerExecutionCounts.child++;
}
}
class GrandChildCheckInfoTrigger extends EntityMutationTrigger<
GrandChildFields,
string,
TestViewerContext,
GrandChildEntity
> {
async executeAsync(
_viewerContext: TestViewerContext,
_queryContext: EntityTransactionalQueryContext,
_entity: GrandChildEntity,
mutationInfo: EntityTriggerMutationInfo<
GrandChildFields,
string,
TestViewerContext,
GrandChildEntity
>
): Promise<void> {
if (mutationInfo.type !== EntityMutationType.DELETE) {
return;
}
if (mutationInfo.cascadingDeleteCause === null) {
throw new Error('GrandChild entity should have cascade delete cause');
}
const cascadingDeleteCauseEntity = mutationInfo.cascadingDeleteCause.entity;
if (!(cascadingDeleteCauseEntity instanceof ChildEntity)) {
throw new Error(
'GrandChild entity should have cascade delete cause entity of type ChildEntity'
);
}
const secondLevelCascadingDeleteCause =
mutationInfo.cascadingDeleteCause.cascadingDeleteCause;
if (!secondLevelCascadingDeleteCause) {
throw new Error('GrandChild entity should have two-level casade delete cause');
}
const secondLevelCascadingDeleteCauseEntity = secondLevelCascadingDeleteCause.entity;
if (!(secondLevelCascadingDeleteCauseEntity instanceof ParentEntity)) {
throw new Error(
'GrandChild entity should have second level casade delete cause entity of type ParentEntity'
);
}
const thirdLevelCascadingDeleteCause = secondLevelCascadingDeleteCause.cascadingDeleteCause;
if (thirdLevelCascadingDeleteCause) {
throw new Error('GrandChild entity should not have three-level casade delete cause');
}
triggerExecutionCounts.grandchild++;
}
}
class ParentEntity extends Entity<ParentFields, string, TestViewerContext> {
static getCompanionDefinition(): EntityCompanionDefinition<
ParentFields,
@@ -143,33 +266,45 @@ const makeEntityClasses = (edgeDeletionBehavior: EntityEdgeDeletionBehavior) =>
entityClass: ParentEntity,
entityConfiguration: parentEntityConfiguration,
privacyPolicyClass: TestEntityPrivacyPolicy,
mutationTriggers: () => ({
beforeDelete: [new ParentCheckInfoTrigger()],
afterDelete: [new ParentCheckInfoTrigger()],
}),
});
const childEntityCompanion = new EntityCompanionDefinition({
entityClass: ChildEntity,
entityConfiguration: childEntityConfiguration,
privacyPolicyClass: TestEntityPrivacyPolicy,
mutationTriggers: () => ({
beforeDelete: [new ChildCheckInfoTrigger()],
afterDelete: [new ChildCheckInfoTrigger()],
}),
});
const grandChildEntityCompanion = new EntityCompanionDefinition({
entityClass: GrandChildEntity,
entityConfiguration: grandChildEntityConfiguration,
privacyPolicyClass: TestEntityPrivacyPolicy,
mutationTriggers: () => ({
beforeDelete: [new GrandChildCheckInfoTrigger()],
afterDelete: [new GrandChildCheckInfoTrigger()],
}),
});
return {
ParentEntity,
ChildEntity,
GrandChildEntity,
triggerExecutionCounts,
};
};
describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
describe('EntityEdgeDeletionBehavior.CASCADE_DELETE', () => {
it('deletes', async () => {
const { ParentEntity, ChildEntity, GrandChildEntity } = makeEntityClasses(
EntityEdgeDeletionBehavior.CASCADE_DELETE
);
const { ParentEntity, ChildEntity, GrandChildEntity, triggerExecutionCounts } =
makeEntityClasses(EntityEdgeDeletionBehavior.CASCADE_DELETE);
const companionProvider = createUnitTestEntityCompanionProvider();
const viewerContext = new TestViewerContext(companionProvider);
@@ -202,14 +337,20 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
await expect(
GrandChildEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(grandchild.getID())
).resolves.toBeNull();
// two calls for each trigger, one beforeDelete, one afterDelete
expect(triggerExecutionCounts).toMatchObject({
parent: 2,
child: 2,
grandchild: 2,
});
});
});
describe('EntityEdgeDeletionBehavior.SET_NULL', () => {
it('sets null', async () => {
const { ParentEntity, ChildEntity, GrandChildEntity } = makeEntityClasses(
EntityEdgeDeletionBehavior.SET_NULL
);
const { ParentEntity, ChildEntity, GrandChildEntity, triggerExecutionCounts } =
makeEntityClasses(EntityEdgeDeletionBehavior.SET_NULL);
const companionProvider = createUnitTestEntityCompanionProvider();
const viewerContext = new TestViewerContext(companionProvider);
@@ -247,14 +388,21 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
.enforcing()
.loadByIDAsync(grandchild.getID());
expect(loadedGrandchild.getField('parent_id')).toEqual(loadedChild.getID());
// two calls for only parent trigger, one beforeDelete, one afterDelete
// when using set null the descendants aren't deleted
expect(triggerExecutionCounts).toMatchObject({
parent: 2,
child: 0,
grandchild: 0,
});
});
});
describe('EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE', () => {
it('invalidates the cache', async () => {
const { ParentEntity, ChildEntity, GrandChildEntity } = makeEntityClasses(
EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE
);
const { ParentEntity, ChildEntity, GrandChildEntity, triggerExecutionCounts } =
makeEntityClasses(EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE);
const companionProvider = createUnitTestEntityCompanionProvider();
const viewerContext = new TestViewerContext(companionProvider);
@@ -318,6 +466,13 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
await expect(
GrandChildEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(grandchild.getID())
).resolves.not.toBeNull();
// two calls for each trigger, one beforeDelete, one afterDelete
expect(triggerExecutionCounts).toMatchObject({
parent: 2,
child: 2,
grandchild: 2,
});
});
});
});
@@ -1,7 +1,7 @@
import { v1 as uuidv1, v3 as uuidv3, v4 as uuidv4, v5 as uuidv5 } from 'uuid';
import { EntityFieldDefinition } from '../EntityFieldDefinition';
import {
EntityFieldDefinition,
StringField,
UUIDField,
DateField,
@@ -14,6 +14,7 @@ import {
JSONArrayField,
MaybeJSONArrayField,
} from '../EntityFields';
import describeFieldTestCase from '../utils/testing/describeFieldTestCase';
class TestFieldDefinition extends EntityFieldDefinition<string> {
protected validateInputValueInternal(value: string): boolean {
@@ -53,26 +54,6 @@ describe(EntityFieldDefinition, () => {
});
});
const describeFieldTestCase = <T>(
fieldDefinition: EntityFieldDefinition<T>,
validValues: T[],
invalidValues: any[]
): void => {
describe(fieldDefinition.constructor.name, () => {
if (validValues.length > 0) {
test.each(validValues)(`${fieldDefinition.constructor.name}.valid %p`, (value) => {
expect(fieldDefinition.validateInputValue(value)).toBe(true);
});
}
if (invalidValues.length > 0) {
test.each(invalidValues)(`${fieldDefinition.constructor.name}.invalid %p`, (value) => {
expect(fieldDefinition.validateInputValue(value)).toBe(false);
});
}
});
};
describeFieldTestCase(new StringField({ columnName: 'wat' }), ['hello', ''], [1, true, {}, [[]]]);
describeFieldTestCase(
new UUIDField({ columnName: 'wat' }),
@@ -0,0 +1,177 @@
import { instance, mock } from 'ts-mockito';
import Entity from '../Entity';
import { EntityCompanionDefinition } from '../EntityCompanionProvider';
import EntityConfiguration from '../EntityConfiguration';
import { StringField } from '../EntityFields';
import EntityLoader from '../EntityLoader';
import EntityPrivacyPolicy from '../EntityPrivacyPolicy';
import ViewerContext from '../ViewerContext';
import EntityDataManager from '../internal/EntityDataManager';
import ReadThroughEntityCache from '../internal/ReadThroughEntityCache';
import IEntityMetricsAdapter from '../metrics/IEntityMetricsAdapter';
import AlwaysAllowPrivacyPolicyRule from '../rules/AlwaysAllowPrivacyPolicyRule';
import { NoCacheStubCacheAdapterProvider } from '../utils/testing/StubCacheAdapter';
import StubDatabaseAdapter from '../utils/testing/StubDatabaseAdapter';
import StubQueryContextProvider from '../utils/testing/StubQueryContextProvider';
export type TestFields = {
id: string;
};
export type TestFieldSelection = keyof TestFields;
export const testEntityConfiguration = new EntityConfiguration<TestFields>({
idField: 'id',
tableName: 'test_entity_should_not_write_to_db',
schema: {
id: new StringField({
columnName: 'id',
}),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
});
export class TestEntityPrivacyPolicy extends EntityPrivacyPolicy<
TestFields,
string,
ViewerContext,
TestEntity,
TestFieldSelection
> {
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<
TestFields,
string,
ViewerContext,
TestEntity,
TestFieldSelection
>(),
];
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<
TestFields,
string,
ViewerContext,
TestEntity,
TestFieldSelection
>(),
];
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<
TestFields,
string,
ViewerContext,
TestEntity,
TestFieldSelection
>(),
];
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<
TestFields,
string,
ViewerContext,
TestEntity,
TestFieldSelection
>(),
];
}
const ID_SENTINEL_THROW_LITERAL = 'throw_literal';
const ID_SENTINEL_THROW_ERROR = 'throw_error';
export default class TestEntity extends Entity<
TestFields,
string,
ViewerContext,
TestFieldSelection
> {
constructor(viewerContext: ViewerContext, rawFields: Readonly<TestFields>) {
if (rawFields.id === ID_SENTINEL_THROW_LITERAL) {
// eslint-disable-next-line no-throw-literal,@typescript-eslint/no-throw-literal
throw 'hello';
} else if (rawFields.id === ID_SENTINEL_THROW_ERROR) {
throw new Error('world');
}
super(viewerContext, rawFields);
}
static getCompanionDefinition(): EntityCompanionDefinition<
TestFields,
string,
ViewerContext,
TestEntity,
TestEntityPrivacyPolicy,
TestFieldSelection
> {
return testEntityCompanion;
}
}
export const testEntityCompanion = new EntityCompanionDefinition({
entityClass: TestEntity,
entityConfiguration: testEntityConfiguration,
privacyPolicyClass: TestEntityPrivacyPolicy,
});
describe(EntityLoader, () => {
it('handles thrown errors and literals from constructor', async () => {
const viewerContext = instance(mock(ViewerContext));
const metricsAdapter = instance(mock<IEntityMetricsAdapter>());
const queryContext = StubQueryContextProvider.getQueryContext();
const databaseAdapter = new StubDatabaseAdapter<TestFields>(
testEntityConfiguration,
StubDatabaseAdapter.convertFieldObjectsToDataStore(
testEntityConfiguration,
new Map([
[
testEntityConfiguration.tableName,
[
{
id: ID_SENTINEL_THROW_LITERAL,
},
{
id: ID_SENTINEL_THROW_ERROR,
},
],
],
])
)
);
const privacyPolicy = new TestEntityPrivacyPolicy();
const cacheAdapterProvider = new NoCacheStubCacheAdapterProvider();
const cacheAdapter = cacheAdapterProvider.getCacheAdapter(testEntityConfiguration);
const entityCache = new ReadThroughEntityCache(testEntityConfiguration, cacheAdapter);
const dataManager = new EntityDataManager(
databaseAdapter,
entityCache,
StubQueryContextProvider,
metricsAdapter,
TestEntity.name
);
const entityLoader = new EntityLoader(
viewerContext,
queryContext,
testEntityConfiguration,
TestEntity,
privacyPolicy,
dataManager,
metricsAdapter
);
let capturedThrownThing1: any;
try {
await entityLoader.loadByIDAsync(ID_SENTINEL_THROW_LITERAL);
} catch (e) {
capturedThrownThing1 = e;
}
expect(capturedThrownThing1).not.toBeInstanceOf(Error);
expect(capturedThrownThing1).toEqual('hello');
const result = await entityLoader.loadByIDAsync(ID_SENTINEL_THROW_ERROR);
expect(result.ok).toBe(false);
expect(result.enforceError().message).toEqual('world');
});
});
@@ -21,6 +21,7 @@ describe(EntityLoader, () => {
it('loads entities', async () => {
const dateToInsert = new Date();
const viewerContext = instance(mock(ViewerContext));
const metricsAdapter = instance(mock<IEntityMetricsAdapter>());
const queryContext = StubQueryContextProvider.getQueryContext();
const id1 = uuidv4();
@@ -71,7 +72,8 @@ describe(EntityLoader, () => {
testEntityConfiguration,
TestEntity,
privacyPolicy,
dataManager
dataManager,
metricsAdapter
);
const entity = await enforceAsyncResult(entityLoader.loadByIDAsync(id1));
expect(entity.getID()).toEqual(id1);
@@ -111,6 +113,7 @@ describe(EntityLoader, () => {
const privacyPolicy = new TestEntityPrivacyPolicy();
const spiedPrivacyPolicy = spy(privacyPolicy);
const viewerContext = instance(mock(ViewerContext));
const metricsAdapter = instance(mock<IEntityMetricsAdapter>());
const queryContext = StubQueryContextProvider.getQueryContext();
const id1 = uuidv4();
@@ -169,7 +172,8 @@ describe(EntityLoader, () => {
testEntityConfiguration,
TestEntity,
privacyPolicy,
dataManager
dataManager,
metricsAdapter
);
const entities = await enforceResultsAsync(
entityLoader.loadManyByFieldEqualityConjunctionAsync([
@@ -185,7 +189,12 @@ describe(EntityLoader, () => {
);
expect(entities).toHaveLength(2);
verify(
spiedPrivacyPolicy.authorizeReadAsync(viewerContext, queryContext, anyOfClass(TestEntity))
spiedPrivacyPolicy.authorizeReadAsync(
viewerContext,
queryContext,
anyOfClass(TestEntity),
anything()
)
).twice();
await expect(
@@ -200,6 +209,7 @@ describe(EntityLoader, () => {
const spiedPrivacyPolicy = spy(privacyPolicy);
const viewerContext = instance(mock(ViewerContext));
const metricsAdapter = instance(mock<IEntityMetricsAdapter>());
const queryContext = StubQueryContextProvider.getQueryContext();
const id1 = uuidv4();
@@ -240,14 +250,18 @@ describe(EntityLoader, () => {
testEntityConfiguration,
TestEntity,
privacyPolicy,
dataManager
dataManager,
metricsAdapter
);
const entity = await enforceAsyncResult(entityLoader.loadByIDAsync(id1));
verify(spiedPrivacyPolicy.authorizeReadAsync(viewerContext, queryContext, entity)).once();
verify(
spiedPrivacyPolicy.authorizeReadAsync(viewerContext, queryContext, entity, anything())
).once();
});
it('invalidates upon invalidate one', async () => {
const viewerContext = instance(mock(ViewerContext));
const metricsAdapter = instance(mock<IEntityMetricsAdapter>());
const queryContext = StubQueryContextProvider.getQueryContext();
const privacyPolicy = instance(mock(TestEntityPrivacyPolicy));
const dataManagerMock = mock<EntityDataManager<TestFields>>();
@@ -260,7 +274,8 @@ describe(EntityLoader, () => {
testEntityConfiguration,
TestEntity,
privacyPolicy,
dataManagerInstance
dataManagerInstance,
metricsAdapter
);
await entityLoader.invalidateFieldsAsync({ customIdField: id1 } as any);
@@ -271,6 +286,7 @@ describe(EntityLoader, () => {
it('invalidates upon invalidate by field', async () => {
const viewerContext = instance(mock(ViewerContext));
const metricsAdapter = instance(mock<IEntityMetricsAdapter>());
const queryContext = StubQueryContextProvider.getQueryContext();
const privacyPolicy = instance(mock(TestEntityPrivacyPolicy));
const dataManagerMock = mock<EntityDataManager<TestFields>>();
@@ -283,7 +299,8 @@ describe(EntityLoader, () => {
testEntityConfiguration,
TestEntity,
privacyPolicy,
dataManagerInstance
dataManagerInstance,
metricsAdapter
);
await entityLoader.invalidateFieldsAsync({ customIdField: id1 } as any);
verify(
@@ -293,6 +310,7 @@ describe(EntityLoader, () => {
it('invalidates upon invalidate by entity', async () => {
const viewerContext = instance(mock(ViewerContext));
const metricsAdapter = instance(mock<IEntityMetricsAdapter>());
const queryContext = StubQueryContextProvider.getQueryContext();
const privacyPolicy = instance(mock(TestEntityPrivacyPolicy));
const dataManagerMock = mock<EntityDataManager<TestFields>>();
@@ -309,7 +327,8 @@ describe(EntityLoader, () => {
testEntityConfiguration,
TestEntity,
privacyPolicy,
dataManagerInstance
dataManagerInstance,
metricsAdapter
);
await entityLoader.invalidateEntityAsync(entityInstance);
verify(
@@ -319,6 +338,7 @@ describe(EntityLoader, () => {
it('returns error result when not allowed', async () => {
const viewerContext = instance(mock(ViewerContext));
const metricsAdapter = instance(mock<IEntityMetricsAdapter>());
const queryContext = StubQueryContextProvider.getQueryContext();
const privacyPolicyMock = mock(TestEntityPrivacyPolicy);
const dataManagerMock = mock<EntityDataManager<TestFields>>();
@@ -331,7 +351,12 @@ describe(EntityLoader, () => {
const rejectionError = new Error();
when(
privacyPolicyMock.authorizeReadAsync(viewerContext, queryContext, anyOfClass(TestEntity))
privacyPolicyMock.authorizeReadAsync(
viewerContext,
queryContext,
anyOfClass(TestEntity),
anything()
)
).thenReject(rejectionError);
const privacyPolicy = instance(privacyPolicyMock);
@@ -343,7 +368,8 @@ describe(EntityLoader, () => {
testEntityConfiguration,
TestEntity,
privacyPolicy,
dataManagerInstance
dataManagerInstance,
metricsAdapter
);
const entityResult = await entityLoader.loadByIDAsync(id1);
@@ -354,6 +380,7 @@ describe(EntityLoader, () => {
it('throws upon database adapter error', async () => {
const viewerContext = instance(mock(ViewerContext));
const metricsAdapter = instance(mock<IEntityMetricsAdapter>());
const queryContext = StubQueryContextProvider.getQueryContext();
const privacyPolicy = instance(mock(TestEntityPrivacyPolicy));
const dataManagerMock = mock<EntityDataManager<TestFields>>();
@@ -372,7 +399,8 @@ describe(EntityLoader, () => {
testEntityConfiguration,
TestEntity,
privacyPolicy,
dataManagerInstance
dataManagerInstance,
metricsAdapter
);
const loadByValue = uuidv4();
@@ -0,0 +1,105 @@
import Entity from '../Entity';
import { EntityCompanionDefinition } from '../EntityCompanionProvider';
import EntityConfiguration from '../EntityConfiguration';
import { UUIDField } from '../EntityFields';
import { EntityMutationType, EntityTriggerMutationInfo } from '../EntityMutationInfo';
import { EntityNonTransactionalMutationTrigger } from '../EntityMutationTriggerConfiguration';
import EntityPrivacyPolicy from '../EntityPrivacyPolicy';
import ViewerContext from '../ViewerContext';
import AlwaysAllowPrivacyPolicyRule from '../rules/AlwaysAllowPrivacyPolicyRule';
import { createUnitTestEntityCompanionProvider } from '../utils/testing/createUnitTestEntityCompanionProvider';
type BlahFields = {
id: string;
};
class BlahEntityPrivacyPolicy extends EntityPrivacyPolicy<
BlahFields,
string,
ViewerContext,
BlahEntity
> {
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<BlahFields, string, ViewerContext, BlahEntity>(),
];
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<BlahFields, string, ViewerContext, BlahEntity>(),
];
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<BlahFields, string, ViewerContext, BlahEntity>(),
];
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<BlahFields, string, ViewerContext, BlahEntity>(),
];
}
class BlahEntity extends Entity<BlahFields, string, ViewerContext> {
static getCompanionDefinition(): EntityCompanionDefinition<
BlahFields,
string,
ViewerContext,
BlahEntity,
BlahEntityPrivacyPolicy
> {
return blahCompanion;
}
}
const blahCompanion = new EntityCompanionDefinition({
entityClass: BlahEntity,
entityConfiguration: new EntityConfiguration<BlahFields>({
idField: 'id',
tableName: 'blah_table',
schema: {
id: new UUIDField({
columnName: 'id',
cache: true,
}),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
}),
privacyPolicyClass: BlahEntityPrivacyPolicy,
mutationTriggers: () => ({
afterCommit: [new TestNonTransactionalMutationTrigger()],
}),
});
class TestNonTransactionalMutationTrigger extends EntityNonTransactionalMutationTrigger<
BlahFields,
string,
ViewerContext,
BlahEntity
> {
async executeAsync(
viewerContext: ViewerContext,
entity: BlahEntity,
mutationInfo: EntityTriggerMutationInfo<BlahFields, string, ViewerContext, BlahEntity>
): Promise<void> {
if (mutationInfo.type === EntityMutationType.DELETE) {
const entityLoaded = await BlahEntity.loader(viewerContext)
.enforcing()
.loadByIDNullableAsync(entity.getID());
if (entityLoaded) {
throw new Error(
'should not have been able to re-load the entity after delete. this means the cache has not been cleared'
);
}
}
}
}
describe('EntityMutator', () => {
test('cache consistency with post-commit callbacks', async () => {
const companionProvider = createUnitTestEntityCompanionProvider();
const viewerContext = new ViewerContext(companionProvider);
// put it in cache
const entity = await BlahEntity.creator(viewerContext).enforceCreateAsync();
const entityLoaded = await BlahEntity.loader(viewerContext)
.enforcing()
.loadByIDAsync(entity.getID());
await BlahEntity.enforceDeleteAsync(entityLoaded);
});
});
@@ -14,12 +14,16 @@ import { v4 as uuidv4 } from 'uuid';
import EntityDatabaseAdapter from '../EntityDatabaseAdapter';
import EntityLoaderFactory from '../EntityLoaderFactory';
import {
EntityMutationType,
EntityTriggerMutationInfo,
EntityValidatorMutationInfo,
} from '../EntityMutationInfo';
import EntityMutationTriggerConfiguration, {
EntityMutationTrigger,
EntityNonTransactionalMutationTrigger,
} from '../EntityMutationTriggerConfiguration';
import EntityMutationValidator from '../EntityMutationValidator';
import { EntityMutationInfo, EntityMutationType } from '../EntityMutator';
import EntityMutatorFactory from '../EntityMutatorFactory';
import { EntityTransactionalQueryContext, EntityQueryContext } from '../EntityQueryContext';
import ViewerContext from '../ViewerContext';
@@ -53,7 +57,7 @@ class TestMutationTrigger extends EntityMutationTrigger<
_viewerContext: ViewerContext,
_queryContext: EntityQueryContext,
_entity: TestEntity,
_mutationInfo: EntityMutationInfo<
_mutationInfo: EntityTriggerMutationInfo<
TestFields,
string,
ViewerContext,
@@ -95,7 +99,13 @@ const verifyValidatorCounts = (
keyof TestFields
>[],
expectedCalls: number,
mutationInfo: EntityMutationInfo<TestFields, string, ViewerContext, TestEntity, keyof TestFields>
mutationInfo: EntityValidatorMutationInfo<
TestFields,
string,
ViewerContext,
TestEntity,
keyof TestFields
>
): void => {
for (const validator of mutationValidatorSpies) {
verify(
@@ -125,15 +135,15 @@ const setUpMutationTriggerSpies = (
keyof TestFields
> => {
return {
beforeCreate: [spy(mutationTriggers.beforeCreate![0])],
afterCreate: [spy(mutationTriggers.afterCreate![0])],
beforeUpdate: [spy(mutationTriggers.beforeUpdate![0])],
afterUpdate: [spy(mutationTriggers.afterUpdate![0])],
beforeDelete: [spy(mutationTriggers.beforeDelete![0])],
afterDelete: [spy(mutationTriggers.afterDelete![0])],
beforeAll: [spy(mutationTriggers.beforeAll![0])],
afterAll: [spy(mutationTriggers.afterAll![0])],
afterCommit: [spy(mutationTriggers.afterCommit![0])],
beforeCreate: [spy(mutationTriggers.beforeCreate![0]!)],
afterCreate: [spy(mutationTriggers.afterCreate![0]!)],
beforeUpdate: [spy(mutationTriggers.beforeUpdate![0]!)],
afterUpdate: [spy(mutationTriggers.afterUpdate![0]!)],
beforeDelete: [spy(mutationTriggers.beforeDelete![0]!)],
afterDelete: [spy(mutationTriggers.afterDelete![0]!)],
beforeAll: [spy(mutationTriggers.beforeAll![0]!)],
afterAll: [spy(mutationTriggers.afterAll![0]!)],
afterCommit: [spy(mutationTriggers.afterCommit![0]!)],
};
};
@@ -164,7 +174,13 @@ const verifyTriggerCounts = (
>,
boolean
>,
mutationInfo: EntityMutationInfo<TestFields, string, ViewerContext, TestEntity, keyof TestFields>
mutationInfo: EntityTriggerMutationInfo<
TestFields,
string,
ViewerContext,
TestEntity,
keyof TestFields
>
): void => {
Object.keys(executed).forEach((s) => {
if ((executed as any)[s]) {
@@ -189,7 +205,7 @@ const verifyTriggerCounts = (
});
verify(
mutationTriggerSpies.beforeAll![0].executeAsync(
mutationTriggerSpies.beforeAll![0]!.executeAsync(
viewerContext,
anyOfClass(EntityTransactionalQueryContext),
anyOfClass(TestEntity),
@@ -198,7 +214,7 @@ const verifyTriggerCounts = (
).once();
verify(
mutationTriggerSpies.afterAll![0].executeAsync(
mutationTriggerSpies.afterAll![0]!.executeAsync(
viewerContext,
anyOfClass(EntityTransactionalQueryContext),
anyOfClass(TestEntity),
@@ -207,7 +223,7 @@ const verifyTriggerCounts = (
).once();
verify(
mutationTriggerSpies.afterCommit![0].executeAsync(
mutationTriggerSpies.afterCommit![0]!.executeAsync(
viewerContext,
anyOfClass(TestEntity),
deepEqual(mutationInfo)
@@ -297,7 +313,8 @@ const createEntityMutatorFactory = (
testEntityConfiguration,
TestEntity,
privacyPolicy,
dataManager
dataManager,
metricsAdapter
);
const entityMutatorFactory = new EntityMutatorFactory(
testEntityConfiguration,
@@ -388,7 +405,8 @@ describe(EntityMutatorFactory, () => {
spiedPrivacyPolicy.authorizeCreateAsync(
viewerContext,
anyOfClass(EntityTransactionalQueryContext),
anyOfClass(TestEntity)
anyOfClass(TestEntity),
anything()
)
).once();
});
@@ -527,28 +545,25 @@ describe(EntityMutatorFactory, () => {
const id1 = uuidv4();
const id2 = uuidv4();
const {
privacyPolicy,
entityMutatorFactory,
entityLoaderFactory,
} = createEntityMutatorFactory([
{
customIdField: id1,
stringField: 'huh',
testIndexedField: '3',
intField: 3,
dateField: new Date(),
nullableField: null,
},
{
customIdField: id2,
stringField: 'huh',
testIndexedField: '4',
intField: 3,
dateField: new Date(),
nullableField: null,
},
]);
const { privacyPolicy, entityMutatorFactory, entityLoaderFactory } =
createEntityMutatorFactory([
{
customIdField: id1,
stringField: 'huh',
testIndexedField: '3',
intField: 3,
dateField: new Date(),
nullableField: null,
},
{
customIdField: id2,
stringField: 'huh',
testIndexedField: '4',
intField: 3,
dateField: new Date(),
nullableField: null,
},
]);
const spiedPrivacyPolicy = spy(privacyPolicy);
@@ -565,7 +580,8 @@ describe(EntityMutatorFactory, () => {
spiedPrivacyPolicy.authorizeUpdateAsync(
viewerContext,
anyOfClass(EntityTransactionalQueryContext),
anyOfClass(TestEntity)
anyOfClass(TestEntity),
anything()
)
).once();
});
@@ -576,28 +592,25 @@ describe(EntityMutatorFactory, () => {
const id1 = uuidv4();
const id2 = uuidv4();
const {
mutationTriggers,
entityMutatorFactory,
entityLoaderFactory,
} = createEntityMutatorFactory([
{
customIdField: id1,
stringField: 'huh',
testIndexedField: '3',
intField: 3,
dateField: new Date(),
nullableField: null,
},
{
customIdField: id2,
stringField: 'huh',
testIndexedField: '4',
intField: 3,
dateField: new Date(),
nullableField: null,
},
]);
const { mutationTriggers, entityMutatorFactory, entityLoaderFactory } =
createEntityMutatorFactory([
{
customIdField: id1,
stringField: 'huh',
testIndexedField: '3',
intField: 3,
dateField: new Date(),
nullableField: null,
},
{
customIdField: id2,
stringField: 'huh',
testIndexedField: '4',
intField: 3,
dateField: new Date(),
nullableField: null,
},
]);
const triggerSpies = setUpMutationTriggerSpies(mutationTriggers);
@@ -630,28 +643,25 @@ describe(EntityMutatorFactory, () => {
const id1 = uuidv4();
const id2 = uuidv4();
const {
mutationValidators,
entityMutatorFactory,
entityLoaderFactory,
} = createEntityMutatorFactory([
{
customIdField: id1,
stringField: 'huh',
testIndexedField: '3',
intField: 3,
dateField: new Date(),
nullableField: null,
},
{
customIdField: id2,
stringField: 'huh',
testIndexedField: '4',
intField: 3,
dateField: new Date(),
nullableField: null,
},
]);
const { mutationValidators, entityMutatorFactory, entityLoaderFactory } =
createEntityMutatorFactory([
{
customIdField: id1,
stringField: 'huh',
testIndexedField: '3',
intField: 3,
dateField: new Date(),
nullableField: null,
},
{
customIdField: id2,
stringField: 'huh',
testIndexedField: '4',
intField: 3,
dateField: new Date(),
nullableField: null,
},
]);
const validatorSpies = setUpMutationValidatorSpies(mutationValidators);
@@ -707,20 +717,17 @@ describe(EntityMutatorFactory, () => {
const queryContext = StubQueryContextProvider.getQueryContext();
const id1 = uuidv4();
const {
privacyPolicy,
entityMutatorFactory,
entityLoaderFactory,
} = createEntityMutatorFactory([
{
customIdField: id1,
stringField: 'huh',
testIndexedField: '3',
intField: 3,
dateField: new Date(),
nullableField: null,
},
]);
const { privacyPolicy, entityMutatorFactory, entityLoaderFactory } =
createEntityMutatorFactory([
{
customIdField: id1,
stringField: 'huh',
testIndexedField: '3',
intField: 3,
dateField: new Date(),
nullableField: null,
},
]);
const spiedPrivacyPolicy = spy(privacyPolicy);
@@ -734,7 +741,8 @@ describe(EntityMutatorFactory, () => {
spiedPrivacyPolicy.authorizeDeleteAsync(
viewerContext,
anyOfClass(EntityTransactionalQueryContext),
anyOfClass(TestEntity)
anyOfClass(TestEntity),
anything()
)
).once();
});
@@ -744,20 +752,17 @@ describe(EntityMutatorFactory, () => {
const queryContext = StubQueryContextProvider.getQueryContext();
const id1 = uuidv4();
const {
mutationTriggers,
entityMutatorFactory,
entityLoaderFactory,
} = createEntityMutatorFactory([
{
customIdField: id1,
stringField: 'huh',
testIndexedField: '3',
intField: 3,
dateField: new Date(),
nullableField: null,
},
]);
const { mutationTriggers, entityMutatorFactory, entityLoaderFactory } =
createEntityMutatorFactory([
{
customIdField: id1,
stringField: 'huh',
testIndexedField: '3',
intField: 3,
dateField: new Date(),
nullableField: null,
},
]);
const triggerSpies = setUpMutationTriggerSpies(mutationTriggers);
@@ -778,7 +783,7 @@ describe(EntityMutatorFactory, () => {
beforeDelete: true,
afterDelete: true,
},
{ type: EntityMutationType.DELETE }
{ type: EntityMutationType.DELETE, cascadingDeleteCause: null }
);
});
@@ -787,20 +792,17 @@ describe(EntityMutatorFactory, () => {
const queryContext = StubQueryContextProvider.getQueryContext();
const id1 = uuidv4();
const {
mutationValidators,
entityMutatorFactory,
entityLoaderFactory,
} = createEntityMutatorFactory([
{
customIdField: id1,
stringField: 'huh',
testIndexedField: '3',
intField: 3,
dateField: new Date(),
nullableField: null,
},
]);
const { mutationValidators, entityMutatorFactory, entityLoaderFactory } =
createEntityMutatorFactory([
{
customIdField: id1,
stringField: 'huh',
testIndexedField: '3',
intField: 3,
dateField: new Date(),
nullableField: null,
},
]);
const validatorSpies = setUpMutationValidatorSpies(mutationValidators);
@@ -810,7 +812,9 @@ describe(EntityMutatorFactory, () => {
await entityMutatorFactory.forDelete(existingEntity, queryContext).enforceDeleteAsync();
verifyValidatorCounts(viewerContext, validatorSpies, 0, { type: EntityMutationType.DELETE });
verifyValidatorCounts(viewerContext, validatorSpies, 0, {
type: EntityMutationType.DELETE as any,
});
});
});
@@ -912,21 +916,24 @@ describe(EntityMutatorFactory, () => {
privacyPolicyMock.authorizeCreateAsync(
viewerContext,
anyOfClass(EntityTransactionalQueryContext),
anyOfClass(SimpleTestEntity)
anyOfClass(SimpleTestEntity),
anything()
)
).thenReject(rejectionError);
when(
privacyPolicyMock.authorizeUpdateAsync(
viewerContext,
anyOfClass(EntityTransactionalQueryContext),
anyOfClass(SimpleTestEntity)
anyOfClass(SimpleTestEntity),
anything()
)
).thenReject(rejectionError);
when(
privacyPolicyMock.authorizeDeleteAsync(
viewerContext,
anyOfClass(EntityTransactionalQueryContext),
anyOfClass(SimpleTestEntity)
anyOfClass(SimpleTestEntity),
anything()
)
).thenReject(rejectionError);
@@ -1,4 +1,4 @@
import { mock, instance, spy, verify, anyOfClass } from 'ts-mockito';
import { mock, instance, spy, verify, anyOfClass, anything, objectContaining } from 'ts-mockito';
import Entity from '../Entity';
import { EntityCompanionDefinition } from '../EntityCompanionProvider';
@@ -6,11 +6,15 @@ import EntityConfiguration from '../EntityConfiguration';
import { UUIDField } from '../EntityFields';
import EntityPrivacyPolicy, {
EntityPrivacyPolicyEvaluator,
EntityAuthorizationAction,
EntityPrivacyPolicyEvaluationMode,
} from '../EntityPrivacyPolicy';
import { EntityQueryContext } from '../EntityQueryContext';
import ViewerContext from '../ViewerContext';
import EntityNotAuthorizedError from '../errors/EntityNotAuthorizedError';
import IEntityMetricsAdapter, {
EntityMetricsAuthorizationResult,
} from '../metrics/IEntityMetricsAdapter';
import AlwaysAllowPrivacyPolicyRule from '../rules/AlwaysAllowPrivacyPolicyRule';
import AlwaysDenyPrivacyPolicyRule from '../rules/AlwaysDenyPrivacyPolicyRule';
import AlwaysSkipPrivacyPolicyRule from '../rules/AlwaysSkipPrivacyPolicyRule';
@@ -33,16 +37,16 @@ class BlahEntity extends Entity<BlahFields, string, ViewerContext> {
}
class AlwaysDenyPolicy extends EntityPrivacyPolicy<BlahFields, string, ViewerContext, BlahEntity> {
protected readonly createRules = [
protected override readonly createRules = [
new AlwaysDenyPrivacyPolicyRule<BlahFields, string, ViewerContext, BlahEntity>(),
];
protected readonly readRules = [
protected override readonly readRules = [
new AlwaysDenyPrivacyPolicyRule<BlahFields, string, ViewerContext, BlahEntity>(),
];
protected readonly updateRules = [
protected override readonly updateRules = [
new AlwaysDenyPrivacyPolicyRule<BlahFields, string, ViewerContext, BlahEntity>(),
];
protected readonly deleteRules = [
protected override readonly deleteRules = [
new AlwaysDenyPrivacyPolicyRule<BlahFields, string, ViewerContext, BlahEntity>(),
];
}
@@ -53,7 +57,7 @@ class DryRunAlwaysDenyPolicy extends AlwaysDenyPolicy {
_error: EntityNotAuthorizedError<BlahFields, string, ViewerContext, BlahEntity>
): void {}
protected getPrivacyPolicyEvaluator(): EntityPrivacyPolicyEvaluator<
protected override getPrivacyPolicyEvaluator(): EntityPrivacyPolicyEvaluator<
BlahFields,
string,
ViewerContext,
@@ -72,7 +76,7 @@ class LoggingEnforceAlwaysDenyPolicy extends AlwaysDenyPolicy {
_error: EntityNotAuthorizedError<BlahFields, string, ViewerContext, BlahEntity>
): void {}
protected getPrivacyPolicyEvaluator(): EntityPrivacyPolicyEvaluator<
protected override getPrivacyPolicyEvaluator(): EntityPrivacyPolicyEvaluator<
BlahFields,
string,
ViewerContext,
@@ -86,16 +90,16 @@ class LoggingEnforceAlwaysDenyPolicy extends AlwaysDenyPolicy {
}
class AlwaysAllowPolicy extends EntityPrivacyPolicy<BlahFields, string, ViewerContext, BlahEntity> {
protected readonly createRules = [
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<BlahFields, string, ViewerContext, BlahEntity>(),
];
protected readonly readRules = [
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<BlahFields, string, ViewerContext, BlahEntity>(),
];
protected readonly updateRules = [
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<BlahFields, string, ViewerContext, BlahEntity>(),
];
protected readonly deleteRules = [
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<BlahFields, string, ViewerContext, BlahEntity>(),
];
}
@@ -106,7 +110,7 @@ class DryRunAlwaysAllowPolicy extends AlwaysAllowPolicy {
_error: EntityNotAuthorizedError<BlahFields, string, ViewerContext, BlahEntity>
): void {}
protected getPrivacyPolicyEvaluator(): EntityPrivacyPolicyEvaluator<
protected override getPrivacyPolicyEvaluator(): EntityPrivacyPolicyEvaluator<
BlahFields,
string,
ViewerContext,
@@ -125,7 +129,7 @@ class LoggingEnforceAlwaysAllowPolicy extends AlwaysAllowPolicy {
_error: EntityNotAuthorizedError<BlahFields, string, ViewerContext, BlahEntity>
): void {}
protected getPrivacyPolicyEvaluator(): EntityPrivacyPolicyEvaluator<
protected override getPrivacyPolicyEvaluator(): EntityPrivacyPolicyEvaluator<
BlahFields,
string,
ViewerContext,
@@ -139,16 +143,16 @@ class LoggingEnforceAlwaysAllowPolicy extends AlwaysAllowPolicy {
}
class SkipAllPolicy extends EntityPrivacyPolicy<BlahFields, string, ViewerContext, BlahEntity> {
protected readonly createRules = [
protected override readonly createRules = [
new AlwaysSkipPrivacyPolicyRule<BlahFields, string, ViewerContext, BlahEntity>(),
];
protected readonly readRules = [
protected override readonly readRules = [
new AlwaysSkipPrivacyPolicyRule<BlahFields, string, ViewerContext, BlahEntity>(),
];
protected readonly updateRules = [
protected override readonly updateRules = [
new AlwaysSkipPrivacyPolicyRule<BlahFields, string, ViewerContext, BlahEntity>(),
];
protected readonly deleteRules = [
protected override readonly deleteRules = [
new AlwaysSkipPrivacyPolicyRule<BlahFields, string, ViewerContext, BlahEntity>(),
];
}
@@ -169,10 +173,10 @@ class AlwaysThrowPrivacyPolicyRule extends PrivacyPolicyRule<
}
class ThrowAllPolicy extends EntityPrivacyPolicy<BlahFields, string, ViewerContext, BlahEntity> {
protected readonly createRules = [new AlwaysThrowPrivacyPolicyRule()];
protected readonly readRules = [new AlwaysThrowPrivacyPolicyRule()];
protected readonly updateRules = [new AlwaysThrowPrivacyPolicyRule()];
protected readonly deleteRules = [new AlwaysThrowPrivacyPolicyRule()];
protected override readonly createRules = [new AlwaysThrowPrivacyPolicyRule()];
protected override readonly readRules = [new AlwaysThrowPrivacyPolicyRule()];
protected override readonly updateRules = [new AlwaysThrowPrivacyPolicyRule()];
protected override readonly deleteRules = [new AlwaysThrowPrivacyPolicyRule()];
}
class DryRunThrowAllPolicy extends ThrowAllPolicy {
@@ -181,7 +185,7 @@ class DryRunThrowAllPolicy extends ThrowAllPolicy {
_error: EntityNotAuthorizedError<BlahFields, string, ViewerContext, BlahEntity>
): void {}
protected getPrivacyPolicyEvaluator(): EntityPrivacyPolicyEvaluator<
protected override getPrivacyPolicyEvaluator(): EntityPrivacyPolicyEvaluator<
BlahFields,
string,
ViewerContext,
@@ -200,7 +204,7 @@ class LoggingEnforceThrowAllPolicy extends ThrowAllPolicy {
_error: EntityNotAuthorizedError<BlahFields, string, ViewerContext, BlahEntity>
): void {}
protected getPrivacyPolicyEvaluator(): EntityPrivacyPolicyEvaluator<
protected override getPrivacyPolicyEvaluator(): EntityPrivacyPolicyEvaluator<
BlahFields,
string,
ViewerContext,
@@ -214,10 +218,10 @@ class LoggingEnforceThrowAllPolicy extends ThrowAllPolicy {
}
class EmptyPolicy extends EntityPrivacyPolicy<BlahFields, string, ViewerContext, BlahEntity> {
protected readonly createRules = [];
protected readonly readRules = [];
protected readonly updateRules = [];
protected readonly deleteRules = [];
protected override readonly createRules = [];
protected override readonly readRules = [];
protected override readonly updateRules = [];
protected override readonly deleteRules = [];
}
const blahEntityCompanionDefinition = new EntityCompanionDefinition({
@@ -237,143 +241,276 @@ const blahEntityCompanionDefinition = new EntityCompanionDefinition({
});
describe(EntityPrivacyPolicy, () => {
it('throws EntityNotAuthorizedError when deny', async () => {
const viewerContext = instance(mock(ViewerContext));
const queryContext = instance(mock(EntityQueryContext));
const entity = new BlahEntity(viewerContext, { id: '1' });
const policy = new AlwaysDenyPolicy();
await expect(
policy.authorizeCreateAsync(viewerContext, queryContext, entity)
).rejects.toBeInstanceOf(EntityNotAuthorizedError);
describe(EntityPrivacyPolicyEvaluationMode.ENFORCE.toString(), () => {
it('throws EntityNotAuthorizedError when deny', async () => {
const viewerContext = instance(mock(ViewerContext));
const queryContext = instance(mock(EntityQueryContext));
const metricsAdapterMock = mock<IEntityMetricsAdapter>();
const metricsAdapter = instance(metricsAdapterMock);
const entity = new BlahEntity(viewerContext, { id: '1' });
const policy = new AlwaysDenyPolicy();
await expect(
policy.authorizeCreateAsync(viewerContext, queryContext, entity, metricsAdapter)
).rejects.toBeInstanceOf(EntityNotAuthorizedError);
verify(
metricsAdapterMock.logAuthorizationEvent(
objectContaining({
entityClassName: entity.constructor.name,
action: EntityAuthorizationAction.CREATE,
evaluationResult: EntityMetricsAuthorizationResult.DENY,
privacyPolicyEvaluationMode: EntityPrivacyPolicyEvaluationMode.ENFORCE,
})
)
).once();
});
it('returns entity when allowed', async () => {
const viewerContext = instance(mock(ViewerContext));
const queryContext = instance(mock(EntityQueryContext));
const metricsAdapterMock = mock<IEntityMetricsAdapter>();
const metricsAdapter = instance(metricsAdapterMock);
const entity = new BlahEntity(viewerContext, { id: '1' });
const policy = new AlwaysAllowPolicy();
const approvedEntity = await policy.authorizeCreateAsync(
viewerContext,
queryContext,
entity,
metricsAdapter
);
expect(approvedEntity).toEqual(entity);
verify(
metricsAdapterMock.logAuthorizationEvent(
objectContaining({
entityClassName: entity.constructor.name,
action: EntityAuthorizationAction.CREATE,
evaluationResult: EntityMetricsAuthorizationResult.ALLOW,
privacyPolicyEvaluationMode: EntityPrivacyPolicyEvaluationMode.ENFORCE,
})
)
).once();
});
it('throws EntityNotAuthorizedError when all skipped', async () => {
const viewerContext = instance(mock(ViewerContext));
const queryContext = instance(mock(EntityQueryContext));
const metricsAdapterMock = mock<IEntityMetricsAdapter>();
const metricsAdapter = instance(metricsAdapterMock);
const entity = new BlahEntity(viewerContext, { id: '1' });
const policy = new SkipAllPolicy();
await expect(
policy.authorizeCreateAsync(viewerContext, queryContext, entity, metricsAdapter)
).rejects.toBeInstanceOf(EntityNotAuthorizedError);
verify(
metricsAdapterMock.logAuthorizationEvent(
objectContaining({
entityClassName: entity.constructor.name,
action: EntityAuthorizationAction.CREATE,
evaluationResult: EntityMetricsAuthorizationResult.DENY,
privacyPolicyEvaluationMode: EntityPrivacyPolicyEvaluationMode.ENFORCE,
})
)
).once();
});
it('throws EntityNotAuthorizedError when empty policy', async () => {
const viewerContext = instance(mock(ViewerContext));
const queryContext = instance(mock(EntityQueryContext));
const metricsAdapterMock = mock<IEntityMetricsAdapter>();
const metricsAdapter = instance(metricsAdapterMock);
const entity = new BlahEntity(viewerContext, { id: '1' });
const policy = new EmptyPolicy();
await expect(
policy.authorizeCreateAsync(viewerContext, queryContext, entity, metricsAdapter)
).rejects.toBeInstanceOf(EntityNotAuthorizedError);
verify(
metricsAdapterMock.logAuthorizationEvent(
objectContaining({
entityClassName: entity.constructor.name,
action: EntityAuthorizationAction.CREATE,
evaluationResult: EntityMetricsAuthorizationResult.DENY,
privacyPolicyEvaluationMode: EntityPrivacyPolicyEvaluationMode.ENFORCE,
})
)
).once();
});
it('throws when rule throws', async () => {
const viewerContext = instance(mock(ViewerContext));
const queryContext = instance(mock(EntityQueryContext));
const metricsAdapterMock = mock<IEntityMetricsAdapter>();
const metricsAdapter = instance(metricsAdapterMock);
const entity = new BlahEntity(viewerContext, { id: '1' });
const policy = new ThrowAllPolicy();
await expect(
policy.authorizeCreateAsync(viewerContext, queryContext, entity, metricsAdapter)
).rejects.toThrowError('WooHoo!');
verify(metricsAdapterMock.logAuthorizationEvent(anything())).never();
});
});
it('returns entity when allowed', async () => {
const viewerContext = instance(mock(ViewerContext));
const queryContext = instance(mock(EntityQueryContext));
const entity = new BlahEntity(viewerContext, { id: '1' });
const policy = new AlwaysAllowPolicy();
const approvedEntity = await policy.authorizeCreateAsync(viewerContext, queryContext, entity);
expect(approvedEntity).toEqual(entity);
});
it('throws EntityNotAuthorizedError when all skipped', async () => {
const viewerContext = instance(mock(ViewerContext));
const queryContext = instance(mock(EntityQueryContext));
const entity = new BlahEntity(viewerContext, { id: '1' });
const policy = new SkipAllPolicy();
await expect(
policy.authorizeCreateAsync(viewerContext, queryContext, entity)
).rejects.toBeInstanceOf(EntityNotAuthorizedError);
});
it('throws EntityNotAuthorizedError when empty policy', async () => {
const viewerContext = instance(mock(ViewerContext));
const queryContext = instance(mock(EntityQueryContext));
const entity = new BlahEntity(viewerContext, { id: '1' });
const policy = new EmptyPolicy();
await expect(
policy.authorizeCreateAsync(viewerContext, queryContext, entity)
).rejects.toBeInstanceOf(EntityNotAuthorizedError);
});
it('throws when rule throws', async () => {
const viewerContext = instance(mock(ViewerContext));
const queryContext = instance(mock(EntityQueryContext));
const entity = new BlahEntity(viewerContext, { id: '1' });
const policy = new ThrowAllPolicy();
await expect(
policy.authorizeCreateAsync(viewerContext, queryContext, entity)
).rejects.toThrowError('WooHoo!');
});
describe('dry run', () => {
describe(EntityPrivacyPolicyEvaluationMode.DRY_RUN.toString(), () => {
it('returns entity when denied but calls denialHandler', async () => {
const viewerContext = instance(mock(ViewerContext));
const queryContext = instance(mock(EntityQueryContext));
const metricsAdapterMock = mock<IEntityMetricsAdapter>();
const metricsAdapter = instance(metricsAdapterMock);
const entity = new BlahEntity(viewerContext, { id: '1' });
const policy = new DryRunAlwaysDenyPolicy();
const policySpy = spy(policy);
const approvedEntity = await policy.authorizeCreateAsync(viewerContext, queryContext, entity);
const approvedEntity = await policy.authorizeCreateAsync(
viewerContext,
queryContext,
entity,
metricsAdapter
);
expect(approvedEntity).toEqual(entity);
verify(policySpy.denyHandler(anyOfClass(EntityNotAuthorizedError))).once();
verify(
metricsAdapterMock.logAuthorizationEvent(
objectContaining({
entityClassName: entity.constructor.name,
action: EntityAuthorizationAction.CREATE,
evaluationResult: EntityMetricsAuthorizationResult.DENY,
privacyPolicyEvaluationMode: EntityPrivacyPolicyEvaluationMode.DRY_RUN,
})
)
).once();
});
it('does not log when not denied', async () => {
const viewerContext = instance(mock(ViewerContext));
const queryContext = instance(mock(EntityQueryContext));
const metricsAdapterMock = mock<IEntityMetricsAdapter>();
const metricsAdapter = instance(metricsAdapterMock);
const entity = new BlahEntity(viewerContext, { id: '1' });
const policy = new DryRunAlwaysAllowPolicy();
const policySpy = spy(policy);
const approvedEntity = await policy.authorizeCreateAsync(viewerContext, queryContext, entity);
const approvedEntity = await policy.authorizeCreateAsync(
viewerContext,
queryContext,
entity,
metricsAdapter
);
expect(approvedEntity).toEqual(entity);
verify(policySpy.denyHandler(anyOfClass(EntityNotAuthorizedError))).never();
verify(
metricsAdapterMock.logAuthorizationEvent(
objectContaining({
entityClassName: entity.constructor.name,
action: EntityAuthorizationAction.CREATE,
evaluationResult: EntityMetricsAuthorizationResult.ALLOW,
privacyPolicyEvaluationMode: EntityPrivacyPolicyEvaluationMode.DRY_RUN,
})
)
).once();
});
it('passes through other errors', async () => {
const viewerContext = instance(mock(ViewerContext));
const queryContext = instance(mock(EntityQueryContext));
const metricsAdapterMock = mock<IEntityMetricsAdapter>();
const metricsAdapter = instance(metricsAdapterMock);
const entity = new BlahEntity(viewerContext, { id: '1' });
const policy = new DryRunThrowAllPolicy();
const policySpy = spy(policy);
await expect(
policy.authorizeCreateAsync(viewerContext, queryContext, entity)
policy.authorizeCreateAsync(viewerContext, queryContext, entity, metricsAdapter)
).rejects.toThrowError('WooHoo!');
verify(policySpy.denyHandler(anyOfClass(EntityNotAuthorizedError))).never();
verify(metricsAdapterMock.logAuthorizationEvent(anything())).never();
});
});
describe('logging enforce', () => {
describe(EntityPrivacyPolicyEvaluationMode.ENFORCE_AND_LOG.toString(), () => {
it('denies when denied but calls denialHandler', async () => {
const viewerContext = instance(mock(ViewerContext));
const queryContext = instance(mock(EntityQueryContext));
const metricsAdapterMock = mock<IEntityMetricsAdapter>();
const metricsAdapter = instance(metricsAdapterMock);
const entity = new BlahEntity(viewerContext, { id: '1' });
const policy = new LoggingEnforceAlwaysDenyPolicy();
const policySpy = spy(policy);
await expect(
policy.authorizeCreateAsync(viewerContext, queryContext, entity)
policy.authorizeCreateAsync(viewerContext, queryContext, entity, metricsAdapter)
).rejects.toBeInstanceOf(EntityNotAuthorizedError);
verify(policySpy.denyHandler(anyOfClass(EntityNotAuthorizedError))).once();
verify(
metricsAdapterMock.logAuthorizationEvent(
objectContaining({
entityClassName: entity.constructor.name,
action: EntityAuthorizationAction.CREATE,
evaluationResult: EntityMetricsAuthorizationResult.DENY,
privacyPolicyEvaluationMode: EntityPrivacyPolicyEvaluationMode.ENFORCE_AND_LOG,
})
)
).once();
});
it('does not log when not denied', async () => {
const viewerContext = instance(mock(ViewerContext));
const queryContext = instance(mock(EntityQueryContext));
const metricsAdapterMock = mock<IEntityMetricsAdapter>();
const metricsAdapter = instance(metricsAdapterMock);
const entity = new BlahEntity(viewerContext, { id: '1' });
const policy = new LoggingEnforceAlwaysAllowPolicy();
const policySpy = spy(policy);
const approvedEntity = await policy.authorizeCreateAsync(viewerContext, queryContext, entity);
const approvedEntity = await policy.authorizeCreateAsync(
viewerContext,
queryContext,
entity,
metricsAdapter
);
expect(approvedEntity).toEqual(entity);
verify(policySpy.denyHandler(anyOfClass(EntityNotAuthorizedError))).never();
verify(
metricsAdapterMock.logAuthorizationEvent(
objectContaining({
entityClassName: entity.constructor.name,
action: EntityAuthorizationAction.CREATE,
evaluationResult: EntityMetricsAuthorizationResult.ALLOW,
privacyPolicyEvaluationMode: EntityPrivacyPolicyEvaluationMode.ENFORCE_AND_LOG,
})
)
).once();
});
it('passes through other errors', async () => {
const viewerContext = instance(mock(ViewerContext));
const queryContext = instance(mock(EntityQueryContext));
const metricsAdapterMock = mock<IEntityMetricsAdapter>();
const metricsAdapter = instance(metricsAdapterMock);
const entity = new BlahEntity(viewerContext, { id: '1' });
const policy = new LoggingEnforceThrowAllPolicy();
const policySpy = spy(policy);
await expect(
policy.authorizeCreateAsync(viewerContext, queryContext, entity)
policy.authorizeCreateAsync(viewerContext, queryContext, entity, metricsAdapter)
).rejects.toThrowError('WooHoo!');
verify(policySpy.denyHandler(anyOfClass(EntityNotAuthorizedError))).never();
verify(metricsAdapterMock.logAuthorizationEvent(anything())).never();
});
});
});
@@ -0,0 +1,82 @@
import invariant from 'invariant';
import { EntityQueryContext } from '../EntityQueryContext';
import ViewerContext from '../ViewerContext';
import { createUnitTestEntityCompanionProvider } from '../utils/testing/createUnitTestEntityCompanionProvider';
describe(EntityQueryContext, () => {
describe('callbacks', () => {
it('calls all callbacks, and calls invalidation first', async () => {
const companionProvider = createUnitTestEntityCompanionProvider();
const viewerContext = new ViewerContext(companionProvider);
const preCommitFirstCallback = jest.fn(async (): Promise<void> => {});
const preCommitSecondCallback = jest.fn(async (): Promise<void> => {});
const postCommitInvalidationCallback = jest.fn(async (): Promise<void> => {
invariant(
preCommitFirstCallback.mock.calls.length === 1,
'preCommit should be called before postCommitInvalidation'
);
invariant(
preCommitSecondCallback.mock.calls.length === 1,
'preCommit should be called before postCommitInvalidation'
);
});
const postCommitCallback = jest.fn(async (): Promise<void> => {
invariant(
preCommitFirstCallback.mock.calls.length === 1,
'preCommit should be called before postCommit'
);
invariant(
preCommitSecondCallback.mock.calls.length === 1,
'preCommit should be called before postCommit'
);
invariant(
postCommitInvalidationCallback.mock.calls.length === 1,
'postCommitInvalidation should be called before postCommit'
);
});
await viewerContext.runInTransactionForDatabaseAdaptorFlavorAsync(
'postgres',
async (queryContext) => {
queryContext.appendPostCommitCallback(postCommitCallback);
queryContext.appendPostCommitInvalidationCallback(postCommitInvalidationCallback);
queryContext.appendPreCommitCallback(preCommitSecondCallback, 2);
queryContext.appendPreCommitCallback(preCommitFirstCallback, 1);
}
);
expect(preCommitFirstCallback).toHaveBeenCalledTimes(1);
expect(preCommitSecondCallback).toHaveBeenCalledTimes(1);
expect(postCommitCallback).toHaveBeenCalledTimes(1);
expect(postCommitInvalidationCallback).toHaveBeenCalledTimes(1);
});
it('prevents transaction from finishing when precommit throws (post commit callbacks are not called)', async () => {
const companionProvider = createUnitTestEntityCompanionProvider();
const viewerContext = new ViewerContext(companionProvider);
const preCommitCallback = jest.fn(async (): Promise<void> => {
throw new Error('wat');
});
const postCommitInvalidationCallback = jest.fn(async (): Promise<void> => {});
const postCommitCallback = jest.fn(async (): Promise<void> => {});
await expect(
viewerContext.runInTransactionForDatabaseAdaptorFlavorAsync(
'postgres',
async (queryContext) => {
queryContext.appendPostCommitCallback(postCommitCallback);
queryContext.appendPostCommitInvalidationCallback(postCommitInvalidationCallback);
queryContext.appendPreCommitCallback(preCommitCallback, 0);
}
)
).rejects.toThrowError('wat');
expect(preCommitCallback).toHaveBeenCalledTimes(1);
expect(postCommitCallback).toHaveBeenCalledTimes(0);
expect(postCommitInvalidationCallback).toHaveBeenCalledTimes(0);
});
});
});
@@ -35,9 +35,8 @@ describe(EntitySecondaryCacheLoader, () => {
const createdEntity = await SimpleTestEntity.creator(vc1).enforceCreateAsync();
const loadParams = { id: createdEntity.getID() };
const secondaryEntityCacheMock = mock<
ISecondaryEntityCache<SimpleTestFields, TestLoadParams>
>();
const secondaryEntityCacheMock =
mock<ISecondaryEntityCache<SimpleTestFields, TestLoadParams>>();
when(
secondaryEntityCacheMock.loadManyThroughAsync(deepEqual([loadParams]), anything())
).thenResolve(new Map());
@@ -61,9 +60,8 @@ describe(EntitySecondaryCacheLoader, () => {
const createdEntity = await SimpleTestEntity.creator(vc1).enforceCreateAsync();
const loadParams = { id: createdEntity.getID() };
const secondaryEntityCacheMock = mock<
ISecondaryEntityCache<SimpleTestFields, TestLoadParams>
>();
const secondaryEntityCacheMock =
mock<ISecondaryEntityCache<SimpleTestFields, TestLoadParams>>();
when(
secondaryEntityCacheMock.loadManyThroughAsync(deepEqual([loadParams]), anything())
).thenResolve(new Map([[loadParams, createdEntity.getAllFields()]]));
@@ -80,6 +78,7 @@ describe(EntitySecondaryCacheLoader, () => {
spiedPrivacyPolicy.authorizeReadAsync(
vc1,
anyOfClass(EntityNonTransactionalQueryContext),
anything(),
anything()
)
).once();
@@ -93,9 +92,8 @@ describe(EntitySecondaryCacheLoader, () => {
const createdEntity = await SimpleTestEntity.creator(vc1).enforceCreateAsync();
const loadParams = { id: createdEntity.getID() };
const secondaryEntityCacheMock = mock<
ISecondaryEntityCache<SimpleTestFields, TestLoadParams>
>();
const secondaryEntityCacheMock =
mock<ISecondaryEntityCache<SimpleTestFields, TestLoadParams>>();
const secondaryEntityCache = instance(secondaryEntityCacheMock);
const loader = SimpleTestEntity.loader(vc1);
const secondaryCacheLoader = new TestSecondaryRedisCacheLoader(secondaryEntityCache, loader);
@@ -1,7 +1,8 @@
import Entity from '../Entity';
import { EntityCompanionDefinition } from '../EntityCompanionProvider';
import EntityConfiguration from '../EntityConfiguration';
import { UUIDField, EntityEdgeDeletionBehavior } from '../EntityFields';
import { EntityEdgeDeletionBehavior } from '../EntityFieldDefinition';
import { UUIDField } from '../EntityFields';
import EntityPrivacyPolicy from '../EntityPrivacyPolicy';
import { CacheStatus } from '../internal/ReadThroughEntityCache';
import AlwaysAllowPrivacyPolicyRule from '../rules/AlwaysAllowPrivacyPolicyRule';
@@ -21,16 +22,16 @@ class CategoryPrivacyPolicy extends EntityPrivacyPolicy<
any,
any
> {
protected readonly readRules = [
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<CategoryFields, string, TestViewerContext, any, any>(),
];
protected readonly createRules = [
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<CategoryFields, string, TestViewerContext, any, any>(),
];
protected readonly updateRules = [
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<CategoryFields, string, TestViewerContext, any, any>(),
];
protected readonly deleteRules = [
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<CategoryFields, string, TestViewerContext, any, any>(),
];
}
@@ -17,12 +17,13 @@ describe(ViewerContext, () => {
it('creates a new transactional query context', async () => {
const companionProvider = createUnitTestEntityCompanionProvider();
const viewerContext = new ViewerContext(companionProvider);
const didCreateTransaction = await viewerContext.runInTransactionForDatabaseAdaptorFlavorAsync(
'postgres',
async (queryContext) => {
return queryContext.isInTransaction();
}
);
const didCreateTransaction =
await viewerContext.runInTransactionForDatabaseAdaptorFlavorAsync(
'postgres',
async (queryContext) => {
return queryContext.isInTransaction();
}
);
expect(didCreateTransaction).toBe(true);
});
});
@@ -10,16 +10,17 @@ import TestEntity, { TestFields, TestEntityPrivacyPolicy } from '../testfixtures
describe(ViewerScopedEntityCompanion, () => {
it('returns viewer scoped loader and mutator factory', () => {
const vc = instance(mock(ViewerContext));
const entityCompanion = mock<
EntityCompanion<
TestFields,
string,
ViewerContext,
TestEntity,
TestEntityPrivacyPolicy,
keyof TestFields
>
>();
const entityCompanion =
mock<
EntityCompanion<
TestFields,
string,
ViewerContext,
TestEntity,
TestEntityPrivacyPolicy,
keyof TestFields
>
>();
const viewerScopedEntityCompanion = new ViewerScopedEntityCompanion(entityCompanion, vc);
expect(viewerScopedEntityCompanion.getLoaderFactory()).toBeInstanceOf(
ViewerScopedEntityLoaderFactory
@@ -10,9 +10,10 @@ describe(ViewerScopedEntityMutatorFactory, () => {
it('correctly scopes viewer to entity mutations', async () => {
const viewerContext = instance(mock(ViewerContext));
const queryContext = instance(mock(EntityQueryContext));
const baseMutatorFactory = mock<
EntityMutatorFactory<TestFields, string, ViewerContext, TestEntity, TestEntityPrivacyPolicy>
>(EntityMutatorFactory);
const baseMutatorFactory =
mock<
EntityMutatorFactory<TestFields, string, ViewerContext, TestEntity, TestEntityPrivacyPolicy>
>(EntityMutatorFactory);
const baseMutatorFactoryInstance = instance(baseMutatorFactory);
const viewerScopedEntityLoader = new ViewerScopedEntityMutatorFactory<
@@ -45,8 +45,8 @@ describe('Two entities backed by the same table', () => {
expect(successfulManyResults).toHaveLength(1);
expect(failedManyResults).toHaveLength(1);
expect(successfulManyResults[0].enforceValue().getID()).toEqual(one.getID());
expect(failedManyResults[0].enforceError().message).toEqual(
expect(successfulManyResults[0]!.enforceValue().getID()).toEqual(one.getID());
expect(failedManyResults[0]!.enforceError().message).toEqual(
'OneTestEntity must be instantiated with one data'
);
@@ -107,16 +107,16 @@ const testEntityConfiguration = new EntityConfiguration<TestFields>({
});
class TestEntityPrivacyPolicy extends EntityPrivacyPolicy<any, string, ViewerContext, any, any> {
protected readonly readRules = [
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, ViewerContext, any, any>(),
];
protected readonly createRules = [
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, ViewerContext, any, any>(),
];
protected readonly updateRules = [
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, ViewerContext, any, any>(),
];
protected readonly deleteRules = [
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, ViewerContext, any, any>(),
];
}
@@ -115,16 +115,16 @@ const testEntityConfiguration = new EntityConfiguration<TestFields>({
});
class TestEntityPrivacyPolicy extends EntityPrivacyPolicy<any, string, ViewerContext, any, any> {
protected readonly readRules = [
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, ViewerContext, any, any>(),
];
protected readonly createRules = [
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, ViewerContext, any, any>(),
];
protected readonly updateRules = [
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, ViewerContext, any, any>(),
];
protected readonly deleteRules = [
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<any, string, ViewerContext, any, any>(),
];
}
+3
View File
@@ -26,12 +26,14 @@ export { default as EntityError } from './errors/EntityError';
export { default as EntityNotAuthorizedError } from './errors/EntityNotAuthorizedError';
export { default as EntityNotFoundError } from './errors/EntityNotFoundError';
export * from './EntityFields';
export * from './EntityFieldDefinition';
export { default as EntityLoader } from './EntityLoader';
export { default as EntityLoaderFactory } from './EntityLoaderFactory';
export { default as EntitySecondaryCacheLoader } from './EntitySecondaryCacheLoader';
export * from './EntitySecondaryCacheLoader';
export * from './EntityMutator';
export { default as EntityMutationValidator } from './EntityMutationValidator';
export * from './EntityMutationInfo';
export * from './EntityMutationTriggerConfiguration';
export { default as EntityMutationTriggerConfiguration } from './EntityMutationTriggerConfiguration';
export { default as EntityMutatorFactory } from './EntityMutatorFactory';
@@ -63,6 +65,7 @@ export { default as PrivacyPolicyRule } from './rules/PrivacyPolicyRule';
export * from './rules/PrivacyPolicyRule';
export * from './utils/testing/PrivacyPolicyRuleTestUtils';
export * from './utils/testing/StubCacheAdapter';
export { default as describeFieldTestCase } from './utils/testing/describeFieldTestCase';
export { default as StubDatabaseAdapter } from './utils/testing/StubDatabaseAdapter';
export { default as StubDatabaseAdapterProvider } from './utils/testing/StubDatabaseAdapterProvider';
export { default as StubQueryContextProvider } from './utils/testing/StubQueryContextProvider';
@@ -125,7 +125,8 @@ export default class EntityDataManager<TFields> {
const results = await dataLoader.loadMany(fieldValues);
const [values, errors] = partitionErrors(results);
if (errors.length > 0) {
throw errors[0];
const error = errors[0]!;
throw error;
}
return zipToMap(fieldValues, values);
@@ -3,11 +3,6 @@ import invariant from 'invariant';
import EntityCacheAdapter from '../EntityCacheAdapter';
import EntityConfiguration from '../EntityConfiguration';
import { filterMap } from '../utils/collections/maps';
import {
FieldTransformerMap,
transformCacheObjectToFields,
transformFieldsToCacheObject,
} from './EntityFieldTransformationUtils';
export enum CacheStatus {
HIT,
@@ -15,10 +10,10 @@ export enum CacheStatus {
NEGATIVE,
}
export type CacheLoadResult =
export type CacheLoadResult<TFields> =
| {
status: CacheStatus.HIT;
item: Readonly<object>;
item: Readonly<TFields>;
}
| {
status: CacheStatus.MISS;
@@ -32,14 +27,10 @@ export type CacheLoadResult =
* {@link EntityCacheAdapter} within the {@link EntityDataManager}.
*/
export default class ReadThroughEntityCache<TFields> {
private readonly fieldTransformerMap: FieldTransformerMap;
constructor(
private readonly entityConfiguration: EntityConfiguration<TFields>,
private readonly entityCacheAdapter: EntityCacheAdapter<TFields>
) {
this.fieldTransformerMap = entityCacheAdapter.getFieldTransformerMap();
}
) {}
private isFieldCacheable<N extends keyof TFields>(fieldName: N): boolean {
return this.entityConfiguration.cacheableKeys.has(fieldName);
@@ -91,13 +82,7 @@ export default class ReadThroughEntityCache<TFields> {
const results: Map<NonNullable<TFields[N]>, readonly Readonly<TFields>[]> = new Map();
cacheLoadResults.forEach((cacheLoadResult, fieldValue) => {
if (cacheLoadResult.status === CacheStatus.HIT) {
results.set(fieldValue, [
transformCacheObjectToFields(
this.entityConfiguration,
this.fieldTransformerMap,
cacheLoadResult.item
),
]);
results.set(fieldValue, [cacheLoadResult.item]);
}
});
@@ -110,7 +95,7 @@ export default class ReadThroughEntityCache<TFields> {
return !objectsFromFulfillerForFv || objectsFromFulfillerForFv.length === 0;
});
const objectsToCache: Map<NonNullable<TFields[N]>, object> = new Map();
const objectsToCache: Map<NonNullable<TFields[N]>, Readonly<TFields>> = new Map();
for (const [fieldValue, objects] of dbFetchResults.entries()) {
if (objects.length > 1) {
// multiple objects received for what was supposed to be a unique query, don't add to return map nor cache
@@ -123,14 +108,7 @@ export default class ReadThroughEntityCache<TFields> {
}
const uniqueObject = objects[0];
if (uniqueObject) {
objectsToCache.set(
fieldValue,
transformFieldsToCacheObject(
this.entityConfiguration,
this.fieldTransformerMap,
uniqueObject
)
);
objectsToCache.set(fieldValue, uniqueObject);
results.set(fieldValue, [uniqueObject]);
}
}
@@ -298,7 +298,7 @@ describe(EntityDataManager, () => {
);
const queryContext = StubQueryContextProvider.getQueryContext();
const objectInQuestion = objects.get(testEntityConfiguration.tableName)![1];
const objectInQuestion = objects.get(testEntityConfiguration.tableName)![1]!;
const dbSpy = jest.spyOn(databaseAdapter, 'fetchManyWhereAsync');
const cacheSpy = jest.spyOn(entityCache, 'readManyThroughAsync');
@@ -337,7 +337,7 @@ describe(EntityDataManager, () => {
);
const queryContext = StubQueryContextProvider.getQueryContext();
const objectInQuestion = objects.get(testEntityConfiguration.tableName)![1];
const objectInQuestion = objects.get(testEntityConfiguration.tableName)![1]!;
const dbSpy = jest.spyOn(databaseAdapter, 'fetchManyWhereAsync');
const cacheSpy = jest.spyOn(entityCache, 'readManyThroughAsync');
@@ -20,25 +20,26 @@ const makeEntityConfiguration = (cacheIdField: boolean): EntityConfiguration<Bla
cacheAdapterFlavor: 'redis',
});
const createIdFetcher = (ids: string[]) => async <N extends keyof BlahFields>(
fetcherFieldValues: readonly NonNullable<BlahFields[N]>[]
): Promise<ReadonlyMap<NonNullable<BlahFields[N]>, readonly Readonly<BlahFields>[]>> => {
const results = new Map();
fetcherFieldValues.forEach((v) => {
if (ids.includes(v)) {
results.set(v, [{ id: v }]);
} else {
results.set(v, []);
}
});
return results;
};
const createIdFetcher =
(ids: string[]) =>
async <N extends keyof BlahFields>(
fetcherFieldValues: readonly NonNullable<BlahFields[N]>[]
): Promise<ReadonlyMap<NonNullable<BlahFields[N]>, readonly Readonly<BlahFields>[]>> => {
const results = new Map();
fetcherFieldValues.forEach((v) => {
if (ids.includes(v)) {
results.set(v, [{ id: v }]);
} else {
results.set(v, []);
}
});
return results;
};
describe(ReadThroughEntityCache, () => {
describe('readManyThroughAsync', () => {
it('fetches from DB upon cache miss and caches the result', async () => {
const cacheAdapterMock = mock<EntityCacheAdapter<BlahFields>>();
when(cacheAdapterMock.getFieldTransformerMap()).thenReturn(new Map());
const cacheAdapter = instance(cacheAdapterMock);
const entityCache = new ReadThroughEntityCache(makeEntityConfiguration(true), cacheAdapter);
const fetcher = createIdFetcher(['wat', 'who']);
@@ -75,7 +76,6 @@ describe(ReadThroughEntityCache, () => {
it('does not fetch from the DB or cache results when all cache fetches are hits', async () => {
const cacheAdapterMock = mock<EntityCacheAdapter<BlahFields>>();
when(cacheAdapterMock.getFieldTransformerMap()).thenReturn(new Map());
const cacheAdapter = instance(cacheAdapterMock);
const entityCache = new ReadThroughEntityCache(makeEntityConfiguration(true), cacheAdapter);
const fetcher = createIdFetcher(['wat', 'who']);
@@ -112,7 +112,6 @@ describe(ReadThroughEntityCache, () => {
it('negatively caches db misses', async () => {
const cacheAdapterMock = mock<EntityCacheAdapter<BlahFields>>();
when(cacheAdapterMock.getFieldTransformerMap()).thenReturn(new Map());
const cacheAdapter = instance(cacheAdapterMock);
const entityCache = new ReadThroughEntityCache(makeEntityConfiguration(true), cacheAdapter);
@@ -133,7 +132,6 @@ describe(ReadThroughEntityCache, () => {
it('does not return or fetch negatively cached results from DB', async () => {
const cacheAdapterMock = mock<EntityCacheAdapter<BlahFields>>();
when(cacheAdapterMock.getFieldTransformerMap()).thenReturn(new Map());
const cacheAdapter = instance(cacheAdapterMock);
const entityCache = new ReadThroughEntityCache(makeEntityConfiguration(true), cacheAdapter);
const fetcher = createIdFetcher([]);
@@ -151,7 +149,6 @@ describe(ReadThroughEntityCache, () => {
it('does a mix and match of hit, miss, and negative', async () => {
const cacheAdapterMock = mock<EntityCacheAdapter<BlahFields>>();
when(cacheAdapterMock.getFieldTransformerMap()).thenReturn(new Map());
const cacheAdapter = instance(cacheAdapterMock);
const entityCache = new ReadThroughEntityCache(makeEntityConfiguration(true), cacheAdapter);
const fetcher = createIdFetcher(['wat', 'who', 'why']);
@@ -187,7 +184,6 @@ describe(ReadThroughEntityCache, () => {
it('does not call into cache for field that is not cacheable', async () => {
const cacheAdapterMock = mock<EntityCacheAdapter<BlahFields>>();
when(cacheAdapterMock.getFieldTransformerMap()).thenReturn(new Map());
const cacheAdapter = instance(cacheAdapterMock);
const entityCache = new ReadThroughEntityCache(makeEntityConfiguration(false), cacheAdapter);
const fetcher = createIdFetcher(['wat']);
@@ -195,44 +191,6 @@ describe(ReadThroughEntityCache, () => {
verify(cacheAdapterMock.loadManyAsync('id', anything())).never();
expect(result).toEqual(new Map([['wat', [{ id: 'wat' }]]]));
});
it('transforms fields for cache storage', async () => {
const cacheAdapterMock = mock<EntityCacheAdapter<BlahFields>>();
when(cacheAdapterMock.getFieldTransformerMap()).thenReturn(
new Map([
[
UUIDField.name,
{
read: (val) => val.split('-')[0],
write: (val) => `${val}-in-cache`,
},
],
])
);
const cacheAdapter = instance(cacheAdapterMock);
const entityCache = new ReadThroughEntityCache(makeEntityConfiguration(true), cacheAdapter);
const fetcher = createIdFetcher(['wat', 'who']);
when(cacheAdapterMock.loadManyAsync('id', deepEqual(['wat', 'who']))).thenResolve(
new Map([
['wat', { status: CacheStatus.MISS }],
['who', { status: CacheStatus.HIT, item: { id: 'who-in-cache' } }],
])
);
const result = await entityCache.readManyThroughAsync('id', ['wat', 'who'], fetcher);
verify(cacheAdapterMock.loadManyAsync('id', deepEqual(['wat', 'who']))).once();
verify(
cacheAdapterMock.cacheManyAsync('id', deepEqual(new Map([['wat', { id: 'wat-in-cache' }]])))
).once();
expect(result).toEqual(
new Map([
['wat', [{ id: 'wat' }]],
['who', [{ id: 'who' }]],
])
);
});
});
describe('invalidateManyAsync', () => {
@@ -4,62 +4,68 @@ import IEntityMetricsAdapter, {
EntityMetricsMutationType,
} from './IEntityMetricsAdapter';
export const timeAndLogLoadEventAsync = (
metricsAdapter: IEntityMetricsAdapter,
loadType: EntityMetricsLoadType,
entityClassName: string
) => async <TFields>(promise: Promise<readonly Readonly<TFields>[]>) => {
const startTime = Date.now();
const result = await promise;
const endTime = Date.now();
export const timeAndLogLoadEventAsync =
(
metricsAdapter: IEntityMetricsAdapter,
loadType: EntityMetricsLoadType,
entityClassName: string
) =>
async <TFields>(promise: Promise<readonly Readonly<TFields>[]>) => {
const startTime = Date.now();
const result = await promise;
const endTime = Date.now();
metricsAdapter.logDataManagerLoadEvent({
type: loadType,
entityClassName,
duration: endTime - startTime,
count: result.length,
});
metricsAdapter.logDataManagerLoadEvent({
type: loadType,
entityClassName,
duration: endTime - startTime,
count: result.length,
});
return result;
};
return result;
};
export const timeAndLogLoadMapEventAsync = (
metricsAdapter: IEntityMetricsAdapter,
loadType: EntityMetricsLoadType,
entityClassName: string
) => async <TFields, N extends keyof TFields>(
promise: Promise<ReadonlyMap<NonNullable<TFields[N]>, readonly Readonly<TFields>[]>>
) => {
const startTime = Date.now();
const result = await promise;
const endTime = Date.now();
export const timeAndLogLoadMapEventAsync =
(
metricsAdapter: IEntityMetricsAdapter,
loadType: EntityMetricsLoadType,
entityClassName: string
) =>
async <TFields, N extends keyof TFields>(
promise: Promise<ReadonlyMap<NonNullable<TFields[N]>, readonly Readonly<TFields>[]>>
) => {
const startTime = Date.now();
const result = await promise;
const endTime = Date.now();
const count = reduceMap(result, (acc, v) => acc + v.length, 0);
const count = reduceMap(result, (acc, v) => acc + v.length, 0);
metricsAdapter.logDataManagerLoadEvent({
type: loadType,
entityClassName,
duration: endTime - startTime,
count,
});
metricsAdapter.logDataManagerLoadEvent({
type: loadType,
entityClassName,
duration: endTime - startTime,
count,
});
return result;
};
return result;
};
export const timeAndLogMutationEventAsync = (
metricsAdapter: IEntityMetricsAdapter,
mutationType: EntityMetricsMutationType,
entityClassName: string
) => async <T>(promise: Promise<T>) => {
const startTime = Date.now();
const result = await promise;
const endTime = Date.now();
export const timeAndLogMutationEventAsync =
(
metricsAdapter: IEntityMetricsAdapter,
mutationType: EntityMetricsMutationType,
entityClassName: string
) =>
async <T>(promise: Promise<T>) => {
const startTime = Date.now();
const result = await promise;
const endTime = Date.now();
metricsAdapter.logMutatorMutationEvent({
type: mutationType,
entityClassName,
duration: endTime - startTime,
});
metricsAdapter.logMutatorMutationEvent({
type: mutationType,
entityClassName,
duration: endTime - startTime,
});
return result;
};
return result;
};
@@ -1,3 +1,8 @@
import {
EntityAuthorizationAction,
EntityPrivacyPolicyEvaluationMode,
} from '../EntityPrivacyPolicy';
export enum EntityMetricsLoadType {
LOAD_MANY,
LOAD_MANY_EQUALITY_CONJUNCTION,
@@ -28,11 +33,29 @@ export interface IncrementLoadCountEvent {
entityClassName: string;
}
export enum EntityMetricsAuthorizationResult {
DENY,
ALLOW,
}
export interface EntityMetricsAuthorizationEvent {
entityClassName: string;
action: EntityAuthorizationAction;
evaluationResult: EntityMetricsAuthorizationResult;
privacyPolicyEvaluationMode: EntityPrivacyPolicyEvaluationMode;
}
/**
* An interface for gathering metrics about the Entity framework. Information about
* entity load and mutation operations is piped to an instance of this adapter.
*/
export default interface IEntityMetricsAdapter {
/**
* Called when a {@link EntityPrivacyPolicy} authorization succeeds or fails.
* @param authorizationEvent - info about the authorization event
*/
logAuthorizationEvent(authorizationEvent: EntityMetricsAuthorizationEvent): void;
/**
* Called when any load occurs.
* @param loadEvent - info about the load event
@@ -1,10 +1,12 @@
import IEntityMetricsAdapter, {
EntityMetricsAuthorizationEvent,
EntityMetricsLoadEvent,
EntityMetricsMutationEvent,
IncrementLoadCountEvent,
} from './IEntityMetricsAdapter';
export default class NoOpEntityMetricsAdapter implements IEntityMetricsAdapter {
logAuthorizationEvent(_authorizationEvent: EntityMetricsAuthorizationEvent): void {}
logDataManagerLoadEvent(_loadEvent: EntityMetricsLoadEvent): void {}
logMutatorMutationEvent(_mutationEvent: EntityMetricsMutationEvent): void {}
incrementDataManagerDataloaderLoadCount(
@@ -28,16 +28,16 @@ export class DateIDTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
ViewerContext,
DateIDTestEntity
> {
protected readonly readRules = [
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<DateIDTestFields, Date, ViewerContext, DateIDTestEntity>(),
];
protected readonly createRules = [
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<DateIDTestFields, Date, ViewerContext, DateIDTestEntity>(),
];
protected readonly updateRules = [
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<DateIDTestFields, Date, ViewerContext, DateIDTestEntity>(),
];
protected readonly deleteRules = [
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<DateIDTestFields, Date, ViewerContext, DateIDTestEntity>(),
];
}
@@ -31,7 +31,7 @@ export class SimpleTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
SimpleTestEntity,
SimpleTestFieldSelection
> {
protected readonly readRules = [
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<
SimpleTestFields,
string,
@@ -40,7 +40,7 @@ export class SimpleTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
SimpleTestFieldSelection
>(),
];
protected readonly createRules = [
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<
SimpleTestFields,
string,
@@ -49,7 +49,7 @@ export class SimpleTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
SimpleTestFieldSelection
>(),
];
protected readonly updateRules = [
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<
SimpleTestFields,
string,
@@ -58,7 +58,7 @@ export class SimpleTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
SimpleTestFieldSelection
>(),
];
protected readonly deleteRules = [
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<
SimpleTestFields,
string,
Loaded 100 of 109 files, more files were not shown because too many files have changed in this diff. Show more