mirror of
https://github.com/expo/entity.git
synced 2026-11-15 03:48:08 -10:00
Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f17a33c428 | ||
|
|
93905be81c | ||
|
|
2edc7af7e2 | ||
|
|
6569486ecd | ||
|
|
778d9e304e | ||
|
|
05726d45fa | ||
|
|
7e2cea1697 | ||
|
|
7c3c9854a4 | ||
|
|
1e8ea64017 | ||
|
|
af495a9a85 | ||
|
|
60fc9a4c4b | ||
|
|
14bd959e53 | ||
|
|
91d46ba49f | ||
|
|
16e7c21b1c | ||
|
|
3ce0ac91f8 | ||
|
|
428cc6fc5c | ||
|
|
8490ad5a19 | ||
|
|
8a448b5c42 | ||
|
|
3fd8a92cc8 | ||
|
|
b5998063a6 | ||
|
|
8fc0849618 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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/
|
||||
|
||||
@@ -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.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,7 +4,7 @@ Entity is a privacy-aware data layer for defining, caching, and authorizing acce
|
||||
|
||||
[](https://github.com/expo/entity/actions?query=workflow%3Atests)
|
||||
[](https://expo.github.io/entity/)
|
||||
[](https://codecov.io/gh/expo/entity)
|
||||
[](https://codecov.io/gh/expo/entity)
|
||||
[](https://www.npmjs.com/package/@expo/entity)
|
||||
[](https://www.npmjs.com/package/@expo/entity)
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
codecov:
|
||||
branch: main
|
||||
ignore:
|
||||
- "packages/entity-example"
|
||||
+1
-1
@@ -4,5 +4,5 @@
|
||||
],
|
||||
"npmClient": "yarn",
|
||||
"useWorkspaces": true,
|
||||
"version": "0.35.0"
|
||||
"version": "0.36.0"
|
||||
}
|
||||
+16
-13
@@ -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",
|
||||
"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,17 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [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.36.0",
|
||||
"description": "Cross-request local memory cache adapter for @expo/entity",
|
||||
"files": [
|
||||
"build",
|
||||
@@ -33,6 +33,6 @@
|
||||
"lru-cache": "^6.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/entity": "^0.35.0"
|
||||
"@expo/entity": "^0.36.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),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
+14
-12
@@ -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([
|
||||
|
||||
+11
-5
@@ -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', []);
|
||||
});
|
||||
|
||||
+4
-4
@@ -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,17 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [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.36.0",
|
||||
"description": "Redis cache adapter for @expo/entity",
|
||||
"files": [
|
||||
"build",
|
||||
@@ -31,7 +31,7 @@
|
||||
"ioredis": ">=5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/entity": "^0.35.0",
|
||||
"@expo/entity": "^0.36.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));
|
||||
}
|
||||
|
||||
+16
-14
@@ -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();
|
||||
});
|
||||
|
||||
+20
-18
@@ -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()
|
||||
|
||||
+6
-6
@@ -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([]);
|
||||
});
|
||||
|
||||
+2
-2
@@ -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,17 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [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.36.0",
|
||||
"description": "Knex database adapter for @expo/entity",
|
||||
"files": [
|
||||
"build",
|
||||
@@ -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.36.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:
|
||||
|
||||
+129
-106
@@ -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);
|
||||
|
||||
+8
-8
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+10
-4
@@ -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}`);
|
||||
|
||||
+2
-2
@@ -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}`);
|
||||
|
||||
+2
-2
@@ -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(),
|
||||
},
|
||||
],
|
||||
])
|
||||
]),
|
||||
);
|
||||
};
|
||||
@@ -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.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)
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@expo/entity-example",
|
||||
"private": true,
|
||||
"version": "0.35.0",
|
||||
"version": "0.36.0",
|
||||
"description": "An example integration of the @expo/entity framework",
|
||||
"scripts": {
|
||||
"tsc": "tsc",
|
||||
@@ -25,7 +25,7 @@
|
||||
"dependencies": {
|
||||
"@apollo/server": "^4.7.3",
|
||||
"@as-integrations/koa": "^1.1.1",
|
||||
"@expo/entity": "^0.35.0",
|
||||
"@expo/entity": "^0.36.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({}));
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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,17 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [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.36.0",
|
||||
"description": "Full redis and knex integration tests for the entity framework",
|
||||
"scripts": {
|
||||
"tsc": "tsc",
|
||||
@@ -27,8 +27,8 @@
|
||||
"@expo/entity-database-adapter-knex": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/entity": "^0.35.0",
|
||||
"@expo/entity-cache-adapter-redis": "^0.35.0",
|
||||
"@expo/entity-database-adapter-knex": "^0.35.0"
|
||||
"@expo/entity": "^0.36.0",
|
||||
"@expo/entity-cache-adapter-redis": "^0.36.0",
|
||||
"@expo/entity-database-adapter-knex": "^0.36.0"
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -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)
|
||||
|
||||
+6
-6
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+149
@@ -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());
|
||||
});
|
||||
});
|
||||
+7
-7
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
+2
-2
@@ -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,14 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [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.36.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.36.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,17 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [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.36.0",
|
||||
"description": "Local memory secondary cache for @expo/entity",
|
||||
"files": [
|
||||
"build",
|
||||
@@ -31,8 +31,8 @@
|
||||
"@expo/entity-cache-adapter-local-memory": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/entity": "^0.35.0",
|
||||
"@expo/entity-cache-adapter-local-memory": "^0.35.0",
|
||||
"@expo/entity": "^0.36.0",
|
||||
"@expo/entity-cache-adapter-local-memory": "^0.36.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),
|
||||
);
|
||||
}
|
||||
}
|
||||
+11
-11
@@ -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,17 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [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.36.0",
|
||||
"description": "Redis secondary cache for @expo/entity",
|
||||
"files": [
|
||||
"build",
|
||||
@@ -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.36.0",
|
||||
"@expo/entity-cache-adapter-redis": "^0.36.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);
|
||||
}
|
||||
|
||||
+11
-11
@@ -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 };
|
||||
|
||||
+2
-2
@@ -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),
|
||||
},
|
||||
],
|
||||
])
|
||||
]),
|
||||
);
|
||||
};
|
||||
@@ -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.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)
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/entity",
|
||||
"version": "0.35.0",
|
||||
"version": "0.36.0",
|
||||
"description": "A privacy-first data model",
|
||||
"files": [
|
||||
"build",
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -19,7 +19,7 @@ export default abstract class ReadonlyEntity<
|
||||
TFields extends object,
|
||||
TID extends NonNullable<TFields[TSelectedFields]>,
|
||||
TViewerContext extends ViewerContext,
|
||||
TSelectedFields extends keyof TFields = keyof TFields
|
||||
TSelectedFields extends keyof TFields = keyof TFields,
|
||||
> {
|
||||
private readonly viewerContext: TViewerContext;
|
||||
private readonly id: TID;
|
||||
@@ -100,7 +100,7 @@ export default abstract class ReadonlyEntity<
|
||||
* @returns the value of the field or undefined if not loaded with that field
|
||||
*/
|
||||
getField<K extends keyof Pick<TFields, TSelectedFields>>(
|
||||
fieldName: K
|
||||
fieldName: K,
|
||||
): Pick<TFields, TSelectedFields>[K] {
|
||||
return this.selectedFields[fieldName];
|
||||
}
|
||||
@@ -137,7 +137,7 @@ export default abstract class ReadonlyEntity<
|
||||
TMEntity,
|
||||
TMSelectedFields
|
||||
>,
|
||||
TMSelectedFields extends keyof TMFields = keyof TMFields
|
||||
TMSelectedFields extends keyof TMFields = keyof TMFields,
|
||||
>(
|
||||
this: IEntityClass<
|
||||
TMFields,
|
||||
@@ -151,11 +151,11 @@ export default abstract class ReadonlyEntity<
|
||||
queryContext: EntityQueryContext = viewerContext
|
||||
.getViewerScopedEntityCompanionForClass(this)
|
||||
.getQueryContextProvider()
|
||||
.getQueryContext()
|
||||
.getQueryContext(),
|
||||
): EntityLoader<TMFields, TMID, TMViewerContext, TMEntity, TMPrivacyPolicy, TMSelectedFields> {
|
||||
return viewerContext
|
||||
.getViewerScopedEntityCompanionForClass(this)
|
||||
.getLoaderFactory()
|
||||
.forLoad(queryContext, { cascadingDeleteCause: null });
|
||||
.forLoad(queryContext, { previousValue: null, cascadingDeleteCause: null });
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ export default class ViewerContext {
|
||||
constructor(public readonly entityCompanionProvider: EntityCompanionProvider) {
|
||||
this.viewerScopedEntityCompanionProvider = new ViewerScopedEntityCompanionProvider(
|
||||
entityCompanionProvider,
|
||||
this
|
||||
this,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ export default class ViewerContext {
|
||||
TMEntity,
|
||||
TMSelectedFields
|
||||
>,
|
||||
TMSelectedFields extends keyof TMFields
|
||||
TMSelectedFields extends keyof TMFields,
|
||||
>(
|
||||
entityClass: IEntityClass<
|
||||
TMFields,
|
||||
@@ -51,7 +51,7 @@ export default class ViewerContext {
|
||||
TMEntity,
|
||||
TMPrivacyPolicy,
|
||||
TMSelectedFields
|
||||
>
|
||||
>,
|
||||
): ViewerScopedEntityCompanion<
|
||||
TMFields,
|
||||
TMID,
|
||||
@@ -68,7 +68,7 @@ export default class ViewerContext {
|
||||
* @param databaseAdaptorFlavor - database adaptor flavor
|
||||
*/
|
||||
getQueryContextForDatabaseAdaptorFlavor(
|
||||
databaseAdaptorFlavor: DatabaseAdapterFlavor
|
||||
databaseAdaptorFlavor: DatabaseAdapterFlavor,
|
||||
): EntityQueryContext {
|
||||
return this.entityCompanionProvider
|
||||
.getQueryContextProviderForDatabaseAdaptorFlavor(databaseAdaptorFlavor)
|
||||
@@ -84,7 +84,7 @@ export default class ViewerContext {
|
||||
async runInTransactionForDatabaseAdaptorFlavorAsync<TResult>(
|
||||
databaseAdaptorFlavor: DatabaseAdapterFlavor,
|
||||
transactionScope: (queryContext: EntityTransactionalQueryContext) => Promise<TResult>,
|
||||
transactionConfig?: TransactionConfig
|
||||
transactionConfig?: TransactionConfig,
|
||||
): Promise<TResult> {
|
||||
return await this.entityCompanionProvider
|
||||
.getQueryContextProviderForDatabaseAdaptorFlavor(databaseAdaptorFlavor)
|
||||
|
||||
@@ -23,7 +23,7 @@ export default class ViewerScopedEntityCompanion<
|
||||
TEntity,
|
||||
TSelectedFields
|
||||
>,
|
||||
TSelectedFields extends keyof TFields
|
||||
TSelectedFields extends keyof TFields,
|
||||
> {
|
||||
constructor(
|
||||
public readonly entityCompanion: EntityCompanion<
|
||||
@@ -34,7 +34,7 @@ export default class ViewerScopedEntityCompanion<
|
||||
TPrivacyPolicy,
|
||||
TSelectedFields
|
||||
>,
|
||||
private readonly viewerContext: TViewerContext
|
||||
private readonly viewerContext: TViewerContext,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -50,7 +50,7 @@ export default class ViewerScopedEntityCompanion<
|
||||
> {
|
||||
return new ViewerScopedEntityLoaderFactory(
|
||||
this.entityCompanion.getLoaderFactory(),
|
||||
this.viewerContext
|
||||
this.viewerContext,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ export default class ViewerScopedEntityCompanion<
|
||||
> {
|
||||
return new ViewerScopedEntityMutatorFactory(
|
||||
this.entityCompanion.getMutatorFactory(),
|
||||
this.viewerContext
|
||||
this.viewerContext,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Loaded 100 of 171 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user