Compare commits

...
Author SHA1 Message Date
Will Schurman 59fc77810a v0.37.0 2024-06-12 09:56:29 -07:00
Will Schurman 8c5f81bcca fix: update barrels (#241) 2024-06-12 09:54:39 -07:00
Will Schurman 28e3afb62b chore: update CHANGELOG.md (#240) 2024-06-11 21:35:08 -07:00
Will Schurman f17a33c428 v0.36.0 2024-06-11 21:29:05 -07:00
Will Schurman 93905be81c chore: move entity loader utils into their own object (#239) 2024-06-11 21:04:20 -07:00
Will Schurman 2edc7af7e2 BREAKING CHANGE: make using non-enforcing entity loader explicit (#238) 2024-06-11 20:50:49 -07:00
Josh McFarlin 6569486ecd feat: Add global mutation trigger field to EntityCompanionProvider (#215)
* Add global mutation trigger field to EntityCompanionProvider

* Add globalMutationTrigger to additional test cases

* merge mutation triggers outside of EntityMutator

* Exclude triggers with empty arrays
2024-06-11 12:24:16 -07:00
Will Schurman 778d9e304e chore: upgrade dev dependencies and knex (#237) 2024-06-04 16:01:56 -07:00
Will Schurman 05726d45fa fix: disallow keys of JS Object prototype for safety (#236) 2024-06-04 15:41:13 -07:00
Will Schurman 7e2cea1697 fix: constrain entity fields type to string-keyed object (#235) 2024-06-04 15:35:13 -07:00
Will Schurman 7c3c9854a4 fix: always reload entity after update since cascading changes may have changed it since commit (#233) 2024-06-04 11:19:22 -07:00
Josh McFarlin 1e8ea64017 feat: Use uuid v7 in stub database adapter (#234) 2024-06-03 14:14:59 -07:00
Will Schurman af495a9a85 feat: add previousValue to privacy policy context for updates (#232) 2024-05-31 13:14:32 -07:00
Will Schurman 60fc9a4c4b feat: make canViewerDeleteAsync recursive (#224) 2024-05-31 12:41:36 -07:00
Will Schurman 14bd959e53 chore: Update README.md 2024-05-30 10:17:03 -07:00
Will Schurman 91d46ba49f chore: add test coverage for remaining code (#231) 2024-05-30 10:11:35 -07:00
Will Schurman 16e7c21b1c chore: add more tests for stub database adapter and test utils (#230) 2024-05-30 10:11:02 -07:00
Will Schurman 3ce0ac91f8 chore: add more tests for entity loader (#229) 2024-05-30 10:10:29 -07:00
Will Schurman 428cc6fc5c chore: add more tests for privacy policy rules and nested transaction query contexts (#228) 2024-05-30 10:09:57 -07:00
Will Schurman 8490ad5a19 chore: add more test coverage to postgres transaction isolation levels (#227) 2024-05-30 10:09:16 -07:00
Will Schurman 8a448b5c42 chore: Update README.md 2024-05-28 12:19:23 -07:00
Will Schurman 3fd8a92cc8 chore: Update README.md 2024-05-28 12:18:37 -07:00
Will Schurman b5998063a6 chore: set codecov default branch (#226) 2024-05-28 12:12:15 -07:00
Will Schurman 8fc0849618 chore: ignore entity-example package from codecov (#225) 2024-05-28 12:01:19 -07:00
174 changed files with 6831 additions and 3404 deletions

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
{
"directory": "./src",
"location": "top",
"exclude": ["tests__", "testfixtures"],
"singleQuotes": true,
"exportDefault": true,
"delete": true
}
+4 -8
View File
@@ -9,15 +9,11 @@ jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 18.x
- name: Cache Node.js modules
uses: actions/cache@v1
with:
path: ${{ github.workspace }}/node_modules
key: ${{ runner.OS }}-node_modules-${{ hashFiles('yarn.lock') }}
cache: yarn
node-version-file: package.json
- run: yarn --frozen-lockfile
- run: yarn typedoc
- name: Deploy
+4 -8
View File
@@ -9,17 +9,13 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- uses: actions/setup-node@v1
- uses: actions/setup-node@v4
with:
node-version: 18.x
- name: Cache Node.js modules
uses: actions/cache@v1
with:
path: ${{ github.workspace }}/node_modules
key: ${{ runner.OS }}-node_modules-${{ hashFiles('yarn.lock') }}
cache: yarn
node-version-file: package.json
- run: yarn --frozen-lockfile
- run: yarn lint -- -- --max-warnings=0
- run: yarn test -- -- --coverage
+8 -1
View File
@@ -23,12 +23,19 @@ node_modules/
.yarn-integrity
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.pnp.*
# [yarn](https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored)
**/.yarn/*
!**/.yarn/plugins
!**/.yarn/releases
!**/.yarn/sdks
!**/.yarn/versions
**/.pnp.*
# Entity-specific ignores
build/
+36
View File
@@ -3,6 +3,42 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.37.0](https://github.com/expo/entity/compare/v0.36.0...v0.37.0) (2024-06-12)
### Bug Fixes
* update barrels ([#241](https://github.com/expo/entity/issues/241)) ([8c5f81b](https://github.com/expo/entity/commit/8c5f81bcca107e9b7bbea5f0ab41b0105057806e))
# [0.36.0](https://github.com/expo/entity/compare/v0.35.0...v0.36.0) (2024-06-12)
### Breaking Changes
* make using non-enforcing entity loader explicit ([#238](https://github.com/expo/entity/issues/238)) ([2edc7af](https://github.com/expo/entity/commit/2edc7af7e20c362688ff2ee674fa2245205d6de4))
### Bug Fixes
* always reload entity after update since cascading changes may have changed it since commit ([#233](https://github.com/expo/entity/issues/233)) ([7c3c985](https://github.com/expo/entity/commit/7c3c9854a4dd91d4b73ebdb18bdeadea8b63f4c7))
* constrain entity fields type to string-keyed object ([#235](https://github.com/expo/entity/issues/235)) ([7e2cea1](https://github.com/expo/entity/commit/7e2cea16973a0ae1917f867cd25d6ef7c8eaecef))
* disallow keys of JS Object prototype for safety ([#236](https://github.com/expo/entity/issues/236)) ([05726d4](https://github.com/expo/entity/commit/05726d45fa9b91a6b25ddbc9c7e3e097b02097ca))
### Features
* Add global mutation trigger field to EntityCompanionProvider ([#215](https://github.com/expo/entity/issues/215)) ([6569486](https://github.com/expo/entity/commit/6569486ecddf2fd33c626f634c761e06636b1730))
* add previousValue to privacy policy context for updates ([#232](https://github.com/expo/entity/issues/232)) ([af495a9](https://github.com/expo/entity/commit/af495a9a856a5042f00cfd150348e415139cb85e))
* Add StrictEnumField with better validation ([#222](https://github.com/expo/entity/issues/222)) ([8753252](https://github.com/expo/entity/commit/87532525d090b3a67f1cedd95ac990f7dcb3ab5d))
* make canViewerDeleteAsync recursive ([#224](https://github.com/expo/entity/issues/224)) ([60fc9a4](https://github.com/expo/entity/commit/60fc9a4c4b587b866f2f5a1b3bd22145bc77e6e9))
* Use uuid v7 in stub database adapter ([#234](https://github.com/expo/entity/issues/234)) ([1e8ea64](https://github.com/expo/entity/commit/1e8ea64017430a2ce5ea197678bd78713ad77ecd))
* move entity loader utils into their own object ([#239](https://github.com/expo/entity/issues/239))([93905be](https://github.com/expo/entity/commit/93905be81cf74c5626ca19358794261c17223f95))
# [0.35.0](https://github.com/expo/entity/compare/v0.34.0...v0.35.0) (2024-04-11)
+1 -1
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/main/graph/badge.svg)](https://codecov.io/gh/expo/entity)
[![codecov](https://codecov.io/gh/expo/entity/graph/badge.svg?token=4cFAAdINbk)](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)
+4
View File
@@ -0,0 +1,4 @@
codecov:
branch: main
ignore:
- "packages/entity-example"
+1 -1
View File
@@ -4,5 +4,5 @@
],
"npmClient": "yarn",
"useWorkspaces": true,
"version": "0.35.0"
"version": "0.37.0"
}
+17 -14
View File
@@ -19,27 +19,27 @@
"@expo/batcher": "^1.0.0",
"@expo/entity": "file:packages/entity",
"@expo/results": "^1.0.0",
"@types/invariant": "^2.2.33",
"@types/jest": "^29.5.2",
"@types/invariant": "^2.2.37",
"@types/jest": "^29.5.12",
"@types/jsbn": "^1.2.29",
"@types/lru-cache": "^5.1.1",
"@types/node": "^16.18.34",
"@types/node": "^20.14.1",
"@types/uuid": "^8.3.0",
"barrelsby": "^2.2.0",
"eslint": "^8.14.0",
"eslint-config-universe": "^11.2.0",
"eslint-plugin-tsdoc": "^0.2.17",
"ioredis": "^5.2.5",
"jest": "^29.1.2",
"barrelsby": "^2.8.1",
"eslint": "^8.56.0",
"eslint-config-universe": "^13.0.0",
"eslint-plugin-tsdoc": "^0.3.0",
"ioredis": "^5.4.1",
"jest": "^29.7.0",
"lerna": "^5.4.0",
"lru-cache": "^6.0.0",
"nullthrows": "^1.1.1",
"pg": "8.10.0",
"prettier": "^2.8.8",
"ts-jest": "^29.1.0",
"pg": "8.12.0",
"prettier": "^3.3.0",
"ts-jest": "^29.1.4",
"ts-mockito": "^2.6.1",
"typedoc": "^0.24.8",
"typescript": "^5.1.3",
"typedoc": "^0.25.13",
"typescript": "^5.4.5",
"uuid": "^8.3.0"
},
"dependencies": {
@@ -50,5 +50,8 @@
"@expo/entity-ip-address-field": "file:packages/entity-ip-address-field",
"@expo/entity-secondary-cache-local-memory": "file:packages/entity-secondary-cache-local-memory",
"@expo/entity-secondary-cache-redis": "file:packages/entity-secondary-cache-redis"
},
"volta": {
"node": "20.14.0"
}
}
@@ -3,6 +3,28 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.37.0](https://github.com/expo/entity/compare/v0.36.0...v0.37.0) (2024-06-12)
### Bug Fixes
* update barrels ([#241](https://github.com/expo/entity/issues/241)) ([8c5f81b](https://github.com/expo/entity/commit/8c5f81bcca107e9b7bbea5f0ab41b0105057806e))
# [0.36.0](https://github.com/expo/entity/compare/v0.35.0...v0.36.0) (2024-06-12)
### Bug Fixes
* constrain entity fields type to string-keyed object ([#235](https://github.com/expo/entity/issues/235)) ([7e2cea1](https://github.com/expo/entity/commit/7e2cea16973a0ae1917f867cd25d6ef7c8eaecef))
# [0.35.0](https://github.com/expo/entity/compare/v0.34.0...v0.35.0) (2024-04-11)
@@ -1,6 +1,6 @@
{
"name": "@expo/entity-cache-adapter-local-memory",
"version": "0.35.0",
"version": "0.37.0",
"description": "Cross-request local memory cache adapter for @expo/entity",
"files": [
"build",
@@ -16,7 +16,7 @@
"test": "jest --rootDir . --config ../../resources/jest.config.js",
"integration": "../../resources/run-with-docker yarn integration-no-setup",
"integration-no-setup": "jest --config ../../resources/jest-integration.config.js --rootDir . --runInBand --passWithNoTests",
"barrelsby": "barrelsby --directory src --location top --exclude tests__ --singleQuotes --exportDefault --delete"
"barrelsby": "barrelsby --config ../../.barrelsby.json"
},
"engines": {
"node": ">=16"
@@ -33,6 +33,6 @@
"lru-cache": "^6.0.0"
},
"devDependencies": {
"@expo/entity": "^0.35.0"
"@expo/entity": "^0.37.0"
}
}
@@ -13,14 +13,16 @@ export const DOES_NOT_EXIST_LOCAL_MEMORY_CACHE = Symbol('doesNotExist');
type LocalMemoryCacheValue<TFields> = Readonly<TFields> | typeof DOES_NOT_EXIST_LOCAL_MEMORY_CACHE;
export type LocalMemoryCache<TFields> = LRUCache<string, LocalMemoryCacheValue<TFields>>;
export default class GenericLocalMemoryCacher<TFields> implements IEntityGenericCacher<TFields> {
export default class GenericLocalMemoryCacher<TFields extends Record<string, any>>
implements IEntityGenericCacher<TFields>
{
constructor(
private readonly entityConfiguration: EntityConfiguration<TFields>,
private readonly localMemoryCache: LocalMemoryCache<TFields>
private readonly localMemoryCache: LocalMemoryCache<TFields>,
) {}
static createLRUCache<TFields>(
options: { maxSize?: number; ttlSeconds?: number } = {}
options: { maxSize?: number; ttlSeconds?: number } = {},
): LocalMemoryCache<TFields> {
const DEFAULT_LRU_CACHE_MAX_AGE_SECONDS = 10;
const DEFAULT_LRU_CACHE_SIZE = 10000;
@@ -40,7 +42,7 @@ export default class GenericLocalMemoryCacher<TFields> implements IEntityGeneric
}
public async loadManyAsync(
keys: readonly string[]
keys: readonly string[],
): Promise<ReadonlyMap<string, CacheLoadResult<TFields>>> {
const cacheResults = new Map<string, CacheLoadResult<TFields>>();
for (const key of keys) {
@@ -83,7 +85,7 @@ export default class GenericLocalMemoryCacher<TFields> implements IEntityGeneric
public makeCacheKey<N extends keyof TFields>(
fieldName: N,
fieldValue: NonNullable<TFields[N]>
fieldValue: NonNullable<TFields[N]>,
): string {
const columnName = this.entityConfiguration.entityToDBFieldsKeyMapping.get(fieldName);
invariant(columnName, `database field mapping missing for ${String(fieldName)}`);
@@ -96,7 +98,7 @@ export default class GenericLocalMemoryCacher<TFields> implements IEntityGeneric
const delimiter = ':';
const escapedParts = parts.map((part) =>
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`)
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`),
);
return escapedParts.join(delimiter);
}
@@ -18,7 +18,7 @@ export default class LocalMemoryCacheAdapterProvider implements IEntityCacheAdap
*/
static createNoOpProvider(): IEntityCacheAdapterProvider {
return new LocalMemoryCacheAdapterProvider(<TFields>() =>
GenericLocalMemoryCacher.createNoOpCache<TFields>()
GenericLocalMemoryCacher.createNoOpCache<TFields>(),
);
}
@@ -26,26 +26,26 @@ export default class LocalMemoryCacheAdapterProvider implements IEntityCacheAdap
* @returns a local memory cache adapter provider configured with the supplied options.
*/
static createProviderWithOptions(
options: { maxSize?: number; ttlSeconds?: number } = {}
options: { maxSize?: number; ttlSeconds?: number } = {},
): IEntityCacheAdapterProvider {
return new LocalMemoryCacheAdapterProvider(<TFields>() =>
GenericLocalMemoryCacher.createLRUCache<TFields>(options)
GenericLocalMemoryCacher.createLRUCache<TFields>(options),
);
}
private localMemoryCacheAdapterMap = new Map<string, GenericEntityCacheAdapter<any>>();
private constructor(
private readonly localMemoryCacheCreator: <TFields>() => LocalMemoryCache<TFields>
private readonly localMemoryCacheCreator: <TFields>() => LocalMemoryCache<TFields>,
) {}
public getCacheAdapter<TFields>(
entityConfiguration: EntityConfiguration<TFields>
public getCacheAdapter<TFields extends Record<string, any>>(
entityConfiguration: EntityConfiguration<TFields>,
): IEntityCacheAdapter<TFields> {
return computeIfAbsent(this.localMemoryCacheAdapterMap, entityConfiguration.tableName, () => {
const localMemoryCache = this.localMemoryCacheCreator<TFields>();
return new GenericEntityCacheAdapter(
new GenericLocalMemoryCacher(entityConfiguration, localMemoryCache)
new GenericLocalMemoryCacher(entityConfiguration, localMemoryCache),
);
});
}
@@ -45,7 +45,7 @@ describe(GenericLocalMemoryCacher, () => {
'localMemoryCacheAdapterMap'
].get(
viewerContext.entityCompanionProvider.getCompanionForEntity(LocalMemoryTestEntity)
.entityCompanionDefinition.entityConfiguration.tableName
.entityCompanionDefinition.entityConfiguration.tableName,
)!['genericCacher'];
const cachedResult = await entitySpecificGenericCacher.loadManyAsync([
cacheKeyMaker('id', entity1.getID()),
@@ -63,9 +63,9 @@ describe(GenericLocalMemoryCacher, () => {
// simulate non existent db fetch, should write negative result ('') to cache
const nonExistentId = uuidv4();
const entityNonExistentResult = await LocalMemoryTestEntity.loader(viewerContext).loadByIDAsync(
nonExistentId
);
const entityNonExistentResult = await LocalMemoryTestEntity.loader(viewerContext)
.withAuthorizationResults()
.loadByIDAsync(nonExistentId);
expect(entityNonExistentResult.ok).toBe(false);
const nonExistentCachedResult = await entitySpecificGenericCacher.loadManyAsync([
@@ -76,13 +76,15 @@ describe(GenericLocalMemoryCacher, () => {
});
// load again through entities framework to ensure it reads negative result
const entityNonExistentResult2 = await LocalMemoryTestEntity.loader(
viewerContext
).loadByIDAsync(nonExistentId);
const entityNonExistentResult2 = await LocalMemoryTestEntity.loader(viewerContext)
.withAuthorizationResults()
.loadByIDAsync(nonExistentId);
expect(entityNonExistentResult2.ok).toBe(false);
// invalidate from cache to ensure it invalidates correctly
await LocalMemoryTestEntity.loader(viewerContext).invalidateFieldsAsync(entity1.getAllFields());
await LocalMemoryTestEntity.loader(viewerContext)
.utils()
.invalidateFieldsAsync(entity1.getAllFields());
const cachedResultMiss = await entitySpecificGenericCacher.loadManyAsync([
cacheKeyMaker('id', entity1.getID()),
]);
@@ -120,7 +122,7 @@ describe(GenericLocalMemoryCacher, () => {
'localMemoryCacheAdapterMap'
].get(
viewerContext.entityCompanionProvider.getCompanionForEntity(LocalMemoryTestEntity)
.entityCompanionDefinition.entityConfiguration.tableName
.entityCompanionDefinition.entityConfiguration.tableName,
)!['genericCacher'];
const cachedResult = await entitySpecificGenericCacher.loadManyAsync([
cacheKeyMaker('id', entity1.getID()),
@@ -133,9 +135,9 @@ describe(GenericLocalMemoryCacher, () => {
// a non existent db fetch should try to write negative result ('') but it's a noop cache, so it should be a miss
const nonExistentId = uuidv4();
const entityNonExistentResult = await LocalMemoryTestEntity.loader(viewerContext).loadByIDAsync(
nonExistentId
);
const entityNonExistentResult = await LocalMemoryTestEntity.loader(viewerContext)
.withAuthorizationResults()
.loadByIDAsync(nonExistentId);
expect(entityNonExistentResult.ok).toBe(false);
const nonExistentCachedResult = await entitySpecificGenericCacher.loadManyAsync([
@@ -54,7 +54,10 @@ describe('Use within GenericEntityCacheAdapter', () => {
describe('loadManyAsync', () => {
it('returns appropriate cache results', async () => {
const cacheAdapter = new GenericEntityCacheAdapter(
new GenericLocalMemoryCacher(entityConfiguration, GenericLocalMemoryCacher.createLRUCache())
new GenericLocalMemoryCacher(
entityConfiguration,
GenericLocalMemoryCacher.createLRUCache(),
),
);
const cacheHits = new Map<string, Readonly<BlahFields>>([['test-id-1', { id: 'test-id-1' }]]);
@@ -78,7 +81,10 @@ describe('Use within GenericEntityCacheAdapter', () => {
it('returns empty map when passed empty array of fieldValues', async () => {
const cacheAdapter = new GenericEntityCacheAdapter(
new GenericLocalMemoryCacher(entityConfiguration, GenericLocalMemoryCacher.createLRUCache())
new GenericLocalMemoryCacher(
entityConfiguration,
GenericLocalMemoryCacher.createLRUCache(),
),
);
const results = await cacheAdapter.loadManyAsync('id', []);
expect(results).toEqual(new Map());
@@ -118,7 +124,7 @@ describe('Use within GenericEntityCacheAdapter', () => {
const localMemoryCache = GenericLocalMemoryCacher.createLRUCache<BlahFields>({});
const cacheAdapter = new GenericEntityCacheAdapter(
new GenericLocalMemoryCacher(entityConfiguration, localMemoryCache)
new GenericLocalMemoryCacher(entityConfiguration, localMemoryCache),
);
await cacheAdapter.cacheManyAsync('id', new Map([['test-id-1', { id: 'test-id-1' }]]));
await cacheAdapter.cacheDBMissesAsync('id', ['test-id-2']);
@@ -133,8 +139,8 @@ describe('Use within GenericEntityCacheAdapter', () => {
const cacheAdapter = new GenericEntityCacheAdapter(
new GenericLocalMemoryCacher(
entityConfiguration,
GenericLocalMemoryCacher.createLRUCache<BlahFields>({})
)
GenericLocalMemoryCacher.createLRUCache<BlahFields>({}),
),
);
await cacheAdapter.invalidateManyAsync('id', []);
});
@@ -10,7 +10,7 @@ import LocalMemoryCacheAdapterProvider from '../LocalMemoryCacheAdapterProvider'
export const createLocalMemoryTestEntityCompanionProvider = (
localMemoryOptions: { maxSize?: number; ttlSeconds?: number } = {},
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter()
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter(),
): EntityCompanionProvider => {
const localMemoryCacheAdapterProvider =
localMemoryOptions.maxSize === 0 && localMemoryOptions.ttlSeconds === 0
@@ -34,15 +34,15 @@ export const createLocalMemoryTestEntityCompanionProvider = (
cacheAdapterProvider: localMemoryCacheAdapterProvider,
},
],
])
]),
);
};
export const createNoOpLocalMemoryIntegrationTestEntityCompanionProvider = (
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter()
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter(),
): EntityCompanionProvider => {
return createLocalMemoryTestEntityCompanionProvider(
{ maxSize: 0, ttlSeconds: 0 },
metricsAdapter
metricsAdapter,
);
};
@@ -3,6 +3,28 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.37.0](https://github.com/expo/entity/compare/v0.36.0...v0.37.0) (2024-06-12)
### Bug Fixes
* update barrels ([#241](https://github.com/expo/entity/issues/241)) ([8c5f81b](https://github.com/expo/entity/commit/8c5f81bcca107e9b7bbea5f0ab41b0105057806e))
# [0.36.0](https://github.com/expo/entity/compare/v0.35.0...v0.36.0) (2024-06-12)
### Bug Fixes
* constrain entity fields type to string-keyed object ([#235](https://github.com/expo/entity/issues/235)) ([7e2cea1](https://github.com/expo/entity/commit/7e2cea16973a0ae1917f867cd25d6ef7c8eaecef))
# [0.35.0](https://github.com/expo/entity/compare/v0.34.0...v0.35.0) (2024-04-11)
@@ -1,6 +1,6 @@
{
"name": "@expo/entity-cache-adapter-redis",
"version": "0.35.0",
"version": "0.37.0",
"description": "Redis cache adapter for @expo/entity",
"files": [
"build",
@@ -16,7 +16,7 @@
"test": "jest --rootDir . --config ../../resources/jest.config.js",
"integration": "../../resources/run-with-docker yarn integration-no-setup",
"integration-no-setup": "jest --config ../../resources/jest-integration.config.js --rootDir . --runInBand --passWithNoTests",
"barrelsby": "barrelsby --directory src --location top --exclude tests__ --singleQuotes --exportDefault --delete"
"barrelsby": "barrelsby --config ../../.barrelsby.json"
},
"engines": {
"node": ">=16"
@@ -31,7 +31,7 @@
"ioredis": ">=5"
},
"devDependencies": {
"@expo/entity": "^0.35.0",
"@expo/entity": "^0.37.0",
"ioredis": "^5.2.5"
}
}
@@ -57,21 +57,23 @@ export interface GenericRedisCacheContext {
ttlSecondsNegative: number;
}
export default class GenericRedisCacher<TFields> implements IEntityGenericCacher<TFields> {
export default class GenericRedisCacher<TFields extends Record<string, any>>
implements IEntityGenericCacher<TFields>
{
constructor(
private readonly context: GenericRedisCacheContext,
private readonly entityConfiguration: EntityConfiguration<TFields>
private readonly entityConfiguration: EntityConfiguration<TFields>,
) {}
public async loadManyAsync(
keys: readonly string[]
keys: readonly string[],
): Promise<ReadonlyMap<string, CacheLoadResult<TFields>>> {
if (keys.length === 0) {
return new Map();
}
const redisResults = await wrapNativeRedisCallAsync(() =>
this.context.redisClient.mget(...keys)
this.context.redisClient.mget(...keys),
);
const results = new Map<string, CacheLoadResult<TFields>>();
@@ -89,7 +91,7 @@ export default class GenericRedisCacher<TFields> implements IEntityGenericCacher
item: transformCacheObjectToFields(
this.entityConfiguration,
redisTransformerMap,
JSON.parse(redisResult)
JSON.parse(redisResult),
),
});
} else {
@@ -111,10 +113,10 @@ export default class GenericRedisCacher<TFields> implements IEntityGenericCacher
redisTransaction = redisTransaction.set(
key,
JSON.stringify(
transformFieldsToCacheObject(this.entityConfiguration, redisTransformerMap, object)
transformFieldsToCacheObject(this.entityConfiguration, redisTransformerMap, object),
),
'EX',
this.context.ttlSecondsPositive
this.context.ttlSecondsPositive,
);
});
await wrapNativeRedisCallAsync(() => redisTransaction.exec());
@@ -131,7 +133,7 @@ export default class GenericRedisCacher<TFields> implements IEntityGenericCacher
key,
DOES_NOT_EXIST_REDIS,
'EX',
this.context.ttlSecondsNegative
this.context.ttlSecondsNegative,
);
});
await wrapNativeRedisCallAsync(() => redisTransaction.exec());
@@ -147,7 +149,7 @@ export default class GenericRedisCacher<TFields> implements IEntityGenericCacher
public makeCacheKey<N extends keyof TFields>(
fieldName: N,
fieldValue: NonNullable<TFields[N]>
fieldValue: NonNullable<TFields[N]>,
): string {
const columnName = this.entityConfiguration.entityToDBFieldsKeyMapping.get(fieldName);
invariant(columnName, `database field mapping missing for ${String(fieldName)}`);
@@ -156,7 +158,7 @@ export default class GenericRedisCacher<TFields> implements IEntityGenericCacher
this.entityConfiguration.tableName,
`v2.${this.entityConfiguration.cacheKeyVersion}`,
columnName,
String(fieldValue)
String(fieldValue),
);
}
}
@@ -10,8 +10,8 @@ import GenericRedisCacher, { GenericRedisCacheContext } from './GenericRedisCach
export default class RedisCacheAdapterProvider implements IEntityCacheAdapterProvider {
constructor(private readonly context: GenericRedisCacheContext) {}
getCacheAdapter<TFields>(
entityConfiguration: EntityConfiguration<TFields>
getCacheAdapter<TFields extends Record<string, any>>(
entityConfiguration: EntityConfiguration<TFields>,
): IEntityCacheAdapter<TFields> {
return new GenericEntityCacheAdapter(new GenericRedisCacher(this.context, entityConfiguration));
}
@@ -19,7 +19,7 @@ class BatchedRedis implements IRedis {
this.batchMgetAsync.bind(this),
{
maxBatchInterval: 0,
}
},
);
constructor(private readonly redis: Redis) {}
@@ -40,7 +40,7 @@ class BatchedRedis implements IRedis {
const result = keysToResults.get(key);
invariant(result !== undefined, 'result should not be undefined');
return result;
})
}),
);
}
@@ -69,7 +69,7 @@ describe(GenericRedisCacher, () => {
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`)
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`),
);
return escapedParts.join(delimiter);
},
@@ -90,7 +90,7 @@ describe(GenericRedisCacher, () => {
it('has correct caching behavior', async () => {
// simulate two requests
const viewerContext = new ViewerContext(
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext)
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext),
);
const mgetSpy = jest.spyOn(redis, 'mget');
@@ -108,13 +108,13 @@ describe(GenericRedisCacher, () => {
// loading an entity should put it in cache. load by multiple requests and multiple fields in same tick to ensure batch works
mgetSpy.mockClear();
const viewerContext1 = new ViewerContext(
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext)
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext),
);
const viewerContext2 = new ViewerContext(
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext)
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext),
);
const viewerContext3 = new ViewerContext(
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext)
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext),
);
const [entity1, entity2, entity3] = await Promise.all([
RedisTestEntity.loader(viewerContext1).enforcing().loadByIDAsync(entity1Created.getID()),
@@ -145,21 +145,23 @@ describe(GenericRedisCacher, () => {
// simulate non existent db fetch, should write negative result ('') to cache
const nonExistentId = uuidv4();
const entityNonExistentResult = await RedisTestEntity.loader(viewerContext).loadByIDAsync(
nonExistentId
);
const entityNonExistentResult = await RedisTestEntity.loader(viewerContext)
.withAuthorizationResults()
.loadByIDAsync(nonExistentId);
expect(entityNonExistentResult.ok).toBe(false);
const cacheKeyNonExistent = cacheKeyMaker('id', nonExistentId);
const nonExistentCachedValue = await redis.get(cacheKeyNonExistent);
expect(nonExistentCachedValue).toEqual('');
// load again through entities framework to ensure it reads negative result
const entityNonExistentResult2 = await RedisTestEntity.loader(viewerContext).loadByIDAsync(
nonExistentId
);
const entityNonExistentResult2 = await RedisTestEntity.loader(viewerContext)
.withAuthorizationResults()
.loadByIDAsync(nonExistentId);
expect(entityNonExistentResult2.ok).toBe(false);
// invalidate from cache to ensure it invalidates correctly in both caches
await RedisTestEntity.loader(viewerContext).invalidateFieldsAsync(entity1.getAllFields());
await RedisTestEntity.loader(viewerContext)
.utils()
.invalidateFieldsAsync(entity1.getAllFields());
await expect(redis.get(cacheKeyEntity1)).resolves.toBeNull();
await expect(redis.get(cacheKeyEntity1NameField)).resolves.toBeNull();
});
@@ -19,7 +19,7 @@ describe(GenericRedisCacher, () => {
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`)
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`),
);
return escapedParts.join(delimiter);
},
@@ -38,7 +38,7 @@ describe(GenericRedisCacher, () => {
it('has correct caching behavior', async () => {
const viewerContext = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext)
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext),
);
const genericCacher =
viewerContext.entityCompanionProvider.getCompanionForEntity(RedisTestEntity)[
@@ -56,7 +56,7 @@ describe(GenericRedisCacher, () => {
.loadByIDAsync(entity1Created.getID());
const cachedJSON = await (genericRedisCacheContext.redisClient as Redis).get(
cacheKeyMaker('id', entity1.getID())
cacheKeyMaker('id', entity1.getID()),
);
const cachedValue = JSON.parse(cachedJSON!);
expect(cachedValue).toMatchObject({
@@ -67,37 +67,39 @@ describe(GenericRedisCacher, () => {
// simulate non existent db fetch, should write negative result ('') to cache
const nonExistentId = uuidv4();
const entityNonExistentResult = await RedisTestEntity.loader(viewerContext).loadByIDAsync(
nonExistentId
);
const entityNonExistentResult = await RedisTestEntity.loader(viewerContext)
.withAuthorizationResults()
.loadByIDAsync(nonExistentId);
expect(entityNonExistentResult.ok).toBe(false);
const nonExistentCachedValue = await (genericRedisCacheContext.redisClient as Redis).get(
cacheKeyMaker('id', nonExistentId)
cacheKeyMaker('id', nonExistentId),
);
expect(nonExistentCachedValue).toEqual('');
// load again through entities framework to ensure it reads negative result
const entityNonExistentResult2 = await RedisTestEntity.loader(viewerContext).loadByIDAsync(
nonExistentId
);
const entityNonExistentResult2 = await RedisTestEntity.loader(viewerContext)
.withAuthorizationResults()
.loadByIDAsync(nonExistentId);
expect(entityNonExistentResult2.ok).toBe(false);
// invalidate from cache to ensure it invalidates correctly
await RedisTestEntity.loader(viewerContext).invalidateFieldsAsync(entity1.getAllFields());
await RedisTestEntity.loader(viewerContext)
.utils()
.invalidateFieldsAsync(entity1.getAllFields());
const cachedValueNull = await (genericRedisCacheContext.redisClient as Redis).get(
cacheKeyMaker('id', entity1.getID())
cacheKeyMaker('id', entity1.getID()),
);
expect(cachedValueNull).toBe(null);
});
it('caches and restores date fields', async () => {
const viewerContext = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext)
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext),
);
const date = new Date();
const entity1 = await enforceAsyncResult(
RedisTestEntity.creator(viewerContext).setField('dateField', date).createAsync()
RedisTestEntity.creator(viewerContext).setField('dateField', date).createAsync(),
);
expect(entity1.getField('dateField')).toEqual(date);
@@ -108,7 +110,7 @@ describe(GenericRedisCacher, () => {
// simulate new request
const vc2 = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext)
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext),
);
const entity3 = await RedisTestEntity.loader(vc2).enforcing().loadByIDAsync(entity1.getID());
expect(entity3.getField('dateField')).toEqual(date);
@@ -116,10 +118,10 @@ describe(GenericRedisCacher, () => {
it('caches and restores empty string field keys', async () => {
const viewerContext = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext)
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext),
);
const entity1 = await enforceAsyncResult(
RedisTestEntity.creator(viewerContext).setField('name', '').createAsync()
RedisTestEntity.creator(viewerContext).setField('name', '').createAsync(),
);
const entity2 = await RedisTestEntity.loader(viewerContext)
.enforcing()
@@ -128,7 +130,7 @@ describe(GenericRedisCacher, () => {
// simulate new request
const vc2 = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext)
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext),
);
const entity3 = await RedisTestEntity.loader(vc2)
.enforcing()
@@ -20,7 +20,7 @@ describe(GenericRedisCacher, () => {
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`)
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`),
);
return escapedParts.join(delimiter);
},
@@ -39,11 +39,11 @@ describe(GenericRedisCacher, () => {
it('has correct caching and loading behavior', async () => {
const viewerContext = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext)
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext),
);
const genericRedisCacher = new GenericRedisCacher(
genericRedisCacheContext,
redisTestEntityConfiguration
redisTestEntityConfiguration,
);
const date = new Date();
const entity1Created = await RedisTestEntity.creator(viewerContext)
@@ -74,7 +74,7 @@ describe(GenericRedisCacher, () => {
it('has correct negative caching behaviour', async () => {
const genericRedisCacher = new GenericRedisCacher(
genericRedisCacheContext,
redisTestEntityConfiguration
redisTestEntityConfiguration,
);
const testKey = `test-id-key-non-existent-id`;
@@ -85,11 +85,11 @@ describe(GenericRedisCacher, () => {
});
it('has correct invalidation behaviour', async () => {
const viewerContext = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext)
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext),
);
const genericRedisCacher = new GenericRedisCacher(
genericRedisCacheContext,
redisTestEntityConfiguration
redisTestEntityConfiguration,
);
const date = new Date();
const entity1Created = await RedisTestEntity.creator(viewerContext)
@@ -18,7 +18,7 @@ describe(GenericRedisCacher, () => {
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`)
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`),
);
return escapedParts.join(delimiter);
},
@@ -36,11 +36,11 @@ describe(GenericRedisCacher, () => {
redisClient.disconnect();
const vc1 = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext)
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext),
);
await expect(
RedisTestEntity.creator(vc1).setField('name', 'blah').enforceCreateAsync()
RedisTestEntity.creator(vc1).setField('name', 'blah').enforceCreateAsync(),
).rejects.toThrow(EntityCacheAdapterTransientError);
});
});
@@ -27,7 +27,7 @@ describe(GenericRedisCacher, () => {
// need to have one anything() for each element of spread, in this case 3
when(mockRedisClient.mget(anything(), anything(), anything())).thenCall(async (...keys) =>
keys.map((k) => redisResults.get(k) ?? null)
keys.map((k) => redisResults.get(k) ?? null),
);
const genericCacher = new GenericRedisCacher(
@@ -38,7 +38,7 @@ describe(GenericRedisCacher, () => {
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
},
entityConfiguration
entityConfiguration,
);
const cacheKeyWat = genericCacher['makeCacheKey']('id', 'wat');
@@ -68,7 +68,7 @@ describe(GenericRedisCacher, () => {
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
},
entityConfiguration
entityConfiguration,
);
const results = await genericCacher.loadManyAsync([]);
expect(results).toEqual(new Map());
@@ -84,7 +84,7 @@ describe(GenericRedisCacher, () => {
(key, value, code, ttl) => {
redisResults.set(key, { value, code, ttl });
return pipeline;
}
},
);
when(mockPipeline.exec()).thenResolve({} as any);
const pipeline = instance(mockPipeline);
@@ -100,7 +100,7 @@ describe(GenericRedisCacher, () => {
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
},
entityConfiguration
entityConfiguration,
);
const cacheKey = genericCacher['makeCacheKey']('id', 'wat');
@@ -123,7 +123,7 @@ describe(GenericRedisCacher, () => {
(key, value, code, ttl) => {
redisResults.set(key, { value, code, ttl });
return pipeline;
}
},
);
when(mockPipeline.exec()).thenResolve({} as any);
const pipeline = instance(mockPipeline);
@@ -139,7 +139,7 @@ describe(GenericRedisCacher, () => {
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
},
entityConfiguration
entityConfiguration,
);
const cacheKey = genericCacher['makeCacheKey']('id', 'wat');
@@ -167,7 +167,7 @@ describe(GenericRedisCacher, () => {
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
},
entityConfiguration
entityConfiguration,
);
const cacheKey = genericCacher['makeCacheKey']('id', 'wat');
@@ -185,7 +185,7 @@ describe(GenericRedisCacher, () => {
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
},
entityConfiguration
entityConfiguration,
);
await genericCacher.invalidateManyAsync([]);
});
@@ -4,7 +4,8 @@
* @module @expo/entity-cache-adapter-redis
*/
export { default as RedisCacheAdapterProvider } from './RedisCacheAdapterProvider';
export * from './RedisCommon';
export { default as GenericRedisCacher } from './GenericRedisCacher';
export * from './GenericRedisCacher';
export { default as RedisCacheAdapterProvider } from './RedisCacheAdapterProvider';
export * from './RedisCommon';
export { default as wrapNativeRedisCallAsync } from './errors/wrapNativeRedisCallAsync';
@@ -14,7 +14,7 @@ const adapterProvider = new StubDatabaseAdapterProvider();
export const createRedisIntegrationTestEntityCompanionProvider = (
genericRedisCacheContext: GenericRedisCacheContext,
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter()
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter(),
): EntityCompanionProvider => {
return new EntityCompanionProvider(
metricsAdapter,
@@ -34,6 +34,6 @@ export const createRedisIntegrationTestEntityCompanionProvider = (
cacheAdapterProvider: new RedisCacheAdapterProvider(genericRedisCacheContext),
},
],
])
]),
);
};
@@ -3,6 +3,28 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.37.0](https://github.com/expo/entity/compare/v0.36.0...v0.37.0) (2024-06-12)
### Bug Fixes
* update barrels ([#241](https://github.com/expo/entity/issues/241)) ([8c5f81b](https://github.com/expo/entity/commit/8c5f81bcca107e9b7bbea5f0ab41b0105057806e))
# [0.36.0](https://github.com/expo/entity/compare/v0.35.0...v0.36.0) (2024-06-12)
### Bug Fixes
* constrain entity fields type to string-keyed object ([#235](https://github.com/expo/entity/issues/235)) ([7e2cea1](https://github.com/expo/entity/commit/7e2cea16973a0ae1917f867cd25d6ef7c8eaecef))
# [0.35.0](https://github.com/expo/entity/compare/v0.34.0...v0.35.0) (2024-04-11)
@@ -1,6 +1,6 @@
{
"name": "@expo/entity-database-adapter-knex",
"version": "0.35.0",
"version": "0.37.0",
"description": "Knex database adapter for @expo/entity",
"files": [
"build",
@@ -16,7 +16,7 @@
"test": "jest --rootDir . --config ../../resources/jest.config.js --passWithNoTests",
"integration": "../../resources/run-with-docker yarn integration-no-setup",
"integration-no-setup": "jest --config ../../resources/jest-integration.config.js --rootDir . --runInBand --passWithNoTests",
"barrelsby": "barrelsby --directory src --location top --exclude tests__ --singleQuotes --exportDefault --delete"
"barrelsby": "barrelsby --config ../../.barrelsby.json"
},
"engines": {
"node": ">=16"
@@ -30,10 +30,10 @@
"@expo/entity": "*"
},
"dependencies": {
"knex": "^2.4.2"
"knex": "^3.1.0"
},
"devDependencies": {
"@expo/entity": "^0.35.0",
"pg": "8.10.0"
"@expo/entity": "^0.37.0",
"pg": "8.12.0"
}
}
@@ -12,7 +12,9 @@ import { Knex } from 'knex';
import { JSONArrayField, MaybeJSONArrayField } from './EntityFields';
import wrapNativePostgresCallAsync from './errors/wrapNativePostgresCallAsync';
export default class PostgresEntityDatabaseAdapter<TFields> extends EntityDatabaseAdapter<TFields> {
export default class PostgresEntityDatabaseAdapter<
TFields extends Record<string, any>,
> extends EntityDatabaseAdapter<TFields> {
protected getFieldTransformerMap(): FieldTransformerMap {
return new Map<string, FieldTransformer<any>>([
[
@@ -43,19 +45,19 @@ export default class PostgresEntityDatabaseAdapter<TFields> extends EntityDataba
queryInterface: Knex,
tableName: string,
tableField: string,
tableValues: readonly any[]
tableValues: readonly any[],
): Promise<object[]> {
return await wrapNativePostgresCallAsync(() =>
queryInterface
.select()
.from(tableName)
.whereRaw('?? = ANY(?)', [tableField, tableValues as any[]])
.whereRaw('?? = ANY(?)', [tableField, tableValues as any[]]),
);
}
private applyQueryModifiersToQueryOrderByRaw(
query: Knex.QueryBuilder,
querySelectionModifiers: TableQuerySelectionModifiersWithOrderByRaw
querySelectionModifiers: TableQuerySelectionModifiersWithOrderByRaw,
): Knex.QueryBuilder {
let ret = this.applyQueryModifiersToQuery(query, querySelectionModifiers);
@@ -69,7 +71,7 @@ export default class PostgresEntityDatabaseAdapter<TFields> extends EntityDataba
private applyQueryModifiersToQuery(
query: Knex.QueryBuilder,
querySelectionModifiers: TableQuerySelectionModifiers
querySelectionModifiers: TableQuerySelectionModifiers,
): Knex.QueryBuilder {
const { orderBy, offset, limit } = querySelectionModifiers;
@@ -97,7 +99,7 @@ export default class PostgresEntityDatabaseAdapter<TFields> extends EntityDataba
tableName: string,
tableFieldSingleValueEqualityOperands: TableFieldSingleValueEqualityCondition[],
tableFieldMultiValueEqualityOperands: TableFieldMultiValueEqualityCondition[],
querySelectionModifiers: TableQuerySelectionModifiers
querySelectionModifiers: TableQuerySelectionModifiers,
): Promise<object[]> {
let query = queryInterface.select().from(tableName);
@@ -143,7 +145,7 @@ export default class PostgresEntityDatabaseAdapter<TFields> extends EntityDataba
tableName: string,
rawWhereClause: string,
bindings: object | any[],
querySelectionModifiers: TableQuerySelectionModifiersWithOrderByRaw
querySelectionModifiers: TableQuerySelectionModifiersWithOrderByRaw,
): Promise<object[]> {
let query = queryInterface
.select()
@@ -156,10 +158,10 @@ export default class PostgresEntityDatabaseAdapter<TFields> extends EntityDataba
protected async insertInternalAsync(
queryInterface: Knex,
tableName: string,
object: object
object: object,
): Promise<object[]> {
return await wrapNativePostgresCallAsync(() =>
queryInterface.insert(object).into(tableName).returning('*')
queryInterface.insert(object).into(tableName).returning('*'),
);
}
@@ -168,10 +170,10 @@ export default class PostgresEntityDatabaseAdapter<TFields> extends EntityDataba
tableName: string,
tableIdField: string,
id: any,
object: object
object: object,
): Promise<object[]> {
return await wrapNativePostgresCallAsync(() =>
queryInterface.update(object).into(tableName).where(tableIdField, id).returning('*')
queryInterface.update(object).into(tableName).where(tableIdField, id).returning('*'),
);
}
@@ -179,10 +181,10 @@ export default class PostgresEntityDatabaseAdapter<TFields> extends EntityDataba
queryInterface: Knex,
tableName: string,
tableIdField: string,
id: any
id: any,
): Promise<number> {
return await wrapNativePostgresCallAsync(() =>
queryInterface.into(tableName).where(tableIdField, id).del()
queryInterface.into(tableName).where(tableIdField, id).del(),
);
}
}
@@ -9,8 +9,8 @@ import PostgresEntityDatabaseAdapter from './PostgresEntityDatabaseAdapter';
export default class PostgresEntityDatabaseAdapterProvider
implements IEntityDatabaseAdapterProvider
{
getDatabaseAdapter<TFields>(
entityConfiguration: EntityConfiguration<TFields>
getDatabaseAdapter<TFields extends Record<string, any>>(
entityConfiguration: EntityConfiguration<TFields>,
): EntityDatabaseAdapter<TFields> {
return new PostgresEntityDatabaseAdapter(entityConfiguration);
}
@@ -18,28 +18,28 @@ export default class PostgresEntityQueryContextProvider extends EntityQueryConte
}
protected createTransactionRunner<T>(
transactionConfig?: TransactionConfig
transactionConfig?: TransactionConfig,
): (transactionScope: (trx: any) => Promise<T>) => Promise<T> {
return (transactionScope) =>
this.knexInstance.transaction(
transactionScope,
transactionConfig
? PostgresEntityQueryContextProvider.convertTransactionConfig(transactionConfig)
: undefined
: undefined,
);
}
protected createNestedTransactionRunner<T>(
outerQueryInterface: any
outerQueryInterface: any,
): (transactionScope: (queryInterface: any) => Promise<T>) => Promise<T> {
return (transactionScope) => (outerQueryInterface as Knex).transaction(transactionScope);
}
private static convertTransactionConfig(
transactionConfig: TransactionConfig
transactionConfig: TransactionConfig,
): Knex.TransactionConfig {
const convertIsolationLevel = (
isolationLevel: TransactionIsolationLevel
isolationLevel: TransactionIsolationLevel,
): Knex.IsolationLevels => {
switch (isolationLevel) {
case TransactionIsolationLevel.READ_COMMITTED:
@@ -1,13 +1,13 @@
import {
OrderByOrdering,
createUnitTestEntityCompanionProvider,
enforceResultsAsync,
ViewerContext,
TransactionIsolationLevel,
} from '@expo/entity';
import { enforceAsyncResult } from '@expo/results';
import { knex, Knex } from 'knex';
import nullthrows from 'nullthrows';
import { setTimeout } from 'timers/promises';
import PostgresTestEntity from '../testfixtures/PostgresTestEntity';
import PostgresTriggerTestEntity from '../testfixtures/PostgresTriggerTestEntity';
@@ -42,7 +42,7 @@ describe('postgres entity integration', () => {
it('supports parallel partial updates', async () => {
const vc = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance));
const entity = await enforceAsyncResult(
PostgresTestEntity.creator(vc).setField('name', 'hello').createAsync()
PostgresTestEntity.creator(vc).setField('name', 'hello').createAsync(),
);
// update two different fields at the same time (from the same entity)
@@ -69,7 +69,7 @@ describe('postgres entity integration', () => {
it('throws knex error upon empty update', async () => {
const vc = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance));
const entity = await enforceAsyncResult(
PostgresTestEntity.creator(vc).setField('name', 'hello').createAsync()
PostgresTestEntity.creator(vc).setField('name', 'hello').createAsync(),
);
await expect(PostgresTestEntity.updater(entity).updateAsync()).rejects.toThrow();
});
@@ -77,7 +77,7 @@ describe('postgres entity integration', () => {
it('throws error upon empty update for stub database adapter to match behavior', async () => {
const vc = new ViewerContext(createUnitTestEntityCompanionProvider());
const entity = await enforceAsyncResult(
PostgresTestEntity.creator(vc).setField('name', 'hello').createAsync()
PostgresTestEntity.creator(vc).setField('name', 'hello').createAsync(),
);
await expect(PostgresTestEntity.updater(entity).updateAsync()).rejects.toThrow();
});
@@ -88,66 +88,89 @@ describe('postgres entity integration', () => {
// put one in the DB
const firstEntity = await enforceAsyncResult(
PostgresTestEntity.creator(vc1).setField('name', 'hello').createAsync()
PostgresTestEntity.creator(vc1).setField('name', 'hello').createAsync(),
);
await enforceAsyncResult(PostgresTestEntity.loader(vc1).loadByIDAsync(firstEntity.getID()));
await PostgresTestEntity.loader(vc1).enforcing().loadByIDAsync(firstEntity.getID());
const errorToThrow = new Error('Intentional error');
await expect(
vc1.runInTransactionForDatabaseAdaptorFlavorAsync('postgres', async (queryContext) => {
// put another in the DB that will be rolled back due to error thrown
await enforceAsyncResult(
PostgresTestEntity.creator(vc1, queryContext).setField('name', 'hello').createAsync()
);
vc1.runInTransactionForDatabaseAdaptorFlavorAsync(
'postgres',
async (queryContext) => {
// put another in the DB that will be rolled back due to error thrown
await enforceAsyncResult(
PostgresTestEntity.creator(vc1, queryContext).setField('name', 'hello').createAsync(),
);
throw errorToThrow;
})
throw errorToThrow;
},
{}, // test empty transaction config
),
).rejects.toEqual(errorToThrow);
const entities = await enforceResultsAsync(
PostgresTestEntity.loader(vc1).loadManyByFieldEqualingAsync('name', 'hello')
);
const entities = await PostgresTestEntity.loader(vc1)
.enforcing()
.loadManyByFieldEqualingAsync('name', 'hello');
expect(entities).toHaveLength(1);
});
it('passes transaction config into transactions', async () => {
const vc1 = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance));
const firstEntity = await enforceAsyncResult(
PostgresTestEntity.creator(vc1).setField('name', 'hello').createAsync()
);
const loadAndUpdateAsync = async (newName: string): Promise<{ error?: Error }> => {
try {
await vc1.runInTransactionForDatabaseAdaptorFlavorAsync(
'postgres',
async (queryContext) => {
const entity = await PostgresTestEntity.loader(vc1, queryContext)
.enforcing()
.loadByIDAsync(firstEntity.getID());
await PostgresTestEntity.updater(entity, queryContext)
.setField('name', newName)
.enforceUpdateAsync();
},
{ isolationLevel: TransactionIsolationLevel.SERIALIZABLE }
describe('isolation levels', () => {
test.each(Object.values(TransactionIsolationLevel))(
'isolation level: %p',
async (isolationLevel: TransactionIsolationLevel) => {
const vc1 = new ViewerContext(
createKnexIntegrationTestEntityCompanionProvider(knexInstance),
);
return {};
} catch (e) {
return { error: e as Error };
}
};
// do some parallel updates to trigger serializable error in at least some of them
const results = await Promise.all([
loadAndUpdateAsync('hello2'),
loadAndUpdateAsync('hello3'),
loadAndUpdateAsync('hello4'),
loadAndUpdateAsync('hello5'),
]);
const firstEntity = await enforceAsyncResult(
PostgresTestEntity.creator(vc1).setField('name', 'hello').createAsync(),
);
expect(results.filter((r) => (r.error as any)?.cause?.code === '40001').length > 0).toBe(true);
const loadAndUpdateAsync = async (
newName: string,
delay: number,
): Promise<{ error?: Error }> => {
try {
await vc1.runInTransactionForDatabaseAdaptorFlavorAsync(
'postgres',
async (queryContext) => {
const entity = await PostgresTestEntity.loader(vc1, queryContext)
.enforcing()
.loadByIDAsync(firstEntity.getID());
await setTimeout(delay);
await PostgresTestEntity.updater(entity, queryContext)
.setField('name', entity.getField('name') + ',' + newName)
.enforceUpdateAsync();
},
{ isolationLevel },
);
return {};
} catch (e) {
return { error: e as Error };
}
};
// do some parallel updates to trigger serializable error in at least some of them
const results = await Promise.all([
loadAndUpdateAsync('hello2', 0),
loadAndUpdateAsync('hello3', 100),
loadAndUpdateAsync('hello4', 200),
loadAndUpdateAsync('hello5', 300),
]);
if (isolationLevel === TransactionIsolationLevel.READ_COMMITTED) {
// read committed seems executes the transactions and doesn't produce a consistent result, but doesn't throw
expect(results.filter((r) => !!r.error).length > 0).toBe(false);
} else {
// all other isolation levels throw since they're doing nonrepeatable reads
expect(results.filter((r) => (r.error as any)?.cause?.code === '40001').length > 0).toBe(
true,
);
}
},
);
});
describe('JSON fields', () => {
@@ -158,7 +181,7 @@ describe('postgres entity integration', () => {
PostgresTestEntity.creator(vc1)
.setField('stringArray', ['hello', 'world'])
.setField('jsonArrayField', ['hello', 'world'])
.createAsync()
.createAsync(),
);
expect(entity.getField('stringArray')).toEqual(['hello', 'world']);
@@ -171,7 +194,7 @@ describe('postgres entity integration', () => {
const entity = await enforceAsyncResult(
PostgresTestEntity.creator(vc1)
.setField('jsonObjectField', { hello: 'world' })
.createAsync()
.createAsync(),
);
expect(entity.getField('jsonObjectField')).toEqual({ hello: 'world' });
@@ -183,12 +206,12 @@ describe('postgres entity integration', () => {
const entity1 = await enforceAsyncResult(
PostgresTestEntity.creator(vc1)
.setField('maybeJsonArrayField', ['hello', 'world'])
.createAsync()
.createAsync(),
);
const entity2 = await enforceAsyncResult(
PostgresTestEntity.creator(vc1)
.setField('maybeJsonArrayField', { hello: 'world' })
.createAsync()
.createAsync(),
);
expect(entity1.getField('maybeJsonArrayField')).toEqual(['hello', 'world']);
@@ -201,17 +224,17 @@ describe('postgres entity integration', () => {
const vc1 = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance));
let entity = await enforceAsyncResult(
PostgresTestEntity.creator(vc1).setField('bigintField', '72057594037928038').createAsync()
PostgresTestEntity.creator(vc1).setField('bigintField', '72057594037928038').createAsync(),
);
expect(entity.getField('bigintField')).toEqual('72057594037928038');
entity = await enforceAsyncResult(
PostgresTestEntity.updater(entity).setField('bigintField', '10').updateAsync()
PostgresTestEntity.updater(entity).setField('bigintField', '10').updateAsync(),
);
expect(entity.getField('bigintField')).toEqual('10');
entity = await enforceAsyncResult(
PostgresTestEntity.updater(entity).setField('bigintField', '-10').updateAsync()
PostgresTestEntity.updater(entity).setField('bigintField', '-10').updateAsync(),
);
expect(entity.getField('bigintField')).toEqual('-10');
});
@@ -226,7 +249,7 @@ describe('postgres entity integration', () => {
.setField('name', 'hello')
.setField('hasACat', false)
.setField('hasADog', true)
.createAsync()
.createAsync(),
);
await enforceAsyncResult(
@@ -234,7 +257,7 @@ describe('postgres entity integration', () => {
.setField('name', 'world')
.setField('hasACat', false)
.setField('hasADog', true)
.createAsync()
.createAsync(),
);
await enforceAsyncResult(
@@ -242,7 +265,7 @@ describe('postgres entity integration', () => {
.setField('name', 'wat')
.setField('hasACat', false)
.setField('hasADog', false)
.createAsync()
.createAsync(),
);
const results = await PostgresTestEntity.loader(vc1)
@@ -299,25 +322,25 @@ describe('postgres entity integration', () => {
PostgresTestEntity.creator(vc1)
.setField('name', 'a')
.setField('hasADog', true)
.createAsync()
.createAsync(),
);
await enforceAsyncResult(
PostgresTestEntity.creator(vc1)
.setField('name', 'b')
.setField('hasADog', true)
.createAsync()
.createAsync(),
);
await enforceAsyncResult(
PostgresTestEntity.creator(vc1)
.setField('name', null)
.setField('hasADog', true)
.createAsync()
.createAsync(),
);
await enforceAsyncResult(
PostgresTestEntity.creator(vc1)
.setField('name', null)
.setField('hasADog', false)
.createAsync()
.createAsync(),
);
const results = await PostgresTestEntity.loader(vc1)
@@ -340,7 +363,7 @@ describe('postgres entity integration', () => {
order: OrderByOrdering.DESCENDING,
},
],
}
},
);
expect(results2).toHaveLength(2);
expect(results2.map((e) => e.getField('name'))).toEqual([null, 'a']);
@@ -355,7 +378,7 @@ describe('postgres entity integration', () => {
.setField('name', 'hello')
.setField('hasACat', false)
.setField('hasADog', true)
.createAsync()
.createAsync(),
);
const results = await PostgresTestEntity.loader(vc1)
@@ -372,13 +395,13 @@ describe('postgres entity integration', () => {
.setField('name', 'hello')
.setField('hasACat', false)
.setField('hasADog', true)
.createAsync()
.createAsync(),
);
await expect(
PostgresTestEntity.loader(vc1)
.enforcing()
.loadManyByRawWhereClauseAsync('invalid_column = ?', ['hello'])
.loadManyByRawWhereClauseAsync('invalid_column = ?', ['hello']),
).rejects.toThrow();
});
@@ -389,21 +412,21 @@ describe('postgres entity integration', () => {
PostgresTestEntity.creator(vc1)
.setField('name', 'a')
.setField('hasADog', true)
.createAsync()
.createAsync(),
);
await enforceAsyncResult(
PostgresTestEntity.creator(vc1)
.setField('name', 'b')
.setField('hasADog', true)
.createAsync()
.createAsync(),
);
await enforceAsyncResult(
PostgresTestEntity.creator(vc1)
.setField('name', 'c')
.setField('hasADog', true)
.createAsync()
.createAsync(),
);
const results = await PostgresTestEntity.loader(vc1)
@@ -455,58 +478,58 @@ describe('postgres entity integration', () => {
describe('create', () => {
it('rolls back transaction when trigger throws except afterCommit', async () => {
const vc1 = new ViewerContext(
createKnexIntegrationTestEntityCompanionProvider(knexInstance)
createKnexIntegrationTestEntityCompanionProvider(knexInstance),
);
await expect(
PostgresTriggerTestEntity.creator(vc1)
.setField('name', 'beforeCreate')
.enforceCreateAsync()
.enforceCreateAsync(),
).rejects.toThrowError('name cannot have value beforeCreate');
await expect(
PostgresTriggerTestEntity.loader(vc1)
.enforcing()
.loadByFieldEqualingAsync('name', 'beforeCreate')
.loadByFieldEqualingAsync('name', 'beforeCreate'),
).resolves.toBeNull();
await expect(
PostgresTriggerTestEntity.creator(vc1)
.setField('name', 'afterCreate')
.enforceCreateAsync()
.enforceCreateAsync(),
).rejects.toThrowError('name cannot have value afterCreate');
await expect(
PostgresTriggerTestEntity.loader(vc1)
.enforcing()
.loadByFieldEqualingAsync('name', 'afterCreate')
.loadByFieldEqualingAsync('name', 'afterCreate'),
).resolves.toBeNull();
await expect(
PostgresTriggerTestEntity.creator(vc1).setField('name', 'beforeAll').enforceCreateAsync()
PostgresTriggerTestEntity.creator(vc1).setField('name', 'beforeAll').enforceCreateAsync(),
).rejects.toThrowError('name cannot have value beforeAll');
await expect(
PostgresTriggerTestEntity.loader(vc1)
.enforcing()
.loadByFieldEqualingAsync('name', 'beforeAll')
.loadByFieldEqualingAsync('name', 'beforeAll'),
).resolves.toBeNull();
await expect(
PostgresTriggerTestEntity.creator(vc1).setField('name', 'afterAll').enforceCreateAsync()
PostgresTriggerTestEntity.creator(vc1).setField('name', 'afterAll').enforceCreateAsync(),
).rejects.toThrowError('name cannot have value afterAll');
await expect(
PostgresTriggerTestEntity.loader(vc1)
.enforcing()
.loadByFieldEqualingAsync('name', 'afterAll')
.loadByFieldEqualingAsync('name', 'afterAll'),
).resolves.toBeNull();
await expect(
PostgresTriggerTestEntity.creator(vc1)
.setField('name', 'afterCommit')
.enforceCreateAsync()
.enforceCreateAsync(),
).rejects.toThrowError('name cannot have value afterCommit');
await expect(
PostgresTriggerTestEntity.loader(vc1)
.enforcing()
.loadByFieldEqualingAsync('name', 'afterCommit')
.loadByFieldEqualingAsync('name', 'afterCommit'),
).resolves.not.toBeNull();
});
});
@@ -514,7 +537,7 @@ describe('postgres entity integration', () => {
describe('update', () => {
it('rolls back transaction when trigger throws except afterCommit', async () => {
const vc1 = new ViewerContext(
createKnexIntegrationTestEntityCompanionProvider(knexInstance)
createKnexIntegrationTestEntityCompanionProvider(knexInstance),
);
const entity = await PostgresTriggerTestEntity.creator(vc1)
@@ -524,56 +547,56 @@ describe('postgres entity integration', () => {
await expect(
PostgresTriggerTestEntity.updater(entity)
.setField('name', 'beforeUpdate')
.enforceUpdateAsync()
.enforceUpdateAsync(),
).rejects.toThrowError('name cannot have value beforeUpdate');
await expect(
PostgresTriggerTestEntity.loader(vc1)
.enforcing()
.loadByFieldEqualingAsync('name', 'beforeUpdate')
.loadByFieldEqualingAsync('name', 'beforeUpdate'),
).resolves.toBeNull();
await expect(
PostgresTriggerTestEntity.updater(entity)
.setField('name', 'afterUpdate')
.enforceUpdateAsync()
.enforceUpdateAsync(),
).rejects.toThrowError('name cannot have value afterUpdate');
await expect(
PostgresTriggerTestEntity.loader(vc1)
.enforcing()
.loadByFieldEqualingAsync('name', 'afterUpdate')
.loadByFieldEqualingAsync('name', 'afterUpdate'),
).resolves.toBeNull();
await expect(
PostgresTriggerTestEntity.updater(entity)
.setField('name', 'beforeAll')
.enforceUpdateAsync()
.enforceUpdateAsync(),
).rejects.toThrowError('name cannot have value beforeAll');
await expect(
PostgresTriggerTestEntity.loader(vc1)
.enforcing()
.loadByFieldEqualingAsync('name', 'beforeAll')
.loadByFieldEqualingAsync('name', 'beforeAll'),
).resolves.toBeNull();
await expect(
PostgresTriggerTestEntity.updater(entity)
.setField('name', 'afterAll')
.enforceUpdateAsync()
.enforceUpdateAsync(),
).rejects.toThrowError('name cannot have value afterAll');
await expect(
PostgresTriggerTestEntity.loader(vc1)
.enforcing()
.loadByFieldEqualingAsync('name', 'afterAll')
.loadByFieldEqualingAsync('name', 'afterAll'),
).resolves.toBeNull();
await expect(
PostgresTriggerTestEntity.updater(entity)
.setField('name', 'afterCommit')
.enforceUpdateAsync()
.enforceUpdateAsync(),
).rejects.toThrowError('name cannot have value afterCommit');
await expect(
PostgresTriggerTestEntity.loader(vc1)
.enforcing()
.loadByFieldEqualingAsync('name', 'afterCommit')
.loadByFieldEqualingAsync('name', 'afterCommit'),
).resolves.not.toBeNull();
});
});
@@ -581,31 +604,31 @@ describe('postgres entity integration', () => {
describe('delete', () => {
it('rolls back transaction when trigger throws except afterCommit', async () => {
const vc1 = new ViewerContext(
createKnexIntegrationTestEntityCompanionProvider(knexInstance)
createKnexIntegrationTestEntityCompanionProvider(knexInstance),
);
const entityBeforeDelete = await PostgresTriggerTestEntity.creator(vc1)
.setField('name', 'beforeDelete')
.enforceCreateAsync();
await expect(
PostgresTriggerTestEntity.enforceDeleteAsync(entityBeforeDelete)
PostgresTriggerTestEntity.enforceDeleteAsync(entityBeforeDelete),
).rejects.toThrowError('name cannot have value beforeDelete');
await expect(
PostgresTriggerTestEntity.loader(vc1)
.enforcing()
.loadByFieldEqualingAsync('name', 'beforeDelete')
.loadByFieldEqualingAsync('name', 'beforeDelete'),
).resolves.not.toBeNull();
const entityAfterDelete = await PostgresTriggerTestEntity.creator(vc1)
.setField('name', 'afterDelete')
.enforceCreateAsync();
await expect(
PostgresTriggerTestEntity.enforceDeleteAsync(entityAfterDelete)
PostgresTriggerTestEntity.enforceDeleteAsync(entityAfterDelete),
).rejects.toThrowError('name cannot have value afterDelete');
await expect(
PostgresTriggerTestEntity.loader(vc1)
.enforcing()
.loadByFieldEqualingAsync('name', 'afterDelete')
.loadByFieldEqualingAsync('name', 'afterDelete'),
).resolves.not.toBeNull();
});
});
@@ -613,25 +636,25 @@ describe('postgres entity integration', () => {
describe('create', () => {
it('rolls back transaction when trigger throws ', async () => {
const vc1 = new ViewerContext(
createKnexIntegrationTestEntityCompanionProvider(knexInstance)
createKnexIntegrationTestEntityCompanionProvider(knexInstance),
);
await expect(
PostgresValidatorTestEntity.creator(vc1)
.setField('name', 'beforeCreateAndBeforeUpdate')
.enforceCreateAsync()
.enforceCreateAsync(),
).rejects.toThrowError('name cannot have value beforeCreateAndBeforeUpdate');
await expect(
PostgresValidatorTestEntity.loader(vc1)
.enforcing()
.loadByFieldEqualingAsync('name', 'beforeCreateAndBeforeUpdate')
.loadByFieldEqualingAsync('name', 'beforeCreateAndBeforeUpdate'),
).resolves.toBeNull();
});
});
describe('update', () => {
it('rolls back transaction when trigger throws ', async () => {
const vc1 = new ViewerContext(
createKnexIntegrationTestEntityCompanionProvider(knexInstance)
createKnexIntegrationTestEntityCompanionProvider(knexInstance),
);
const entity = await PostgresValidatorTestEntity.creator(vc1)
@@ -641,19 +664,19 @@ describe('postgres entity integration', () => {
await expect(
PostgresValidatorTestEntity.updater(entity)
.setField('name', 'beforeCreateAndBeforeUpdate')
.enforceUpdateAsync()
.enforceUpdateAsync(),
).rejects.toThrowError('name cannot have value beforeCreateAndBeforeUpdate');
await expect(
PostgresValidatorTestEntity.loader(vc1)
.enforcing()
.loadByFieldEqualingAsync('name', 'beforeCreateAndBeforeUpdate')
.loadByFieldEqualingAsync('name', 'beforeCreateAndBeforeUpdate'),
).resolves.toBeNull();
});
});
describe('delete', () => {
it('validation should not run on a delete mutation', async () => {
const vc1 = new ViewerContext(
createKnexIntegrationTestEntityCompanionProvider(knexInstance)
createKnexIntegrationTestEntityCompanionProvider(knexInstance),
);
const entityToDelete = await PostgresValidatorTestEntity.creator(vc1)
@@ -663,7 +686,7 @@ describe('postgres entity integration', () => {
await expect(
PostgresValidatorTestEntity.loader(vc1)
.enforcing()
.loadByFieldEqualingAsync('name', 'shouldBeDeleted')
.loadByFieldEqualingAsync('name', 'shouldBeDeleted'),
).resolves.toBeNull();
});
});
@@ -718,7 +741,7 @@ describe('postgres entity integration', () => {
preCommitCallCount++;
throw Error('wat');
}, 0);
})
}),
).rejects.toThrowError('wat');
expect(preCommitCallCount).toBe(2);
@@ -35,40 +35,40 @@ describe('postgres entity integration', () => {
it('throws after deletion of multiple rows or no rows', async () => {
const vc = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance));
const entity1 = await enforceAsyncResult(
InvalidTestEntity.creator(vc).setField('id', 1).setField('name', 'hello').createAsync()
InvalidTestEntity.creator(vc).setField('id', 1).setField('name', 'hello').createAsync(),
);
await enforceAsyncResult(
InvalidTestEntity.creator(vc).setField('id', 1).setField('name', 'world').createAsync()
InvalidTestEntity.creator(vc).setField('id', 1).setField('name', 'world').createAsync(),
);
await expect(InvalidTestEntity.deleteAsync(entity1)).rejects.toThrowError(
'Excessive deletions from database adapter delete'
'Excessive deletions from database adapter delete',
);
});
it('throws after update of multiple rows', async () => {
const vc = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance));
const entity1 = await enforceAsyncResult(
InvalidTestEntity.creator(vc).setField('id', 1).setField('name', 'hello').createAsync()
InvalidTestEntity.creator(vc).setField('id', 1).setField('name', 'hello').createAsync(),
);
await enforceAsyncResult(
InvalidTestEntity.creator(vc).setField('id', 1).setField('name', 'world').createAsync()
InvalidTestEntity.creator(vc).setField('id', 1).setField('name', 'world').createAsync(),
);
await expect(
InvalidTestEntity.updater(entity1).setField('name', 'blah').enforceUpdateAsync()
InvalidTestEntity.updater(entity1).setField('name', 'blah').enforceUpdateAsync(),
).rejects.toThrowError('Excessive results from database adapter update');
});
it('throws after update of no rows', async () => {
const vc = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance));
const entity1 = await enforceAsyncResult(
InvalidTestEntity.creator(vc).setField('id', 1).setField('name', 'hello').createAsync()
InvalidTestEntity.creator(vc).setField('id', 1).setField('name', 'hello').createAsync(),
);
await InvalidTestEntity.deleteAsync(entity1);
await expect(
InvalidTestEntity.updater(entity1).setField('name', 'blah').enforceUpdateAsync()
InvalidTestEntity.updater(entity1).setField('name', 'blah').enforceUpdateAsync(),
).rejects.toThrowError('Empty results from database adapter update');
});
});
@@ -58,10 +58,10 @@ describe('postgres errors', () => {
acquireConnectionTimeout: 1,
});
const vc2 = new ViewerContext(
createKnexIntegrationTestEntityCompanionProvider(shortTimeoutKnexInstance)
createKnexIntegrationTestEntityCompanionProvider(shortTimeoutKnexInstance),
);
await expect(ErrorsTestEntity.loader(vc2).enforcing().loadByIDAsync(1)).rejects.toThrow(
EntityDatabaseAdapterTransientError
EntityDatabaseAdapterTransientError,
);
await shortTimeoutKnexInstance.destroy();
});
@@ -72,7 +72,7 @@ describe('postgres errors', () => {
ErrorsTestEntity.creator(vc)
.setField('id', 1)
.setField('fieldNonNull', null as any)
.enforceCreateAsync()
.enforceCreateAsync(),
).rejects.toThrow(EntityDatabaseAdapterNotNullConstraintError);
});
@@ -83,7 +83,7 @@ describe('postgres errors', () => {
.setField('id', 1)
.setField('fieldNonNull', 'hello')
.setField('fieldForeignKey', 2)
.enforceCreateAsync()
.enforceCreateAsync(),
).rejects.toThrow(EntityDatabaseAdapterForeignKeyConstraintError);
});
@@ -99,7 +99,7 @@ describe('postgres errors', () => {
ErrorsTestEntity.creator(vc)
.setField('id', 1)
.setField('fieldNonNull', 'hello')
.enforceCreateAsync()
.enforceCreateAsync(),
).rejects.toThrow(EntityDatabaseAdapterUniqueConstraintError);
});
@@ -116,7 +116,7 @@ describe('postgres errors', () => {
.setField('id', 1)
.setField('fieldNonNull', 'hello')
.setField('fieldUnique', 'hello')
.enforceCreateAsync()
.enforceCreateAsync(),
).rejects.toThrow(EntityDatabaseAdapterUniqueConstraintError);
});
@@ -127,7 +127,7 @@ describe('postgres errors', () => {
.setField('id', 1)
.setField('fieldNonNull', 'hello')
.setField('checkLessThan5', 2)
.enforceCreateAsync()
.enforceCreateAsync(),
).resolves.toBeTruthy();
await expect(
@@ -135,7 +135,7 @@ describe('postgres errors', () => {
.setField('id', 2)
.setField('fieldNonNull', 'hello')
.setField('checkLessThan5', 10)
.enforceCreateAsync()
.enforceCreateAsync(),
).rejects.toThrow(EntityDatabaseAdapterCheckConstraintError);
});
@@ -146,7 +146,7 @@ describe('postgres errors', () => {
.setField('id', 1)
.setField('fieldNonNull', 'hello')
.setField('fieldExclusion', 'what')
.enforceCreateAsync()
.enforceCreateAsync(),
).resolves.toBeTruthy();
await expect(
@@ -154,7 +154,7 @@ describe('postgres errors', () => {
.setField('id', 2)
.setField('fieldNonNull', 'hello')
.setField('fieldExclusion', 'what')
.enforceCreateAsync()
.enforceCreateAsync(),
).rejects.toThrow(EntityDatabaseAdapterExclusionConstraintError);
});
@@ -165,7 +165,7 @@ describe('postgres errors', () => {
.setField('id', 1)
.setField('fieldNonNull', 'hello')
.setField('nonExistentColumn', 'what')
.enforceCreateAsync()
.enforceCreateAsync(),
).rejects.toThrow(EntityDatabaseAdapterUnknownError);
});
});
@@ -5,15 +5,15 @@ import { BigIntField, JSONArrayField, MaybeJSONArrayField } from '../EntityField
describeFieldTestCase(
new JSONArrayField({ columnName: 'wat' }),
[[[1, 2]] as any, [['hello']] as any],
[1, 'hello']
[1, 'hello'],
);
describeFieldTestCase(
new MaybeJSONArrayField({ columnName: 'wat' }),
[1, 'hello', [['hello']]],
[]
[],
);
describeFieldTestCase(
new BigIntField({ columnName: 'wat' }),
['123457682149816498126412896', '123', '-1', '-124147812641876482716841'],
[1, false, -1, 1e6, {}]
[1, false, -1, 1e6, {}],
);
@@ -11,7 +11,7 @@ import {
import { knex } from 'knex';
function wrapNativePostgresError(
error: Error & { code?: string }
error: Error & { code?: string },
): EntityDatabaseAdapterError & Error {
const ret = translatePostgresError(error);
if (error.stack) {
@@ -21,7 +21,7 @@ function wrapNativePostgresError(
}
function translatePostgresError(
error: Error & { code?: string }
error: Error & { code?: string },
): EntityDatabaseAdapterError & Error {
if (error instanceof knex.KnexTimeoutError) {
return new EntityDatabaseAdapterTransientError(error.message, error);
@@ -4,7 +4,8 @@
* @module @expo/entity-database-adapter-knex
*/
export * from './EntityFields';
export { default as PostgresEntityDatabaseAdapter } from './PostgresEntityDatabaseAdapter';
export { default as PostgresEntityDatabaseAdapterProvider } from './PostgresEntityDatabaseAdapterProvider';
export { default as PostgresEntityQueryContextProvider } from './PostgresEntityQueryContextProvider';
export * from './EntityFields';
export { default as wrapNativePostgresCallAsync } from './errors/wrapNativePostgresCallAsync';
@@ -70,7 +70,7 @@ export default class ErrorsTestEntity extends Entity<
CHECK (check_less_than_5 IS NULL OR check_less_than_5 < 5);`,
{
tableName,
}
},
);
await knex.raw(
@@ -81,7 +81,7 @@ export default class ErrorsTestEntity extends Entity<
`,
{
tableName,
}
},
);
}
@@ -116,7 +116,10 @@ class ThrowConditionallyTrigger extends EntityMutationTrigger<
ViewerContext,
PostgresTriggerTestEntity
> {
constructor(private fieldName: keyof PostgresTriggerTestEntityFields, private badValue: string) {
constructor(
private fieldName: keyof PostgresTriggerTestEntityFields,
private badValue: string,
) {
super();
}
@@ -129,7 +132,7 @@ class ThrowConditionallyTrigger extends EntityMutationTrigger<
string,
ViewerContext,
PostgresTriggerTestEntity
>
>,
): Promise<void> {
if (entity.getField(this.fieldName) === this.badValue) {
throw new Error(`${this.fieldName} cannot have value ${this.badValue}`);
@@ -143,13 +146,16 @@ class ThrowConditionallyNonTransactionalTrigger extends EntityNonTransactionalMu
ViewerContext,
PostgresTriggerTestEntity
> {
constructor(private fieldName: keyof PostgresTriggerTestEntityFields, private badValue: string) {
constructor(
private fieldName: keyof PostgresTriggerTestEntityFields,
private badValue: string,
) {
super();
}
async executeAsync(
_viewerContext: ViewerContext,
entity: PostgresTriggerTestEntity
entity: PostgresTriggerTestEntity,
): Promise<void> {
if (entity.getField(this.fieldName) === this.badValue) {
throw new Error(`${this.fieldName} cannot have value ${this.badValue}`);
@@ -107,7 +107,7 @@ class ThrowConditionallyTrigger extends EntityMutationTrigger<
> {
constructor(
private fieldName: keyof PostgresValidatorTestEntityFields,
private badValue: string
private badValue: string,
) {
super();
}
@@ -121,7 +121,7 @@ class ThrowConditionallyTrigger extends EntityMutationTrigger<
string,
ViewerContext,
PostgresValidatorTestEntity
>
>,
): Promise<void> {
if (entity.getField(this.fieldName) === this.badValue) {
throw new Error(`${this.fieldName} cannot have value ${this.badValue}`);
@@ -11,7 +11,7 @@ import PostgresEntityQueryContextProvider from '../PostgresEntityQueryContextPro
export const createKnexIntegrationTestEntityCompanionProvider = (
knex: Knex,
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter()
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter(),
): EntityCompanionProvider => {
return new EntityCompanionProvider(
metricsAdapter,
@@ -31,6 +31,6 @@ export const createKnexIntegrationTestEntityCompanionProvider = (
cacheAdapterProvider: new InMemoryFullCacheStubCacheAdapterProvider(),
},
],
])
]),
);
};
+27
View File
@@ -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.37.0](https://github.com/expo/entity/compare/v0.36.0...v0.37.0) (2024-06-12)
### Bug Fixes
* update barrels ([#241](https://github.com/expo/entity/issues/241)) ([8c5f81b](https://github.com/expo/entity/commit/8c5f81bcca107e9b7bbea5f0ab41b0105057806e))
# [0.36.0](https://github.com/expo/entity/compare/v0.35.0...v0.36.0) (2024-06-12)
### Bug Fixes
* constrain entity fields type to string-keyed object ([#235](https://github.com/expo/entity/issues/235)) ([7e2cea1](https://github.com/expo/entity/commit/7e2cea16973a0ae1917f867cd25d6ef7c8eaecef))
### Features
* add previousValue to privacy policy context for updates ([#232](https://github.com/expo/entity/issues/232)) ([af495a9](https://github.com/expo/entity/commit/af495a9a856a5042f00cfd150348e415139cb85e))
# [0.35.0](https://github.com/expo/entity/compare/v0.34.0...v0.35.0) (2024-04-11)
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "@expo/entity-example",
"private": true,
"version": "0.35.0",
"version": "0.37.0",
"description": "An example integration of the @expo/entity framework",
"scripts": {
"tsc": "tsc",
@@ -11,7 +11,7 @@
"test": "jest --rootDir . --config ../../resources/jest.config.js",
"integration": "../../resources/run-with-docker yarn integration-no-setup",
"integration-no-setup": "jest --config ../../resources/jest-integration.config.js --rootDir . --runInBand --passWithNoTests",
"barrelsby": "barrelsby --directory src --location top --exclude tests__ --singleQuotes --exportDefault --delete",
"barrelsby": "",
"start": "tsnd --transpile-only --inspect --respawn --no-notify --async-stack-traces --throw-deprecation -- src/index.ts"
},
"engines": {
@@ -25,7 +25,7 @@
"dependencies": {
"@apollo/server": "^4.7.3",
"@as-integrations/koa": "^1.1.1",
"@expo/entity": "^0.35.0",
"@expo/entity": "^0.37.0",
"@koa/cors": "^4.0.0",
"@koa/router": "^12.0.0",
"graphql": "^16.6.0",
@@ -15,8 +15,8 @@ import { v4 as uuidv4 } from 'uuid';
const dbObjects: Readonly<{ [key: string]: any }>[] = [];
export class InMemoryDatabaseAdapterProvider implements IEntityDatabaseAdapterProvider {
getDatabaseAdapter<TFields>(
entityConfiguration: EntityConfiguration<TFields>
getDatabaseAdapter<TFields extends Record<string, any>>(
entityConfiguration: EntityConfiguration<TFields>,
): EntityDatabaseAdapter<TFields> {
return new InMemoryDatabaseAdapter(entityConfiguration);
}
@@ -26,7 +26,7 @@ export class InMemoryDatabaseAdapterProvider implements IEntityDatabaseAdapterPr
* In-memory database adapter for entity for the purposes of this example. Normally `@expo/entity-database-adapter-knex`
* or another production adapter would be used. Very similar to StubDatabaseAdapter but shared in a way more akin to a normal database.
*/
class InMemoryDatabaseAdapter<T> extends EntityDatabaseAdapter<T> {
class InMemoryDatabaseAdapter<T extends Record<string, any>> extends EntityDatabaseAdapter<T> {
protected getFieldTransformerMap(): FieldTransformerMap {
return new Map();
}
@@ -35,13 +35,13 @@ class InMemoryDatabaseAdapter<T> extends EntityDatabaseAdapter<T> {
_queryInterface: any,
_tableName: string,
tableField: string,
tableValues: readonly any[]
tableValues: readonly any[],
): Promise<object[]> {
return tableValues.reduce((acc, fieldValue) => {
return acc.concat(
dbObjects.filter((obj) => {
return obj[tableField] === fieldValue;
})
}),
);
}, []);
}
@@ -52,7 +52,7 @@ class InMemoryDatabaseAdapter<T> extends EntityDatabaseAdapter<T> {
order: OrderByOrdering;
}[],
objectA: { [key: string]: any },
objectB: { [key: string]: any }
objectB: { [key: string]: any },
): 0 | 1 | -1 {
if (orderBys.length === 0) {
return 0;
@@ -66,14 +66,14 @@ class InMemoryDatabaseAdapter<T> extends EntityDatabaseAdapter<T> {
return aField > bField
? -1
: aField < bField
? 1
: this.compareByOrderBys(orderBys.slice(1), objectA, objectB);
? 1
: this.compareByOrderBys(orderBys.slice(1), objectA, objectB);
case OrderByOrdering.ASCENDING:
return bField > aField
? -1
: bField < aField
? 1
: this.compareByOrderBys(orderBys.slice(1), objectA, objectB);
? 1
: this.compareByOrderBys(orderBys.slice(1), objectA, objectB);
}
}
@@ -82,7 +82,7 @@ class InMemoryDatabaseAdapter<T> extends EntityDatabaseAdapter<T> {
_tableName: string,
tableFieldSingleValueEqualityOperands: TableFieldSingleValueEqualityCondition[],
tableFieldMultiValueEqualityOperands: TableFieldMultiValueEqualityCondition[],
querySelectionModifiers: TableQuerySelectionModifiers
querySelectionModifiers: TableQuerySelectionModifiers,
): Promise<object[]> {
let filteredObjects = dbObjects;
for (const { tableField, tableValue } of tableFieldSingleValueEqualityOperands) {
@@ -96,7 +96,7 @@ class InMemoryDatabaseAdapter<T> extends EntityDatabaseAdapter<T> {
const orderBy = querySelectionModifiers.orderBy;
if (orderBy !== undefined) {
filteredObjects = filteredObjects.sort((a, b) =>
InMemoryDatabaseAdapter.compareByOrderBys(orderBy, a, b)
InMemoryDatabaseAdapter.compareByOrderBys(orderBy, a, b),
);
}
@@ -118,7 +118,7 @@ class InMemoryDatabaseAdapter<T> extends EntityDatabaseAdapter<T> {
_tableName: string,
_rawWhereClause: string,
_bindings: object | any[],
_querySelectionModifiers: TableQuerySelectionModifiers
_querySelectionModifiers: TableQuerySelectionModifiers,
): Promise<object[]> {
throw new Error('Raw WHERE clauses not supported for InMemoryDatabaseAdapter');
}
@@ -126,12 +126,12 @@ class InMemoryDatabaseAdapter<T> extends EntityDatabaseAdapter<T> {
protected async insertInternalAsync(
_queryInterface: any,
_tableName: string,
object: object
object: object,
): Promise<object[]> {
const configurationPrivate = this['entityConfiguration'] as EntityConfiguration<T>;
const idField = getDatabaseFieldForEntityField(
configurationPrivate,
configurationPrivate.idField
configurationPrivate.idField,
);
const objectToInsert = {
[idField]: uuidv4(),
@@ -146,7 +146,7 @@ class InMemoryDatabaseAdapter<T> extends EntityDatabaseAdapter<T> {
_tableName: string,
tableIdField: string,
id: any,
object: object
object: object,
): Promise<object[]> {
// SQL does not support empty updates, mirror behavior here for better test simulation
if (Object.keys(object).length === 0) {
@@ -168,7 +168,7 @@ class InMemoryDatabaseAdapter<T> extends EntityDatabaseAdapter<T> {
_queryInterface: any,
_tableName: string,
tableIdField: string,
id: any
id: any,
): Promise<number> {
const objectIndex = dbObjects.findIndex((obj) => {
return obj[tableIdField] === id;
@@ -6,13 +6,13 @@ export default class InMemoryQueryContextProvider extends EntityQueryContextProv
}
protected createTransactionRunner<T>(
_transactionConfig?: TransactionConfig
_transactionConfig?: TransactionConfig,
): (transactionScope: (queryInterface: any) => Promise<T>) => Promise<T> {
return (transactionScope) => Promise.resolve(transactionScope({}));
}
protected createNestedTransactionRunner<T>(
_outerQueryInterface: any
_outerQueryInterface: any,
): (transactionScope: (queryInterface: any) => Promise<T>) => Promise<T> {
return (transactionScope) => Promise.resolve(transactionScope({}));
}
+1 -1
View File
@@ -53,7 +53,7 @@ export default async function createAppAsync(): Promise<Koa<ExampleState, Exampl
'/graphql',
koaMiddleware(server, {
context: async ({ ctx }) => ({ viewerContext: (ctx as ExampleContext).state.viewerContext }),
})
}),
);
app.use(router.routes()).use(router.allowedMethods());
+2 -2
View File
@@ -15,7 +15,7 @@ import InMemoryQueryContextProvider from './adapters/InMemoryQueryContextProvide
* One EntityCompanionProvider should be created per-request.
*/
export const createEntityCompanionProvider = (
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter()
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter(),
): EntityCompanionProvider => {
return new EntityCompanionProvider(
metricsAdapter,
@@ -39,6 +39,6 @@ export const createEntityCompanionProvider = (
cacheAdapterProvider: new InMemoryFullCacheStubCacheAdapterProvider(),
},
],
])
]),
);
};
@@ -26,7 +26,7 @@ export default class AllowIfUserOwnerPrivacyRule<
TFields extends object,
TID extends NonNullable<TFields[TSelectedFields]>,
TEntity extends ReadonlyEntity<TFields, TID, ExampleViewerContext>,
TSelectedFields extends keyof TFields = keyof TFields
TSelectedFields extends keyof TFields = keyof TFields,
> extends PrivacyPolicyRule<TFields, TID, ExampleViewerContext, TEntity> {
constructor(private readonly entityOwnerField: keyof TFields) {
super();
@@ -35,8 +35,14 @@ export default class AllowIfUserOwnerPrivacyRule<
async evaluateAsync(
viewerContext: ExampleViewerContext,
_queryContext: EntityQueryContext,
_evaluationContext: EntityPrivacyPolicyEvaluationContext,
entity: TEntity
_evaluationContext: EntityPrivacyPolicyEvaluationContext<
TFields,
TID,
ExampleViewerContext,
TEntity,
TSelectedFields
>,
entity: TEntity,
): Promise<RuleEvaluationResult> {
if (viewerContext.isUserViewerContext()) {
if (String(entity.getField(this.entityOwnerField)) === viewerContext.userID) {
@@ -30,7 +30,9 @@ router.get('/', async (ctx) => {
router.get('/:id', async (ctx) => {
const viewerContext = ctx.state.viewerContext;
const noteResult = await NoteEntity.loader(viewerContext).loadByIDAsync(ctx.params['id']!);
const noteResult = await NoteEntity.loader(viewerContext)
.withAuthorizationResults()
.loadByIDAsync(ctx.params['id']!);
if (!noteResult.ok) {
ctx.throw(403, noteResult.reason);
return;
@@ -72,7 +74,9 @@ router.put('/:id', async (ctx) => {
const viewerContext = ctx.state.viewerContext;
const { title, body } = ctx.request.body as any;
const noteLoadResult = await NoteEntity.loader(viewerContext).loadByIDAsync(ctx.params['id']!);
const noteLoadResult = await NoteEntity.loader(viewerContext)
.withAuthorizationResults()
.loadByIDAsync(ctx.params['id']!);
if (!noteLoadResult.ok) {
ctx.throw(403, noteLoadResult.reason);
return;
@@ -95,7 +99,9 @@ router.put('/:id', async (ctx) => {
router.delete('/:id', async (ctx) => {
const viewerContext = ctx.state.viewerContext;
const noteLoadResult = await NoteEntity.loader(viewerContext).loadByIDAsync(ctx.params['id']!);
const noteLoadResult = await NoteEntity.loader(viewerContext)
.withAuthorizationResults()
.loadByIDAsync(ctx.params['id']!);
if (!noteLoadResult.ok) {
ctx.throw(403, noteLoadResult.reason);
return;
@@ -17,7 +17,10 @@ export abstract class ExampleViewerContext extends ViewerContext {
* Represents a logged-in user.
*/
export class UserViewerContext extends ExampleViewerContext {
constructor(entityCompanionProvider: EntityCompanionProvider, public userID: string) {
constructor(
entityCompanionProvider: EntityCompanionProvider,
public userID: string,
) {
super(entityCompanionProvider);
}
}
@@ -3,6 +3,28 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.37.0](https://github.com/expo/entity/compare/v0.36.0...v0.37.0) (2024-06-12)
### Bug Fixes
* update barrels ([#241](https://github.com/expo/entity/issues/241)) ([8c5f81b](https://github.com/expo/entity/commit/8c5f81bcca107e9b7bbea5f0ab41b0105057806e))
# [0.36.0](https://github.com/expo/entity/compare/v0.35.0...v0.36.0) (2024-06-12)
### Bug Fixes
* always reload entity after update since cascading changes may have changed it since commit ([#233](https://github.com/expo/entity/issues/233)) ([7c3c985](https://github.com/expo/entity/commit/7c3c9854a4dd91d4b73ebdb18bdeadea8b63f4c7))
# [0.35.0](https://github.com/expo/entity/compare/v0.34.0...v0.35.0) (2024-04-11)
@@ -1,7 +1,7 @@
{
"name": "@expo/entity-full-integration-tests",
"private": true,
"version": "0.35.0",
"version": "0.37.0",
"description": "Full redis and knex integration tests for the entity framework",
"scripts": {
"tsc": "tsc",
@@ -11,7 +11,7 @@
"test": "jest --rootDir . --config ../../resources/jest.config.js --passWithNoTests",
"integration": "../../resources/run-with-docker yarn integration-no-setup",
"integration-no-setup": "jest --config ../../resources/jest-integration.config.js --rootDir . --runInBand --passWithNoTests",
"barrelsby": "barrelsby --directory src --location top --exclude tests__ --singleQuotes --exportDefault --delete"
"barrelsby": "barrelsby --config ../../.barrelsby.json"
},
"engines": {
"node": ">=16"
@@ -27,8 +27,8 @@
"@expo/entity-database-adapter-knex": "*"
},
"devDependencies": {
"@expo/entity": "^0.35.0",
"@expo/entity-cache-adapter-redis": "^0.35.0",
"@expo/entity-database-adapter-knex": "^0.35.0"
"@expo/entity": "^0.37.0",
"@expo/entity-cache-adapter-redis": "^0.37.0",
"@expo/entity-database-adapter-knex": "^0.37.0"
}
}
@@ -114,7 +114,7 @@ describe('Entity cache inconsistency', () => {
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`)
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`),
);
return escapedParts.join(delimiter);
},
@@ -137,7 +137,7 @@ describe('Entity cache inconsistency', () => {
test('lots of updates in long-ish running transactions', async () => {
const viewerContext = new ViewerContext(
createFullIntegrationTestEntityCompanionProvider(knexInstance, genericRedisCacheContext)
createFullIntegrationTestEntityCompanionProvider(knexInstance, genericRedisCacheContext),
);
const entity1 = await TestEntity.creator(viewerContext)
@@ -170,7 +170,7 @@ describe('Entity cache inconsistency', () => {
await barrier1;
const viewerContextInternal = new ViewerContext(
createFullIntegrationTestEntityCompanionProvider(knexInstance, genericRedisCacheContext)
createFullIntegrationTestEntityCompanionProvider(knexInstance, genericRedisCacheContext),
);
await TestEntity.loader(viewerContextInternal).enforcing().loadByIDAsync(entity1.getID());
@@ -187,13 +187,13 @@ describe('Entity cache inconsistency', () => {
// wait for to ensure the transaction isn't committed until after load above occurs
await barrier2;
}
},
),
]);
// ensure cache consistency
const viewerContextLast = new ViewerContext(
createFullIntegrationTestEntityCompanionProvider(knexInstance, genericRedisCacheContext)
createFullIntegrationTestEntityCompanionProvider(knexInstance, genericRedisCacheContext),
);
const loadedById = await TestEntity.loader(viewerContextLast)
@@ -52,7 +52,7 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`)
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`),
);
return escapedParts.join(delimiter);
},
@@ -76,7 +76,7 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
describe('EntityEdgeDeletionBehavior.INVALIDATE_CACHE', () => {
it('invalidates the cache', async () => {
const viewerContext = new ViewerContext(
createFullIntegrationTestEntityCompanionProvider(knexInstance, genericRedisCacheContext)
createFullIntegrationTestEntityCompanionProvider(knexInstance, genericRedisCacheContext),
);
const parent = await ParentEntity.creator(viewerContext).enforceCreateAsync();
@@ -85,22 +85,22 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
.enforceCreateAsync();
await expect(
ParentEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(parent.getID())
ParentEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(parent.getID()),
).resolves.not.toBeNull();
await expect(
ChildEntity.loader(viewerContext)
.enforcing()
.loadByFieldEqualingAsync('parent_id', parent.getID())
.loadByFieldEqualingAsync('parent_id', parent.getID()),
).resolves.not.toBeNull();
await ParentEntity.enforceDeleteAsync(parent);
await expect(
ParentEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(parent.getID())
ParentEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(parent.getID()),
).resolves.toBeNull();
await expect(
ChildEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(child.getID())
ChildEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(child.getID()),
).resolves.toBeNull();
});
});
@@ -0,0 +1,149 @@
import {
EntityPrivacyPolicy,
ViewerContext,
AlwaysAllowPrivacyPolicyRule,
Entity,
EntityCompanionDefinition,
EntityConfiguration,
UUIDField,
} from '@expo/entity';
import { GenericRedisCacheContext } from '@expo/entity-cache-adapter-redis';
import Redis from 'ioredis';
import { knex, Knex } from 'knex';
import nullthrows from 'nullthrows';
import { URL } from 'url';
import { v4 as uuidv4 } from 'uuid';
import { createFullIntegrationTestEntityCompanionProvider } from '../testfixtures/createFullIntegrationTestEntityCompanionProvider';
interface TestFields {
id: string;
}
class TestEntityPrivacyPolicy extends EntityPrivacyPolicy<
TestFields,
string,
ViewerContext,
TestEntity
> {
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<TestFields, string, ViewerContext, TestEntity>(),
];
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<TestFields, string, ViewerContext, TestEntity>(),
];
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<TestFields, string, ViewerContext, TestEntity>(),
];
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<TestFields, string, ViewerContext, TestEntity>(),
];
}
class TestEntity extends Entity<TestFields, string, ViewerContext> {
static defineCompanionDefinition(): EntityCompanionDefinition<
TestFields,
string,
ViewerContext,
TestEntity,
TestEntityPrivacyPolicy
> {
return {
entityClass: TestEntity,
entityConfiguration: testEntityConfiguration,
privacyPolicyClass: TestEntityPrivacyPolicy,
};
}
}
const testEntityConfiguration = new EntityConfiguration<TestFields>({
idField: 'id',
tableName: 'testentities',
schema: {
id: new UUIDField({
columnName: 'id',
cache: true,
}),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
});
async function createOrTruncatePostgresTables(knex: Knex): Promise<void> {
await knex.schema.createTable('testentities', (table) => {
table.uuid('id').defaultTo(knex.raw('gen_random_uuid()')).primary();
});
await knex.into('testentities').truncate();
}
async function dropPostgresTable(knex: Knex): Promise<void> {
if (await knex.schema.hasTable('testentities')) {
await knex.schema.dropTable('testentities');
}
}
describe('Entity integrity', () => {
let knexInstance: Knex;
const redisClient = new Redis(new URL(process.env['REDIS_URL']!).toString());
let genericRedisCacheContext: GenericRedisCacheContext;
beforeAll(() => {
knexInstance = knex({
client: 'pg',
connection: {
user: nullthrows(process.env['PGUSER']),
password: nullthrows(process.env['PGPASSWORD']),
host: 'localhost',
port: parseInt(nullthrows(process.env['PGPORT']), 10),
database: nullthrows(process.env['PGDATABASE']),
},
});
genericRedisCacheContext = {
redisClient,
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`),
);
return escapedParts.join(delimiter);
},
cacheKeyPrefix: 'test-',
ttlSecondsPositive: 86400, // 1 day
ttlSecondsNegative: 600, // 10 minutes
};
});
beforeEach(async () => {
await createOrTruncatePostgresTables(knexInstance);
await redisClient.flushdb();
});
afterAll(async () => {
await dropPostgresTable(knexInstance);
await knexInstance.destroy();
redisClient.disconnect();
});
test('cannot update ID', async () => {
const viewerContext = new ViewerContext(
createFullIntegrationTestEntityCompanionProvider(knexInstance, genericRedisCacheContext),
);
const entity1 = await TestEntity.creator(viewerContext).enforceCreateAsync();
await expect(
TestEntity.updater(entity1).setField('id', uuidv4()).enforceUpdateAsync(),
).rejects.toThrow('id field updates not supported: (entityClass = TestEntity)');
// ensure cache consistency
const viewerContextLast = new ViewerContext(
createFullIntegrationTestEntityCompanionProvider(knexInstance, genericRedisCacheContext),
);
const loadedById = await TestEntity.loader(viewerContextLast)
.enforcing()
.loadByIDAsync(entity1.getID());
expect(loadedById.getID()).toEqual(entity1.getID());
});
});
@@ -191,7 +191,7 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`)
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`),
);
return escapedParts.join(delimiter);
},
@@ -214,11 +214,11 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
])('behavior: %p', async (edgeDeletionBehavior) => {
const { CategoryEntity, OtherEntity } = await makeEntityClasses(
knexInstance,
edgeDeletionBehavior
edgeDeletionBehavior,
);
const viewerContext = new ViewerContext(
createFullIntegrationTestEntityCompanionProvider(knexInstance, genericRedisCacheContext)
createFullIntegrationTestEntityCompanionProvider(knexInstance, genericRedisCacheContext),
);
const category1 = await CategoryEntity.creator(viewerContext).enforceCreateAsync();
@@ -233,7 +233,7 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
if (edgeDeletionBehavior === EntityEdgeDeletionBehavior.SET_NULL) {
await expect(
CategoryEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(category1.getID())
CategoryEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(category1.getID()),
).resolves.toBeNull();
const otherLoaded = await OtherEntity.loader(viewerContext)
.enforcing()
@@ -241,7 +241,7 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
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())
CategoryEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(category1.getID()),
).resolves.toBeNull();
const otherLoaded = await OtherEntity.loader(viewerContext)
.enforcing()
@@ -249,10 +249,10 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
expect(otherLoaded?.getField('parent_category_id')).toBeNull();
} else {
await expect(
CategoryEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(category1.getID())
CategoryEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(category1.getID()),
).resolves.toBeNull();
await expect(
OtherEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(other1.getID())
OtherEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(other1.getID()),
).resolves.toBeNull();
}
});
@@ -16,7 +16,7 @@ import { Knex } from 'knex';
export const createFullIntegrationTestEntityCompanionProvider = (
knex: Knex,
genericRedisCacheContext: GenericRedisCacheContext,
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter()
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter(),
): EntityCompanionProvider => {
return new EntityCompanionProvider(
metricsAdapter,
@@ -36,6 +36,6 @@ export const createFullIntegrationTestEntityCompanionProvider = (
cacheAdapterProvider: new RedisCacheAdapterProvider(genericRedisCacheContext),
},
],
])
]),
);
};
@@ -3,6 +3,22 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.37.0](https://github.com/expo/entity/compare/v0.36.0...v0.37.0) (2024-06-12)
**Note:** Version bump only for package @expo/entity-ip-address-field
# [0.36.0](https://github.com/expo/entity/compare/v0.35.0...v0.36.0) (2024-06-12)
**Note:** Version bump only for package @expo/entity-ip-address-field
# [0.35.0](https://github.com/expo/entity/compare/v0.34.0...v0.35.0) (2024-04-11)
**Note:** Version bump only for package @expo/entity-ip-address-field
@@ -1,6 +1,6 @@
{
"name": "@expo/entity-ip-address-field",
"version": "0.35.0",
"version": "0.37.0",
"description": "IP address EntityField definitions for @expo/entity",
"files": [
"build",
@@ -28,7 +28,7 @@
"ip-address": "^8.1.0"
},
"devDependencies": {
"@expo/entity": "^0.35.0",
"@expo/entity": "^0.37.0",
"@types/jsbn": "^1.2.29"
},
"peerDependencies": {
@@ -12,5 +12,5 @@ describeFieldTestCase(
'1:2:3:4:5:6:7:8/64',
'fedc:ba98:7654:3210:fedc:ba98:7654:3210',
],
['', 'abs', '0.0.0.x/0', '123.21.23', '1:2:3:4:5:6', '198.10/8']
['', 'abs', '0.0.0.x/0', '123.21.23', '1:2:3:4:5:6', '198.10/8'],
);
@@ -3,6 +3,28 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.37.0](https://github.com/expo/entity/compare/v0.36.0...v0.37.0) (2024-06-12)
### Bug Fixes
* update barrels ([#241](https://github.com/expo/entity/issues/241)) ([8c5f81b](https://github.com/expo/entity/commit/8c5f81bcca107e9b7bbea5f0ab41b0105057806e))
# [0.36.0](https://github.com/expo/entity/compare/v0.35.0...v0.36.0) (2024-06-12)
### Bug Fixes
* constrain entity fields type to string-keyed object ([#235](https://github.com/expo/entity/issues/235)) ([7e2cea1](https://github.com/expo/entity/commit/7e2cea16973a0ae1917f867cd25d6ef7c8eaecef))
# [0.35.0](https://github.com/expo/entity/compare/v0.34.0...v0.35.0) (2024-04-11)
@@ -1,6 +1,6 @@
{
"name": "@expo/entity-secondary-cache-local-memory",
"version": "0.35.0",
"version": "0.37.0",
"description": "Local memory secondary cache for @expo/entity",
"files": [
"build",
@@ -16,7 +16,7 @@
"test": "jest --rootDir . --config ../../resources/jest.config.js --passWithNoTests",
"integration": "../../resources/run-with-docker yarn integration-no-setup",
"integration-no-setup": "jest --config ../../resources/jest-integration.config.js --rootDir . --runInBand --passWithNoTests",
"barrelsby": "barrelsby --directory src --location top --exclude tests__ --singleQuotes --exportDefault --delete"
"barrelsby": "barrelsby --config ../../.barrelsby.json"
},
"engines": {
"node": ">=16"
@@ -31,8 +31,8 @@
"@expo/entity-cache-adapter-local-memory": "*"
},
"devDependencies": {
"@expo/entity": "^0.35.0",
"@expo/entity-cache-adapter-local-memory": "^0.35.0",
"@expo/entity": "^0.37.0",
"@expo/entity-cache-adapter-local-memory": "^0.37.0",
"nullthrows": "^1.1.1"
}
}
@@ -12,15 +12,15 @@ import {
* TLoadParams must be JSON stringifyable.
*/
export default class LocalMemorySecondaryEntityCache<
TFields,
TLoadParams
TFields extends Record<string, any>,
TLoadParams,
> extends GenericSecondaryEntityCache<TFields, TLoadParams> {
constructor(
entityConfiguration: EntityConfiguration<TFields>,
localMemoryCache: LocalMemoryCache<TFields>
localMemoryCache: LocalMemoryCache<TFields>,
) {
super(new GenericLocalMemoryCacher(entityConfiguration, localMemoryCache), (params) =>
JSON.stringify(params)
JSON.stringify(params),
);
}
}
@@ -105,7 +105,7 @@ export const localMemoryTestEntityConfiguration =
});
export const createTestEntityCompanionProvider = (
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter()
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter(),
): EntityCompanionProvider => {
return new EntityCompanionProvider(
metricsAdapter,
@@ -125,7 +125,7 @@ export const createTestEntityCompanionProvider = (
cacheAdapterProvider: LocalMemoryCacheAdapterProvider.createProviderWithOptions(),
},
],
])
]),
);
};
@@ -146,7 +146,7 @@ class TestSecondaryLocalMemoryCacheLoader extends EntitySecondaryCacheLoader<
public databaseLoadCount = 0;
protected async fetchObjectsFromDatabaseAsync(
loadParamsArray: readonly Readonly<TestLoadParams>[]
loadParamsArray: readonly Readonly<TestLoadParams>[],
): Promise<ReadonlyMap<Readonly<TestLoadParams>, Readonly<LocalMemoryTestEntityFields> | null>> {
this.databaseLoadCount += loadParamsArray.length;
@@ -162,7 +162,7 @@ class TestSecondaryLocalMemoryCacheLoader extends EntitySecondaryCacheLoader<
.loadManyByFieldEqualityConjunctionAsync([
{ fieldName: 'id', fieldValue: loadParams.id },
])
)[0]
)[0],
).getAllFields();
});
}
@@ -179,22 +179,22 @@ describe(LocalMemorySecondaryEntityCache, () => {
const secondaryCacheLoader = new TestSecondaryLocalMemoryCacheLoader(
new LocalMemorySecondaryEntityCache(
localMemoryTestEntityConfiguration,
GenericLocalMemoryCacher.createLRUCache<LocalMemoryTestEntityFields>({})
GenericLocalMemoryCacher.createLRUCache<LocalMemoryTestEntityFields>({}),
),
LocalMemoryTestEntity.loader(viewerContext)
LocalMemoryTestEntity.loader(viewerContext),
);
const loadParams = { id: createdEntity.getID() };
const results = await secondaryCacheLoader.loadManyAsync([loadParams]);
expect(nullthrows(results.get(loadParams)).enforceValue().getID()).toEqual(
createdEntity.getID()
createdEntity.getID(),
);
expect(secondaryCacheLoader.databaseLoadCount).toEqual(1);
const results2 = await secondaryCacheLoader.loadManyAsync([loadParams]);
expect(nullthrows(results2.get(loadParams)).enforceValue().getID()).toEqual(
createdEntity.getID()
createdEntity.getID(),
);
expect(secondaryCacheLoader.databaseLoadCount).toEqual(1);
@@ -203,7 +203,7 @@ describe(LocalMemorySecondaryEntityCache, () => {
const results3 = await secondaryCacheLoader.loadManyAsync([loadParams]);
expect(nullthrows(results3.get(loadParams)).enforceValue().getID()).toEqual(
createdEntity.getID()
createdEntity.getID(),
);
expect(secondaryCacheLoader.databaseLoadCount).toEqual(2);
@@ -215,9 +215,9 @@ describe(LocalMemorySecondaryEntityCache, () => {
const secondaryCacheLoader = new TestSecondaryLocalMemoryCacheLoader(
new LocalMemorySecondaryEntityCache(
localMemoryTestEntityConfiguration,
GenericLocalMemoryCacher.createLRUCache<LocalMemoryTestEntityFields>({})
GenericLocalMemoryCacher.createLRUCache<LocalMemoryTestEntityFields>({}),
),
LocalMemoryTestEntity.loader(viewerContext)
LocalMemoryTestEntity.loader(viewerContext),
);
const loadParams = { id: FAKE_ID };
@@ -3,6 +3,28 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.37.0](https://github.com/expo/entity/compare/v0.36.0...v0.37.0) (2024-06-12)
### Bug Fixes
* update barrels ([#241](https://github.com/expo/entity/issues/241)) ([8c5f81b](https://github.com/expo/entity/commit/8c5f81bcca107e9b7bbea5f0ab41b0105057806e))
# [0.36.0](https://github.com/expo/entity/compare/v0.35.0...v0.36.0) (2024-06-12)
### Bug Fixes
* constrain entity fields type to string-keyed object ([#235](https://github.com/expo/entity/issues/235)) ([7e2cea1](https://github.com/expo/entity/commit/7e2cea16973a0ae1917f867cd25d6ef7c8eaecef))
# [0.35.0](https://github.com/expo/entity/compare/v0.34.0...v0.35.0) (2024-04-11)
@@ -1,6 +1,6 @@
{
"name": "@expo/entity-secondary-cache-redis",
"version": "0.35.0",
"version": "0.37.0",
"description": "Redis secondary cache for @expo/entity",
"files": [
"build",
@@ -16,7 +16,7 @@
"test": "jest --rootDir . --config ../../resources/jest.config.js --passWithNoTests",
"integration": "../../resources/run-with-docker yarn integration-no-setup",
"integration-no-setup": "jest --config ../../resources/jest-integration.config.js --rootDir . --runInBand --passWithNoTests",
"barrelsby": "barrelsby --directory src --location top --exclude tests__ --singleQuotes --exportDefault --delete"
"barrelsby": "barrelsby --config ../../.barrelsby.json"
},
"engines": {
"node": ">=16"
@@ -31,8 +31,8 @@
"@expo/entity-cache-adapter-redis": "*"
},
"devDependencies": {
"@expo/entity": "^0.35.0",
"@expo/entity-cache-adapter-redis": "^0.35.0",
"@expo/entity": "^0.37.0",
"@expo/entity-cache-adapter-redis": "^0.37.0",
"ioredis": "^5.2.5",
"nullthrows": "^1.1.1"
}
@@ -5,13 +5,13 @@ import { GenericRedisCacheContext, GenericRedisCacher } from '@expo/entity-cache
* A redis GenericSecondaryEntityCache.
*/
export default class RedisSecondaryEntityCache<
TFields,
TLoadParams
TFields extends Record<string, any>,
TLoadParams,
> extends GenericSecondaryEntityCache<TFields, TLoadParams> {
constructor(
entityConfiguration: EntityConfiguration<TFields>,
genericRedisCacheContext: GenericRedisCacheContext,
constructRedisKey: (params: Readonly<TLoadParams>) => string
constructRedisKey: (params: Readonly<TLoadParams>) => string,
) {
super(new GenericRedisCacher(genericRedisCacheContext, entityConfiguration), constructRedisKey);
}
@@ -29,7 +29,7 @@ class TestSecondaryRedisCacheLoader extends EntitySecondaryCacheLoader<
public databaseLoadCount = 0;
protected async fetchObjectsFromDatabaseAsync(
loadParamsArray: readonly Readonly<TestLoadParams>[]
loadParamsArray: readonly Readonly<TestLoadParams>[],
): Promise<ReadonlyMap<Readonly<TestLoadParams>, Readonly<RedisTestEntityFields> | null>> {
this.databaseLoadCount += loadParamsArray.length;
@@ -45,7 +45,7 @@ class TestSecondaryRedisCacheLoader extends EntitySecondaryCacheLoader<
.loadManyByFieldEqualityConjunctionAsync([
{ fieldName: 'id', fieldValue: loadParams.id },
])
)[0]
)[0],
).getAllFields();
});
}
@@ -76,7 +76,7 @@ describe(RedisSecondaryEntityCache, () => {
it('Loads through secondary loader, caches, and invalidates', async () => {
const viewerContext = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext)
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext),
);
const createdEntity = await RedisTestEntity.creator(viewerContext)
@@ -87,22 +87,22 @@ describe(RedisSecondaryEntityCache, () => {
new RedisSecondaryEntityCache(
redisTestEntityConfiguration,
genericRedisCacheContext,
(loadParams) => `test-key-${loadParams.id}`
(loadParams) => `test-key-${loadParams.id}`,
),
RedisTestEntity.loader(viewerContext)
RedisTestEntity.loader(viewerContext),
);
const loadParams = { id: createdEntity.getID() };
const results = await secondaryCacheLoader.loadManyAsync([loadParams]);
expect(nullthrows(results.get(loadParams)).enforceValue().getID()).toEqual(
createdEntity.getID()
createdEntity.getID(),
);
expect(secondaryCacheLoader.databaseLoadCount).toEqual(1);
const results2 = await secondaryCacheLoader.loadManyAsync([loadParams]);
expect(nullthrows(results2.get(loadParams)).enforceValue().getID()).toEqual(
createdEntity.getID()
createdEntity.getID(),
);
expect(secondaryCacheLoader.databaseLoadCount).toEqual(1);
@@ -111,7 +111,7 @@ describe(RedisSecondaryEntityCache, () => {
const results3 = await secondaryCacheLoader.loadManyAsync([loadParams]);
expect(nullthrows(results3.get(loadParams)).enforceValue().getID()).toEqual(
createdEntity.getID()
createdEntity.getID(),
);
expect(secondaryCacheLoader.databaseLoadCount).toEqual(2);
@@ -119,16 +119,16 @@ describe(RedisSecondaryEntityCache, () => {
it('correctly handles uncached and unfetchable load params', async () => {
const viewerContext = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext)
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext),
);
const secondaryCacheLoader = new TestSecondaryRedisCacheLoader(
new RedisSecondaryEntityCache(
redisTestEntityConfiguration,
genericRedisCacheContext,
(loadParams) => `test-key-${loadParams.id}`
(loadParams) => `test-key-${loadParams.id}`,
),
RedisTestEntity.loader(viewerContext)
RedisTestEntity.loader(viewerContext),
);
const loadParams = { id: FAKE_ID };
@@ -12,7 +12,7 @@ import {
export const createRedisIntegrationTestEntityCompanionProvider = (
genericRedisCacheContext: GenericRedisCacheContext,
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter()
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter(),
): EntityCompanionProvider => {
return new EntityCompanionProvider(
metricsAdapter,
@@ -32,6 +32,6 @@ export const createRedisIntegrationTestEntityCompanionProvider = (
cacheAdapterProvider: new RedisCacheAdapterProvider(genericRedisCacheContext),
},
],
])
]),
);
};
+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.37.0](https://github.com/expo/entity/compare/v0.36.0...v0.37.0) (2024-06-12)
### Bug Fixes
* update barrels ([#241](https://github.com/expo/entity/issues/241)) ([8c5f81b](https://github.com/expo/entity/commit/8c5f81bcca107e9b7bbea5f0ab41b0105057806e))
# [0.36.0](https://github.com/expo/entity/compare/v0.35.0...v0.36.0) (2024-06-12)
### Bug Fixes
* always reload entity after update since cascading changes may have changed it since commit ([#233](https://github.com/expo/entity/issues/233)) ([7c3c985](https://github.com/expo/entity/commit/7c3c9854a4dd91d4b73ebdb18bdeadea8b63f4c7))
* constrain entity fields type to string-keyed object ([#235](https://github.com/expo/entity/issues/235)) ([7e2cea1](https://github.com/expo/entity/commit/7e2cea16973a0ae1917f867cd25d6ef7c8eaecef))
* disallow keys of JS Object prototype for safety ([#236](https://github.com/expo/entity/issues/236)) ([05726d4](https://github.com/expo/entity/commit/05726d45fa9b91a6b25ddbc9c7e3e097b02097ca))
### Features
* Add global mutation trigger field to EntityCompanionProvider ([#215](https://github.com/expo/entity/issues/215)) ([6569486](https://github.com/expo/entity/commit/6569486ecddf2fd33c626f634c761e06636b1730))
* add previousValue to privacy policy context for updates ([#232](https://github.com/expo/entity/issues/232)) ([af495a9](https://github.com/expo/entity/commit/af495a9a856a5042f00cfd150348e415139cb85e))
* Add StrictEnumField with better validation ([#222](https://github.com/expo/entity/issues/222)) ([8753252](https://github.com/expo/entity/commit/87532525d090b3a67f1cedd95ac990f7dcb3ab5d))
* make canViewerDeleteAsync recursive ([#224](https://github.com/expo/entity/issues/224)) ([60fc9a4](https://github.com/expo/entity/commit/60fc9a4c4b587b866f2f5a1b3bd22145bc77e6e9))
* Use uuid v7 in stub database adapter ([#234](https://github.com/expo/entity/issues/234)) ([1e8ea64](https://github.com/expo/entity/commit/1e8ea64017430a2ce5ea197678bd78713ad77ecd))
# [0.35.0](https://github.com/expo/entity/compare/v0.34.0...v0.35.0) (2024-04-11)
+4 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/entity",
"version": "0.35.0",
"version": "0.37.0",
"description": "A privacy-first data model",
"files": [
"build",
@@ -16,7 +16,7 @@
"test": "jest --rootDir . --config ../../resources/jest.config.js",
"integration": "../../resources/run-with-docker yarn integration-no-setup",
"integration-no-setup": "jest --config ../../resources/jest-integration.config.js --rootDir . --runInBand --passWithNoTests",
"barrelsby": "barrelsby --directory src --location top --exclude tests__ --singleQuotes --exportDefault --delete"
"barrelsby": "barrelsby --config ../../.barrelsby.json"
},
"engines": {
"node": ">=16"
@@ -31,6 +31,7 @@
"dataloader": "^2.0.0",
"es6-error": "^4.1.1",
"invariant": "^2.2.4",
"uuid": "^8.3.0"
"uuid": "^8.3.0",
"uuidv7": "^1.0.0"
}
}
@@ -0,0 +1,297 @@
import { Result, result } from '@expo/results';
import invariant from 'invariant';
import nullthrows from 'nullthrows';
import { IEntityClass } from './Entity';
import EntityConfiguration from './EntityConfiguration';
import {
FieldEqualityCondition,
QuerySelectionModifiers,
isSingleValueFieldEqualityCondition,
QuerySelectionModifiersWithOrderByRaw,
} from './EntityDatabaseAdapter';
import EntityLoaderUtils from './EntityLoaderUtils';
import EntityPrivacyPolicy from './EntityPrivacyPolicy';
import { EntityQueryContext } from './EntityQueryContext';
import ReadonlyEntity from './ReadonlyEntity';
import ViewerContext from './ViewerContext';
import EntityInvalidFieldValueError from './errors/EntityInvalidFieldValueError';
import EntityNotFoundError from './errors/EntityNotFoundError';
import EntityDataManager from './internal/EntityDataManager';
import IEntityMetricsAdapter from './metrics/IEntityMetricsAdapter';
import { mapMap } from './utils/collections/maps';
/**
* Authorization-result-based entity loader. All normal loads are batched,
* cached, and authorized against the entity's EntityPrivacyPolicy. All loads through this
* loader are are results (or null for some loader methods), where an unsuccessful result
* means an authorization error or entity construction error occurred. Other errors are thrown.
*/
export default class AuthorizationResultBasedEntityLoader<
TFields extends object,
TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
TPrivacyPolicy extends EntityPrivacyPolicy<
TFields,
TID,
TViewerContext,
TEntity,
TSelectedFields
>,
TSelectedFields extends keyof TFields,
> {
constructor(
private readonly queryContext: EntityQueryContext,
private readonly entityConfiguration: EntityConfiguration<TFields>,
private readonly entityClass: IEntityClass<
TFields,
TID,
TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
>,
private readonly dataManager: EntityDataManager<TFields>,
protected readonly metricsAdapter: IEntityMetricsAdapter,
private readonly utils: EntityLoaderUtils<
TFields,
TID,
TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
>,
) {}
/**
* Load many entities where fieldName is one of fieldValues.
* @param fieldName - entity field being queried
* @param fieldValues - fieldName field values being queried
* @returns map from fieldValue to entity results that match the query for that fieldValue,
* where result errors can be UnauthorizedError
*/
async loadManyByFieldEqualingManyAsync<N extends keyof Pick<TFields, TSelectedFields>>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[],
): Promise<ReadonlyMap<NonNullable<TFields[N]>, readonly Result<TEntity>[]>> {
this.validateFieldValues(fieldName, fieldValues);
const fieldValuesToFieldObjects = await this.dataManager.loadManyByFieldEqualingAsync(
this.queryContext,
fieldName,
fieldValues,
);
return await this.utils.constructAndAuthorizeEntitiesAsync(fieldValuesToFieldObjects);
}
/**
* Load many entities where fieldName equals fieldValue.
* @param fieldName - entity field being queried
* @param fieldValue - fieldName field value being queried
* @returns array of entity results that match the query for fieldValue, where result error can be UnauthorizedError
*/
async loadManyByFieldEqualingAsync<N extends keyof Pick<TFields, TSelectedFields>>(
fieldName: N,
fieldValue: NonNullable<TFields[N]>,
): Promise<readonly Result<TEntity>[]> {
const entityResults = await this.loadManyByFieldEqualingManyAsync(fieldName, [fieldValue]);
const entityResultsForFieldValue = entityResults.get(fieldValue);
invariant(
entityResultsForFieldValue !== undefined,
`${fieldValue} should be guaranteed to be present in returned map of entities`,
);
return entityResultsForFieldValue!;
}
/**
* Load an entity where fieldName equals fieldValue, or null if no entity exists.
* @param uniqueFieldName - entity field being queried
* @param fieldValue - uniqueFieldName field value being queried
* @returns entity result where uniqueFieldName equals fieldValue, or null if no entity matches the condition.
* @throws when multiple entities match the condition
*/
async loadByFieldEqualingAsync<N extends keyof Pick<TFields, TSelectedFields>>(
uniqueFieldName: N,
fieldValue: NonNullable<TFields[N]>,
): Promise<Result<TEntity> | null> {
const entityResults = await this.loadManyByFieldEqualingAsync(uniqueFieldName, fieldValue);
invariant(
entityResults.length <= 1,
`loadByFieldEqualing: Multiple entities of type ${this.entityClass.name} found for ${String(
uniqueFieldName,
)}=${fieldValue}`,
);
return entityResults[0] ?? null;
}
/**
* Loads an entity by a specified ID.
* @param id - ID of the entity
* @returns entity result for matching ID, where result error can be UnauthorizedError or EntityNotFoundError.
*/
async loadByIDAsync(id: TID): Promise<Result<TEntity>> {
const entityResults = await this.loadManyByIDsAsync([id]);
// loadManyByIDsAsync is always populated for each id supplied
return nullthrows(entityResults.get(id));
}
/**
* Load an entity by a specified ID, or return null if non-existent.
* @param id - ID of the entity
* @returns entity result for matching ID, or null if no entity exists for ID.
*/
async loadByIDNullableAsync(id: TID): Promise<Result<TEntity> | null> {
return await this.loadByFieldEqualingAsync(
this.entityConfiguration.idField as TSelectedFields,
id,
);
}
/**
* Loads many entities for a list of IDs.
* @param ids - IDs of the entities to load
* @returns map from ID to corresponding entity result, where result error can be UnauthorizedError or EntityNotFoundError.
*/
async loadManyByIDsAsync(ids: readonly TID[]): Promise<ReadonlyMap<TID, Result<TEntity>>> {
const entityResults = (await this.loadManyByFieldEqualingManyAsync(
this.entityConfiguration.idField as TSelectedFields,
ids,
)) as ReadonlyMap<TID, readonly Result<TEntity>[]>;
return mapMap(entityResults, (entityResultsForId, id) => {
const entityResult = entityResultsForId[0];
return (
entityResult ??
result(new EntityNotFoundError(this.entityClass, this.entityConfiguration.idField, id))
);
});
}
/**
* Loads many entities for a list of IDs, returning null for any IDs that are non-existent.
* @param ids - IDs of the entities to load
* @returns map from ID to nullable corresponding entity result, where result error can be UnauthorizedError or EntityNotFoundError.
*/
async loadManyByIDsNullableAsync(
ids: readonly TID[],
): Promise<ReadonlyMap<TID, Result<TEntity> | null>> {
const entityResults = (await this.loadManyByFieldEqualingManyAsync(
this.entityConfiguration.idField as TSelectedFields,
ids,
)) as ReadonlyMap<TID, readonly Result<TEntity>[]>;
return mapMap(entityResults, (entityResultsForId) => {
return entityResultsForId[0] ?? null;
});
}
/**
* Loads the first entity matching the selection constructed from the conjunction of specified
* operands, or null if no matching entity exists. Entities loaded using this method are not
* batched or cached.
*
* This is a convenience method for {@link loadManyByFieldEqualityConjunctionAsync}. However, the
* `orderBy` option must be specified to define what "first" means. If ordering doesn't matter,
* explicitly pass in an empty array.
*
* @param fieldEqualityOperands - list of field equality selection operand specifications
* @param querySelectionModifiers - orderBy and optional offset for the query
* @returns the first entity results that matches the query, where result error can be
* UnauthorizedError
*/
async loadFirstByFieldEqualityConjunctionAsync<N extends keyof Pick<TFields, TSelectedFields>>(
fieldEqualityOperands: FieldEqualityCondition<TFields, N>[],
querySelectionModifiers: Omit<QuerySelectionModifiers<TFields>, 'limit'> &
Required<Pick<QuerySelectionModifiers<TFields>, 'orderBy'>>,
): Promise<Result<TEntity> | null> {
const results = await this.loadManyByFieldEqualityConjunctionAsync(fieldEqualityOperands, {
...querySelectionModifiers,
limit: 1,
});
return results[0] ?? null;
}
/**
* Loads many entities matching the selection constructed from the conjunction of specified operands.
* Entities loaded using this method are not batched or cached.
*
* @example
* fieldEqualityOperands:
* `[{fieldName: 'hello', fieldValue: 1}, {fieldName: 'world', fieldValues: [2, 3]}]`
* Entities returned with a SQL EntityDatabaseAdapter:
* `WHERE hello = 1 AND world = ANY({2, 3})`
*
* @param fieldEqualityOperands - list of field equality selection operand specifications
* @param querySelectionModifiers - limit, offset, and orderBy for the query
* @returns array of entity results that match the query, where result error can be UnauthorizedError
*/
async loadManyByFieldEqualityConjunctionAsync<N extends keyof Pick<TFields, TSelectedFields>>(
fieldEqualityOperands: FieldEqualityCondition<TFields, N>[],
querySelectionModifiers: QuerySelectionModifiers<TFields> = {},
): Promise<readonly Result<TEntity>[]> {
for (const fieldEqualityOperand of fieldEqualityOperands) {
const fieldValues = isSingleValueFieldEqualityCondition(fieldEqualityOperand)
? [fieldEqualityOperand.fieldValue]
: fieldEqualityOperand.fieldValues;
this.validateFieldValues(fieldEqualityOperand.fieldName, fieldValues);
}
const fieldObjects = await this.dataManager.loadManyByFieldEqualityConjunctionAsync(
this.queryContext,
fieldEqualityOperands,
querySelectionModifiers,
);
return await this.utils.constructAndAuthorizeEntitiesArrayAsync(fieldObjects);
}
/**
* Loads many entities matching the raw WHERE clause. Corresponds to the knex `whereRaw` argument format.
*
* @remarks
* Important notes:
* - Fields in clause are database column names instead of transformed entity field names.
* - Entities loaded using this method are not batched or cached.
* - Not all database adapters implement the ability to execute this method of fetching entities.
*
* @example
* rawWhereClause: `id = ?`
* bindings: `[1]`
* Entites returned `WHERE id = 1`
*
* 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, 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
*/
async loadManyByRawWhereClauseAsync(
rawWhereClause: string,
bindings: any[] | object,
querySelectionModifiers: QuerySelectionModifiersWithOrderByRaw<TFields> = {},
): Promise<readonly Result<TEntity>[]> {
const fieldObjects = await this.dataManager.loadManyByRawWhereClauseAsync(
this.queryContext,
rawWhereClause,
bindings,
querySelectionModifiers,
);
return await this.utils.constructAndAuthorizeEntitiesArrayAsync(fieldObjects);
}
private validateFieldValues<N extends keyof Pick<TFields, TSelectedFields>>(
fieldName: N,
fieldValues: readonly TFields[N][],
): void {
const fieldDefinition = this.entityConfiguration.schema.get(fieldName);
invariant(fieldDefinition, `must have field definition for field = ${String(fieldName)}`);
for (const fieldValue of fieldValues) {
const isInputValid = fieldDefinition.validateInputValue(fieldValue);
if (!isInputValid) {
throw new EntityInvalidFieldValueError(this.entityClass, fieldName, fieldValue);
}
}
}
}
@@ -17,12 +17,12 @@ export default class ComposedEntityCacheAdapter<TFields> implements IEntityCache
public async loadManyAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
fieldValues: readonly NonNullable<TFields[N]>[],
): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult<TFields>>> {
const retMap = new Map<NonNullable<TFields[N]>, CacheLoadResult<TFields>>();
const fulfilledFieldValuesByCacheIndex: NonNullable<TFields[N]>[][] = Array.from(
{ length: this.cacheAdapters.length },
() => []
() => [],
);
let unfulfilledFieldValues = fieldValues;
@@ -30,7 +30,7 @@ export default class ComposedEntityCacheAdapter<TFields> implements IEntityCache
const cacheAdapter = nullthrows(this.cacheAdapters[i]);
const cacheResultsFromAdapter = await cacheAdapter.loadManyAsync(
fieldName,
unfulfilledFieldValues
unfulfilledFieldValues,
);
const newUnfulfilledFieldValues = [];
@@ -87,7 +87,7 @@ export default class ComposedEntityCacheAdapter<TFields> implements IEntityCache
public async cacheManyAsync<N extends keyof TFields>(
fieldName: N,
objectMap: ReadonlyMap<NonNullable<TFields[N]>, Readonly<TFields>>
objectMap: ReadonlyMap<NonNullable<TFields[N]>, Readonly<TFields>>,
): Promise<void> {
// write to lower layers first
for (let i = this.cacheAdapters.length - 1; i >= 0; i--) {
@@ -98,7 +98,7 @@ export default class ComposedEntityCacheAdapter<TFields> implements IEntityCache
public async cacheDBMissesAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
fieldValues: readonly NonNullable<TFields[N]>[],
): Promise<void> {
// write to lower layers first
for (let i = this.cacheAdapters.length - 1; i >= 0; i--) {
@@ -109,7 +109,7 @@ export default class ComposedEntityCacheAdapter<TFields> implements IEntityCache
public async invalidateManyAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
fieldValues: readonly NonNullable<TFields[N]>[],
): Promise<void> {
// delete from lower layers first
for (let i = this.cacheAdapters.length - 1; i >= 0; i--) {
@@ -14,19 +14,19 @@ export default class ComposedSecondaryEntityCache<TLoadParams, TFields>
* Typically, caches closer to the application should be ordered before caches closer to the database.
*/
constructor(
private readonly secondaryEntityCaches: ISecondaryEntityCache<TFields, TLoadParams>[]
private readonly secondaryEntityCaches: ISecondaryEntityCache<TFields, TLoadParams>[],
) {}
async loadManyThroughAsync(
loadParamsArray: readonly Readonly<TLoadParams>[],
fetcher: (
fetcherLoadParamsArray: readonly Readonly<TLoadParams>[]
) => Promise<ReadonlyMap<Readonly<TLoadParams>, Readonly<TFields> | null>>
fetcherLoadParamsArray: readonly Readonly<TLoadParams>[],
) => Promise<ReadonlyMap<Readonly<TLoadParams>, Readonly<TFields> | null>>,
): Promise<ReadonlyMap<Readonly<TLoadParams>, Readonly<TFields> | null>> {
return await ComposedSecondaryEntityCache.loadManyThroughRecursivelyAsync(
this.secondaryEntityCaches,
loadParamsArray,
fetcher
fetcher,
);
}
@@ -34,8 +34,8 @@ export default class ComposedSecondaryEntityCache<TLoadParams, TFields>
secondaryEntityCaches: ISecondaryEntityCache<TFields, TLoadParams>[],
loadParamsArray: readonly Readonly<TLoadParams>[],
fetcher: (
fetcherLoadParamsArray: readonly Readonly<TLoadParams>[]
) => Promise<ReadonlyMap<Readonly<TLoadParams>, Readonly<TFields> | null>>
fetcherLoadParamsArray: readonly Readonly<TLoadParams>[],
) => Promise<ReadonlyMap<Readonly<TLoadParams>, Readonly<TFields> | null>>,
): Promise<ReadonlyMap<Readonly<TLoadParams>, Readonly<TFields> | null>> {
if (secondaryEntityCaches.length === 0) {
return await fetcher(loadParamsArray);
@@ -49,8 +49,8 @@ export default class ComposedSecondaryEntityCache<TLoadParams, TFields>
ComposedSecondaryEntityCache.loadManyThroughRecursivelyAsync(
restCaches,
fetcherLoadParamsArray,
fetcher
)
fetcher,
),
);
}
+20 -19
View File
@@ -1,17 +1,18 @@
import AuthorizationResultBasedEntityLoader from './AuthorizationResultBasedEntityLoader';
import {
FieldEqualityCondition,
QuerySelectionModifiers,
QuerySelectionModifiersWithOrderByRaw,
} from './EntityDatabaseAdapter';
import EntityLoader from './EntityLoader';
import EntityPrivacyPolicy from './EntityPrivacyPolicy';
import ReadonlyEntity from './ReadonlyEntity';
import ViewerContext from './ViewerContext';
import { mapMap } from './utils/collections/maps';
/**
* Enforcing view on an entity loader. All loads through this loader will throw
* if the loads are not successful.
* Enforcing entity loader. All normal loads are batched,
* cached, and authorized against the entity's EntityPrivacyPolicy. All loads
* through this loader will throw if the load is not successful.
*/
export default class EnforcingEntityLoader<
TFields extends object,
@@ -25,17 +26,17 @@ export default class EnforcingEntityLoader<
TEntity,
TSelectedFields
>,
TSelectedFields extends keyof TFields
TSelectedFields extends keyof TFields,
> {
constructor(
private readonly entityLoader: EntityLoader<
private readonly entityLoader: AuthorizationResultBasedEntityLoader<
TFields,
TID,
TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
>
>,
) {}
/**
@@ -44,14 +45,14 @@ export default class EnforcingEntityLoader<
*/
async loadManyByFieldEqualingManyAsync<N extends keyof Pick<TFields, TSelectedFields>>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
fieldValues: readonly NonNullable<TFields[N]>[],
): Promise<ReadonlyMap<NonNullable<TFields[N]>, readonly TEntity[]>> {
const fieldValuesToResults = await this.entityLoader.loadManyByFieldEqualingManyAsync(
fieldName,
fieldValues
fieldValues,
);
return mapMap(fieldValuesToResults, (results) =>
results.map((result) => result.enforceValue())
results.map((result) => result.enforceValue()),
);
}
@@ -61,11 +62,11 @@ export default class EnforcingEntityLoader<
*/
async loadManyByFieldEqualingAsync<N extends keyof Pick<TFields, TSelectedFields>>(
fieldName: N,
fieldValue: NonNullable<TFields[N]>
fieldValue: NonNullable<TFields[N]>,
): Promise<readonly TEntity[]> {
const entityResults = await this.entityLoader.loadManyByFieldEqualingAsync(
fieldName,
fieldValue
fieldValue,
);
return entityResults.map((result) => result.enforceValue());
}
@@ -77,11 +78,11 @@ export default class EnforcingEntityLoader<
*/
async loadByFieldEqualingAsync<N extends keyof Pick<TFields, TSelectedFields>>(
uniqueFieldName: N,
fieldValue: NonNullable<TFields[N]>
fieldValue: NonNullable<TFields[N]>,
): Promise<TEntity | null> {
const entityResult = await this.entityLoader.loadByFieldEqualingAsync(
uniqueFieldName,
fieldValue
fieldValue,
);
return entityResult ? entityResult.enforceValue() : null;
}
@@ -130,11 +131,11 @@ export default class EnforcingEntityLoader<
async loadFirstByFieldEqualityConjunctionAsync<N extends keyof Pick<TFields, TSelectedFields>>(
fieldEqualityOperands: FieldEqualityCondition<TFields, N>[],
querySelectionModifiers: Omit<QuerySelectionModifiers<TFields>, 'limit'> &
Required<Pick<QuerySelectionModifiers<TFields>, 'orderBy'>>
Required<Pick<QuerySelectionModifiers<TFields>, 'orderBy'>>,
): Promise<TEntity | null> {
const entityResult = await this.entityLoader.loadFirstByFieldEqualityConjunctionAsync(
fieldEqualityOperands,
querySelectionModifiers
querySelectionModifiers,
);
return entityResult ? entityResult.enforceValue() : null;
}
@@ -145,11 +146,11 @@ export default class EnforcingEntityLoader<
*/
async loadManyByFieldEqualityConjunctionAsync<N extends keyof Pick<TFields, TSelectedFields>>(
fieldEqualityOperands: FieldEqualityCondition<TFields, N>[],
querySelectionModifiers: QuerySelectionModifiers<TFields> = {}
querySelectionModifiers: QuerySelectionModifiers<TFields> = {},
): Promise<readonly TEntity[]> {
const entityResults = await this.entityLoader.loadManyByFieldEqualityConjunctionAsync(
fieldEqualityOperands,
querySelectionModifiers
querySelectionModifiers,
);
return entityResults.map((result) => result.enforceValue());
}
@@ -161,12 +162,12 @@ export default class EnforcingEntityLoader<
async loadManyByRawWhereClauseAsync(
rawWhereClause: string,
bindings: any[] | object,
querySelectionModifiers: QuerySelectionModifiersWithOrderByRaw<TFields> = {}
querySelectionModifiers: QuerySelectionModifiersWithOrderByRaw<TFields> = {},
): Promise<readonly TEntity[]> {
const entityResults = await this.entityLoader.loadManyByRawWhereClauseAsync(
rawWhereClause,
bindings,
querySelectionModifiers
querySelectionModifiers,
);
return entityResults.map((result) => result.enforceValue());
}
+11 -126
View File
@@ -1,4 +1,4 @@
import { Result, asyncResult } from '@expo/results';
import { Result } from '@expo/results';
import { EntityCompanionDefinition } from './EntityCompanionProvider';
import { CreateMutator, UpdateMutator } from './EntityMutator';
@@ -29,7 +29,7 @@ export default abstract class Entity<
TFields extends object,
TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext,
TSelectedFields extends keyof TFields = keyof TFields
TSelectedFields extends keyof TFields = keyof TFields,
> extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields> {
/**
* Vend mutator for creating a new entity in given query context.
@@ -50,7 +50,7 @@ export default abstract class Entity<
TMEntity,
TMSelectedFields
>,
TMSelectedFields extends keyof TMFields = keyof TMFields
TMSelectedFields extends keyof TMFields = keyof TMFields,
>(
this: IEntityClass<
TMFields,
@@ -64,7 +64,7 @@ export default abstract class Entity<
queryContext: EntityQueryContext = viewerContext
.getViewerScopedEntityCompanionForClass(this)
.getQueryContextProvider()
.getQueryContext()
.getQueryContext(),
): CreateMutator<TMFields, TMID, TMViewerContext, TMEntity, TMPrivacyPolicy, TMSelectedFields> {
return viewerContext
.getViewerScopedEntityCompanionForClass(this)
@@ -90,7 +90,7 @@ export default abstract class Entity<
TMEntity,
TMSelectedFields
>,
TMSelectedFields extends keyof TMFields = keyof TMFields
TMSelectedFields extends keyof TMFields = keyof TMFields,
>(
this: IEntityClass<
TMFields,
@@ -105,7 +105,7 @@ export default abstract class Entity<
.getViewerContext()
.getViewerScopedEntityCompanionForClass(this)
.getQueryContextProvider()
.getQueryContext()
.getQueryContext(),
): UpdateMutator<TMFields, TMID, TMViewerContext, TMEntity, TMPrivacyPolicy, TMSelectedFields> {
return existingEntity
.getViewerContext()
@@ -131,7 +131,7 @@ export default abstract class Entity<
TMEntity,
TMSelectedFields
>,
TMSelectedFields extends keyof TMFields = keyof TMFields
TMSelectedFields extends keyof TMFields = keyof TMFields,
>(
this: IEntityClass<
TMFields,
@@ -146,7 +146,7 @@ export default abstract class Entity<
.getViewerContext()
.getViewerScopedEntityCompanionForClass(this)
.getQueryContextProvider()
.getQueryContext()
.getQueryContext(),
): Promise<Result<void>> {
return existingEntity
.getViewerContext()
@@ -173,7 +173,7 @@ export default abstract class Entity<
TMEntity,
TMSelectedFields
>,
TMSelectedFields extends keyof TMFields = keyof TMFields
TMSelectedFields extends keyof TMFields = keyof TMFields,
>(
this: IEntityClass<
TMFields,
@@ -188,7 +188,7 @@ export default abstract class Entity<
.getViewerContext()
.getViewerScopedEntityCompanionForClass(this)
.getQueryContextProvider()
.getQueryContext()
.getQueryContext(),
): Promise<void> {
return existingEntity
.getViewerContext()
@@ -197,121 +197,6 @@ export default abstract class Entity<
.forDelete(existingEntity, queryContext)
.enforceDeleteAsync();
}
/**
* Check whether an entity loaded by a viewer can be updated by that same viewer.
*
* @remarks
*
* This may be useful in situations relying upon the thrown privacy policy thrown authorization error
* is insufficient for the task at hand. When dealing with purely a sequence of mutations it is easy
* to roll back all mutations given a single authorization error by wrapping them in a single transaction.
* When certain portions of a mutation cannot be rolled back transactionally (third pary calls,
* legacy code, etc), using this method can help decide whether the sequence of mutations will fail before
* attempting them. Note that if any privacy policy rules use a piece of data being updated in the mutations
* the result of this method and the update mutation itself may differ.
*
* @param existingEntity - entity loaded by viewer
* @param queryContext - query context in which to perform the check
*/
static async canViewerUpdateAsync<
TMFields extends object,
TMID extends NonNullable<TMFields[TMSelectedFields]>,
TMViewerContext extends ViewerContext,
TMEntity extends Entity<TMFields, TMID, TMViewerContext, TMSelectedFields>,
TMPrivacyPolicy extends EntityPrivacyPolicy<
TMFields,
TMID,
TMViewerContext,
TMEntity,
TMSelectedFields
>,
TMSelectedFields extends keyof TMFields = keyof TMFields
>(
this: IEntityClass<
TMFields,
TMID,
TMViewerContext,
TMEntity,
TMPrivacyPolicy,
TMSelectedFields
>,
existingEntity: TMEntity,
queryContext: EntityQueryContext = existingEntity
.getViewerContext()
.getViewerScopedEntityCompanionForClass(this)
.getQueryContextProvider()
.getQueryContext()
): Promise<boolean> {
const companion = existingEntity
.getViewerContext()
.getViewerScopedEntityCompanionForClass(this);
const privacyPolicy = companion.entityCompanion.privacyPolicy;
const evaluationResult = await asyncResult(
privacyPolicy.authorizeUpdateAsync(
existingEntity.getViewerContext(),
queryContext,
{ cascadingDeleteCause: null },
existingEntity,
companion.getMetricsAdapter()
)
);
return evaluationResult.ok;
}
/**
* Check whether an entity loaded by a viewer can be deleted by that same viewer.
*
* @remarks
* See remarks for canViewerUpdate.
*
* @param existingEntity - entity loaded by viewer
* @param queryContext - query context in which to perform the check
*/
static async canViewerDeleteAsync<
TMFields extends object,
TMID extends NonNullable<TMFields[TMSelectedFields]>,
TMViewerContext extends ViewerContext,
TMEntity extends Entity<TMFields, TMID, TMViewerContext, TMSelectedFields>,
TMPrivacyPolicy extends EntityPrivacyPolicy<
TMFields,
TMID,
TMViewerContext,
TMEntity,
TMSelectedFields
>,
TMSelectedFields extends keyof TMFields = keyof TMFields
>(
this: IEntityClass<
TMFields,
TMID,
TMViewerContext,
TMEntity,
TMPrivacyPolicy,
TMSelectedFields
>,
existingEntity: TMEntity,
queryContext: EntityQueryContext = existingEntity
.getViewerContext()
.getViewerScopedEntityCompanionForClass(this)
.getQueryContextProvider()
.getQueryContext()
): Promise<boolean> {
const companion = existingEntity
.getViewerContext()
.getViewerScopedEntityCompanionForClass(this);
const privacyPolicy = companion.entityCompanion.privacyPolicy;
const evaluationResult = await asyncResult(
privacyPolicy.authorizeDeleteAsync(
existingEntity.getViewerContext(),
queryContext,
{ cascadingDeleteCause: null },
existingEntity,
companion.getMetricsAdapter()
)
);
return evaluationResult.ok;
}
}
/**
@@ -329,7 +214,7 @@ export interface IEntityClass<
TEntity,
TSelectedFields
>,
TSelectedFields extends keyof TFields = keyof TFields
TSelectedFields extends keyof TFields = keyof TFields,
> {
new (constructorParam: {
viewerContext: TViewerContext;
+40 -41
View File
@@ -16,7 +16,7 @@ export default class EntityAssociationLoader<
TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
TSelectedFields extends keyof TFields
TSelectedFields extends keyof TFields,
> {
constructor(private readonly entity: TEntity) {}
@@ -44,7 +44,7 @@ export default class EntityAssociationLoader<
TAssociatedEntity,
TAssociatedSelectedFields
>,
TAssociatedSelectedFields extends keyof TAssociatedFields = keyof TAssociatedFields
TAssociatedSelectedFields extends keyof TAssociatedFields = keyof TAssociatedFields,
>(
fieldIdentifyingAssociatedEntity: TIdentifyingField,
associatedEntityClass: IEntityClass<
@@ -59,7 +59,7 @@ export default class EntityAssociationLoader<
.getViewerContext()
.getViewerScopedEntityCompanionForClass(associatedEntityClass)
.getQueryContextProvider()
.getQueryContext()
.getQueryContext(),
): Promise<
Result<null extends TFields[TIdentifyingField] ? TAssociatedEntity | null : TAssociatedEntity>
> {
@@ -74,9 +74,11 @@ export default class EntityAssociationLoader<
.getViewerContext()
.getViewerScopedEntityCompanionForClass(associatedEntityClass)
.getLoaderFactory()
.forLoad(queryContext, { cascadingDeleteCause: null });
.forLoad(queryContext, { previousValue: null, cascadingDeleteCause: null });
return (await loader.loadByIDAsync(associatedEntityID as unknown as TAssociatedID)) as Result<
return (await loader
.withAuthorizationResults()
.loadByIDAsync(associatedEntityID as unknown as TAssociatedID)) as Result<
null extends TFields[TIdentifyingField] ? TAssociatedEntity | null : TAssociatedEntity
>;
}
@@ -106,7 +108,7 @@ export default class EntityAssociationLoader<
TAssociatedEntity,
TAssociatedSelectedFields
>,
TAssociatedSelectedFields extends keyof TAssociatedFields = keyof TAssociatedFields
TAssociatedSelectedFields extends keyof TAssociatedFields = keyof TAssociatedFields,
>(
associatedEntityClass: IEntityClass<
TAssociatedFields,
@@ -121,18 +123,17 @@ export default class EntityAssociationLoader<
.getViewerContext()
.getViewerScopedEntityCompanionForClass(associatedEntityClass)
.getQueryContextProvider()
.getQueryContext()
.getQueryContext(),
): Promise<readonly Result<TAssociatedEntity>[]> {
const thisID = this.entity.getID();
const loader = this.entity
.getViewerContext()
.getViewerScopedEntityCompanionForClass(associatedEntityClass)
.getLoaderFactory()
.forLoad(queryContext, { cascadingDeleteCause: null });
return await loader.loadManyByFieldEqualingAsync(
associatedEntityFieldContainingThisID,
thisID as any
);
.forLoad(queryContext, { previousValue: null, cascadingDeleteCause: null });
return await loader
.withAuthorizationResults()
.loadManyByFieldEqualingAsync(associatedEntityFieldContainingThisID, thisID as any);
}
/**
@@ -159,7 +160,7 @@ export default class EntityAssociationLoader<
TAssociatedEntity,
TAssociatedSelectedFields
>,
TAssociatedSelectedFields extends keyof TAssociatedFields = keyof TAssociatedFields
TAssociatedSelectedFields extends keyof TAssociatedFields = keyof TAssociatedFields,
>(
fieldIdentifyingAssociatedEntity: keyof Pick<TFields, TSelectedFields>,
associatedEntityClass: IEntityClass<
@@ -175,7 +176,7 @@ export default class EntityAssociationLoader<
.getViewerContext()
.getViewerScopedEntityCompanionForClass(associatedEntityClass)
.getQueryContextProvider()
.getQueryContext()
.getQueryContext(),
): Promise<Result<TAssociatedEntity> | null> {
const associatedFieldValue = this.entity.getField(fieldIdentifyingAssociatedEntity);
if (!associatedFieldValue) {
@@ -185,11 +186,10 @@ export default class EntityAssociationLoader<
.getViewerContext()
.getViewerScopedEntityCompanionForClass(associatedEntityClass)
.getLoaderFactory()
.forLoad(queryContext, { cascadingDeleteCause: null });
return await loader.loadByFieldEqualingAsync(
associatedEntityLookupByField,
associatedFieldValue as any
);
.forLoad(queryContext, { previousValue: null, cascadingDeleteCause: null });
return await loader
.withAuthorizationResults()
.loadByFieldEqualingAsync(associatedEntityLookupByField, associatedFieldValue as any);
}
/**
@@ -216,7 +216,7 @@ export default class EntityAssociationLoader<
TAssociatedEntity,
TAssociatedSelectedFields
>,
TAssociatedSelectedFields extends keyof TAssociatedFields = keyof TAssociatedFields
TAssociatedSelectedFields extends keyof TAssociatedFields = keyof TAssociatedFields,
>(
fieldIdentifyingAssociatedEntity: keyof Pick<TFields, TSelectedFields>,
associatedEntityClass: IEntityClass<
@@ -232,7 +232,7 @@ export default class EntityAssociationLoader<
.getViewerContext()
.getViewerScopedEntityCompanionForClass(associatedEntityClass)
.getQueryContextProvider()
.getQueryContext()
.getQueryContext(),
): Promise<readonly Result<TAssociatedEntity>[]> {
const associatedFieldValue = this.entity.getField(fieldIdentifyingAssociatedEntity);
if (!associatedFieldValue) {
@@ -243,11 +243,10 @@ export default class EntityAssociationLoader<
.getViewerContext()
.getViewerScopedEntityCompanionForClass(associatedEntityClass)
.getLoaderFactory()
.forLoad(queryContext, { cascadingDeleteCause: null });
return await loader.loadManyByFieldEqualingAsync(
associatedEntityLookupByField,
associatedFieldValue as any
);
.forLoad(queryContext, { previousValue: null, cascadingDeleteCause: null });
return await loader
.withAuthorizationResults()
.loadManyByFieldEqualingAsync(associatedEntityLookupByField, associatedFieldValue as any);
}
/**
@@ -267,7 +266,7 @@ export default class EntityAssociationLoader<
TEntity2,
TSelectedFields2
>,
TSelectedFields2 extends keyof TFields2 = keyof TFields2
TSelectedFields2 extends keyof TFields2 = keyof TFields2,
>(
loadDirectives: [
EntityLoadThroughDirective<
@@ -279,9 +278,9 @@ export default class EntityAssociationLoader<
TPrivacyPolicy2,
TSelectedFields,
TSelectedFields2
>
>,
],
queryContext?: EntityQueryContext
queryContext?: EntityQueryContext,
): Promise<Result<TEntity2> | null>;
/**
@@ -312,7 +311,7 @@ export default class EntityAssociationLoader<
TSelectedFields3
>,
TSelectedFields2 extends keyof TFields2 = keyof TFields2,
TSelectedFields3 extends keyof TFields3 = keyof TFields3
TSelectedFields3 extends keyof TFields3 = keyof TFields3,
>(
loadDirectives: [
EntityLoadThroughDirective<
@@ -334,9 +333,9 @@ export default class EntityAssociationLoader<
TPrivacyPolicy3,
TSelectedFields2,
TSelectedFields3
>
>,
],
queryContext?: EntityQueryContext
queryContext?: EntityQueryContext,
): Promise<Result<TEntity3> | null>;
/**
@@ -378,9 +377,9 @@ export default class EntityAssociationLoader<
>,
TSelectedFields2 extends keyof TFields2 = keyof TFields2,
TSelectedFields3 extends keyof TFields3 = keyof TFields3,
TSelectedFields4 extends keyof TFields4 = keyof TFields4
TSelectedFields4 extends keyof TFields4 = keyof TFields4,
>(
loadDirective: [
loadDirectives: [
EntityLoadThroughDirective<
TViewerContext,
TFields,
@@ -410,9 +409,9 @@ export default class EntityAssociationLoader<
TPrivacyPolicy4,
TSelectedFields3,
TSelectedFields4
>
>,
],
queryContext?: EntityQueryContext
queryContext?: EntityQueryContext,
): Promise<Result<TEntity4> | null>;
/**
@@ -423,12 +422,12 @@ export default class EntityAssociationLoader<
*/
async loadAssociatedEntityThroughAsync(
loadDirectives: EntityLoadThroughDirective<TViewerContext, any, any, any, any, any, any, any>[],
queryContext?: EntityQueryContext
queryContext?: EntityQueryContext,
): Promise<Result<ReadonlyEntity<any, any, any, any>> | null>;
async loadAssociatedEntityThroughAsync(
loadDirectives: EntityLoadThroughDirective<TViewerContext, any, any, any, any, any, any, any>[],
queryContext?: EntityQueryContext
queryContext?: EntityQueryContext,
): Promise<Result<ReadonlyEntity<any, any, any, any>> | null> {
let currentEntity: ReadonlyEntity<any, any, any, any> = this.entity;
for (const loadDirective of loadDirectives) {
@@ -445,7 +444,7 @@ export default class EntityAssociationLoader<
fieldIdentifyingAssociatedEntity,
associatedEntityClass,
associatedEntityLookupByField,
queryContext
queryContext,
);
} else {
const associatedEntityResultLocal = await currentEntity
@@ -453,7 +452,7 @@ export default class EntityAssociationLoader<
.loadAssociatedEntityAsync(
fieldIdentifyingAssociatedEntity,
associatedEntityClass,
queryContext
queryContext,
);
if (associatedEntityResultLocal.ok && associatedEntityResultLocal.value === null) {
@@ -498,7 +497,7 @@ export interface EntityLoadThroughDirective<
TAssociatedSelectedFields
>,
TSelectedFields extends keyof TFields = keyof TFields,
TAssociatedSelectedFields extends keyof TAssociatedFields = keyof TAssociatedFields
TAssociatedSelectedFields extends keyof TAssociatedFields = keyof TAssociatedFields,
> {
/**
* Class of entity to load at this step.
+8 -4
View File
@@ -7,6 +7,7 @@ import ReadonlyEntity from './ReadonlyEntity';
import ViewerContext from './ViewerContext';
import EntityTableDataCoordinator from './internal/EntityTableDataCoordinator';
import IEntityMetricsAdapter from './metrics/IEntityMetricsAdapter';
import { mergeEntityMutationTriggerConfigurations } from './utils/mergeEntityMutationTriggerConfigurations';
export interface IPrivacyPolicyClass<TPrivacyPolicy> {
new (): TPrivacyPolicy;
@@ -27,7 +28,7 @@ export default class EntityCompanion<
TEntity,
TSelectedFields
>,
TSelectedFields extends keyof TFields
TSelectedFields extends keyof TFields,
> {
public readonly privacyPolicy: TPrivacyPolicy;
@@ -59,7 +60,7 @@ export default class EntityCompanion<
TSelectedFields
>,
private readonly tableDataCoordinator: EntityTableDataCoordinator<TFields>,
private readonly metricsAdapter: IEntityMetricsAdapter
private readonly metricsAdapter: IEntityMetricsAdapter,
) {
this.privacyPolicy = new entityCompanionDefinition.privacyPolicyClass();
this.entityLoaderFactory = new EntityLoaderFactory<
@@ -76,10 +77,13 @@ export default class EntityCompanion<
entityCompanionDefinition.entityClass,
this.privacyPolicy,
entityCompanionDefinition.mutationValidators ?? [],
entityCompanionDefinition.mutationTriggers ?? {},
mergeEntityMutationTriggerConfigurations(
entityCompanionDefinition.mutationTriggers ?? {},
entityCompanionProvider.globalMutationTriggers ?? {},
),
this.entityLoaderFactory,
tableDataCoordinator.databaseAdapter,
metricsAdapter
metricsAdapter,
);
}
+24 -16
View File
@@ -61,7 +61,7 @@ export interface EntityCompanionDefinition<
TEntity,
TSelectedFields
>,
TSelectedFields extends keyof TFields = keyof TFields
TSelectedFields extends keyof TFields = keyof TFields,
> {
/**
* The concrete Entity class for which this is the definition.
@@ -139,6 +139,7 @@ export default class EntityCompanionProvider {
* @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
* @param globalMutationTriggers - Optional set of EntityMutationTrigger to run for all entity mutations systemwide.
*/
constructor(
public readonly metricsAdapter: IEntityMetricsAdapter,
@@ -146,7 +147,14 @@ export default class EntityCompanionProvider {
DatabaseAdapterFlavor,
DatabaseAdapterFlavorDefinition
>,
private cacheAdapterFlavors: ReadonlyMap<CacheAdapterFlavor, CacheAdapterFlavorDefinition>
private cacheAdapterFlavors: ReadonlyMap<CacheAdapterFlavor, CacheAdapterFlavorDefinition>,
readonly globalMutationTriggers: EntityMutationTriggerConfiguration<
any,
any,
any,
any,
any
> = {},
) {}
/**
@@ -167,7 +175,7 @@ export default class EntityCompanionProvider {
TEntity,
TSelectedFields
>,
TSelectedFields extends keyof TFields
TSelectedFields extends keyof TFields,
>(
entityClass: IEntityClass<
TFields,
@@ -176,58 +184,58 @@ export default class EntityCompanionProvider {
TEntity,
TPrivacyPolicy,
TSelectedFields
>
>,
): EntityCompanion<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy, TSelectedFields> {
const entityCompanionDefinition = computeIfAbsent(
this.companionDefinitionMap,
entityClass.name,
() => entityClass.defineCompanionDefinition()
() => entityClass.defineCompanionDefinition(),
);
const tableDataCoordinator = this.getTableDataCoordinatorForEntity(
entityCompanionDefinition.entityConfiguration,
entityClass.name
entityClass.name,
);
return computeIfAbsent(this.companionMap, entityClass.name, () => {
return new EntityCompanion(
this,
entityCompanionDefinition,
tableDataCoordinator,
this.metricsAdapter
this.metricsAdapter,
);
});
}
getQueryContextProviderForDatabaseAdaptorFlavor(
databaseAdapterFlavor: DatabaseAdapterFlavor
databaseAdapterFlavor: DatabaseAdapterFlavor,
): EntityQueryContextProvider {
const entityDatabaseAdapterFlavor = this.databaseAdapterFlavors.get(databaseAdapterFlavor);
invariant(
entityDatabaseAdapterFlavor,
`No database adaptor configuration found for flavor: ${databaseAdapterFlavor}`
`No database adaptor configuration found for flavor: ${databaseAdapterFlavor}`,
);
return entityDatabaseAdapterFlavor.queryContextProvider;
}
private getTableDataCoordinatorForEntity<TFields>(
private getTableDataCoordinatorForEntity<TFields extends Record<string, any>>(
entityConfiguration: EntityConfiguration<TFields>,
entityClassName: string
entityClassName: string,
): EntityTableDataCoordinator<TFields> {
return computeIfAbsent(this.tableDataCoordinatorMap, entityConfiguration.tableName, () => {
const entityDatabaseAdapterFlavor = this.databaseAdapterFlavors.get(
entityConfiguration.databaseAdapterFlavor
entityConfiguration.databaseAdapterFlavor,
);
invariant(
entityDatabaseAdapterFlavor,
`No database adaptor configuration found for flavor: ${entityConfiguration.databaseAdapterFlavor}`
`No database adaptor configuration found for flavor: ${entityConfiguration.databaseAdapterFlavor}`,
);
const entityCacheAdapterFlavor = this.cacheAdapterFlavors.get(
entityConfiguration.cacheAdapterFlavor
entityConfiguration.cacheAdapterFlavor,
);
invariant(
entityCacheAdapterFlavor,
`No cache adaptor configuration found for flavor: ${entityConfiguration.cacheAdapterFlavor}`
`No cache adaptor configuration found for flavor: ${entityConfiguration.cacheAdapterFlavor}`,
);
return new EntityTableDataCoordinator(
@@ -236,7 +244,7 @@ export default class EntityCompanionProvider {
entityCacheAdapterFlavor.cacheAdapterProvider,
entityDatabaseAdapterFlavor.queryContextProvider,
this.metricsAdapter,
entityClassName
entityClassName,
);
});
}
+18 -7
View File
@@ -7,7 +7,7 @@ import { mapMap, invertMap, reduceMap } from './utils/collections/maps';
* The data storage configuration for a type of Entity. Contains information relating to IDs,
* cachable fields, field mappings, and types of cache and database adapter.
*/
export default class EntityConfiguration<TFields> {
export default class EntityConfiguration<TFields extends Record<string, any>> {
readonly idField: keyof TFields;
readonly tableName: string;
readonly cacheableKeys: ReadonlySet<keyof TFields>;
@@ -75,18 +75,29 @@ export default class EntityConfiguration<TFields> {
// external schema is a Record to typecheck that all fields have FieldDefinitions,
// but internally the most useful representation is a map for lookups
// TODO(wschurman): validate schema
this.schema = new Map(Object.entries(schema) as any);
EntityConfiguration.validateSchema(schema);
this.schema = new Map(Object.entries(schema));
this.cacheableKeys = EntityConfiguration.computeCacheableKeys(this.schema);
this.entityToDBFieldsKeyMapping = EntityConfiguration.computeEntityToDBFieldsKeyMapping(
this.schema
this.schema,
);
this.dbToEntityFieldsKeyMapping = invertMap(this.entityToDBFieldsKeyMapping);
}
private static validateSchema<TFields extends Record<string, any>>(schema: TFields): void {
const disallowedFieldsKeys = Object.getOwnPropertyNames(Object.prototype);
for (const disallowedFieldsKey of disallowedFieldsKeys) {
if (Object.hasOwn(schema, disallowedFieldsKey)) {
throw new Error(
`Entity field name not allowed to prevent conflicts with standard Object prototype fields: ${disallowedFieldsKey}`,
);
}
}
}
private static computeCacheableKeys<TFields>(
schema: ReadonlyMap<keyof TFields, EntityFieldDefinition<any>>
schema: ReadonlyMap<keyof TFields, EntityFieldDefinition<any>>,
): ReadonlySet<keyof TFields> {
return reduceMap(
schema,
@@ -96,12 +107,12 @@ export default class EntityConfiguration<TFields> {
}
return acc;
},
new Set<keyof TFields>()
new Set<keyof TFields>(),
);
}
private static computeEntityToDBFieldsKeyMapping<TFields>(
schema: ReadonlyMap<keyof TFields, EntityFieldDefinition<any>>
schema: ReadonlyMap<keyof TFields, EntityFieldDefinition<any>>,
): ReadonlyMap<keyof TFields, string> {
return mapMap(schema, (v) => v.columnName);
}
+41 -41
View File
@@ -12,7 +12,7 @@ import {
*/
export interface SingleValueFieldEqualityCondition<
TFields,
N extends keyof TFields = keyof TFields
N extends keyof TFields = keyof TFields,
> {
fieldName: N;
fieldValue: TFields[N];
@@ -23,7 +23,7 @@ export interface SingleValueFieldEqualityCondition<
*/
export interface MultiValueFieldEqualityCondition<
TFields,
N extends keyof TFields = keyof TFields
N extends keyof TFields = keyof TFields,
> {
fieldName: N;
fieldValues: readonly TFields[N][];
@@ -39,9 +39,9 @@ export type FieldEqualityCondition<TFields, N extends keyof TFields = keyof TFie
export function isSingleValueFieldEqualityCondition<
TFields,
N extends keyof TFields = keyof TFields
N extends keyof TFields = keyof TFields,
>(
condition: FieldEqualityCondition<TFields, N>
condition: FieldEqualityCondition<TFields, N>,
): condition is SingleValueFieldEqualityCondition<TFields, N> {
return (condition as SingleValueFieldEqualityCondition<TFields, N>).fieldValue !== undefined;
}
@@ -113,7 +113,7 @@ export interface TableQuerySelectionModifiersWithOrderByRaw extends TableQuerySe
* handles all entity field transformation. Subclasses are responsible for
* implementing database-specific logic for a type of database.
*/
export default abstract class EntityDatabaseAdapter<TFields> {
export default abstract class EntityDatabaseAdapter<TFields extends Record<string, any>> {
private readonly fieldTransformerMap: FieldTransformerMap;
constructor(private readonly entityConfiguration: EntityConfiguration<TFields>) {
@@ -137,18 +137,18 @@ export default abstract class EntityDatabaseAdapter<TFields> {
*/
async fetchManyWhereAsync<K extends keyof TFields>(
queryContext: EntityQueryContext,
field: K,
fieldValues: readonly NonNullable<TFields[K]>[]
fieldName: K,
fieldValues: readonly NonNullable<TFields[K]>[],
): Promise<ReadonlyMap<NonNullable<TFields[K]>, readonly Readonly<TFields>[]>> {
const fieldColumn = getDatabaseFieldForEntityField(this.entityConfiguration, field);
const fieldColumn = getDatabaseFieldForEntityField(this.entityConfiguration, fieldName);
const results = await this.fetchManyWhereInternalAsync(
queryContext.getQueryInterface(),
this.entityConfiguration.tableName,
fieldColumn,
fieldValues
fieldValues,
);
const objects = results.map((result) =>
transformDatabaseObjectToFields(this.entityConfiguration, this.fieldTransformerMap, result)
transformDatabaseObjectToFields(this.entityConfiguration, this.fieldTransformerMap, result),
);
const objectMap = new Map();
@@ -157,7 +157,7 @@ export default abstract class EntityDatabaseAdapter<TFields> {
}
objects.forEach((object) => {
const objectFieldValue = object[field];
const objectFieldValue = object[fieldName];
objectMap.get(objectFieldValue).push(object);
});
@@ -168,7 +168,7 @@ export default abstract class EntityDatabaseAdapter<TFields> {
queryInterface: any,
tableName: string,
tableField: string,
tableValues: readonly any[]
tableValues: readonly any[],
): Promise<object[]>;
/**
@@ -183,7 +183,7 @@ export default abstract class EntityDatabaseAdapter<TFields> {
async fetchManyByFieldEqualityConjunctionAsync<N extends keyof TFields>(
queryContext: EntityQueryContext,
fieldEqualityOperands: FieldEqualityCondition<TFields, N>[],
querySelectionModifiers: QuerySelectionModifiers<TFields>
querySelectionModifiers: QuerySelectionModifiers<TFields>,
): Promise<readonly Readonly<TFields>[]> {
const tableFieldSingleValueOperands: TableFieldSingleValueEqualityCondition[] = [];
const tableFieldMultipleValueOperands: TableFieldMultiValueEqualityCondition[] = [];
@@ -206,11 +206,11 @@ export default abstract class EntityDatabaseAdapter<TFields> {
this.entityConfiguration.tableName,
tableFieldSingleValueOperands,
tableFieldMultipleValueOperands,
this.convertToTableQueryModifiers(querySelectionModifiers)
this.convertToTableQueryModifiers(querySelectionModifiers),
);
return results.map((result) =>
transformDatabaseObjectToFields(this.entityConfiguration, this.fieldTransformerMap, result)
transformDatabaseObjectToFields(this.entityConfiguration, this.fieldTransformerMap, result),
);
}
@@ -219,7 +219,7 @@ export default abstract class EntityDatabaseAdapter<TFields> {
tableName: string,
tableFieldSingleValueEqualityOperands: TableFieldSingleValueEqualityCondition[],
tableFieldMultiValueEqualityOperands: TableFieldMultiValueEqualityCondition[],
querySelectionModifiers: TableQuerySelectionModifiers
querySelectionModifiers: TableQuerySelectionModifiers,
): Promise<object[]>;
/**
@@ -235,18 +235,18 @@ export default abstract class EntityDatabaseAdapter<TFields> {
queryContext: EntityQueryContext,
rawWhereClause: string,
bindings: any[] | object,
querySelectionModifiers: QuerySelectionModifiersWithOrderByRaw<TFields>
querySelectionModifiers: QuerySelectionModifiersWithOrderByRaw<TFields>,
): Promise<readonly Readonly<TFields>[]> {
const results = await this.fetchManyByRawWhereClauseInternalAsync(
queryContext.getQueryInterface(),
this.entityConfiguration.tableName,
rawWhereClause,
bindings,
this.convertToTableQueryModifiersWithOrderByRaw(querySelectionModifiers)
this.convertToTableQueryModifiersWithOrderByRaw(querySelectionModifiers),
);
return results.map((result) =>
transformDatabaseObjectToFields(this.entityConfiguration, this.fieldTransformerMap, result)
transformDatabaseObjectToFields(this.entityConfiguration, this.fieldTransformerMap, result),
);
}
@@ -255,7 +255,7 @@ export default abstract class EntityDatabaseAdapter<TFields> {
tableName: string,
rawWhereClause: string,
bindings: any[] | object,
querySelectionModifiers: TableQuerySelectionModifiersWithOrderByRaw
querySelectionModifiers: TableQuerySelectionModifiersWithOrderByRaw,
): Promise<object[]>;
/**
@@ -267,40 +267,40 @@ export default abstract class EntityDatabaseAdapter<TFields> {
*/
async insertAsync(
queryContext: EntityQueryContext,
object: Readonly<Partial<TFields>>
object: Readonly<Partial<TFields>>,
): Promise<Readonly<TFields>> {
const dbObject = transformFieldsToDatabaseObject(
this.entityConfiguration,
this.fieldTransformerMap,
object
object,
);
const results = await this.insertInternalAsync(
queryContext.getQueryInterface(),
this.entityConfiguration.tableName,
dbObject
dbObject,
);
if (results.length > 1) {
throw new Error(
`Excessive results from database adapter insert: ${this.entityConfiguration.tableName}`
`Excessive results from database adapter insert: ${this.entityConfiguration.tableName}`,
);
} else if (results.length === 0) {
throw new Error(
`Empty results from database adapter insert: ${this.entityConfiguration.tableName}`
`Empty results from database adapter insert: ${this.entityConfiguration.tableName}`,
);
}
return transformDatabaseObjectToFields(
this.entityConfiguration,
this.fieldTransformerMap,
results[0]!
results[0]!,
);
}
protected abstract insertInternalAsync(
queryInterface: any,
tableName: string,
object: object
object: object,
): Promise<object[]>;
/**
@@ -316,36 +316,36 @@ export default abstract class EntityDatabaseAdapter<TFields> {
queryContext: EntityQueryContext,
idField: K,
id: any,
object: Readonly<Partial<TFields>>
object: Readonly<Partial<TFields>>,
): Promise<Readonly<TFields>> {
const idColumn = getDatabaseFieldForEntityField(this.entityConfiguration, idField);
const dbObject = transformFieldsToDatabaseObject(
this.entityConfiguration,
this.fieldTransformerMap,
object
object,
);
const results = await this.updateInternalAsync(
queryContext.getQueryInterface(),
this.entityConfiguration.tableName,
idColumn,
id,
dbObject
dbObject,
);
if (results.length > 1) {
throw new Error(
`Excessive results from database adapter update: ${this.entityConfiguration.tableName}(id = ${id})`
`Excessive results from database adapter update: ${this.entityConfiguration.tableName}(id = ${id})`,
);
} else if (results.length === 0) {
throw new Error(
`Empty results from database adapter update: ${this.entityConfiguration.tableName}(id = ${id})`
`Empty results from database adapter update: ${this.entityConfiguration.tableName}(id = ${id})`,
);
}
return transformDatabaseObjectToFields(
this.entityConfiguration,
this.fieldTransformerMap,
results[0]!
results[0]!,
);
}
@@ -354,7 +354,7 @@ export default abstract class EntityDatabaseAdapter<TFields> {
tableName: string,
tableIdField: string,
id: any,
object: object
object: object,
): Promise<object[]>;
/**
@@ -367,19 +367,19 @@ export default abstract class EntityDatabaseAdapter<TFields> {
async deleteAsync<K extends keyof TFields>(
queryContext: EntityQueryContext,
idField: K,
id: any
id: any,
): Promise<void> {
const idColumn = getDatabaseFieldForEntityField(this.entityConfiguration, idField);
const numDeleted = await this.deleteInternalAsync(
queryContext.getQueryInterface(),
this.entityConfiguration.tableName,
idColumn,
id
id,
);
if (numDeleted > 1) {
throw new Error(
`Excessive deletions from database adapter delete: ${this.entityConfiguration.tableName}(id = ${id})`
`Excessive deletions from database adapter delete: ${this.entityConfiguration.tableName}(id = ${id})`,
);
}
}
@@ -388,11 +388,11 @@ export default abstract class EntityDatabaseAdapter<TFields> {
queryInterface: any,
tableName: string,
tableIdField: string,
id: any
id: any,
): Promise<number>;
private convertToTableQueryModifiersWithOrderByRaw(
querySelectionModifiers: QuerySelectionModifiersWithOrderByRaw<TFields>
querySelectionModifiers: QuerySelectionModifiersWithOrderByRaw<TFields>,
): TableQuerySelectionModifiersWithOrderByRaw {
return {
...this.convertToTableQueryModifiers(querySelectionModifiers),
@@ -401,7 +401,7 @@ export default abstract class EntityDatabaseAdapter<TFields> {
}
private convertToTableQueryModifiers(
querySelectionModifiers: QuerySelectionModifiers<TFields>
querySelectionModifiers: QuerySelectionModifiers<TFields>,
): TableQuerySelectionModifiers {
const orderBy = querySelectionModifiers.orderBy;
return {
@@ -410,7 +410,7 @@ export default abstract class EntityDatabaseAdapter<TFields> {
? orderBy.map((orderBySpecification) => ({
columnName: getDatabaseFieldForEntityField(
this.entityConfiguration,
orderBySpecification.fieldName
orderBySpecification.fieldName,
),
order: orderBySpecification.order,
}))
+28 -18
View File
@@ -56,7 +56,7 @@ export interface EntityAssociationDefinition<
TAssociatedEntity,
TAssociatedSelectedFields
>,
TAssociatedSelectedFields extends keyof TAssociatedFields = keyof TAssociatedFields
TAssociatedSelectedFields extends keyof TAssociatedFields = keyof TAssociatedFields,
> {
/**
* Class of entity on the other end of this edge.
@@ -95,6 +95,28 @@ export interface EntityAssociationDefinition<
edgeDeletionBehavior: EntityEdgeDeletionBehavior;
}
/**
* Options for EntityFieldDefinition
*/
export interface EntityFieldDefinitionOptions {
/**
* Column name in the database.
*/
columnName: string;
/**
* Whether or not to cache loaded instances of the entity by this field. The column name is
* used to derive a cache key for the cache entry. If true, this column must be able uniquely
* identify the entity.
*/
cache?: boolean;
/**
* Defines the association behavior for an entity that this column references.
*/
association?: EntityAssociationDefinition<any, any, any, any, any, any>;
}
/**
* Definition for a field referencing a column in the underlying database. Specifies things like
* cache behavior and associations, and handles input validation.
@@ -105,24 +127,12 @@ export abstract class EntityFieldDefinition<T> {
readonly association: EntityAssociationDefinition<any, any, any, any, any, any> | undefined;
/**
*
* @param columnName - Column name in the database.
* @param cache - Whether or not to cache loaded instances of the entity by this field. The column name is
* used to derive a cache key for the cache entry. If true, this column must be able uniquely
* identify the entity.
* @param association - Defines the association behavior for an entity that this column references.
* @param options - options for this field definition
*/
constructor({
columnName,
cache = false,
association,
}: {
columnName: string;
cache?: boolean;
association?: EntityAssociationDefinition<any, any, any, any, any, any>;
}) {
this.columnName = columnName;
this.cache = cache;
this.association = association;
constructor(options: EntityFieldDefinitionOptions) {
this.columnName = options.columnName;
this.cache = options.cache ?? false;
this.association = options.association;
}
/**
+63 -357
View File
@@ -1,26 +1,14 @@
import { Result, asyncResult, result } from '@expo/results';
import invariant from 'invariant';
import nullthrows from 'nullthrows';
import AuthorizationResultBasedEntityLoader from './AuthorizationResultBasedEntityLoader';
import EnforcingEntityLoader from './EnforcingEntityLoader';
import { IEntityClass } from './Entity';
import EntityConfiguration from './EntityConfiguration';
import {
FieldEqualityCondition,
QuerySelectionModifiers,
isSingleValueFieldEqualityCondition,
QuerySelectionModifiersWithOrderByRaw,
} from './EntityDatabaseAdapter';
import EntityLoaderUtils from './EntityLoaderUtils';
import EntityPrivacyPolicy, { EntityPrivacyPolicyEvaluationContext } from './EntityPrivacyPolicy';
import { EntityQueryContext } from './EntityQueryContext';
import ReadonlyEntity from './ReadonlyEntity';
import ViewerContext from './ViewerContext';
import { pick } from './entityUtils';
import EntityInvalidFieldValueError from './errors/EntityInvalidFieldValueError';
import EntityNotFoundError from './errors/EntityNotFoundError';
import EntityDataManager from './internal/EntityDataManager';
import IEntityMetricsAdapter from './metrics/IEntityMetricsAdapter';
import { mapMap, mapMapAsync } from './utils/collections/maps';
/**
* The primary interface for loading entities. All normal loads are batched,
@@ -38,12 +26,27 @@ export default class EntityLoader<
TEntity,
TSelectedFields
>,
TSelectedFields extends keyof TFields
TSelectedFields extends keyof TFields,
> {
private readonly utilsPrivate: EntityLoaderUtils<
TFields,
TID,
TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
>;
constructor(
private readonly viewerContext: TViewerContext,
private readonly queryContext: EntityQueryContext,
private readonly privacyPolicyEvaluationContext: EntityPrivacyPolicyEvaluationContext,
private readonly privacyPolicyEvaluationContext: EntityPrivacyPolicyEvaluationContext<
TFields,
TID,
TViewerContext,
TEntity,
TSelectedFields
>,
private readonly entityConfiguration: EntityConfiguration<TFields>,
private readonly entityClass: IEntityClass<
TFields,
@@ -56,11 +59,23 @@ export default class EntityLoader<
private readonly entitySelectedFields: TSelectedFields[] | undefined,
private readonly privacyPolicy: TPrivacyPolicy,
private readonly dataManager: EntityDataManager<TFields>,
protected readonly metricsAdapter: IEntityMetricsAdapter
) {}
protected readonly metricsAdapter: IEntityMetricsAdapter,
) {
this.utilsPrivate = new EntityLoaderUtils(
this.viewerContext,
this.queryContext,
this.privacyPolicyEvaluationContext,
this.entityConfiguration,
this.entityClass,
this.entitySelectedFields,
this.privacyPolicy,
this.dataManager,
this.metricsAdapter,
);
}
/**
* Enforcing view on this entity loader. All loads through this view are
* Enforcing entity loader. All loads through this loader are
* guaranteed to be the values of successful results (or null for some loader methods),
* and will throw otherwise.
*/
@@ -72,353 +87,44 @@ export default class EntityLoader<
TPrivacyPolicy,
TSelectedFields
> {
return new EnforcingEntityLoader(this);
return new EnforcingEntityLoader(this.withAuthorizationResults());
}
/**
* Load many entities where fieldName is one of fieldValues.
* @param fieldName - entity field being queried
* @param fieldValues - fieldName field values being queried
* @returns map from fieldValue to entity results that match the query for that fieldValue,
* where result errors can be UnauthorizedError
* Authorization-result-based entity loader. All loads through this
* loader are are results (or null for some loader methods), where an unsuccessful result
* means an authorization error or entity construction error occurred. Other errors are thrown.
*/
async loadManyByFieldEqualingManyAsync<N extends keyof Pick<TFields, TSelectedFields>>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<ReadonlyMap<NonNullable<TFields[N]>, readonly Result<TEntity>[]>> {
this.validateFieldValues(fieldName, fieldValues);
const fieldValuesToFieldObjects = await this.dataManager.loadManyByFieldEqualingAsync(
withAuthorizationResults(): AuthorizationResultBasedEntityLoader<
TFields,
TID,
TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
> {
return new AuthorizationResultBasedEntityLoader(
this.queryContext,
fieldName,
fieldValues
);
return await this.constructAndAuthorizeEntitiesAsync(fieldValuesToFieldObjects);
}
/**
* Load many entities where fieldName equals fieldValue.
* @param fieldName - entity field being queried
* @param fieldValue - fieldName field value being queried
* @returns array of entity results that match the query for fieldValue, where result error can be UnauthorizedError
*/
async loadManyByFieldEqualingAsync<N extends keyof Pick<TFields, TSelectedFields>>(
fieldName: N,
fieldValue: NonNullable<TFields[N]>
): Promise<readonly Result<TEntity>[]> {
const entityResults = await this.loadManyByFieldEqualingManyAsync(fieldName, [fieldValue]);
const entityResultsForFieldValue = entityResults.get(fieldValue);
invariant(
entityResultsForFieldValue !== undefined,
`${fieldValue} should be guaranteed to be present in returned map of entities`
);
return entityResultsForFieldValue!;
}
/**
* Load an entity where fieldName equals fieldValue, or null if no entity exists.
* @param uniqueFieldName - entity field being queried
* @param fieldValue - uniqueFieldName field value being queried
* @returns entity result where uniqueFieldName equals fieldValue, or null if no entity matches the condition.
* @throws when multiple entities match the condition
*/
async loadByFieldEqualingAsync<N extends keyof Pick<TFields, TSelectedFields>>(
uniqueFieldName: N,
fieldValue: NonNullable<TFields[N]>
): Promise<Result<TEntity> | null> {
const entityResults = await this.loadManyByFieldEqualingAsync(uniqueFieldName, fieldValue);
invariant(
entityResults.length <= 1,
`loadByFieldEqualing: Multiple entities of type ${this.entityClass.name} found for ${String(
uniqueFieldName
)}=${fieldValue}`
);
return entityResults[0] ?? null;
}
/**
* Loads an entity by a specified ID.
* @param id - ID of the entity
* @returns entity result for matching ID, where result error can be UnauthorizedError or EntityNotFoundError.
*/
async loadByIDAsync(id: TID): Promise<Result<TEntity>> {
const entityResults = await this.loadManyByIDsAsync([id]);
const entityResult = entityResults.get(id);
if (entityResult === undefined) {
return result(
new EntityNotFoundError(this.entityClass, this.entityConfiguration.idField, id)
);
}
return entityResult;
}
/**
* Load an entity by a specified ID, or return null if non-existent.
* @param id - ID of the entity
* @returns entity result for matching ID, or null if no entity exists for ID.
*/
async loadByIDNullableAsync(id: TID): Promise<Result<TEntity> | null> {
return await this.loadByFieldEqualingAsync(
this.entityConfiguration.idField as TSelectedFields,
id
this.entityConfiguration,
this.entityClass,
this.dataManager,
this.metricsAdapter,
this.utilsPrivate,
);
}
/**
* Loads many entities for a list of IDs.
* @param viewerContext - viewer context of loading user
* @param ids - IDs of the entities to load
* @returns map from ID to corresponding entity result, where result error can be UnauthorizedError or EntityNotFoundError.
* Entity loader utilities for things like cache invalidation, entity construction, and authorization.
* Calling into these should only be necessary in rare cases.
*/
async loadManyByIDsAsync(ids: readonly TID[]): Promise<ReadonlyMap<TID, Result<TEntity>>> {
const entityResults = (await this.loadManyByFieldEqualingManyAsync(
this.entityConfiguration.idField as TSelectedFields,
ids
)) as ReadonlyMap<TID, readonly Result<TEntity>[]>;
return mapMap(entityResults, (entityResultsForId, id) => {
const entityResult = entityResultsForId[0];
return (
entityResult ??
result(new EntityNotFoundError(this.entityClass, this.entityConfiguration.idField, id))
);
});
}
/**
* Loads many entities for a list of IDs, returning null for any IDs that are non-existent.
* @param ids - IDs of the entities to load
* @returns map from ID to nullable corresponding entity result, where result error can be UnauthorizedError or EntityNotFoundError.
*/
async loadManyByIDsNullableAsync(
ids: readonly TID[]
): Promise<ReadonlyMap<TID, Result<TEntity> | null>> {
const entityResults = (await this.loadManyByFieldEqualingManyAsync(
this.entityConfiguration.idField as TSelectedFields,
ids
)) as ReadonlyMap<TID, readonly Result<TEntity>[]>;
return mapMap(entityResults, (entityResultsForId) => {
return entityResultsForId[0] ?? null;
});
}
/**
* Loads the first entity matching the selection constructed from the conjunction of specified
* operands, or null if no matching entity exists. Entities loaded using this method are not
* batched or cached.
*
* This is a convenience method for {@link loadManyByFieldEqualityConjunctionAsync}. However, the
* `orderBy` option must be specified to define what "first" means. If ordering doesn't matter,
* explicitly pass in an empty array.
*
* @param fieldEqualityOperands - list of field equality selection operand specifications
* @param querySelectionModifiers - orderBy and optional offset for the query
* @returns the first entity results that matches the query, where result error can be
* UnauthorizedError
*/
async loadFirstByFieldEqualityConjunctionAsync<N extends keyof Pick<TFields, TSelectedFields>>(
fieldEqualityOperands: FieldEqualityCondition<TFields, N>[],
querySelectionModifiers: Omit<QuerySelectionModifiers<TFields>, 'limit'> &
Required<Pick<QuerySelectionModifiers<TFields>, 'orderBy'>>
): Promise<Result<TEntity> | null> {
const results = await this.loadManyByFieldEqualityConjunctionAsync(fieldEqualityOperands, {
...querySelectionModifiers,
limit: 1,
});
return results[0] ?? null;
}
/**
* Loads many entities matching the selection constructed from the conjunction of specified operands.
* Entities loaded using this method are not batched or cached.
*
* @example
* fieldEqualityOperands:
* `[{fieldName: 'hello', fieldValue: 1}, {fieldName: 'world', fieldValues: [2, 3]}]`
* Entities returned with a SQL EntityDatabaseAdapter:
* `WHERE hello = 1 AND world = ANY({2, 3})`
*
* @param fieldEqualityOperands - list of field equality selection operand specifications
* @param querySelectionModifiers - limit, offset, and orderBy for the query
* @returns array of entity results that match the query, where result error can be UnauthorizedError
*/
async loadManyByFieldEqualityConjunctionAsync<N extends keyof Pick<TFields, TSelectedFields>>(
fieldEqualityOperands: FieldEqualityCondition<TFields, N>[],
querySelectionModifiers: QuerySelectionModifiers<TFields> = {}
): Promise<readonly Result<TEntity>[]> {
for (const fieldEqualityOperand of fieldEqualityOperands) {
const fieldValues = isSingleValueFieldEqualityCondition(fieldEqualityOperand)
? [fieldEqualityOperand.fieldValue]
: fieldEqualityOperand.fieldValues;
this.validateFieldValues(fieldEqualityOperand.fieldName, fieldValues);
}
const fieldObjects = await this.dataManager.loadManyByFieldEqualityConjunctionAsync(
this.queryContext,
fieldEqualityOperands,
querySelectionModifiers
);
const uncheckedEntityResults = this.tryConstructEntities(fieldObjects);
return await Promise.all(
uncheckedEntityResults.map(async (uncheckedEntityResult) => {
if (!uncheckedEntityResult.ok) {
return uncheckedEntityResult;
}
return await asyncResult(
this.privacyPolicy.authorizeReadAsync(
this.viewerContext,
this.queryContext,
this.privacyPolicyEvaluationContext,
uncheckedEntityResult.value,
this.metricsAdapter
)
);
})
);
}
/**
* Loads many entities matching the raw WHERE clause. Corresponds to the knex `whereRaw` argument format.
*
* @remarks
* Important notes:
* - Fields in clause are database column names instead of transformed entity field names.
* - Entities loaded using this method are not batched or cached.
* - Not all database adapters implement the ability to execute this method of fetching entities.
*
* @example
* rawWhereClause: `id = ?`
* bindings: `[1]`
* Entites returned `WHERE id = 1`
*
* 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, 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
*
* @deprecated prefer caching loaders
*/
async loadManyByRawWhereClauseAsync(
rawWhereClause: string,
bindings: any[] | object,
querySelectionModifiers: QuerySelectionModifiersWithOrderByRaw<TFields> = {}
): Promise<readonly Result<TEntity>[]> {
const fieldObjects = await this.dataManager.loadManyByRawWhereClauseAsync(
this.queryContext,
rawWhereClause,
bindings,
querySelectionModifiers
);
const uncheckedEntityResults = this.tryConstructEntities(fieldObjects);
return await Promise.all(
uncheckedEntityResults.map(async (uncheckedEntityResult) => {
if (!uncheckedEntityResult.ok) {
return uncheckedEntityResult;
}
return await asyncResult(
this.privacyPolicy.authorizeReadAsync(
this.viewerContext,
this.queryContext,
this.privacyPolicyEvaluationContext,
uncheckedEntityResult.value,
this.metricsAdapter
)
);
})
);
}
/**
* 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> {
await this.dataManager.invalidateObjectFieldsAsync(objectFields);
}
/**
* Invalidate all caches for an entity. One potential use case would be to keep the entity
* framework in sync with changes made to data outside of the framework.
* @param entity - entity to be invalidated
*/
async invalidateEntityAsync(entity: TEntity): Promise<void> {
await this.invalidateFieldsAsync(entity.getAllDatabaseFields());
}
private tryConstructEntities(fieldsObjects: readonly TFields[]): readonly Result<TEntity>[] {
return fieldsObjects.map((fieldsObject) => {
try {
return result(this.constructEntity(fieldsObject));
} catch (e) {
if (!(e instanceof Error)) {
throw e;
}
return result(e);
}
});
}
public constructEntity(fieldsObject: TFields): TEntity {
const idField = this.entityConfiguration.idField;
const id = nullthrows(fieldsObject[idField], 'must provide ID to create an entity');
const entitySelectedFields =
this.entitySelectedFields ?? Array.from(this.entityConfiguration.schema.keys());
const selectedFields = pick(fieldsObject, entitySelectedFields);
return new this.entityClass({
viewerContext: this.viewerContext,
id: id as TID,
databaseFields: fieldsObject,
selectedFields,
});
}
/**
* Construct and authorize entities from fields map, returning error results for entities that fail
* to construct or fail to authorize.
*
* @param map - map from an arbitrary key type to an array of entity field objects
*/
public async constructAndAuthorizeEntitiesAsync<K>(
map: ReadonlyMap<K, readonly Readonly<TFields>[]>
): Promise<ReadonlyMap<K, readonly Result<TEntity>[]>> {
const uncheckedEntityResultsMap = mapMap(map, (fieldObjects) =>
this.tryConstructEntities(fieldObjects)
);
return await mapMapAsync(uncheckedEntityResultsMap, async (uncheckedEntityResults) => {
return await Promise.all(
uncheckedEntityResults.map(async (uncheckedEntityResult) => {
if (!uncheckedEntityResult.ok) {
return uncheckedEntityResult;
}
return await asyncResult(
this.privacyPolicy.authorizeReadAsync(
this.viewerContext,
this.queryContext,
this.privacyPolicyEvaluationContext,
uncheckedEntityResult.value,
this.metricsAdapter
)
);
})
);
});
}
private validateFieldValues<N extends keyof Pick<TFields, TSelectedFields>>(
fieldName: N,
fieldValues: readonly TFields[N][]
): void {
const fieldDefinition = this.entityConfiguration.schema.get(fieldName);
invariant(fieldDefinition, `must have field definition for field = ${String(fieldName)}`);
for (const fieldValue of fieldValues) {
const isInputValid = fieldDefinition.validateInputValue(fieldValue);
if (!isInputValid) {
throw new EntityInvalidFieldValueError(this.entityClass, fieldName, fieldValue);
}
}
public utils(): EntityLoaderUtils<
TFields,
TID,
TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
> {
return this.utilsPrivate;
}
}
+10 -4
View File
@@ -22,7 +22,7 @@ export default class EntityLoaderFactory<
TEntity,
TSelectedFields
>,
TSelectedFields extends keyof TFields
TSelectedFields extends keyof TFields,
> {
constructor(
private readonly entityCompanion: EntityCompanion<
@@ -34,7 +34,7 @@ export default class EntityLoaderFactory<
TSelectedFields
>,
private readonly dataManager: EntityDataManager<TFields>,
protected readonly metricsAdapter: IEntityMetricsAdapter
protected readonly metricsAdapter: IEntityMetricsAdapter,
) {}
/**
@@ -45,7 +45,13 @@ export default class EntityLoaderFactory<
forLoad(
viewerContext: TViewerContext,
queryContext: EntityQueryContext,
privacyPolicyEvaluationContext: EntityPrivacyPolicyEvaluationContext
privacyPolicyEvaluationContext: EntityPrivacyPolicyEvaluationContext<
TFields,
TID,
TViewerContext,
TEntity,
TSelectedFields
>,
): EntityLoader<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy, TSelectedFields> {
return new EntityLoader(
viewerContext,
@@ -56,7 +62,7 @@ export default class EntityLoaderFactory<
this.entityCompanion.entityCompanionDefinition.entitySelectedFields,
this.entityCompanion.privacyPolicy,
this.dataManager,
this.metricsAdapter
this.metricsAdapter,
);
}
}
+149
View File
@@ -0,0 +1,149 @@
import { Result, asyncResult, result } from '@expo/results';
import nullthrows from 'nullthrows';
import { IEntityClass } from './Entity';
import EntityConfiguration from './EntityConfiguration';
import EntityPrivacyPolicy, { EntityPrivacyPolicyEvaluationContext } from './EntityPrivacyPolicy';
import { EntityQueryContext } from './EntityQueryContext';
import ReadonlyEntity from './ReadonlyEntity';
import ViewerContext from './ViewerContext';
import { pick } from './entityUtils';
import EntityDataManager from './internal/EntityDataManager';
import IEntityMetricsAdapter from './metrics/IEntityMetricsAdapter';
import { mapMapAsync } from './utils/collections/maps';
/**
* Entity loader utilities for things like invalidation, entity construction, and authorization.
* Methods are exposed publicly since in rare cases they may need to be called manually.
*/
export default class EntityLoaderUtils<
TFields extends object,
TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
TPrivacyPolicy extends EntityPrivacyPolicy<
TFields,
TID,
TViewerContext,
TEntity,
TSelectedFields
>,
TSelectedFields extends keyof TFields,
> {
constructor(
private readonly viewerContext: TViewerContext,
private readonly queryContext: EntityQueryContext,
private readonly privacyPolicyEvaluationContext: EntityPrivacyPolicyEvaluationContext<
TFields,
TID,
TViewerContext,
TEntity,
TSelectedFields
>,
private readonly entityConfiguration: EntityConfiguration<TFields>,
private readonly entityClass: IEntityClass<
TFields,
TID,
TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
>,
private readonly entitySelectedFields: TSelectedFields[] | undefined,
private readonly privacyPolicy: TPrivacyPolicy,
private readonly dataManager: EntityDataManager<TFields>,
protected readonly metricsAdapter: IEntityMetricsAdapter,
) {}
/**
* 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> {
await this.dataManager.invalidateObjectFieldsAsync(objectFields);
}
/**
* Invalidate all caches for an entity. One potential use case would be to keep the entity
* framework in sync with changes made to data outside of the framework.
* @param entity - entity to be invalidated
*/
async invalidateEntityAsync(entity: TEntity): Promise<void> {
await this.invalidateFieldsAsync(entity.getAllDatabaseFields());
}
/**
* Construct an entity from a fields object (applying field selection if applicable),
* checking that the ID field is specified.
*
* @param fieldsObject - fields object
*/
public constructEntity(fieldsObject: TFields): TEntity {
const idField = this.entityConfiguration.idField;
const id = nullthrows(fieldsObject[idField], 'must provide ID to create an entity');
const entitySelectedFields =
this.entitySelectedFields ?? Array.from(this.entityConfiguration.schema.keys());
const selectedFields = pick(fieldsObject, entitySelectedFields);
return new this.entityClass({
viewerContext: this.viewerContext,
id: id as TID,
databaseFields: fieldsObject,
selectedFields,
});
}
/**
* Construct and authorize entities from fields map, returning error results for entities that fail
* to construct or fail to authorize.
*
* @param map - map from an arbitrary key type to an array of entity field objects
*/
public async constructAndAuthorizeEntitiesAsync<K>(
map: ReadonlyMap<K, readonly Readonly<TFields>[]>,
): Promise<ReadonlyMap<K, readonly Result<TEntity>[]>> {
return await mapMapAsync(map, async (fieldObjects) => {
return await this.constructAndAuthorizeEntitiesArrayAsync(fieldObjects);
});
}
/**
* Construct and authorize entities from field objects array, returning error results for entities that fail
* to construct or fail to authorize.
*
* @param fieldObjects - array of field objects
*/
public async constructAndAuthorizeEntitiesArrayAsync(
fieldObjects: readonly Readonly<TFields>[],
): Promise<readonly Result<TEntity>[]> {
const uncheckedEntityResults = this.tryConstructEntities(fieldObjects);
return await Promise.all(
uncheckedEntityResults.map(async (uncheckedEntityResult) => {
if (!uncheckedEntityResult.ok) {
return uncheckedEntityResult;
}
return await asyncResult(
this.privacyPolicy.authorizeReadAsync(
this.viewerContext,
this.queryContext,
this.privacyPolicyEvaluationContext,
uncheckedEntityResult.value,
this.metricsAdapter,
),
);
}),
);
}
private tryConstructEntities(fieldsObjects: readonly TFields[]): readonly Result<TEntity>[] {
return fieldsObjects.map((fieldsObject) => {
try {
return result(this.constructEntity(fieldsObject));
} catch (e) {
if (!(e instanceof Error)) {
throw e;
}
return result(e);
}
});
}
}
+2 -2
View File
@@ -12,7 +12,7 @@ export type EntityValidatorMutationInfo<
TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext,
TEntity extends Entity<TFields, TID, TViewerContext, TSelectedFields>,
TSelectedFields extends keyof TFields = keyof TFields
TSelectedFields extends keyof TFields = keyof TFields,
> =
| {
type: EntityMutationType.CREATE;
@@ -43,7 +43,7 @@ export type EntityTriggerMutationInfo<
TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext,
TEntity extends Entity<TFields, TID, TViewerContext, TSelectedFields>,
TSelectedFields extends keyof TFields = keyof TFields
TSelectedFields extends keyof TFields = keyof TFields,
> =
| {
type: EntityMutationType.CREATE;
@@ -11,7 +11,7 @@ export default interface EntityMutationTriggerConfiguration<
TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
TSelectedFields extends keyof TFields = keyof TFields
TSelectedFields extends keyof TFields = keyof TFields,
> {
/**
* Trigger set that runs within the transaction but before the entity is created in the database.
@@ -72,13 +72,13 @@ export abstract class EntityMutationTrigger<
TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
TSelectedFields extends keyof TFields = keyof TFields
TSelectedFields extends keyof TFields = keyof TFields,
> {
abstract executeAsync(
viewerContext: TViewerContext,
queryContext: EntityTransactionalQueryContext,
entity: TEntity,
mutationInfo: EntityTriggerMutationInfo<TFields, TID, TViewerContext, TEntity, TSelectedFields>
mutationInfo: EntityTriggerMutationInfo<TFields, TID, TViewerContext, TEntity, TSelectedFields>,
): Promise<void>;
}
@@ -91,11 +91,11 @@ export abstract class EntityNonTransactionalMutationTrigger<
TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
TSelectedFields extends keyof TFields = keyof TFields
TSelectedFields extends keyof TFields = keyof TFields,
> {
abstract executeAsync(
viewerContext: TViewerContext,
entity: TEntity,
mutationInfo: EntityTriggerMutationInfo<TFields, TID, TViewerContext, TEntity, TSelectedFields>
mutationInfo: EntityTriggerMutationInfo<TFields, TID, TViewerContext, TEntity, TSelectedFields>,
): Promise<void>;
}
@@ -12,7 +12,7 @@ export default abstract class EntityMutationValidator<
TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
TSelectedFields extends keyof TFields = keyof TFields
TSelectedFields extends keyof TFields = keyof TFields,
> {
abstract executeAsync(
viewerContext: TViewerContext,
@@ -24,6 +24,6 @@ export default abstract class EntityMutationValidator<
TViewerContext,
TEntity,
TSelectedFields
>
>,
): Promise<void>;
}
+146 -144
View File
@@ -20,7 +20,6 @@ import EntityMutationTriggerConfiguration, {
import EntityMutationValidator from './EntityMutationValidator';
import EntityPrivacyPolicy from './EntityPrivacyPolicy';
import { EntityQueryContext, EntityTransactionalQueryContext } from './EntityQueryContext';
import ReadonlyEntity from './ReadonlyEntity';
import ViewerContext from './ViewerContext';
import EntityInvalidFieldValueError from './errors/EntityInvalidFieldValueError';
import { timeAndLogMutationEventAsync } from './metrics/EntityMetricsUtils';
@@ -39,7 +38,7 @@ abstract class BaseMutator<
TEntity,
TSelectedFields
>,
TSelectedFields extends keyof TFields
TSelectedFields extends keyof TFields,
> {
constructor(
protected readonly companionProvider: EntityCompanionProvider,
@@ -78,7 +77,7 @@ abstract class BaseMutator<
TSelectedFields
>,
protected readonly databaseAdapter: EntityDatabaseAdapter<TFields>,
protected readonly metricsAdapter: IEntityMetricsAdapter
protected readonly metricsAdapter: IEntityMetricsAdapter,
) {}
protected validateFields(fields: Partial<TFields>): void {
@@ -103,12 +102,12 @@ abstract class BaseMutator<
TViewerContext,
TEntity,
TSelectedFields
>
>,
): Promise<void> {
await Promise.all(
validators.map((validator) =>
validator.executeAsync(this.viewerContext, queryContext, entity, mutationInfo)
)
validator.executeAsync(this.viewerContext, queryContext, entity, mutationInfo),
),
);
}
@@ -118,15 +117,15 @@ abstract class BaseMutator<
| undefined,
queryContext: EntityTransactionalQueryContext,
entity: TEntity,
mutationInfo: EntityTriggerMutationInfo<TFields, TID, TViewerContext, TEntity, TSelectedFields>
mutationInfo: EntityTriggerMutationInfo<TFields, TID, TViewerContext, TEntity, TSelectedFields>,
): Promise<void> {
if (!triggers) {
return;
}
await Promise.all(
triggers.map((trigger) =>
trigger.executeAsync(this.viewerContext, queryContext, entity, mutationInfo)
)
trigger.executeAsync(this.viewerContext, queryContext, entity, mutationInfo),
),
);
}
@@ -141,13 +140,13 @@ abstract class BaseMutator<
>[]
| undefined,
entity: TEntity,
mutationInfo: EntityTriggerMutationInfo<TFields, TID, TViewerContext, TEntity, TSelectedFields>
mutationInfo: EntityTriggerMutationInfo<TFields, TID, TViewerContext, TEntity, TSelectedFields>,
): Promise<void> {
if (!triggers) {
return;
}
await Promise.all(
triggers.map((trigger) => trigger.executeAsync(this.viewerContext, entity, mutationInfo))
triggers.map((trigger) => trigger.executeAsync(this.viewerContext, entity, mutationInfo)),
);
}
}
@@ -167,7 +166,7 @@ export class CreateMutator<
TEntity,
TSelectedFields
>,
TSelectedFields extends keyof TFields
TSelectedFields extends keyof TFields,
> extends BaseMutator<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy, TSelectedFields> {
private readonly fieldsForEntity: Partial<TFields> = {};
@@ -190,7 +189,7 @@ export class CreateMutator<
return await timeAndLogMutationEventAsync(
this.metricsAdapter,
EntityMetricsMutationType.CREATE,
this.entityClass.name
this.entityClass.name,
)(this.createInTransactionAsync());
}
@@ -203,20 +202,21 @@ export class CreateMutator<
private async createInTransactionAsync(): Promise<Result<TEntity>> {
return await this.queryContext.runInTransactionIfNotInTransactionAsync((innerQueryContext) =>
this.createInternalAsync(innerQueryContext)
this.createInternalAsync(innerQueryContext),
);
}
private async createInternalAsync(
queryContext: EntityTransactionalQueryContext
queryContext: EntityTransactionalQueryContext,
): Promise<Result<TEntity>> {
this.validateFields(this.fieldsForEntity);
const entityLoader = this.entityLoaderFactory.forLoad(this.viewerContext, queryContext, {
previousValue: null,
cascadingDeleteCause: null,
});
const temporaryEntityForPrivacyCheck = entityLoader.constructEntity({
const temporaryEntityForPrivacyCheck = entityLoader.utils().constructEntity({
[this.entityConfiguration.idField]: '00000000-0000-0000-0000-000000000000', // zero UUID
...this.fieldsForEntity,
} as unknown as TFields);
@@ -225,10 +225,10 @@ export class CreateMutator<
this.privacyPolicy.authorizeCreateAsync(
this.viewerContext,
queryContext,
{ cascadingDeleteCause: null },
{ previousValue: null, cascadingDeleteCause: null },
temporaryEntityForPrivacyCheck,
this.metricsAdapter
)
this.metricsAdapter,
),
);
if (!authorizeCreateResult.ok) {
return authorizeCreateResult;
@@ -238,28 +238,28 @@ export class CreateMutator<
this.mutationValidators,
queryContext,
temporaryEntityForPrivacyCheck,
{ type: EntityMutationType.CREATE }
{ type: EntityMutationType.CREATE },
);
await this.executeMutationTriggersAsync(
this.mutationTriggers.beforeAll,
queryContext,
temporaryEntityForPrivacyCheck,
{ type: EntityMutationType.CREATE }
{ type: EntityMutationType.CREATE },
);
await this.executeMutationTriggersAsync(
this.mutationTriggers.beforeCreate,
queryContext,
temporaryEntityForPrivacyCheck,
{ type: EntityMutationType.CREATE }
{ type: EntityMutationType.CREATE },
);
const insertResult = await this.databaseAdapter.insertAsync(queryContext, this.fieldsForEntity);
queryContext.appendPostCommitInvalidationCallback(
entityLoader.invalidateFieldsAsync.bind(entityLoader, insertResult)
entityLoader.utils().invalidateFieldsAsync.bind(entityLoader, insertResult),
);
const unauthorizedEntityAfterInsert = entityLoader.constructEntity(insertResult);
const unauthorizedEntityAfterInsert = entityLoader.utils().constructEntity(insertResult);
const newEntity = await entityLoader
.enforcing()
.loadByIDAsync(unauthorizedEntityAfterInsert.getID());
@@ -268,13 +268,13 @@ export class CreateMutator<
this.mutationTriggers.afterCreate,
queryContext,
newEntity,
{ type: EntityMutationType.CREATE }
{ type: EntityMutationType.CREATE },
);
await this.executeMutationTriggersAsync(
this.mutationTriggers.afterAll,
queryContext,
newEntity,
{ type: EntityMutationType.CREATE }
{ type: EntityMutationType.CREATE },
);
queryContext.appendPostCommitCallback(
@@ -282,8 +282,8 @@ export class CreateMutator<
this,
this.mutationTriggers.afterCommit,
newEntity,
{ type: EntityMutationType.CREATE }
)
{ type: EntityMutationType.CREATE },
),
);
return result(newEntity);
@@ -305,7 +305,7 @@ export class UpdateMutator<
TEntity,
TSelectedFields
>,
TSelectedFields extends keyof TFields
TSelectedFields extends keyof TFields,
> extends BaseMutator<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy, TSelectedFields> {
private readonly originalEntity: TEntity;
private readonly fieldsForEntity: TFields;
@@ -349,7 +349,7 @@ export class UpdateMutator<
>,
databaseAdapter: EntityDatabaseAdapter<TFields>,
metricsAdapter: IEntityMetricsAdapter,
originalEntity: TEntity
originalEntity: TEntity,
) {
super(
companionProvider,
@@ -362,7 +362,7 @@ export class UpdateMutator<
mutationTriggers,
entityLoaderFactory,
databaseAdapter,
metricsAdapter
metricsAdapter,
);
this.originalEntity = originalEntity;
this.fieldsForEntity = { ...originalEntity.getAllDatabaseFields() };
@@ -388,7 +388,7 @@ export class UpdateMutator<
return await timeAndLogMutationEventAsync(
this.metricsAdapter,
EntityMetricsMutationType.UPDATE,
this.entityClass.name
this.entityClass.name,
)(this.updateInTransactionAsync(false, null));
}
@@ -401,41 +401,43 @@ export class UpdateMutator<
private async updateInTransactionAsync(
skipDatabaseUpdate: true,
cascadingDeleteCause: EntityCascadingDeletionInfo
cascadingDeleteCause: EntityCascadingDeletionInfo,
): Promise<Result<TEntity>>;
private async updateInTransactionAsync(
skipDatabaseUpdate: false,
cascadingDeleteCause: EntityCascadingDeletionInfo | null
cascadingDeleteCause: EntityCascadingDeletionInfo | null,
): Promise<Result<TEntity>>;
private async updateInTransactionAsync(
skipDatabaseUpdate: boolean,
cascadingDeleteCause: EntityCascadingDeletionInfo | null
cascadingDeleteCause: EntityCascadingDeletionInfo | null,
): Promise<Result<TEntity>> {
return await this.queryContext.runInTransactionIfNotInTransactionAsync((innerQueryContext) =>
this.updateInternalAsync(innerQueryContext, skipDatabaseUpdate, cascadingDeleteCause)
this.updateInternalAsync(innerQueryContext, skipDatabaseUpdate, cascadingDeleteCause),
);
}
private async updateInternalAsync(
queryContext: EntityTransactionalQueryContext,
skipDatabaseUpdate: boolean,
cascadingDeleteCause: EntityCascadingDeletionInfo | null
cascadingDeleteCause: EntityCascadingDeletionInfo | null,
): Promise<Result<TEntity>> {
this.validateFields(this.updatedFields);
this.ensureStableIDField(this.updatedFields);
const entityLoader = this.entityLoaderFactory.forLoad(this.viewerContext, queryContext, {
previousValue: this.originalEntity,
cascadingDeleteCause,
});
const entityAboutToBeUpdated = entityLoader.constructEntity(this.fieldsForEntity);
const entityAboutToBeUpdated = entityLoader.utils().constructEntity(this.fieldsForEntity);
const authorizeUpdateResult = await asyncResult(
this.privacyPolicy.authorizeUpdateAsync(
this.viewerContext,
queryContext,
{ cascadingDeleteCause },
{ previousValue: this.originalEntity, cascadingDeleteCause },
entityAboutToBeUpdated,
this.metricsAdapter
)
this.metricsAdapter,
),
);
if (!authorizeUpdateResult.ok) {
return authorizeUpdateResult;
@@ -445,60 +447,55 @@ export class UpdateMutator<
this.mutationValidators,
queryContext,
entityAboutToBeUpdated,
{ type: EntityMutationType.UPDATE, previousValue: this.originalEntity, cascadingDeleteCause }
{ type: EntityMutationType.UPDATE, previousValue: this.originalEntity, cascadingDeleteCause },
);
await this.executeMutationTriggersAsync(
this.mutationTriggers.beforeAll,
queryContext,
entityAboutToBeUpdated,
{ type: EntityMutationType.UPDATE, previousValue: this.originalEntity, cascadingDeleteCause }
{ type: EntityMutationType.UPDATE, previousValue: this.originalEntity, cascadingDeleteCause },
);
await this.executeMutationTriggersAsync(
this.mutationTriggers.beforeUpdate,
queryContext,
entityAboutToBeUpdated,
{ type: EntityMutationType.UPDATE, previousValue: this.originalEntity, cascadingDeleteCause }
{ type: EntityMutationType.UPDATE, previousValue: this.originalEntity, cascadingDeleteCause },
);
// skip the database update when specified
const updateResult = skipDatabaseUpdate
? null
: await this.databaseAdapter.updateAsync(
queryContext,
this.entityConfiguration.idField,
entityAboutToBeUpdated.getID(),
this.updatedFields
);
if (!skipDatabaseUpdate) {
await this.databaseAdapter.updateAsync(
queryContext,
this.entityConfiguration.idField,
entityAboutToBeUpdated.getID(),
this.updatedFields,
);
}
queryContext.appendPostCommitInvalidationCallback(
entityLoader.invalidateFieldsAsync.bind(
entityLoader,
this.originalEntity.getAllDatabaseFields()
)
entityLoader
.utils()
.invalidateFieldsAsync.bind(entityLoader, this.originalEntity.getAllDatabaseFields()),
);
queryContext.appendPostCommitInvalidationCallback(
entityLoader.invalidateFieldsAsync.bind(entityLoader, this.fieldsForEntity)
entityLoader.utils().invalidateFieldsAsync.bind(entityLoader, this.fieldsForEntity),
);
// when the database update was skipped, assume it succeeded and use the optimistic
// entity to execute triggers
const updatedEntity = updateResult
? await entityLoader
.enforcing()
.loadByIDAsync(entityLoader.constructEntity(updateResult).getID())
: entityAboutToBeUpdated;
const updatedEntity = await entityLoader
.enforcing()
.loadByIDAsync(entityAboutToBeUpdated.getID()); // ID is guaranteed to be stable by ensureStableIDField
await this.executeMutationTriggersAsync(
this.mutationTriggers.afterUpdate,
queryContext,
updatedEntity,
{ type: EntityMutationType.UPDATE, previousValue: this.originalEntity, cascadingDeleteCause }
{ type: EntityMutationType.UPDATE, previousValue: this.originalEntity, cascadingDeleteCause },
);
await this.executeMutationTriggersAsync(
this.mutationTriggers.afterAll,
queryContext,
updatedEntity,
{ type: EntityMutationType.UPDATE, previousValue: this.originalEntity, cascadingDeleteCause }
{ type: EntityMutationType.UPDATE, previousValue: this.originalEntity, cascadingDeleteCause },
);
queryContext.appendPostCommitCallback(
@@ -510,12 +507,20 @@ export class UpdateMutator<
type: EntityMutationType.UPDATE,
previousValue: this.originalEntity,
cascadingDeleteCause,
}
)
},
),
);
return result(updatedEntity);
}
private ensureStableIDField(updatedFields: Partial<TFields>): void {
const originalId = this.originalEntity.getID();
const idField = this.entityConfiguration.idField;
if (updatedFields.hasOwnProperty(idField) && originalId !== updatedFields[idField]) {
throw new Error(`id field updates not supported: (entityClass = ${this.entityClass.name})`);
}
}
}
/**
@@ -533,7 +538,7 @@ export class DeleteMutator<
TEntity,
TSelectedFields
>,
TSelectedFields extends keyof TFields
TSelectedFields extends keyof TFields,
> extends BaseMutator<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy, TSelectedFields> {
constructor(
companionProvider: EntityCompanionProvider,
@@ -573,7 +578,7 @@ export class DeleteMutator<
>,
databaseAdapter: EntityDatabaseAdapter<TFields>,
metricsAdapter: IEntityMetricsAdapter,
private readonly entity: TEntity
private readonly entity: TEntity,
) {
super(
companionProvider,
@@ -586,7 +591,7 @@ export class DeleteMutator<
mutationTriggers,
entityLoaderFactory,
databaseAdapter,
metricsAdapter
metricsAdapter,
);
}
@@ -598,7 +603,7 @@ export class DeleteMutator<
return await timeAndLogMutationEventAsync(
this.metricsAdapter,
EntityMetricsMutationType.DELETE,
this.entityClass.name
this.entityClass.name,
)(this.deleteInTransactionAsync(new Set(), false, null));
}
@@ -612,15 +617,15 @@ export class DeleteMutator<
private async deleteInTransactionAsync(
processedEntityIdentifiersFromTransitiveDeletions: Set<string>,
skipDatabaseDeletion: boolean,
cascadingDeleteCause: EntityCascadingDeletionInfo | null
cascadingDeleteCause: EntityCascadingDeletionInfo | null,
): Promise<Result<void>> {
return await this.queryContext.runInTransactionIfNotInTransactionAsync((innerQueryContext) =>
this.deleteInternalAsync(
innerQueryContext,
processedEntityIdentifiersFromTransitiveDeletions,
skipDatabaseDeletion,
cascadingDeleteCause
)
cascadingDeleteCause,
),
);
}
@@ -628,16 +633,16 @@ export class DeleteMutator<
queryContext: EntityTransactionalQueryContext,
processedEntityIdentifiersFromTransitiveDeletions: Set<string>,
skipDatabaseDeletion: boolean,
cascadingDeleteCause: EntityCascadingDeletionInfo | null
cascadingDeleteCause: EntityCascadingDeletionInfo | null,
): Promise<Result<void>> {
const authorizeDeleteResult = await asyncResult(
this.privacyPolicy.authorizeDeleteAsync(
this.viewerContext,
queryContext,
{ cascadingDeleteCause },
{ previousValue: null, cascadingDeleteCause },
this.entity,
this.metricsAdapter
)
this.metricsAdapter,
),
);
if (!authorizeDeleteResult.ok) {
return authorizeDeleteResult;
@@ -647,48 +652,51 @@ export class DeleteMutator<
this.entity,
queryContext,
processedEntityIdentifiersFromTransitiveDeletions,
cascadingDeleteCause
cascadingDeleteCause,
);
await this.executeMutationTriggersAsync(
this.mutationTriggers.beforeAll,
queryContext,
this.entity,
{ type: EntityMutationType.DELETE, cascadingDeleteCause }
{ type: EntityMutationType.DELETE, cascadingDeleteCause },
);
await this.executeMutationTriggersAsync(
this.mutationTriggers.beforeDelete,
queryContext,
this.entity,
{ type: EntityMutationType.DELETE, cascadingDeleteCause }
{ type: EntityMutationType.DELETE, cascadingDeleteCause },
);
if (!skipDatabaseDeletion) {
await this.databaseAdapter.deleteAsync(
queryContext,
this.entityConfiguration.idField,
this.entity.getID()
this.entity.getID(),
);
}
const entityLoader = this.entityLoaderFactory.forLoad(this.viewerContext, queryContext, {
previousValue: null,
cascadingDeleteCause,
});
queryContext.appendPostCommitInvalidationCallback(
entityLoader.invalidateFieldsAsync.bind(entityLoader, this.entity.getAllDatabaseFields())
entityLoader
.utils()
.invalidateFieldsAsync.bind(entityLoader, this.entity.getAllDatabaseFields()),
);
await this.executeMutationTriggersAsync(
this.mutationTriggers.afterDelete,
queryContext,
this.entity,
{ type: EntityMutationType.DELETE, cascadingDeleteCause }
{ type: EntityMutationType.DELETE, cascadingDeleteCause },
);
await this.executeMutationTriggersAsync(
this.mutationTriggers.afterAll,
queryContext,
this.entity,
{ type: EntityMutationType.DELETE, cascadingDeleteCause }
{ type: EntityMutationType.DELETE, cascadingDeleteCause },
);
queryContext.appendPostCommitCallback(
@@ -696,8 +704,8 @@ export class DeleteMutator<
this,
this.mutationTriggers.afterCommit,
this.entity,
{ type: EntityMutationType.DELETE, cascadingDeleteCause }
)
{ type: EntityMutationType.DELETE, cascadingDeleteCause },
),
);
return result();
@@ -721,7 +729,7 @@ export class DeleteMutator<
entity: TEntity,
queryContext: EntityTransactionalQueryContext,
processedEntityIdentifiers: Set<string>,
cascadingDeleteCause: EntityCascadingDeletionInfo | null
cascadingDeleteCause: EntityCascadingDeletionInfo | null,
): Promise<void> {
// prevent infinite reference cycles by keeping track of entities already processed
if (processedEntityIdentifiers.has(entity.getUniqueIdentifier())) {
@@ -737,12 +745,27 @@ export class DeleteMutator<
TEntity,
TPrivacyPolicy,
TSelectedFields
>
>,
).entityCompanionDefinition;
const entityConfiguration = companionDefinition.entityConfiguration;
const inboundEdges = entityConfiguration.inboundEdges;
const newCascadingDeleteCause = {
entity,
cascadingDeleteCause,
};
await Promise.all(
inboundEdges.map(async (entityClass) => {
const loaderFactory = entity
.getViewerContext()
.getViewerScopedEntityCompanionForClass(entityClass)
.getLoaderFactory();
const mutatorFactory = entity
.getViewerContext()
.getViewerScopedEntityCompanionForClass(entityClass)
.getMutatorFactory();
return await mapMapAsync(
this.companionProvider.getCompanionForEntity(entityClass).entityCompanionDefinition
.entityConfiguration.schema,
@@ -753,43 +776,24 @@ export class DeleteMutator<
}
const associatedConfiguration = this.companionProvider.getCompanionForEntity(
association.associatedEntityClass
association.associatedEntityClass,
).entityCompanionDefinition.entityConfiguration;
if (associatedConfiguration !== entityConfiguration) {
return;
}
const associatedEntityLookupByField = association.associatedEntityLookupByField;
const loaderFactory = entity
.getViewerContext()
.getViewerScopedEntityCompanionForClass(entityClass)
.getLoaderFactory();
const mutatorFactory = entity
.getViewerContext()
.getViewerScopedEntityCompanionForClass(entityClass)
.getMutatorFactory();
const newCascadingDeleteCause = {
entity,
cascadingDeleteCause,
};
let inboundReferenceEntities: readonly ReadonlyEntity<any, any, any, any>[];
if (associatedEntityLookupByField) {
inboundReferenceEntities = await loaderFactory
.forLoad(queryContext, { cascadingDeleteCause: newCascadingDeleteCause })
.enforcing()
.loadManyByFieldEqualingAsync(
fieldName,
entity.getField(associatedEntityLookupByField as any)
);
} else {
inboundReferenceEntities = await loaderFactory
.forLoad(queryContext, { cascadingDeleteCause: newCascadingDeleteCause })
.enforcing()
.loadManyByFieldEqualingAsync(fieldName, entity.getID());
}
const inboundReferenceEntities = await loaderFactory
.forLoad(queryContext, {
previousValue: null,
cascadingDeleteCause: newCascadingDeleteCause,
})
.enforcing()
.loadManyByFieldEqualingAsync(
fieldName,
association.associatedEntityLookupByField
? entity.getField(association.associatedEntityLookupByField as any)
: entity.getID(),
);
switch (association.edgeDeletionBehavior) {
case EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE_ONLY: {
@@ -801,10 +805,10 @@ export class DeleteMutator<
.deleteInTransactionAsync(
processedEntityIdentifiers,
/* skipDatabaseDeletion */ true, // deletion is handled by DB
newCascadingDeleteCause
)
)
)
newCascadingDeleteCause,
),
),
),
);
break;
}
@@ -815,12 +819,11 @@ export class DeleteMutator<
mutatorFactory
.forUpdate(inboundReferenceEntity, queryContext)
.setField(fieldName, null)
['updateInTransactionAsync'](
/* skipDatabaseUpdate */ true,
newCascadingDeleteCause
)
)
)
[
'updateInTransactionAsync'
](/* skipDatabaseUpdate */ true, newCascadingDeleteCause),
),
),
);
break;
}
@@ -831,12 +834,11 @@ export class DeleteMutator<
mutatorFactory
.forUpdate(inboundReferenceEntity, queryContext)
.setField(fieldName, null)
['updateInTransactionAsync'](
/* skipDatabaseUpdate */ false,
newCascadingDeleteCause
)
)
)
[
'updateInTransactionAsync'
](/* skipDatabaseUpdate */ false, newCascadingDeleteCause),
),
),
);
break;
}
@@ -849,16 +851,16 @@ export class DeleteMutator<
.deleteInTransactionAsync(
processedEntityIdentifiers,
/* skipDatabaseDeletion */ false,
newCascadingDeleteCause
)
)
)
newCascadingDeleteCause,
),
),
),
);
}
}
}
},
);
})
}),
);
}
}
+8 -8
View File
@@ -26,7 +26,7 @@ export default class EntityMutatorFactory<
TEntity,
TSelectedFields
>,
TSelectedFields extends keyof TFields = keyof TFields
TSelectedFields extends keyof TFields = keyof TFields,
> {
constructor(
private readonly entityCompanionProvider: EntityCompanionProvider,
@@ -63,7 +63,7 @@ export default class EntityMutatorFactory<
TSelectedFields
>,
private readonly databaseAdapter: EntityDatabaseAdapter<TFields>,
private readonly metricsAdapter: IEntityMetricsAdapter
private readonly metricsAdapter: IEntityMetricsAdapter,
) {}
/**
@@ -74,7 +74,7 @@ export default class EntityMutatorFactory<
*/
forCreate(
viewerContext: TViewerContext,
queryContext: EntityQueryContext
queryContext: EntityQueryContext,
): CreateMutator<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy, TSelectedFields> {
return new CreateMutator(
this.entityCompanionProvider,
@@ -87,7 +87,7 @@ export default class EntityMutatorFactory<
this.mutationTriggers,
this.entityLoaderFactory,
this.databaseAdapter,
this.metricsAdapter
this.metricsAdapter,
);
}
@@ -99,7 +99,7 @@ export default class EntityMutatorFactory<
*/
forUpdate(
existingEntity: TEntity,
queryContext: EntityQueryContext
queryContext: EntityQueryContext,
): UpdateMutator<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy, TSelectedFields> {
return new UpdateMutator(
this.entityCompanionProvider,
@@ -113,7 +113,7 @@ export default class EntityMutatorFactory<
this.entityLoaderFactory,
this.databaseAdapter,
this.metricsAdapter,
existingEntity
existingEntity,
);
}
@@ -124,7 +124,7 @@ export default class EntityMutatorFactory<
*/
forDelete(
existingEntity: TEntity,
queryContext: EntityQueryContext
queryContext: EntityQueryContext,
): DeleteMutator<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy, TSelectedFields> {
return new DeleteMutator(
this.entityCompanionProvider,
@@ -138,7 +138,7 @@ export default class EntityMutatorFactory<
this.entityLoaderFactory,
this.databaseAdapter,
this.metricsAdapter,
existingEntity
existingEntity,
);
}
}
+78 -28
View File
@@ -11,7 +11,19 @@ import PrivacyPolicyRule, { RuleEvaluationResult } from './rules/PrivacyPolicyRu
/**
* Information about the reason this privacy policy is being evaluated.
*/
export type EntityPrivacyPolicyEvaluationContext = {
export type EntityPrivacyPolicyEvaluationContext<
TFields extends object,
TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
TSelectedFields extends keyof TFields = keyof TFields,
> = {
/**
* When this privacy policy is being evaluated as a result of an update, this will be populated with the value
* of the entity before the update. Note that this doesn't only apply to UPDATE authorization actions though:
* when an entity is updated it is re-LOADed after the update completes.
*/
previousValue: TEntity | null;
/**
* When this privacy policy is being evaluated as a result of a cascading deletion, this will be populated
* with information on the cascading delete.
@@ -45,7 +57,7 @@ export type EntityPrivacyPolicyEvaluator<
TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
TSelectedFields extends keyof TFields = keyof TFields
TSelectedFields extends keyof TFields = keyof TFields,
> =
| {
mode: EntityPrivacyPolicyEvaluationMode.ENFORCE;
@@ -53,13 +65,13 @@ export type EntityPrivacyPolicyEvaluator<
| {
mode: EntityPrivacyPolicyEvaluationMode.DRY_RUN;
denyHandler: (
error: EntityNotAuthorizedError<TFields, TID, TViewerContext, TEntity, TSelectedFields>
error: EntityNotAuthorizedError<TFields, TID, TViewerContext, TEntity, TSelectedFields>,
) => void;
}
| {
mode: EntityPrivacyPolicyEvaluationMode.ENFORCE_AND_LOG;
denyHandler: (
error: EntityNotAuthorizedError<TFields, TID, TViewerContext, TEntity, TSelectedFields>
error: EntityNotAuthorizedError<TFields, TID, TViewerContext, TEntity, TSelectedFields>,
) => void;
};
@@ -96,7 +108,7 @@ export default abstract class EntityPrivacyPolicy<
TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
TSelectedFields extends keyof TFields = keyof TFields
TSelectedFields extends keyof TFields = keyof TFields,
> {
protected readonly createRules: readonly PrivacyPolicyRule<
TFields,
@@ -136,7 +148,7 @@ export default abstract class EntityPrivacyPolicy<
* Override to enable dry run evaluation of the policy.
*/
protected getPrivacyPolicyEvaluator(
_viewerContext: TViewerContext
_viewerContext: TViewerContext,
): EntityPrivacyPolicyEvaluator<TFields, TID, TViewerContext, TEntity, TSelectedFields> {
return {
mode: EntityPrivacyPolicyEvaluationMode.ENFORCE,
@@ -154,9 +166,15 @@ export default abstract class EntityPrivacyPolicy<
async authorizeCreateAsync(
viewerContext: TViewerContext,
queryContext: EntityQueryContext,
evaluationContext: EntityPrivacyPolicyEvaluationContext,
evaluationContext: EntityPrivacyPolicyEvaluationContext<
TFields,
TID,
TViewerContext,
TEntity,
TSelectedFields
>,
entity: TEntity,
metricsAdapter: IEntityMetricsAdapter
metricsAdapter: IEntityMetricsAdapter,
): Promise<TEntity> {
return await this.authorizeForRulesetAsync(
this.createRules,
@@ -165,7 +183,7 @@ export default abstract class EntityPrivacyPolicy<
evaluationContext,
entity,
EntityAuthorizationAction.CREATE,
metricsAdapter
metricsAdapter,
);
}
@@ -180,9 +198,15 @@ export default abstract class EntityPrivacyPolicy<
async authorizeReadAsync(
viewerContext: TViewerContext,
queryContext: EntityQueryContext,
evaluationContext: EntityPrivacyPolicyEvaluationContext,
evaluationContext: EntityPrivacyPolicyEvaluationContext<
TFields,
TID,
TViewerContext,
TEntity,
TSelectedFields
>,
entity: TEntity,
metricsAdapter: IEntityMetricsAdapter
metricsAdapter: IEntityMetricsAdapter,
): Promise<TEntity> {
return await this.authorizeForRulesetAsync(
this.readRules,
@@ -191,7 +215,7 @@ export default abstract class EntityPrivacyPolicy<
evaluationContext,
entity,
EntityAuthorizationAction.READ,
metricsAdapter
metricsAdapter,
);
}
@@ -206,9 +230,15 @@ export default abstract class EntityPrivacyPolicy<
async authorizeUpdateAsync(
viewerContext: TViewerContext,
queryContext: EntityQueryContext,
evaluationContext: EntityPrivacyPolicyEvaluationContext,
evaluationContext: EntityPrivacyPolicyEvaluationContext<
TFields,
TID,
TViewerContext,
TEntity,
TSelectedFields
>,
entity: TEntity,
metricsAdapter: IEntityMetricsAdapter
metricsAdapter: IEntityMetricsAdapter,
): Promise<TEntity> {
return await this.authorizeForRulesetAsync(
this.updateRules,
@@ -217,7 +247,7 @@ export default abstract class EntityPrivacyPolicy<
evaluationContext,
entity,
EntityAuthorizationAction.UPDATE,
metricsAdapter
metricsAdapter,
);
}
@@ -232,9 +262,15 @@ export default abstract class EntityPrivacyPolicy<
async authorizeDeleteAsync(
viewerContext: TViewerContext,
queryContext: EntityQueryContext,
evaluationContext: EntityPrivacyPolicyEvaluationContext,
evaluationContext: EntityPrivacyPolicyEvaluationContext<
TFields,
TID,
TViewerContext,
TEntity,
TSelectedFields
>,
entity: TEntity,
metricsAdapter: IEntityMetricsAdapter
metricsAdapter: IEntityMetricsAdapter,
): Promise<TEntity> {
return await this.authorizeForRulesetAsync(
this.deleteRules,
@@ -243,7 +279,7 @@ export default abstract class EntityPrivacyPolicy<
evaluationContext,
entity,
EntityAuthorizationAction.DELETE,
metricsAdapter
metricsAdapter,
);
}
@@ -251,10 +287,16 @@ export default abstract class EntityPrivacyPolicy<
ruleset: readonly PrivacyPolicyRule<TFields, TID, TViewerContext, TEntity, TSelectedFields>[],
viewerContext: TViewerContext,
queryContext: EntityQueryContext,
evaluationContext: EntityPrivacyPolicyEvaluationContext,
evaluationContext: EntityPrivacyPolicyEvaluationContext<
TFields,
TID,
TViewerContext,
TEntity,
TSelectedFields
>,
entity: TEntity,
action: EntityAuthorizationAction,
metricsAdapter: IEntityMetricsAdapter
metricsAdapter: IEntityMetricsAdapter,
): Promise<TEntity> {
const privacyPolicyEvaluator = this.getPrivacyPolicyEvaluator(viewerContext);
switch (privacyPolicyEvaluator.mode) {
@@ -266,7 +308,7 @@ export default abstract class EntityPrivacyPolicy<
queryContext,
evaluationContext,
entity,
action
action,
);
metricsAdapter.logAuthorizationEvent({
entityClassName: entity.constructor.name,
@@ -295,7 +337,7 @@ export default abstract class EntityPrivacyPolicy<
queryContext,
evaluationContext,
entity,
action
action,
);
metricsAdapter.logAuthorizationEvent({
entityClassName: entity.constructor.name,
@@ -325,7 +367,7 @@ export default abstract class EntityPrivacyPolicy<
queryContext,
evaluationContext,
entity,
action
action,
);
metricsAdapter.logAuthorizationEvent({
entityClassName: entity.constructor.name,
@@ -354,9 +396,15 @@ export default abstract class EntityPrivacyPolicy<
ruleset: readonly PrivacyPolicyRule<TFields, TID, TViewerContext, TEntity, TSelectedFields>[],
viewerContext: TViewerContext,
queryContext: EntityQueryContext,
evaluationContext: EntityPrivacyPolicyEvaluationContext,
evaluationContext: EntityPrivacyPolicyEvaluationContext<
TFields,
TID,
TViewerContext,
TEntity,
TSelectedFields
>,
entity: TEntity,
action: EntityAuthorizationAction
action: EntityAuthorizationAction,
): Promise<TEntity> {
for (let i = 0; i < ruleset.length; i++) {
const rule = ruleset[i]!;
@@ -364,7 +412,7 @@ export default abstract class EntityPrivacyPolicy<
viewerContext,
queryContext,
evaluationContext,
entity
entity,
);
switch (ruleEvaluationResult) {
case RuleEvaluationResult.DENY:
@@ -380,7 +428,9 @@ export default abstract class EntityPrivacyPolicy<
case RuleEvaluationResult.ALLOW:
return entity;
default:
throw new Error('should not be a fourth type of rule evaluation result');
throw new Error(
`Invalid RuleEvaluationResult returned from rule: ${entity} (viewer = ${viewerContext}, action = ${EntityAuthorizationAction[action]}, ruleIndex = ${i})`,
);
}
}
@@ -388,7 +438,7 @@ export default abstract class EntityPrivacyPolicy<
entity,
viewerContext,
action,
-1
-1,
);
}
}
+14 -13
View File
@@ -36,7 +36,7 @@ export abstract class EntityQueryContext {
abstract runInTransactionIfNotInTransactionAsync<T>(
transactionScope: (queryContext: EntityTransactionalQueryContext) => Promise<T>,
transactionConfig?: TransactionConfig
transactionConfig?: TransactionConfig,
): Promise<T>;
}
@@ -49,7 +49,7 @@ export abstract class EntityQueryContext {
export class EntityNonTransactionalQueryContext extends EntityQueryContext {
constructor(
queryInterface: any,
private readonly entityQueryContextProvider: EntityQueryContextProvider
private readonly entityQueryContextProvider: EntityQueryContextProvider,
) {
super(queryInterface);
}
@@ -60,11 +60,11 @@ export class EntityNonTransactionalQueryContext extends EntityQueryContext {
async runInTransactionIfNotInTransactionAsync<T>(
transactionScope: (queryContext: EntityTransactionalQueryContext) => Promise<T>,
transactionConfig?: TransactionConfig
transactionConfig?: TransactionConfig,
): Promise<T> {
return await this.entityQueryContextProvider.runInTransactionAsync(
transactionScope,
transactionConfig
transactionConfig,
);
}
}
@@ -82,7 +82,7 @@ export class EntityTransactionalQueryContext extends EntityQueryContext {
constructor(
queryInterface: any,
private readonly entityQueryContextProvider: EntityQueryContextProvider
private readonly entityQueryContextProvider: EntityQueryContextProvider,
) {
super(queryInterface);
}
@@ -98,7 +98,7 @@ export class EntityTransactionalQueryContext extends EntityQueryContext {
public appendPreCommitCallback(callback: PreCommitCallback, order: number): void {
assert(
order >= Number.MIN_SAFE_INTEGER && order <= Number.MAX_SAFE_INTEGER,
`Invalid order specified: ${order}`
`Invalid order specified: ${order}`,
);
this.preCommitCallbacks.push({ callback, order });
}
@@ -148,21 +148,21 @@ export class EntityTransactionalQueryContext extends EntityQueryContext {
async runInTransactionIfNotInTransactionAsync<T>(
transactionScope: (queryContext: EntityTransactionalQueryContext) => Promise<T>,
transactionConfig?: TransactionConfig
transactionConfig?: TransactionConfig,
): Promise<T> {
assert(
transactionConfig === undefined,
'Should not pass transactionConfig to a nested transaction'
'Should not pass transactionConfig to a nested transaction',
);
return await transactionScope(this);
}
async runInNestedTransactionAsync<T>(
transactionScope: (innerQueryContext: EntityTransactionalQueryContext) => Promise<T>
transactionScope: (innerQueryContext: EntityTransactionalQueryContext) => Promise<T>,
): Promise<T> {
return await this.entityQueryContextProvider.runInNestedTransactionAsync(
this,
transactionScope
transactionScope,
);
}
}
@@ -172,7 +172,8 @@ export class EntityTransactionalQueryContext extends EntityQueryContext {
* to EntityMutator and EntityLoader methods, those methods and their
* dependent triggers and validators will run within the nested transaction.
*
* This exists to forward post-commit callbacks to the parent query context.
* This exists to forward post-commit callbacks to the parent query context but only after
* successful commit of the nested transaction.
*/
export class EntityNestedTransactionalQueryContext extends EntityTransactionalQueryContext {
private readonly postCommitInvalidationCallbacksToTransfer: PostCommitCallback[] = [];
@@ -181,7 +182,7 @@ export class EntityNestedTransactionalQueryContext extends EntityTransactionalQu
constructor(
queryInterface: any,
private readonly parentQueryContext: EntityTransactionalQueryContext,
entityQueryContextProvider: EntityQueryContextProvider
entityQueryContextProvider: EntityQueryContextProvider,
) {
super(queryInterface, entityQueryContextProvider);
}
@@ -196,7 +197,7 @@ export class EntityNestedTransactionalQueryContext extends EntityTransactionalQu
public override runPostCommitCallbacksAsync(): Promise<void> {
throw new Error(
'Must not call runPostCommitCallbacksAsync on EntityNestedTransactionalQueryContext'
'Must not call runPostCommitCallbacksAsync on EntityNestedTransactionalQueryContext',
);
}
@@ -25,11 +25,11 @@ export default abstract class EntityQueryContextProvider {
* Vend a transaction runner for use in runInTransactionAsync.
*/
protected abstract createTransactionRunner<T>(
transactionConfig?: TransactionConfig
transactionConfig?: TransactionConfig,
): (transactionScope: (queryInterface: any) => Promise<T>) => Promise<T>;
protected abstract createNestedTransactionRunner<T>(
outerQueryInterface: any
outerQueryInterface: any,
): (transactionScope: (queryInterface: any) => Promise<T>) => Promise<T>;
/**
@@ -38,7 +38,7 @@ export default abstract class EntityQueryContextProvider {
*/
async runInTransactionAsync<T>(
transactionScope: (queryContext: EntityTransactionalQueryContext) => Promise<T>,
transactionConfig?: TransactionConfig
transactionConfig?: TransactionConfig,
): Promise<T> {
const [returnedValue, queryContext] = await this.createTransactionRunner<
[T, EntityTransactionalQueryContext]
@@ -60,7 +60,7 @@ export default abstract class EntityQueryContextProvider {
*/
async runInNestedTransactionAsync<T>(
outerQueryContext: EntityTransactionalQueryContext,
transactionScope: (innerQueryContext: EntityNestedTransactionalQueryContext) => Promise<T>
transactionScope: (innerQueryContext: EntityNestedTransactionalQueryContext) => Promise<T>,
): Promise<T> {
const [returnedValue, innerQueryContext] = await this.createNestedTransactionRunner<
[T, EntityNestedTransactionalQueryContext]
@@ -68,7 +68,7 @@ export default abstract class EntityQueryContextProvider {
const innerQueryContext = new EntityNestedTransactionalQueryContext(
innerQueryInterface,
outerQueryContext,
this
this,
);
const result = await transactionScope(innerQueryContext);
await innerQueryContext.runPreCommitCallbacksAsync();
@@ -20,8 +20,8 @@ export interface ISecondaryEntityCache<TFields, TLoadParams> {
loadManyThroughAsync(
loadParamsArray: readonly Readonly<TLoadParams>[],
fetcher: (
fetcherLoadParamsArray: readonly Readonly<TLoadParams>[]
) => Promise<ReadonlyMap<Readonly<TLoadParams>, Readonly<TFields> | null>>
fetcherLoadParamsArray: readonly Readonly<TLoadParams>[],
) => Promise<ReadonlyMap<Readonly<TLoadParams>, Readonly<TFields> | null>>,
): Promise<ReadonlyMap<Readonly<TLoadParams>, Readonly<TFields> | null>>;
/**
@@ -56,7 +56,7 @@ export default abstract class EntitySecondaryCacheLoader<
TEntity,
TSelectedFields
>,
TSelectedFields extends keyof TFields = keyof TFields
TSelectedFields extends keyof TFields = keyof TFields,
> {
constructor(
private readonly secondaryEntityCache: ISecondaryEntityCache<TFields, TLoadParams>,
@@ -67,7 +67,7 @@ export default abstract class EntitySecondaryCacheLoader<
TEntity,
TPrivacyPolicy,
TSelectedFields
>
>,
) {}
/**
@@ -76,17 +76,19 @@ export default abstract class EntitySecondaryCacheLoader<
* @param loadParamsArray - array of loadParams to load through the cache
*/
public async loadManyAsync(
loadParamsArray: readonly Readonly<TLoadParams>[]
loadParamsArray: readonly Readonly<TLoadParams>[],
): Promise<ReadonlyMap<Readonly<TLoadParams>, Result<TEntity> | null>> {
const loadParamsToFieldObjects = await this.secondaryEntityCache.loadManyThroughAsync(
loadParamsArray,
this.fetchObjectsFromDatabaseAsync.bind(this)
this.fetchObjectsFromDatabaseAsync.bind(this),
);
// convert value to and from array to reuse complex code
const entitiesMap = await this.entityLoader.constructAndAuthorizeEntitiesAsync(
mapMap(loadParamsToFieldObjects, (fieldObject) => (fieldObject ? [fieldObject] : []))
);
const entitiesMap = await this.entityLoader
.utils()
.constructAndAuthorizeEntitiesAsync(
mapMap(loadParamsToFieldObjects, (fieldObject) => (fieldObject ? [fieldObject] : [])),
);
return mapMap(entitiesMap, (fieldObjects) => fieldObjects[0] ?? null);
}
@@ -96,7 +98,7 @@ export default abstract class EntitySecondaryCacheLoader<
* @param loadParamsArray - array of load params objects to invalidate
*/
public async invalidateManyAsync(
loadParamsArray: readonly Readonly<TLoadParams>[]
loadParamsArray: readonly Readonly<TLoadParams>[],
): Promise<void> {
await this.secondaryEntityCache.invalidateManyAsync(loadParamsArray);
}
@@ -107,6 +109,6 @@ export default abstract class EntitySecondaryCacheLoader<
* @param loadParamsArray - array of load params objects to load
*/
protected abstract fetchObjectsFromDatabaseAsync(
loadParamsArray: readonly Readonly<TLoadParams>[]
loadParamsArray: readonly Readonly<TLoadParams>[],
): Promise<ReadonlyMap<Readonly<Readonly<TLoadParams>>, Readonly<TFields> | null>>;
}
@@ -13,16 +13,16 @@ export default class GenericEntityCacheAdapter<TFields> implements IEntityCacheA
public async loadManyAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
fieldValues: readonly NonNullable<TFields[N]>[],
): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult<TFields>>> {
const redisCacheKeyToFieldValueMapping = new Map(
fieldValues.map((fieldValue) => [
this.genericCacher.makeCacheKey(fieldName, fieldValue),
fieldValue,
])
]),
);
const cacheResults = await this.genericCacher.loadManyAsync(
Array.from(redisCacheKeyToFieldValueMapping.keys())
Array.from(redisCacheKeyToFieldValueMapping.keys()),
);
return mapKeys(cacheResults, (redisCacheKey) => {
@@ -30,7 +30,7 @@ export default class GenericEntityCacheAdapter<TFields> implements IEntityCacheA
invariant(
fieldValue !== undefined,
'Unspecified cache key %s returned from generic cacher',
redisCacheKey
redisCacheKey,
);
return fieldValue;
});
@@ -38,28 +38,28 @@ export default class GenericEntityCacheAdapter<TFields> implements IEntityCacheA
public async cacheManyAsync<N extends keyof TFields>(
fieldName: N,
objectMap: ReadonlyMap<NonNullable<TFields[N]>, Readonly<TFields>>
objectMap: ReadonlyMap<NonNullable<TFields[N]>, Readonly<TFields>>,
): Promise<void> {
await this.genericCacher.cacheManyAsync(
mapKeys(objectMap, (fieldValue) => this.genericCacher.makeCacheKey(fieldName, fieldValue))
mapKeys(objectMap, (fieldValue) => this.genericCacher.makeCacheKey(fieldName, fieldValue)),
);
}
public async cacheDBMissesAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
fieldValues: readonly NonNullable<TFields[N]>[],
): Promise<void> {
await this.genericCacher.cacheDBMissesAsync(
fieldValues.map((fieldValue) => this.genericCacher.makeCacheKey(fieldName, fieldValue))
fieldValues.map((fieldValue) => this.genericCacher.makeCacheKey(fieldName, fieldValue)),
);
}
public async invalidateManyAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
fieldValues: readonly NonNullable<TFields[N]>[],
): Promise<void> {
await this.genericCacher.invalidateManyAsync(
fieldValues.map((fieldValue) => this.genericCacher.makeCacheKey(fieldName, fieldValue))
fieldValues.map((fieldValue) => this.genericCacher.makeCacheKey(fieldName, fieldValue)),
);
}
}
@@ -15,14 +15,14 @@ export default abstract class GenericSecondaryEntityCache<TFields, TLoadParams>
{
constructor(
protected readonly cacher: IEntityGenericCacher<TFields>,
protected readonly constructCacheKey: (params: Readonly<TLoadParams>) => string
protected readonly constructCacheKey: (params: Readonly<TLoadParams>) => string,
) {}
public async loadManyThroughAsync(
loadParamsArray: readonly Readonly<TLoadParams>[],
fetcher: (
fetcherLoadParamsArray: readonly Readonly<TLoadParams>[]
) => Promise<ReadonlyMap<Readonly<TLoadParams>, Readonly<TFields> | null>>
fetcherLoadParamsArray: readonly Readonly<TLoadParams>[],
) => Promise<ReadonlyMap<Readonly<TLoadParams>, Readonly<TFields> | null>>,
): Promise<ReadonlyMap<Readonly<TLoadParams>, Readonly<TFields> | null>> {
const cacheKeys = loadParamsArray.map(this.constructCacheKey);
const cacheKeyToLoadParamsMap = zipToMap(cacheKeys, loadParamsArray);
@@ -31,14 +31,14 @@ export default abstract class GenericSecondaryEntityCache<TFields, TLoadParams>
invariant(
cacheLoadResults.size === loadParamsArray.length,
`${this.constructor.name} loadMany should return a result for each key`
`${this.constructor.name} loadMany should return a result for each key`,
);
const cacheKeysToFetch = Array.from(
filterMap(
cacheLoadResults,
(cacheLoadResult) => cacheLoadResult.status === CacheStatus.MISS
).keys()
(cacheLoadResult) => cacheLoadResult.status === CacheStatus.MISS,
).keys(),
);
// put cache hits in result map
+4 -4
View File
@@ -13,7 +13,7 @@ export default interface IEntityCacheAdapter<TFields> {
*/
loadManyAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
fieldValues: readonly NonNullable<TFields[N]>[],
): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult<TFields>>>;
/**
@@ -23,7 +23,7 @@ export default interface IEntityCacheAdapter<TFields> {
*/
cacheManyAsync<N extends keyof TFields>(
fieldName: N,
objectMap: ReadonlyMap<NonNullable<TFields[N]>, Readonly<TFields>>
objectMap: ReadonlyMap<NonNullable<TFields[N]>, Readonly<TFields>>,
): Promise<void>;
/**
@@ -34,7 +34,7 @@ export default interface IEntityCacheAdapter<TFields> {
*/
cacheDBMissesAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
fieldValues: readonly NonNullable<TFields[N]>[],
): Promise<void>;
/**
@@ -44,6 +44,6 @@ export default interface IEntityCacheAdapter<TFields> {
*/
invalidateManyAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
fieldValues: readonly NonNullable<TFields[N]>[],
): Promise<void>;
}
@@ -9,7 +9,7 @@ export default interface IEntityCacheAdapterProvider {
/**
* Vend a cache adapter for an entity configuration.
*/
getCacheAdapter<TFields>(
entityConfiguration: EntityConfiguration<TFields>
getCacheAdapter<TFields extends Record<string, any>>(
entityConfiguration: EntityConfiguration<TFields>,
): IEntityCacheAdapter<TFields>;
}
@@ -9,7 +9,7 @@ export default interface IEntityDatabaseAdapterProvider {
/**
* Vend a database adapter.
*/
getDatabaseAdapter<TFields>(
entityConfiguration: EntityConfiguration<TFields>
getDatabaseAdapter<TFields extends Record<string, any>>(
entityConfiguration: EntityConfiguration<TFields>,
): EntityDatabaseAdapter<TFields>;
}
Loaded 100 of 174 files, more files were not shown because too many files have changed in this diff. Show more