mirror of
https://github.com/expo/entity.git
synced 2026-11-15 03:48:08 -10:00
Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58797a50b6 | ||
|
|
d09a735264 | ||
|
|
dc3aafc6ab | ||
|
|
87532525d0 | ||
|
|
33771295c5 | ||
|
|
1bdfbd6b56 | ||
|
|
0fa6f554a2 | ||
|
|
8b0b31fdde | ||
|
|
cc6f3dc1e6 | ||
|
|
9c43a56a39 | ||
|
|
ef7b452c9f | ||
|
|
659e08386f | ||
|
|
aad94aca47 | ||
|
|
d2a4405fa1 | ||
|
|
503a2fb6a8 | ||
|
|
3a00a43036 | ||
|
|
05b14843a6 | ||
|
|
6f8020fbf8 | ||
|
|
4b18010d42 | ||
|
|
2069a0d9fd | ||
|
|
83fe9cdea5 | ||
|
|
1934216b70 | ||
|
|
92f5529eba | ||
|
|
67732d6847 | ||
|
|
bb0cd51eb5 |
No files matched your search
+66
-1
@@ -3,6 +3,71 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [0.35.0](https://github.com/expo/entity/compare/v0.34.0...v0.35.0) (2024-04-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Revert: require explicit query context specification ([#219](https://github.com/expo/entity/issues/219)) ([1bdfbd6](https://github.com/expo/entity/commit/1bdfbd6b562d1200e4029df0533b5adb2d917831))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.34.0](https://github.com/expo/entity/compare/v0.33.0...v0.34.0) (2024-04-11)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Allow UUIDv6/7/8 in UUIDField validation ([#221](https://github.com/expo/entity/issues/221)) ([cc6f3dc](https://github.com/expo/entity/commit/cc6f3dc1e677382eec220723c37b5b883d980372))
|
||||
* require explicit query context specification ([#219](https://github.com/expo/entity/issues/219)) ([8b0b31f](https://github.com/expo/entity/commit/8b0b31fdde5bd565aa527719003ef283a45f55cc))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.33.0](https://github.com/expo/entity/compare/v0.32.0...v0.33.0) (2023-10-06)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add loadManyByIDsNullableAsync loader method ([#214](https://github.com/expo/entity/issues/214)) ([aad94ac](https://github.com/expo/entity/commit/aad94aca479ac07cc7f76b5e1a122ff85a2d1542))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.32.0](https://github.com/expo/entity/compare/v0.31.1...v0.32.0) (2023-06-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* remove unused field from redis configuration ([#213](https://github.com/expo/entity/issues/213)) ([503a2fb](https://github.com/expo/entity/commit/503a2fb6a8d57f6a09f6da99daf5ab92fc3e7945))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add ability to specify knex transaction config ([#207](https://github.com/expo/entity/issues/207)) ([2069a0d](https://github.com/expo/entity/commit/2069a0d9fd1d8f7d090a71a90a758b2429ea5dd3))
|
||||
* entity companion definition static thunk ([#210](https://github.com/expo/entity/issues/210)) ([4b18010](https://github.com/expo/entity/commit/4b18010d42be50ef329f428b08330e21bf676586))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.31.1](https://github.com/expo/entity/compare/v0.31.0...v0.31.1) (2023-03-06)
|
||||
|
||||
### Features
|
||||
* add loadFirstByFieldEqualityConjunction, a convenience method ([#206](https://github.com/expo/entity/pull/206)) ([1934216](https://github.com/expo/entity/commit/1934216b70572cf2b9693e0f14939f49b90427dd))
|
||||
|
||||
# [0.31.0](https://github.com/expo/entity/compare/v0.30.0...v0.31.0) (2023-01-19)
|
||||
|
||||
**Note:** Version bump only for package root
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.30.0](https://github.com/expo/entity/compare/v0.29.0...v0.30.0) (2022-12-23)
|
||||
|
||||
**Note:** Version bump only for package root
|
||||
@@ -128,7 +193,7 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline
|
||||
|
||||
### Chores
|
||||
|
||||
* Refactor cachers to use an abstract class and interface ([#157](https://github.com/expo/entity/pull/157))
|
||||
* Refactor cachers to use an abstract class and interface ([#157](https://github.com/expo/entity/pull/157))
|
||||
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -4,5 +4,5 @@
|
||||
],
|
||||
"npmClient": "yarn",
|
||||
"useWorkspaces": true,
|
||||
"version": "0.30.0"
|
||||
"version": "0.35.0"
|
||||
}
|
||||
+12
-14
@@ -20,28 +20,26 @@
|
||||
"@expo/entity": "file:packages/entity",
|
||||
"@expo/results": "^1.0.0",
|
||||
"@types/invariant": "^2.2.33",
|
||||
"@types/jest": "^26.0.10",
|
||||
"@types/jest": "^29.5.2",
|
||||
"@types/jsbn": "^1.2.29",
|
||||
"@types/lru-cache": "^5.1.1",
|
||||
"@types/node": "^14.6.0",
|
||||
"@types/node": "^16.18.34",
|
||||
"@types/uuid": "^8.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "^4.14.0",
|
||||
"@typescript-eslint/parser": "^4.14.0",
|
||||
"barrelsby": "^2.2.0",
|
||||
"eslint": "^7.7.0",
|
||||
"eslint-config-universe": "^7.0.0",
|
||||
"eslint-plugin-tsdoc": "^0.2.16",
|
||||
"ioredis": "^5.0.4",
|
||||
"jest": "^26.6.3",
|
||||
"eslint": "^8.14.0",
|
||||
"eslint-config-universe": "^11.2.0",
|
||||
"eslint-plugin-tsdoc": "^0.2.17",
|
||||
"ioredis": "^5.2.5",
|
||||
"jest": "^29.1.2",
|
||||
"lerna": "^5.4.0",
|
||||
"lru-cache": "^6.0.0",
|
||||
"nullthrows": "^1.1.1",
|
||||
"pg": "^8.6.0",
|
||||
"prettier": "^2.3.2",
|
||||
"ts-jest": "~26.4.4",
|
||||
"pg": "8.10.0",
|
||||
"prettier": "^2.8.8",
|
||||
"ts-jest": "^29.1.0",
|
||||
"ts-mockito": "^2.6.1",
|
||||
"typedoc": "^0.23.10",
|
||||
"typescript": "^4.6.3",
|
||||
"typedoc": "^0.24.8",
|
||||
"typescript": "^5.1.3",
|
||||
"uuid": "^8.3.0"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -3,6 +3,55 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [0.35.0](https://github.com/expo/entity/compare/v0.34.0...v0.35.0) (2024-04-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Revert: require explicit query context specification ([#219](https://github.com/expo/entity/issues/219)) ([1bdfbd6](https://github.com/expo/entity/commit/1bdfbd6b562d1200e4029df0533b5adb2d917831))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.34.0](https://github.com/expo/entity/compare/v0.33.0...v0.34.0) (2024-04-11)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* require explicit query context specification ([#219](https://github.com/expo/entity/issues/219)) ([8b0b31f](https://github.com/expo/entity/commit/8b0b31fdde5bd565aa527719003ef283a45f55cc))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.33.0](https://github.com/expo/entity/compare/v0.32.0...v0.33.0) (2023-10-06)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.32.0](https://github.com/expo/entity/compare/v0.31.1...v0.32.0) (2023-06-10)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* entity companion definition static thunk ([#210](https://github.com/expo/entity/issues/210)) ([4b18010](https://github.com/expo/entity/commit/4b18010d42be50ef329f428b08330e21bf676586))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.31.0](https://github.com/expo/entity/compare/v0.30.0...v0.31.0) (2023-01-19)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.30.0](https://github.com/expo/entity/compare/v0.29.0...v0.30.0) (2022-12-23)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/entity-cache-adapter-local-memory",
|
||||
"version": "0.30.0",
|
||||
"version": "0.35.0",
|
||||
"description": "Cross-request local memory cache adapter for @expo/entity",
|
||||
"files": [
|
||||
"build",
|
||||
@@ -19,7 +19,7 @@
|
||||
"barrelsby": "barrelsby --directory src --location top --exclude tests__ --singleQuotes --exportDefault --delete"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=16"
|
||||
},
|
||||
"keywords": [
|
||||
"entity"
|
||||
@@ -33,6 +33,6 @@
|
||||
"lru-cache": "^6.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/entity": "^0.30.0"
|
||||
"@expo/entity": "^0.35.0"
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,10 @@
|
||||
import { CacheLoadResult, CacheStatus, IEntityGenericCacher } from '@expo/entity';
|
||||
import {
|
||||
CacheLoadResult,
|
||||
CacheStatus,
|
||||
EntityConfiguration,
|
||||
IEntityGenericCacher,
|
||||
} from '@expo/entity';
|
||||
import invariant from 'invariant';
|
||||
import LRUCache from 'lru-cache';
|
||||
|
||||
// Sentinel value we store in local memory to negatively cache a database miss.
|
||||
@@ -8,7 +14,10 @@ type LocalMemoryCacheValue<TFields> = Readonly<TFields> | typeof DOES_NOT_EXIST_
|
||||
export type LocalMemoryCache<TFields> = LRUCache<string, LocalMemoryCacheValue<TFields>>;
|
||||
|
||||
export default class GenericLocalMemoryCacher<TFields> implements IEntityGenericCacher<TFields> {
|
||||
constructor(private readonly localMemoryCache: LocalMemoryCache<TFields>) {}
|
||||
constructor(
|
||||
private readonly entityConfiguration: EntityConfiguration<TFields>,
|
||||
private readonly localMemoryCache: LocalMemoryCache<TFields>
|
||||
) {}
|
||||
|
||||
static createLRUCache<TFields>(
|
||||
options: { maxSize?: number; ttlSeconds?: number } = {}
|
||||
@@ -72,7 +81,19 @@ export default class GenericLocalMemoryCacher<TFields> implements IEntityGeneric
|
||||
}
|
||||
}
|
||||
|
||||
public makeCacheKey(parts: string[]): string {
|
||||
public makeCacheKey<N extends keyof TFields>(
|
||||
fieldName: N,
|
||||
fieldValue: NonNullable<TFields[N]>
|
||||
): string {
|
||||
const columnName = this.entityConfiguration.entityToDBFieldsKeyMapping.get(fieldName);
|
||||
invariant(columnName, `database field mapping missing for ${String(fieldName)}`);
|
||||
const parts = [
|
||||
this.entityConfiguration.tableName,
|
||||
`${this.entityConfiguration.cacheKeyVersion}`,
|
||||
columnName,
|
||||
String(fieldValue),
|
||||
];
|
||||
|
||||
const delimiter = ':';
|
||||
const escapedParts = parts.map((part) =>
|
||||
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`)
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import { EntityCacheAdapter, CacheLoadResult, EntityConfiguration, mapKeys } from '@expo/entity';
|
||||
import invariant from 'invariant';
|
||||
|
||||
import GenericLocalMemoryCacher, { LocalMemoryCache } from './GenericLocalMemoryCacher';
|
||||
|
||||
export default class LocalMemoryCacheAdapter<TFields> extends EntityCacheAdapter<TFields> {
|
||||
private readonly genericLocalMemoryCacher: GenericLocalMemoryCacher<TFields>;
|
||||
|
||||
constructor(
|
||||
entityConfiguration: EntityConfiguration<TFields>,
|
||||
localMemoryCache: LocalMemoryCache<TFields>
|
||||
) {
|
||||
super(entityConfiguration);
|
||||
this.genericLocalMemoryCacher = new GenericLocalMemoryCacher(localMemoryCache);
|
||||
}
|
||||
|
||||
public async loadManyAsync<N extends keyof TFields>(
|
||||
fieldName: N,
|
||||
fieldValues: readonly NonNullable<TFields[N]>[]
|
||||
): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult<TFields>>> {
|
||||
const localMemoryCacheKeyToFieldValueMapping = new Map(
|
||||
fieldValues.map((fieldValue) => [this.makeCacheKey(fieldName, fieldValue), fieldValue])
|
||||
);
|
||||
const cacheResults = await this.genericLocalMemoryCacher.loadManyAsync(
|
||||
Array.from(localMemoryCacheKeyToFieldValueMapping.keys())
|
||||
);
|
||||
|
||||
return mapKeys(cacheResults, (cacheKey) => {
|
||||
const fieldValue = localMemoryCacheKeyToFieldValueMapping.get(cacheKey);
|
||||
invariant(
|
||||
fieldValue !== undefined,
|
||||
'Unspecified cache key %s returned from generic local memory cacher',
|
||||
cacheKey
|
||||
);
|
||||
return fieldValue;
|
||||
});
|
||||
}
|
||||
|
||||
public async cacheManyAsync<N extends keyof TFields>(
|
||||
fieldName: N,
|
||||
objectMap: ReadonlyMap<NonNullable<TFields[N]>, Readonly<TFields>>
|
||||
): Promise<void> {
|
||||
await this.genericLocalMemoryCacher.cacheManyAsync(
|
||||
mapKeys(objectMap, (fieldValue) => this.makeCacheKey(fieldName, fieldValue))
|
||||
);
|
||||
}
|
||||
|
||||
public async cacheDBMissesAsync<N extends keyof TFields>(
|
||||
fieldName: N,
|
||||
fieldValues: readonly NonNullable<TFields[N]>[]
|
||||
): Promise<void> {
|
||||
await this.genericLocalMemoryCacher.cacheDBMissesAsync(
|
||||
fieldValues.map((fieldValue) => this.makeCacheKey(fieldName, fieldValue))
|
||||
);
|
||||
}
|
||||
|
||||
public async invalidateManyAsync<N extends keyof TFields>(
|
||||
fieldName: N,
|
||||
fieldValues: readonly NonNullable<TFields[N]>[]
|
||||
): Promise<void> {
|
||||
await this.genericLocalMemoryCacher.invalidateManyAsync(
|
||||
fieldValues.map((fieldValue) => this.makeCacheKey(fieldName, fieldValue))
|
||||
);
|
||||
}
|
||||
|
||||
private makeCacheKey<N extends keyof TFields>(
|
||||
fieldName: N,
|
||||
fieldValue: NonNullable<TFields[N]>
|
||||
): string {
|
||||
const columnName = this.entityConfiguration.entityToDBFieldsKeyMapping.get(fieldName);
|
||||
invariant(columnName, `database field mapping missing for ${String(fieldName)}`);
|
||||
const parts = [
|
||||
this.entityConfiguration.tableName,
|
||||
`${this.entityConfiguration.cacheKeyVersion}`,
|
||||
columnName,
|
||||
String(fieldValue),
|
||||
];
|
||||
return this.genericLocalMemoryCacher.makeCacheKey(parts);
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,31 @@
|
||||
import {
|
||||
computeIfAbsent,
|
||||
EntityCacheAdapter,
|
||||
IEntityCacheAdapter,
|
||||
EntityConfiguration,
|
||||
GenericEntityCacheAdapter,
|
||||
IEntityCacheAdapterProvider,
|
||||
} from '@expo/entity';
|
||||
|
||||
import GenericLocalMemoryCacher, { LocalMemoryCache } from './GenericLocalMemoryCacher';
|
||||
import LocalMemoryCacheAdapter from './LocalMemoryCacheAdapter';
|
||||
|
||||
/**
|
||||
* Vends local memory cache adapters. An instance of this class may be shared across requests to
|
||||
* share the local memory cache.
|
||||
*/
|
||||
export default class LocalMemoryCacheAdapterProvider implements IEntityCacheAdapterProvider {
|
||||
// local memory cache adapters should be shared/reused across requests
|
||||
static localMemoryCacheAdapterMap = new Map<string, LocalMemoryCacheAdapter<any>>();
|
||||
|
||||
static getNoOpProvider(): IEntityCacheAdapterProvider {
|
||||
/**
|
||||
* @returns a no-op local memory cache adapter provider, or one that doesn't cache locally.
|
||||
*/
|
||||
static createNoOpProvider(): IEntityCacheAdapterProvider {
|
||||
return new LocalMemoryCacheAdapterProvider(<TFields>() =>
|
||||
GenericLocalMemoryCacher.createNoOpCache<TFields>()
|
||||
);
|
||||
}
|
||||
|
||||
static getProvider(
|
||||
/**
|
||||
* @returns a local memory cache adapter provider configured with the supplied options.
|
||||
*/
|
||||
static createProviderWithOptions(
|
||||
options: { maxSize?: number; ttlSeconds?: number } = {}
|
||||
): IEntityCacheAdapterProvider {
|
||||
return new LocalMemoryCacheAdapterProvider(<TFields>() =>
|
||||
@@ -26,20 +33,20 @@ export default class LocalMemoryCacheAdapterProvider implements IEntityCacheAdap
|
||||
);
|
||||
}
|
||||
|
||||
private localMemoryCacheAdapterMap = new Map<string, GenericEntityCacheAdapter<any>>();
|
||||
|
||||
private constructor(
|
||||
private readonly localMemoryCacheCreator: <TFields>() => LocalMemoryCache<TFields>
|
||||
) {}
|
||||
|
||||
public getCacheAdapter<TFields>(
|
||||
entityConfiguration: EntityConfiguration<TFields>
|
||||
): EntityCacheAdapter<TFields> {
|
||||
return computeIfAbsent(
|
||||
LocalMemoryCacheAdapterProvider.localMemoryCacheAdapterMap,
|
||||
entityConfiguration.tableName,
|
||||
() => {
|
||||
const localMemoryCache = this.localMemoryCacheCreator<TFields>();
|
||||
return new LocalMemoryCacheAdapter(entityConfiguration, localMemoryCache);
|
||||
}
|
||||
);
|
||||
): IEntityCacheAdapter<TFields> {
|
||||
return computeIfAbsent(this.localMemoryCacheAdapterMap, entityConfiguration.tableName, () => {
|
||||
const localMemoryCache = this.localMemoryCacheCreator<TFields>();
|
||||
return new GenericEntityCacheAdapter(
|
||||
new GenericLocalMemoryCacher(entityConfiguration, localMemoryCache)
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
+46
-72
@@ -1,29 +1,28 @@
|
||||
import { CacheStatus, ViewerContext } from '@expo/entity';
|
||||
import {
|
||||
CacheAdapterFlavor,
|
||||
CacheAdapterFlavorDefinition,
|
||||
CacheStatus,
|
||||
ViewerContext,
|
||||
} from '@expo/entity';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import GenericLocalMemoryCacher from '../GenericLocalMemoryCacher';
|
||||
import LocalMemoryCacheAdapter from '../LocalMemoryCacheAdapter';
|
||||
import LocalMemoryCacheAdapterProvider from '../LocalMemoryCacheAdapterProvider';
|
||||
import LocalMemoryTestEntity from '../testfixtures/LocalMemoryTestEntity';
|
||||
import {
|
||||
createLocalMemoryTestEntityCompanionProvider,
|
||||
createNoopLocalMemoryIntegrationTestEntityCompanionProvider,
|
||||
createNoOpLocalMemoryIntegrationTestEntityCompanionProvider,
|
||||
} from '../testfixtures/createLocalMemoryTestEntityCompanionProvider';
|
||||
|
||||
class TestViewerContext extends ViewerContext {}
|
||||
|
||||
describe(LocalMemoryCacheAdapter, () => {
|
||||
beforeEach(async () => {
|
||||
LocalMemoryCacheAdapterProvider.localMemoryCacheAdapterMap.clear();
|
||||
});
|
||||
|
||||
describe(GenericLocalMemoryCacher, () => {
|
||||
it('has correct caching behavior', async () => {
|
||||
const viewerContext = new TestViewerContext(createLocalMemoryTestEntityCompanionProvider());
|
||||
const cacheAdapter = viewerContext.entityCompanionProvider.getCompanionForEntity(
|
||||
LocalMemoryTestEntity,
|
||||
LocalMemoryTestEntity.getCompanionDefinition()
|
||||
)['tableDataCoordinator']['cacheAdapter'];
|
||||
const cacheKeyMaker = cacheAdapter['makeCacheKey'].bind(cacheAdapter);
|
||||
const entityCompanionProvider = createLocalMemoryTestEntityCompanionProvider();
|
||||
const viewerContext = new ViewerContext(entityCompanionProvider);
|
||||
const genericCacher =
|
||||
viewerContext.entityCompanionProvider.getCompanionForEntity(LocalMemoryTestEntity)[
|
||||
'tableDataCoordinator'
|
||||
]['cacheAdapter']['genericCacher'];
|
||||
const cacheKeyMaker = genericCacher['makeCacheKey'].bind(genericCacher);
|
||||
|
||||
const date = new Date();
|
||||
const entity1Created = await LocalMemoryTestEntity.creator(viewerContext)
|
||||
@@ -36,10 +35,18 @@ describe(LocalMemoryCacheAdapter, () => {
|
||||
.enforcing()
|
||||
.loadByIDAsync(entity1Created.getID());
|
||||
|
||||
const entitySpecificGenericCacher =
|
||||
LocalMemoryCacheAdapterProvider.localMemoryCacheAdapterMap.get(
|
||||
LocalMemoryTestEntity.getCompanionDefinition().entityConfiguration.tableName
|
||||
)!['genericLocalMemoryCacher'];
|
||||
const localMemoryCacheAdapterProvider = (
|
||||
entityCompanionProvider['cacheAdapterFlavors'] as ReadonlyMap<
|
||||
CacheAdapterFlavor,
|
||||
CacheAdapterFlavorDefinition
|
||||
>
|
||||
).get('local-memory')!.cacheAdapterProvider as LocalMemoryCacheAdapterProvider;
|
||||
const entitySpecificGenericCacher = localMemoryCacheAdapterProvider[
|
||||
'localMemoryCacheAdapterMap'
|
||||
].get(
|
||||
viewerContext.entityCompanionProvider.getCompanionForEntity(LocalMemoryTestEntity)
|
||||
.entityCompanionDefinition.entityConfiguration.tableName
|
||||
)!['genericCacher'];
|
||||
const cachedResult = await entitySpecificGenericCacher.loadManyAsync([
|
||||
cacheKeyMaker('id', entity1.getID()),
|
||||
]);
|
||||
@@ -83,55 +90,14 @@ describe(LocalMemoryCacheAdapter, () => {
|
||||
expect(cachedValueMiss).toMatchObject({ status: CacheStatus.MISS });
|
||||
});
|
||||
|
||||
it('shares the cache between different requests', async () => {
|
||||
const genericLocalMemoryCacherLoadManySpy = jest.spyOn(
|
||||
GenericLocalMemoryCacher.prototype as unknown as any,
|
||||
'loadManyAsync'
|
||||
);
|
||||
const viewerContext = new TestViewerContext(createLocalMemoryTestEntityCompanionProvider());
|
||||
|
||||
const date = new Date();
|
||||
const entity1Created = await LocalMemoryTestEntity.creator(viewerContext)
|
||||
.setField('name', 'blah')
|
||||
.setField('dateField', date)
|
||||
.enforceCreateAsync();
|
||||
|
||||
// loading an entity should put it in cache
|
||||
await LocalMemoryTestEntity.loader(viewerContext)
|
||||
.enforcing()
|
||||
.loadByIDAsync(entity1Created.getID());
|
||||
|
||||
// load entity with a different request
|
||||
const viewerContext2 = new TestViewerContext(createLocalMemoryTestEntityCompanionProvider());
|
||||
const entity1WithVc2 = await LocalMemoryTestEntity.loader(viewerContext2)
|
||||
.enforcing()
|
||||
.loadByIDAsync(entity1Created.getID());
|
||||
|
||||
const cacheAdapter = viewerContext.entityCompanionProvider.getCompanionForEntity(
|
||||
LocalMemoryTestEntity,
|
||||
LocalMemoryTestEntity.getCompanionDefinition()
|
||||
)['tableDataCoordinator']['cacheAdapter'];
|
||||
const cacheKeyMaker = cacheAdapter['makeCacheKey'].bind(cacheAdapter);
|
||||
expect(entity1WithVc2.getAllFields()).toMatchObject({
|
||||
id: entity1WithVc2.getID(),
|
||||
name: 'blah',
|
||||
dateField: date,
|
||||
});
|
||||
expect(genericLocalMemoryCacherLoadManySpy).toBeCalledWith([
|
||||
cacheKeyMaker('id', entity1WithVc2.getID()),
|
||||
]);
|
||||
expect(genericLocalMemoryCacherLoadManySpy).toBeCalledTimes(2);
|
||||
});
|
||||
|
||||
it('respects the parameters of a noop cache', async () => {
|
||||
const viewerContext = new TestViewerContext(
|
||||
createNoopLocalMemoryIntegrationTestEntityCompanionProvider()
|
||||
);
|
||||
const cacheAdapter = viewerContext.entityCompanionProvider.getCompanionForEntity(
|
||||
LocalMemoryTestEntity,
|
||||
LocalMemoryTestEntity.getCompanionDefinition()
|
||||
)['tableDataCoordinator']['cacheAdapter'];
|
||||
const cacheKeyMaker = cacheAdapter['makeCacheKey'].bind(cacheAdapter);
|
||||
const entityCompanionProvider = createNoOpLocalMemoryIntegrationTestEntityCompanionProvider();
|
||||
const viewerContext = new ViewerContext(entityCompanionProvider);
|
||||
const genericCacher =
|
||||
viewerContext.entityCompanionProvider.getCompanionForEntity(LocalMemoryTestEntity)[
|
||||
'tableDataCoordinator'
|
||||
]['cacheAdapter']['genericCacher'];
|
||||
const cacheKeyMaker = genericCacher['makeCacheKey'].bind(genericCacher);
|
||||
|
||||
const date = new Date();
|
||||
const entity1Created = await LocalMemoryTestEntity.creator(viewerContext)
|
||||
@@ -144,10 +110,18 @@ describe(LocalMemoryCacheAdapter, () => {
|
||||
.enforcing()
|
||||
.loadByIDAsync(entity1Created.getID());
|
||||
|
||||
const entitySpecificGenericCacher =
|
||||
LocalMemoryCacheAdapterProvider.localMemoryCacheAdapterMap.get(
|
||||
LocalMemoryTestEntity.getCompanionDefinition().entityConfiguration.tableName
|
||||
)!['genericLocalMemoryCacher'];
|
||||
const localMemoryCacheAdapterProvider = (
|
||||
entityCompanionProvider['cacheAdapterFlavors'] as ReadonlyMap<
|
||||
CacheAdapterFlavor,
|
||||
CacheAdapterFlavorDefinition
|
||||
>
|
||||
).get('local-memory')!.cacheAdapterProvider as LocalMemoryCacheAdapterProvider;
|
||||
const entitySpecificGenericCacher = localMemoryCacheAdapterProvider[
|
||||
'localMemoryCacheAdapterMap'
|
||||
].get(
|
||||
viewerContext.entityCompanionProvider.getCompanionForEntity(LocalMemoryTestEntity)
|
||||
.entityCompanionDefinition.entityConfiguration.tableName
|
||||
)!['genericCacher'];
|
||||
const cachedResult = await entitySpecificGenericCacher.loadManyAsync([
|
||||
cacheKeyMaker('id', entity1.getID()),
|
||||
]);
|
||||
+115
-1
@@ -1,4 +1,27 @@
|
||||
import GenericLocalMemoryCacher from '../GenericLocalMemoryCacher';
|
||||
import {
|
||||
CacheStatus,
|
||||
UUIDField,
|
||||
EntityConfiguration,
|
||||
GenericEntityCacheAdapter,
|
||||
} from '@expo/entity';
|
||||
|
||||
import GenericLocalMemoryCacher, {
|
||||
DOES_NOT_EXIST_LOCAL_MEMORY_CACHE,
|
||||
} from '../GenericLocalMemoryCacher';
|
||||
|
||||
type BlahFields = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
const entityConfiguration = new EntityConfiguration<BlahFields>({
|
||||
idField: 'id',
|
||||
tableName: 'blah',
|
||||
schema: {
|
||||
id: new UUIDField({ columnName: 'id', cache: true }),
|
||||
},
|
||||
databaseAdapterFlavor: 'postgres',
|
||||
cacheAdapterFlavor: 'local-memory',
|
||||
});
|
||||
|
||||
describe(GenericLocalMemoryCacher, () => {
|
||||
describe(GenericLocalMemoryCacher.createLRUCache, () => {
|
||||
@@ -26,3 +49,94 @@ describe(GenericLocalMemoryCacher, () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Use within GenericEntityCacheAdapter', () => {
|
||||
describe('loadManyAsync', () => {
|
||||
it('returns appropriate cache results', async () => {
|
||||
const cacheAdapter = new GenericEntityCacheAdapter(
|
||||
new GenericLocalMemoryCacher(entityConfiguration, GenericLocalMemoryCacher.createLRUCache())
|
||||
);
|
||||
|
||||
const cacheHits = new Map<string, Readonly<BlahFields>>([['test-id-1', { id: 'test-id-1' }]]);
|
||||
await cacheAdapter.cacheManyAsync('id', cacheHits);
|
||||
await cacheAdapter.cacheDBMissesAsync('id', ['test-id-2']);
|
||||
|
||||
const results = await cacheAdapter.loadManyAsync('id', [
|
||||
'test-id-1',
|
||||
'test-id-2',
|
||||
'test-id-3',
|
||||
]);
|
||||
|
||||
expect(results.get('test-id-1')).toMatchObject({
|
||||
status: CacheStatus.HIT,
|
||||
item: { id: 'test-id-1' },
|
||||
});
|
||||
expect(results.get('test-id-2')).toMatchObject({ status: CacheStatus.NEGATIVE });
|
||||
expect(results.get('test-id-3')).toMatchObject({ status: CacheStatus.MISS });
|
||||
expect(results.size).toBe(3);
|
||||
});
|
||||
|
||||
it('returns empty map when passed empty array of fieldValues', async () => {
|
||||
const cacheAdapter = new GenericEntityCacheAdapter(
|
||||
new GenericLocalMemoryCacher(entityConfiguration, GenericLocalMemoryCacher.createLRUCache())
|
||||
);
|
||||
const results = await cacheAdapter.loadManyAsync('id', []);
|
||||
expect(results).toEqual(new Map());
|
||||
});
|
||||
});
|
||||
|
||||
describe('cacheManyAsync', () => {
|
||||
it('correctly caches all objects', async () => {
|
||||
const localMemoryCache = GenericLocalMemoryCacher.createLRUCache<BlahFields>({});
|
||||
|
||||
const localMemoryCacher = new GenericLocalMemoryCacher(entityConfiguration, localMemoryCache);
|
||||
const cacheAdapter = new GenericEntityCacheAdapter(localMemoryCacher);
|
||||
await cacheAdapter.cacheManyAsync('id', new Map([['test-id-1', { id: 'test-id-1' }]]));
|
||||
|
||||
const cacheKey = localMemoryCacher['makeCacheKey']('id', 'test-id-1');
|
||||
expect(localMemoryCache.get(cacheKey)).toMatchObject({
|
||||
id: 'test-id-1',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('cacheDBMissesAsync', () => {
|
||||
it('correctly caches misses', async () => {
|
||||
const localMemoryCache = GenericLocalMemoryCacher.createLRUCache<BlahFields>({});
|
||||
|
||||
const localMemoryCacher = new GenericLocalMemoryCacher(entityConfiguration, localMemoryCache);
|
||||
const cacheAdapter = new GenericEntityCacheAdapter(localMemoryCacher);
|
||||
await cacheAdapter.cacheDBMissesAsync('id', ['test-id-1']);
|
||||
|
||||
const cacheKey = localMemoryCacher['makeCacheKey']('id', 'test-id-1');
|
||||
expect(localMemoryCache.get(cacheKey)).toEqual(DOES_NOT_EXIST_LOCAL_MEMORY_CACHE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalidateManyAsync', () => {
|
||||
it('invalidates correctly', async () => {
|
||||
const localMemoryCache = GenericLocalMemoryCacher.createLRUCache<BlahFields>({});
|
||||
|
||||
const cacheAdapter = new GenericEntityCacheAdapter(
|
||||
new GenericLocalMemoryCacher(entityConfiguration, localMemoryCache)
|
||||
);
|
||||
await cacheAdapter.cacheManyAsync('id', new Map([['test-id-1', { id: 'test-id-1' }]]));
|
||||
await cacheAdapter.cacheDBMissesAsync('id', ['test-id-2']);
|
||||
await cacheAdapter.invalidateManyAsync('id', ['test-id-1', 'test-id-2']);
|
||||
|
||||
const results = await cacheAdapter.loadManyAsync('id', ['test-id-1', 'test-id-2']);
|
||||
expect(results.get('test-id-1')).toMatchObject({ status: CacheStatus.MISS });
|
||||
expect(results.get('test-id-2')).toMatchObject({ status: CacheStatus.MISS });
|
||||
});
|
||||
|
||||
it('returns when passed empty array of fieldValues', async () => {
|
||||
const cacheAdapter = new GenericEntityCacheAdapter(
|
||||
new GenericLocalMemoryCacher(
|
||||
entityConfiguration,
|
||||
GenericLocalMemoryCacher.createLRUCache<BlahFields>({})
|
||||
)
|
||||
);
|
||||
await cacheAdapter.invalidateManyAsync('id', []);
|
||||
});
|
||||
});
|
||||
});
|
||||
-107
@@ -1,107 +0,0 @@
|
||||
import { CacheStatus, UUIDField, EntityConfiguration } from '@expo/entity';
|
||||
|
||||
import GenericLocalMemoryCacher, {
|
||||
DOES_NOT_EXIST_LOCAL_MEMORY_CACHE,
|
||||
} from '../GenericLocalMemoryCacher';
|
||||
import LocalMemoryCacheAdapter from '../LocalMemoryCacheAdapter';
|
||||
|
||||
type BlahFields = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
const entityConfiguration = new EntityConfiguration<BlahFields>({
|
||||
idField: 'id',
|
||||
tableName: 'blah',
|
||||
schema: {
|
||||
id: new UUIDField({ columnName: 'id', cache: true }),
|
||||
},
|
||||
databaseAdapterFlavor: 'postgres',
|
||||
cacheAdapterFlavor: 'local-memory',
|
||||
});
|
||||
|
||||
describe(LocalMemoryCacheAdapter, () => {
|
||||
describe('loadManyAsync', () => {
|
||||
it('returns appropriate cache results', async () => {
|
||||
const cacheAdapter = new LocalMemoryCacheAdapter(
|
||||
entityConfiguration,
|
||||
GenericLocalMemoryCacher.createLRUCache()
|
||||
);
|
||||
|
||||
const cacheHits = new Map<string, Readonly<BlahFields>>([['test-id-1', { id: 'test-id-1' }]]);
|
||||
await cacheAdapter.cacheManyAsync('id', cacheHits);
|
||||
await cacheAdapter.cacheDBMissesAsync('id', ['test-id-2']);
|
||||
|
||||
const results = await cacheAdapter.loadManyAsync('id', [
|
||||
'test-id-1',
|
||||
'test-id-2',
|
||||
'test-id-3',
|
||||
]);
|
||||
|
||||
expect(results.get('test-id-1')).toMatchObject({
|
||||
status: CacheStatus.HIT,
|
||||
item: { id: 'test-id-1' },
|
||||
});
|
||||
expect(results.get('test-id-2')).toMatchObject({ status: CacheStatus.NEGATIVE });
|
||||
expect(results.get('test-id-3')).toMatchObject({ status: CacheStatus.MISS });
|
||||
expect(results.size).toBe(3);
|
||||
});
|
||||
|
||||
it('returns empty map when passed empty array of fieldValues', async () => {
|
||||
const cacheAdapter = new LocalMemoryCacheAdapter(
|
||||
entityConfiguration,
|
||||
GenericLocalMemoryCacher.createLRUCache()
|
||||
);
|
||||
const results = await cacheAdapter.loadManyAsync('id', []);
|
||||
expect(results).toEqual(new Map());
|
||||
});
|
||||
});
|
||||
|
||||
describe('cacheManyAsync', () => {
|
||||
it('correctly caches all objects', async () => {
|
||||
const localMemoryCache = GenericLocalMemoryCacher.createLRUCache<BlahFields>({});
|
||||
|
||||
const cacheAdapter = new LocalMemoryCacheAdapter(entityConfiguration, localMemoryCache);
|
||||
await cacheAdapter.cacheManyAsync('id', new Map([['test-id-1', { id: 'test-id-1' }]]));
|
||||
|
||||
const cacheKey = cacheAdapter['makeCacheKey']('id', 'test-id-1');
|
||||
expect(localMemoryCache.get(cacheKey)).toMatchObject({
|
||||
id: 'test-id-1',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('cacheDBMissesAsync', () => {
|
||||
it('correctly caches misses', async () => {
|
||||
const localMemoryCache = GenericLocalMemoryCacher.createLRUCache<BlahFields>({});
|
||||
|
||||
const cacheAdapter = new LocalMemoryCacheAdapter(entityConfiguration, localMemoryCache);
|
||||
await cacheAdapter.cacheDBMissesAsync('id', ['test-id-1']);
|
||||
|
||||
const cacheKey = cacheAdapter['makeCacheKey']('id', 'test-id-1');
|
||||
expect(localMemoryCache.get(cacheKey)).toEqual(DOES_NOT_EXIST_LOCAL_MEMORY_CACHE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalidateManyAsync', () => {
|
||||
it('invalidates correctly', async () => {
|
||||
const localMemoryCache = GenericLocalMemoryCacher.createLRUCache<BlahFields>({});
|
||||
|
||||
const cacheAdapter = new LocalMemoryCacheAdapter(entityConfiguration, localMemoryCache);
|
||||
await cacheAdapter.cacheManyAsync('id', new Map([['test-id-1', { id: 'test-id-1' }]]));
|
||||
await cacheAdapter.cacheDBMissesAsync('id', ['test-id-2']);
|
||||
await cacheAdapter.invalidateManyAsync('id', ['test-id-1', 'test-id-2']);
|
||||
|
||||
const results = await cacheAdapter.loadManyAsync('id', ['test-id-1', 'test-id-2']);
|
||||
expect(results.get('test-id-1')).toMatchObject({ status: CacheStatus.MISS });
|
||||
expect(results.get('test-id-2')).toMatchObject({ status: CacheStatus.MISS });
|
||||
});
|
||||
|
||||
it('returns when passed empty array of fieldValues', async () => {
|
||||
const cacheAdapter = new LocalMemoryCacheAdapter(
|
||||
entityConfiguration,
|
||||
GenericLocalMemoryCacher.createLRUCache<BlahFields>({})
|
||||
);
|
||||
await cacheAdapter.invalidateManyAsync('id', []);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,5 +6,4 @@
|
||||
|
||||
export { default as GenericLocalMemoryCacher } from './GenericLocalMemoryCacher';
|
||||
export * from './GenericLocalMemoryCacher';
|
||||
export { default as LocalMemoryCacheAdapter } from './LocalMemoryCacheAdapter';
|
||||
export { default as LocalMemoryCacheAdapterProvider } from './LocalMemoryCacheAdapterProvider';
|
||||
+6
-8
@@ -21,14 +21,18 @@ export default class LocalMemoryTestEntity extends Entity<
|
||||
string,
|
||||
ViewerContext
|
||||
> {
|
||||
static getCompanionDefinition(): EntityCompanionDefinition<
|
||||
static defineCompanionDefinition(): EntityCompanionDefinition<
|
||||
LocalMemoryTestEntityFields,
|
||||
string,
|
||||
ViewerContext,
|
||||
LocalMemoryTestEntity,
|
||||
LocalMemoryTestEntityPrivacyPolicy
|
||||
> {
|
||||
return localMemoryTestEntityCompanionDefinition;
|
||||
return {
|
||||
entityClass: LocalMemoryTestEntity,
|
||||
entityConfiguration: localMemoryTestEntityConfiguration,
|
||||
privacyPolicyClass: LocalMemoryTestEntityPrivacyPolicy,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,9 +96,3 @@ export const localMemoryTestEntityConfiguration =
|
||||
databaseAdapterFlavor: 'postgres',
|
||||
cacheAdapterFlavor: 'local-memory',
|
||||
});
|
||||
|
||||
const localMemoryTestEntityCompanionDefinition = new EntityCompanionDefinition({
|
||||
entityClass: LocalMemoryTestEntity,
|
||||
entityConfiguration: localMemoryTestEntityConfiguration,
|
||||
privacyPolicyClass: LocalMemoryTestEntityPrivacyPolicy,
|
||||
});
|
||||
+3
-3
@@ -14,8 +14,8 @@ export const createLocalMemoryTestEntityCompanionProvider = (
|
||||
): EntityCompanionProvider => {
|
||||
const localMemoryCacheAdapterProvider =
|
||||
localMemoryOptions.maxSize === 0 && localMemoryOptions.ttlSeconds === 0
|
||||
? LocalMemoryCacheAdapterProvider.getNoOpProvider()
|
||||
: LocalMemoryCacheAdapterProvider.getProvider(localMemoryOptions);
|
||||
? LocalMemoryCacheAdapterProvider.createNoOpProvider()
|
||||
: LocalMemoryCacheAdapterProvider.createProviderWithOptions(localMemoryOptions);
|
||||
return new EntityCompanionProvider(
|
||||
metricsAdapter,
|
||||
new Map([
|
||||
@@ -38,7 +38,7 @@ export const createLocalMemoryTestEntityCompanionProvider = (
|
||||
);
|
||||
};
|
||||
|
||||
export const createNoopLocalMemoryIntegrationTestEntityCompanionProvider = (
|
||||
export const createNoOpLocalMemoryIntegrationTestEntityCompanionProvider = (
|
||||
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter()
|
||||
): EntityCompanionProvider => {
|
||||
return createLocalMemoryTestEntityCompanionProvider(
|
||||
|
||||
@@ -3,6 +3,60 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [0.35.0](https://github.com/expo/entity/compare/v0.34.0...v0.35.0) (2024-04-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Revert: require explicit query context specification ([#219](https://github.com/expo/entity/issues/219)) ([1bdfbd6](https://github.com/expo/entity/commit/1bdfbd6b562d1200e4029df0533b5adb2d917831))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.34.0](https://github.com/expo/entity/compare/v0.33.0...v0.34.0) (2024-04-11)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* require explicit query context specification ([#219](https://github.com/expo/entity/issues/219)) ([8b0b31f](https://github.com/expo/entity/commit/8b0b31fdde5bd565aa527719003ef283a45f55cc))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.33.0](https://github.com/expo/entity/compare/v0.32.0...v0.33.0) (2023-10-06)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.32.0](https://github.com/expo/entity/compare/v0.31.1...v0.32.0) (2023-06-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* remove unused field from redis configuration ([#213](https://github.com/expo/entity/issues/213)) ([503a2fb](https://github.com/expo/entity/commit/503a2fb6a8d57f6a09f6da99daf5ab92fc3e7945))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* entity companion definition static thunk ([#210](https://github.com/expo/entity/issues/210)) ([4b18010](https://github.com/expo/entity/commit/4b18010d42be50ef329f428b08330e21bf676586))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.31.0](https://github.com/expo/entity/compare/v0.30.0...v0.31.0) (2023-01-19)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.30.0](https://github.com/expo/entity/compare/v0.29.0...v0.30.0) (2022-12-23)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
|
||||
|
||||
@@ -11,7 +11,7 @@ During `EntityCompanionProvider` instantiation:
|
||||
```typescript
|
||||
import Redis from 'ioredis';
|
||||
|
||||
const redisCacheAdapterContext = {
|
||||
const genericRedisCacherContext = {
|
||||
redisClient: new Redis(new URL(process.env['REDIS_URL']!).toString()),
|
||||
makeKeyFn(...parts: string[]): string {
|
||||
const delimiter = ':';
|
||||
@@ -21,7 +21,6 @@ const redisCacheAdapterContext = {
|
||||
return escapedParts.join(delimiter);
|
||||
},
|
||||
cacheKeyPrefix: 'ent-',
|
||||
cacheKeyVersion: 1,
|
||||
ttlSecondsPositive: 86400, // 1 day
|
||||
ttlSecondsNegative: 600, // 10 minutes
|
||||
};
|
||||
@@ -36,7 +35,7 @@ export const createDefaultEntityCompanionProvider = (
|
||||
},
|
||||
{
|
||||
['redis']: {
|
||||
cacheAdapterProvider: new RedisCacheAdapterProvider(redisCacheAdapterContext),
|
||||
cacheAdapterProvider: new RedisCacheAdapterProvider(genericRedisCacheContext),
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/entity-cache-adapter-redis",
|
||||
"version": "0.30.0",
|
||||
"version": "0.35.0",
|
||||
"description": "Redis cache adapter for @expo/entity",
|
||||
"files": [
|
||||
"build",
|
||||
@@ -19,7 +19,7 @@
|
||||
"barrelsby": "barrelsby --directory src --location top --exclude tests__ --singleQuotes --exportDefault --delete"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=16"
|
||||
},
|
||||
"keywords": [
|
||||
"entity"
|
||||
@@ -31,7 +31,7 @@
|
||||
"ioredis": ">=5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/entity": "^0.30.0",
|
||||
"ioredis": "^5.0.4"
|
||||
"@expo/entity": "^0.35.0",
|
||||
"ioredis": "^5.2.5"
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
transformFieldsToCacheObject,
|
||||
IEntityGenericCacher,
|
||||
} from '@expo/entity';
|
||||
import invariant from 'invariant';
|
||||
|
||||
import { redisTransformerMap } from './RedisCommon';
|
||||
import wrapNativeRedisCallAsync from './errors/wrapNativeRedisCallAsync';
|
||||
@@ -31,6 +32,18 @@ export interface GenericRedisCacheContext {
|
||||
*/
|
||||
redisClient: IRedis;
|
||||
|
||||
/**
|
||||
* Create a key string for key parts (cache key prefix, versions, entity name, etc).
|
||||
* Most commonly a simple `parts.join(':')`. See integration test for example.
|
||||
*/
|
||||
makeKeyFn: (...parts: string[]) => string;
|
||||
|
||||
/**
|
||||
* Prefix prepended to all entity cache keys. Useful for adding a short, human-readable
|
||||
* distintion for entity keys, e.g. `ent-`
|
||||
*/
|
||||
cacheKeyPrefix: string;
|
||||
|
||||
/**
|
||||
* TTL for caching database hits. Successive entity loads within this TTL
|
||||
* will be read from cache (unless invalidated).
|
||||
@@ -131,4 +144,19 @@ export default class GenericRedisCacher<TFields> implements IEntityGenericCacher
|
||||
|
||||
await wrapNativeRedisCallAsync(() => this.context.redisClient.del(...keys));
|
||||
}
|
||||
|
||||
public makeCacheKey<N extends keyof TFields>(
|
||||
fieldName: N,
|
||||
fieldValue: NonNullable<TFields[N]>
|
||||
): string {
|
||||
const columnName = this.entityConfiguration.entityToDBFieldsKeyMapping.get(fieldName);
|
||||
invariant(columnName, `database field mapping missing for ${String(fieldName)}`);
|
||||
return this.context.makeKeyFn(
|
||||
this.context.cacheKeyPrefix,
|
||||
this.entityConfiguration.tableName,
|
||||
`v2.${this.entityConfiguration.cacheKeyVersion}`,
|
||||
columnName,
|
||||
String(fieldValue)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
import { EntityCacheAdapter, EntityConfiguration, CacheLoadResult, mapKeys } from '@expo/entity';
|
||||
import invariant from 'invariant';
|
||||
|
||||
import GenericRedisCacher, { IRedis } from './GenericRedisCacher';
|
||||
|
||||
export interface RedisCacheAdapterContext {
|
||||
/**
|
||||
* Instance of ioredis.Redis
|
||||
*/
|
||||
redisClient: IRedis;
|
||||
|
||||
/**
|
||||
* Create a key string for key parts (cache key prefix, versions, entity name, etc).
|
||||
* Most commonly a simple `parts.join(':')`. See integration test for example.
|
||||
*/
|
||||
makeKeyFn: (...parts: string[]) => string;
|
||||
|
||||
/**
|
||||
* Global cache version for the entity framework. Bumping this version will
|
||||
* invalidate the cache for all entities at once.
|
||||
*/
|
||||
cacheKeyVersion: number;
|
||||
|
||||
/**
|
||||
* Prefix prepended to all entity cache keys. Useful for adding a short, human-readable
|
||||
* distintion for entity keys, e.g. `ent-`
|
||||
*/
|
||||
cacheKeyPrefix: string;
|
||||
|
||||
/**
|
||||
* TTL for caching database hits. Successive entity loads within this TTL
|
||||
* will be read from cache (unless invalidated).
|
||||
*/
|
||||
ttlSecondsPositive: number;
|
||||
|
||||
/**
|
||||
* TTL for negatively caching database misses. Successive entity loads within
|
||||
* this TTL will be assumed not present in the database (unless invalidated).
|
||||
*/
|
||||
ttlSecondsNegative: number;
|
||||
}
|
||||
|
||||
export default class RedisCacheAdapter<TFields> extends EntityCacheAdapter<TFields> {
|
||||
private readonly genericRedisCacher: GenericRedisCacher<TFields>;
|
||||
constructor(
|
||||
entityConfiguration: EntityConfiguration<TFields>,
|
||||
private readonly context: RedisCacheAdapterContext
|
||||
) {
|
||||
super(entityConfiguration);
|
||||
|
||||
this.genericRedisCacher = new GenericRedisCacher(
|
||||
{
|
||||
redisClient: context.redisClient,
|
||||
ttlSecondsNegative: context.ttlSecondsNegative,
|
||||
ttlSecondsPositive: context.ttlSecondsPositive,
|
||||
},
|
||||
entityConfiguration
|
||||
);
|
||||
}
|
||||
|
||||
public async loadManyAsync<N extends keyof TFields>(
|
||||
fieldName: N,
|
||||
fieldValues: readonly NonNullable<TFields[N]>[]
|
||||
): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult<TFields>>> {
|
||||
const redisCacheKeyToFieldValueMapping = new Map(
|
||||
fieldValues.map((fieldValue) => [this.makeCacheKey(fieldName, fieldValue), fieldValue])
|
||||
);
|
||||
const cacheResults = await this.genericRedisCacher.loadManyAsync(
|
||||
Array.from(redisCacheKeyToFieldValueMapping.keys())
|
||||
);
|
||||
|
||||
return mapKeys(cacheResults, (redisCacheKey) => {
|
||||
const fieldValue = redisCacheKeyToFieldValueMapping.get(redisCacheKey);
|
||||
invariant(
|
||||
fieldValue !== undefined,
|
||||
'Unspecified cache key %s returned from generic Redis cacher',
|
||||
redisCacheKey
|
||||
);
|
||||
return fieldValue;
|
||||
});
|
||||
}
|
||||
|
||||
public async cacheManyAsync<N extends keyof TFields>(
|
||||
fieldName: N,
|
||||
objectMap: ReadonlyMap<NonNullable<TFields[N]>, Readonly<TFields>>
|
||||
): Promise<void> {
|
||||
await this.genericRedisCacher.cacheManyAsync(
|
||||
mapKeys(objectMap, (fieldValue) => this.makeCacheKey(fieldName, fieldValue))
|
||||
);
|
||||
}
|
||||
|
||||
public async cacheDBMissesAsync<N extends keyof TFields>(
|
||||
fieldName: N,
|
||||
fieldValues: readonly NonNullable<TFields[N]>[]
|
||||
): Promise<void> {
|
||||
await this.genericRedisCacher.cacheDBMissesAsync(
|
||||
fieldValues.map((fieldValue) => this.makeCacheKey(fieldName, fieldValue))
|
||||
);
|
||||
}
|
||||
|
||||
public async invalidateManyAsync<N extends keyof TFields>(
|
||||
fieldName: N,
|
||||
fieldValues: readonly NonNullable<TFields[N]>[]
|
||||
): Promise<void> {
|
||||
await this.genericRedisCacher.invalidateManyAsync(
|
||||
fieldValues.map((fieldValue) => this.makeCacheKey(fieldName, fieldValue))
|
||||
);
|
||||
}
|
||||
|
||||
private makeCacheKey<N extends keyof TFields>(
|
||||
fieldName: N,
|
||||
fieldValue: NonNullable<TFields[N]>
|
||||
): string {
|
||||
const columnName = this.entityConfiguration.entityToDBFieldsKeyMapping.get(fieldName);
|
||||
invariant(columnName, `database field mapping missing for ${String(fieldName)}`);
|
||||
return this.context.makeKeyFn(
|
||||
this.context.cacheKeyPrefix,
|
||||
this.entityConfiguration.tableName,
|
||||
`v2.${this.entityConfiguration.cacheKeyVersion}`,
|
||||
columnName,
|
||||
String(fieldValue)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,18 @@
|
||||
import { IEntityCacheAdapterProvider, EntityConfiguration, EntityCacheAdapter } from '@expo/entity';
|
||||
import {
|
||||
IEntityCacheAdapterProvider,
|
||||
EntityConfiguration,
|
||||
IEntityCacheAdapter,
|
||||
GenericEntityCacheAdapter,
|
||||
} from '@expo/entity';
|
||||
|
||||
import RedisCacheAdapter, { RedisCacheAdapterContext } from './RedisCacheAdapter';
|
||||
import GenericRedisCacher, { GenericRedisCacheContext } from './GenericRedisCacher';
|
||||
|
||||
export default class RedisCacheAdapterProvider implements IEntityCacheAdapterProvider {
|
||||
constructor(private readonly context: RedisCacheAdapterContext) {}
|
||||
constructor(private readonly context: GenericRedisCacheContext) {}
|
||||
|
||||
getCacheAdapter<TFields>(
|
||||
entityConfiguration: EntityConfiguration<TFields>
|
||||
): EntityCacheAdapter<TFields> {
|
||||
return new RedisCacheAdapter(entityConfiguration, this.context);
|
||||
): IEntityCacheAdapter<TFields> {
|
||||
return new GenericEntityCacheAdapter(new GenericRedisCacher(this.context, entityConfiguration));
|
||||
}
|
||||
}
|
||||
+17
-15
@@ -6,8 +6,11 @@ import nullthrows from 'nullthrows';
|
||||
import { URL } from 'url';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { IRedis, IRedisTransaction } from '../GenericRedisCacher';
|
||||
import RedisCacheAdapter, { RedisCacheAdapterContext } from '../RedisCacheAdapter';
|
||||
import GenericRedisCacher, {
|
||||
IRedis,
|
||||
IRedisTransaction,
|
||||
GenericRedisCacheContext,
|
||||
} from '../GenericRedisCacher';
|
||||
import RedisTestEntity from '../testfixtures/RedisTestEntity';
|
||||
import { createRedisIntegrationTestEntityCompanionProvider } from '../testfixtures/createRedisIntegrationTestEntityCompanionProvider';
|
||||
|
||||
@@ -54,14 +57,14 @@ class BatchedRedis implements IRedis {
|
||||
}
|
||||
}
|
||||
|
||||
describe(RedisCacheAdapter, () => {
|
||||
describe(GenericRedisCacher, () => {
|
||||
const redis = new Redis(new URL(process.env['REDIS_URL']!).toString());
|
||||
const redisClient = new BatchedRedis(redis);
|
||||
|
||||
let redisCacheAdapterContext: RedisCacheAdapterContext;
|
||||
let genericRedisCacheContext: GenericRedisCacheContext;
|
||||
|
||||
beforeAll(() => {
|
||||
redisCacheAdapterContext = {
|
||||
genericRedisCacheContext = {
|
||||
redisClient,
|
||||
makeKeyFn(...parts: string[]): string {
|
||||
const delimiter = ':';
|
||||
@@ -71,7 +74,6 @@ describe(RedisCacheAdapter, () => {
|
||||
return escapedParts.join(delimiter);
|
||||
},
|
||||
cacheKeyPrefix: 'test-',
|
||||
cacheKeyVersion: 1,
|
||||
ttlSecondsPositive: 86400, // 1 day
|
||||
ttlSecondsNegative: 600, // 10 minutes
|
||||
};
|
||||
@@ -88,16 +90,16 @@ describe(RedisCacheAdapter, () => {
|
||||
it('has correct caching behavior', async () => {
|
||||
// simulate two requests
|
||||
const viewerContext = new ViewerContext(
|
||||
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
|
||||
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext)
|
||||
);
|
||||
|
||||
const mgetSpy = jest.spyOn(redis, 'mget');
|
||||
|
||||
const cacheAdapter = viewerContext.entityCompanionProvider.getCompanionForEntity(
|
||||
RedisTestEntity,
|
||||
RedisTestEntity.getCompanionDefinition()
|
||||
)['tableDataCoordinator']['cacheAdapter'];
|
||||
const cacheKeyMaker = cacheAdapter['makeCacheKey'].bind(cacheAdapter);
|
||||
const genericCacher =
|
||||
viewerContext.entityCompanionProvider.getCompanionForEntity(RedisTestEntity)[
|
||||
'tableDataCoordinator'
|
||||
]['cacheAdapter']['genericCacher'];
|
||||
const cacheKeyMaker = genericCacher['makeCacheKey'].bind(genericCacher);
|
||||
|
||||
const entity1Created = await RedisTestEntity.creator(viewerContext)
|
||||
.setField('name', 'blah')
|
||||
@@ -106,13 +108,13 @@ describe(RedisCacheAdapter, () => {
|
||||
// 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(redisCacheAdapterContext)
|
||||
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext)
|
||||
);
|
||||
const viewerContext2 = new ViewerContext(
|
||||
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
|
||||
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext)
|
||||
);
|
||||
const viewerContext3 = new ViewerContext(
|
||||
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
|
||||
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext)
|
||||
);
|
||||
const [entity1, entity2, entity3] = await Promise.all([
|
||||
RedisTestEntity.loader(viewerContext1).enforcing().loadByIDAsync(entity1Created.getID()),
|
||||
|
||||
+26
-22
@@ -4,19 +4,18 @@ import Redis from 'ioredis';
|
||||
import { URL } from 'url';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import RedisCacheAdapter, { RedisCacheAdapterContext } from '../RedisCacheAdapter';
|
||||
import GenericRedisCacher, { GenericRedisCacheContext } from '../GenericRedisCacher';
|
||||
import RedisTestEntity from '../testfixtures/RedisTestEntity';
|
||||
import { createRedisIntegrationTestEntityCompanionProvider } from '../testfixtures/createRedisIntegrationTestEntityCompanionProvider';
|
||||
|
||||
class TestViewerContext extends ViewerContext {}
|
||||
|
||||
describe(RedisCacheAdapter, () => {
|
||||
const redisClient = new Redis(new URL(process.env['REDIS_URL']!).toString());
|
||||
let redisCacheAdapterContext: RedisCacheAdapterContext;
|
||||
describe(GenericRedisCacher, () => {
|
||||
let genericRedisCacheContext: GenericRedisCacheContext;
|
||||
|
||||
beforeAll(() => {
|
||||
redisCacheAdapterContext = {
|
||||
redisClient,
|
||||
genericRedisCacheContext = {
|
||||
redisClient: new Redis(new URL(process.env['REDIS_URL']!).toString()),
|
||||
makeKeyFn(...parts: string[]): string {
|
||||
const delimiter = ':';
|
||||
const escapedParts = parts.map((part) =>
|
||||
@@ -25,28 +24,27 @@ describe(RedisCacheAdapter, () => {
|
||||
return escapedParts.join(delimiter);
|
||||
},
|
||||
cacheKeyPrefix: 'test-',
|
||||
cacheKeyVersion: 1,
|
||||
ttlSecondsPositive: 86400, // 1 day
|
||||
ttlSecondsNegative: 600, // 10 minutes
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await redisClient.flushdb();
|
||||
await (genericRedisCacheContext.redisClient as Redis).flushdb();
|
||||
});
|
||||
afterAll(async () => {
|
||||
redisClient.disconnect();
|
||||
(genericRedisCacheContext.redisClient as Redis).disconnect();
|
||||
});
|
||||
|
||||
it('has correct caching behavior', async () => {
|
||||
const viewerContext = new TestViewerContext(
|
||||
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
|
||||
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext)
|
||||
);
|
||||
const cacheAdapter = viewerContext.entityCompanionProvider.getCompanionForEntity(
|
||||
RedisTestEntity,
|
||||
RedisTestEntity.getCompanionDefinition()
|
||||
)['tableDataCoordinator']['cacheAdapter'];
|
||||
const cacheKeyMaker = cacheAdapter['makeCacheKey'].bind(cacheAdapter);
|
||||
const genericCacher =
|
||||
viewerContext.entityCompanionProvider.getCompanionForEntity(RedisTestEntity)[
|
||||
'tableDataCoordinator'
|
||||
]['cacheAdapter']['genericCacher'];
|
||||
const cacheKeyMaker = genericCacher['makeCacheKey'].bind(genericCacher);
|
||||
|
||||
const entity1Created = await RedisTestEntity.creator(viewerContext)
|
||||
.setField('name', 'blah')
|
||||
@@ -57,7 +55,9 @@ describe(RedisCacheAdapter, () => {
|
||||
.enforcing()
|
||||
.loadByIDAsync(entity1Created.getID());
|
||||
|
||||
const cachedJSON = await redisClient.get(cacheKeyMaker('id', entity1.getID()));
|
||||
const cachedJSON = await (genericRedisCacheContext.redisClient as Redis).get(
|
||||
cacheKeyMaker('id', entity1.getID())
|
||||
);
|
||||
const cachedValue = JSON.parse(cachedJSON!);
|
||||
expect(cachedValue).toMatchObject({
|
||||
id: entity1.getID(),
|
||||
@@ -72,7 +72,9 @@ describe(RedisCacheAdapter, () => {
|
||||
);
|
||||
expect(entityNonExistentResult.ok).toBe(false);
|
||||
|
||||
const nonExistentCachedValue = await redisClient.get(cacheKeyMaker('id', nonExistentId));
|
||||
const nonExistentCachedValue = await (genericRedisCacheContext.redisClient as Redis).get(
|
||||
cacheKeyMaker('id', nonExistentId)
|
||||
);
|
||||
expect(nonExistentCachedValue).toEqual('');
|
||||
|
||||
// load again through entities framework to ensure it reads negative result
|
||||
@@ -83,13 +85,15 @@ describe(RedisCacheAdapter, () => {
|
||||
|
||||
// invalidate from cache to ensure it invalidates correctly
|
||||
await RedisTestEntity.loader(viewerContext).invalidateFieldsAsync(entity1.getAllFields());
|
||||
const cachedValueNull = await redisClient.get(cacheKeyMaker('id', entity1.getID()));
|
||||
const cachedValueNull = await (genericRedisCacheContext.redisClient as Redis).get(
|
||||
cacheKeyMaker('id', entity1.getID())
|
||||
);
|
||||
expect(cachedValueNull).toBe(null);
|
||||
});
|
||||
|
||||
it('caches and restores date fields', async () => {
|
||||
const viewerContext = new TestViewerContext(
|
||||
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
|
||||
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext)
|
||||
);
|
||||
const date = new Date();
|
||||
const entity1 = await enforceAsyncResult(
|
||||
@@ -104,7 +108,7 @@ describe(RedisCacheAdapter, () => {
|
||||
|
||||
// simulate new request
|
||||
const vc2 = new TestViewerContext(
|
||||
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
|
||||
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext)
|
||||
);
|
||||
const entity3 = await RedisTestEntity.loader(vc2).enforcing().loadByIDAsync(entity1.getID());
|
||||
expect(entity3.getField('dateField')).toEqual(date);
|
||||
@@ -112,7 +116,7 @@ describe(RedisCacheAdapter, () => {
|
||||
|
||||
it('caches and restores empty string field keys', async () => {
|
||||
const viewerContext = new TestViewerContext(
|
||||
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
|
||||
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext)
|
||||
);
|
||||
const entity1 = await enforceAsyncResult(
|
||||
RedisTestEntity.creator(viewerContext).setField('name', '').createAsync()
|
||||
@@ -124,7 +128,7 @@ describe(RedisCacheAdapter, () => {
|
||||
|
||||
// simulate new request
|
||||
const vc2 = new TestViewerContext(
|
||||
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
|
||||
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext)
|
||||
);
|
||||
const entity3 = await RedisTestEntity.loader(vc2)
|
||||
.enforcing()
|
||||
+12
-27
@@ -2,8 +2,7 @@ import { CacheStatus, ViewerContext } from '@expo/entity';
|
||||
import Redis from 'ioredis';
|
||||
import { URL } from 'url';
|
||||
|
||||
import GenericRedisCacher from '../GenericRedisCacher';
|
||||
import { RedisCacheAdapterContext } from '../RedisCacheAdapter';
|
||||
import GenericRedisCacher, { GenericRedisCacheContext } from '../GenericRedisCacher';
|
||||
import RedisTestEntity, {
|
||||
redisTestEntityConfiguration,
|
||||
RedisTestEntityFields,
|
||||
@@ -13,12 +12,11 @@ import { createRedisIntegrationTestEntityCompanionProvider } from '../testfixtur
|
||||
class TestViewerContext extends ViewerContext {}
|
||||
|
||||
describe(GenericRedisCacher, () => {
|
||||
const redisClient = new Redis(new URL(process.env['REDIS_URL']!).toString());
|
||||
let redisCacheAdapterContext: RedisCacheAdapterContext;
|
||||
let genericRedisCacheContext: GenericRedisCacheContext;
|
||||
|
||||
beforeAll(() => {
|
||||
redisCacheAdapterContext = {
|
||||
redisClient,
|
||||
genericRedisCacheContext = {
|
||||
redisClient: new Redis(new URL(process.env['REDIS_URL']!).toString()),
|
||||
makeKeyFn(...parts: string[]): string {
|
||||
const delimiter = ':';
|
||||
const escapedParts = parts.map((part) =>
|
||||
@@ -27,29 +25,24 @@ describe(GenericRedisCacher, () => {
|
||||
return escapedParts.join(delimiter);
|
||||
},
|
||||
cacheKeyPrefix: 'test-',
|
||||
cacheKeyVersion: 1,
|
||||
ttlSecondsPositive: 86400, // 1 day
|
||||
ttlSecondsNegative: 600, // 10 minutes
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await redisClient.flushdb();
|
||||
await (genericRedisCacheContext.redisClient as Redis).flushdb();
|
||||
});
|
||||
afterAll(async () => {
|
||||
redisClient.disconnect();
|
||||
(genericRedisCacheContext.redisClient as Redis).disconnect();
|
||||
});
|
||||
|
||||
it('has correct caching and loading behavior', async () => {
|
||||
const viewerContext = new TestViewerContext(
|
||||
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
|
||||
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext)
|
||||
);
|
||||
const genericRedisCacher = new GenericRedisCacher(
|
||||
{
|
||||
redisClient: redisCacheAdapterContext.redisClient,
|
||||
ttlSecondsNegative: redisCacheAdapterContext.ttlSecondsNegative,
|
||||
ttlSecondsPositive: redisCacheAdapterContext.ttlSecondsPositive,
|
||||
},
|
||||
genericRedisCacheContext,
|
||||
redisTestEntityConfiguration
|
||||
);
|
||||
const date = new Date();
|
||||
@@ -63,7 +56,7 @@ describe(GenericRedisCacher, () => {
|
||||
]);
|
||||
await genericRedisCacher.cacheManyAsync(objectMap);
|
||||
|
||||
const cachedJSON = await redisClient.get(testKey);
|
||||
const cachedJSON = await (genericRedisCacheContext.redisClient as Redis).get(testKey);
|
||||
const cachedValue = JSON.parse(cachedJSON!);
|
||||
expect(cachedValue).toMatchObject({
|
||||
id: entity1Created.getID(),
|
||||
@@ -80,11 +73,7 @@ describe(GenericRedisCacher, () => {
|
||||
});
|
||||
it('has correct negative caching behaviour', async () => {
|
||||
const genericRedisCacher = new GenericRedisCacher(
|
||||
{
|
||||
redisClient: redisCacheAdapterContext.redisClient,
|
||||
ttlSecondsNegative: redisCacheAdapterContext.ttlSecondsNegative,
|
||||
ttlSecondsPositive: redisCacheAdapterContext.ttlSecondsPositive,
|
||||
},
|
||||
genericRedisCacheContext,
|
||||
redisTestEntityConfiguration
|
||||
);
|
||||
|
||||
@@ -96,14 +85,10 @@ describe(GenericRedisCacher, () => {
|
||||
});
|
||||
it('has correct invalidation behaviour', async () => {
|
||||
const viewerContext = new TestViewerContext(
|
||||
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
|
||||
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext)
|
||||
);
|
||||
const genericRedisCacher = new GenericRedisCacher(
|
||||
{
|
||||
redisClient: redisCacheAdapterContext.redisClient,
|
||||
ttlSecondsNegative: redisCacheAdapterContext.ttlSecondsNegative,
|
||||
ttlSecondsPositive: redisCacheAdapterContext.ttlSecondsPositive,
|
||||
},
|
||||
genericRedisCacheContext,
|
||||
redisTestEntityConfiguration
|
||||
);
|
||||
const date = new Date();
|
||||
|
||||
@@ -2,18 +2,18 @@ import { EntityCacheAdapterTransientError, ViewerContext } from '@expo/entity';
|
||||
import Redis from 'ioredis';
|
||||
import { URL } from 'url';
|
||||
|
||||
import RedisCacheAdapter, { RedisCacheAdapterContext } from '../RedisCacheAdapter';
|
||||
import GenericRedisCacher, { GenericRedisCacheContext } from '../GenericRedisCacher';
|
||||
import RedisTestEntity from '../testfixtures/RedisTestEntity';
|
||||
import { createRedisIntegrationTestEntityCompanionProvider } from '../testfixtures/createRedisIntegrationTestEntityCompanionProvider';
|
||||
|
||||
class TestViewerContext extends ViewerContext {}
|
||||
|
||||
describe(RedisCacheAdapter, () => {
|
||||
describe(GenericRedisCacher, () => {
|
||||
const redisClient = new Redis(new URL(process.env['REDIS_URL']!).toString());
|
||||
let redisCacheAdapterContext: RedisCacheAdapterContext;
|
||||
let genericRedisCacheContext: GenericRedisCacheContext;
|
||||
|
||||
beforeAll(() => {
|
||||
redisCacheAdapterContext = {
|
||||
genericRedisCacheContext = {
|
||||
redisClient,
|
||||
makeKeyFn(...parts: string[]): string {
|
||||
const delimiter = ':';
|
||||
@@ -23,7 +23,6 @@ describe(RedisCacheAdapter, () => {
|
||||
return escapedParts.join(delimiter);
|
||||
},
|
||||
cacheKeyPrefix: 'test-',
|
||||
cacheKeyVersion: 1,
|
||||
ttlSecondsPositive: 86400, // 1 day
|
||||
ttlSecondsNegative: 600, // 10 minutes
|
||||
};
|
||||
@@ -37,7 +36,7 @@ describe(RedisCacheAdapter, () => {
|
||||
redisClient.disconnect();
|
||||
|
||||
const vc1 = new TestViewerContext(
|
||||
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
|
||||
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext)
|
||||
);
|
||||
|
||||
await expect(
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { CacheStatus, UUIDField, EntityConfiguration } from '@expo/entity';
|
||||
import { Redis, Pipeline } from 'ioredis';
|
||||
import { mock, when, instance, anything, verify } from 'ts-mockito';
|
||||
|
||||
import GenericRedisCacher from '../GenericRedisCacher';
|
||||
|
||||
type BlahFields = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
const entityConfiguration = new EntityConfiguration<BlahFields>({
|
||||
idField: 'id',
|
||||
tableName: 'blah',
|
||||
schema: {
|
||||
id: new UUIDField({ columnName: 'id', cache: true }),
|
||||
},
|
||||
databaseAdapterFlavor: 'postgres',
|
||||
cacheAdapterFlavor: 'redis',
|
||||
});
|
||||
|
||||
describe(GenericRedisCacher, () => {
|
||||
describe('loadManyAsync', () => {
|
||||
it('returns appropriate cache results', async () => {
|
||||
const redisResults = new Map();
|
||||
|
||||
const mockRedisClient = mock<Redis>();
|
||||
|
||||
// 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)
|
||||
);
|
||||
|
||||
const genericCacher = new GenericRedisCacher(
|
||||
{
|
||||
redisClient: instance(mockRedisClient),
|
||||
makeKeyFn: (...parts) => parts.join(':'),
|
||||
cacheKeyPrefix: 'hello-',
|
||||
ttlSecondsPositive: 1,
|
||||
ttlSecondsNegative: 2,
|
||||
},
|
||||
entityConfiguration
|
||||
);
|
||||
|
||||
const cacheKeyWat = genericCacher['makeCacheKey']('id', 'wat');
|
||||
const cacheKeyWho = genericCacher['makeCacheKey']('id', 'who');
|
||||
const cacheKeyWhy = genericCacher['makeCacheKey']('id', 'why');
|
||||
|
||||
redisResults.set(cacheKeyWat, JSON.stringify({ id: 'wat' }));
|
||||
redisResults.set(cacheKeyWho, '');
|
||||
|
||||
const results = await genericCacher.loadManyAsync([cacheKeyWat, cacheKeyWho, cacheKeyWhy]);
|
||||
|
||||
expect(results.get(cacheKeyWat)).toMatchObject({
|
||||
status: CacheStatus.HIT,
|
||||
item: { id: 'wat' },
|
||||
});
|
||||
expect(results.get(cacheKeyWho)).toMatchObject({ status: CacheStatus.NEGATIVE });
|
||||
expect(results.get(cacheKeyWhy)).toMatchObject({ status: CacheStatus.MISS });
|
||||
expect(results.size).toBe(3);
|
||||
});
|
||||
|
||||
it('returns empty map when passed empty array of fieldValues', async () => {
|
||||
const genericCacher = new GenericRedisCacher(
|
||||
{
|
||||
redisClient: instance(mock<Redis>()),
|
||||
makeKeyFn: (...parts) => parts.join(':'),
|
||||
cacheKeyPrefix: 'hello-',
|
||||
ttlSecondsPositive: 1,
|
||||
ttlSecondsNegative: 2,
|
||||
},
|
||||
entityConfiguration
|
||||
);
|
||||
const results = await genericCacher.loadManyAsync([]);
|
||||
expect(results).toEqual(new Map());
|
||||
});
|
||||
});
|
||||
|
||||
describe('cacheManyAsync', () => {
|
||||
it('correctly caches all objects', async () => {
|
||||
const redisResults = new Map();
|
||||
|
||||
const mockPipeline = mock<Pipeline>();
|
||||
when(mockPipeline.set(anything(), anything(), anything(), anything())).thenCall(
|
||||
(key, value, code, ttl) => {
|
||||
redisResults.set(key, { value, code, ttl });
|
||||
return pipeline;
|
||||
}
|
||||
);
|
||||
when(mockPipeline.exec()).thenResolve({} as any);
|
||||
const pipeline = instance(mockPipeline);
|
||||
|
||||
const mockRedisClient = mock<Redis>();
|
||||
when(mockRedisClient.multi()).thenReturn(pipeline);
|
||||
|
||||
const genericCacher = new GenericRedisCacher(
|
||||
{
|
||||
redisClient: instance(mockRedisClient),
|
||||
makeKeyFn: (...parts) => parts.join(':'),
|
||||
cacheKeyPrefix: 'hello-',
|
||||
ttlSecondsPositive: 1,
|
||||
ttlSecondsNegative: 2,
|
||||
},
|
||||
entityConfiguration
|
||||
);
|
||||
|
||||
const cacheKey = genericCacher['makeCacheKey']('id', 'wat');
|
||||
|
||||
await genericCacher.cacheManyAsync(new Map([[cacheKey, { id: 'wat' }]]));
|
||||
|
||||
expect(redisResults.get(cacheKey)).toMatchObject({
|
||||
value: JSON.stringify({ id: 'wat' }),
|
||||
code: 'EX',
|
||||
ttl: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('cacheDBMissesAsync', () => {
|
||||
it('correctly caches misses', async () => {
|
||||
const redisResults = new Map();
|
||||
const mockPipeline = mock<Pipeline>();
|
||||
when(mockPipeline.set(anything(), anything(), anything(), anything())).thenCall(
|
||||
(key, value, code, ttl) => {
|
||||
redisResults.set(key, { value, code, ttl });
|
||||
return pipeline;
|
||||
}
|
||||
);
|
||||
when(mockPipeline.exec()).thenResolve({} as any);
|
||||
const pipeline = instance(mockPipeline);
|
||||
|
||||
const mockRedisClient = mock<Redis>();
|
||||
when(mockRedisClient.multi()).thenReturn(pipeline);
|
||||
|
||||
const genericCacher = new GenericRedisCacher(
|
||||
{
|
||||
redisClient: instance(mockRedisClient),
|
||||
makeKeyFn: (...parts) => parts.join(':'),
|
||||
cacheKeyPrefix: 'hello-',
|
||||
ttlSecondsPositive: 1,
|
||||
ttlSecondsNegative: 2,
|
||||
},
|
||||
entityConfiguration
|
||||
);
|
||||
|
||||
const cacheKey = genericCacher['makeCacheKey']('id', 'wat');
|
||||
|
||||
await genericCacher.cacheDBMissesAsync([cacheKey]);
|
||||
|
||||
expect(redisResults.get(cacheKey)).toMatchObject({
|
||||
value: '',
|
||||
code: 'EX',
|
||||
ttl: 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalidateManyAsync', () => {
|
||||
it('invalidates correctly', async () => {
|
||||
const mockRedisClient = mock<Redis>();
|
||||
when(mockRedisClient.del()).thenResolve(1);
|
||||
|
||||
const genericCacher = new GenericRedisCacher(
|
||||
{
|
||||
redisClient: instance(mockRedisClient),
|
||||
makeKeyFn: (...parts) => parts.join(':'),
|
||||
cacheKeyPrefix: 'hello-',
|
||||
ttlSecondsPositive: 1,
|
||||
ttlSecondsNegative: 2,
|
||||
},
|
||||
entityConfiguration
|
||||
);
|
||||
const cacheKey = genericCacher['makeCacheKey']('id', 'wat');
|
||||
|
||||
await genericCacher.invalidateManyAsync([cacheKey]);
|
||||
|
||||
verify(mockRedisClient.del(cacheKey)).once();
|
||||
});
|
||||
|
||||
it('returns when passed empty array of fieldValues', async () => {
|
||||
const genericCacher = new GenericRedisCacher(
|
||||
{
|
||||
redisClient: instance(mock<Redis>()),
|
||||
makeKeyFn: (...parts) => parts.join(':'),
|
||||
cacheKeyPrefix: 'hello-',
|
||||
ttlSecondsPositive: 1,
|
||||
ttlSecondsNegative: 2,
|
||||
},
|
||||
entityConfiguration
|
||||
);
|
||||
await genericCacher.invalidateManyAsync([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,169 +0,0 @@
|
||||
import { CacheStatus, UUIDField, EntityConfiguration } from '@expo/entity';
|
||||
import { Redis, Pipeline } from 'ioredis';
|
||||
import { mock, when, instance, anything, verify } from 'ts-mockito';
|
||||
|
||||
import RedisCacheAdapter from '../RedisCacheAdapter';
|
||||
|
||||
type BlahFields = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
const entityConfiguration = new EntityConfiguration<BlahFields>({
|
||||
idField: 'id',
|
||||
tableName: 'blah',
|
||||
schema: {
|
||||
id: new UUIDField({ columnName: 'id', cache: true }),
|
||||
},
|
||||
databaseAdapterFlavor: 'postgres',
|
||||
cacheAdapterFlavor: 'redis',
|
||||
});
|
||||
|
||||
describe(RedisCacheAdapter, () => {
|
||||
describe('loadManyAsync', () => {
|
||||
it('returns appropriate cache results', async () => {
|
||||
const redisResults = new Map();
|
||||
|
||||
const mockRedisClient = mock<Redis>();
|
||||
|
||||
// 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)
|
||||
);
|
||||
|
||||
const cacheAdapter = new RedisCacheAdapter(entityConfiguration, {
|
||||
redisClient: instance(mockRedisClient),
|
||||
makeKeyFn: (...parts) => parts.join(':'),
|
||||
cacheKeyVersion: 1,
|
||||
cacheKeyPrefix: 'hello-',
|
||||
ttlSecondsPositive: 1,
|
||||
ttlSecondsNegative: 2,
|
||||
});
|
||||
|
||||
redisResults.set(cacheAdapter['makeCacheKey']('id', 'wat'), JSON.stringify({ id: 'wat' }));
|
||||
redisResults.set(cacheAdapter['makeCacheKey']('id', 'who'), '');
|
||||
|
||||
const results = await cacheAdapter.loadManyAsync('id', ['wat', 'who', 'why']);
|
||||
|
||||
expect(results.get('wat')).toMatchObject({ status: CacheStatus.HIT, item: { id: 'wat' } });
|
||||
expect(results.get('who')).toMatchObject({ status: CacheStatus.NEGATIVE });
|
||||
expect(results.get('why')).toMatchObject({ status: CacheStatus.MISS });
|
||||
expect(results.size).toBe(3);
|
||||
});
|
||||
|
||||
it('returns empty map when passed empty array of fieldValues', async () => {
|
||||
const cacheAdapter = new RedisCacheAdapter(entityConfiguration, {
|
||||
redisClient: instance(mock<Redis>()),
|
||||
makeKeyFn: (...parts) => parts.join(':'),
|
||||
cacheKeyVersion: 1,
|
||||
cacheKeyPrefix: 'hello-',
|
||||
ttlSecondsPositive: 1,
|
||||
ttlSecondsNegative: 2,
|
||||
});
|
||||
const results = await cacheAdapter.loadManyAsync('id', []);
|
||||
expect(results).toEqual(new Map());
|
||||
});
|
||||
});
|
||||
|
||||
describe('cacheManyAsync', () => {
|
||||
it('correctly caches all objects', async () => {
|
||||
const redisResults = new Map();
|
||||
|
||||
const mockPipeline = mock<Pipeline>();
|
||||
when(mockPipeline.set(anything(), anything(), anything(), anything())).thenCall(
|
||||
(key, value, code, ttl) => {
|
||||
redisResults.set(key, { value, code, ttl });
|
||||
return pipeline;
|
||||
}
|
||||
);
|
||||
when(mockPipeline.exec()).thenResolve({} as any);
|
||||
const pipeline = instance(mockPipeline);
|
||||
|
||||
const mockRedisClient = mock<Redis>();
|
||||
when(mockRedisClient.multi()).thenReturn(pipeline);
|
||||
|
||||
const cacheAdapter = new RedisCacheAdapter(entityConfiguration, {
|
||||
redisClient: instance(mockRedisClient),
|
||||
makeKeyFn: (...parts) => parts.join(':'),
|
||||
cacheKeyVersion: 1,
|
||||
cacheKeyPrefix: 'hello-',
|
||||
ttlSecondsPositive: 1,
|
||||
ttlSecondsNegative: 2,
|
||||
});
|
||||
await cacheAdapter.cacheManyAsync('id', new Map([['wat', { id: 'wat' }]]));
|
||||
|
||||
const cacheKey = cacheAdapter['makeCacheKey']('id', 'wat');
|
||||
expect(redisResults.get(cacheKey)).toMatchObject({
|
||||
value: JSON.stringify({ id: 'wat' }),
|
||||
code: 'EX',
|
||||
ttl: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('cacheDBMissesAsync', () => {
|
||||
it('correctly caches misses', async () => {
|
||||
const redisResults = new Map();
|
||||
const mockPipeline = mock<Pipeline>();
|
||||
when(mockPipeline.set(anything(), anything(), anything(), anything())).thenCall(
|
||||
(key, value, code, ttl) => {
|
||||
redisResults.set(key, { value, code, ttl });
|
||||
return pipeline;
|
||||
}
|
||||
);
|
||||
when(mockPipeline.exec()).thenResolve({} as any);
|
||||
const pipeline = instance(mockPipeline);
|
||||
|
||||
const mockRedisClient = mock<Redis>();
|
||||
when(mockRedisClient.multi()).thenReturn(pipeline);
|
||||
|
||||
const cacheAdapter = new RedisCacheAdapter(entityConfiguration, {
|
||||
redisClient: instance(mockRedisClient),
|
||||
makeKeyFn: (...parts) => parts.join(':'),
|
||||
cacheKeyVersion: 1,
|
||||
cacheKeyPrefix: 'hello-',
|
||||
ttlSecondsPositive: 1,
|
||||
ttlSecondsNegative: 2,
|
||||
});
|
||||
await cacheAdapter.cacheDBMissesAsync('id', ['wat']);
|
||||
|
||||
const cacheKey = cacheAdapter['makeCacheKey']('id', 'wat');
|
||||
expect(redisResults.get(cacheKey)).toMatchObject({
|
||||
value: '',
|
||||
code: 'EX',
|
||||
ttl: 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalidateManyAsync', () => {
|
||||
it('invalidates correctly', async () => {
|
||||
const mockRedisClient = mock<Redis>();
|
||||
when(mockRedisClient.del()).thenResolve(1);
|
||||
|
||||
const cacheAdapter = new RedisCacheAdapter(entityConfiguration, {
|
||||
redisClient: instance(mockRedisClient),
|
||||
makeKeyFn: (...parts) => parts.join(':'),
|
||||
cacheKeyVersion: 1,
|
||||
cacheKeyPrefix: 'hello-',
|
||||
ttlSecondsPositive: 1,
|
||||
ttlSecondsNegative: 2,
|
||||
});
|
||||
await cacheAdapter.invalidateManyAsync('id', ['wat']);
|
||||
|
||||
const cacheKey = cacheAdapter['makeCacheKey']('id', 'wat');
|
||||
verify(mockRedisClient.del(cacheKey)).once();
|
||||
});
|
||||
|
||||
it('returns when passed empty array of fieldValues', async () => {
|
||||
const cacheAdapter = new RedisCacheAdapter(entityConfiguration, {
|
||||
redisClient: instance(mock<Redis>()),
|
||||
makeKeyFn: (...parts) => parts.join(':'),
|
||||
cacheKeyVersion: 1,
|
||||
cacheKeyPrefix: 'hello-',
|
||||
ttlSecondsPositive: 1,
|
||||
ttlSecondsNegative: 2,
|
||||
});
|
||||
await cacheAdapter.invalidateManyAsync('id', []);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,8 +4,6 @@
|
||||
* @module @expo/entity-cache-adapter-redis
|
||||
*/
|
||||
|
||||
export { default as RedisCacheAdapter } from './RedisCacheAdapter';
|
||||
export * from './RedisCacheAdapter';
|
||||
export { default as RedisCacheAdapterProvider } from './RedisCacheAdapterProvider';
|
||||
export * from './RedisCommon';
|
||||
export { default as GenericRedisCacher } from './GenericRedisCacher';
|
||||
|
||||
@@ -17,14 +17,18 @@ export type RedisTestEntityFields = {
|
||||
};
|
||||
|
||||
export default class RedisTestEntity extends Entity<RedisTestEntityFields, string, ViewerContext> {
|
||||
static getCompanionDefinition(): EntityCompanionDefinition<
|
||||
static defineCompanionDefinition(): EntityCompanionDefinition<
|
||||
RedisTestEntityFields,
|
||||
string,
|
||||
ViewerContext,
|
||||
RedisTestEntity,
|
||||
RedisTestEntityPrivacyPolicy
|
||||
> {
|
||||
return redisTestEntityCompanionDefinition;
|
||||
return {
|
||||
entityClass: RedisTestEntity,
|
||||
entityConfiguration: redisTestEntityConfiguration,
|
||||
privacyPolicyClass: RedisTestEntityPrivacyPolicy,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,9 +91,3 @@ export const redisTestEntityConfiguration = new EntityConfiguration<RedisTestEnt
|
||||
databaseAdapterFlavor: 'postgres',
|
||||
cacheAdapterFlavor: 'redis',
|
||||
});
|
||||
|
||||
const redisTestEntityCompanionDefinition = new EntityCompanionDefinition({
|
||||
entityClass: RedisTestEntity,
|
||||
entityConfiguration: redisTestEntityConfiguration,
|
||||
privacyPolicyClass: RedisTestEntityPrivacyPolicy,
|
||||
});
|
||||
+3
-3
@@ -6,14 +6,14 @@ import {
|
||||
StubDatabaseAdapterProvider,
|
||||
} from '@expo/entity';
|
||||
|
||||
import { RedisCacheAdapterContext } from '../RedisCacheAdapter';
|
||||
import { GenericRedisCacheContext } from '../GenericRedisCacher';
|
||||
import RedisCacheAdapterProvider from '../RedisCacheAdapterProvider';
|
||||
|
||||
// share across all in calls in test to simulate postgres
|
||||
const adapterProvider = new StubDatabaseAdapterProvider();
|
||||
|
||||
export const createRedisIntegrationTestEntityCompanionProvider = (
|
||||
redisCacheAdapterContext: RedisCacheAdapterContext,
|
||||
genericRedisCacheContext: GenericRedisCacheContext,
|
||||
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter()
|
||||
): EntityCompanionProvider => {
|
||||
return new EntityCompanionProvider(
|
||||
@@ -31,7 +31,7 @@ export const createRedisIntegrationTestEntityCompanionProvider = (
|
||||
[
|
||||
'redis',
|
||||
{
|
||||
cacheAdapterProvider: new RedisCacheAdapterProvider(redisCacheAdapterContext),
|
||||
cacheAdapterProvider: new RedisCacheAdapterProvider(genericRedisCacheContext),
|
||||
},
|
||||
],
|
||||
])
|
||||
|
||||
@@ -3,6 +3,56 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [0.35.0](https://github.com/expo/entity/compare/v0.34.0...v0.35.0) (2024-04-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Revert: require explicit query context specification ([#219](https://github.com/expo/entity/issues/219)) ([1bdfbd6](https://github.com/expo/entity/commit/1bdfbd6b562d1200e4029df0533b5adb2d917831))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.34.0](https://github.com/expo/entity/compare/v0.33.0...v0.34.0) (2024-04-11)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* require explicit query context specification ([#219](https://github.com/expo/entity/issues/219)) ([8b0b31f](https://github.com/expo/entity/commit/8b0b31fdde5bd565aa527719003ef283a45f55cc))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.33.0](https://github.com/expo/entity/compare/v0.32.0...v0.33.0) (2023-10-06)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-database-adapter-knex
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.32.0](https://github.com/expo/entity/compare/v0.31.1...v0.32.0) (2023-06-10)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add ability to specify knex transaction config ([#207](https://github.com/expo/entity/issues/207)) ([2069a0d](https://github.com/expo/entity/commit/2069a0d9fd1d8f7d090a71a90a758b2429ea5dd3))
|
||||
* entity companion definition static thunk ([#210](https://github.com/expo/entity/issues/210)) ([4b18010](https://github.com/expo/entity/commit/4b18010d42be50ef329f428b08330e21bf676586))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.31.0](https://github.com/expo/entity/compare/v0.30.0...v0.31.0) (2023-01-19)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-database-adapter-knex
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.30.0](https://github.com/expo/entity/compare/v0.29.0...v0.30.0) (2022-12-23)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-database-adapter-knex
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/entity-database-adapter-knex",
|
||||
"version": "0.30.0",
|
||||
"version": "0.35.0",
|
||||
"description": "Knex database adapter for @expo/entity",
|
||||
"files": [
|
||||
"build",
|
||||
@@ -19,7 +19,7 @@
|
||||
"barrelsby": "barrelsby --directory src --location top --exclude tests__ --singleQuotes --exportDefault --delete"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=16"
|
||||
},
|
||||
"keywords": [
|
||||
"entity"
|
||||
@@ -30,10 +30,10 @@
|
||||
"@expo/entity": "*"
|
||||
},
|
||||
"dependencies": {
|
||||
"knex": "^2.3.0"
|
||||
"knex": "^2.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/entity": "^0.30.0",
|
||||
"pg": "^8.7.1"
|
||||
"@expo/entity": "^0.35.0",
|
||||
"pg": "8.10.0"
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
import { EntityQueryContextProvider } from '@expo/entity';
|
||||
import {
|
||||
EntityQueryContextProvider,
|
||||
TransactionConfig,
|
||||
TransactionIsolationLevel,
|
||||
} from '@expo/entity';
|
||||
import { Knex } from 'knex';
|
||||
|
||||
/**
|
||||
@@ -13,10 +17,16 @@ export default class PostgresEntityQueryContextProvider extends EntityQueryConte
|
||||
return this.knexInstance;
|
||||
}
|
||||
|
||||
protected createTransactionRunner<T>(): (
|
||||
transactionScope: (trx: any) => Promise<T>
|
||||
) => Promise<T> {
|
||||
return (transactionScope) => this.knexInstance.transaction(transactionScope);
|
||||
protected createTransactionRunner<T>(
|
||||
transactionConfig?: TransactionConfig
|
||||
): (transactionScope: (trx: any) => Promise<T>) => Promise<T> {
|
||||
return (transactionScope) =>
|
||||
this.knexInstance.transaction(
|
||||
transactionScope,
|
||||
transactionConfig
|
||||
? PostgresEntityQueryContextProvider.convertTransactionConfig(transactionConfig)
|
||||
: undefined
|
||||
);
|
||||
}
|
||||
|
||||
protected createNestedTransactionRunner<T>(
|
||||
@@ -24,4 +34,27 @@ export default class PostgresEntityQueryContextProvider extends EntityQueryConte
|
||||
): (transactionScope: (queryInterface: any) => Promise<T>) => Promise<T> {
|
||||
return (transactionScope) => (outerQueryInterface as Knex).transaction(transactionScope);
|
||||
}
|
||||
|
||||
private static convertTransactionConfig(
|
||||
transactionConfig: TransactionConfig
|
||||
): Knex.TransactionConfig {
|
||||
const convertIsolationLevel = (
|
||||
isolationLevel: TransactionIsolationLevel
|
||||
): Knex.IsolationLevels => {
|
||||
switch (isolationLevel) {
|
||||
case TransactionIsolationLevel.READ_COMMITTED:
|
||||
return 'read committed';
|
||||
case TransactionIsolationLevel.REPEATABLE_READ:
|
||||
return 'repeatable read';
|
||||
case TransactionIsolationLevel.SERIALIZABLE:
|
||||
return 'serializable';
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
...(transactionConfig.isolationLevel
|
||||
? { isolationLevel: convertIsolationLevel(transactionConfig.isolationLevel) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
+39
@@ -3,6 +3,7 @@ import {
|
||||
createUnitTestEntityCompanionProvider,
|
||||
enforceResultsAsync,
|
||||
ViewerContext,
|
||||
TransactionIsolationLevel,
|
||||
} from '@expo/entity';
|
||||
import { enforceAsyncResult } from '@expo/results';
|
||||
import { knex, Knex } from 'knex';
|
||||
@@ -111,6 +112,44 @@ describe('postgres entity integration', () => {
|
||||
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 }
|
||||
);
|
||||
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'),
|
||||
]);
|
||||
|
||||
expect(results.filter((r) => (r.error as any)?.cause?.code === '40001').length > 0).toBe(true);
|
||||
});
|
||||
|
||||
describe('JSON fields', () => {
|
||||
it('supports both types of array fields', async () => {
|
||||
const vc1 = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance));
|
||||
|
||||
@@ -27,21 +27,24 @@ export default class ErrorsTestEntity extends Entity<
|
||||
number,
|
||||
ViewerContext
|
||||
> {
|
||||
static getCompanionDefinition(): EntityCompanionDefinition<
|
||||
static defineCompanionDefinition(): EntityCompanionDefinition<
|
||||
ErrorsTestEntityFields,
|
||||
number,
|
||||
ViewerContext,
|
||||
ErrorsTestEntity,
|
||||
ErrorsTestEntityPrivacyPolicy
|
||||
> {
|
||||
return errorsTestEntityCompanionDefinition;
|
||||
return {
|
||||
entityClass: ErrorsTestEntity,
|
||||
entityConfiguration: ErrorsTestEntityConfiguration,
|
||||
privacyPolicyClass: ErrorsTestEntityPrivacyPolicy,
|
||||
};
|
||||
}
|
||||
|
||||
public static async createOrTruncatePostgresTable(knex: Knex): Promise<void> {
|
||||
await knex.raw('CREATE EXTENSION IF NOT EXISTS "btree_gist"'); // for gist exclusion on varchar
|
||||
|
||||
const tableName = this.getCompanionDefinition().entityConfiguration.tableName;
|
||||
|
||||
const tableName = 'postgres_test_entities';
|
||||
const hasForeignTable = await knex.schema.hasTable(foreignTableName);
|
||||
if (!hasForeignTable) {
|
||||
await knex.schema.createTable(foreignTableName, (table) => {
|
||||
@@ -87,7 +90,7 @@ export default class ErrorsTestEntity extends Entity<
|
||||
}
|
||||
|
||||
public static async dropPostgresTable(knex: Knex): Promise<void> {
|
||||
const tableName = this.getCompanionDefinition().entityConfiguration.tableName;
|
||||
const tableName = 'postgres_test_entities';
|
||||
const hasTable = await knex.schema.hasTable(tableName);
|
||||
if (hasTable) {
|
||||
await knex.schema.dropTable(tableName);
|
||||
@@ -169,9 +172,3 @@ export const ErrorsTestEntityConfiguration = new EntityConfiguration<ErrorsTestE
|
||||
databaseAdapterFlavor: 'postgres',
|
||||
cacheAdapterFlavor: 'redis',
|
||||
});
|
||||
|
||||
const errorsTestEntityCompanionDefinition = new EntityCompanionDefinition({
|
||||
entityClass: ErrorsTestEntity,
|
||||
entityConfiguration: ErrorsTestEntityConfiguration,
|
||||
privacyPolicyClass: ErrorsTestEntityPrivacyPolicy,
|
||||
});
|
||||
@@ -20,18 +20,22 @@ export default class InvalidTestEntity extends Entity<
|
||||
number,
|
||||
ViewerContext
|
||||
> {
|
||||
static getCompanionDefinition(): EntityCompanionDefinition<
|
||||
static defineCompanionDefinition(): EntityCompanionDefinition<
|
||||
InvalidTestEntityFields,
|
||||
number,
|
||||
ViewerContext,
|
||||
InvalidTestEntity,
|
||||
InvalidTestEntityPrivacyPolicy
|
||||
> {
|
||||
return invalidTestEntityCompanionDefinition;
|
||||
return {
|
||||
entityClass: InvalidTestEntity,
|
||||
entityConfiguration: invalidTestEntityConfiguration,
|
||||
privacyPolicyClass: InvalidTestEntityPrivacyPolicy,
|
||||
};
|
||||
}
|
||||
|
||||
public static async createOrTruncatePostgresTable(knex: Knex): Promise<void> {
|
||||
const tableName = this.getCompanionDefinition().entityConfiguration.tableName;
|
||||
const tableName = 'postgres_test_entities';
|
||||
const hasTable = await knex.schema.hasTable(tableName);
|
||||
if (!hasTable) {
|
||||
await knex.schema.createTable(tableName, (table) => {
|
||||
@@ -43,7 +47,7 @@ export default class InvalidTestEntity extends Entity<
|
||||
}
|
||||
|
||||
public static async dropPostgresTable(knex: Knex): Promise<void> {
|
||||
const tableName = this.getCompanionDefinition().entityConfiguration.tableName;
|
||||
const tableName = 'postgres_test_entities';
|
||||
const hasTable = await knex.schema.hasTable(tableName);
|
||||
if (hasTable) {
|
||||
await knex.schema.dropTable(tableName);
|
||||
@@ -105,9 +109,3 @@ export const invalidTestEntityConfiguration = new EntityConfiguration<InvalidTes
|
||||
databaseAdapterFlavor: 'postgres',
|
||||
cacheAdapterFlavor: 'redis',
|
||||
});
|
||||
|
||||
const invalidTestEntityCompanionDefinition = new EntityCompanionDefinition({
|
||||
entityClass: InvalidTestEntity,
|
||||
entityConfiguration: invalidTestEntityConfiguration,
|
||||
privacyPolicyClass: InvalidTestEntityPrivacyPolicy,
|
||||
});
|
||||
@@ -36,18 +36,22 @@ export default class PostgresTestEntity extends Entity<
|
||||
string,
|
||||
ViewerContext
|
||||
> {
|
||||
static getCompanionDefinition(): EntityCompanionDefinition<
|
||||
static defineCompanionDefinition(): EntityCompanionDefinition<
|
||||
PostgresTestEntityFields,
|
||||
string,
|
||||
ViewerContext,
|
||||
PostgresTestEntity,
|
||||
PostgresTestEntityPrivacyPolicy
|
||||
> {
|
||||
return postgresTestEntityCompanionDefinition;
|
||||
return {
|
||||
entityClass: PostgresTestEntity,
|
||||
entityConfiguration: postgresTestEntityConfiguration,
|
||||
privacyPolicyClass: PostgresTestEntityPrivacyPolicy,
|
||||
};
|
||||
}
|
||||
|
||||
public static async createOrTruncatePostgresTable(knex: Knex): Promise<void> {
|
||||
const tableName = this.getCompanionDefinition().entityConfiguration.tableName;
|
||||
const tableName = 'postgres_test_entities';
|
||||
const hasTable = await knex.schema.hasTable(tableName);
|
||||
if (!hasTable) {
|
||||
await knex.schema.createTable(tableName, (table) => {
|
||||
@@ -67,7 +71,7 @@ export default class PostgresTestEntity extends Entity<
|
||||
}
|
||||
|
||||
public static async dropPostgresTable(knex: Knex): Promise<void> {
|
||||
const tableName = this.getCompanionDefinition().entityConfiguration.tableName;
|
||||
const tableName = 'postgres_test_entities';
|
||||
const hasTable = await knex.schema.hasTable(tableName);
|
||||
if (hasTable) {
|
||||
await knex.schema.dropTable(tableName);
|
||||
@@ -154,9 +158,3 @@ export const postgresTestEntityConfiguration = new EntityConfiguration<PostgresT
|
||||
databaseAdapterFlavor: 'postgres',
|
||||
cacheAdapterFlavor: 'redis',
|
||||
});
|
||||
|
||||
const postgresTestEntityCompanionDefinition = new EntityCompanionDefinition({
|
||||
entityClass: PostgresTestEntity,
|
||||
entityConfiguration: postgresTestEntityConfiguration,
|
||||
privacyPolicyClass: PostgresTestEntityPrivacyPolicy,
|
||||
});
|
||||
+19
-21
@@ -24,18 +24,33 @@ export default class PostgresTriggerTestEntity extends Entity<
|
||||
string,
|
||||
ViewerContext
|
||||
> {
|
||||
static getCompanionDefinition(): EntityCompanionDefinition<
|
||||
static defineCompanionDefinition(): EntityCompanionDefinition<
|
||||
PostgresTriggerTestEntityFields,
|
||||
string,
|
||||
ViewerContext,
|
||||
PostgresTriggerTestEntity,
|
||||
PostgresTriggerTestEntityPrivacyPolicy
|
||||
> {
|
||||
return postgresTestEntityCompanionDefinition;
|
||||
return {
|
||||
entityClass: PostgresTriggerTestEntity,
|
||||
entityConfiguration: postgresTestEntityConfiguration,
|
||||
privacyPolicyClass: PostgresTriggerTestEntityPrivacyPolicy,
|
||||
mutationTriggers: {
|
||||
beforeCreate: [new ThrowConditionallyTrigger('name', 'beforeCreate')],
|
||||
afterCreate: [new ThrowConditionallyTrigger('name', 'afterCreate')],
|
||||
beforeUpdate: [new ThrowConditionallyTrigger('name', 'beforeUpdate')],
|
||||
afterUpdate: [new ThrowConditionallyTrigger('name', 'afterUpdate')],
|
||||
beforeDelete: [new ThrowConditionallyTrigger('name', 'beforeDelete')],
|
||||
afterDelete: [new ThrowConditionallyTrigger('name', 'afterDelete')],
|
||||
beforeAll: [new ThrowConditionallyTrigger('name', 'beforeAll')],
|
||||
afterAll: [new ThrowConditionallyTrigger('name', 'afterAll')],
|
||||
afterCommit: [new ThrowConditionallyNonTransactionalTrigger('name', 'afterCommit')],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public static async createOrTruncatePostgresTable(knex: Knex): Promise<void> {
|
||||
const tableName = this.getCompanionDefinition().entityConfiguration.tableName;
|
||||
const tableName = 'postgres_test_entities';
|
||||
const hasTable = await knex.schema.hasTable(tableName);
|
||||
if (!hasTable) {
|
||||
await knex.schema.createTable(tableName, (table) => {
|
||||
@@ -47,7 +62,7 @@ export default class PostgresTriggerTestEntity extends Entity<
|
||||
}
|
||||
|
||||
public static async dropPostgresTable(knex: Knex): Promise<void> {
|
||||
const tableName = this.getCompanionDefinition().entityConfiguration.tableName;
|
||||
const tableName = 'postgres_test_entities';
|
||||
const hasTable = await knex.schema.hasTable(tableName);
|
||||
if (hasTable) {
|
||||
await knex.schema.dropTable(tableName);
|
||||
@@ -158,20 +173,3 @@ export const postgresTestEntityConfiguration =
|
||||
databaseAdapterFlavor: 'postgres',
|
||||
cacheAdapterFlavor: 'redis',
|
||||
});
|
||||
|
||||
const postgresTestEntityCompanionDefinition = new EntityCompanionDefinition({
|
||||
entityClass: PostgresTriggerTestEntity,
|
||||
entityConfiguration: postgresTestEntityConfiguration,
|
||||
privacyPolicyClass: PostgresTriggerTestEntityPrivacyPolicy,
|
||||
mutationTriggers: () => ({
|
||||
beforeCreate: [new ThrowConditionallyTrigger('name', 'beforeCreate')],
|
||||
afterCreate: [new ThrowConditionallyTrigger('name', 'afterCreate')],
|
||||
beforeUpdate: [new ThrowConditionallyTrigger('name', 'beforeUpdate')],
|
||||
afterUpdate: [new ThrowConditionallyTrigger('name', 'afterUpdate')],
|
||||
beforeDelete: [new ThrowConditionallyTrigger('name', 'beforeDelete')],
|
||||
afterDelete: [new ThrowConditionallyTrigger('name', 'afterDelete')],
|
||||
beforeAll: [new ThrowConditionallyTrigger('name', 'beforeAll')],
|
||||
afterAll: [new ThrowConditionallyTrigger('name', 'afterAll')],
|
||||
afterCommit: [new ThrowConditionallyNonTransactionalTrigger('name', 'afterCommit')],
|
||||
}),
|
||||
});
|
||||
@@ -20,18 +20,22 @@ export default class PostgresUniqueTestEntity extends Entity<
|
||||
string,
|
||||
ViewerContext
|
||||
> {
|
||||
static getCompanionDefinition(): EntityCompanionDefinition<
|
||||
static defineCompanionDefinition(): EntityCompanionDefinition<
|
||||
PostgresUniqueTestEntityFields,
|
||||
string,
|
||||
ViewerContext,
|
||||
PostgresUniqueTestEntity,
|
||||
PostgresUniqueTestEntityPrivacyPolicy
|
||||
> {
|
||||
return postgresTestEntityCompanionDefinition;
|
||||
return {
|
||||
entityClass: PostgresUniqueTestEntity,
|
||||
entityConfiguration: postgresTestEntityConfiguration,
|
||||
privacyPolicyClass: PostgresUniqueTestEntityPrivacyPolicy,
|
||||
};
|
||||
}
|
||||
|
||||
public static async createOrTruncatePostgresTable(knex: Knex): Promise<void> {
|
||||
const tableName = this.getCompanionDefinition().entityConfiguration.tableName;
|
||||
const tableName = 'postgres_test_entities';
|
||||
const hasTable = await knex.schema.hasTable(tableName);
|
||||
if (!hasTable) {
|
||||
await knex.schema.createTable(tableName, (table) => {
|
||||
@@ -43,7 +47,7 @@ export default class PostgresUniqueTestEntity extends Entity<
|
||||
}
|
||||
|
||||
public static async dropPostgresTable(knex: Knex): Promise<void> {
|
||||
const tableName = this.getCompanionDefinition().entityConfiguration.tableName;
|
||||
const tableName = 'postgres_test_entities';
|
||||
const hasTable = await knex.schema.hasTable(tableName);
|
||||
if (hasTable) {
|
||||
await knex.schema.dropTable(tableName);
|
||||
@@ -107,9 +111,3 @@ export const postgresTestEntityConfiguration =
|
||||
databaseAdapterFlavor: 'postgres',
|
||||
cacheAdapterFlavor: 'redis',
|
||||
});
|
||||
|
||||
const postgresTestEntityCompanionDefinition = new EntityCompanionDefinition({
|
||||
entityClass: PostgresUniqueTestEntity,
|
||||
entityConfiguration: postgresTestEntityConfiguration,
|
||||
privacyPolicyClass: PostgresUniqueTestEntityPrivacyPolicy,
|
||||
});
|
||||
+9
-11
@@ -23,18 +23,23 @@ export default class PostgresValidatorTestEntity extends Entity<
|
||||
string,
|
||||
ViewerContext
|
||||
> {
|
||||
static getCompanionDefinition(): EntityCompanionDefinition<
|
||||
static defineCompanionDefinition(): EntityCompanionDefinition<
|
||||
PostgresValidatorTestEntityFields,
|
||||
string,
|
||||
ViewerContext,
|
||||
PostgresValidatorTestEntity,
|
||||
PostgresValidatorTestEntityPrivacyPolicy
|
||||
> {
|
||||
return postgresTestEntityCompanionDefinition;
|
||||
return {
|
||||
entityClass: PostgresValidatorTestEntity,
|
||||
entityConfiguration: postgresTestEntityConfiguration,
|
||||
privacyPolicyClass: PostgresValidatorTestEntityPrivacyPolicy,
|
||||
mutationValidators: [new ThrowConditionallyTrigger('name', 'beforeCreateAndBeforeUpdate')],
|
||||
};
|
||||
}
|
||||
|
||||
public static async createOrTruncatePostgresTable(knex: Knex): Promise<void> {
|
||||
const tableName = this.getCompanionDefinition().entityConfiguration.tableName;
|
||||
const tableName = 'postgres_test_entities';
|
||||
const hasTable = await knex.schema.hasTable(tableName);
|
||||
if (!hasTable) {
|
||||
await knex.schema.createTable(tableName, (table) => {
|
||||
@@ -46,7 +51,7 @@ export default class PostgresValidatorTestEntity extends Entity<
|
||||
}
|
||||
|
||||
public static async dropPostgresTable(knex: Knex): Promise<void> {
|
||||
const tableName = this.getCompanionDefinition().entityConfiguration.tableName;
|
||||
const tableName = 'postgres_test_entities';
|
||||
const hasTable = await knex.schema.hasTable(tableName);
|
||||
if (hasTable) {
|
||||
await knex.schema.dropTable(tableName);
|
||||
@@ -140,10 +145,3 @@ export const postgresTestEntityConfiguration =
|
||||
databaseAdapterFlavor: 'postgres',
|
||||
cacheAdapterFlavor: 'redis',
|
||||
});
|
||||
|
||||
const postgresTestEntityCompanionDefinition = new EntityCompanionDefinition({
|
||||
entityClass: PostgresValidatorTestEntity,
|
||||
entityConfiguration: postgresTestEntityConfiguration,
|
||||
privacyPolicyClass: PostgresValidatorTestEntityPrivacyPolicy,
|
||||
mutationValidators: () => [new ThrowConditionallyTrigger('name', 'beforeCreateAndBeforeUpdate')],
|
||||
});
|
||||
@@ -3,6 +3,56 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [0.35.0](https://github.com/expo/entity/compare/v0.34.0...v0.35.0) (2024-04-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Revert: require explicit query context specification ([#219](https://github.com/expo/entity/issues/219)) ([1bdfbd6](https://github.com/expo/entity/commit/1bdfbd6b562d1200e4029df0533b5adb2d917831))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.34.0](https://github.com/expo/entity/compare/v0.33.0...v0.34.0) (2024-04-11)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* require explicit query context specification ([#219](https://github.com/expo/entity/issues/219)) ([8b0b31f](https://github.com/expo/entity/commit/8b0b31fdde5bd565aa527719003ef283a45f55cc))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.33.0](https://github.com/expo/entity/compare/v0.32.0...v0.33.0) (2023-10-06)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-example
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.32.0](https://github.com/expo/entity/compare/v0.31.1...v0.32.0) (2023-06-10)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add ability to specify knex transaction config ([#207](https://github.com/expo/entity/issues/207)) ([2069a0d](https://github.com/expo/entity/commit/2069a0d9fd1d8f7d090a71a90a758b2429ea5dd3))
|
||||
* entity companion definition static thunk ([#210](https://github.com/expo/entity/issues/210)) ([4b18010](https://github.com/expo/entity/commit/4b18010d42be50ef329f428b08330e21bf676586))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.31.0](https://github.com/expo/entity/compare/v0.30.0...v0.31.0) (2023-01-19)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-example
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.30.0](https://github.com/expo/entity/compare/v0.29.0...v0.30.0) (2022-12-23)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-example
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@expo/entity-example",
|
||||
"private": true,
|
||||
"version": "0.30.0",
|
||||
"version": "0.35.0",
|
||||
"description": "An example integration of the @expo/entity framework",
|
||||
"scripts": {
|
||||
"tsc": "tsc",
|
||||
@@ -15,7 +15,7 @@
|
||||
"start": "tsnd --transpile-only --inspect --respawn --no-notify --async-stack-traces --throw-deprecation -- src/index.ts"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=16"
|
||||
},
|
||||
"keywords": [
|
||||
"entity"
|
||||
@@ -23,16 +23,20 @@
|
||||
"author": "Expo",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@expo/entity": "^0.30.0",
|
||||
"apollo-server-koa": "^2.16.1",
|
||||
"graphql": "^15.3.0",
|
||||
"koa": "^2.13.0",
|
||||
"koa-body": "^4.2.0",
|
||||
"koa-router": "^9.4.0"
|
||||
"@apollo/server": "^4.7.3",
|
||||
"@as-integrations/koa": "^1.1.1",
|
||||
"@expo/entity": "^0.35.0",
|
||||
"@koa/cors": "^4.0.0",
|
||||
"@koa/router": "^12.0.0",
|
||||
"graphql": "^16.6.0",
|
||||
"koa": "^2.14.2",
|
||||
"koa-bodyparser": "^4.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/koa": "^2.11.4",
|
||||
"@types/koa-router": "^7.4.1",
|
||||
"@types/koa": "^2.13.6",
|
||||
"@types/koa-bodyparser": "^4.3.10",
|
||||
"@types/koa__cors": "^4.0.0",
|
||||
"@types/koa__router": "^12.0.0",
|
||||
"@types/supertest": "^2.0.10",
|
||||
"supertest": "^4.0.2",
|
||||
"ts-node-dev": "^1.0.0-pre.60"
|
||||
|
||||
@@ -2,10 +2,11 @@ import http from 'http';
|
||||
import request from 'supertest';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import app from '../app';
|
||||
import createAppAsync from '../app';
|
||||
|
||||
describe('graphql', () => {
|
||||
it('allows CRUD of user notes', async () => {
|
||||
const app = await createAppAsync();
|
||||
const server = request(http.createServer(app.callback()));
|
||||
|
||||
const userId = uuidv4();
|
||||
@@ -117,6 +118,7 @@ describe('graphql', () => {
|
||||
});
|
||||
|
||||
it('disallows anonymous note creation', async () => {
|
||||
const app = await createAppAsync();
|
||||
const server = request(http.createServer(app.callback()));
|
||||
const createResponse1 = await server.post('/graphql').send({
|
||||
query: `mutation($note: NoteInput!) { addNote(note: $note) { id, title, body } }`,
|
||||
@@ -133,6 +135,7 @@ describe('graphql', () => {
|
||||
});
|
||||
|
||||
it('disallows cross-user note impersonation', async () => {
|
||||
const app = await createAppAsync();
|
||||
const server = request(http.createServer(app.callback()));
|
||||
|
||||
const userId = uuidv4();
|
||||
|
||||
@@ -2,15 +2,17 @@ import http from 'http';
|
||||
import request from 'supertest';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import app from '../app';
|
||||
import createAppAsync from '../app';
|
||||
|
||||
describe('notesRouter', () => {
|
||||
it('returns empty array for logged-out', async () => {
|
||||
const app = await createAppAsync();
|
||||
const response = await request(http.createServer(app.callback())).get('/notes/').send();
|
||||
expect(response.body.notes).toEqual([]);
|
||||
});
|
||||
|
||||
it('allows CRUD of user notes', async () => {
|
||||
const app = await createAppAsync();
|
||||
const server = request(http.createServer(app.callback()));
|
||||
|
||||
const userId = uuidv4();
|
||||
@@ -84,6 +86,7 @@ describe('notesRouter', () => {
|
||||
});
|
||||
|
||||
it('disallows anonymous note creation', async () => {
|
||||
const app = await createAppAsync();
|
||||
const server = request(http.createServer(app.callback()));
|
||||
const createResponse1 = await server.post('/notes/').send({
|
||||
title: 'track',
|
||||
@@ -94,6 +97,7 @@ describe('notesRouter', () => {
|
||||
});
|
||||
|
||||
it('disallows cross-user note impersonation', async () => {
|
||||
const app = await createAppAsync();
|
||||
const server = request(http.createServer(app.callback()));
|
||||
const userId = uuidv4();
|
||||
const userId2 = uuidv4();
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { EntityQueryContextProvider } from '@expo/entity';
|
||||
import { EntityQueryContextProvider, TransactionConfig } from '@expo/entity';
|
||||
|
||||
export default class InMemoryQueryContextProvider extends EntityQueryContextProvider {
|
||||
protected getQueryInterface(): any {
|
||||
return {};
|
||||
}
|
||||
|
||||
protected createTransactionRunner<T>(): (
|
||||
transactionScope: (queryInterface: any) => Promise<T>
|
||||
) => Promise<T> {
|
||||
protected createTransactionRunner<T>(
|
||||
_transactionConfig?: TransactionConfig
|
||||
): (transactionScope: (queryInterface: any) => Promise<T>) => Promise<T> {
|
||||
return (transactionScope) => Promise.resolve(transactionScope({}));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { ApolloServer } from '@apollo/server';
|
||||
import { koaMiddleware } from '@as-integrations/koa';
|
||||
import { EntityCompanionProvider } from '@expo/entity';
|
||||
import { ApolloServer } from 'apollo-server-koa';
|
||||
import cors from '@koa/cors';
|
||||
import Router from '@koa/router';
|
||||
import Koa from 'koa';
|
||||
import koaBody from 'koa-body';
|
||||
import KoaRouter, { RouterContext } from 'koa-router';
|
||||
import bodyParser from 'koa-bodyparser';
|
||||
|
||||
import { entityCompanionMiddleware, viewerContextMiddleware } from './middleware';
|
||||
import notesRouter from './routers/notesRouter';
|
||||
import { typeDefs, resolvers } from './schema';
|
||||
import { ExampleViewerContext } from './viewerContexts';
|
||||
|
||||
export type ExampleContext = RouterContext<ExampleState>;
|
||||
export type ExampleContext = Koa.ParameterizedContext<ExampleState>;
|
||||
|
||||
/**
|
||||
* Koa provides a per-request state container to place custom properties.
|
||||
@@ -21,31 +23,40 @@ export type ExampleState = {
|
||||
entityCompanionProvider: EntityCompanionProvider; // entityCompanionMiddleware
|
||||
};
|
||||
|
||||
const app = new Koa();
|
||||
export default async function createAppAsync(): Promise<Koa<ExampleState, ExampleContext>> {
|
||||
const app = new Koa<ExampleState, ExampleContext>();
|
||||
|
||||
// body parsing for POST/PUT requests
|
||||
app.use(koaBody());
|
||||
// initialze the entity framework for each request
|
||||
app.use(entityCompanionMiddleware);
|
||||
|
||||
// initialze the entity framework for each request
|
||||
app.use(entityCompanionMiddleware);
|
||||
// generate a viewer context for each request
|
||||
app.use(viewerContextMiddleware);
|
||||
|
||||
// generate a viewer context for each request
|
||||
app.use(viewerContextMiddleware);
|
||||
app.use(cors());
|
||||
|
||||
// serve routes that make use of the viewer context
|
||||
const router = new KoaRouter<ExampleState>();
|
||||
router.use(notesRouter.routes());
|
||||
app.use(router.routes()).use(router.allowedMethods());
|
||||
// body parsing for POST/PUT requests
|
||||
app.use(bodyParser());
|
||||
|
||||
const server = new ApolloServer({
|
||||
typeDefs,
|
||||
resolvers,
|
||||
context: ({ ctx }) => {
|
||||
return {
|
||||
viewerContext: ctx.state.viewerContext,
|
||||
};
|
||||
},
|
||||
});
|
||||
app.use(server.getMiddleware({ cors: false, disableHealthCheck: true, bodyParserConfig: false }));
|
||||
// serve routes that make use of the viewer context
|
||||
const router = new Router<ExampleState, ExampleContext>();
|
||||
|
||||
export default app;
|
||||
// normal API routes
|
||||
router.use(notesRouter.routes());
|
||||
|
||||
// GraphQL routes
|
||||
const server = new ApolloServer({
|
||||
typeDefs,
|
||||
resolvers,
|
||||
});
|
||||
await server.start();
|
||||
router.post(
|
||||
'/graphql',
|
||||
koaMiddleware(server, {
|
||||
context: async ({ ctx }) => ({ viewerContext: (ctx as ExampleContext).state.viewerContext }),
|
||||
})
|
||||
);
|
||||
|
||||
app.use(router.routes()).use(router.allowedMethods());
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import {
|
||||
PrivacyPolicyRule,
|
||||
ReadonlyEntity,
|
||||
EntityQueryContext,
|
||||
RuleEvaluationResult,
|
||||
EntityPrivacyPolicyEvaluationContext,
|
||||
RuleComplexity,
|
||||
AllowOrSkipPrivacyPolicyRule,
|
||||
} from '@expo/entity';
|
||||
|
||||
import { ExampleViewerContext } from '../viewerContexts';
|
||||
@@ -23,21 +24,23 @@ import { ExampleViewerContext } from '../viewerContexts';
|
||||
* Otherwise, it defers to the next rule in the policy.
|
||||
*/
|
||||
export default class AllowIfUserOwnerPrivacyRule<
|
||||
TFields,
|
||||
TFields extends object,
|
||||
TID extends NonNullable<TFields[TSelectedFields]>,
|
||||
TEntity extends ReadonlyEntity<TFields, TID, ExampleViewerContext>,
|
||||
TSelectedFields extends keyof TFields = keyof TFields
|
||||
> extends PrivacyPolicyRule<TFields, TID, ExampleViewerContext, TEntity> {
|
||||
> extends AllowOrSkipPrivacyPolicyRule<TFields, TID, ExampleViewerContext, TEntity> {
|
||||
constructor(private readonly entityOwnerField: keyof TFields) {
|
||||
super();
|
||||
}
|
||||
|
||||
override complexity = RuleComplexity.LOW;
|
||||
|
||||
async evaluateAsync(
|
||||
viewerContext: ExampleViewerContext,
|
||||
_queryContext: EntityQueryContext,
|
||||
_evaluationContext: EntityPrivacyPolicyEvaluationContext,
|
||||
entity: TEntity
|
||||
): Promise<RuleEvaluationResult> {
|
||||
): Promise<RuleEvaluationResult.ALLOW | RuleEvaluationResult.SKIP> {
|
||||
if (viewerContext.isUserViewerContext()) {
|
||||
if (String(entity.getField(this.entityOwnerField)) === viewerContext.userID) {
|
||||
return RuleEvaluationResult.ALLOW;
|
||||
|
||||
@@ -6,8 +6,8 @@ import {
|
||||
StringField,
|
||||
} from '@expo/entity';
|
||||
|
||||
import { ExampleViewerContext } from '../viewerContexts';
|
||||
import NotePrivacyPolicy from './NotePrivacyPolicy';
|
||||
import { ExampleViewerContext } from '../viewerContexts';
|
||||
|
||||
export interface NoteFields {
|
||||
id: string;
|
||||
@@ -20,43 +20,41 @@ export interface NoteFields {
|
||||
* A simple entity representing a "notes" table/collection. Each note has an owner, title, and body.
|
||||
*/
|
||||
export default class NoteEntity extends Entity<NoteFields, string, ExampleViewerContext> {
|
||||
static getCompanionDefinition(): EntityCompanionDefinition<
|
||||
/**
|
||||
* The companion provides configuration instructions to the Entity framework for this type
|
||||
* of entity. In some languages, this would be representable as "abstract" static members
|
||||
* of the class itself, but TypeScript disallows static generic and abstract methods.
|
||||
*/
|
||||
static defineCompanionDefinition(): EntityCompanionDefinition<
|
||||
NoteFields,
|
||||
string,
|
||||
ExampleViewerContext,
|
||||
NoteEntity,
|
||||
NotePrivacyPolicy
|
||||
> {
|
||||
return noteEntityCompanion;
|
||||
return {
|
||||
entityClass: NoteEntity,
|
||||
entityConfiguration: new EntityConfiguration<NoteFields>({
|
||||
idField: 'id',
|
||||
tableName: 'notes',
|
||||
schema: {
|
||||
id: new UUIDField({
|
||||
columnName: 'id',
|
||||
}),
|
||||
userID: new UUIDField({
|
||||
columnName: 'user_id',
|
||||
}),
|
||||
title: new StringField({
|
||||
columnName: 'title',
|
||||
}),
|
||||
body: new StringField({
|
||||
columnName: 'body',
|
||||
}),
|
||||
},
|
||||
databaseAdapterFlavor: 'postgres',
|
||||
cacheAdapterFlavor: 'redis',
|
||||
}),
|
||||
privacyPolicyClass: NotePrivacyPolicy,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The companion provides configuration instructions to the Entity framework for this type
|
||||
* of entity. In some languages, this would be representable as "abstract" static members
|
||||
* of the class itself, but TypeScript disallows static generic and abstract methods.
|
||||
*/
|
||||
export const noteEntityCompanion = new EntityCompanionDefinition({
|
||||
entityClass: NoteEntity,
|
||||
entityConfiguration: new EntityConfiguration<NoteFields>({
|
||||
idField: 'id',
|
||||
tableName: 'notes',
|
||||
schema: {
|
||||
id: new UUIDField({
|
||||
columnName: 'id',
|
||||
}),
|
||||
userID: new UUIDField({
|
||||
columnName: 'user_id',
|
||||
}),
|
||||
title: new StringField({
|
||||
columnName: 'title',
|
||||
}),
|
||||
body: new StringField({
|
||||
columnName: 'body',
|
||||
}),
|
||||
},
|
||||
databaseAdapterFlavor: 'postgres',
|
||||
cacheAdapterFlavor: 'redis',
|
||||
}),
|
||||
privacyPolicyClass: NotePrivacyPolicy,
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
import { EntityPrivacyPolicy, AlwaysAllowPrivacyPolicyRule } from '@expo/entity';
|
||||
|
||||
import { ExampleViewerContext } from '../viewerContexts';
|
||||
import AllowIfUserOwnerPrivacyRule from './AllowIfUserOwnerPrivacyRule';
|
||||
import NoteEntity, { NoteFields } from './NoteEntity';
|
||||
import { ExampleViewerContext } from '../viewerContexts';
|
||||
|
||||
/**
|
||||
* For purposes of this demonstration, notes are considered public and can only be mutated by the owner.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import app from './app';
|
||||
import createAppAsync from './app';
|
||||
|
||||
app.listen(3000);
|
||||
async function main(): Promise<void> {
|
||||
const app = await createAppAsync();
|
||||
app.listen(3000);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,4 +1,4 @@
|
||||
import KoaRouter from 'koa-router';
|
||||
import Router from '@koa/router';
|
||||
|
||||
import { ExampleState, ExampleContext } from '../app';
|
||||
import NoteEntity from '../entities/NoteEntity';
|
||||
@@ -11,11 +11,11 @@ import NoteEntity from '../entities/NoteEntity';
|
||||
* PUT /notes/:id - update a note
|
||||
* DELETE /notes/:id - delete a note
|
||||
*/
|
||||
const router = new KoaRouter<ExampleState>({
|
||||
const router = new Router<ExampleState, ExampleContext>({
|
||||
prefix: '/notes',
|
||||
});
|
||||
|
||||
router.get('/', async (ctx: ExampleContext) => {
|
||||
router.get('/', async (ctx) => {
|
||||
const viewerContext = ctx.state.viewerContext;
|
||||
let notes: readonly NoteEntity[] = [];
|
||||
if (viewerContext.isUserViewerContext()) {
|
||||
@@ -28,11 +28,12 @@ router.get('/', async (ctx: ExampleContext) => {
|
||||
};
|
||||
});
|
||||
|
||||
router.get('/:id', async (ctx: ExampleContext) => {
|
||||
router.get('/:id', async (ctx) => {
|
||||
const viewerContext = ctx.state.viewerContext;
|
||||
const noteResult = await NoteEntity.loader(viewerContext).loadByIDAsync(ctx.params['id']!);
|
||||
if (!noteResult.ok) {
|
||||
ctx.throw(403, noteResult.reason);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.body = {
|
||||
@@ -40,16 +41,17 @@ router.get('/:id', async (ctx: ExampleContext) => {
|
||||
};
|
||||
});
|
||||
|
||||
router.post('/', async (ctx: ExampleContext) => {
|
||||
router.post('/', async (ctx) => {
|
||||
const viewerContext = ctx.state.viewerContext;
|
||||
if (!viewerContext.isUserViewerContext()) {
|
||||
// Only logged-in users can create notes. Technically this
|
||||
// would be handled by the privacy policy (it would fail)
|
||||
// but we throw here for better type safety.
|
||||
ctx.throw(403);
|
||||
return;
|
||||
}
|
||||
|
||||
const { title, body } = ctx.request.body;
|
||||
const { title, body } = ctx.request.body as any;
|
||||
|
||||
const createResult = await NoteEntity.creator(viewerContext)
|
||||
.setField('userID', viewerContext.userID)
|
||||
@@ -58,6 +60,7 @@ router.post('/', async (ctx: ExampleContext) => {
|
||||
.createAsync();
|
||||
if (!createResult.ok) {
|
||||
ctx.throw(403, createResult.reason);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.body = {
|
||||
@@ -65,13 +68,14 @@ router.post('/', async (ctx: ExampleContext) => {
|
||||
};
|
||||
});
|
||||
|
||||
router.put('/:id', async (ctx: ExampleContext) => {
|
||||
router.put('/:id', async (ctx) => {
|
||||
const viewerContext = ctx.state.viewerContext;
|
||||
const { title, body } = ctx.request.body;
|
||||
const { title, body } = ctx.request.body as any;
|
||||
|
||||
const noteLoadResult = await NoteEntity.loader(viewerContext).loadByIDAsync(ctx.params['id']!);
|
||||
if (!noteLoadResult.ok) {
|
||||
ctx.throw(403, noteLoadResult.reason);
|
||||
return;
|
||||
}
|
||||
|
||||
const noteUpdateResult = await NoteEntity.updater(noteLoadResult.value)
|
||||
@@ -80,6 +84,7 @@ router.put('/:id', async (ctx: ExampleContext) => {
|
||||
.updateAsync();
|
||||
if (!noteUpdateResult.ok) {
|
||||
ctx.throw(403, noteUpdateResult.reason);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.body = {
|
||||
@@ -87,17 +92,19 @@ router.put('/:id', async (ctx: ExampleContext) => {
|
||||
};
|
||||
});
|
||||
|
||||
router.delete('/:id', async (ctx: ExampleContext) => {
|
||||
router.delete('/:id', async (ctx) => {
|
||||
const viewerContext = ctx.state.viewerContext;
|
||||
|
||||
const noteLoadResult = await NoteEntity.loader(viewerContext).loadByIDAsync(ctx.params['id']!);
|
||||
if (!noteLoadResult.ok) {
|
||||
ctx.throw(403, noteLoadResult.reason);
|
||||
return;
|
||||
}
|
||||
|
||||
const noteDeleteResult = await NoteEntity.deleteAsync(noteLoadResult.value);
|
||||
if (!noteDeleteResult.ok) {
|
||||
ctx.throw(403, noteDeleteResult.reason);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.body = {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { gql, IResolvers, IResolverObject } from 'apollo-server-koa';
|
||||
import { IResolvers, IObjectTypeResolver } from '@graphql-tools/utils';
|
||||
|
||||
import NoteEntity from './entities/NoteEntity';
|
||||
import { ExampleViewerContext } from './viewerContexts';
|
||||
@@ -7,7 +7,7 @@ type GraphqlContext = {
|
||||
viewerContext: ExampleViewerContext;
|
||||
};
|
||||
|
||||
export const typeDefs = gql`
|
||||
export const typeDefs = `#graphql
|
||||
type User {
|
||||
id: ID!
|
||||
notes: [Note]!
|
||||
@@ -48,7 +48,7 @@ export const resolvers: IResolvers<any, GraphqlContext> = {
|
||||
async noteByID(_root, args, { viewerContext }) {
|
||||
return await NoteEntity.loader(viewerContext).enforcing().loadByIDAsync(args.id);
|
||||
},
|
||||
} as IResolverObject<any, GraphqlContext>,
|
||||
} as IObjectTypeResolver<any, GraphqlContext>,
|
||||
|
||||
User: {
|
||||
id: (root) => root,
|
||||
@@ -57,14 +57,14 @@ export const resolvers: IResolvers<any, GraphqlContext> = {
|
||||
.enforcing()
|
||||
.loadManyByFieldEqualingAsync('userID', root);
|
||||
},
|
||||
} as IResolverObject<string, GraphqlContext>,
|
||||
} as IObjectTypeResolver<string, GraphqlContext>,
|
||||
|
||||
Note: {
|
||||
id: (root) => root.getID(),
|
||||
title: (root) => root.getField('title'),
|
||||
body: (root) => root.getField('body'),
|
||||
user: (root) => root.getField('userID'),
|
||||
} as IResolverObject<NoteEntity, GraphqlContext>,
|
||||
} as IObjectTypeResolver<NoteEntity, GraphqlContext>,
|
||||
|
||||
Mutation: {
|
||||
async addNote(_root, args, { viewerContext }) {
|
||||
@@ -94,5 +94,5 @@ export const resolvers: IResolvers<any, GraphqlContext> = {
|
||||
await NoteEntity.enforceDeleteAsync(existingNote);
|
||||
return existingNote;
|
||||
},
|
||||
} as IResolverObject<any, GraphqlContext>,
|
||||
} as IObjectTypeResolver<any, GraphqlContext>,
|
||||
};
|
||||
@@ -3,6 +3,60 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [0.35.0](https://github.com/expo/entity/compare/v0.34.0...v0.35.0) (2024-04-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Revert: require explicit query context specification ([#219](https://github.com/expo/entity/issues/219)) ([1bdfbd6](https://github.com/expo/entity/commit/1bdfbd6b562d1200e4029df0533b5adb2d917831))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.34.0](https://github.com/expo/entity/compare/v0.33.0...v0.34.0) (2024-04-11)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* require explicit query context specification ([#219](https://github.com/expo/entity/issues/219)) ([8b0b31f](https://github.com/expo/entity/commit/8b0b31fdde5bd565aa527719003ef283a45f55cc))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.33.0](https://github.com/expo/entity/compare/v0.32.0...v0.33.0) (2023-10-06)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-full-integration-tests
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.32.0](https://github.com/expo/entity/compare/v0.31.1...v0.32.0) (2023-06-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* remove unused field from redis configuration ([#213](https://github.com/expo/entity/issues/213)) ([503a2fb](https://github.com/expo/entity/commit/503a2fb6a8d57f6a09f6da99daf5ab92fc3e7945))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* entity companion definition static thunk ([#210](https://github.com/expo/entity/issues/210)) ([4b18010](https://github.com/expo/entity/commit/4b18010d42be50ef329f428b08330e21bf676586))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.31.0](https://github.com/expo/entity/compare/v0.30.0...v0.31.0) (2023-01-19)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-full-integration-tests
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.30.0](https://github.com/expo/entity/compare/v0.29.0...v0.30.0) (2022-12-23)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-full-integration-tests
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@expo/entity-full-integration-tests",
|
||||
"private": true,
|
||||
"version": "0.30.0",
|
||||
"version": "0.35.0",
|
||||
"description": "Full redis and knex integration tests for the entity framework",
|
||||
"scripts": {
|
||||
"tsc": "tsc",
|
||||
@@ -14,7 +14,7 @@
|
||||
"barrelsby": "barrelsby --directory src --location top --exclude tests__ --singleQuotes --exportDefault --delete"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=16"
|
||||
},
|
||||
"keywords": [
|
||||
"entity"
|
||||
@@ -27,8 +27,8 @@
|
||||
"@expo/entity-database-adapter-knex": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/entity": "^0.30.0",
|
||||
"@expo/entity-cache-adapter-redis": "^0.30.0",
|
||||
"@expo/entity-database-adapter-knex": "^0.30.0"
|
||||
"@expo/entity": "^0.35.0",
|
||||
"@expo/entity-cache-adapter-redis": "^0.35.0",
|
||||
"@expo/entity-database-adapter-knex": "^0.35.0"
|
||||
}
|
||||
}
|
||||
+12
-15
@@ -8,7 +8,7 @@ import {
|
||||
UUIDField,
|
||||
StringField,
|
||||
} from '@expo/entity';
|
||||
import { RedisCacheAdapterContext } from '@expo/entity-cache-adapter-redis';
|
||||
import { GenericRedisCacheContext } from '@expo/entity-cache-adapter-redis';
|
||||
import Redis from 'ioredis';
|
||||
import { knex, Knex } from 'knex';
|
||||
import nullthrows from 'nullthrows';
|
||||
@@ -43,14 +43,18 @@ class TestEntityPrivacyPolicy extends EntityPrivacyPolicy<
|
||||
}
|
||||
|
||||
class TestEntity extends Entity<TestFields, string, ViewerContext> {
|
||||
static getCompanionDefinition(): EntityCompanionDefinition<
|
||||
static defineCompanionDefinition(): EntityCompanionDefinition<
|
||||
TestFields,
|
||||
string,
|
||||
ViewerContext,
|
||||
TestEntity,
|
||||
TestEntityPrivacyPolicy
|
||||
> {
|
||||
return testEntityCompanion;
|
||||
return {
|
||||
entityClass: TestEntity,
|
||||
entityConfiguration: testEntityConfiguration,
|
||||
privacyPolicyClass: TestEntityPrivacyPolicy,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,12 +78,6 @@ const testEntityConfiguration = new EntityConfiguration<TestFields>({
|
||||
cacheAdapterFlavor: 'redis',
|
||||
});
|
||||
|
||||
const testEntityCompanion = new EntityCompanionDefinition({
|
||||
entityClass: TestEntity,
|
||||
entityConfiguration: testEntityConfiguration,
|
||||
privacyPolicyClass: TestEntityPrivacyPolicy,
|
||||
});
|
||||
|
||||
async function createOrTruncatePostgresTables(knex: Knex): Promise<void> {
|
||||
await knex.schema.createTable('testentities', (table) => {
|
||||
table.uuid('id').defaultTo(knex.raw('gen_random_uuid()')).primary();
|
||||
@@ -98,7 +96,7 @@ async function dropPostgresTable(knex: Knex): Promise<void> {
|
||||
describe('Entity cache inconsistency', () => {
|
||||
let knexInstance: Knex;
|
||||
const redisClient = new Redis(new URL(process.env['REDIS_URL']!).toString());
|
||||
let redisCacheAdapterContext: RedisCacheAdapterContext;
|
||||
let genericRedisCacheContext: GenericRedisCacheContext;
|
||||
|
||||
beforeAll(() => {
|
||||
knexInstance = knex({
|
||||
@@ -111,7 +109,7 @@ describe('Entity cache inconsistency', () => {
|
||||
database: nullthrows(process.env['PGDATABASE']),
|
||||
},
|
||||
});
|
||||
redisCacheAdapterContext = {
|
||||
genericRedisCacheContext = {
|
||||
redisClient,
|
||||
makeKeyFn(...parts: string[]): string {
|
||||
const delimiter = ':';
|
||||
@@ -121,7 +119,6 @@ describe('Entity cache inconsistency', () => {
|
||||
return escapedParts.join(delimiter);
|
||||
},
|
||||
cacheKeyPrefix: 'test-',
|
||||
cacheKeyVersion: 1,
|
||||
ttlSecondsPositive: 86400, // 1 day
|
||||
ttlSecondsNegative: 600, // 10 minutes
|
||||
};
|
||||
@@ -140,7 +137,7 @@ describe('Entity cache inconsistency', () => {
|
||||
|
||||
test('lots of updates in long-ish running transactions', async () => {
|
||||
const viewerContext = new ViewerContext(
|
||||
createFullIntegrationTestEntityCompanionProvider(knexInstance, redisCacheAdapterContext)
|
||||
createFullIntegrationTestEntityCompanionProvider(knexInstance, genericRedisCacheContext)
|
||||
);
|
||||
|
||||
const entity1 = await TestEntity.creator(viewerContext)
|
||||
@@ -173,7 +170,7 @@ describe('Entity cache inconsistency', () => {
|
||||
await barrier1;
|
||||
|
||||
const viewerContextInternal = new ViewerContext(
|
||||
createFullIntegrationTestEntityCompanionProvider(knexInstance, redisCacheAdapterContext)
|
||||
createFullIntegrationTestEntityCompanionProvider(knexInstance, genericRedisCacheContext)
|
||||
);
|
||||
await TestEntity.loader(viewerContextInternal).enforcing().loadByIDAsync(entity1.getID());
|
||||
|
||||
@@ -196,7 +193,7 @@ describe('Entity cache inconsistency', () => {
|
||||
|
||||
// ensure cache consistency
|
||||
const viewerContextLast = new ViewerContext(
|
||||
createFullIntegrationTestEntityCompanionProvider(knexInstance, redisCacheAdapterContext)
|
||||
createFullIntegrationTestEntityCompanionProvider(knexInstance, genericRedisCacheContext)
|
||||
);
|
||||
|
||||
const loadedById = await TestEntity.loader(viewerContextLast)
|
||||
|
||||
+5
-6
@@ -1,13 +1,13 @@
|
||||
import { ViewerContext } from '@expo/entity';
|
||||
import { RedisCacheAdapterContext } from '@expo/entity-cache-adapter-redis';
|
||||
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 { createFullIntegrationTestEntityCompanionProvider } from '../testfixtures/createFullIntegrationTestEntityCompanionProvider';
|
||||
import ChildEntity from './entities/ChildEntity';
|
||||
import ParentEntity from './entities/ParentEntity';
|
||||
import { createFullIntegrationTestEntityCompanionProvider } from '../testfixtures/createFullIntegrationTestEntityCompanionProvider';
|
||||
|
||||
async function createOrTruncatePostgresTables(knex: Knex): Promise<void> {
|
||||
await knex.schema.createTable('parents', (table) => {
|
||||
@@ -34,7 +34,7 @@ async function dropPostgresTable(knex: Knex): Promise<void> {
|
||||
describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
|
||||
let knexInstance: Knex;
|
||||
const redisClient = new Redis(new URL(process.env['REDIS_URL']!).toString());
|
||||
let redisCacheAdapterContext: RedisCacheAdapterContext;
|
||||
let genericRedisCacheContext: GenericRedisCacheContext;
|
||||
|
||||
beforeAll(() => {
|
||||
knexInstance = knex({
|
||||
@@ -47,7 +47,7 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
|
||||
database: nullthrows(process.env['PGDATABASE']),
|
||||
},
|
||||
});
|
||||
redisCacheAdapterContext = {
|
||||
genericRedisCacheContext = {
|
||||
redisClient,
|
||||
makeKeyFn(...parts: string[]): string {
|
||||
const delimiter = ':';
|
||||
@@ -57,7 +57,6 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
|
||||
return escapedParts.join(delimiter);
|
||||
},
|
||||
cacheKeyPrefix: 'test-',
|
||||
cacheKeyVersion: 1,
|
||||
ttlSecondsPositive: 86400, // 1 day
|
||||
ttlSecondsNegative: 600, // 10 minutes
|
||||
};
|
||||
@@ -77,7 +76,7 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
|
||||
describe('EntityEdgeDeletionBehavior.INVALIDATE_CACHE', () => {
|
||||
it('invalidates the cache', async () => {
|
||||
const viewerContext = new ViewerContext(
|
||||
createFullIntegrationTestEntityCompanionProvider(knexInstance, redisCacheAdapterContext)
|
||||
createFullIntegrationTestEntityCompanionProvider(knexInstance, genericRedisCacheContext)
|
||||
);
|
||||
|
||||
const parent = await ParentEntity.creator(viewerContext).enforceCreateAsync();
|
||||
|
||||
+20
-25
@@ -8,7 +8,7 @@ import {
|
||||
UUIDField,
|
||||
EntityEdgeDeletionBehavior,
|
||||
} from '@expo/entity';
|
||||
import { RedisCacheAdapterContext } from '@expo/entity-cache-adapter-redis';
|
||||
import { GenericRedisCacheContext } from '@expo/entity-cache-adapter-redis';
|
||||
import Redis from 'ioredis';
|
||||
import { knex, Knex } from 'knex';
|
||||
import nullthrows from 'nullthrows';
|
||||
@@ -48,33 +48,41 @@ const makeEntityClasses = async (knex: Knex, edgeDeletionBehavior: EntityEdgeDel
|
||||
const othersTableName = uuidv4();
|
||||
|
||||
class CategoryEntity extends Entity<CategoryFields, string, ViewerContext> {
|
||||
static getCompanionDefinition(): EntityCompanionDefinition<
|
||||
static defineCompanionDefinition(): EntityCompanionDefinition<
|
||||
CategoryFields,
|
||||
string,
|
||||
ViewerContext,
|
||||
CategoryEntity,
|
||||
PrivacyPolicy
|
||||
> {
|
||||
return categoryEntityCompanion;
|
||||
return {
|
||||
entityClass: CategoryEntity,
|
||||
entityConfiguration: categoryEntityConfiguration,
|
||||
privacyPolicyClass: PrivacyPolicy,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class OtherEntity extends Entity<OtherFields, string, ViewerContext> {
|
||||
static getCompanionDefinition(): EntityCompanionDefinition<
|
||||
static defineCompanionDefinition(): EntityCompanionDefinition<
|
||||
OtherFields,
|
||||
string,
|
||||
ViewerContext,
|
||||
OtherEntity,
|
||||
PrivacyPolicy
|
||||
> {
|
||||
return otherEntityCompanion;
|
||||
return {
|
||||
entityClass: OtherEntity,
|
||||
entityConfiguration: otherEntityConfiguration,
|
||||
privacyPolicyClass: PrivacyPolicy,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const categoryEntityConfiguration = new EntityConfiguration<CategoryFields>({
|
||||
idField: 'id',
|
||||
tableName: categoriesTableName,
|
||||
getInboundEdges: () => [OtherEntity],
|
||||
inboundEdges: [OtherEntity],
|
||||
schema: {
|
||||
id: new UUIDField({
|
||||
columnName: 'id',
|
||||
@@ -84,7 +92,7 @@ const makeEntityClasses = async (knex: Knex, edgeDeletionBehavior: EntityEdgeDel
|
||||
columnName: 'parent_other_id',
|
||||
cache: true,
|
||||
association: {
|
||||
getAssociatedEntityClass: () => OtherEntity,
|
||||
associatedEntityClass: OtherEntity,
|
||||
edgeDeletionBehavior,
|
||||
},
|
||||
}),
|
||||
@@ -93,16 +101,10 @@ const makeEntityClasses = async (knex: Knex, edgeDeletionBehavior: EntityEdgeDel
|
||||
cacheAdapterFlavor: 'redis',
|
||||
});
|
||||
|
||||
const categoryEntityCompanion = new EntityCompanionDefinition({
|
||||
entityClass: CategoryEntity,
|
||||
entityConfiguration: categoryEntityConfiguration,
|
||||
privacyPolicyClass: PrivacyPolicy,
|
||||
});
|
||||
|
||||
const otherEntityConfiguration = new EntityConfiguration<OtherFields>({
|
||||
idField: 'id',
|
||||
tableName: othersTableName,
|
||||
getInboundEdges: () => [CategoryEntity],
|
||||
inboundEdges: [CategoryEntity],
|
||||
schema: {
|
||||
id: new UUIDField({
|
||||
columnName: 'id',
|
||||
@@ -112,7 +114,7 @@ const makeEntityClasses = async (knex: Knex, edgeDeletionBehavior: EntityEdgeDel
|
||||
columnName: 'parent_category_id',
|
||||
cache: true,
|
||||
association: {
|
||||
getAssociatedEntityClass: () => CategoryEntity,
|
||||
associatedEntityClass: CategoryEntity,
|
||||
edgeDeletionBehavior,
|
||||
},
|
||||
}),
|
||||
@@ -121,12 +123,6 @@ const makeEntityClasses = async (knex: Knex, edgeDeletionBehavior: EntityEdgeDel
|
||||
cacheAdapterFlavor: 'redis',
|
||||
});
|
||||
|
||||
const otherEntityCompanion = new EntityCompanionDefinition({
|
||||
entityClass: OtherEntity,
|
||||
entityConfiguration: otherEntityConfiguration,
|
||||
privacyPolicyClass: PrivacyPolicy,
|
||||
});
|
||||
|
||||
await knex.schema.createTable(categoriesTableName, (table) => {
|
||||
table.uuid('id').defaultTo(knex.raw('gen_random_uuid()')).primary();
|
||||
});
|
||||
@@ -177,7 +173,7 @@ const makeEntityClasses = async (knex: Knex, edgeDeletionBehavior: EntityEdgeDel
|
||||
describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
|
||||
let knexInstance: Knex;
|
||||
const redisClient = new Redis(new URL(process.env['REDIS_URL']!).toString());
|
||||
let redisCacheAdapterContext: RedisCacheAdapterContext;
|
||||
let genericRedisCacheContext: GenericRedisCacheContext;
|
||||
|
||||
beforeAll(() => {
|
||||
knexInstance = knex({
|
||||
@@ -190,7 +186,7 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
|
||||
database: nullthrows(process.env['PGDATABASE']),
|
||||
},
|
||||
});
|
||||
redisCacheAdapterContext = {
|
||||
genericRedisCacheContext = {
|
||||
redisClient,
|
||||
makeKeyFn(...parts: string[]): string {
|
||||
const delimiter = ':';
|
||||
@@ -200,7 +196,6 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
|
||||
return escapedParts.join(delimiter);
|
||||
},
|
||||
cacheKeyPrefix: 'test-',
|
||||
cacheKeyVersion: 1,
|
||||
ttlSecondsPositive: 86400, // 1 day
|
||||
ttlSecondsNegative: 600, // 10 minutes
|
||||
};
|
||||
@@ -223,7 +218,7 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
|
||||
);
|
||||
|
||||
const viewerContext = new ViewerContext(
|
||||
createFullIntegrationTestEntityCompanionProvider(knexInstance, redisCacheAdapterContext)
|
||||
createFullIntegrationTestEntityCompanionProvider(knexInstance, genericRedisCacheContext)
|
||||
);
|
||||
|
||||
const category1 = await CategoryEntity.creator(viewerContext).enforceCreateAsync();
|
||||
|
||||
+25
-29
@@ -32,40 +32,36 @@ class TestEntityPrivacyPolicy extends EntityPrivacyPolicy<any, string, ViewerCon
|
||||
}
|
||||
|
||||
export default class ChildEntity extends Entity<ChildFields, string, ViewerContext> {
|
||||
static getCompanionDefinition(): EntityCompanionDefinition<
|
||||
static defineCompanionDefinition(): EntityCompanionDefinition<
|
||||
ChildFields,
|
||||
string,
|
||||
ViewerContext,
|
||||
ChildEntity,
|
||||
TestEntityPrivacyPolicy
|
||||
> {
|
||||
return childEntityCompanion;
|
||||
return {
|
||||
entityClass: ChildEntity,
|
||||
entityConfiguration: new EntityConfiguration<ChildFields>({
|
||||
idField: 'id',
|
||||
tableName: 'children',
|
||||
schema: {
|
||||
id: new UUIDField({
|
||||
columnName: 'id',
|
||||
cache: true,
|
||||
}),
|
||||
parent_id: new UUIDField({
|
||||
columnName: 'parent_id',
|
||||
cache: true,
|
||||
association: {
|
||||
associatedEntityClass: ParentEntity,
|
||||
edgeDeletionBehavior: EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE_ONLY,
|
||||
},
|
||||
}),
|
||||
},
|
||||
databaseAdapterFlavor: 'postgres',
|
||||
cacheAdapterFlavor: 'redis',
|
||||
}),
|
||||
privacyPolicyClass: TestEntityPrivacyPolicy,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const childEntityConfiguration = new EntityConfiguration<ChildFields>({
|
||||
idField: 'id',
|
||||
tableName: 'children',
|
||||
schema: {
|
||||
id: new UUIDField({
|
||||
columnName: 'id',
|
||||
cache: true,
|
||||
}),
|
||||
parent_id: new UUIDField({
|
||||
columnName: 'parent_id',
|
||||
cache: true,
|
||||
association: {
|
||||
getAssociatedEntityClass: () => ParentEntity,
|
||||
edgeDeletionBehavior: EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE_ONLY,
|
||||
},
|
||||
}),
|
||||
},
|
||||
databaseAdapterFlavor: 'postgres',
|
||||
cacheAdapterFlavor: 'redis',
|
||||
});
|
||||
|
||||
const childEntityCompanion = new EntityCompanionDefinition({
|
||||
entityClass: ChildEntity,
|
||||
entityConfiguration: childEntityConfiguration,
|
||||
privacyPolicyClass: TestEntityPrivacyPolicy,
|
||||
});
|
||||
+18
-22
@@ -30,33 +30,29 @@ class TestEntityPrivacyPolicy extends EntityPrivacyPolicy<any, string, ViewerCon
|
||||
}
|
||||
|
||||
export default class ParentEntity extends Entity<ParentFields, string, ViewerContext> {
|
||||
static getCompanionDefinition(): EntityCompanionDefinition<
|
||||
static defineCompanionDefinition(): EntityCompanionDefinition<
|
||||
ParentFields,
|
||||
string,
|
||||
ViewerContext,
|
||||
ParentEntity,
|
||||
TestEntityPrivacyPolicy
|
||||
> {
|
||||
return parentEntityCompanion;
|
||||
return {
|
||||
entityClass: ParentEntity,
|
||||
entityConfiguration: new EntityConfiguration<ParentFields>({
|
||||
idField: 'id',
|
||||
tableName: 'parents',
|
||||
inboundEdges: [ChildEntity],
|
||||
schema: {
|
||||
id: new UUIDField({
|
||||
columnName: 'id',
|
||||
cache: true,
|
||||
}),
|
||||
},
|
||||
databaseAdapterFlavor: 'postgres',
|
||||
cacheAdapterFlavor: 'redis',
|
||||
}),
|
||||
privacyPolicyClass: TestEntityPrivacyPolicy,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const parentEntityConfiguration = new EntityConfiguration<ParentFields>({
|
||||
idField: 'id',
|
||||
tableName: 'parents',
|
||||
getInboundEdges: () => [ChildEntity],
|
||||
schema: {
|
||||
id: new UUIDField({
|
||||
columnName: 'id',
|
||||
cache: true,
|
||||
}),
|
||||
},
|
||||
databaseAdapterFlavor: 'postgres',
|
||||
cacheAdapterFlavor: 'redis',
|
||||
});
|
||||
|
||||
const parentEntityCompanion = new EntityCompanionDefinition({
|
||||
entityClass: ParentEntity,
|
||||
entityConfiguration: parentEntityConfiguration,
|
||||
privacyPolicyClass: TestEntityPrivacyPolicy,
|
||||
});
|
||||
+3
-3
@@ -4,7 +4,7 @@ import {
|
||||
EntityCompanionProvider,
|
||||
} from '@expo/entity';
|
||||
import {
|
||||
RedisCacheAdapterContext,
|
||||
GenericRedisCacheContext,
|
||||
RedisCacheAdapterProvider,
|
||||
} from '@expo/entity-cache-adapter-redis';
|
||||
import {
|
||||
@@ -15,7 +15,7 @@ import { Knex } from 'knex';
|
||||
|
||||
export const createFullIntegrationTestEntityCompanionProvider = (
|
||||
knex: Knex,
|
||||
redisCacheAdapterContext: RedisCacheAdapterContext,
|
||||
genericRedisCacheContext: GenericRedisCacheContext,
|
||||
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter()
|
||||
): EntityCompanionProvider => {
|
||||
return new EntityCompanionProvider(
|
||||
@@ -33,7 +33,7 @@ export const createFullIntegrationTestEntityCompanionProvider = (
|
||||
[
|
||||
'redis',
|
||||
{
|
||||
cacheAdapterProvider: new RedisCacheAdapterProvider(redisCacheAdapterContext),
|
||||
cacheAdapterProvider: new RedisCacheAdapterProvider(genericRedisCacheContext),
|
||||
},
|
||||
],
|
||||
])
|
||||
|
||||
@@ -3,6 +3,46 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [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
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.34.0](https://github.com/expo/entity/compare/v0.33.0...v0.34.0) (2024-04-11)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-ip-address-field
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.33.0](https://github.com/expo/entity/compare/v0.32.0...v0.33.0) (2023-10-06)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-ip-address-field
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.32.0](https://github.com/expo/entity/compare/v0.31.1...v0.32.0) (2023-06-10)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-ip-address-field
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.31.0](https://github.com/expo/entity/compare/v0.30.0...v0.31.0) (2023-01-19)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-ip-address-field
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.30.0](https://github.com/expo/entity/compare/v0.29.0...v0.30.0) (2022-12-23)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-ip-address-field
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/entity-ip-address-field",
|
||||
"version": "0.30.0",
|
||||
"version": "0.35.0",
|
||||
"description": "IP address EntityField definitions for @expo/entity",
|
||||
"files": [
|
||||
"build",
|
||||
@@ -17,7 +17,7 @@
|
||||
"barrelsby": "barrelsby --directory src --location top --exclude tests__ --singleQuotes --delete"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=16"
|
||||
},
|
||||
"keywords": [
|
||||
"entity"
|
||||
@@ -28,7 +28,7 @@
|
||||
"ip-address": "^8.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/entity": "^0.30.0",
|
||||
"@expo/entity": "^0.35.0",
|
||||
"@types/jsbn": "^1.2.29"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -3,6 +3,55 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [0.35.0](https://github.com/expo/entity/compare/v0.34.0...v0.35.0) (2024-04-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Revert: require explicit query context specification ([#219](https://github.com/expo/entity/issues/219)) ([1bdfbd6](https://github.com/expo/entity/commit/1bdfbd6b562d1200e4029df0533b5adb2d917831))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.34.0](https://github.com/expo/entity/compare/v0.33.0...v0.34.0) (2024-04-11)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* require explicit query context specification ([#219](https://github.com/expo/entity/issues/219)) ([8b0b31f](https://github.com/expo/entity/commit/8b0b31fdde5bd565aa527719003ef283a45f55cc))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.33.0](https://github.com/expo/entity/compare/v0.32.0...v0.33.0) (2023-10-06)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-secondary-cache-local-memory
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.32.0](https://github.com/expo/entity/compare/v0.31.1...v0.32.0) (2023-06-10)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* entity companion definition static thunk ([#210](https://github.com/expo/entity/issues/210)) ([4b18010](https://github.com/expo/entity/commit/4b18010d42be50ef329f428b08330e21bf676586))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.31.0](https://github.com/expo/entity/compare/v0.30.0...v0.31.0) (2023-01-19)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-secondary-cache-local-memory
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.30.0](https://github.com/expo/entity/compare/v0.29.0...v0.30.0) (2022-12-23)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-secondary-cache-local-memory
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/entity-secondary-cache-local-memory",
|
||||
"version": "0.30.0",
|
||||
"version": "0.35.0",
|
||||
"description": "Local memory secondary cache for @expo/entity",
|
||||
"files": [
|
||||
"build",
|
||||
@@ -19,7 +19,7 @@
|
||||
"barrelsby": "barrelsby --directory src --location top --exclude tests__ --singleQuotes --exportDefault --delete"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=16"
|
||||
},
|
||||
"keywords": [
|
||||
"entity"
|
||||
@@ -31,8 +31,8 @@
|
||||
"@expo/entity-cache-adapter-local-memory": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/entity": "^0.30.0",
|
||||
"@expo/entity-cache-adapter-local-memory": "^0.30.0",
|
||||
"@expo/entity": "^0.35.0",
|
||||
"@expo/entity-cache-adapter-local-memory": "^0.35.0",
|
||||
"nullthrows": "^1.1.1"
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { GenericSecondaryEntityCache } from '@expo/entity';
|
||||
import { EntityConfiguration, GenericSecondaryEntityCache } from '@expo/entity';
|
||||
import {
|
||||
GenericLocalMemoryCacher,
|
||||
LocalMemoryCache,
|
||||
@@ -15,7 +15,12 @@ export default class LocalMemorySecondaryEntityCache<
|
||||
TFields,
|
||||
TLoadParams
|
||||
> extends GenericSecondaryEntityCache<TFields, TLoadParams> {
|
||||
constructor(localMemoryCache: LocalMemoryCache<TFields>) {
|
||||
super(new GenericLocalMemoryCacher(localMemoryCache), (params) => JSON.stringify(params));
|
||||
constructor(
|
||||
entityConfiguration: EntityConfiguration<TFields>,
|
||||
localMemoryCache: LocalMemoryCache<TFields>
|
||||
) {
|
||||
super(new GenericLocalMemoryCacher(entityConfiguration, localMemoryCache), (params) =>
|
||||
JSON.stringify(params)
|
||||
);
|
||||
}
|
||||
}
|
||||
+9
-9
@@ -33,14 +33,18 @@ export default class LocalMemoryTestEntity extends Entity<
|
||||
string,
|
||||
ViewerContext
|
||||
> {
|
||||
static getCompanionDefinition(): EntityCompanionDefinition<
|
||||
static defineCompanionDefinition(): EntityCompanionDefinition<
|
||||
LocalMemoryTestEntityFields,
|
||||
string,
|
||||
ViewerContext,
|
||||
LocalMemoryTestEntity,
|
||||
LocalMemoryTestEntityPrivacyPolicy
|
||||
> {
|
||||
return localMemoryTestEntityCompanionDefinition;
|
||||
return {
|
||||
entityClass: LocalMemoryTestEntity,
|
||||
entityConfiguration: localMemoryTestEntityConfiguration,
|
||||
privacyPolicyClass: LocalMemoryTestEntityPrivacyPolicy,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,12 +104,6 @@ export const localMemoryTestEntityConfiguration =
|
||||
cacheAdapterFlavor: 'local-memory',
|
||||
});
|
||||
|
||||
const localMemoryTestEntityCompanionDefinition = new EntityCompanionDefinition({
|
||||
entityClass: LocalMemoryTestEntity,
|
||||
entityConfiguration: localMemoryTestEntityConfiguration,
|
||||
privacyPolicyClass: LocalMemoryTestEntityPrivacyPolicy,
|
||||
});
|
||||
|
||||
export const createTestEntityCompanionProvider = (
|
||||
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter()
|
||||
): EntityCompanionProvider => {
|
||||
@@ -124,7 +122,7 @@ export const createTestEntityCompanionProvider = (
|
||||
[
|
||||
'local-memory',
|
||||
{
|
||||
cacheAdapterProvider: LocalMemoryCacheAdapterProvider.getProvider(),
|
||||
cacheAdapterProvider: LocalMemoryCacheAdapterProvider.createProviderWithOptions(),
|
||||
},
|
||||
],
|
||||
])
|
||||
@@ -180,6 +178,7 @@ describe(LocalMemorySecondaryEntityCache, () => {
|
||||
|
||||
const secondaryCacheLoader = new TestSecondaryLocalMemoryCacheLoader(
|
||||
new LocalMemorySecondaryEntityCache(
|
||||
localMemoryTestEntityConfiguration,
|
||||
GenericLocalMemoryCacher.createLRUCache<LocalMemoryTestEntityFields>({})
|
||||
),
|
||||
LocalMemoryTestEntity.loader(viewerContext)
|
||||
@@ -215,6 +214,7 @@ describe(LocalMemorySecondaryEntityCache, () => {
|
||||
|
||||
const secondaryCacheLoader = new TestSecondaryLocalMemoryCacheLoader(
|
||||
new LocalMemorySecondaryEntityCache(
|
||||
localMemoryTestEntityConfiguration,
|
||||
GenericLocalMemoryCacher.createLRUCache<LocalMemoryTestEntityFields>({})
|
||||
),
|
||||
LocalMemoryTestEntity.loader(viewerContext)
|
||||
|
||||
@@ -3,6 +3,60 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [0.35.0](https://github.com/expo/entity/compare/v0.34.0...v0.35.0) (2024-04-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Revert: require explicit query context specification ([#219](https://github.com/expo/entity/issues/219)) ([1bdfbd6](https://github.com/expo/entity/commit/1bdfbd6b562d1200e4029df0533b5adb2d917831))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.34.0](https://github.com/expo/entity/compare/v0.33.0...v0.34.0) (2024-04-11)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* require explicit query context specification ([#219](https://github.com/expo/entity/issues/219)) ([8b0b31f](https://github.com/expo/entity/commit/8b0b31fdde5bd565aa527719003ef283a45f55cc))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.33.0](https://github.com/expo/entity/compare/v0.32.0...v0.33.0) (2023-10-06)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-secondary-cache-redis
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.32.0](https://github.com/expo/entity/compare/v0.31.1...v0.32.0) (2023-06-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* remove unused field from redis configuration ([#213](https://github.com/expo/entity/issues/213)) ([503a2fb](https://github.com/expo/entity/commit/503a2fb6a8d57f6a09f6da99daf5ab92fc3e7945))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* entity companion definition static thunk ([#210](https://github.com/expo/entity/issues/210)) ([4b18010](https://github.com/expo/entity/commit/4b18010d42be50ef329f428b08330e21bf676586))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.31.0](https://github.com/expo/entity/compare/v0.30.0...v0.31.0) (2023-01-19)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-secondary-cache-redis
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.30.0](https://github.com/expo/entity/compare/v0.29.0...v0.30.0) (2022-12-23)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-secondary-cache-redis
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
const secondaryCacheLoader = new TestSecondaryRedisCacheLoader(
|
||||
new RedisSecondaryEntityCache(
|
||||
redisTestEntityConfiguration,
|
||||
redisCacheAdapterContext,
|
||||
genericRedisCacheContext,
|
||||
(loadParams) => `${loadParams.id}`
|
||||
),
|
||||
RedisTestEntity.loader(viewerContext)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/entity-secondary-cache-redis",
|
||||
"version": "0.30.0",
|
||||
"version": "0.35.0",
|
||||
"description": "Redis secondary cache for @expo/entity",
|
||||
"files": [
|
||||
"build",
|
||||
@@ -19,7 +19,7 @@
|
||||
"barrelsby": "barrelsby --directory src --location top --exclude tests__ --singleQuotes --exportDefault --delete"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=16"
|
||||
},
|
||||
"keywords": [
|
||||
"entity"
|
||||
@@ -31,9 +31,9 @@
|
||||
"@expo/entity-cache-adapter-redis": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/entity": "^0.30.0",
|
||||
"@expo/entity-cache-adapter-redis": "^0.30.0",
|
||||
"ioredis": "^5.0.4",
|
||||
"@expo/entity": "^0.35.0",
|
||||
"@expo/entity-cache-adapter-redis": "^0.35.0",
|
||||
"ioredis": "^5.2.5",
|
||||
"nullthrows": "^1.1.1"
|
||||
}
|
||||
}
|
||||
+7
-8
@@ -1,5 +1,5 @@
|
||||
import { EntitySecondaryCacheLoader, mapMapAsync, ViewerContext } from '@expo/entity';
|
||||
import { RedisCacheAdapterContext } from '@expo/entity-cache-adapter-redis';
|
||||
import { GenericRedisCacheContext } from '@expo/entity-cache-adapter-redis';
|
||||
import Redis from 'ioredis';
|
||||
import nullthrows from 'nullthrows';
|
||||
import { URL } from 'url';
|
||||
@@ -53,16 +53,15 @@ class TestSecondaryRedisCacheLoader extends EntitySecondaryCacheLoader<
|
||||
|
||||
describe(RedisSecondaryEntityCache, () => {
|
||||
const redisClient = new Redis(new URL(process.env['REDIS_URL']!).toString());
|
||||
let redisCacheAdapterContext: RedisCacheAdapterContext;
|
||||
let genericRedisCacheContext: GenericRedisCacheContext;
|
||||
|
||||
beforeAll(() => {
|
||||
redisCacheAdapterContext = {
|
||||
genericRedisCacheContext = {
|
||||
redisClient,
|
||||
makeKeyFn(..._parts: string[]): string {
|
||||
throw new Error('should not be used by this test');
|
||||
},
|
||||
cacheKeyPrefix: 'test-',
|
||||
cacheKeyVersion: 1,
|
||||
ttlSecondsPositive: 86400, // 1 day
|
||||
ttlSecondsNegative: 600, // 10 minutes
|
||||
};
|
||||
@@ -77,7 +76,7 @@ describe(RedisSecondaryEntityCache, () => {
|
||||
|
||||
it('Loads through secondary loader, caches, and invalidates', async () => {
|
||||
const viewerContext = new TestViewerContext(
|
||||
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
|
||||
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext)
|
||||
);
|
||||
|
||||
const createdEntity = await RedisTestEntity.creator(viewerContext)
|
||||
@@ -87,7 +86,7 @@ describe(RedisSecondaryEntityCache, () => {
|
||||
const secondaryCacheLoader = new TestSecondaryRedisCacheLoader(
|
||||
new RedisSecondaryEntityCache(
|
||||
redisTestEntityConfiguration,
|
||||
redisCacheAdapterContext,
|
||||
genericRedisCacheContext,
|
||||
(loadParams) => `test-key-${loadParams.id}`
|
||||
),
|
||||
RedisTestEntity.loader(viewerContext)
|
||||
@@ -120,13 +119,13 @@ describe(RedisSecondaryEntityCache, () => {
|
||||
|
||||
it('correctly handles uncached and unfetchable load params', async () => {
|
||||
const viewerContext = new TestViewerContext(
|
||||
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
|
||||
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext)
|
||||
);
|
||||
|
||||
const secondaryCacheLoader = new TestSecondaryRedisCacheLoader(
|
||||
new RedisSecondaryEntityCache(
|
||||
redisTestEntityConfiguration,
|
||||
redisCacheAdapterContext,
|
||||
genericRedisCacheContext,
|
||||
(loadParams) => `test-key-${loadParams.id}`
|
||||
),
|
||||
RedisTestEntity.loader(viewerContext)
|
||||
|
||||
@@ -15,14 +15,18 @@ export type RedisTestEntityFields = {
|
||||
};
|
||||
|
||||
export default class RedisTestEntity extends Entity<RedisTestEntityFields, string, ViewerContext> {
|
||||
static getCompanionDefinition(): EntityCompanionDefinition<
|
||||
static defineCompanionDefinition(): EntityCompanionDefinition<
|
||||
RedisTestEntityFields,
|
||||
string,
|
||||
ViewerContext,
|
||||
RedisTestEntity,
|
||||
RedisTestEntityPrivacyPolicy
|
||||
> {
|
||||
return redisTestEntityCompanionDefinition;
|
||||
return {
|
||||
entityClass: RedisTestEntity,
|
||||
entityConfiguration: redisTestEntityConfiguration,
|
||||
privacyPolicyClass: RedisTestEntityPrivacyPolicy,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,9 +84,3 @@ export const redisTestEntityConfiguration = new EntityConfiguration<RedisTestEnt
|
||||
databaseAdapterFlavor: 'postgres',
|
||||
cacheAdapterFlavor: 'redis',
|
||||
});
|
||||
|
||||
const redisTestEntityCompanionDefinition = new EntityCompanionDefinition({
|
||||
entityClass: RedisTestEntity,
|
||||
entityConfiguration: redisTestEntityConfiguration,
|
||||
privacyPolicyClass: RedisTestEntityPrivacyPolicy,
|
||||
});
|
||||
+3
-3
@@ -6,12 +6,12 @@ import {
|
||||
StubDatabaseAdapterProvider,
|
||||
} from '@expo/entity';
|
||||
import {
|
||||
RedisCacheAdapterContext,
|
||||
GenericRedisCacheContext,
|
||||
RedisCacheAdapterProvider,
|
||||
} from '@expo/entity-cache-adapter-redis';
|
||||
|
||||
export const createRedisIntegrationTestEntityCompanionProvider = (
|
||||
redisCacheAdapterContext: RedisCacheAdapterContext,
|
||||
genericRedisCacheContext: GenericRedisCacheContext,
|
||||
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter()
|
||||
): EntityCompanionProvider => {
|
||||
return new EntityCompanionProvider(
|
||||
@@ -29,7 +29,7 @@ export const createRedisIntegrationTestEntityCompanionProvider = (
|
||||
[
|
||||
'redis',
|
||||
{
|
||||
cacheAdapterProvider: new RedisCacheAdapterProvider(redisCacheAdapterContext),
|
||||
cacheAdapterProvider: new RedisCacheAdapterProvider(genericRedisCacheContext),
|
||||
},
|
||||
],
|
||||
])
|
||||
|
||||
@@ -3,6 +3,60 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [0.35.0](https://github.com/expo/entity/compare/v0.34.0...v0.35.0) (2024-04-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Revert: require explicit query context specification ([#219](https://github.com/expo/entity/issues/219)) ([1bdfbd6](https://github.com/expo/entity/commit/1bdfbd6b562d1200e4029df0533b5adb2d917831))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.34.0](https://github.com/expo/entity/compare/v0.33.0...v0.34.0) (2024-04-11)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Allow UUIDv6/7/8 in UUIDField validation ([#221](https://github.com/expo/entity/issues/221)) ([cc6f3dc](https://github.com/expo/entity/commit/cc6f3dc1e677382eec220723c37b5b883d980372))
|
||||
* require explicit query context specification ([#219](https://github.com/expo/entity/issues/219)) ([8b0b31f](https://github.com/expo/entity/commit/8b0b31fdde5bd565aa527719003ef283a45f55cc))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.33.0](https://github.com/expo/entity/compare/v0.32.0...v0.33.0) (2023-10-06)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add loadManyByIDsNullableAsync loader method ([#214](https://github.com/expo/entity/issues/214)) ([aad94ac](https://github.com/expo/entity/commit/aad94aca479ac07cc7f76b5e1a122ff85a2d1542))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.32.0](https://github.com/expo/entity/compare/v0.31.1...v0.32.0) (2023-06-10)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add ability to specify knex transaction config ([#207](https://github.com/expo/entity/issues/207)) ([2069a0d](https://github.com/expo/entity/commit/2069a0d9fd1d8f7d090a71a90a758b2429ea5dd3))
|
||||
* entity companion definition static thunk ([#210](https://github.com/expo/entity/issues/210)) ([4b18010](https://github.com/expo/entity/commit/4b18010d42be50ef329f428b08330e21bf676586))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.31.0](https://github.com/expo/entity/compare/v0.30.0...v0.31.0) (2023-01-19)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.30.0](https://github.com/expo/entity/compare/v0.29.0...v0.30.0) (2022-12-23)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/entity",
|
||||
"version": "0.30.0",
|
||||
"version": "0.35.0",
|
||||
"description": "A privacy-first data model",
|
||||
"files": [
|
||||
"build",
|
||||
@@ -19,7 +19,7 @@
|
||||
"barrelsby": "barrelsby --directory src --location top --exclude tests__ --singleQuotes --exportDefault --delete"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=16"
|
||||
},
|
||||
"keywords": [
|
||||
"entity"
|
||||
|
||||
@@ -1,26 +1,19 @@
|
||||
import nullthrows from 'nullthrows';
|
||||
|
||||
import EntityCacheAdapter from './EntityCacheAdapter';
|
||||
import EntityConfiguration from './EntityConfiguration';
|
||||
import IEntityCacheAdapter from './IEntityCacheAdapter';
|
||||
import { CacheStatus, CacheLoadResult } from './internal/ReadThroughEntityCache';
|
||||
|
||||
/**
|
||||
* A EntityCacheAdapter that composes other EntityCacheAdapter instances.
|
||||
* A IEntityCacheAdapter that composes other IEntityCacheAdapter instances.
|
||||
*/
|
||||
export default class ComposedEntityCacheAdapter<TFields> extends EntityCacheAdapter<TFields> {
|
||||
export default class ComposedEntityCacheAdapter<TFields> implements IEntityCacheAdapter<TFields> {
|
||||
/**
|
||||
* @param entityConfiguration - configuration for entity being loaded
|
||||
* @param cacheAdapters - list of cache adapters to compose in order of precedence.
|
||||
* Earlier cache adapters are read from first and written to (including invalidations) last.
|
||||
* Typically, caches closer to the application should be ordered before caches closer to the database.
|
||||
* A lower layer cache is closer to the database, while a higher layer cache is closer to the application.
|
||||
*/
|
||||
constructor(
|
||||
entityConfiguration: EntityConfiguration<TFields>,
|
||||
private readonly cacheAdapters: EntityCacheAdapter<TFields>[]
|
||||
) {
|
||||
super(entityConfiguration);
|
||||
}
|
||||
constructor(private readonly cacheAdapters: IEntityCacheAdapter<TFields>[]) {}
|
||||
|
||||
public async loadManyAsync<N extends keyof TFields>(
|
||||
fieldName: N,
|
||||
|
||||
@@ -14,7 +14,7 @@ import { mapMap } from './utils/collections/maps';
|
||||
* if the loads are not successful.
|
||||
*/
|
||||
export default class EnforcingEntityLoader<
|
||||
TFields,
|
||||
TFields extends object,
|
||||
TID extends NonNullable<TFields[TSelectedFields]>,
|
||||
TViewerContext extends ViewerContext,
|
||||
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
|
||||
@@ -114,6 +114,31 @@ export default class EnforcingEntityLoader<
|
||||
return mapMap(entityResults, (result) => result.enforceValue());
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforcing version of entity loader method by the same name.
|
||||
* @throws EntityNotAuthorizedError when viewer is not authorized to view one or more of the returned entities
|
||||
*/
|
||||
async loadManyByIDsNullableAsync(ids: readonly TID[]): Promise<ReadonlyMap<TID, TEntity | null>> {
|
||||
const entityResults = await this.entityLoader.loadManyByIDsNullableAsync(ids);
|
||||
return mapMap(entityResults, (result) => result?.enforceValue() ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforcing version of entity loader method by the same name.
|
||||
* @throws EntityNotAuthorizedError when viewer is not authorized to view one or more of the returned entities
|
||||
*/
|
||||
async loadFirstByFieldEqualityConjunctionAsync<N extends keyof Pick<TFields, TSelectedFields>>(
|
||||
fieldEqualityOperands: FieldEqualityCondition<TFields, N>[],
|
||||
querySelectionModifiers: Omit<QuerySelectionModifiers<TFields>, 'limit'> &
|
||||
Required<Pick<QuerySelectionModifiers<TFields>, 'orderBy'>>
|
||||
): Promise<TEntity | null> {
|
||||
const entityResult = await this.entityLoader.loadFirstByFieldEqualityConjunctionAsync(
|
||||
fieldEqualityOperands,
|
||||
querySelectionModifiers
|
||||
);
|
||||
return entityResult ? entityResult.enforceValue() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforcing version of entity loader method by the same name.
|
||||
* @throws EntityNotAuthorizedError when viewer is not authorized to view one or more of the returned entities
|
||||
|
||||
@@ -26,7 +26,7 @@ import ViewerContext from './ViewerContext';
|
||||
* own EntityCompanionDefinition.
|
||||
*/
|
||||
export default abstract class Entity<
|
||||
TFields,
|
||||
TFields extends object,
|
||||
TID extends NonNullable<TFields[TSelectedFields]>,
|
||||
TViewerContext extends ViewerContext,
|
||||
TSelectedFields extends keyof TFields = keyof TFields
|
||||
@@ -38,7 +38,7 @@ export default abstract class Entity<
|
||||
* @returns mutator for creating an entity
|
||||
*/
|
||||
static creator<
|
||||
TMFields,
|
||||
TMFields extends object,
|
||||
TMID extends NonNullable<TMFields[TMSelectedFields]>,
|
||||
TMViewerContext extends ViewerContext,
|
||||
TMViewerContext2 extends TMViewerContext,
|
||||
@@ -79,7 +79,7 @@ export default abstract class Entity<
|
||||
* @returns mutator for updating existingEntity
|
||||
*/
|
||||
static updater<
|
||||
TMFields,
|
||||
TMFields extends object,
|
||||
TMID extends NonNullable<TMFields[TMSelectedFields]>,
|
||||
TMViewerContext extends ViewerContext,
|
||||
TMEntity extends Entity<TMFields, TMID, TMViewerContext, TMSelectedFields>,
|
||||
@@ -120,7 +120,7 @@ export default abstract class Entity<
|
||||
* @param queryContext - query context in which to perform the delete
|
||||
*/
|
||||
static deleteAsync<
|
||||
TMFields,
|
||||
TMFields extends object,
|
||||
TMID extends NonNullable<TMFields[TMSelectedFields]>,
|
||||
TMViewerContext extends ViewerContext,
|
||||
TMEntity extends Entity<TMFields, TMID, TMViewerContext, TMSelectedFields>,
|
||||
@@ -162,7 +162,7 @@ export default abstract class Entity<
|
||||
* @param queryContext - query context in which to perform the delete
|
||||
*/
|
||||
static enforceDeleteAsync<
|
||||
TMFields,
|
||||
TMFields extends object,
|
||||
TMID extends NonNullable<TMFields[TMSelectedFields]>,
|
||||
TMViewerContext extends ViewerContext,
|
||||
TMEntity extends Entity<TMFields, TMID, TMViewerContext, TMSelectedFields>,
|
||||
@@ -215,7 +215,7 @@ export default abstract class Entity<
|
||||
* @param queryContext - query context in which to perform the check
|
||||
*/
|
||||
static async canViewerUpdateAsync<
|
||||
TMFields,
|
||||
TMFields extends object,
|
||||
TMID extends NonNullable<TMFields[TMSelectedFields]>,
|
||||
TMViewerContext extends ViewerContext,
|
||||
TMEntity extends Entity<TMFields, TMID, TMViewerContext, TMSelectedFields>,
|
||||
@@ -246,7 +246,7 @@ export default abstract class Entity<
|
||||
const companion = existingEntity
|
||||
.getViewerContext()
|
||||
.getViewerScopedEntityCompanionForClass(this);
|
||||
const privacyPolicy = new (this.getCompanionDefinition().privacyPolicyClass)();
|
||||
const privacyPolicy = companion.entityCompanion.privacyPolicy;
|
||||
const evaluationResult = await asyncResult(
|
||||
privacyPolicy.authorizeUpdateAsync(
|
||||
existingEntity.getViewerContext(),
|
||||
@@ -269,7 +269,7 @@ export default abstract class Entity<
|
||||
* @param queryContext - query context in which to perform the check
|
||||
*/
|
||||
static async canViewerDeleteAsync<
|
||||
TMFields,
|
||||
TMFields extends object,
|
||||
TMID extends NonNullable<TMFields[TMSelectedFields]>,
|
||||
TMViewerContext extends ViewerContext,
|
||||
TMEntity extends Entity<TMFields, TMID, TMViewerContext, TMSelectedFields>,
|
||||
@@ -300,7 +300,7 @@ export default abstract class Entity<
|
||||
const companion = existingEntity
|
||||
.getViewerContext()
|
||||
.getViewerScopedEntityCompanionForClass(this);
|
||||
const privacyPolicy = new (this.getCompanionDefinition().privacyPolicyClass)();
|
||||
const privacyPolicy = companion.entityCompanion.privacyPolicy;
|
||||
const evaluationResult = await asyncResult(
|
||||
privacyPolicy.authorizeDeleteAsync(
|
||||
existingEntity.getViewerContext(),
|
||||
@@ -318,7 +318,7 @@ export default abstract class Entity<
|
||||
* An interface to pass in constructor (class) of an Entity as a function argument.
|
||||
*/
|
||||
export interface IEntityClass<
|
||||
TFields,
|
||||
TFields extends object,
|
||||
TID extends NonNullable<TFields[TSelectedFields]>,
|
||||
TViewerContext extends ViewerContext,
|
||||
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
|
||||
@@ -331,8 +331,19 @@ export interface IEntityClass<
|
||||
>,
|
||||
TSelectedFields extends keyof TFields = keyof TFields
|
||||
> {
|
||||
new (viewerContext: TViewerContext, obj: Readonly<TFields>): TEntity;
|
||||
getCompanionDefinition(): EntityCompanionDefinition<
|
||||
new (constructorParam: {
|
||||
viewerContext: TViewerContext;
|
||||
id: TID;
|
||||
databaseFields: Readonly<TFields>;
|
||||
selectedFields: Readonly<Pick<TFields, TSelectedFields>>;
|
||||
}): TEntity;
|
||||
|
||||
/**
|
||||
* Returns a EntityCompanionDefinition for this entity.
|
||||
*
|
||||
* Memoized by the entity framework.
|
||||
*/
|
||||
defineCompanionDefinition(): EntityCompanionDefinition<
|
||||
TFields,
|
||||
TID,
|
||||
TViewerContext,
|
||||
|
||||
@@ -12,7 +12,7 @@ import ViewerContext from './ViewerContext';
|
||||
* by foreign keys.
|
||||
*/
|
||||
export default class EntityAssociationLoader<
|
||||
TFields,
|
||||
TFields extends object,
|
||||
TID extends NonNullable<TFields[TSelectedFields]>,
|
||||
TViewerContext extends ViewerContext,
|
||||
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
|
||||
@@ -29,7 +29,7 @@ export default class EntityAssociationLoader<
|
||||
*/
|
||||
async loadAssociatedEntityAsync<
|
||||
TIdentifyingField extends keyof Pick<TFields, TSelectedFields>,
|
||||
TAssociatedFields,
|
||||
TAssociatedFields extends object,
|
||||
TAssociatedID extends NonNullable<TAssociatedFields[TAssociatedSelectedFields]>,
|
||||
TAssociatedEntity extends ReadonlyEntity<
|
||||
TAssociatedFields,
|
||||
@@ -91,7 +91,7 @@ export default class EntityAssociationLoader<
|
||||
* @param queryContext - query context in which to perform the load
|
||||
*/
|
||||
async loadManyAssociatedEntitiesAsync<
|
||||
TAssociatedFields,
|
||||
TAssociatedFields extends object,
|
||||
TAssociatedID extends NonNullable<TAssociatedFields[TAssociatedSelectedFields]>,
|
||||
TAssociatedEntity extends ReadonlyEntity<
|
||||
TAssociatedFields,
|
||||
@@ -144,7 +144,7 @@ export default class EntityAssociationLoader<
|
||||
* @param queryContext - query context in which to perform the load
|
||||
*/
|
||||
async loadAssociatedEntityByFieldEqualingAsync<
|
||||
TAssociatedFields,
|
||||
TAssociatedFields extends object,
|
||||
TAssociatedID extends NonNullable<TAssociatedFields[TAssociatedSelectedFields]>,
|
||||
TAssociatedEntity extends ReadonlyEntity<
|
||||
TAssociatedFields,
|
||||
@@ -201,7 +201,7 @@ export default class EntityAssociationLoader<
|
||||
* @param queryContext - query context in which to perform the load
|
||||
*/
|
||||
async loadManyAssociatedEntitiesByFieldEqualingAsync<
|
||||
TAssociatedFields,
|
||||
TAssociatedFields extends object,
|
||||
TAssociatedID extends NonNullable<TAssociatedFields[TAssociatedSelectedFields]>,
|
||||
TAssociatedEntity extends ReadonlyEntity<
|
||||
TAssociatedFields,
|
||||
@@ -257,7 +257,7 @@ export default class EntityAssociationLoader<
|
||||
* @param queryContext - query context in which to perform the loads
|
||||
*/
|
||||
async loadAssociatedEntityThroughAsync<
|
||||
TFields2,
|
||||
TFields2 extends object,
|
||||
TID2 extends NonNullable<TFields2[TSelectedFields2]>,
|
||||
TEntity2 extends ReadonlyEntity<TFields2, TID2, TViewerContext, TSelectedFields2>,
|
||||
TPrivacyPolicy2 extends EntityPrivacyPolicy<
|
||||
@@ -291,7 +291,7 @@ export default class EntityAssociationLoader<
|
||||
* @param queryContext - query context in which to perform the loads
|
||||
*/
|
||||
async loadAssociatedEntityThroughAsync<
|
||||
TFields2,
|
||||
TFields2 extends object,
|
||||
TID2 extends NonNullable<TFields2[TSelectedFields2]>,
|
||||
TEntity2 extends ReadonlyEntity<TFields2, TID2, TViewerContext, TSelectedFields2>,
|
||||
TPrivacyPolicy2 extends EntityPrivacyPolicy<
|
||||
@@ -301,7 +301,7 @@ export default class EntityAssociationLoader<
|
||||
TEntity2,
|
||||
TSelectedFields2
|
||||
>,
|
||||
TFields3,
|
||||
TFields3 extends object,
|
||||
TID3 extends NonNullable<TFields3[TSelectedFields3]>,
|
||||
TEntity3 extends ReadonlyEntity<TFields3, TID3, TViewerContext, TSelectedFields3>,
|
||||
TPrivacyPolicy3 extends EntityPrivacyPolicy<
|
||||
@@ -346,7 +346,7 @@ export default class EntityAssociationLoader<
|
||||
* @param queryContext - query context in which to perform the loads
|
||||
*/
|
||||
async loadAssociatedEntityThroughAsync<
|
||||
TFields2,
|
||||
TFields2 extends object,
|
||||
TID2 extends NonNullable<TFields2[TSelectedFields2]>,
|
||||
TEntity2 extends ReadonlyEntity<TFields2, TID2, TViewerContext, TSelectedFields2>,
|
||||
TPrivacyPolicy2 extends EntityPrivacyPolicy<
|
||||
@@ -356,7 +356,7 @@ export default class EntityAssociationLoader<
|
||||
TEntity2,
|
||||
TSelectedFields2
|
||||
>,
|
||||
TFields3,
|
||||
TFields3 extends object,
|
||||
TID3 extends NonNullable<TFields3[TSelectedFields3]>,
|
||||
TEntity3 extends ReadonlyEntity<TFields3, TID3, TViewerContext, TSelectedFields3>,
|
||||
TPrivacyPolicy3 extends EntityPrivacyPolicy<
|
||||
@@ -366,7 +366,7 @@ export default class EntityAssociationLoader<
|
||||
TEntity3,
|
||||
TSelectedFields3
|
||||
>,
|
||||
TFields4,
|
||||
TFields4 extends object,
|
||||
TID4 extends NonNullable<TFields4[TSelectedFields4]>,
|
||||
TEntity4 extends ReadonlyEntity<TFields4, TID4, TViewerContext, TSelectedFields4>,
|
||||
TPrivacyPolicy4 extends EntityPrivacyPolicy<
|
||||
@@ -482,7 +482,7 @@ export default class EntityAssociationLoader<
|
||||
export interface EntityLoadThroughDirective<
|
||||
TViewerContext extends ViewerContext,
|
||||
TFields,
|
||||
TAssociatedFields,
|
||||
TAssociatedFields extends object,
|
||||
TAssociatedID extends NonNullable<TAssociatedFields[TAssociatedSelectedFields]>,
|
||||
TAssociatedEntity extends ReadonlyEntity<
|
||||
TAssociatedFields,
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { IEntityClass } from './Entity';
|
||||
import EntityCompanionProvider, { EntityCompanionDefinition } from './EntityCompanionProvider';
|
||||
import EntityLoaderFactory from './EntityLoaderFactory';
|
||||
import EntityMutationTriggerConfiguration from './EntityMutationTriggerConfiguration';
|
||||
import EntityMutationValidator from './EntityMutationValidator';
|
||||
import EntityMutatorFactory from './EntityMutatorFactory';
|
||||
import EntityPrivacyPolicy from './EntityPrivacyPolicy';
|
||||
import EntityQueryContextProvider from './EntityQueryContextProvider';
|
||||
@@ -18,7 +16,7 @@ export interface IPrivacyPolicyClass<TPrivacyPolicy> {
|
||||
* Composition root responsible for orchestrating setup of Entity mutators and loaders.
|
||||
*/
|
||||
export default class EntityCompanion<
|
||||
TFields,
|
||||
TFields extends object,
|
||||
TID extends NonNullable<TFields[TSelectedFields]>,
|
||||
TViewerContext extends ViewerContext,
|
||||
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
|
||||
@@ -31,6 +29,8 @@ export default class EntityCompanion<
|
||||
>,
|
||||
TSelectedFields extends keyof TFields
|
||||
> {
|
||||
public readonly privacyPolicy: TPrivacyPolicy;
|
||||
|
||||
private readonly entityLoaderFactory: EntityLoaderFactory<
|
||||
TFields,
|
||||
TID,
|
||||
@@ -49,7 +49,8 @@ export default class EntityCompanion<
|
||||
>;
|
||||
|
||||
constructor(
|
||||
entityClass: IEntityClass<
|
||||
public readonly entityCompanionProvider: EntityCompanionProvider,
|
||||
public readonly entityCompanionDefinition: EntityCompanionDefinition<
|
||||
TFields,
|
||||
TID,
|
||||
TViewerContext,
|
||||
@@ -58,24 +59,9 @@ export default class EntityCompanion<
|
||||
TSelectedFields
|
||||
>,
|
||||
private readonly tableDataCoordinator: EntityTableDataCoordinator<TFields>,
|
||||
PrivacyPolicyClass: IPrivacyPolicyClass<TPrivacyPolicy>,
|
||||
mutationValidators: EntityMutationValidator<
|
||||
TFields,
|
||||
TID,
|
||||
TViewerContext,
|
||||
TEntity,
|
||||
TSelectedFields
|
||||
>[],
|
||||
mutationTriggers: EntityMutationTriggerConfiguration<
|
||||
TFields,
|
||||
TID,
|
||||
TViewerContext,
|
||||
TEntity,
|
||||
TSelectedFields
|
||||
>,
|
||||
private readonly metricsAdapter: IEntityMetricsAdapter
|
||||
) {
|
||||
const privacyPolicy = new PrivacyPolicyClass();
|
||||
this.privacyPolicy = new entityCompanionDefinition.privacyPolicyClass();
|
||||
this.entityLoaderFactory = new EntityLoaderFactory<
|
||||
TFields,
|
||||
TID,
|
||||
@@ -83,19 +69,14 @@ export default class EntityCompanion<
|
||||
TEntity,
|
||||
TPrivacyPolicy,
|
||||
TSelectedFields
|
||||
>(
|
||||
tableDataCoordinator.entityConfiguration,
|
||||
entityClass,
|
||||
privacyPolicy,
|
||||
tableDataCoordinator.dataManager,
|
||||
metricsAdapter
|
||||
);
|
||||
>(this, tableDataCoordinator.dataManager, metricsAdapter);
|
||||
this.entityMutatorFactory = new EntityMutatorFactory(
|
||||
entityCompanionProvider,
|
||||
tableDataCoordinator.entityConfiguration,
|
||||
entityClass,
|
||||
privacyPolicy,
|
||||
mutationValidators,
|
||||
mutationTriggers,
|
||||
entityCompanionDefinition.entityClass,
|
||||
this.privacyPolicy,
|
||||
entityCompanionDefinition.mutationValidators ?? [],
|
||||
entityCompanionDefinition.mutationTriggers ?? {},
|
||||
this.entityLoaderFactory,
|
||||
tableDataCoordinator.databaseAdapter,
|
||||
metricsAdapter
|
||||
|
||||
@@ -49,8 +49,8 @@ export interface CacheAdapterFlavorDefinition {
|
||||
* Definition for constructing a companion for an entity. Defines the core set of objects
|
||||
* used to power the entity framework for a particular type of entity.
|
||||
*/
|
||||
export class EntityCompanionDefinition<
|
||||
TFields,
|
||||
export interface EntityCompanionDefinition<
|
||||
TFields extends object,
|
||||
TID extends NonNullable<TFields[TSelectedFields]>,
|
||||
TViewerContext extends ViewerContext,
|
||||
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
|
||||
@@ -63,6 +63,9 @@ export class EntityCompanionDefinition<
|
||||
>,
|
||||
TSelectedFields extends keyof TFields = keyof TFields
|
||||
> {
|
||||
/**
|
||||
* The concrete Entity class for which this is the definition.
|
||||
*/
|
||||
readonly entityClass: IEntityClass<
|
||||
TFields,
|
||||
TID,
|
||||
@@ -71,85 +74,45 @@ export class EntityCompanionDefinition<
|
||||
TPrivacyPolicy,
|
||||
TSelectedFields
|
||||
>;
|
||||
|
||||
/**
|
||||
* The EntityConfiguration for this entity.
|
||||
*/
|
||||
readonly entityConfiguration: EntityConfiguration<TFields>;
|
||||
|
||||
/**
|
||||
* The EntityPrivacyPolicy class for this entity.
|
||||
*/
|
||||
readonly privacyPolicyClass: IPrivacyPolicyClass<TPrivacyPolicy>;
|
||||
readonly mutationValidators: () => EntityMutationValidator<
|
||||
|
||||
/**
|
||||
* An optional list of EntityMutationValidator for this entity.
|
||||
*/
|
||||
readonly mutationValidators?: EntityMutationValidator<
|
||||
TFields,
|
||||
TID,
|
||||
TViewerContext,
|
||||
TEntity,
|
||||
TSelectedFields
|
||||
>[];
|
||||
readonly mutationTriggers: () => EntityMutationTriggerConfiguration<
|
||||
|
||||
/**
|
||||
* An optional list of EntityMutationTrigger for this entity.
|
||||
*/
|
||||
readonly mutationTriggers?: EntityMutationTriggerConfiguration<
|
||||
TFields,
|
||||
TID,
|
||||
TViewerContext,
|
||||
TEntity,
|
||||
TSelectedFields
|
||||
>;
|
||||
readonly entitySelectedFields: TSelectedFields[];
|
||||
|
||||
constructor({
|
||||
entityClass,
|
||||
entityConfiguration,
|
||||
privacyPolicyClass,
|
||||
mutationValidators = () => [],
|
||||
mutationTriggers = () => ({}),
|
||||
entitySelectedFields = Array.from(entityConfiguration.schema.keys()) as TSelectedFields[],
|
||||
}: {
|
||||
/**
|
||||
* The concrete Entity class for which this is the definition.
|
||||
*/
|
||||
entityClass: IEntityClass<
|
||||
TFields,
|
||||
TID,
|
||||
TViewerContext,
|
||||
TEntity,
|
||||
TPrivacyPolicy,
|
||||
TSelectedFields
|
||||
>;
|
||||
/**
|
||||
* The EntityConfiguration for this entity.
|
||||
*/
|
||||
entityConfiguration: EntityConfiguration<TFields>;
|
||||
/**
|
||||
* The EntityPrivacyPolicy class for this entity.
|
||||
*/
|
||||
privacyPolicyClass: IPrivacyPolicyClass<TPrivacyPolicy>;
|
||||
/**
|
||||
* An optional list of EntityMutationValidator for this entity.
|
||||
*/
|
||||
mutationValidators?: () => EntityMutationValidator<
|
||||
TFields,
|
||||
TID,
|
||||
TViewerContext,
|
||||
TEntity,
|
||||
TSelectedFields
|
||||
>[];
|
||||
/**
|
||||
* An optional list of EntityMutationTrigger for this entity.
|
||||
*/
|
||||
mutationTriggers?: () => EntityMutationTriggerConfiguration<
|
||||
TFields,
|
||||
TID,
|
||||
TViewerContext,
|
||||
TEntity,
|
||||
TSelectedFields
|
||||
>;
|
||||
/**
|
||||
* An optional subset of fields defined in the EntityConfiguration which belong to this entity.
|
||||
* For use when multiple types of entities are backed by a single table (EntityConfiguration) yet
|
||||
* only expose a subset of the fields.
|
||||
*/
|
||||
entitySelectedFields?: TSelectedFields[];
|
||||
}) {
|
||||
this.entityClass = entityClass;
|
||||
this.entityConfiguration = entityConfiguration;
|
||||
this.privacyPolicyClass = privacyPolicyClass;
|
||||
this.mutationValidators = mutationValidators;
|
||||
this.mutationTriggers = mutationTriggers;
|
||||
this.entitySelectedFields = entitySelectedFields;
|
||||
}
|
||||
/**
|
||||
* An optional subset of fields defined in the EntityConfiguration which belong to this entity.
|
||||
* For use when multiple types of entities are backed by a single table (EntityConfiguration) yet
|
||||
* only expose a subset of the fields.
|
||||
*/
|
||||
readonly entitySelectedFields?: TSelectedFields[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -162,6 +125,10 @@ export class EntityCompanionDefinition<
|
||||
* EntityCompanion for each type of Entity.
|
||||
*/
|
||||
export default class EntityCompanionProvider {
|
||||
private readonly companionDefinitionMap: Map<
|
||||
string,
|
||||
EntityCompanionDefinition<any, any, any, any, any, any>
|
||||
> = new Map();
|
||||
private readonly companionMap: Map<string, EntityCompanion<any, any, any, any, any, any>> =
|
||||
new Map();
|
||||
private readonly tableDataCoordinatorMap: Map<string, EntityTableDataCoordinator<any>> =
|
||||
@@ -187,10 +154,9 @@ export default class EntityCompanionProvider {
|
||||
* companion is constructed using the configuration provided by the factory.
|
||||
*
|
||||
* @param entityClass - entity class to load
|
||||
* @param factory - entity companion factory
|
||||
*/
|
||||
getCompanionForEntity<
|
||||
TFields,
|
||||
TFields extends object,
|
||||
TID extends NonNullable<TFields[TSelectedFields]>,
|
||||
TViewerContext extends ViewerContext,
|
||||
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
|
||||
@@ -210,27 +176,22 @@ export default class EntityCompanionProvider {
|
||||
TEntity,
|
||||
TPrivacyPolicy,
|
||||
TSelectedFields
|
||||
>,
|
||||
entityCompanionDefinition: EntityCompanionDefinition<
|
||||
TFields,
|
||||
TID,
|
||||
TViewerContext,
|
||||
TEntity,
|
||||
TPrivacyPolicy,
|
||||
TSelectedFields
|
||||
>
|
||||
): EntityCompanion<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy, TSelectedFields> {
|
||||
const entityCompanionDefinition = computeIfAbsent(
|
||||
this.companionDefinitionMap,
|
||||
entityClass.name,
|
||||
() => entityClass.defineCompanionDefinition()
|
||||
);
|
||||
const tableDataCoordinator = this.getTableDataCoordinatorForEntity(
|
||||
entityCompanionDefinition.entityConfiguration,
|
||||
entityClass.name
|
||||
);
|
||||
return computeIfAbsent(this.companionMap, entityClass.name, () => {
|
||||
return new EntityCompanion(
|
||||
entityCompanionDefinition.entityClass,
|
||||
this,
|
||||
entityCompanionDefinition,
|
||||
tableDataCoordinator,
|
||||
entityCompanionDefinition.privacyPolicyClass,
|
||||
entityCompanionDefinition.mutationValidators(),
|
||||
entityCompanionDefinition.mutationTriggers(),
|
||||
this.metricsAdapter
|
||||
);
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ export default class EntityConfiguration<TFields> {
|
||||
readonly cacheableKeys: ReadonlySet<keyof TFields>;
|
||||
readonly cacheKeyVersion: number;
|
||||
|
||||
readonly getInboundEdges: () => IEntityClass<any, any, any, any, any, any>[];
|
||||
readonly inboundEdges: IEntityClass<any, any, any, any, any, any>[];
|
||||
readonly schema: ReadonlyMap<keyof TFields, EntityFieldDefinition<any>>;
|
||||
readonly entityToDBFieldsKeyMapping: ReadonlyMap<keyof TFields, string>;
|
||||
readonly dbToEntityFieldsKeyMapping: ReadonlyMap<string, keyof TFields>;
|
||||
@@ -25,7 +25,7 @@ export default class EntityConfiguration<TFields> {
|
||||
idField,
|
||||
tableName,
|
||||
schema,
|
||||
getInboundEdges = () => [],
|
||||
inboundEdges = [],
|
||||
cacheKeyVersion = 0,
|
||||
databaseAdapterFlavor,
|
||||
cacheAdapterFlavor,
|
||||
@@ -48,7 +48,7 @@ export default class EntityConfiguration<TFields> {
|
||||
/**
|
||||
* List of other entity types that reference this type in EntityFieldDefinition associations.
|
||||
*/
|
||||
getInboundEdges?: () => IEntityClass<any, any, any, any, any, any>[];
|
||||
inboundEdges?: IEntityClass<any, any, any, any, any, any>[];
|
||||
|
||||
/**
|
||||
* Cache key version for this entity type. Should be bumped when a field is added to, removed from, or changed
|
||||
@@ -71,8 +71,7 @@ export default class EntityConfiguration<TFields> {
|
||||
this.cacheKeyVersion = cacheKeyVersion;
|
||||
this.databaseAdapterFlavor = databaseAdapterFlavor;
|
||||
this.cacheAdapterFlavor = cacheAdapterFlavor;
|
||||
|
||||
this.getInboundEdges = getInboundEdges;
|
||||
this.inboundEdges = inboundEdges;
|
||||
|
||||
// external schema is a Record to typecheck that all fields have FieldDefinitions,
|
||||
// but internally the most useful representation is a map for lookups
|
||||
|
||||
@@ -41,7 +41,7 @@ export enum EntityEdgeDeletionBehavior {
|
||||
*/
|
||||
export interface EntityAssociationDefinition<
|
||||
TViewerContext extends ViewerContext,
|
||||
TAssociatedFields,
|
||||
TAssociatedFields extends object,
|
||||
TAssociatedID extends NonNullable<TAssociatedFields[TAssociatedSelectedFields]>,
|
||||
TAssociatedEntity extends ReadonlyEntity<
|
||||
TAssociatedFields,
|
||||
@@ -61,7 +61,7 @@ export interface EntityAssociationDefinition<
|
||||
/**
|
||||
* Class of entity on the other end of this edge.
|
||||
*/
|
||||
getAssociatedEntityClass: () => IEntityClass<
|
||||
associatedEntityClass: IEntityClass<
|
||||
TAssociatedFields,
|
||||
TAssociatedID,
|
||||
TViewerContext,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { validate as validateUUID } from 'uuid';
|
||||
|
||||
import { EntityFieldDefinition } from './EntityFieldDefinition';
|
||||
|
||||
// Use our own regex since the `uuid` package doesn't support validating UUIDv6/7/8 yet
|
||||
const UUID_REGEX =
|
||||
/^(?:[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$/i;
|
||||
|
||||
/**
|
||||
* EntityFieldDefinition for a column with a JS string type.
|
||||
*/
|
||||
@@ -17,7 +19,7 @@ export class StringField extends EntityFieldDefinition<string> {
|
||||
*/
|
||||
export class UUIDField extends StringField {
|
||||
protected override validateInputValueInternal(value: string): boolean {
|
||||
return validateUUID(value);
|
||||
return super.validateInputValueInternal(value) && UUID_REGEX.test(value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,3 +88,18 @@ export class EnumField extends EntityFieldDefinition<string | number> {
|
||||
return typeof value === 'number' || typeof value === 'string';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* EntityFieldDefinition for a enum column with a strict typescript enum type.
|
||||
*/
|
||||
export class StrictEnumField<T extends object> extends EnumField {
|
||||
private readonly enum: T;
|
||||
constructor(options: ConstructorParameters<typeof EnumField>[0] & { enum: T }) {
|
||||
super(options);
|
||||
this.enum = options.enum;
|
||||
}
|
||||
|
||||
protected override validateInputValueInternal(value: string | number): boolean {
|
||||
return super.validateInputValueInternal(value) && Object.values(this.enum).includes(value);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Result, asyncResult, result } from '@expo/results';
|
||||
import invariant from 'invariant';
|
||||
import nullthrows from 'nullthrows';
|
||||
|
||||
import EnforcingEntityLoader from './EnforcingEntityLoader';
|
||||
import { IEntityClass } from './Entity';
|
||||
@@ -14,6 +15,7 @@ import EntityPrivacyPolicy, { EntityPrivacyPolicyEvaluationContext } from './Ent
|
||||
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';
|
||||
@@ -25,7 +27,7 @@ import { mapMap, mapMapAsync } from './utils/collections/maps';
|
||||
* cached, and authorized against the entity's EntityPrivacyPolicy.
|
||||
*/
|
||||
export default class EntityLoader<
|
||||
TFields,
|
||||
TFields extends object,
|
||||
TID extends NonNullable<TFields[TSelectedFields]>,
|
||||
TViewerContext extends ViewerContext,
|
||||
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
|
||||
@@ -51,6 +53,7 @@ export default class EntityLoader<
|
||||
TPrivacyPolicy,
|
||||
TSelectedFields
|
||||
>,
|
||||
private readonly entitySelectedFields: TSelectedFields[] | undefined,
|
||||
private readonly privacyPolicy: TPrivacyPolicy,
|
||||
private readonly dataManager: EntityDataManager<TFields>,
|
||||
protected readonly metricsAdapter: IEntityMetricsAdapter
|
||||
@@ -182,6 +185,49 @@ export default class EntityLoader<
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
@@ -306,7 +352,7 @@ export default class EntityLoader<
|
||||
private tryConstructEntities(fieldsObjects: readonly TFields[]): readonly Result<TEntity>[] {
|
||||
return fieldsObjects.map((fieldsObject) => {
|
||||
try {
|
||||
return result(new this.entityClass(this.viewerContext, fieldsObject));
|
||||
return result(this.constructEntity(fieldsObject));
|
||||
} catch (e) {
|
||||
if (!(e instanceof Error)) {
|
||||
throw e;
|
||||
@@ -316,6 +362,20 @@ export default class EntityLoader<
|
||||
});
|
||||
}
|
||||
|
||||
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.
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { IEntityClass } from './Entity';
|
||||
import EntityConfiguration from './EntityConfiguration';
|
||||
import EntityCompanion from './EntityCompanion';
|
||||
import EntityLoader from './EntityLoader';
|
||||
import EntityPrivacyPolicy, { EntityPrivacyPolicyEvaluationContext } from './EntityPrivacyPolicy';
|
||||
import { EntityQueryContext } from './EntityQueryContext';
|
||||
@@ -12,7 +11,7 @@ import IEntityMetricsAdapter from './metrics/IEntityMetricsAdapter';
|
||||
* The primary entry point for loading entities.
|
||||
*/
|
||||
export default class EntityLoaderFactory<
|
||||
TFields,
|
||||
TFields extends object,
|
||||
TID extends NonNullable<TFields[TSelectedFields]>,
|
||||
TViewerContext extends ViewerContext,
|
||||
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
|
||||
@@ -26,8 +25,7 @@ export default class EntityLoaderFactory<
|
||||
TSelectedFields extends keyof TFields
|
||||
> {
|
||||
constructor(
|
||||
private readonly entityConfiguration: EntityConfiguration<TFields>,
|
||||
private readonly entityClass: IEntityClass<
|
||||
private readonly entityCompanion: EntityCompanion<
|
||||
TFields,
|
||||
TID,
|
||||
TViewerContext,
|
||||
@@ -35,7 +33,6 @@ export default class EntityLoaderFactory<
|
||||
TPrivacyPolicy,
|
||||
TSelectedFields
|
||||
>,
|
||||
private readonly privacyPolicyClass: TPrivacyPolicy,
|
||||
private readonly dataManager: EntityDataManager<TFields>,
|
||||
protected readonly metricsAdapter: IEntityMetricsAdapter
|
||||
) {}
|
||||
@@ -54,9 +51,10 @@ export default class EntityLoaderFactory<
|
||||
viewerContext,
|
||||
queryContext,
|
||||
privacyPolicyEvaluationContext,
|
||||
this.entityConfiguration,
|
||||
this.entityClass,
|
||||
this.privacyPolicyClass,
|
||||
this.entityCompanion.entityCompanionDefinition.entityConfiguration,
|
||||
this.entityCompanion.entityCompanionDefinition.entityClass,
|
||||
this.entityCompanion.entityCompanionDefinition.entitySelectedFields,
|
||||
this.entityCompanion.privacyPolicy,
|
||||
this.dataManager,
|
||||
this.metricsAdapter
|
||||
);
|
||||
|
||||
@@ -8,7 +8,7 @@ export enum EntityMutationType {
|
||||
}
|
||||
|
||||
export type EntityValidatorMutationInfo<
|
||||
TFields,
|
||||
TFields extends object,
|
||||
TID extends NonNullable<TFields[TSelectedFields]>,
|
||||
TViewerContext extends ViewerContext,
|
||||
TEntity extends Entity<TFields, TID, TViewerContext, TSelectedFields>,
|
||||
@@ -39,7 +39,7 @@ export type EntityCascadingDeletionInfo = {
|
||||
};
|
||||
|
||||
export type EntityTriggerMutationInfo<
|
||||
TFields,
|
||||
TFields extends object,
|
||||
TID extends NonNullable<TFields[TSelectedFields]>,
|
||||
TViewerContext extends ViewerContext,
|
||||
TEntity extends Entity<TFields, TID, TViewerContext, TSelectedFields>,
|
||||
|
||||
@@ -7,7 +7,7 @@ import ViewerContext from './ViewerContext';
|
||||
* Interface to define trigger behavior for entities.
|
||||
*/
|
||||
export default interface EntityMutationTriggerConfiguration<
|
||||
TFields,
|
||||
TFields extends object,
|
||||
TID extends NonNullable<TFields[TSelectedFields]>,
|
||||
TViewerContext extends ViewerContext,
|
||||
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
|
||||
@@ -51,9 +51,8 @@ export default interface EntityMutationTriggerConfiguration<
|
||||
afterAll?: EntityMutationTrigger<TFields, TID, TViewerContext, TEntity, TSelectedFields>[];
|
||||
|
||||
/**
|
||||
* Trigger set that runs after committing the transaction unless one is supplied
|
||||
* after any mutation (create, update, delete). If the call to the mutation is wrapped in a transaction,
|
||||
* this too will be within the transaction.
|
||||
* Trigger set that runs after committing the mutation transaction. If the call to the mutation is wrapped in a transaction, these
|
||||
* will be run after the wrapping transaction is completed.
|
||||
*/
|
||||
afterCommit?: EntityNonTransactionalMutationTrigger<
|
||||
TFields,
|
||||
@@ -65,12 +64,11 @@ export default interface EntityMutationTriggerConfiguration<
|
||||
}
|
||||
|
||||
/**
|
||||
* A trigger is a way to specify entity mutation operation side-effects that run within the
|
||||
* same transaction as the mutation itself. The one exception is afterCommit, which will run within
|
||||
* the transaction if a transaction is supplied.
|
||||
* A transactional trigger is a way to specify entity mutation operation side-effects that run within the
|
||||
* same transaction as the mutation itself.
|
||||
*/
|
||||
export abstract class EntityMutationTrigger<
|
||||
TFields,
|
||||
TFields extends object,
|
||||
TID extends NonNullable<TFields[TSelectedFields]>,
|
||||
TViewerContext extends ViewerContext,
|
||||
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
|
||||
@@ -86,10 +84,10 @@ export abstract class EntityMutationTrigger<
|
||||
|
||||
/**
|
||||
* A non-transactional trigger is like a EntityMutationTrigger but used for afterCommit triggers
|
||||
* since they explicitly occur outside of the transaction.
|
||||
* since they run after the transaction is committed.
|
||||
*/
|
||||
export abstract class EntityNonTransactionalMutationTrigger<
|
||||
TFields,
|
||||
TFields extends object,
|
||||
TID extends NonNullable<TFields[TSelectedFields]>,
|
||||
TViewerContext extends ViewerContext,
|
||||
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
|
||||
|
||||
@@ -8,7 +8,7 @@ import ViewerContext from './ViewerContext';
|
||||
* same transaction as the mutation itself before creating or updating an entity.
|
||||
*/
|
||||
export default abstract class EntityMutationValidator<
|
||||
TFields,
|
||||
TFields extends object,
|
||||
TID extends NonNullable<TFields[TSelectedFields]>,
|
||||
TViewerContext extends ViewerContext,
|
||||
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Result, asyncResult, result, enforceAsyncResult } from '@expo/results';
|
||||
import invariant from 'invariant';
|
||||
|
||||
import Entity, { IEntityClass } from './Entity';
|
||||
import { EntityCompanionDefinition } from './EntityCompanionProvider';
|
||||
import EntityCompanionProvider from './EntityCompanionProvider';
|
||||
import EntityConfiguration from './EntityConfiguration';
|
||||
import EntityDatabaseAdapter from './EntityDatabaseAdapter';
|
||||
import { EntityEdgeDeletionBehavior } from './EntityFieldDefinition';
|
||||
@@ -28,7 +28,7 @@ import IEntityMetricsAdapter, { EntityMetricsMutationType } from './metrics/IEnt
|
||||
import { mapMapAsync } from './utils/collections/maps';
|
||||
|
||||
abstract class BaseMutator<
|
||||
TFields,
|
||||
TFields extends object,
|
||||
TID extends NonNullable<TFields[TSelectedFields]>,
|
||||
TViewerContext extends ViewerContext,
|
||||
TEntity extends Entity<TFields, TID, TViewerContext, TSelectedFields>,
|
||||
@@ -42,6 +42,7 @@ abstract class BaseMutator<
|
||||
TSelectedFields extends keyof TFields
|
||||
> {
|
||||
constructor(
|
||||
protected readonly companionProvider: EntityCompanionProvider,
|
||||
protected readonly viewerContext: TViewerContext,
|
||||
protected readonly queryContext: EntityQueryContext,
|
||||
protected readonly entityConfiguration: EntityConfiguration<TFields>,
|
||||
@@ -155,7 +156,7 @@ abstract class BaseMutator<
|
||||
* Mutator for creating a new entity.
|
||||
*/
|
||||
export class CreateMutator<
|
||||
TFields,
|
||||
TFields extends object,
|
||||
TID extends NonNullable<TFields[TSelectedFields]>,
|
||||
TViewerContext extends ViewerContext,
|
||||
TEntity extends Entity<TFields, TID, TViewerContext, TSelectedFields>,
|
||||
@@ -211,7 +212,11 @@ export class CreateMutator<
|
||||
): Promise<Result<TEntity>> {
|
||||
this.validateFields(this.fieldsForEntity);
|
||||
|
||||
const temporaryEntityForPrivacyCheck = new this.entityClass(this.viewerContext, {
|
||||
const entityLoader = this.entityLoaderFactory.forLoad(this.viewerContext, queryContext, {
|
||||
cascadingDeleteCause: null,
|
||||
});
|
||||
|
||||
const temporaryEntityForPrivacyCheck = entityLoader.constructEntity({
|
||||
[this.entityConfiguration.idField]: '00000000-0000-0000-0000-000000000000', // zero UUID
|
||||
...this.fieldsForEntity,
|
||||
} as unknown as TFields);
|
||||
@@ -250,14 +255,11 @@ export class CreateMutator<
|
||||
|
||||
const insertResult = await this.databaseAdapter.insertAsync(queryContext, this.fieldsForEntity);
|
||||
|
||||
const entityLoader = this.entityLoaderFactory.forLoad(this.viewerContext, queryContext, {
|
||||
cascadingDeleteCause: null,
|
||||
});
|
||||
queryContext.appendPostCommitInvalidationCallback(
|
||||
entityLoader.invalidateFieldsAsync.bind(entityLoader, insertResult)
|
||||
);
|
||||
|
||||
const unauthorizedEntityAfterInsert = new this.entityClass(this.viewerContext, insertResult);
|
||||
const unauthorizedEntityAfterInsert = entityLoader.constructEntity(insertResult);
|
||||
const newEntity = await entityLoader
|
||||
.enforcing()
|
||||
.loadByIDAsync(unauthorizedEntityAfterInsert.getID());
|
||||
@@ -292,7 +294,7 @@ export class CreateMutator<
|
||||
* Mutator for updating an existing entity.
|
||||
*/
|
||||
export class UpdateMutator<
|
||||
TFields,
|
||||
TFields extends object,
|
||||
TID extends NonNullable<TFields[TSelectedFields]>,
|
||||
TViewerContext extends ViewerContext,
|
||||
TEntity extends Entity<TFields, TID, TViewerContext, TSelectedFields>,
|
||||
@@ -310,6 +312,7 @@ export class UpdateMutator<
|
||||
private readonly updatedFields: Partial<TFields> = {};
|
||||
|
||||
constructor(
|
||||
companionProvider: EntityCompanionProvider,
|
||||
viewerContext: TViewerContext,
|
||||
queryContext: EntityQueryContext,
|
||||
entityConfiguration: EntityConfiguration<TFields>,
|
||||
@@ -349,6 +352,7 @@ export class UpdateMutator<
|
||||
originalEntity: TEntity
|
||||
) {
|
||||
super(
|
||||
companionProvider,
|
||||
viewerContext,
|
||||
queryContext,
|
||||
entityConfiguration,
|
||||
@@ -419,7 +423,11 @@ export class UpdateMutator<
|
||||
): Promise<Result<TEntity>> {
|
||||
this.validateFields(this.updatedFields);
|
||||
|
||||
const entityAboutToBeUpdated = new this.entityClass(this.viewerContext, this.fieldsForEntity);
|
||||
const entityLoader = this.entityLoaderFactory.forLoad(this.viewerContext, queryContext, {
|
||||
cascadingDeleteCause,
|
||||
});
|
||||
|
||||
const entityAboutToBeUpdated = entityLoader.constructEntity(this.fieldsForEntity);
|
||||
const authorizeUpdateResult = await asyncResult(
|
||||
this.privacyPolicy.authorizeUpdateAsync(
|
||||
this.viewerContext,
|
||||
@@ -462,10 +470,6 @@ export class UpdateMutator<
|
||||
this.updatedFields
|
||||
);
|
||||
|
||||
const entityLoader = this.entityLoaderFactory.forLoad(this.viewerContext, queryContext, {
|
||||
cascadingDeleteCause,
|
||||
});
|
||||
|
||||
queryContext.appendPostCommitInvalidationCallback(
|
||||
entityLoader.invalidateFieldsAsync.bind(
|
||||
entityLoader,
|
||||
@@ -481,7 +485,7 @@ export class UpdateMutator<
|
||||
const updatedEntity = updateResult
|
||||
? await entityLoader
|
||||
.enforcing()
|
||||
.loadByIDAsync(new this.entityClass(this.viewerContext, updateResult).getID())
|
||||
.loadByIDAsync(entityLoader.constructEntity(updateResult).getID())
|
||||
: entityAboutToBeUpdated;
|
||||
|
||||
await this.executeMutationTriggersAsync(
|
||||
@@ -518,7 +522,7 @@ export class UpdateMutator<
|
||||
* Mutator for deleting an existing entity.
|
||||
*/
|
||||
export class DeleteMutator<
|
||||
TFields,
|
||||
TFields extends object,
|
||||
TID extends NonNullable<TFields[TSelectedFields]>,
|
||||
TViewerContext extends ViewerContext,
|
||||
TEntity extends Entity<TFields, TID, TViewerContext, TSelectedFields>,
|
||||
@@ -532,6 +536,7 @@ export class DeleteMutator<
|
||||
TSelectedFields extends keyof TFields
|
||||
> extends BaseMutator<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy, TSelectedFields> {
|
||||
constructor(
|
||||
companionProvider: EntityCompanionProvider,
|
||||
viewerContext: TViewerContext,
|
||||
queryContext: EntityQueryContext,
|
||||
entityConfiguration: EntityConfiguration<TFields>,
|
||||
@@ -571,6 +576,7 @@ export class DeleteMutator<
|
||||
private readonly entity: TEntity
|
||||
) {
|
||||
super(
|
||||
companionProvider,
|
||||
viewerContext,
|
||||
queryContext,
|
||||
entityConfiguration,
|
||||
@@ -723,31 +729,32 @@ export class DeleteMutator<
|
||||
}
|
||||
processedEntityIdentifiers.add(entity.getUniqueIdentifier());
|
||||
|
||||
const companionDefinition = (
|
||||
entity.constructor as any
|
||||
).getCompanionDefinition() as EntityCompanionDefinition<
|
||||
TFields,
|
||||
TID,
|
||||
TViewerContext,
|
||||
TEntity,
|
||||
TPrivacyPolicy,
|
||||
TSelectedFields
|
||||
>;
|
||||
const companionDefinition = this.companionProvider.getCompanionForEntity(
|
||||
entity.constructor as IEntityClass<
|
||||
TFields,
|
||||
TID,
|
||||
TViewerContext,
|
||||
TEntity,
|
||||
TPrivacyPolicy,
|
||||
TSelectedFields
|
||||
>
|
||||
).entityCompanionDefinition;
|
||||
const entityConfiguration = companionDefinition.entityConfiguration;
|
||||
const inboundEdges = entityConfiguration.getInboundEdges();
|
||||
const inboundEdges = entityConfiguration.inboundEdges;
|
||||
await Promise.all(
|
||||
inboundEdges.map(async (entityClass) => {
|
||||
return await mapMapAsync(
|
||||
entityClass.getCompanionDefinition().entityConfiguration.schema,
|
||||
this.companionProvider.getCompanionForEntity(entityClass).entityCompanionDefinition
|
||||
.entityConfiguration.schema,
|
||||
async (fieldDefinition, fieldName) => {
|
||||
const association = fieldDefinition.association;
|
||||
if (!association) {
|
||||
return;
|
||||
}
|
||||
|
||||
const associatedConfiguration = association
|
||||
.getAssociatedEntityClass()
|
||||
.getCompanionDefinition().entityConfiguration;
|
||||
const associatedConfiguration = this.companionProvider.getCompanionForEntity(
|
||||
association.associatedEntityClass
|
||||
).entityCompanionDefinition.entityConfiguration;
|
||||
if (associatedConfiguration !== entityConfiguration) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Entity, { IEntityClass } from './Entity';
|
||||
import EntityCompanionProvider from './EntityCompanionProvider';
|
||||
import EntityConfiguration from './EntityConfiguration';
|
||||
import EntityDatabaseAdapter from './EntityDatabaseAdapter';
|
||||
import EntityLoaderFactory from './EntityLoaderFactory';
|
||||
@@ -14,7 +15,7 @@ import IEntityMetricsAdapter from './metrics/IEntityMetricsAdapter';
|
||||
* The primary interface for creating, mutating, and deleting entities.
|
||||
*/
|
||||
export default class EntityMutatorFactory<
|
||||
TFields,
|
||||
TFields extends object,
|
||||
TID extends NonNullable<TFields[TSelectedFields]>,
|
||||
TViewerContext extends ViewerContext,
|
||||
TEntity extends Entity<TFields, TID, TViewerContext, TSelectedFields>,
|
||||
@@ -28,6 +29,7 @@ export default class EntityMutatorFactory<
|
||||
TSelectedFields extends keyof TFields = keyof TFields
|
||||
> {
|
||||
constructor(
|
||||
private readonly entityCompanionProvider: EntityCompanionProvider,
|
||||
private readonly entityConfiguration: EntityConfiguration<TFields>,
|
||||
private readonly entityClass: IEntityClass<
|
||||
TFields,
|
||||
@@ -75,6 +77,7 @@ export default class EntityMutatorFactory<
|
||||
queryContext: EntityQueryContext
|
||||
): CreateMutator<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy, TSelectedFields> {
|
||||
return new CreateMutator(
|
||||
this.entityCompanionProvider,
|
||||
viewerContext,
|
||||
queryContext,
|
||||
this.entityConfiguration,
|
||||
@@ -99,6 +102,7 @@ export default class EntityMutatorFactory<
|
||||
queryContext: EntityQueryContext
|
||||
): UpdateMutator<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy, TSelectedFields> {
|
||||
return new UpdateMutator(
|
||||
this.entityCompanionProvider,
|
||||
existingEntity.getViewerContext(),
|
||||
queryContext,
|
||||
this.entityConfiguration,
|
||||
@@ -123,6 +127,7 @@ export default class EntityMutatorFactory<
|
||||
queryContext: EntityQueryContext
|
||||
): DeleteMutator<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy, TSelectedFields> {
|
||||
return new DeleteMutator(
|
||||
this.entityCompanionProvider,
|
||||
existingEntity.getViewerContext(),
|
||||
queryContext,
|
||||
this.entityConfiguration,
|
||||
|
||||
@@ -6,7 +6,9 @@ import EntityNotAuthorizedError from './errors/EntityNotAuthorizedError';
|
||||
import IEntityMetricsAdapter, {
|
||||
EntityMetricsAuthorizationResult,
|
||||
} from './metrics/IEntityMetricsAdapter';
|
||||
import PrivacyPolicyRule, { RuleEvaluationResult } from './rules/PrivacyPolicyRule';
|
||||
import { RuleEvaluationResult } from './rules/PrivacyPolicyRuleEnums';
|
||||
import { PrivacyPolicyRule } from './rules/PrivacyPolicyRuleTypes';
|
||||
import { reorderRulesByRuleComplexityGroups } from './rules/internal/PrivacyPolicyRuleOrdering';
|
||||
|
||||
/**
|
||||
* Information about the reason this privacy policy is being evaluated.
|
||||
@@ -41,7 +43,7 @@ export enum EntityPrivacyPolicyEvaluationMode {
|
||||
}
|
||||
|
||||
export type EntityPrivacyPolicyEvaluator<
|
||||
TFields,
|
||||
TFields extends object,
|
||||
TID extends NonNullable<TFields[TSelectedFields]>,
|
||||
TViewerContext extends ViewerContext,
|
||||
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
|
||||
@@ -92,7 +94,7 @@ export enum EntityAuthorizationAction {
|
||||
* ```
|
||||
*/
|
||||
export default abstract class EntityPrivacyPolicy<
|
||||
TFields,
|
||||
TFields extends object,
|
||||
TID extends NonNullable<TFields[TSelectedFields]>,
|
||||
TViewerContext extends ViewerContext,
|
||||
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
|
||||
@@ -143,6 +145,15 @@ export default abstract class EntityPrivacyPolicy<
|
||||
};
|
||||
}
|
||||
|
||||
protected async shouldAutoReorderRulesAccordingToComplexityAsync(
|
||||
_viewerContext: TViewerContext,
|
||||
_queryContext: EntityQueryContext,
|
||||
_evaluationContext: EntityPrivacyPolicyEvaluationContext,
|
||||
_action: EntityAuthorizationAction
|
||||
): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorize an entity against creation policy.
|
||||
* @param viewerContext - viewer context of user creating the entity
|
||||
@@ -248,7 +259,13 @@ export default abstract class EntityPrivacyPolicy<
|
||||
}
|
||||
|
||||
private async authorizeForRulesetAsync(
|
||||
ruleset: readonly PrivacyPolicyRule<TFields, TID, TViewerContext, TEntity, TSelectedFields>[],
|
||||
rulesetOriginalOrder: readonly PrivacyPolicyRule<
|
||||
TFields,
|
||||
TID,
|
||||
TViewerContext,
|
||||
TEntity,
|
||||
TSelectedFields
|
||||
>[],
|
||||
viewerContext: TViewerContext,
|
||||
queryContext: EntityQueryContext,
|
||||
evaluationContext: EntityPrivacyPolicyEvaluationContext,
|
||||
@@ -257,6 +274,15 @@ export default abstract class EntityPrivacyPolicy<
|
||||
metricsAdapter: IEntityMetricsAdapter
|
||||
): Promise<TEntity> {
|
||||
const privacyPolicyEvaluator = this.getPrivacyPolicyEvaluator(viewerContext);
|
||||
const shouldAutoReorder = await this.shouldAutoReorderRulesAccordingToComplexityAsync(
|
||||
viewerContext,
|
||||
queryContext,
|
||||
evaluationContext,
|
||||
action
|
||||
);
|
||||
const ruleset = shouldAutoReorder
|
||||
? reorderRulesByRuleComplexityGroups(rulesetOriginalOrder)
|
||||
: rulesetOriginalOrder;
|
||||
switch (privacyPolicyEvaluator.mode) {
|
||||
case EntityPrivacyPolicyEvaluationMode.ENFORCE:
|
||||
try {
|
||||
|
||||
@@ -8,6 +8,16 @@ export type PreCommitCallback = (
|
||||
...args: any
|
||||
) => Promise<any>;
|
||||
|
||||
export enum TransactionIsolationLevel {
|
||||
READ_COMMITTED = 'READ_COMMITTED',
|
||||
REPEATABLE_READ = 'REPEATABLE_READ',
|
||||
SERIALIZABLE = 'SERIALIZABLE',
|
||||
}
|
||||
|
||||
export type TransactionConfig = {
|
||||
isolationLevel?: TransactionIsolationLevel;
|
||||
};
|
||||
|
||||
/**
|
||||
* Entity framework representation of transactional and non-transactional database
|
||||
* query execution units.
|
||||
@@ -25,7 +35,8 @@ export abstract class EntityQueryContext {
|
||||
}
|
||||
|
||||
abstract runInTransactionIfNotInTransactionAsync<T>(
|
||||
transactionScope: (queryContext: EntityTransactionalQueryContext) => Promise<T>
|
||||
transactionScope: (queryContext: EntityTransactionalQueryContext) => Promise<T>,
|
||||
transactionConfig?: TransactionConfig
|
||||
): Promise<T>;
|
||||
}
|
||||
|
||||
@@ -48,9 +59,13 @@ export class EntityNonTransactionalQueryContext extends EntityQueryContext {
|
||||
}
|
||||
|
||||
async runInTransactionIfNotInTransactionAsync<T>(
|
||||
transactionScope: (queryContext: EntityTransactionalQueryContext) => Promise<T>
|
||||
transactionScope: (queryContext: EntityTransactionalQueryContext) => Promise<T>,
|
||||
transactionConfig?: TransactionConfig
|
||||
): Promise<T> {
|
||||
return await this.entityQueryContextProvider.runInTransactionAsync(transactionScope);
|
||||
return await this.entityQueryContextProvider.runInTransactionAsync(
|
||||
transactionScope,
|
||||
transactionConfig
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,8 +147,13 @@ export class EntityTransactionalQueryContext extends EntityQueryContext {
|
||||
}
|
||||
|
||||
async runInTransactionIfNotInTransactionAsync<T>(
|
||||
transactionScope: (queryContext: EntityTransactionalQueryContext) => Promise<T>
|
||||
transactionScope: (queryContext: EntityTransactionalQueryContext) => Promise<T>,
|
||||
transactionConfig?: TransactionConfig
|
||||
): Promise<T> {
|
||||
assert(
|
||||
transactionConfig === undefined,
|
||||
'Should not pass transactionConfig to a nested transaction'
|
||||
);
|
||||
return await transactionScope(this);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
EntityTransactionalQueryContext,
|
||||
EntityNonTransactionalQueryContext,
|
||||
EntityNestedTransactionalQueryContext,
|
||||
TransactionConfig,
|
||||
} from './EntityQueryContext';
|
||||
|
||||
/**
|
||||
@@ -23,9 +24,9 @@ export default abstract class EntityQueryContextProvider {
|
||||
/**
|
||||
* Vend a transaction runner for use in runInTransactionAsync.
|
||||
*/
|
||||
protected abstract createTransactionRunner<T>(): (
|
||||
transactionScope: (queryInterface: any) => Promise<T>
|
||||
) => Promise<T>;
|
||||
protected abstract createTransactionRunner<T>(
|
||||
transactionConfig?: TransactionConfig
|
||||
): (transactionScope: (queryInterface: any) => Promise<T>) => Promise<T>;
|
||||
|
||||
protected abstract createNestedTransactionRunner<T>(
|
||||
outerQueryInterface: any
|
||||
@@ -36,11 +37,12 @@ export default abstract class EntityQueryContextProvider {
|
||||
* @param transactionScope - async callback to execute within the transaction
|
||||
*/
|
||||
async runInTransactionAsync<T>(
|
||||
transactionScope: (queryContext: EntityTransactionalQueryContext) => Promise<T>
|
||||
transactionScope: (queryContext: EntityTransactionalQueryContext) => Promise<T>,
|
||||
transactionConfig?: TransactionConfig
|
||||
): Promise<T> {
|
||||
const [returnedValue, queryContext] = await this.createTransactionRunner<
|
||||
[T, EntityTransactionalQueryContext]
|
||||
>()(async (queryInterface) => {
|
||||
>(transactionConfig)(async (queryInterface) => {
|
||||
const queryContext = new EntityTransactionalQueryContext(queryInterface, this);
|
||||
const result = await transactionScope(queryContext);
|
||||
await queryContext.runPreCommitCallbacksAsync();
|
||||
|
||||
@@ -45,7 +45,7 @@ export interface ISecondaryEntityCache<TFields, TLoadParams> {
|
||||
*/
|
||||
export default abstract class EntitySecondaryCacheLoader<
|
||||
TLoadParams,
|
||||
TFields,
|
||||
TFields extends object,
|
||||
TID extends NonNullable<TFields[TSelectedFields]>,
|
||||
TViewerContext extends ViewerContext,
|
||||
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import invariant from 'invariant';
|
||||
|
||||
import IEntityCacheAdapter from './IEntityCacheAdapter';
|
||||
import IEntityGenericCacher from './IEntityGenericCacher';
|
||||
import { CacheLoadResult } from './internal/ReadThroughEntityCache';
|
||||
import { mapKeys } from './utils/collections/maps';
|
||||
|
||||
/**
|
||||
* A standard IEntityCacheAdapter that coordinates caching through an IEntityGenericCacher.
|
||||
*/
|
||||
export default class GenericEntityCacheAdapter<TFields> implements IEntityCacheAdapter<TFields> {
|
||||
constructor(private readonly genericCacher: IEntityGenericCacher<TFields>) {}
|
||||
|
||||
public async loadManyAsync<N extends keyof TFields>(
|
||||
fieldName: N,
|
||||
fieldValues: readonly NonNullable<TFields[N]>[]
|
||||
): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult<TFields>>> {
|
||||
const redisCacheKeyToFieldValueMapping = new Map(
|
||||
fieldValues.map((fieldValue) => [
|
||||
this.genericCacher.makeCacheKey(fieldName, fieldValue),
|
||||
fieldValue,
|
||||
])
|
||||
);
|
||||
const cacheResults = await this.genericCacher.loadManyAsync(
|
||||
Array.from(redisCacheKeyToFieldValueMapping.keys())
|
||||
);
|
||||
|
||||
return mapKeys(cacheResults, (redisCacheKey) => {
|
||||
const fieldValue = redisCacheKeyToFieldValueMapping.get(redisCacheKey);
|
||||
invariant(
|
||||
fieldValue !== undefined,
|
||||
'Unspecified cache key %s returned from generic cacher',
|
||||
redisCacheKey
|
||||
);
|
||||
return fieldValue;
|
||||
});
|
||||
}
|
||||
|
||||
public async cacheManyAsync<N extends keyof TFields>(
|
||||
fieldName: N,
|
||||
objectMap: ReadonlyMap<NonNullable<TFields[N]>, Readonly<TFields>>
|
||||
): Promise<void> {
|
||||
await this.genericCacher.cacheManyAsync(
|
||||
mapKeys(objectMap, (fieldValue) => this.genericCacher.makeCacheKey(fieldName, fieldValue))
|
||||
);
|
||||
}
|
||||
|
||||
public async cacheDBMissesAsync<N extends keyof TFields>(
|
||||
fieldName: N,
|
||||
fieldValues: readonly NonNullable<TFields[N]>[]
|
||||
): Promise<void> {
|
||||
await this.genericCacher.cacheDBMissesAsync(
|
||||
fieldValues.map((fieldValue) => this.genericCacher.makeCacheKey(fieldName, fieldValue))
|
||||
);
|
||||
}
|
||||
|
||||
public async invalidateManyAsync<N extends keyof TFields>(
|
||||
fieldName: N,
|
||||
fieldValues: readonly NonNullable<TFields[N]>[]
|
||||
): Promise<void> {
|
||||
await this.genericCacher.invalidateManyAsync(
|
||||
fieldValues.map((fieldValue) => this.genericCacher.makeCacheKey(fieldName, fieldValue))
|
||||
);
|
||||
}
|
||||
}
|
||||
+5
-8
@@ -1,20 +1,17 @@
|
||||
import EntityConfiguration from './EntityConfiguration';
|
||||
import { CacheLoadResult } from './internal/ReadThroughEntityCache';
|
||||
|
||||
/**
|
||||
* A cache adapter is an interface by which objects can be
|
||||
* cached, fetched from cache, and removed from cache (invalidated).
|
||||
*/
|
||||
export default abstract class EntityCacheAdapter<TFields> {
|
||||
constructor(protected readonly entityConfiguration: EntityConfiguration<TFields>) {}
|
||||
|
||||
export default interface IEntityCacheAdapter<TFields> {
|
||||
/**
|
||||
* Load many objects from cache.
|
||||
* @param fieldName - object field being queried
|
||||
* @param fieldValues - fieldName field values being queried
|
||||
* @returns map from all field values to a CacheLoadResult for each input value
|
||||
*/
|
||||
public abstract loadManyAsync<N extends keyof TFields>(
|
||||
loadManyAsync<N extends keyof TFields>(
|
||||
fieldName: N,
|
||||
fieldValues: readonly NonNullable<TFields[N]>[]
|
||||
): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult<TFields>>>;
|
||||
@@ -24,7 +21,7 @@ export default abstract class EntityCacheAdapter<TFields> {
|
||||
* @param fieldName - object field being queried
|
||||
* @param objectMap - map from field value to object to cache
|
||||
*/
|
||||
public abstract cacheManyAsync<N extends keyof TFields>(
|
||||
cacheManyAsync<N extends keyof TFields>(
|
||||
fieldName: N,
|
||||
objectMap: ReadonlyMap<NonNullable<TFields[N]>, Readonly<TFields>>
|
||||
): Promise<void>;
|
||||
@@ -35,7 +32,7 @@ export default abstract class EntityCacheAdapter<TFields> {
|
||||
* @param fieldValues - fieldValues for objects reported as CacheStatus.NEGATIVE
|
||||
* in the cache and not found in the DB.
|
||||
*/
|
||||
public abstract cacheDBMissesAsync<N extends keyof TFields>(
|
||||
cacheDBMissesAsync<N extends keyof TFields>(
|
||||
fieldName: N,
|
||||
fieldValues: readonly NonNullable<TFields[N]>[]
|
||||
): Promise<void>;
|
||||
@@ -45,7 +42,7 @@ export default abstract class EntityCacheAdapter<TFields> {
|
||||
* @param fieldName - object field being queried
|
||||
* @param fieldValues - fieldName field values to be invalidated
|
||||
*/
|
||||
public abstract invalidateManyAsync<N extends keyof TFields>(
|
||||
invalidateManyAsync<N extends keyof TFields>(
|
||||
fieldName: N,
|
||||
fieldValues: readonly NonNullable<TFields[N]>[]
|
||||
): Promise<void>;
|
||||
@@ -1,5 +1,5 @@
|
||||
import EntityCacheAdapter from './EntityCacheAdapter';
|
||||
import EntityConfiguration from './EntityConfiguration';
|
||||
import IEntityCacheAdapter from './IEntityCacheAdapter';
|
||||
|
||||
/**
|
||||
* A cache adapter provider vends cache adapters for a particular cache adapter type.
|
||||
@@ -7,9 +7,9 @@ import EntityConfiguration from './EntityConfiguration';
|
||||
*/
|
||||
export default interface IEntityCacheAdapterProvider {
|
||||
/**
|
||||
* Vend a cache adapter.
|
||||
* Vend a cache adapter for an entity configuration.
|
||||
*/
|
||||
getCacheAdapter<TFields>(
|
||||
entityConfiguration: EntityConfiguration<TFields>
|
||||
): EntityCacheAdapter<TFields>;
|
||||
): IEntityCacheAdapter<TFields>;
|
||||
}
|
||||
@@ -1,15 +1,45 @@
|
||||
import { CacheLoadResult } from './internal/ReadThroughEntityCache';
|
||||
|
||||
/**
|
||||
* A cacher stores and loads key-value pairs. It also supports negative caching - it stores the absence
|
||||
* of keys that don't exist in the backing datastore.
|
||||
* A generic cacher stores and loads key-value pairs. It also supports negative caching - it stores the absence
|
||||
* of keys that don't exist in the backing datastore. It is also responsible for cache key creation.
|
||||
*/
|
||||
export default interface IEntityGenericCacher<TFields> {
|
||||
/**
|
||||
* Load many keys from the cache. Return info in a format that is useful for read-through caching and
|
||||
* negative caching.
|
||||
*
|
||||
* @param keys - cache keys to load
|
||||
*/
|
||||
loadManyAsync(keys: readonly string[]): Promise<ReadonlyMap<string, CacheLoadResult<TFields>>>;
|
||||
|
||||
/**
|
||||
* Cache many objects for specified keys.
|
||||
*
|
||||
* @param objectMap - map from cache key to object to cache for key
|
||||
*/
|
||||
cacheManyAsync(objectMap: ReadonlyMap<string, Readonly<TFields>>): Promise<void>;
|
||||
|
||||
/**
|
||||
* Negatively-cache specified keys. Subsequent loads for these keys (without calling invalidate) may
|
||||
* return a negative CacheLoadResult
|
||||
*
|
||||
* @param keys - keys to cache negatively
|
||||
*/
|
||||
cacheDBMissesAsync(keys: readonly string[]): Promise<void>;
|
||||
|
||||
/**
|
||||
* Invalidate specified keys in cache. Subsequent loads for these keys may return a cache miss.
|
||||
*
|
||||
* @param keys - keys to invalidate
|
||||
*/
|
||||
invalidateManyAsync(keys: readonly string[]): Promise<void>;
|
||||
|
||||
/**
|
||||
* Create a cache key for a field and value of a object being cached or invalidated.
|
||||
*
|
||||
* @param fieldName - name of the object field for this cache key
|
||||
* @param fieldValue - value of the obejct field for this cache key
|
||||
*/
|
||||
makeCacheKey<N extends keyof TFields>(fieldName: N, fieldValue: NonNullable<TFields[N]>): string;
|
||||
}
|
||||
@@ -2,12 +2,10 @@ import invariant from 'invariant';
|
||||
|
||||
import { IEntityClass } from './Entity';
|
||||
import EntityAssociationLoader from './EntityAssociationLoader';
|
||||
import { EntityCompanionDefinition } from './EntityCompanionProvider';
|
||||
import EntityLoader from './EntityLoader';
|
||||
import EntityPrivacyPolicy from './EntityPrivacyPolicy';
|
||||
import { EntityQueryContext } from './EntityQueryContext';
|
||||
import ViewerContext from './ViewerContext';
|
||||
import { pick } from './entityUtils';
|
||||
|
||||
/**
|
||||
* A readonly entity exposes only the read functionality of an Entity. Used as the base
|
||||
@@ -18,45 +16,47 @@ import { pick } from './entityUtils';
|
||||
* - Entities representing immutable tables.
|
||||
*/
|
||||
export default abstract class ReadonlyEntity<
|
||||
TFields,
|
||||
TFields extends object,
|
||||
TID extends NonNullable<TFields[TSelectedFields]>,
|
||||
TViewerContext extends ViewerContext,
|
||||
TSelectedFields extends keyof TFields = keyof TFields
|
||||
> {
|
||||
private readonly viewerContext: TViewerContext;
|
||||
private readonly id: TID;
|
||||
private readonly rawFields: Readonly<Pick<TFields, TSelectedFields>>;
|
||||
private readonly databaseFields: Readonly<TFields>;
|
||||
private readonly selectedFields: Readonly<Pick<TFields, TSelectedFields>>;
|
||||
|
||||
/**
|
||||
* Constructs an instance of an Entity.
|
||||
* @param viewerContext - the ViewerContext reading this entity
|
||||
* @param rawFields - all underlying fields for this entity's data
|
||||
*
|
||||
* @param constructorParam - data needed to construct an instance of an entity
|
||||
* viewerContext - the ViewerContext reading this entity
|
||||
* id - the ID of this entity
|
||||
* databaseFields - all underlying fields for this entity's data
|
||||
* selectedFields - selected fields for this entity from TSelectedFields type
|
||||
*
|
||||
* This should only be overridden in cases where additional data validation is needed.
|
||||
* The params should not be modified when calling super during constructions.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
constructor(
|
||||
private readonly viewerContext: TViewerContext,
|
||||
private readonly databaseFields: Readonly<TFields>
|
||||
) {
|
||||
const companionDefinition = (
|
||||
this.constructor as any
|
||||
).getCompanionDefinition() as EntityCompanionDefinition<
|
||||
TFields,
|
||||
TID,
|
||||
TViewerContext,
|
||||
this,
|
||||
EntityPrivacyPolicy<TFields, TID, TViewerContext, this, TSelectedFields>,
|
||||
TSelectedFields
|
||||
>;
|
||||
const idField = companionDefinition.entityConfiguration.idField as keyof Pick<
|
||||
TFields,
|
||||
TSelectedFields
|
||||
>;
|
||||
const id = databaseFields[idField];
|
||||
invariant(id, 'must provide ID to create an entity');
|
||||
this.id = id as any;
|
||||
constructor({
|
||||
viewerContext,
|
||||
id,
|
||||
databaseFields,
|
||||
selectedFields,
|
||||
}: {
|
||||
viewerContext: TViewerContext;
|
||||
id: TID;
|
||||
databaseFields: Readonly<TFields>;
|
||||
selectedFields: Readonly<Pick<TFields, TSelectedFields>>;
|
||||
}) {
|
||||
invariant(id !== null && id !== undefined, 'id must be non-null');
|
||||
|
||||
const entitySelectedFields = companionDefinition.entitySelectedFields as (keyof TFields)[];
|
||||
this.rawFields = pick(databaseFields, entitySelectedFields);
|
||||
this.viewerContext = viewerContext;
|
||||
this.id = id;
|
||||
this.databaseFields = databaseFields;
|
||||
this.selectedFields = selectedFields;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
@@ -102,14 +102,14 @@ export default abstract class ReadonlyEntity<
|
||||
getField<K extends keyof Pick<TFields, TSelectedFields>>(
|
||||
fieldName: K
|
||||
): Pick<TFields, TSelectedFields>[K] {
|
||||
return this.rawFields[fieldName];
|
||||
return this.selectedFields[fieldName];
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns all underlying fields from this entity's data
|
||||
*/
|
||||
getAllFields(): Readonly<Pick<TFields, TSelectedFields>> {
|
||||
return { ...this.rawFields };
|
||||
return { ...this.selectedFields };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -125,7 +125,7 @@ export default abstract class ReadonlyEntity<
|
||||
* @param queryContext - query context in which to perform the load
|
||||
*/
|
||||
static loader<
|
||||
TMFields,
|
||||
TMFields extends object,
|
||||
TMID extends NonNullable<TMFields[TMSelectedFields]>,
|
||||
TMViewerContext extends ViewerContext,
|
||||
TMViewerContext2 extends TMViewerContext,
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { IEntityClass } from './Entity';
|
||||
import EntityCompanionProvider, { DatabaseAdapterFlavor } from './EntityCompanionProvider';
|
||||
import EntityPrivacyPolicy from './EntityPrivacyPolicy';
|
||||
import { EntityQueryContext, EntityTransactionalQueryContext } from './EntityQueryContext';
|
||||
import {
|
||||
EntityQueryContext,
|
||||
EntityTransactionalQueryContext,
|
||||
TransactionConfig,
|
||||
} from './EntityQueryContext';
|
||||
import ReadonlyEntity from './ReadonlyEntity';
|
||||
import ViewerScopedEntityCompanion from './ViewerScopedEntityCompanion';
|
||||
import ViewerScopedEntityCompanionProvider from './ViewerScopedEntityCompanionProvider';
|
||||
@@ -27,7 +31,7 @@ export default class ViewerContext {
|
||||
}
|
||||
|
||||
getViewerScopedEntityCompanionForClass<
|
||||
TMFields,
|
||||
TMFields extends object,
|
||||
TMID extends NonNullable<TMFields[TMSelectedFields]>,
|
||||
TMViewerContext extends ViewerContext,
|
||||
TMEntity extends ReadonlyEntity<TMFields, TMID, TMViewerContext, TMSelectedFields>,
|
||||
@@ -56,10 +60,7 @@ export default class ViewerContext {
|
||||
TMPrivacyPolicy,
|
||||
TMSelectedFields
|
||||
> {
|
||||
return this.viewerScopedEntityCompanionProvider.getViewerScopedCompanionForEntity(
|
||||
entityClass,
|
||||
entityClass.getCompanionDefinition()
|
||||
);
|
||||
return this.viewerScopedEntityCompanionProvider.getViewerScopedCompanionForEntity(entityClass);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,11 +83,12 @@ export default class ViewerContext {
|
||||
*/
|
||||
async runInTransactionForDatabaseAdaptorFlavorAsync<TResult>(
|
||||
databaseAdaptorFlavor: DatabaseAdapterFlavor,
|
||||
transactionScope: (queryContext: EntityTransactionalQueryContext) => Promise<TResult>
|
||||
transactionScope: (queryContext: EntityTransactionalQueryContext) => Promise<TResult>,
|
||||
transactionConfig?: TransactionConfig
|
||||
): Promise<TResult> {
|
||||
return await this.entityCompanionProvider
|
||||
.getQueryContextProviderForDatabaseAdaptorFlavor(databaseAdaptorFlavor)
|
||||
.getQueryContext()
|
||||
.runInTransactionIfNotInTransactionAsync(transactionScope);
|
||||
.runInTransactionIfNotInTransactionAsync(transactionScope, transactionConfig);
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import IEntityMetricsAdapter from './metrics/IEntityMetricsAdapter';
|
||||
* from the viewer-scoped entity companion provider.
|
||||
*/
|
||||
export default class ViewerScopedEntityCompanion<
|
||||
TFields,
|
||||
TFields extends object,
|
||||
TID extends NonNullable<TFields[TSelectedFields]>,
|
||||
TViewerContext extends ViewerContext,
|
||||
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
|
||||
@@ -26,7 +26,7 @@ export default class ViewerScopedEntityCompanion<
|
||||
TSelectedFields extends keyof TFields
|
||||
> {
|
||||
constructor(
|
||||
private readonly entityCompanion: EntityCompanion<
|
||||
public readonly entityCompanion: EntityCompanion<
|
||||
TFields,
|
||||
TID,
|
||||
TViewerContext,
|
||||
|
||||
Loaded 100 of 154 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user