Compare commits

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

* implement PR feedback

* fix tsc

* restrict orderByRaw to loadManyByRawWhereClauseAsync

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

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

* Update packages/entity/src/ComposedEntityCacheAdapter.ts

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

* pr feedback

* Update packages/entity/src/ComposedEntityCacheAdapter.ts

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

* pr feedback

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

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

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

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

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

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

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

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

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

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

Test Plan: Lerna runs tsc in every package (no errors). Unit tests in CI.
2022-04-11 09:16:19 -07:00
80 changed files with 4805 additions and 4570 deletions

No files matched your search

+3 -3
View File
@@ -3,16 +3,16 @@ name: docs
on:
push:
branches:
- master
- main
jobs:
deploy:
runs-on: ubuntu-18.04
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
with:
node-version: 12.x
node-version: 18.x
- name: Cache Node.js modules
uses: actions/cache@v1
with:
+2 -2
View File
@@ -2,7 +2,7 @@ name: tests
on:
push:
branches: [ master ]
branches: [ main ]
pull_request:
jobs:
@@ -14,7 +14,7 @@ jobs:
ref: ${{ github.event.pull_request.head.sha }}
- uses: actions/setup-node@v1
with:
node-version: 12.x
node-version: 18.x
- name: Cache Node.js modules
uses: actions/cache@v1
with:
+39
View File
@@ -3,6 +3,45 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.27.0](https://github.com/expo/entity/compare/v0.26.1...v0.27.0) (2022-08-09)
### Features
* add orderByRaw to loadManyByRawWhereClauseAsync ([#185](https://github.com/expo/entity/issues/185)) ([2817d78](https://github.com/expo/entity/commit/2817d78392dfecd0093111c24d581bdf01dba06d))
## [0.26.1](https://github.com/expo/entity/compare/v0.26.0...v0.26.1) (2022-07-28)
### Features
* recache to higher cache levels when loading from a composed cache ([#182](https://github.com/expo/entity/pull/182))
# [0.26.0](https://github.com/expo/entity/compare/v0.25.3...v0.26.0) (2022-07-14)
### Bug Fixes
* fix workflow branch specification ([#179](https://github.com/expo/entity/issues/179)) ([25d14bf](https://github.com/expo/entity/commit/25d14bf8c1688524ad488aa2b63221393a4f0b05))
### Features
* add EntityEdgeDeletionBehavior.SET_NULL_INVALIDATE_CACHE_ONLY ([#178](https://github.com/expo/entity/issues/178)) ([a0c35dd](https://github.com/expo/entity/commit/a0c35ddd7bca05d0c3f41dcf48e16d058918231c))
* update ioredis to v5, make it a devDependency of the cache packages ([#172](https://github.com/expo/entity/issues/172)) ([9fcfbf7](https://github.com/expo/entity/commit/9fcfbf746a61554cefedf7fb0f1a3a9056b58ffe))
## [0.25.3](https://github.com/expo/entity/compare/v0.25.2...v0.25.3) (2022-03-12)
+3 -3
View File
@@ -4,7 +4,7 @@ Entity is a privacy-aware data layer for defining, caching, and authorizing acce
[![tests](https://github.com/expo/entity/workflows/tests/badge.svg)](https://github.com/expo/entity/actions?query=workflow%3Atests)
[![docs](https://github.com/expo/entity/workflows/docs/badge.svg)](https://expo.github.io/entity/)
[![codecov](https://codecov.io/gh/expo/entity/branch/master/graph/badge.svg)](https://codecov.io/gh/expo/entity)
[![codecov](https://codecov.io/gh/expo/entity/branch/main/graph/badge.svg)](https://codecov.io/gh/expo/entity)
[![npm](https://img.shields.io/npm/v/@expo/entity)](https://www.npmjs.com/package/@expo/entity)
[![NPM](https://img.shields.io/npm/l/@expo/entity)](https://www.npmjs.com/package/@expo/entity)
@@ -82,10 +82,10 @@ Note: The entity framework instance should not be shared across multiple request
To release a new version, let's say `v1.0.0` for example:
1. `git checkout -b bump-v1.0.0`
1. `yarn lerna version minor -- --no-push --conventional-commits`
1. `yarn lerna version [patch|minor|major] -- --no-push --conventional-commits`
1. `git push -u origin bump-v1.0.0`
1. Create a PR from the `bump-v1.0.0` branch, wait for tests, and commit the PR using GitHub interface.
1. `git checkout master && git pull`
1. `git checkout main && git pull`
1. `git tag -d v1.0.0`
1. `git tag v1.0.0`
1. `git push origin v1.0.0`
+1 -1
View File
@@ -4,5 +4,5 @@
],
"npmClient": "yarn",
"useWorkspaces": true,
"version": "0.25.3"
"version": "0.27.0"
}
+5 -5
View File
@@ -19,7 +19,6 @@
"@expo/entity": "file:packages/entity",
"@expo/results": "^1.0.0",
"@types/invariant": "^2.2.33",
"@types/ioredis": "^4.26.4",
"@types/jest": "^26.0.10",
"@types/jsbn": "^1.2.29",
"@types/lru-cache": "^5.1.1",
@@ -30,17 +29,18 @@
"barrelsby": "^2.2.0",
"eslint": "^7.7.0",
"eslint-config-universe": "^7.0.0",
"eslint-plugin-tsdoc": "^0.2.11",
"eslint-plugin-tsdoc": "^0.2.16",
"ioredis": "^5.0.4",
"jest": "^26.6.3",
"lerna": "^4.0.0",
"lerna": "^5.4.0",
"lru-cache": "^6.0.0",
"nullthrows": "^1.1.1",
"pg": "^8.6.0",
"prettier": "^2.3.2",
"ts-jest": "~26.4.4",
"ts-mockito": "^2.6.1",
"typedoc": "^0.21.9",
"typescript": "^4.4.2",
"typedoc": "^0.23.10",
"typescript": "^4.6.3",
"uuid": "^8.3.0"
},
"dependencies": {
@@ -3,6 +3,30 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.27.0](https://github.com/expo/entity/compare/v0.26.1...v0.27.0) (2022-08-09)
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
## [0.26.1](https://github.com/expo/entity/compare/v0.26.0...v0.26.1) (2022-07-28)
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
# [0.26.0](https://github.com/expo/entity/compare/v0.25.3...v0.26.0) (2022-07-14)
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
## [0.25.3](https://github.com/expo/entity/compare/v0.25.2...v0.25.3) (2022-03-12)
@@ -1,6 +1,6 @@
{
"name": "@expo/entity-cache-adapter-local-memory",
"version": "0.25.3",
"version": "0.27.0",
"description": "Cross-request local memory cache adapter for @expo/entity",
"files": [
"build",
@@ -33,6 +33,6 @@
"lru-cache": "^6.0.0"
},
"devDependencies": {
"@expo/entity": "^0.25.3"
"@expo/entity": "^0.27.0"
}
}
@@ -68,7 +68,7 @@ export default class LocalMemoryCacheAdapter<TFields> extends EntityCacheAdapter
fieldValue: NonNullable<TFields[N]>
): string {
const columnName = this.entityConfiguration.entityToDBFieldsKeyMapping.get(fieldName);
invariant(columnName, `database field mapping missing for ${fieldName}`);
invariant(columnName, `database field mapping missing for ${String(fieldName)}`);
const parts = [
this.entityConfiguration.tableName,
`${this.entityConfiguration.cacheKeyVersion}`,
@@ -3,6 +3,33 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.27.0](https://github.com/expo/entity/compare/v0.26.1...v0.27.0) (2022-08-09)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
## [0.26.1](https://github.com/expo/entity/compare/v0.26.0...v0.26.1) (2022-07-28)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.26.0](https://github.com/expo/entity/compare/v0.25.3...v0.26.0) (2022-07-14)
### Features
* update ioredis to v5, make it a devDependency of the cache packages ([#172](https://github.com/expo/entity/issues/172)) ([9fcfbf7](https://github.com/expo/entity/commit/9fcfbf746a61554cefedf7fb0f1a3a9056b58ffe))
## [0.25.3](https://github.com/expo/entity/compare/v0.25.2...v0.25.3) (2022-03-12)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
@@ -1,6 +1,6 @@
{
"name": "@expo/entity-cache-adapter-redis",
"version": "0.25.3",
"version": "0.27.0",
"description": "Redis cache adapter for @expo/entity",
"files": [
"build",
@@ -27,12 +27,11 @@
"author": "Expo",
"license": "MIT",
"peerDependencies": {
"@expo/entity": "*"
},
"dependencies": {
"ioredis": "^4.27.3"
"@expo/entity": "*",
"ioredis": ">=5"
},
"devDependencies": {
"@expo/entity": "^0.25.3"
"@expo/entity": "^0.27.0",
"ioredis": "^5.0.4"
}
}
@@ -1,6 +1,6 @@
import { EntityCacheAdapter, EntityConfiguration, CacheLoadResult, mapKeys } from '@expo/entity';
import invariant from 'invariant';
import { Redis } from 'ioredis';
import type { Redis } from 'ioredis';
import GenericRedisCacher from './GenericRedisCacher';
@@ -113,7 +113,7 @@ export default class RedisCacheAdapter<TFields> extends EntityCacheAdapter<TFiel
fieldValue: NonNullable<TFields[N]>
): string {
const columnName = this.entityConfiguration.entityToDBFieldsKeyMapping.get(fieldName);
invariant(columnName, `database field mapping missing for ${fieldName}`);
invariant(columnName, `database field mapping missing for ${String(fieldName)}`);
return this.context.makeKeyFn(
this.context.cacheKeyPrefix,
this.entityConfiguration.tableName,
@@ -3,6 +3,33 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.27.0](https://github.com/expo/entity/compare/v0.26.1...v0.27.0) (2022-08-09)
### Features
* add orderByRaw to loadManyByRawWhereClauseAsync ([#185](https://github.com/expo/entity/issues/185)) ([2817d78](https://github.com/expo/entity/commit/2817d78392dfecd0093111c24d581bdf01dba06d))
## [0.26.1](https://github.com/expo/entity/compare/v0.26.0...v0.26.1) (2022-07-28)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
# [0.26.0](https://github.com/expo/entity/compare/v0.25.3...v0.26.0) (2022-07-14)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
## [0.25.3](https://github.com/expo/entity/compare/v0.25.2...v0.25.3) (2022-03-12)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
@@ -1,6 +1,6 @@
{
"name": "@expo/entity-database-adapter-knex",
"version": "0.25.3",
"version": "0.27.0",
"description": "Knex database adapter for @expo/entity",
"files": [
"build",
@@ -33,7 +33,7 @@
"knex": "^1.0.2"
},
"devDependencies": {
"@expo/entity": "^0.25.3",
"@expo/entity": "^0.27.0",
"pg": "^8.7.1"
}
}
@@ -7,6 +7,7 @@ import {
TableQuerySelectionModifiers,
TableFieldSingleValueEqualityCondition,
TableFieldMultiValueEqualityCondition,
TableQuerySelectionModifiersWithOrderByRaw,
} from '@expo/entity';
import { Knex } from 'knex';
@@ -53,6 +54,20 @@ export default class PostgresEntityDatabaseAdapter<TFields> extends EntityDataba
);
}
private applyQueryModifiersToQueryOrderByRaw(
query: Knex.QueryBuilder,
querySelectionModifiers: TableQuerySelectionModifiersWithOrderByRaw
): Knex.QueryBuilder {
let ret = this.applyQueryModifiersToQuery(query, querySelectionModifiers);
const { orderByRaw } = querySelectionModifiers;
if (orderByRaw !== undefined) {
ret = ret.orderByRaw(orderByRaw);
}
return ret;
}
private applyQueryModifiersToQuery(
query: Knex.QueryBuilder,
querySelectionModifiers: TableQuerySelectionModifiers
@@ -129,13 +144,13 @@ export default class PostgresEntityDatabaseAdapter<TFields> extends EntityDataba
tableName: string,
rawWhereClause: string,
bindings: object | any[],
querySelectionModifiers: TableQuerySelectionModifiers
querySelectionModifiers: TableQuerySelectionModifiersWithOrderByRaw
): Promise<object[]> {
let query = queryInterface
.select()
.from(tableName)
.whereRaw(rawWhereClause, bindings as any);
query = this.applyQueryModifiersToQuery(query, querySelectionModifiers);
query = this.applyQueryModifiersToQueryOrderByRaw(query, querySelectionModifiers);
return await wrapNativePostgresCallAsync(() => query);
}
@@ -379,6 +379,15 @@ describe('postgres entity integration', () => {
expect(resultsMultipleOrderBy).toHaveLength(3);
expect(resultsMultipleOrderBy.map((e) => e.getField('name'))).toEqual(['c', 'b', 'a']);
const resultsOrderByRaw = await PostgresTestEntity.loader(vc1)
.enforcing()
.loadManyByRawWhereClauseAsync('has_a_dog = ?', [true], {
orderByRaw: 'has_a_dog ASC, name DESC',
});
expect(resultsOrderByRaw).toHaveLength(3);
expect(resultsOrderByRaw.map((e) => e.getField('name'))).toEqual(['c', 'b', 'a']);
});
});
+24
View File
@@ -3,6 +3,30 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.27.0](https://github.com/expo/entity/compare/v0.26.1...v0.27.0) (2022-08-09)
**Note:** Version bump only for package @expo/entity-example
## [0.26.1](https://github.com/expo/entity/compare/v0.26.0...v0.26.1) (2022-07-28)
**Note:** Version bump only for package @expo/entity-example
# [0.26.0](https://github.com/expo/entity/compare/v0.25.3...v0.26.0) (2022-07-14)
**Note:** Version bump only for package @expo/entity-example
## [0.25.3](https://github.com/expo/entity/compare/v0.25.2...v0.25.3) (2022-03-12)
**Note:** Version bump only for package @expo/entity-example
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@expo/entity-example",
"private": true,
"version": "0.25.3",
"version": "0.27.0",
"description": "An example integration of the @expo/entity framework",
"scripts": {
"tsc": "tsc",
@@ -23,7 +23,7 @@
"author": "Expo",
"license": "MIT",
"dependencies": {
"@expo/entity": "^0.25.3",
"@expo/entity": "^0.27.0",
"apollo-server-koa": "^2.16.1",
"graphql": "^15.3.0",
"koa": "^2.13.0",
@@ -30,7 +30,7 @@ router.get('/', async (ctx: ExampleContext) => {
router.get('/:id', async (ctx: ExampleContext) => {
const viewerContext = ctx.state.viewerContext;
const noteResult = await NoteEntity.loader(viewerContext).loadByIDAsync(ctx.params.id);
const noteResult = await NoteEntity.loader(viewerContext).loadByIDAsync(ctx.params['id']!);
if (!noteResult.ok) {
ctx.throw(403, noteResult.reason);
}
@@ -69,7 +69,7 @@ router.put('/:id', async (ctx: ExampleContext) => {
const viewerContext = ctx.state.viewerContext;
const { title, body } = ctx.request.body;
const noteLoadResult = await NoteEntity.loader(viewerContext).loadByIDAsync(ctx.params.id);
const noteLoadResult = await NoteEntity.loader(viewerContext).loadByIDAsync(ctx.params['id']!);
if (!noteLoadResult.ok) {
ctx.throw(403, noteLoadResult.reason);
}
@@ -90,7 +90,7 @@ router.put('/:id', async (ctx: ExampleContext) => {
router.delete('/:id', async (ctx: ExampleContext) => {
const viewerContext = ctx.state.viewerContext;
const noteLoadResult = await NoteEntity.loader(viewerContext).loadByIDAsync(ctx.params.id);
const noteLoadResult = await NoteEntity.loader(viewerContext).loadByIDAsync(ctx.params['id']!);
if (!noteLoadResult.ok) {
ctx.throw(403, noteLoadResult.reason);
}
@@ -3,6 +3,33 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.27.0](https://github.com/expo/entity/compare/v0.26.1...v0.27.0) (2022-08-09)
**Note:** Version bump only for package @expo/entity-full-integration-tests
## [0.26.1](https://github.com/expo/entity/compare/v0.26.0...v0.26.1) (2022-07-28)
**Note:** Version bump only for package @expo/entity-full-integration-tests
# [0.26.0](https://github.com/expo/entity/compare/v0.25.3...v0.26.0) (2022-07-14)
### Features
* add EntityEdgeDeletionBehavior.SET_NULL_INVALIDATE_CACHE_ONLY ([#178](https://github.com/expo/entity/issues/178)) ([a0c35dd](https://github.com/expo/entity/commit/a0c35ddd7bca05d0c3f41dcf48e16d058918231c))
## [0.25.3](https://github.com/expo/entity/compare/v0.25.2...v0.25.3) (2022-03-12)
**Note:** Version bump only for package @expo/entity-full-integration-tests
@@ -1,7 +1,7 @@
{
"name": "@expo/entity-full-integration-tests",
"private": true,
"version": "0.25.3",
"version": "0.27.0",
"description": "Full redis and knex integration tests for the entity framework",
"scripts": {
"tsc": "tsc",
@@ -27,8 +27,8 @@
"@expo/entity-database-adapter-knex": "*"
},
"devDependencies": {
"@expo/entity": "^0.25.3",
"@expo/entity-cache-adapter-redis": "^0.25.3",
"@expo/entity-database-adapter-knex": "^0.25.3"
"@expo/entity": "^0.27.0",
"@expo/entity-cache-adapter-redis": "^0.27.0",
"@expo/entity-database-adapter-knex": "^0.27.0"
}
}
@@ -92,11 +92,8 @@ async function createOrTruncatePostgresTables(knex: Knex): Promise<void> {
}
async function dropPostgresTable(knex: Knex): Promise<void> {
if (await knex.schema.hasTable('children')) {
await knex.schema.dropTable('children');
}
if (await knex.schema.hasTable('parents')) {
await knex.schema.dropTable('parents');
if (await knex.schema.hasTable('testentities')) {
await knex.schema.dropTable('testentities');
}
}
@@ -135,26 +135,40 @@ const makeEntityClasses = async (knex: Knex, edgeDeletionBehavior: EntityEdgeDel
await knex.schema.createTable(othersTableName, (table) => {
table.uuid('id').defaultTo(knex.raw('uuid_generate_v4()')).primary();
if (edgeDeletionBehavior === EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE) {
if (edgeDeletionBehavior === EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE_ONLY) {
table
.uuid('parent_category_id')
.references('id')
.inTable(categoriesTableName)
.unique()
.onDelete('cascade');
} else if (edgeDeletionBehavior === EntityEdgeDeletionBehavior.SET_NULL_INVALIDATE_CACHE_ONLY) {
table
.uuid('parent_category_id')
.references('id')
.inTable(categoriesTableName)
.unique()
.onDelete('set null');
} else {
table.uuid('parent_category_id').unique();
}
});
await knex.schema.alterTable(categoriesTableName, (table) => {
if (edgeDeletionBehavior === EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE) {
if (edgeDeletionBehavior === EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE_ONLY) {
table
.uuid('parent_other_id')
.references('id')
.inTable(othersTableName)
.unique()
.onDelete('cascade');
} else if (edgeDeletionBehavior === EntityEdgeDeletionBehavior.SET_NULL_INVALIDATE_CACHE_ONLY) {
table
.uuid('parent_other_id')
.references('id')
.inTable(othersTableName)
.unique()
.onDelete('set null');
} else {
table.uuid('parent_other_id').unique();
}
@@ -200,8 +214,9 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
it.each([
EntityEdgeDeletionBehavior.CASCADE_DELETE,
EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE,
EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE_ONLY,
EntityEdgeDeletionBehavior.SET_NULL,
EntityEdgeDeletionBehavior.SET_NULL_INVALIDATE_CACHE_ONLY,
])('behavior: %p', async (edgeDeletionBehavior) => {
const { CategoryEntity, OtherEntity } = await makeEntityClasses(
knexInstance,
@@ -230,6 +245,14 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
.enforcing()
.loadByIDNullableAsync(other1.getID());
expect(otherLoaded?.getField('parent_category_id')).toBeNull();
} else if (edgeDeletionBehavior === EntityEdgeDeletionBehavior.SET_NULL_INVALIDATE_CACHE_ONLY) {
await expect(
CategoryEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(category1.getID())
).resolves.toBeNull();
const otherLoaded = await OtherEntity.loader(viewerContext)
.enforcing()
.loadByIDNullableAsync(other1.getID());
expect(otherLoaded?.getField('parent_category_id')).toBeNull();
} else {
await expect(
CategoryEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(category1.getID())
@@ -56,7 +56,7 @@ const childEntityConfiguration = new EntityConfiguration<ChildFields>({
cache: true,
association: {
getAssociatedEntityClass: () => ParentEntity,
edgeDeletionBehavior: EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE,
edgeDeletionBehavior: EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE_ONLY,
},
}),
},
@@ -3,6 +3,30 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.27.0](https://github.com/expo/entity/compare/v0.26.1...v0.27.0) (2022-08-09)
**Note:** Version bump only for package @expo/entity-ip-address-field
## [0.26.1](https://github.com/expo/entity/compare/v0.26.0...v0.26.1) (2022-07-28)
**Note:** Version bump only for package @expo/entity-ip-address-field
# [0.26.0](https://github.com/expo/entity/compare/v0.25.3...v0.26.0) (2022-07-14)
**Note:** Version bump only for package @expo/entity-ip-address-field
## [0.25.3](https://github.com/expo/entity/compare/v0.25.2...v0.25.3) (2022-03-12)
**Note:** Version bump only for package @expo/entity-ip-address-field
@@ -1,6 +1,6 @@
{
"name": "@expo/entity-ip-address-field",
"version": "0.25.3",
"version": "0.27.0",
"description": "IP address EntityField definitions for @expo/entity",
"files": [
"build",
@@ -28,7 +28,7 @@
"ip-address": "^8.1.0"
},
"devDependencies": {
"@expo/entity": "^0.25.3",
"@expo/entity": "^0.27.0",
"@types/jsbn": "^1.2.29"
},
"peerDependencies": {
@@ -3,6 +3,30 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.27.0](https://github.com/expo/entity/compare/v0.26.1...v0.27.0) (2022-08-09)
**Note:** Version bump only for package @expo/entity-secondary-cache-local-memory
## [0.26.1](https://github.com/expo/entity/compare/v0.26.0...v0.26.1) (2022-07-28)
**Note:** Version bump only for package @expo/entity-secondary-cache-local-memory
# [0.26.0](https://github.com/expo/entity/compare/v0.25.3...v0.26.0) (2022-07-14)
**Note:** Version bump only for package @expo/entity-secondary-cache-local-memory
## [0.25.3](https://github.com/expo/entity/compare/v0.25.2...v0.25.3) (2022-03-12)
**Note:** Version bump only for package @expo/entity-secondary-cache-local-memory
@@ -1,6 +1,6 @@
{
"name": "@expo/entity-secondary-cache-local-memory",
"version": "0.25.3",
"version": "0.27.0",
"description": "Local memory secondary cache for @expo/entity",
"files": [
"build",
@@ -31,8 +31,8 @@
"@expo/entity-cache-adapter-local-memory": "*"
},
"devDependencies": {
"@expo/entity": "^0.25.3",
"@expo/entity-cache-adapter-local-memory": "^0.25.3",
"@expo/entity": "^0.27.0",
"@expo/entity-cache-adapter-local-memory": "^0.27.0",
"nullthrows": "^1.1.1"
}
}
@@ -5,7 +5,7 @@ import {
} from '@expo/entity-cache-adapter-local-memory';
/**
* A local memory {@link GenericSecondaryEntityCache}.
* A local memory GenericSecondaryEntityCache.
*
* @remarks
*
@@ -3,6 +3,33 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.27.0](https://github.com/expo/entity/compare/v0.26.1...v0.27.0) (2022-08-09)
**Note:** Version bump only for package @expo/entity-secondary-cache-redis
## [0.26.1](https://github.com/expo/entity/compare/v0.26.0...v0.26.1) (2022-07-28)
**Note:** Version bump only for package @expo/entity-secondary-cache-redis
# [0.26.0](https://github.com/expo/entity/compare/v0.25.3...v0.26.0) (2022-07-14)
### Features
* update ioredis to v5, make it a devDependency of the cache packages ([#172](https://github.com/expo/entity/issues/172)) ([9fcfbf7](https://github.com/expo/entity/commit/9fcfbf746a61554cefedf7fb0f1a3a9056b58ffe))
## [0.25.3](https://github.com/expo/entity/compare/v0.25.2...v0.25.3) (2022-03-12)
**Note:** Version bump only for package @expo/entity-secondary-cache-redis
@@ -1,6 +1,6 @@
{
"name": "@expo/entity-secondary-cache-redis",
"version": "0.25.3",
"version": "0.27.0",
"description": "Redis secondary cache for @expo/entity",
"files": [
"build",
@@ -30,12 +30,10 @@
"@expo/entity": "*",
"@expo/entity-cache-adapter-redis": "*"
},
"dependencies": {
"ioredis": "^4.17.3"
},
"devDependencies": {
"@expo/entity": "^0.25.3",
"@expo/entity-cache-adapter-redis": "^0.25.3",
"@expo/entity": "^0.27.0",
"@expo/entity-cache-adapter-redis": "^0.27.0",
"ioredis": "^5.0.4",
"nullthrows": "^1.1.1"
}
}
@@ -2,7 +2,7 @@ import { EntityConfiguration, GenericSecondaryEntityCache } from '@expo/entity';
import { GenericRedisCacheContext, GenericRedisCacher } from '@expo/entity-cache-adapter-redis';
/**
* A redis {@link GenericSecondaryEntityCache}.
* A redis GenericSecondaryEntityCache.
*/
export default class RedisSecondaryEntityCache<
TFields,
+33
View File
@@ -3,6 +3,39 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.27.0](https://github.com/expo/entity/compare/v0.26.1...v0.27.0) (2022-08-09)
### Features
* add orderByRaw to loadManyByRawWhereClauseAsync ([#185](https://github.com/expo/entity/issues/185)) ([2817d78](https://github.com/expo/entity/commit/2817d78392dfecd0093111c24d581bdf01dba06d))
## [0.26.1](https://github.com/expo/entity/compare/v0.26.0...v0.26.1) (2022-07-28)
### Features
* recache to higher cache levels when loading from a composed cache ([#182](https://github.com/expo/entity/pull/182))
# [0.26.0](https://github.com/expo/entity/compare/v0.25.3...v0.26.0) (2022-07-14)
### Features
* add EntityEdgeDeletionBehavior.SET_NULL_INVALIDATE_CACHE_ONLY ([#178](https://github.com/expo/entity/issues/178)) ([a0c35dd](https://github.com/expo/entity/commit/a0c35ddd7bca05d0c3f41dcf48e16d058918231c))
## [0.25.3](https://github.com/expo/entity/compare/v0.25.2...v0.25.3) (2022-03-12)
**Note:** Version bump only for package @expo/entity
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/entity",
"version": "0.25.3",
"version": "0.27.0",
"description": "A privacy-first data model",
"files": [
"build",
@@ -4,14 +4,16 @@ import EntityCacheAdapter from './EntityCacheAdapter';
import EntityConfiguration from './EntityConfiguration';
import { CacheStatus, CacheLoadResult } from './internal/ReadThroughEntityCache';
/**
* A EntityCacheAdapter that composes other EntityCacheAdapter instances.
*/
export default class ComposedEntityCacheAdapter<TFields> extends EntityCacheAdapter<TFields> {
/**
* A {@link EntityCacheAdapter} that composes other {@link EntityCacheAdapter} instances.
*
* @param entityConfiguration - configuration for entity being loaded
* @param cacheAdapters - list of cache adapters to compose in order of precedence.
* Earlier cache adapters are read from first and written to (including invalidations) last.
* Typically, caches closer to the application should be ordered before caches closer to the database.
* A lower layer cache is closer to the database, while a higher layer cache is closer to the application.
*/
constructor(
entityConfiguration: EntityConfiguration<TFields>,
@@ -25,9 +27,14 @@ export default class ComposedEntityCacheAdapter<TFields> extends EntityCacheAdap
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult<TFields>>> {
const retMap = new Map<NonNullable<TFields[N]>, CacheLoadResult<TFields>>();
const fulfilledFieldValuesByCacheIndex: NonNullable<TFields[N]>[][] = Array.from(
{ length: this.cacheAdapters.length },
() => []
);
let unfulfilledFieldValues = fieldValues;
for (const cacheAdapter of this.cacheAdapters) {
for (let i = 0; i < this.cacheAdapters.length; i++) {
const cacheAdapter = nullthrows(this.cacheAdapters[i]);
const cacheResultsFromAdapter = await cacheAdapter.loadManyAsync(
fieldName,
unfulfilledFieldValues
@@ -39,9 +46,43 @@ export default class ComposedEntityCacheAdapter<TFields> extends EntityCacheAdap
newUnfulfilledFieldValues.push(fieldValue);
} else {
retMap.set(fieldValue, cacheResult);
nullthrows(fulfilledFieldValuesByCacheIndex[i]).push(fieldValue);
}
}
unfulfilledFieldValues = newUnfulfilledFieldValues;
if (unfulfilledFieldValues.length === 0) {
break;
}
}
// Recache values from lower layers that were not found in higher layers
// Write to lower layers first
for (let i = this.cacheAdapters.length - 1; i >= 0; i--) {
const cacheAdapter = nullthrows(this.cacheAdapters[i]);
const hitsToCache = new Map<NonNullable<TFields[N]>, Readonly<TFields>>();
const negativesToCache: NonNullable<TFields[N]>[] = [];
// Loop over all lower layer caches to collect hits and misses
for (let j = i + 1; j < this.cacheAdapters.length; j++) {
const fulfilledFieldValues = nullthrows(fulfilledFieldValuesByCacheIndex[j]);
fulfilledFieldValues.forEach((fieldValue) => {
const cacheResult = nullthrows(retMap.get(fieldValue));
if (cacheResult.status === CacheStatus.HIT) {
hitsToCache.set(fieldValue, cacheResult.item);
} else if (cacheResult.status === CacheStatus.NEGATIVE) {
negativesToCache.push(fieldValue);
}
});
}
const promises = [];
if (hitsToCache.size > 0) {
promises.push(cacheAdapter.cacheManyAsync(fieldName, hitsToCache));
}
if (negativesToCache.length > 0) {
promises.push(cacheAdapter.cacheDBMissesAsync(fieldName, negativesToCache));
}
await Promise.all(promises);
}
for (const fieldValue of unfulfilledFieldValues) {
@@ -2,12 +2,13 @@ import nullthrows from 'nullthrows';
import { ISecondaryEntityCache } from './EntitySecondaryCacheLoader';
/**
* A ISecondaryEntityCache that composes other ISecondaryEntityCache instances.
*/
export default class ComposedSecondaryEntityCache<TLoadParams, TFields>
implements ISecondaryEntityCache<TFields, TLoadParams>
{
/**
* A {@link ISecondaryEntityCache} that composes other {@link ISecondaryEntityCache} instances.
*
* @param secondaryEntityCaches - list of caches to compose in order of precedence.
* Earlier caches are read from first and written to (including invalidations) last.
* Typically, caches closer to the application should be ordered before caches closer to the database.
+14 -10
View File
@@ -1,4 +1,8 @@
import { FieldEqualityCondition, QuerySelectionModifiers } from './EntityDatabaseAdapter';
import {
FieldEqualityCondition,
QuerySelectionModifiers,
QuerySelectionModifiersWithOrderByRaw,
} from './EntityDatabaseAdapter';
import EntityLoader from './EntityLoader';
import EntityPrivacyPolicy from './EntityPrivacyPolicy';
import ReadonlyEntity from './ReadonlyEntity';
@@ -36,7 +40,7 @@ export default class EnforcingEntityLoader<
/**
* Enforcing version of entity loader method by the same name.
* @throws {@link EntityNotAuthorizedError} when viewer is not authorized to view one or more of the returned entities
* @throws EntityNotAuthorizedError when viewer is not authorized to view one or more of the returned entities
*/
async loadManyByFieldEqualingManyAsync<N extends keyof Pick<TFields, TSelectedFields>>(
fieldName: N,
@@ -53,7 +57,7 @@ export default class EnforcingEntityLoader<
/**
* Enforcing version of entity loader method by the same name.
* @throws {@link EntityNotAuthorizedError} when viewer is not authorized to view one or more of the returned entities
* @throws EntityNotAuthorizedError when viewer is not authorized to view one or more of the returned entities
*/
async loadManyByFieldEqualingAsync<N extends keyof Pick<TFields, TSelectedFields>>(
fieldName: N,
@@ -68,7 +72,7 @@ export default class EnforcingEntityLoader<
/**
* Enforcing version of entity loader method by the same name.
* @throws {@link EntityNotAuthorizedError} when viewer is not authorized to view the returned entity
* @throws EntityNotAuthorizedError when viewer is not authorized to view the returned entity
* @throws when multiple entities are found matching the condition
*/
async loadByFieldEqualingAsync<N extends keyof Pick<TFields, TSelectedFields>>(
@@ -84,7 +88,7 @@ export default class EnforcingEntityLoader<
/**
* Enforcing version of entity loader method by the same name.
* @throws {@link EntityNotAuthorizedError} when viewer is not authorized to view the returned entity
* @throws EntityNotAuthorizedError when viewer is not authorized to view the returned entity
*/
async loadByIDAsync(id: TID): Promise<TEntity> {
const entityResult = await this.entityLoader.loadByIDAsync(id);
@@ -93,7 +97,7 @@ export default class EnforcingEntityLoader<
/**
* Enforcing version of entity loader method by the same name.
* @throws {@link EntityNotAuthorizedError} when viewer is not authorized to view the returned entity
* @throws EntityNotAuthorizedError when viewer is not authorized to view the returned entity
* @throws when multiple entities are found matching the condition
*/
async loadByIDNullableAsync(id: TID): Promise<TEntity | null> {
@@ -103,7 +107,7 @@ export default class EnforcingEntityLoader<
/**
* Enforcing version of entity loader method by the same name.
* @throws {@link EntityNotAuthorizedError} when viewer is not authorized to view one or more of the returned entities
* @throws EntityNotAuthorizedError when viewer is not authorized to view one or more of the returned entities
*/
async loadManyByIDsAsync(ids: readonly TID[]): Promise<ReadonlyMap<TID, TEntity>> {
const entityResults = await this.entityLoader.loadManyByIDsAsync(ids);
@@ -112,7 +116,7 @@ export default class EnforcingEntityLoader<
/**
* Enforcing version of entity loader method by the same name.
* @throws {@link EntityNotAuthorizedError} when viewer is not authorized to view one or more of the returned entities
* @throws EntityNotAuthorizedError when viewer is not authorized to view one or more of the returned entities
*/
async loadManyByFieldEqualityConjunctionAsync<N extends keyof Pick<TFields, TSelectedFields>>(
fieldEqualityOperands: FieldEqualityCondition<TFields, N>[],
@@ -127,12 +131,12 @@ export default class EnforcingEntityLoader<
/**
* Enforcing version of entity loader method by the same name.
* @throws {@link EntityNotAuthorizedError} when viewer is not authorized to view one or more of the returned entities
* @throws EntityNotAuthorizedError when viewer is not authorized to view one or more of the returned entities
*/
async loadManyByRawWhereClauseAsync(
rawWhereClause: string,
bindings: any[] | object,
querySelectionModifiers: QuerySelectionModifiers<TFields> = {}
querySelectionModifiers: QuerySelectionModifiersWithOrderByRaw<TFields> = {}
): Promise<readonly TEntity[]> {
const entityResults = await this.entityLoader.loadManyByRawWhereClauseAsync(
rawWhereClause,
+9 -9
View File
@@ -11,19 +11,19 @@ import ViewerContext from './ViewerContext';
* Entity is a privacy-first data model.
*
* A instance of an entity represents a single "row" of persisted data in a database that a
* viewer, represented by the corresponding {@link ViewerContext}, has permission to read.
* viewer, represented by the corresponding ViewerContext, has permission to read.
*
* Create, read, update, and delete permissions for an entity are declaratively defined using an
* {@link EntityPrivacyPolicy}.
* EntityPrivacyPolicy.
*
* Entites are loaded through an {@link EntityLoader}, which is responsible for
* Entites are loaded through an EntityLoader, which is responsible for
* orchestrating fetching, caching, and authorization of reading "rows".
*
* Entities are mutated and deleted through an {@link EntityMutator}, which is responsible for
* Entities are mutated and deleted through an EntityMutator, which is responsible for
* orchestrating database writes, cache invalidation, and authorization of writing "rows".
*
* All concrete entity implementations should extend this class and provide their
* own {@link EntityCompanionDefinition}.
* own EntityCompanionDefinition.
*/
export default abstract class Entity<
TFields,
@@ -69,7 +69,7 @@ export default abstract class Entity<
return viewerContext
.getViewerScopedEntityCompanionForClass(this)
.getMutatorFactory()
.forCreate(queryContext, { cascadingDeleteCause: null });
.forCreate(queryContext);
}
/**
@@ -111,7 +111,7 @@ export default abstract class Entity<
.getViewerContext()
.getViewerScopedEntityCompanionForClass(this)
.getMutatorFactory()
.forUpdate(existingEntity, queryContext, { cascadingDeleteCause: null });
.forUpdate(existingEntity, queryContext);
}
/**
@@ -152,7 +152,7 @@ export default abstract class Entity<
.getViewerContext()
.getViewerScopedEntityCompanionForClass(this)
.getMutatorFactory()
.forDelete(existingEntity, queryContext, { cascadingDeleteCause: null })
.forDelete(existingEntity, queryContext)
.deleteAsync();
}
@@ -194,7 +194,7 @@ export default abstract class Entity<
.getViewerContext()
.getViewerScopedEntityCompanionForClass(this)
.getMutatorFactory()
.forDelete(existingEntity, queryContext, { cascadingDeleteCause: null })
.forDelete(existingEntity, queryContext)
.enforceDeleteAsync();
}
@@ -6,6 +6,11 @@ import { EntityQueryContext } from './EntityQueryContext';
import ReadonlyEntity from './ReadonlyEntity';
import ViewerContext from './ViewerContext';
/**
* An association loader is a set of convenience methods for loading entities
* associated with an entity. In relational databases, these entities are often referenced
* by foreign keys.
*/
export default class EntityAssociationLoader<
TFields,
TID extends NonNullable<TFields[TSelectedFields]>,
@@ -246,7 +251,7 @@ export default class EntityAssociationLoader<
}
/**
* Load an associated entity by folding a sequence of {@link EntityLoadThroughDirective}. At each
* Load an associated entity by folding a sequence of EntityLoadThroughDirective. At each
* fold step, load an associated entity identified by a field value of the current fold value.
* @param loadDirectives - associated entity load directives instructing each step of the fold
* @param queryContext - query context in which to perform the loads
@@ -280,7 +285,7 @@ export default class EntityAssociationLoader<
): Promise<Result<TEntity2> | null>;
/**
* Load an associated entity by folding a sequence of {@link EntityLoadThroughDirective}. At each
* Load an associated entity by folding a sequence of EntityLoadThroughDirective. At each
* fold step, load an associated entity identified by a field value of the current fold value.
* @param loadDirectives - associated entity load directives instructing each step of the fold
* @param queryContext - query context in which to perform the loads
@@ -335,7 +340,7 @@ export default class EntityAssociationLoader<
): Promise<Result<TEntity3> | null>;
/**
* Load an associated entity by folding a sequence of {@link EntityLoadThroughDirective}. At each
* Load an associated entity by folding a sequence of EntityLoadThroughDirective. At each
* fold step, load an associated entity identified by a field value of the current fold value.
* @param loadDirectives - associated entity load directives instructing each step of the fold
* @param queryContext - query context in which to perform the loads
@@ -411,7 +416,7 @@ export default class EntityAssociationLoader<
): Promise<Result<TEntity4> | null>;
/**
* Load an associated entity by folding a sequence of {@link EntityLoadThroughDirective}. At each
* Load an associated entity by folding a sequence of EntityLoadThroughDirective. At each
* fold step, load an associated entity identified by a field value of the current fold value.
* @param loadDirectives - associated entity load directives instructing each step of the fold
* @param queryContext - query context in which to perform the loads
+1 -1
View File
@@ -32,7 +32,7 @@ export default abstract class EntityCacheAdapter<TFields> {
/**
* Negatively cache objects that could not be found in the cache or DB.
* @param fieldName - object field being queried
* @param fieldValues - fieldValues for objects reported as {@link CacheStatus.NEGATIVE}
* @param fieldValues - fieldValues for objects reported as CacheStatus.NEGATIVE
* in the cache and not found in the DB.
*/
public abstract cacheDBMissesAsync<N extends keyof TFields>(
+1 -1
View File
@@ -132,7 +132,7 @@ export default class EntityCompanion<
}
/**
* Get the {@link IEntityMetricsAdapter} for this companion.
* Get the IEntityMetricsAdapter for this companion.
*/
getMetricsAdapter(): IEntityMetricsAdapter {
return this.metricsAdapter;
@@ -109,15 +109,15 @@ export class EntityCompanionDefinition<
TSelectedFields
>;
/**
* The {@link EntityConfiguration} for this entity.
* The EntityConfiguration for this entity.
*/
entityConfiguration: EntityConfiguration<TFields>;
/**
* The {@link EntityPrivacyPolicy} class for this entity.
* The EntityPrivacyPolicy class for this entity.
*/
privacyPolicyClass: IPrivacyPolicyClass<TPrivacyPolicy>;
/**
* An optional list of {@link EntityMutationValidator} for this entity.
* An optional list of EntityMutationValidator for this entity.
*/
mutationValidators?: () => EntityMutationValidator<
TFields,
@@ -127,7 +127,7 @@ export class EntityCompanionDefinition<
TSelectedFields
>[];
/**
* An optional list of {@link EntityMutationTrigger} for this entity.
* An optional list of EntityMutationTrigger for this entity.
*/
mutationTriggers?: () => EntityMutationTriggerConfiguration<
TFields,
@@ -137,8 +137,8 @@ export class EntityCompanionDefinition<
TSelectedFields
>;
/**
* An optional subset of fields defined in the {@link EntityConfiguration} which belong to this entity.
* For use when multiple types of entities are backed by a single table ({@link EntityConfiguration}) yet
* An optional subset of fields defined in the EntityConfiguration which belong to this entity.
* For use when multiple types of entities are backed by a single table (EntityConfiguration) yet
* only expose a subset of the fields.
*/
entitySelectedFields?: TSelectedFields[];
@@ -155,11 +155,11 @@ export class EntityCompanionDefinition<
/**
* An instance of the Entity framework.
*
* Required to create a {@link ViewerContext}, which is the application entry point
* Required to create a ViewerContext, which is the application entry point
* into the framework.
*
* Internally, this is a lazy entity companion factory that instantiates and caches one
* {@link EntityCompanion} for each type of {@link Entity}.
* EntityCompanion for each type of Entity.
*/
export default class EntityCompanionProvider {
private readonly companionMap: Map<string, EntityCompanion<any, any, any, any, any, any>> =
@@ -169,7 +169,7 @@ export default class EntityCompanionProvider {
/**
* Instantiate an Entity framework.
* @param metricsAdapter - An {@link IEntityMetricsAdapter} for collecting metrics on this instance
* @param metricsAdapter - An IEntityMetricsAdapter for collecting metrics on this instance
* @param databaseAdapterFlavors - Database adapter configurations for this instance
* @param cacheAdapterFlavors - Cache adapter configurations for this instance
*/
+2 -2
View File
@@ -41,12 +41,12 @@ export default class EntityConfiguration<TFields> {
tableName: string;
/**
* Map from each entity field to an {@link EntityFieldDefinition} specifying information about the field.
* Map from each entity field to an EntityFieldDefinition specifying information about the field.
*/
schema: Record<keyof TFields, EntityFieldDefinition<any>>;
/**
* List of other entity types that reference this type in {@link EntityFieldDefinition} associations.
* List of other entity types that reference this type in EntityFieldDefinition associations.
*/
getInboundEdges?: () => IEntityClass<any, any, any, any, any, any>[];
+33 -6
View File
@@ -7,12 +7,18 @@ import {
FieldTransformerMap,
} from './internal/EntityFieldTransformationUtils';
interface SingleValueFieldEqualityCondition<TFields, N extends keyof TFields = keyof TFields> {
export interface SingleValueFieldEqualityCondition<
TFields,
N extends keyof TFields = keyof TFields
> {
fieldName: N;
fieldValue: TFields[N];
}
interface MultiValueFieldEqualityCondition<TFields, N extends keyof TFields = keyof TFields> {
export interface MultiValueFieldEqualityCondition<
TFields,
N extends keyof TFields = keyof TFields
> {
fieldName: N;
fieldValues: readonly TFields[N][];
}
@@ -68,6 +74,14 @@ export interface QuerySelectionModifiers<TFields> {
limit?: number;
}
export interface QuerySelectionModifiersWithOrderByRaw<TFields>
extends QuerySelectionModifiers<TFields> {
/**
* Order the entities by a raw SQL `ORDER BY` clause.
*/
orderByRaw?: string;
}
export interface TableQuerySelectionModifiers {
orderBy:
| {
@@ -79,6 +93,10 @@ export interface TableQuerySelectionModifiers {
limit: number | undefined;
}
export interface TableQuerySelectionModifiersWithOrderByRaw extends TableQuerySelectionModifiers {
orderByRaw: string | undefined;
}
/**
* A database adapter is an interface by which entity objects can be
* fetched, inserted, updated, and deleted from a database. This base class
@@ -149,7 +167,7 @@ export default abstract class EntityDatabaseAdapter<TFields> {
*
* @param queryContext - query context with which to perform the fetch
* @param fieldEqualityOperands - list of field equality where clause operand specifications
* @param querySelectionModifiers - limit, offset, and orderBy for the query
* @param querySelectionModifiers - limit, offset, orderBy, and orderByRaw for the query
* @returns array of objects matching the query
*/
async fetchManyByFieldEqualityConjunctionAsync<N extends keyof TFields>(
@@ -207,14 +225,14 @@ export default abstract class EntityDatabaseAdapter<TFields> {
queryContext: EntityQueryContext,
rawWhereClause: string,
bindings: any[] | object,
querySelectionModifiers: QuerySelectionModifiers<TFields>
querySelectionModifiers: QuerySelectionModifiersWithOrderByRaw<TFields>
): Promise<readonly Readonly<TFields>[]> {
const results = await this.fetchManyByRawWhereClauseInternalAsync(
queryContext.getQueryInterface(),
this.entityConfiguration.tableName,
rawWhereClause,
bindings,
this.convertToTableQueryModifiers(querySelectionModifiers)
this.convertToTableQueryModifiersWithOrderByRaw(querySelectionModifiers)
);
return results.map((result) =>
@@ -227,7 +245,7 @@ export default abstract class EntityDatabaseAdapter<TFields> {
tableName: string,
rawWhereClause: string,
bindings: any[] | object,
querySelectionModifiers: TableQuerySelectionModifiers
querySelectionModifiers: TableQuerySelectionModifiersWithOrderByRaw
): Promise<object[]>;
/**
@@ -363,6 +381,15 @@ export default abstract class EntityDatabaseAdapter<TFields> {
id: any
): Promise<number>;
private convertToTableQueryModifiersWithOrderByRaw(
querySelectionModifiers: QuerySelectionModifiersWithOrderByRaw<TFields>
): TableQuerySelectionModifiersWithOrderByRaw {
return {
...this.convertToTableQueryModifiers(querySelectionModifiers),
orderByRaw: querySelectionModifiers.orderByRaw,
};
}
private convertToTableQueryModifiers(
querySelectionModifiers: QuerySelectionModifiers<TFields>
): TableQuerySelectionModifiers {
+15 -6
View File
@@ -5,13 +5,21 @@ import ViewerContext from './ViewerContext';
export enum EntityEdgeDeletionBehavior {
/**
* Default. Invalidate the cache for all entities that reference the entity
* Invalidate the cache for all entities that reference the entity
* being deleted through this field, and transitively run deletions on those entities.
* This is most useful when the database itself expresses foreign
* keys and cascading deletes or set nulls and the entity framework just needs to
* keys and cascading deletes and the entity framework just needs to
* be kept consistent with the state of the database.
*/
CASCADE_DELETE_INVALIDATE_CACHE,
CASCADE_DELETE_INVALIDATE_CACHE_ONLY,
/**
* Invalidate the cache for all entities that reference the entity
* being deleted through this field. This is most useful when the database itself expresses
* foreign keys and cascading "SET NULL"s and the entity framework just needs to be
* kept consistent with the state of the database.
*/
SET_NULL_INVALIDATE_CACHE_ONLY,
/**
* Delete all entities that reference the entity being deleted through this field. This is very similar
@@ -74,16 +82,17 @@ export interface EntityAssociationDefinition<
*
* @remarks
* The entity framework doesn't prescribe a one-size-fits-all solution for referential
* integrity; instead it exposes mechanisms that supports both database foreign key constraints
* integrity; instead it exposes mechanisms that support both database foreign key constraints
* and implicit entity-specified foreign keys. Choosing which approach to use often depends on
* application requirements, and sometimes even a mix-and-match is the right choice.
*
* - If referential integrity is critical to your application, database foreign key constraints
* combined with {@link EntityEdgeDeletionBehavior.CASACDE_DELETE_INVALIDATE_CACHE} are recommended.
* combined with EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE_ONLY or
* EntityEdgeDeletionBehavior.SET_NULL_INVALIDATE_CACHE_ONLY are recommended.
* - If the database being used doesn't support foreign keys, then using the entity framework for referential
* integrity is recommended.
*/
edgeDeletionBehavior?: EntityEdgeDeletionBehavior;
edgeDeletionBehavior: EntityEdgeDeletionBehavior;
}
/**
+11 -11
View File
@@ -3,7 +3,7 @@ import { validate as validateUUID } from 'uuid';
import { EntityFieldDefinition } from './EntityFieldDefinition';
/**
* {@link EntityFieldDefinition} for a column with a JS string type.
* EntityFieldDefinition for a column with a JS string type.
*/
export class StringField extends EntityFieldDefinition<string> {
protected validateInputValueInternal(value: string): boolean {
@@ -12,7 +12,7 @@ export class StringField extends EntityFieldDefinition<string> {
}
/**
* {@link EntityFieldDefinition} for a column with a JS string type.
* EntityFieldDefinition for a column with a JS string type.
* Enforces that the string is a valid UUID.
*/
export class UUIDField extends StringField {
@@ -22,7 +22,7 @@ export class UUIDField extends StringField {
}
/**
* {@link EntityFieldDefinition} for a column with a JS Date type.
* EntityFieldDefinition for a column with a JS Date type.
*/
export class DateField extends EntityFieldDefinition<Date> {
protected validateInputValueInternal(value: Date): boolean {
@@ -31,7 +31,7 @@ export class DateField extends EntityFieldDefinition<Date> {
}
/**
* {@link EntityFieldDefinition} for a column with a JS boolean type.
* EntityFieldDefinition for a column with a JS boolean type.
*/
export class BooleanField extends EntityFieldDefinition<boolean> {
protected validateInputValueInternal(value: boolean): boolean {
@@ -40,7 +40,7 @@ export class BooleanField extends EntityFieldDefinition<boolean> {
}
/**
* {@link EntityFieldDefinition} for a column with a JS number type.
* EntityFieldDefinition for a column with a JS number type.
* Enforces that the number is an integer.
*/
export class IntField extends EntityFieldDefinition<number> {
@@ -50,7 +50,7 @@ export class IntField extends EntityFieldDefinition<number> {
}
/**
* {@link EntityFieldDefinition} for a column with a JS number type.
* EntityFieldDefinition for a column with a JS number type.
* Enforces that the number is a float (which includes integers in JS).
*/
export class FloatField extends EntityFieldDefinition<number> {
@@ -60,7 +60,7 @@ export class FloatField extends EntityFieldDefinition<number> {
}
/**
* {@link EntityFieldDefinition} for a column with a JS string array type.
* EntityFieldDefinition for a column with a JS string array type.
* Enforces that every member of the string array is a string.
*/
export class StringArrayField extends EntityFieldDefinition<string[]> {
@@ -70,7 +70,7 @@ export class StringArrayField extends EntityFieldDefinition<string[]> {
}
/**
* {@link EntityFieldDefinition} for a column with a JS JSON object type.
* EntityFieldDefinition for a column with a JS JSON object type.
*/
export class JSONObjectField extends EntityFieldDefinition<object> {
protected validateInputValueInternal(value: object): boolean {
@@ -79,7 +79,7 @@ export class JSONObjectField extends EntityFieldDefinition<object> {
}
/**
* {@link EntityFieldDefinition} for a enum column with a JS string or number type.
* EntityFieldDefinition for a enum column with a JS string or number type.
*/
export class EnumField extends EntityFieldDefinition<string | number> {
protected validateInputValueInternal(value: string | number): boolean {
@@ -88,7 +88,7 @@ export class EnumField extends EntityFieldDefinition<string | number> {
}
/**
* {@link EntityFieldDefinition} for a column with a JS JSON array type.
* EntityFieldDefinition for a column with a JS JSON array type.
*/
export class JSONArrayField extends EntityFieldDefinition<any[]> {
protected validateInputValueInternal(value: any[]): boolean {
@@ -97,7 +97,7 @@ export class JSONArrayField extends EntityFieldDefinition<any[]> {
}
/**
* {@link EntityFieldDefinition} for a column that may be a JS JSON array type.
* EntityFieldDefinition for a column that may be a JS JSON array type.
* Does not do any validation.
*/
export class MaybeJSONArrayField extends EntityFieldDefinition<any | any[]> {
+11 -8
View File
@@ -8,6 +8,7 @@ import {
FieldEqualityCondition,
QuerySelectionModifiers,
isSingleValueFieldEqualityCondition,
QuerySelectionModifiersWithOrderByRaw,
} from './EntityDatabaseAdapter';
import EntityPrivacyPolicy, { EntityPrivacyPolicyEvaluationContext } from './EntityPrivacyPolicy';
import { EntityQueryContext } from './EntityQueryContext';
@@ -21,7 +22,7 @@ import { mapMap, mapMapAsync } from './utils/collections/maps';
/**
* The primary interface for loading entities. All normal loads are batched,
* cached, and authorized against the entity's {@link EntityPrivacyPolicy}.
* cached, and authorized against the entity's EntityPrivacyPolicy.
*/
export default class EntityLoader<
TFields,
@@ -126,7 +127,9 @@ export default class EntityLoader<
const entityResults = await this.loadManyByFieldEqualingAsync(uniqueFieldName, fieldValue);
invariant(
entityResults.length <= 1,
`loadByFieldEqualing: Multiple entities of type ${this.entityClass.name} found for ${uniqueFieldName}=${fieldValue}`
`loadByFieldEqualing: Multiple entities of type ${this.entityClass.name} found for ${String(
uniqueFieldName
)}=${fieldValue}`
);
return entityResults[0] ?? null;
}
@@ -242,12 +245,12 @@ export default class EntityLoader<
* bindings: `[1]`
* Entites returned `WHERE id = 1`
*
* {@link http://knexjs.org/#Builder-whereRaw}
* {@link http://knexjs.org/#Raw-Bindings}
* http://knexjs.org/#Builder-whereRaw
* http://knexjs.org/#Raw-Bindings
*
* @param rawWhereClause - parameterized SQL WHERE clause with positional binding placeholders or named binding placeholders
* @param bindings - array of positional bindings or object of named bindings
* @param querySelectionModifiers - limit, offset, and orderBy for the query
* @param querySelectionModifiers - limit, offset, orderBy, and orderByRaw for the query
* @returns array of entity results that match the query, where result error can be UnauthorizedError
* @throws Error when rawWhereClause or bindings are invalid
*
@@ -256,7 +259,7 @@ export default class EntityLoader<
async loadManyByRawWhereClauseAsync(
rawWhereClause: string,
bindings: any[] | object,
querySelectionModifiers: QuerySelectionModifiers<TFields> = {}
querySelectionModifiers: QuerySelectionModifiersWithOrderByRaw<TFields> = {}
): Promise<readonly Result<TEntity>[]> {
const fieldObjects = await this.dataManager.loadManyByRawWhereClauseAsync(
this.queryContext,
@@ -284,7 +287,7 @@ export default class EntityLoader<
}
/**
* Invalidate all caches for an entity's fields. Exposed primarily for internal use by {@link EntityMutator}.
* Invalidate all caches for an entity's fields. Exposed primarily for internal use by EntityMutator.
* @param objectFields - entity data object to be invalidated
*/
async invalidateFieldsAsync(objectFields: Readonly<TFields>): Promise<void> {
@@ -350,7 +353,7 @@ export default class EntityLoader<
fieldValues: readonly TFields[N][]
): void {
const fieldDefinition = this.entityConfiguration.schema.get(fieldName);
invariant(fieldDefinition, `must have field definition for field = ${fieldName}`);
invariant(fieldDefinition, `must have field definition for field = ${String(fieldName)}`);
for (const fieldValue of fieldValues) {
const isInputValid = fieldDefinition.validateInputValue(fieldValue);
if (!isInputValid) {
@@ -20,6 +20,7 @@ export type EntityValidatorMutationInfo<
| {
type: EntityMutationType.UPDATE;
previousValue: TEntity;
cascadingDeleteCause: EntityCascadingDeletionInfo | null;
};
/**
@@ -50,6 +51,7 @@ export type EntityTriggerMutationInfo<
| {
type: EntityMutationType.UPDATE;
previousValue: TEntity;
cascadingDeleteCause: EntityCascadingDeletionInfo | null;
}
| {
type: EntityMutationType.DELETE;
@@ -85,7 +85,7 @@ export abstract class EntityMutationTrigger<
}
/**
* A non-transactional trigger is like a {@link EntityMutationTrigger} but used for afterCommit triggers
* A non-transactional trigger is like a EntityMutationTrigger but used for afterCommit triggers
* since they explicitly occur outside of the transaction.
*/
export abstract class EntityNonTransactionalMutationTrigger<
+99 -68
View File
@@ -18,7 +18,7 @@ import EntityMutationTriggerConfiguration, {
EntityNonTransactionalMutationTrigger,
} from './EntityMutationTriggerConfiguration';
import EntityMutationValidator from './EntityMutationValidator';
import EntityPrivacyPolicy, { EntityPrivacyPolicyEvaluationContext } from './EntityPrivacyPolicy';
import EntityPrivacyPolicy from './EntityPrivacyPolicy';
import { EntityQueryContext, EntityTransactionalQueryContext } from './EntityQueryContext';
import ReadonlyEntity from './ReadonlyEntity';
import ViewerContext from './ViewerContext';
@@ -44,7 +44,6 @@ abstract class BaseMutator<
constructor(
protected readonly viewerContext: TViewerContext,
protected readonly queryContext: EntityQueryContext,
protected readonly privacyPolicyEvaluationContext: EntityPrivacyPolicyEvaluationContext,
protected readonly entityConfiguration: EntityConfiguration<TFields>,
protected readonly entityClass: IEntityClass<
TFields,
@@ -183,7 +182,7 @@ export class CreateMutator<
/**
* Commit the new entity after authorizing against creation privacy rules. Invalidates all caches for
* queries that would return new entity and caches the new entity if not in a transactional query context.
* queries that would return new entity.
* @returns authorized, cached, newly-created entity result, where result error can be UnauthorizedError
*/
async createAsync(): Promise<Result<TEntity>> {
@@ -221,7 +220,7 @@ export class CreateMutator<
this.privacyPolicy.authorizeCreateAsync(
this.viewerContext,
queryContext,
this.privacyPolicyEvaluationContext,
{ cascadingDeleteCause: null },
temporaryEntityForPrivacyCheck,
this.metricsAdapter
)
@@ -251,11 +250,9 @@ export class CreateMutator<
const insertResult = await this.databaseAdapter.insertAsync(queryContext, this.fieldsForEntity);
const entityLoader = this.entityLoaderFactory.forLoad(
this.viewerContext,
queryContext,
this.privacyPolicyEvaluationContext
);
const entityLoader = this.entityLoaderFactory.forLoad(this.viewerContext, queryContext, {
cascadingDeleteCause: null,
});
queryContext.appendPostCommitInvalidationCallback(
entityLoader.invalidateFieldsAsync.bind(entityLoader, insertResult)
);
@@ -315,7 +312,6 @@ export class UpdateMutator<
constructor(
viewerContext: TViewerContext,
queryContext: EntityQueryContext,
privacyPolicyEvaluationContext: EntityPrivacyPolicyEvaluationContext,
entityConfiguration: EntityConfiguration<TFields>,
entityClass: IEntityClass<
TFields,
@@ -355,7 +351,6 @@ export class UpdateMutator<
super(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
entityConfiguration,
entityClass,
privacyPolicy,
@@ -382,8 +377,7 @@ export class UpdateMutator<
/**
* Commit the changes to the entity after authorizing against update privacy rules.
* Invalidates all caches for pre-update entity and caches the updated entity if not in a
* transactional query context.
* Invalidates all caches for pre-update entity.
* @returns authorized updated entity result, where result error can be UnauthorizedError
*/
async updateAsync(): Promise<Result<TEntity>> {
@@ -391,7 +385,7 @@ export class UpdateMutator<
this.metricsAdapter,
EntityMetricsMutationType.UPDATE,
this.entityClass.name
)(this.updateInTransactionAsync());
)(this.updateInTransactionAsync(false, null));
}
/**
@@ -401,14 +395,27 @@ export class UpdateMutator<
return await enforceAsyncResult(this.updateAsync());
}
private async updateInTransactionAsync(): Promise<Result<TEntity>> {
private async updateInTransactionAsync(
skipDatabaseUpdate: true,
cascadingDeleteCause: EntityCascadingDeletionInfo
): Promise<Result<TEntity>>;
private async updateInTransactionAsync(
skipDatabaseUpdate: false,
cascadingDeleteCause: EntityCascadingDeletionInfo | null
): Promise<Result<TEntity>>;
private async updateInTransactionAsync(
skipDatabaseUpdate: boolean,
cascadingDeleteCause: EntityCascadingDeletionInfo | null
): Promise<Result<TEntity>> {
return await this.queryContext.runInTransactionIfNotInTransactionAsync((innerQueryContext) =>
this.updateInternalAsync(innerQueryContext)
this.updateInternalAsync(innerQueryContext, skipDatabaseUpdate, cascadingDeleteCause)
);
}
private async updateInternalAsync(
queryContext: EntityTransactionalQueryContext
queryContext: EntityTransactionalQueryContext,
skipDatabaseUpdate: boolean,
cascadingDeleteCause: EntityCascadingDeletionInfo | null
): Promise<Result<TEntity>> {
this.validateFields(this.updatedFields);
@@ -417,7 +424,7 @@ export class UpdateMutator<
this.privacyPolicy.authorizeUpdateAsync(
this.viewerContext,
queryContext,
this.privacyPolicyEvaluationContext,
{ cascadingDeleteCause },
entityAboutToBeUpdated,
this.metricsAdapter
)
@@ -430,33 +437,34 @@ export class UpdateMutator<
this.mutationValidators,
queryContext,
entityAboutToBeUpdated,
{ type: EntityMutationType.UPDATE, previousValue: this.originalEntity }
{ type: EntityMutationType.UPDATE, previousValue: this.originalEntity, cascadingDeleteCause }
);
await this.executeMutationTriggersAsync(
this.mutationTriggers.beforeAll,
queryContext,
entityAboutToBeUpdated,
{ type: EntityMutationType.UPDATE, previousValue: this.originalEntity }
{ type: EntityMutationType.UPDATE, previousValue: this.originalEntity, cascadingDeleteCause }
);
await this.executeMutationTriggersAsync(
this.mutationTriggers.beforeUpdate,
queryContext,
entityAboutToBeUpdated,
{ type: EntityMutationType.UPDATE, previousValue: this.originalEntity }
{ type: EntityMutationType.UPDATE, previousValue: this.originalEntity, cascadingDeleteCause }
);
const updateResult = await this.databaseAdapter.updateAsync(
queryContext,
this.entityConfiguration.idField,
entityAboutToBeUpdated.getID(),
this.updatedFields
);
// skip the database update when specified
const updateResult = skipDatabaseUpdate
? null
: await this.databaseAdapter.updateAsync(
queryContext,
this.entityConfiguration.idField,
entityAboutToBeUpdated.getID(),
this.updatedFields
);
const entityLoader = this.entityLoaderFactory.forLoad(
this.viewerContext,
queryContext,
this.privacyPolicyEvaluationContext
);
const entityLoader = this.entityLoaderFactory.forLoad(this.viewerContext, queryContext, {
cascadingDeleteCause,
});
queryContext.appendPostCommitInvalidationCallback(
entityLoader.invalidateFieldsAsync.bind(
@@ -468,22 +476,25 @@ export class UpdateMutator<
entityLoader.invalidateFieldsAsync.bind(entityLoader, this.fieldsForEntity)
);
const unauthorizedEntityAfterUpdate = new this.entityClass(this.viewerContext, updateResult);
const updatedEntity = await entityLoader
.enforcing()
.loadByIDAsync(unauthorizedEntityAfterUpdate.getID());
// when the database update was skipped, assume it succeeded and use the optimistic
// entity to execute triggers
const updatedEntity = updateResult
? await entityLoader
.enforcing()
.loadByIDAsync(new this.entityClass(this.viewerContext, updateResult).getID())
: entityAboutToBeUpdated;
await this.executeMutationTriggersAsync(
this.mutationTriggers.afterUpdate,
queryContext,
updatedEntity,
{ type: EntityMutationType.UPDATE, previousValue: this.originalEntity }
{ type: EntityMutationType.UPDATE, previousValue: this.originalEntity, cascadingDeleteCause }
);
await this.executeMutationTriggersAsync(
this.mutationTriggers.afterAll,
queryContext,
updatedEntity,
{ type: EntityMutationType.UPDATE, previousValue: this.originalEntity }
{ type: EntityMutationType.UPDATE, previousValue: this.originalEntity, cascadingDeleteCause }
);
queryContext.appendPostCommitCallback(
@@ -491,7 +502,11 @@ export class UpdateMutator<
this,
this.mutationTriggers.afterCommit,
updatedEntity,
{ type: EntityMutationType.UPDATE, previousValue: this.originalEntity }
{
type: EntityMutationType.UPDATE,
previousValue: this.originalEntity,
cascadingDeleteCause,
}
)
);
@@ -519,7 +534,6 @@ export class DeleteMutator<
constructor(
viewerContext: TViewerContext,
queryContext: EntityQueryContext,
privacyPolicyEvaluationContext: EntityPrivacyPolicyEvaluationContext,
entityConfiguration: EntityConfiguration<TFields>,
entityClass: IEntityClass<
TFields,
@@ -559,7 +573,6 @@ export class DeleteMutator<
super(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
entityConfiguration,
entityClass,
privacyPolicy,
@@ -687,7 +700,7 @@ export class DeleteMutator<
/**
* Finds all entities referencing the specified entity and either deletes them, nullifies
* their references to the specified entity, or invalidates the cache depending on the
* {@link OnDeleteBehavior} of the field referencing the specified entity.
* OnDeleteBehavior of the field referencing the specified entity.
*
* @remarks
* This works by doing reverse fan-out queries:
@@ -772,19 +785,34 @@ export class DeleteMutator<
}
switch (association.edgeDeletionBehavior) {
case undefined:
case EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE: {
case EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE_ONLY: {
await Promise.all(
inboundReferenceEntities.map((inboundReferenceEntity) =>
mutatorFactory
.forDelete(inboundReferenceEntity, queryContext, {
cascadingDeleteCause: newCascadingDeleteCause,
})
.deleteInTransactionAsync(
processedEntityIdentifiers,
/* skipDatabaseDeletion */ true, // deletion is handled by DB
newCascadingDeleteCause
)
enforceAsyncResult(
mutatorFactory
.forDelete(inboundReferenceEntity, queryContext)
.deleteInTransactionAsync(
processedEntityIdentifiers,
/* skipDatabaseDeletion */ true, // deletion is handled by DB
newCascadingDeleteCause
)
)
)
);
break;
}
case EntityEdgeDeletionBehavior.SET_NULL_INVALIDATE_CACHE_ONLY: {
await Promise.all(
inboundReferenceEntities.map((inboundReferenceEntity) =>
enforceAsyncResult(
mutatorFactory
.forUpdate(inboundReferenceEntity, queryContext)
.setField(fieldName, null)
['updateInTransactionAsync'](
/* skipDatabaseUpdate */ true,
newCascadingDeleteCause
)
)
)
);
break;
@@ -792,12 +820,15 @@ export class DeleteMutator<
case EntityEdgeDeletionBehavior.SET_NULL: {
await Promise.all(
inboundReferenceEntities.map((inboundReferenceEntity) =>
mutatorFactory
.forUpdate(inboundReferenceEntity, queryContext, {
cascadingDeleteCause: newCascadingDeleteCause,
})
.setField(fieldName, null)
.enforceUpdateAsync()
enforceAsyncResult(
mutatorFactory
.forUpdate(inboundReferenceEntity, queryContext)
.setField(fieldName, null)
['updateInTransactionAsync'](
/* skipDatabaseUpdate */ false,
newCascadingDeleteCause
)
)
)
);
break;
@@ -805,15 +836,15 @@ export class DeleteMutator<
case EntityEdgeDeletionBehavior.CASCADE_DELETE: {
await Promise.all(
inboundReferenceEntities.map((inboundReferenceEntity) =>
mutatorFactory
.forDelete(inboundReferenceEntity, queryContext, {
cascadingDeleteCause: newCascadingDeleteCause,
})
.deleteInTransactionAsync(
processedEntityIdentifiers,
/* skipDatabaseDeletion */ false,
newCascadingDeleteCause
)
enforceAsyncResult(
mutatorFactory
.forDelete(inboundReferenceEntity, queryContext)
.deleteInTransactionAsync(
processedEntityIdentifiers,
/* skipDatabaseDeletion */ false,
newCascadingDeleteCause
)
)
)
);
}
+4 -10
View File
@@ -5,7 +5,7 @@ import EntityLoaderFactory from './EntityLoaderFactory';
import EntityMutationTriggerConfiguration from './EntityMutationTriggerConfiguration';
import EntityMutationValidator from './EntityMutationValidator';
import { CreateMutator, UpdateMutator, DeleteMutator } from './EntityMutator';
import EntityPrivacyPolicy, { EntityPrivacyPolicyEvaluationContext } from './EntityPrivacyPolicy';
import EntityPrivacyPolicy from './EntityPrivacyPolicy';
import { EntityQueryContext } from './EntityQueryContext';
import ViewerContext from './ViewerContext';
import IEntityMetricsAdapter from './metrics/IEntityMetricsAdapter';
@@ -72,13 +72,11 @@ export default class EntityMutatorFactory<
*/
forCreate(
viewerContext: TViewerContext,
queryContext: EntityQueryContext,
privacyPolicyEvaluationContext: EntityPrivacyPolicyEvaluationContext
queryContext: EntityQueryContext
): CreateMutator<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy, TSelectedFields> {
return new CreateMutator(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
this.entityConfiguration,
this.entityClass,
this.privacyPolicy,
@@ -98,13 +96,11 @@ export default class EntityMutatorFactory<
*/
forUpdate(
existingEntity: TEntity,
queryContext: EntityQueryContext,
privacyPolicyEvaluationContext: EntityPrivacyPolicyEvaluationContext
queryContext: EntityQueryContext
): UpdateMutator<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy, TSelectedFields> {
return new UpdateMutator(
existingEntity.getViewerContext(),
queryContext,
privacyPolicyEvaluationContext,
this.entityConfiguration,
this.entityClass,
this.privacyPolicy,
@@ -124,13 +120,11 @@ export default class EntityMutatorFactory<
*/
forDelete(
existingEntity: TEntity,
queryContext: EntityQueryContext,
privacyPolicyEvaluationContext: EntityPrivacyPolicyEvaluationContext
queryContext: EntityQueryContext
): DeleteMutator<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy, TSelectedFields> {
return new DeleteMutator(
existingEntity.getViewerContext(),
queryContext,
privacyPolicyEvaluationContext,
this.entityConfiguration,
this.entityClass,
this.privacyPolicy,
+20 -5
View File
@@ -19,9 +19,24 @@ export type EntityPrivacyPolicyEvaluationContext = {
cascadingDeleteCause: EntityCascadingDeletionInfo | null;
};
/**
* Evaluation mode for a EntityPrivacyPolicy. Useful when transitioning to
* using Entity for privacy.
*/
export enum EntityPrivacyPolicyEvaluationMode {
/**
* Enforce this privacy policy. Throw upon denial.
*/
ENFORCE,
/**
* Do not enforce this privacy policy. Always allow but log when it would have denied.
*/
DRY_RUN,
/**
* Enforce this privacy policy. Throw and log upon denial.
*/
ENFORCE_AND_LOG,
}
@@ -60,7 +75,7 @@ export enum EntityAuthorizationAction {
*
* @remarks
*
* A privacy policy declares lists of {@link PrivacyPolicyRule} for create, read, update, and delete actions
* A privacy policy declares lists of PrivacyPolicyRule for create, read, update, and delete actions
* for an entity and provides logic for authorizing an entity against rules.
*
* Evaluation of a list of rules is performed according the following example. This allows constructing of
@@ -134,7 +149,7 @@ export default abstract class EntityPrivacyPolicy<
* @param queryContext - query context in which to perform the create authorization
* @param entity - entity to authorize
* @returns entity if authorized
* @throws {@link EntityNotAuthorizedError} when not authorized
* @throws EntityNotAuthorizedError when not authorized
*/
async authorizeCreateAsync(
viewerContext: TViewerContext,
@@ -160,7 +175,7 @@ export default abstract class EntityPrivacyPolicy<
* @param queryContext - query context in which to perform the read authorization
* @param entity - entity to authorize
* @returns entity if authorized
* @throws {@link EntityNotAuthorizedError} when not authorized
* @throws EntityNotAuthorizedError when not authorized
*/
async authorizeReadAsync(
viewerContext: TViewerContext,
@@ -186,7 +201,7 @@ export default abstract class EntityPrivacyPolicy<
* @param queryContext - query context in which to perform the update authorization
* @param entity - entity to authorize
* @returns entity if authorized
* @throws {@link EntityNotAuthorizedError} when not authorized
* @throws EntityNotAuthorizedError when not authorized
*/
async authorizeUpdateAsync(
viewerContext: TViewerContext,
@@ -212,7 +227,7 @@ export default abstract class EntityPrivacyPolicy<
* @param queryContext - query context in which to perform the delete authorization
* @param entity - entity to authorize
* @returns entity if authorized
* @throws {@link EntityNotAuthorizedError} when not authorized
* @throws EntityNotAuthorizedError when not authorized
*/
async authorizeDeleteAsync(
viewerContext: TViewerContext,
+12 -1
View File
@@ -12,7 +12,7 @@ export type PreCommitCallback = (
* Entity framework representation of transactional and non-transactional database
* query execution units.
*
* The behavior of {@link EntityMutator} and {@link EntityLoader}
* The behavior of EntityMutator and EntityLoader
* differs when in a transactional context.
*/
export abstract class EntityQueryContext {
@@ -29,6 +29,12 @@ export abstract class EntityQueryContext {
): Promise<T>;
}
/**
* Entity framework representation of a non-transactional query execution unit.
* When supplied to EntityMutator and EntityLoader methods, they will be
* run independently of any running transaction (though mutations start their own
* independent transactions internally when not being run in a transaction).
*/
export class EntityNonTransactionalQueryContext extends EntityQueryContext {
constructor(
queryInterface: any,
@@ -48,6 +54,11 @@ export class EntityNonTransactionalQueryContext extends EntityQueryContext {
}
}
/**
* Entity framework representation of a transactional query execution unit. When supplied
* to EntityMutator and EntityLoader methods, those methods and their
* dependent triggers and validators will run within the transaction.
*/
export class EntityTransactionalQueryContext extends EntityQueryContext {
private readonly postCommitInvalidationCallbacks: PostCommitCallback[] = [];
private readonly postCommitCallbacks: PostCommitCallback[] = [];
@@ -20,7 +20,7 @@ export default abstract class EntityQueryContextProvider {
protected abstract getQueryInterface(): any;
/**
* Vend a transaction runner for use in {@link runInTransactionAsync}.
* Vend a transaction runner for use in runInTransactionAsync.
*/
protected abstract createTransactionRunner<T>(): (
transactionScope: (queryInterface: any) => Promise<T>
@@ -40,7 +40,7 @@ export interface ISecondaryEntityCache<TFields, TLoadParams> {
* when the underlying data of a cache key could be stale.
*
* This is most commonly used to further optimize hot paths that cannot make use of normal entity cache loading
* due to use of a non-unique-field-based {@link EntityLoader} method like `loadManyByFieldEqualityConjunctionAsync` or
* due to use of a non-unique-field-based EntityLoader method like `loadManyByFieldEqualityConjunctionAsync` or
* `loadManyByRawWhereClauseAsync`.
*/
export default abstract class EntitySecondaryCacheLoader<
@@ -8,7 +8,7 @@ import { filterMap, zipToMap } from './utils/collections/maps';
/**
* A custom secondary read-through entity cache is a way to add a custom second layer of caching for a particular
* single entity load. One common way this may be used is to add a second layer of caching in a hot path that makes
* a call to {@link EntityLoader.loadManyByFieldEqualityConjunctionAsync} is guaranteed to return at most one entity.
* a call to EntityLoader.loadManyByFieldEqualityConjunctionAsync is guaranteed to return at most one entity.
*/
export default abstract class GenericSecondaryEntityCache<TFields, TLoadParams>
implements ISecondaryEntityCache<TFields, TLoadParams>
+1 -1
View File
@@ -82,7 +82,7 @@ export default abstract class ReadonlyEntity<
}
/**
* @returns {@link EntityAssociationLoader} for this entity
* @returns EntityAssociationLoader for this entity
*/
associationLoader(): EntityAssociationLoader<
TFields,
+2 -2
View File
@@ -7,9 +7,9 @@ import ViewerScopedEntityCompanion from './ViewerScopedEntityCompanion';
import ViewerScopedEntityCompanionProvider from './ViewerScopedEntityCompanionProvider';
/**
* A viewer context encapsulates all information necessary to evaluate an {@link EntityPrivacyPolicy}.
* A viewer context encapsulates all information necessary to evaluate an EntityPrivacyPolicy.
*
* In combination with an {@link EntityCompanionProvider}, a viewer context is the
* In combination with an EntityCompanionProvider, a viewer context is the
* entry point into the Entity framework.
*/
export default class ViewerContext {
@@ -8,7 +8,7 @@ import ViewerScopedEntityMutatorFactory from './ViewerScopedEntityMutatorFactory
import IEntityMetricsAdapter from './metrics/IEntityMetricsAdapter';
/**
* Provides a simpler API for loading and mutating entities by injecting the {@link ViewerContext}
* Provides a simpler API for loading and mutating entities by injecting the ViewerContext
* from the viewer-scoped entity companion provider.
*/
export default class ViewerScopedEntityCompanion<
@@ -79,7 +79,7 @@ export default class ViewerScopedEntityCompanion<
}
/**
* Get the {@link IEntityMetricsAdapter} for this companion.
* Get the IEntityMetricsAdapter for this companion.
*/
getMetricsAdapter(): IEntityMetricsAdapter {
return this.entityCompanion.getMetricsAdapter();
@@ -6,7 +6,7 @@ import ReadonlyEntity from './ReadonlyEntity';
import ViewerContext from './ViewerContext';
/**
* Provides a cleaner API for loading entities by passing through the {@link ViewerContext}.
* Provides a cleaner API for loading entities by passing through the ViewerContext.
*/
export default class ViewerScopedEntityLoaderFactory<
TFields,
@@ -1,12 +1,12 @@
import { CreateMutator, UpdateMutator, DeleteMutator } from './EntityMutator';
import EntityMutatorFactory from './EntityMutatorFactory';
import EntityPrivacyPolicy, { EntityPrivacyPolicyEvaluationContext } from './EntityPrivacyPolicy';
import EntityPrivacyPolicy from './EntityPrivacyPolicy';
import { EntityQueryContext } from './EntityQueryContext';
import ReadonlyEntity from './ReadonlyEntity';
import ViewerContext from './ViewerContext';
/**
* Provides a cleaner API for mutating entities by passing through the {@link ViewerContext}.
* Provides a cleaner API for mutating entities by passing through the ViewerContext.
*/
export default class ViewerScopedEntityMutatorFactory<
TFields,
@@ -35,37 +35,22 @@ export default class ViewerScopedEntityMutatorFactory<
) {}
forCreate(
queryContext: EntityQueryContext,
privacyPolicyEvaluationContext: EntityPrivacyPolicyEvaluationContext
queryContext: EntityQueryContext
): CreateMutator<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy, TSelectedFields> {
return this.entityMutatorFactory.forCreate(
this.viewerContext,
queryContext,
privacyPolicyEvaluationContext
);
return this.entityMutatorFactory.forCreate(this.viewerContext, queryContext);
}
forUpdate(
existingEntity: TEntity,
queryContext: EntityQueryContext,
privacyPolicyEvaluationContext: EntityPrivacyPolicyEvaluationContext
queryContext: EntityQueryContext
): UpdateMutator<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy, TSelectedFields> {
return this.entityMutatorFactory.forUpdate(
existingEntity,
queryContext,
privacyPolicyEvaluationContext
);
return this.entityMutatorFactory.forUpdate(existingEntity, queryContext);
}
forDelete(
existingEntity: TEntity,
queryContext: EntityQueryContext,
privacyPolicyEvaluationContext: EntityPrivacyPolicyEvaluationContext
queryContext: EntityQueryContext
): DeleteMutator<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy, TSelectedFields> {
return this.entityMutatorFactory.forDelete(
existingEntity,
queryContext,
privacyPolicyEvaluationContext
);
return this.entityMutatorFactory.forDelete(existingEntity, queryContext);
}
}
@@ -95,7 +95,7 @@ class TestLocalCacheAdapter<TFields> extends EntityCacheAdapter<TFields> {
fieldValue: NonNullable<TFields[N]>
): string {
const columnName = this.entityConfiguration.entityToDBFieldsKeyMapping.get(fieldName);
invariant(columnName, `database field mapping missing for ${fieldName}`);
invariant(columnName, `database field mapping missing for ${String(fieldName)}`);
const parts = [
this.entityConfiguration.tableName,
`${this.entityConfiguration.cacheKeyVersion}`,
@@ -162,11 +162,11 @@ describe(ComposedEntityCacheAdapter, () => {
});
it('returns fallback adapter results primary is empty', async () => {
const { primaryCacheAdapter, cacheAdapter } = makeTestCacheAdapters();
const { fallbackCacheAdapter, cacheAdapter } = makeTestCacheAdapters();
const cacheHits = new Map<string, Readonly<BlahFields>>([['test-id-1', { id: 'test-id-1' }]]);
await primaryCacheAdapter.cacheManyAsync('id', cacheHits);
await primaryCacheAdapter.cacheDBMissesAsync('id', ['test-id-2']);
await fallbackCacheAdapter.cacheManyAsync('id', cacheHits);
await fallbackCacheAdapter.cacheDBMissesAsync('id', ['test-id-2']);
const results = await cacheAdapter.loadManyAsync('id', [
'test-id-1',
@@ -183,6 +183,45 @@ describe(ComposedEntityCacheAdapter, () => {
expect(results.size).toBe(3);
});
it('populates primary adapter with fallback adapter results', async () => {
const { primaryCacheAdapter, fallbackCacheAdapter, cacheAdapter } = makeTestCacheAdapters();
const cacheHits = new Map<string, Readonly<BlahFields>>([['test-id-1', { id: 'test-id-1' }]]);
await fallbackCacheAdapter.cacheManyAsync('id', cacheHits);
await fallbackCacheAdapter.cacheDBMissesAsync('id', ['test-id-2']);
// should populate primary cache with fallback cache results
await cacheAdapter.loadManyAsync('id', ['test-id-1', 'test-id-2', 'test-id-3']);
const primaryResults = await primaryCacheAdapter.loadManyAsync('id', [
'test-id-1',
'test-id-2',
'test-id-3',
]);
expect(primaryResults.get('test-id-1')).toMatchObject({
status: CacheStatus.HIT,
item: { id: 'test-id-1' },
});
expect(primaryResults.get('test-id-2')).toMatchObject({ status: CacheStatus.NEGATIVE });
expect(primaryResults.get('test-id-3')).toMatchObject({ status: CacheStatus.MISS });
expect(primaryResults.size).toBe(3);
// ensure that populating the primary cache doesn't change the output
const composedResults = await cacheAdapter.loadManyAsync('id', [
'test-id-1',
'test-id-2',
'test-id-3',
]);
expect(composedResults.get('test-id-1')).toMatchObject({
status: CacheStatus.HIT,
item: { id: 'test-id-1' },
});
expect(composedResults.get('test-id-2')).toMatchObject({ status: CacheStatus.NEGATIVE });
expect(composedResults.get('test-id-3')).toMatchObject({ status: CacheStatus.MISS });
expect(composedResults.size).toBe(3);
});
it('returns empty map when passed empty array of fieldValues', async () => {
const { cacheAdapter } = makeTestCacheAdapters();
const results = await cacheAdapter.loadManyAsync('id', []);
+293 -38
View File
@@ -1,3 +1,5 @@
import invariant from 'invariant';
import Entity from '../Entity';
import { EntityCompanionDefinition } from '../EntityCompanionProvider';
import EntityConfiguration from '../EntityConfiguration';
@@ -33,9 +35,13 @@ interface GrandChildFields {
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
const makeEntityClasses = (edgeDeletionBehavior: EntityEdgeDeletionBehavior) => {
const triggerExecutionCounts = {
ParentEntity: 0,
ChildEntity: 0,
GrandChildEntity: 0,
ParentEntityDeletion: 0,
ChildEntityDeletion: 0,
GrandChildEntityDeletion: 0,
ParentEntityUpdate: 0,
ChildEntityUpdate: 0,
GrandChildEntityUpdate: 0,
};
const privacyPolicyEvaluationRecords = {
@@ -107,7 +113,7 @@ const makeEntityClasses = (edgeDeletionBehavior: EntityEdgeDeletionBehavior) =>
];
}
class ParentCheckInfoTrigger extends EntityMutationTrigger<
class ParentCheckInfoDeletionTrigger extends EntityMutationTrigger<
ParentFields,
string,
TestViewerContext,
@@ -119,19 +125,37 @@ const makeEntityClasses = (edgeDeletionBehavior: EntityEdgeDeletionBehavior) =>
_entity: ParentEntity,
mutationInfo: EntityTriggerMutationInfo<ParentFields, string, TestViewerContext, ParentEntity>
): Promise<void> {
if (mutationInfo.type !== EntityMutationType.DELETE) {
return;
}
invariant(mutationInfo.type === EntityMutationType.DELETE, 'invalid EntityMutationType');
if (mutationInfo.cascadingDeleteCause !== null) {
throw new Error('Parent entity should not have casade delete cause');
}
triggerExecutionCounts.ParentEntity++;
triggerExecutionCounts.ParentEntityDeletion++;
}
}
class ChildCheckInfoTrigger extends EntityMutationTrigger<
class ParentCheckInfoUpdateTrigger extends EntityMutationTrigger<
ParentFields,
string,
TestViewerContext,
ParentEntity
> {
async executeAsync(
_viewerContext: TestViewerContext,
_queryContext: EntityTransactionalQueryContext,
_entity: ParentEntity,
mutationInfo: EntityTriggerMutationInfo<ParentFields, string, TestViewerContext, ParentEntity>
): Promise<void> {
invariant(mutationInfo.type === EntityMutationType.UPDATE, 'invalid EntityMutationType');
if (mutationInfo.cascadingDeleteCause !== null) {
throw new Error('Parent entity should not have casade delete cause');
}
triggerExecutionCounts.ParentEntityUpdate++;
}
}
class ChildCheckInfoDeletionTrigger extends EntityMutationTrigger<
ChildFields,
string,
TestViewerContext,
@@ -143,10 +167,7 @@ const makeEntityClasses = (edgeDeletionBehavior: EntityEdgeDeletionBehavior) =>
_entity: ChildEntity,
mutationInfo: EntityTriggerMutationInfo<ChildFields, string, TestViewerContext, ChildEntity>
): Promise<void> {
if (mutationInfo.type !== EntityMutationType.DELETE) {
return;
}
invariant(mutationInfo.type === EntityMutationType.DELETE, 'invalid EntityMutationType');
if (mutationInfo.cascadingDeleteCause === null) {
throw new Error('Child entity should have casade delete cause');
}
@@ -162,11 +183,43 @@ const makeEntityClasses = (edgeDeletionBehavior: EntityEdgeDeletionBehavior) =>
throw new Error('Child entity should not have two-level casade delete cause');
}
triggerExecutionCounts.ChildEntity++;
triggerExecutionCounts.ChildEntityDeletion++;
}
}
class GrandChildCheckInfoTrigger extends EntityMutationTrigger<
class ChildCheckInfoUpdateTrigger extends EntityMutationTrigger<
ChildFields,
string,
TestViewerContext,
ChildEntity
> {
async executeAsync(
_viewerContext: TestViewerContext,
_queryContext: EntityTransactionalQueryContext,
_entity: ChildEntity,
mutationInfo: EntityTriggerMutationInfo<ChildFields, string, TestViewerContext, ChildEntity>
): Promise<void> {
invariant(mutationInfo.type === EntityMutationType.UPDATE, 'invalid EntityMutationType');
if (mutationInfo.cascadingDeleteCause === null) {
throw new Error('Child entity should have casade delete cause');
}
const cascadingDeleteCauseEntity = mutationInfo.cascadingDeleteCause.entity;
if (!(cascadingDeleteCauseEntity instanceof ParentEntity)) {
throw new Error('Child entity should have casade delete cause entity of type ParentEntity');
}
const secondLevelCascadingDeleteCause =
mutationInfo.cascadingDeleteCause.cascadingDeleteCause;
if (secondLevelCascadingDeleteCause) {
throw new Error('Child entity should not have two-level casade delete cause');
}
triggerExecutionCounts.ChildEntityUpdate++;
}
}
class GrandChildCheckInfoDeletionTrigger extends EntityMutationTrigger<
GrandChildFields,
string,
TestViewerContext,
@@ -183,10 +236,7 @@ const makeEntityClasses = (edgeDeletionBehavior: EntityEdgeDeletionBehavior) =>
GrandChildEntity
>
): Promise<void> {
if (mutationInfo.type !== EntityMutationType.DELETE) {
return;
}
invariant(mutationInfo.type === EntityMutationType.DELETE, 'invalid EntityMutationType');
if (mutationInfo.cascadingDeleteCause === null) {
throw new Error('GrandChild entity should have cascade delete cause');
}
@@ -216,7 +266,58 @@ const makeEntityClasses = (edgeDeletionBehavior: EntityEdgeDeletionBehavior) =>
throw new Error('GrandChild entity should not have three-level casade delete cause');
}
triggerExecutionCounts.GrandChildEntity++;
triggerExecutionCounts.GrandChildEntityDeletion++;
}
}
class GrandChildCheckInfoUpdateTrigger extends EntityMutationTrigger<
GrandChildFields,
string,
TestViewerContext,
GrandChildEntity
> {
async executeAsync(
_viewerContext: TestViewerContext,
_queryContext: EntityTransactionalQueryContext,
_entity: GrandChildEntity,
mutationInfo: EntityTriggerMutationInfo<
GrandChildFields,
string,
TestViewerContext,
GrandChildEntity
>
): Promise<void> {
invariant(mutationInfo.type === EntityMutationType.UPDATE, 'invalid EntityMutationType');
if (mutationInfo.cascadingDeleteCause === null) {
throw new Error('GrandChild entity should have cascade delete cause');
}
const cascadingDeleteCauseEntity = mutationInfo.cascadingDeleteCause.entity;
if (!(cascadingDeleteCauseEntity instanceof ChildEntity)) {
throw new Error(
'GrandChild entity should have cascade delete cause entity of type ChildEntity'
);
}
const secondLevelCascadingDeleteCause =
mutationInfo.cascadingDeleteCause.cascadingDeleteCause;
if (!secondLevelCascadingDeleteCause) {
throw new Error('GrandChild entity should have two-level casade delete cause');
}
const secondLevelCascadingDeleteCauseEntity = secondLevelCascadingDeleteCause.entity;
if (!(secondLevelCascadingDeleteCauseEntity instanceof ParentEntity)) {
throw new Error(
'GrandChild entity should have second level casade delete cause entity of type ParentEntity'
);
}
const thirdLevelCascadingDeleteCause = secondLevelCascadingDeleteCause.cascadingDeleteCause;
if (thirdLevelCascadingDeleteCause) {
throw new Error('GrandChild entity should not have three-level casade delete cause');
}
triggerExecutionCounts.GrandChildEntityUpdate++;
}
}
@@ -318,8 +419,11 @@ const makeEntityClasses = (edgeDeletionBehavior: EntityEdgeDeletionBehavior) =>
entityConfiguration: parentEntityConfiguration,
privacyPolicyClass: TestEntityPrivacyPolicy,
mutationTriggers: () => ({
beforeDelete: [new ParentCheckInfoTrigger()],
afterDelete: [new ParentCheckInfoTrigger()],
beforeDelete: [new ParentCheckInfoDeletionTrigger()],
afterDelete: [new ParentCheckInfoDeletionTrigger()],
beforeUpdate: [new ParentCheckInfoUpdateTrigger()],
afterUpdate: [new ParentCheckInfoUpdateTrigger()],
}),
});
@@ -328,8 +432,11 @@ const makeEntityClasses = (edgeDeletionBehavior: EntityEdgeDeletionBehavior) =>
entityConfiguration: childEntityConfiguration,
privacyPolicyClass: TestEntityPrivacyPolicy,
mutationTriggers: () => ({
beforeDelete: [new ChildCheckInfoTrigger()],
afterDelete: [new ChildCheckInfoTrigger()],
beforeDelete: [new ChildCheckInfoDeletionTrigger()],
afterDelete: [new ChildCheckInfoDeletionTrigger()],
beforeUpdate: [new ChildCheckInfoUpdateTrigger()],
afterUpdate: [new ChildCheckInfoUpdateTrigger()],
}),
});
@@ -338,8 +445,11 @@ const makeEntityClasses = (edgeDeletionBehavior: EntityEdgeDeletionBehavior) =>
entityConfiguration: grandChildEntityConfiguration,
privacyPolicyClass: TestEntityPrivacyPolicy,
mutationTriggers: () => ({
beforeDelete: [new GrandChildCheckInfoTrigger()],
afterDelete: [new GrandChildCheckInfoTrigger()],
beforeDelete: [new GrandChildCheckInfoDeletionTrigger()],
afterDelete: [new GrandChildCheckInfoDeletionTrigger()],
beforeUpdate: [new GrandChildCheckInfoUpdateTrigger()],
afterUpdate: [new GrandChildCheckInfoUpdateTrigger()],
}),
});
@@ -399,9 +509,13 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
// two calls for each trigger, one beforeDelete, one afterDelete
expect(triggerExecutionCounts).toMatchObject({
ParentEntity: 2,
ChildEntity: 2,
GrandChildEntity: 2,
ParentEntityDeletion: 2,
ChildEntityDeletion: 2,
GrandChildEntityDeletion: 2,
ParentEntityUpdate: 0,
ChildEntityUpdate: 0,
GrandChildEntityUpdate: 0,
});
expect(privacyPolicyEvaluationRecords).toMatchObject({
@@ -518,9 +632,13 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
// two calls for only parent trigger, one beforeDelete, one afterDelete
// when using set null the descendants aren't deleted
expect(triggerExecutionCounts).toMatchObject({
ParentEntity: 2,
ChildEntity: 0,
GrandChildEntity: 0,
ParentEntityDeletion: 2,
ChildEntityDeletion: 0,
GrandChildEntityDeletion: 0,
ParentEntityUpdate: 0,
ChildEntityUpdate: 2,
GrandChildEntityUpdate: 0,
});
expect(privacyPolicyEvaluationRecords).toMatchObject({
@@ -573,7 +691,7 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
});
});
describe('EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE', () => {
describe('EntityEdgeDeletionBehavior.SET_NULL_INVALIDATE_CACHE_ONLY', () => {
it('invalidates the cache', async () => {
const {
ParentEntity,
@@ -581,7 +699,140 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
GrandChildEntity,
triggerExecutionCounts,
privacyPolicyEvaluationRecords,
} = makeEntityClasses(EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE);
} = makeEntityClasses(EntityEdgeDeletionBehavior.SET_NULL_INVALIDATE_CACHE_ONLY);
const companionProvider = createUnitTestEntityCompanionProvider();
const viewerContext = new TestViewerContext(companionProvider);
const parent = await ParentEntity.creator(viewerContext).enforceCreateAsync();
const child = await ChildEntity.creator(viewerContext)
.setField('parent_id', parent.getID())
.enforceCreateAsync();
const grandchild = await GrandChildEntity.creator(viewerContext)
.setField('parent_id', child.getID())
.enforceCreateAsync();
await expect(
ParentEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(parent.getID())
).resolves.not.toBeNull();
await expect(
ChildEntity.loader(viewerContext)
.enforcing()
.loadByFieldEqualingAsync('parent_id', parent.getID())
).resolves.not.toBeNull();
await expect(
GrandChildEntity.loader(viewerContext)
.enforcing()
.loadByFieldEqualingAsync('parent_id', child.getID())
).resolves.not.toBeNull();
const childCacheAdapter = viewerContext.getViewerScopedEntityCompanionForClass(ChildEntity)[
'entityCompanion'
]['tableDataCoordinator']['cacheAdapter'] as InMemoryFullCacheStubCacheAdapter<ChildFields>;
const childCachedBefore = await childCacheAdapter.loadManyAsync('parent_id', [
parent.getID(),
]);
expect(childCachedBefore.get(parent.getID())?.status).toEqual(CacheStatus.HIT);
const grandChildCacheAdapter = viewerContext.getViewerScopedEntityCompanionForClass(
GrandChildEntity
)['entityCompanion']['tableDataCoordinator'][
'cacheAdapter'
] as InMemoryFullCacheStubCacheAdapter<ChildFields>;
const grandChildCachedBefore = await grandChildCacheAdapter.loadManyAsync('parent_id', [
child.getID(),
]);
expect(grandChildCachedBefore.get(child.getID())?.status).toEqual(CacheStatus.HIT);
privacyPolicyEvaluationRecords.shouldRecord = true;
await ParentEntity.enforceDeleteAsync(parent);
privacyPolicyEvaluationRecords.shouldRecord = false;
const childCachedAfter = await childCacheAdapter.loadManyAsync('parent_id', [parent.getID()]);
expect(childCachedAfter.get(parent.getID())?.status).toEqual(CacheStatus.MISS);
const grandChildCachedAfter = await grandChildCacheAdapter.loadManyAsync('parent_id', [
child.getID(),
]);
expect(grandChildCachedAfter.get(child.getID())?.status).toEqual(CacheStatus.HIT);
await expect(
ParentEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(parent.getID())
).resolves.toBeNull();
const loadedChild = await ChildEntity.loader(viewerContext)
.enforcing()
.loadByIDAsync(child.getID());
expect(loadedChild).not.toBeNull();
const loadedGrandchild = await GrandChildEntity.loader(viewerContext)
.enforcing()
.loadByIDAsync(grandchild.getID());
expect(loadedGrandchild.getField('parent_id')).toEqual(loadedChild.getID());
// two calls for only parent trigger, one beforeDelete, one afterDelete
// when using set null the descendants aren't deleted
expect(triggerExecutionCounts).toMatchObject({
ParentEntityDeletion: 2,
ChildEntityDeletion: 0,
GrandChildEntityDeletion: 0,
ParentEntityUpdate: 0,
ChildEntityUpdate: 2,
GrandChildEntityUpdate: 0,
});
expect(privacyPolicyEvaluationRecords).toMatchObject({
ParentEntity: {
[EntityAuthorizationAction.CREATE]: [],
[EntityAuthorizationAction.READ]: [],
[EntityAuthorizationAction.UPDATE]: [],
// one DELETE auth action for parent (since it's being deleted)
[EntityAuthorizationAction.DELETE]: [{ cascadingDeleteCause: null }],
},
ChildEntity: {
[EntityAuthorizationAction.CREATE]: [],
// one READ auth action for child in order to update via cascade
// no other entities are read since it is not cascaded past first entity
[EntityAuthorizationAction.READ]: [
{
cascadingDeleteCause: {
entity: expect.any(ParentEntity),
cascadingDeleteCause: null,
},
},
],
// one UPDATE to set null
[EntityAuthorizationAction.UPDATE]: [
{
cascadingDeleteCause: {
entity: expect.any(ParentEntity),
cascadingDeleteCause: null,
},
},
],
[EntityAuthorizationAction.DELETE]: [],
},
GrandChildEntity: {
[EntityAuthorizationAction.CREATE]: [],
[EntityAuthorizationAction.READ]: [],
[EntityAuthorizationAction.UPDATE]: [],
[EntityAuthorizationAction.DELETE]: [],
},
});
});
});
describe('EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE_ONLY', () => {
it('invalidates the cache', async () => {
const {
ParentEntity,
ChildEntity,
GrandChildEntity,
triggerExecutionCounts,
privacyPolicyEvaluationRecords,
} = makeEntityClasses(EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE_ONLY);
const companionProvider = createUnitTestEntityCompanionProvider();
const viewerContext = new TestViewerContext(companionProvider);
@@ -650,9 +901,13 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
// two calls for each trigger, one beforeDelete, one afterDelete
expect(triggerExecutionCounts).toMatchObject({
ParentEntity: 2,
ChildEntity: 2,
GrandChildEntity: 2,
ParentEntityDeletion: 2,
ChildEntityDeletion: 2,
GrandChildEntityDeletion: 2,
ParentEntityUpdate: 0,
ChildEntityUpdate: 0,
GrandChildEntityUpdate: 0,
});
expect(privacyPolicyEvaluationRecords).toMatchObject({
@@ -341,7 +341,6 @@ describe(EntityMutatorFactory, () => {
describe('forCreate', () => {
it('creates entities', async () => {
const viewerContext = mock<ViewerContext>();
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const queryContext = StubQueryContextProvider.getQueryContext();
const id1 = uuidv4();
@@ -365,7 +364,7 @@ describe(EntityMutatorFactory, () => {
},
]);
const newEntity = await entityMutatorFactory
.forCreate(viewerContext, queryContext, privacyPolicyEvaluationContext)
.forCreate(viewerContext, queryContext)
.setField('stringField', 'huh')
.enforceCreateAsync();
expect(newEntity).toBeTruthy();
@@ -373,7 +372,6 @@ describe(EntityMutatorFactory, () => {
it('checks privacy', async () => {
const viewerContext = mock<ViewerContext>();
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const queryContext = StubQueryContextProvider.getQueryContext();
const id1 = uuidv4();
@@ -400,7 +398,7 @@ describe(EntityMutatorFactory, () => {
const spiedPrivacyPolicy = spy(privacyPolicy);
await entityMutatorFactory
.forCreate(viewerContext, queryContext, privacyPolicyEvaluationContext)
.forCreate(viewerContext, queryContext)
.setField('stringField', 'huh')
.enforceCreateAsync();
@@ -408,7 +406,7 @@ describe(EntityMutatorFactory, () => {
spiedPrivacyPolicy.authorizeCreateAsync(
viewerContext,
anyOfClass(EntityTransactionalQueryContext),
privacyPolicyEvaluationContext,
deepEqual({ cascadingDeleteCause: null }),
anyOfClass(TestEntity),
anything()
)
@@ -417,7 +415,6 @@ describe(EntityMutatorFactory, () => {
it('executes triggers', async () => {
const viewerContext = mock<ViewerContext>();
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const queryContext = StubQueryContextProvider.getQueryContext();
const id1 = uuidv4();
@@ -444,7 +441,7 @@ describe(EntityMutatorFactory, () => {
const triggerSpies = setUpMutationTriggerSpies(mutationTriggers);
await entityMutatorFactory
.forCreate(viewerContext, queryContext, privacyPolicyEvaluationContext)
.forCreate(viewerContext, queryContext)
.setField('stringField', 'huh')
.enforceCreateAsync();
@@ -465,7 +462,6 @@ describe(EntityMutatorFactory, () => {
it('executes validators', async () => {
const viewerContext = mock<ViewerContext>();
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const queryContext = StubQueryContextProvider.getQueryContext();
const id1 = uuidv4();
@@ -492,7 +488,7 @@ describe(EntityMutatorFactory, () => {
const validatorSpies = setUpMutationValidatorSpies(mutationValidators);
await entityMutatorFactory
.forCreate(viewerContext, queryContext, privacyPolicyEvaluationContext)
.forCreate(viewerContext, queryContext)
.setField('stringField', 'huh')
.enforceCreateAsync();
@@ -534,7 +530,7 @@ describe(EntityMutatorFactory, () => {
);
const updatedEntity = await entityMutatorFactory
.forUpdate(existingEntity, queryContext, privacyPolicyEvaluationContext)
.forUpdate(existingEntity, queryContext)
.setField('stringField', 'huh2')
.enforceUpdateAsync();
@@ -586,7 +582,7 @@ describe(EntityMutatorFactory, () => {
);
await entityMutatorFactory
.forUpdate(existingEntity, queryContext, privacyPolicyEvaluationContext)
.forUpdate(existingEntity, queryContext)
.setField('stringField', 'huh2')
.enforceUpdateAsync();
@@ -594,7 +590,7 @@ describe(EntityMutatorFactory, () => {
spiedPrivacyPolicy.authorizeUpdateAsync(
viewerContext,
anyOfClass(EntityTransactionalQueryContext),
privacyPolicyEvaluationContext,
deepEqual({ cascadingDeleteCause: null }),
anyOfClass(TestEntity),
anything()
)
@@ -637,7 +633,7 @@ describe(EntityMutatorFactory, () => {
);
await entityMutatorFactory
.forUpdate(existingEntity, queryContext, privacyPolicyEvaluationContext)
.forUpdate(existingEntity, queryContext)
.setField('stringField', 'huh2')
.enforceUpdateAsync();
@@ -652,7 +648,11 @@ describe(EntityMutatorFactory, () => {
beforeDelete: false,
afterDelete: false,
},
{ type: EntityMutationType.UPDATE, previousValue: existingEntity }
{
type: EntityMutationType.UPDATE,
previousValue: existingEntity,
cascadingDeleteCause: null,
}
);
});
it('executes validators', async () => {
@@ -691,13 +691,14 @@ describe(EntityMutatorFactory, () => {
);
await entityMutatorFactory
.forUpdate(existingEntity, queryContext, privacyPolicyEvaluationContext)
.forUpdate(existingEntity, queryContext)
.setField('stringField', 'huh2')
.enforceUpdateAsync();
verifyValidatorCounts(viewerContext, validatorSpies, 1, {
type: EntityMutationType.UPDATE,
previousValue: existingEntity,
cascadingDeleteCause: null,
});
});
});
@@ -727,9 +728,7 @@ describe(EntityMutatorFactory, () => {
);
expect(existingEntity).toBeTruthy();
await entityMutatorFactory
.forDelete(existingEntity, queryContext, privacyPolicyEvaluationContext)
.enforceDeleteAsync();
await entityMutatorFactory.forDelete(existingEntity, queryContext).enforceDeleteAsync();
await expect(
enforceAsyncResult(
@@ -766,9 +765,7 @@ describe(EntityMutatorFactory, () => {
.loadByIDAsync(id1)
);
await entityMutatorFactory
.forDelete(existingEntity, queryContext, privacyPolicyEvaluationContext)
.enforceDeleteAsync();
await entityMutatorFactory.forDelete(existingEntity, queryContext).enforceDeleteAsync();
verify(
spiedPrivacyPolicy.authorizeDeleteAsync(
@@ -807,9 +804,7 @@ describe(EntityMutatorFactory, () => {
.loadByIDAsync(id1)
);
await entityMutatorFactory
.forDelete(existingEntity, queryContext, privacyPolicyEvaluationContext)
.enforceDeleteAsync();
await entityMutatorFactory.forDelete(existingEntity, queryContext).enforceDeleteAsync();
verifyTriggerCounts(
viewerContext,
@@ -852,9 +847,7 @@ describe(EntityMutatorFactory, () => {
.loadByIDAsync(id1)
);
await entityMutatorFactory
.forDelete(existingEntity, queryContext, privacyPolicyEvaluationContext)
.enforceDeleteAsync();
await entityMutatorFactory.forDelete(existingEntity, queryContext).enforceDeleteAsync();
verifyValidatorCounts(viewerContext, validatorSpies, 0, {
type: EntityMutationType.DELETE as any,
@@ -888,7 +881,7 @@ describe(EntityMutatorFactory, () => {
await enforceAsyncResult(
entityMutatorFactory
.forCreate(viewerContext, queryContext, privacyPolicyEvaluationContext)
.forCreate(viewerContext, queryContext)
.setField('stringField', 'huh')
.createAsync()
);
@@ -903,7 +896,6 @@ describe(EntityMutatorFactory, () => {
it('throws error when field not valid', async () => {
const viewerContext = mock<ViewerContext>();
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const queryContext = StubQueryContextProvider.getQueryContext();
const id1 = uuidv4();
const { entityMutatorFactory } = createEntityMutatorFactory([
@@ -919,19 +911,19 @@ describe(EntityMutatorFactory, () => {
await expect(
entityMutatorFactory
.forCreate(viewerContext, queryContext, privacyPolicyEvaluationContext)
.forCreate(viewerContext, queryContext)
.setField('stringField', 10 as any)
.createAsync()
).rejects.toThrowError('Entity field not valid: TestEntity (stringField = 10)');
const createdEntity = await entityMutatorFactory
.forCreate(viewerContext, queryContext, privacyPolicyEvaluationContext)
.forCreate(viewerContext, queryContext)
.setField('stringField', 'hello')
.enforceCreateAsync();
await expect(
entityMutatorFactory
.forUpdate(createdEntity, queryContext, privacyPolicyEvaluationContext)
.forUpdate(createdEntity, queryContext)
.setField('stringField', 10 as any)
.updateAsync()
).rejects.toThrowError('Entity field not valid: TestEntity (stringField = 10)');
@@ -939,7 +931,6 @@ describe(EntityMutatorFactory, () => {
it('returns error result when not authorized to create', async () => {
const viewerContext = instance(mock(ViewerContext));
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const queryContext = StubQueryContextProvider.getQueryContext();
const privacyPolicyMock = mock(SimpleTestEntityPrivacyPolicy);
const databaseAdapter = instance(mock<EntityDatabaseAdapter<SimpleTestFields>>());
@@ -999,7 +990,7 @@ describe(EntityMutatorFactory, () => {
);
const entityCreateResult = await entityMutatorFactory
.forCreate(viewerContext, queryContext, privacyPolicyEvaluationContext)
.forCreate(viewerContext, queryContext)
.createAsync();
expect(entityCreateResult.ok).toBe(false);
expect(entityCreateResult.reason).toEqual(rejectionError);
@@ -1011,14 +1002,14 @@ describe(EntityMutatorFactory, () => {
});
const entityUpdateResult = await entityMutatorFactory
.forUpdate(fakeEntity, queryContext, privacyPolicyEvaluationContext)
.forUpdate(fakeEntity, queryContext)
.updateAsync();
expect(entityUpdateResult.ok).toBe(false);
expect(entityUpdateResult.reason).toEqual(rejectionError);
expect(entityUpdateResult.value).toBe(undefined);
const entityDeleteResult = await entityMutatorFactory
.forDelete(fakeEntity, queryContext, privacyPolicyEvaluationContext)
.forDelete(fakeEntity, queryContext)
.deleteAsync();
expect(entityDeleteResult.ok).toBe(false);
expect(entityDeleteResult.reason).toEqual(rejectionError);
@@ -1027,7 +1018,6 @@ describe(EntityMutatorFactory, () => {
it('throws error when db adapter throws', async () => {
const viewerContext = instance(mock(ViewerContext));
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const queryContext = StubQueryContextProvider.getQueryContext();
const privacyPolicy = instance(mock(SimpleTestEntityPrivacyPolicy));
const databaseAdapterMock = mock<EntityDatabaseAdapter<SimpleTestFields>>();
@@ -1083,48 +1073,37 @@ describe(EntityMutatorFactory, () => {
});
await expect(
entityMutatorFactory
.forCreate(viewerContext, queryContext, privacyPolicyEvaluationContext)
.createAsync()
entityMutatorFactory.forCreate(viewerContext, queryContext).createAsync()
).rejects.toEqual(rejectionError);
await expect(
entityMutatorFactory
.forUpdate(fakeEntity, queryContext, privacyPolicyEvaluationContext)
.updateAsync()
entityMutatorFactory.forUpdate(fakeEntity, queryContext).updateAsync()
).rejects.toEqual(rejectionError);
await expect(
entityMutatorFactory
.forDelete(fakeEntity, queryContext, privacyPolicyEvaluationContext)
.deleteAsync()
entityMutatorFactory.forDelete(fakeEntity, queryContext).deleteAsync()
).rejects.toEqual(rejectionError);
});
it('records metrics appropriately', async () => {
const viewerContext = mock<ViewerContext>();
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const queryContext = StubQueryContextProvider.getQueryContext();
const { entityMutatorFactory, metricsAdapter } = createEntityMutatorFactory([]);
const spiedMetricsAdapter = spy(metricsAdapter);
const newEntity = await enforceAsyncResult(
entityMutatorFactory
.forCreate(viewerContext, queryContext, privacyPolicyEvaluationContext)
.forCreate(viewerContext, queryContext)
.setField('stringField', 'huh')
.createAsync()
);
await enforceAsyncResult(
entityMutatorFactory
.forUpdate(newEntity, queryContext, privacyPolicyEvaluationContext)
.forUpdate(newEntity, queryContext)
.setField('stringField', 'wat')
.updateAsync()
);
await enforceAsyncResult(
entityMutatorFactory
.forDelete(newEntity, queryContext, privacyPolicyEvaluationContext)
.deleteAsync()
);
await enforceAsyncResult(entityMutatorFactory.forDelete(newEntity, queryContext).deleteAsync());
verify(
spiedMetricsAdapter.logMutatorMutationEvent(
@@ -213,7 +213,7 @@ describe('EntityEdgeDeletionBehavior.SET_NULL', () => {
describe('EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE', () => {
it('invalidates the cache', async () => {
const { CategoryEntity } = makeEntityClass(
EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE
EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE_ONLY
);
const companionProvider = createUnitTestEntityCompanionProvider();
@@ -283,7 +283,7 @@ describe('EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE', () => {
it('handles cycles', async () => {
const { CategoryEntity } = makeEntityClass(
EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE
EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE_ONLY
);
const companionProvider = createUnitTestEntityCompanionProvider();
@@ -1,7 +1,6 @@
import { mock, instance, verify } from 'ts-mockito';
import EntityMutatorFactory from '../EntityMutatorFactory';
import { EntityPrivacyPolicyEvaluationContext } from '../EntityPrivacyPolicy';
import { EntityQueryContext } from '../EntityQueryContext';
import ViewerContext from '../ViewerContext';
import ViewerScopedEntityMutatorFactory from '../ViewerScopedEntityMutatorFactory';
@@ -10,7 +9,6 @@ import TestEntity, { TestFields, TestEntityPrivacyPolicy } from '../testfixtures
describe(ViewerScopedEntityMutatorFactory, () => {
it('correctly scopes viewer to entity mutations', async () => {
const viewerContext = instance(mock(ViewerContext));
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const queryContext = instance(mock(EntityQueryContext));
const baseMutatorFactory =
mock<
@@ -27,10 +25,8 @@ describe(ViewerScopedEntityMutatorFactory, () => {
keyof TestFields
>(baseMutatorFactoryInstance, viewerContext);
viewerScopedEntityLoader.forCreate(queryContext, privacyPolicyEvaluationContext);
viewerScopedEntityLoader.forCreate(queryContext);
verify(
baseMutatorFactory.forCreate(viewerContext, queryContext, privacyPolicyEvaluationContext)
).once();
verify(baseMutatorFactory.forCreate(viewerContext, queryContext)).once();
});
});
@@ -34,6 +34,6 @@ export default class EntityInvalidFieldValueError<
fieldName: N,
fieldValue?: TFields[N]
) {
super(`Entity field not valid: ${entityClass.name} (${fieldName} = ${fieldValue})`);
super(`Entity field not valid: ${entityClass.name} (${String(fieldName)} = ${fieldValue})`);
}
}
@@ -34,6 +34,6 @@ export default class EntityNotFoundError<
fieldName: N,
fieldValue: TFields[N]
) {
super(`Entity not found: ${entityClass.name} (${fieldName} = ${fieldValue})`);
super(`Entity not found: ${entityClass.name} (${String(fieldName)} = ${fieldValue})`);
}
}
@@ -3,6 +3,7 @@ import DataLoader from 'dataloader';
import EntityDatabaseAdapter, {
FieldEqualityCondition,
QuerySelectionModifiers,
QuerySelectionModifiersWithOrderByRaw,
} from '../EntityDatabaseAdapter';
import { EntityQueryContext } from '../EntityQueryContext';
import EntityQueryContextProvider from '../EntityQueryContextProvider';
@@ -11,15 +12,18 @@ import {
timeAndLogLoadEventAsync,
timeAndLogLoadMapEventAsync,
} from '../metrics/EntityMetricsUtils';
import IEntityMetricsAdapter, { EntityMetricsLoadType } from '../metrics/IEntityMetricsAdapter';
import IEntityMetricsAdapter, {
EntityMetricsLoadType,
IncrementLoadCountEventType,
} from '../metrics/IEntityMetricsAdapter';
import { computeIfAbsent, zipToMap } from '../utils/collections/maps';
import ReadThroughEntityCache from './ReadThroughEntityCache';
/**
* A data manager is responsible for orchestrating multiple sources of entity
* data including local caches, {@link EntityCacheAdapter}, and {@link EntityDatabaseAdapter}.
* data including local caches, EntityCacheAdapter, and EntityDatabaseAdapter.
*
* It is also responsible for invalidating all sources of data when mutated using {@link EntityMutator}.
* It is also responsible for invalidating all sources of data when mutated using EntityMutator.
*/
export default class EntityDataManager<TFields> {
private readonly fieldDataLoaders: Map<
@@ -57,7 +61,8 @@ export default class EntityDataManager<TFields> {
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<ReadonlyMap<NonNullable<TFields[N]>, readonly Readonly<TFields>[]>> {
this.metricsAdapter.incrementDataManagerCacheLoadCount({
this.metricsAdapter.incrementDataManagerLoadCount({
type: IncrementLoadCountEventType.CACHE,
fieldValueCount: fieldValues.length,
entityClassName: this.entityClassName,
});
@@ -65,7 +70,8 @@ export default class EntityDataManager<TFields> {
fieldName,
fieldValues,
async (fetcherValues) => {
this.metricsAdapter.incrementDataManagerDatabaseLoadCount({
this.metricsAdapter.incrementDataManagerLoadCount({
type: IncrementLoadCountEventType.DATABASE,
fieldValueCount: fieldValues.length,
entityClassName: this.entityClassName,
});
@@ -108,7 +114,9 @@ export default class EntityDataManager<TFields> {
);
if (nullOrUndefinedValueIndex >= 0) {
throw new Error(
`Invalid load: ${this.entityClassName} (${fieldName} = ${fieldValues[nullOrUndefinedValueIndex]})`
`Invalid load: ${this.entityClassName} (${String(fieldName)} = ${
fieldValues[nullOrUndefinedValueIndex]
})`
);
}
@@ -117,7 +125,8 @@ export default class EntityDataManager<TFields> {
return await this.databaseAdapter.fetchManyWhereAsync(queryContext, fieldName, fieldValues);
}
this.metricsAdapter.incrementDataManagerDataloaderLoadCount({
this.metricsAdapter.incrementDataManagerLoadCount({
type: IncrementLoadCountEventType.DATALOADER,
fieldValueCount: fieldValues.length,
entityClassName: this.entityClassName,
});
@@ -165,14 +174,14 @@ export default class EntityDataManager<TFields> {
* @param queryContext - query context in which to perform the load
* @param rawWhereClause - parameterized SQL WHERE clause with positional binding placeholders or named binding placeholders
* @param bindings - array of positional bindings or object of named bindings
* @param querySelectionModifiers - limit, offset, and orderBy for the query
* @param querySelectionModifiers - limit, offset, orderBy, and orderByRaw for the query
* @returns array of objects matching the query
*/
async loadManyByRawWhereClauseAsync(
queryContext: EntityQueryContext,
rawWhereClause: string,
bindings: any[] | object,
querySelectionModifiers: QuerySelectionModifiers<TFields>
querySelectionModifiers: QuerySelectionModifiersWithOrderByRaw<TFields>
): Promise<readonly Readonly<TFields>[]> {
return await timeAndLogLoadEventAsync(
this.metricsAdapter,
@@ -24,7 +24,7 @@ export const getDatabaseFieldForEntityField = <TFields>(
entityField: keyof TFields
): string => {
const databaseField = entityConfiguration.entityToDBFieldsKeyMapping.get(entityField);
invariant(databaseField, `database field mapping missing for ${entityField}`);
invariant(databaseField, `database field mapping missing for ${String(entityField)}`);
return databaseField!;
};
@@ -23,8 +23,8 @@ export type CacheLoadResult<TFields> =
};
/**
* A read-through entity cache is responsible for coordinating {@link EntityDatabaseAdapter} and
* {@link EntityCacheAdapter} within the {@link EntityDataManager}.
* A read-through entity cache is responsible for coordinating EntityDatabaseAdapter and
* EntityCacheAdapter within the EntityDataManager.
*/
export default class ReadThroughEntityCache<TFields> {
constructor(
@@ -102,7 +102,9 @@ export default class ReadThroughEntityCache<TFields> {
// TODO(wschurman): emit or throw here since console may not be available
// eslint-disable-next-line no-console
console.warn(
`unique key ${fieldName} in ${this.entityConfiguration.tableName} returned multiple rows for ${fieldValue}`
`unique key ${String(fieldName)} in ${
this.entityConfiguration.tableName
} returned multiple rows for ${fieldValue}`
);
continue;
}
@@ -4,7 +4,6 @@ import {
when,
anything,
verify,
anyNumber,
objectContaining,
spy,
anyString,
@@ -13,7 +12,10 @@ import {
} from 'ts-mockito';
import EntityDatabaseAdapter from '../../EntityDatabaseAdapter';
import IEntityMetricsAdapter, { EntityMetricsLoadType } from '../../metrics/IEntityMetricsAdapter';
import IEntityMetricsAdapter, {
EntityMetricsLoadType,
IncrementLoadCountEventType,
} from '../../metrics/IEntityMetricsAdapter';
import NoOpEntityMetricsAdapter from '../../metrics/NoOpEntityMetricsAdapter';
import TestEntity, { testEntityConfiguration, TestFields } from '../../testfixtures/TestEntity';
import {
@@ -519,24 +521,27 @@ describe(EntityDataManager, () => {
).once();
verify(
metricsAdapterMock.incrementDataManagerDataloaderLoadCount(
metricsAdapterMock.incrementDataManagerLoadCount(
deepEqual({
type: IncrementLoadCountEventType.DATALOADER,
fieldValueCount: 1,
entityClassName: TestEntity.name,
})
)
).once();
verify(
metricsAdapterMock.incrementDataManagerCacheLoadCount(
metricsAdapterMock.incrementDataManagerLoadCount(
deepEqual({
type: IncrementLoadCountEventType.CACHE,
fieldValueCount: 1,
entityClassName: TestEntity.name,
})
)
).once();
verify(
metricsAdapterMock.incrementDataManagerDatabaseLoadCount(
metricsAdapterMock.incrementDataManagerLoadCount(
deepEqual({
type: IncrementLoadCountEventType.DATABASE,
fieldValueCount: 1,
entityClassName: TestEntity.name,
})
@@ -565,9 +570,7 @@ describe(EntityDataManager, () => {
)
).once();
verify(metricsAdapterMock.incrementDataManagerDataloaderLoadCount(anyNumber())).never();
verify(metricsAdapterMock.incrementDataManagerCacheLoadCount(anyNumber())).never();
verify(metricsAdapterMock.incrementDataManagerDatabaseLoadCount(anyNumber())).never();
verify(metricsAdapterMock.incrementDataManagerLoadCount(anything())).never();
});
it('throws when a load-by value is null or undefined', async () => {
@@ -9,10 +9,28 @@ export enum EntityMetricsLoadType {
LOAD_MANY_RAW,
}
/**
* Event about a single call to an EntityLoader method.
*/
export interface EntityMetricsLoadEvent {
/**
* EntityMetricsLoadType for this load.
*/
type: EntityMetricsLoadType;
/**
* Class name of the Entity being loaded.
*/
entityClassName: string;
/**
* Total duration of this load, including fetch and construction of entities.
*/
duration: number;
/**
* Number of entities returned for this load.
*/
count: number;
}
@@ -23,13 +41,57 @@ export enum EntityMetricsMutationType {
}
export interface EntityMetricsMutationEvent {
/**
* EntityMetricsMutationType for this mutation.
*/
type: EntityMetricsMutationType;
/**
* Class name of the Entity being mutated.
*/
entityClassName: string;
/**
* Total duration of this mutation.
*/
duration: number;
}
export enum IncrementLoadCountEventType {
/**
* Type for when a dataloader load is initiated via the standard load methods
* since all loads go through a dataloader.
*/
DATALOADER,
/**
* Type for when a cache load is initiated due to a dataloader miss.
*/
CACHE,
/**
* Type for when a database load is initiated due to a dataloader and cache miss, when an entity query doesn't support caching, or during a transaction.
*/
DATABASE,
}
/**
* Event used to record dataloader, cache, and database load counts in EntityDataManager.
*/
export interface IncrementLoadCountEvent {
/**
* Type of this event.
*/
type: IncrementLoadCountEventType;
/**
* Number of field values being loaded for this call.
*/
fieldValueCount: number;
/**
* Class name of the Entity being loaded.
*/
entityClassName: string;
}
@@ -38,7 +100,13 @@ export enum EntityMetricsAuthorizationResult {
ALLOW,
}
/**
* Event used to record a singe EntityPrivacyPolicy authorization.
*/
export interface EntityMetricsAuthorizationEvent {
/**
* Class name of the Entity being authorized.
*/
entityClassName: string;
action: EntityAuthorizationAction;
evaluationResult: EntityMetricsAuthorizationResult;
@@ -51,7 +119,7 @@ export interface EntityMetricsAuthorizationEvent {
*/
export default interface IEntityMetricsAdapter {
/**
* Called when a {@link EntityPrivacyPolicy} authorization succeeds or fails.
* Called when a EntityPrivacyPolicy authorization succeeds or fails.
* @param authorizationEvent - info about the authorization event
*/
logAuthorizationEvent(authorizationEvent: EntityMetricsAuthorizationEvent): void;
@@ -69,25 +137,10 @@ export default interface IEntityMetricsAdapter {
logMutatorMutationEvent(mutationEvent: EntityMetricsMutationEvent): void;
/**
* Called when a dataloader load is initiated via the standard
* load methods (not equality conjunction or raw).
* Called when a dataloader, cache, or database load is initiated via the standard
* load methods (not equality conjunction or raw). Most commonly used for logging
* a waterfall to determine dataloader and cache hit rates and ratios.
* @param fieldValueCount - count of field values being loaded for a field
*/
incrementDataManagerDataloaderLoadCount(incrementLoadCountEvent: IncrementLoadCountEvent): void;
/**
* Called when a cache load is initiated via the standard
* load methods (not equality conjunction or raw). Occurs upon a dataloader
* miss.
* @param fieldValueCount - count of field values being loaded for a field
*/
incrementDataManagerCacheLoadCount(incrementLoadCountEvent: IncrementLoadCountEvent): void;
/**
* Called when a database load is initiated via the standard
* load methods (not equality conjunction or raw). Occurs upon a cache
* miss or when fetching an uncacheable field.
* @param fieldValueCount - count of field values being loaded for a field
*/
incrementDataManagerDatabaseLoadCount(incrementLoadCountEvent: IncrementLoadCountEvent): void;
incrementDataManagerLoadCount(incrementLoadCountEvent: IncrementLoadCountEvent): void;
}
@@ -9,9 +9,5 @@ export default class NoOpEntityMetricsAdapter implements IEntityMetricsAdapter {
logAuthorizationEvent(_authorizationEvent: EntityMetricsAuthorizationEvent): void {}
logDataManagerLoadEvent(_loadEvent: EntityMetricsLoadEvent): void {}
logMutatorMutationEvent(_mutationEvent: EntityMetricsMutationEvent): void {}
incrementDataManagerDataloaderLoadCount(
_incrementLoadCountEvent: IncrementLoadCountEvent
): void {}
incrementDataManagerCacheLoadCount(_incrementLoadCountEvent: IncrementLoadCountEvent): void {}
incrementDataManagerDatabaseLoadCount(_incrementLoadCountEvent: IncrementLoadCountEvent): void {}
incrementDataManagerLoadCount(_incrementLoadCountEvent: IncrementLoadCountEvent): void {}
}
@@ -32,7 +32,7 @@ export enum RuleEvaluationResult {
* rule in the privacy policy. If all rules in the policy SKIP, the policy is denied.
*
* Returning DENY from a rule is useful in a few notable cases:
* - Preventing a CRUD action on an entity ({@link AlwaysDenyPrivacyPolicyRule})
* - Preventing a CRUD action on an entity (AlwaysDenyPrivacyPolicyRule)
* - Blocking. For example, a user blocks another user from seeing their posts, and the rule
* would be named something like `DenyIfViewerHasBeenBlockedPrivacyPolicyRule`.
*/
@@ -60,7 +60,7 @@ export const mapMapAsync = async function <K, V, M>(
/**
* Create a new Map by associating the value of each key with mapper executed for each key in the source map.
* The opposite of {@link mapMap}. In the event two source keys map to the same result key, the second source key's
* The opposite of mapMap. In the event two source keys map to the same result key, the second source key's
* value will overwrite the first, in which case the cardinality of the returned map may be smaller than the
* source map's.
*
@@ -17,7 +17,7 @@ export interface Case<
entity: TEntity;
}
type CaseMap<
export type CaseMap<
TFields,
TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext,
@@ -158,7 +158,10 @@ export default class StubDatabaseAdapter<T> extends EntityDatabaseAdapter<T> {
private generateRandomID(): any {
const idSchemaField = this.entityConfiguration2.schema.get(this.entityConfiguration2.idField);
invariant(idSchemaField, `No schema field found for ${this.entityConfiguration2.idField}`);
invariant(
idSchemaField,
`No schema field found for ${String(this.entityConfiguration2.idField)}`
);
if (idSchemaField instanceof StringField) {
return uuidv4();
} else if (idSchemaField instanceof IntField) {
@@ -1,6 +1,6 @@
import EntityQueryContextProvider from '../../EntityQueryContextProvider';
class StubQueryContextProvider extends EntityQueryContextProvider {
export class StubQueryContextProvider extends EntityQueryContextProvider {
protected getQueryInterface(): any {
return {};
}
+3618 -4163
View File
File diff suppressed because it is too large. Load diff