Compare commits

..
Author SHA1 Message Date
Will Schurman 58797a50b6 Change boolean decider to be async 2024-05-07 09:25:04 -07:00
Will Schurman d09a735264 Address comment 2024-05-07 09:20:21 -07:00
Will Schurman dc3aafc6ab feat: add privacy policy rule reordering 2024-05-06 13:02:27 -07:00
Will Schurman 87532525d0 feat: Add StrictEnumField with better validation (#222) 2024-05-02 11:30:32 -07:00
Will Schurman 33771295c5 v0.35.0 2024-04-11 09:55:15 -07:00
Will Schurman 1bdfbd6b56 fix: Revert: require explicit query context specification (#219)
This reverts commit 8b0b31fdde.
2024-04-11 09:51:40 -07:00
Will Schurman 0fa6f554a2 v0.34.0 2024-04-11 09:13:48 -07:00
Will Schurman 8b0b31fdde feat: require explicit query context specification (#219) 2024-04-11 09:09:18 -07:00
James Ide cc6f3dc1e6 feat: Allow UUIDv6/7/8 in UUIDField validation (#221)
Why
---
Currently the UUIDField type supports only UUIDv1-5 and to use a newer UUID version we need to use StringField.

How
---
Manually implemented UUID validation, adding support for versions 6, 7, and 8. Also added support for the "max" UUID, which is now part of the spec.

Test Plan
---
Added a UUIDv7 value (from https://uuid7.com/) to test cases.
2024-04-08 18:17:24 -07:00
Will Schurman 9c43a56a39 BREAKING CHANGE: hoist the responsibility of sharing local memory cache adapter into application (#218) 2023-12-06 12:38:30 -08:00
Will Schurman ef7b452c9f docs: fix EntityMutationTrigger docblocks (#216) 2023-12-04 16:18:29 -08:00
Will Schurman 659e08386f v0.33.0 2023-10-06 16:00:57 -07:00
Will Schurman aad94aca47 feat: add loadManyByIDsNullableAsync loader method (#214) 2023-10-04 15:28:28 -07:00
Will Schurman d2a4405fa1 v0.32.0 2023-06-09 20:13:23 -05:00
Will Schurman 503a2fb6a8 fix: remove unused field from redis configuration (#213) 2023-06-09 17:08:57 -07:00
Will Schurman 3a00a43036 chore: fix lint (#212) 2023-06-09 11:38:07 -07:00
Will Schurman 05b14843a6 chore: upgrade dependencies (#211) 2023-06-09 11:33:58 -07:00
Will Schurman 6f8020fbf8 chore: refactor common cache logic into GenericEntityCacheAdapter (#209) 2023-06-09 11:32:34 -07:00
Will Schurman 4b18010d42 feat: entity companion definition static thunk (#210) 2023-06-09 11:20:24 -07:00
146 changed files with 5344 additions and 4433 deletions

No files matched your search

+51
View File
@@ -3,6 +3,57 @@
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)
+1 -1
View File
@@ -4,5 +4,5 @@
],
"npmClient": "yarn",
"useWorkspaces": true,
"version": "0.31.1"
"version": "0.35.0"
}
+11 -13
View File
@@ -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",
"eslint": "^8.14.0",
"eslint-config-universe": "^11.2.0",
"eslint-plugin-tsdoc": "^0.2.17",
"ioredis": "^5.2.5",
"jest": "^26.6.3",
"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,47 @@
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
@@ -1,6 +1,6 @@
{
"name": "@expo/entity-cache-adapter-local-memory",
"version": "0.31.1",
"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.31.1"
"@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)
);
});
}
}
@@ -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()),
]);
@@ -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', []);
});
});
});
@@ -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';
@@ -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,
});
@@ -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,52 @@
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
@@ -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.31.1",
"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.31.1",
"@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));
}
}
@@ -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()),
@@ -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()
@@ -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,
});
@@ -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,48 @@
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
@@ -1,6 +1,6 @@
{
"name": "@expo/entity-database-adapter-knex",
"version": "0.31.1",
"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.4.1"
"knex": "^2.4.2"
},
"devDependencies": {
"@expo/entity": "^0.31.1",
"pg": "^8.7.1"
"@expo/entity": "^0.35.0",
"pg": "8.10.0"
}
}
@@ -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,
});
@@ -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,
});
@@ -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')],
});
+42
View File
@@ -3,6 +3,48 @@
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
+14 -10
View File
@@ -1,7 +1,7 @@
{
"name": "@expo/entity-example",
"private": true,
"version": "0.31.1",
"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.31.1",
"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();
+37 -26
View File
@@ -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,45 +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,
};
}
}
const noteEntityConfiguration = 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',
});
/**
* 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: noteEntityConfiguration,
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.
+7 -2
View File
@@ -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 = {
+6 -6
View File
@@ -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,52 @@
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
@@ -1,7 +1,7 @@
{
"name": "@expo/entity-full-integration-tests",
"private": true,
"version": "0.31.1",
"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.31.1",
"@expo/entity-cache-adapter-redis": "^0.31.1",
"@expo/entity-database-adapter-knex": "^0.31.1"
"@expo/entity": "^0.35.0",
"@expo/entity-cache-adapter-redis": "^0.35.0",
"@expo/entity-database-adapter-knex": "^0.35.0"
}
}
@@ -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)
@@ -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();
@@ -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: async () => [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: async () => 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: async () => [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: async () => 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();
@@ -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: async () => ParentEntity,
edgeDeletionBehavior: EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE_ONLY,
},
}),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
});
const childEntityCompanion = new EntityCompanionDefinition({
entityClass: ChildEntity,
entityConfiguration: childEntityConfiguration,
privacyPolicyClass: TestEntityPrivacyPolicy,
});
@@ -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: async () => [ChildEntity],
schema: {
id: new UUIDField({
columnName: 'id',
cache: true,
}),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
});
const parentEntityCompanion = new EntityCompanionDefinition({
entityClass: ParentEntity,
entityConfiguration: parentEntityConfiguration,
privacyPolicyClass: TestEntityPrivacyPolicy,
});
@@ -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,38 @@
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
@@ -1,6 +1,6 @@
{
"name": "@expo/entity-ip-address-field",
"version": "0.31.1",
"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.31.1",
"@expo/entity": "^0.35.0",
"@types/jsbn": "^1.2.29"
},
"peerDependencies": {
@@ -3,6 +3,47 @@
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
@@ -1,6 +1,6 @@
{
"name": "@expo/entity-secondary-cache-local-memory",
"version": "0.31.1",
"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.31.1",
"@expo/entity-cache-adapter-local-memory": "^0.31.1",
"@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)
);
}
}
@@ -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,52 @@
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
@@ -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.31.1",
"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,8 +31,8 @@
"@expo/entity-cache-adapter-redis": "*"
},
"devDependencies": {
"@expo/entity": "^0.31.1",
"@expo/entity-cache-adapter-redis": "^0.31.1",
"@expo/entity": "^0.35.0",
"@expo/entity-cache-adapter-redis": "^0.35.0",
"ioredis": "^5.2.5",
"nullthrows": "^1.1.1"
}
@@ -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,
});
@@ -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),
},
],
])
+46
View File
@@ -3,6 +3,52 @@
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
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/entity",
"version": "0.31.1",
"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,
+10 -1
View File
@@ -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,15 @@ 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
+23 -12
View File
@@ -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 -12
View File
@@ -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,
+13 -32
View File
@@ -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
+41 -80
View File
@@ -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
);
});
+4 -5
View File
@@ -13,7 +13,7 @@ export default class EntityConfiguration<TFields> {
readonly cacheableKeys: ReadonlySet<keyof TFields>;
readonly cacheKeyVersion: number;
readonly getInboundEdges: () => Promise<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 = async () => [],
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?: () => Promise<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
+8 -10
View File
@@ -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,15 +61,13 @@ export interface EntityAssociationDefinition<
/**
* Class of entity on the other end of this edge.
*/
getAssociatedEntityClass: () => Promise<
IEntityClass<
TAssociatedFields,
TAssociatedID,
TViewerContext,
TAssociatedEntity,
TAssociatedPrivacyPolicy,
TAssociatedSelectedFields
>
associatedEntityClass: IEntityClass<
TAssociatedFields,
TAssociatedID,
TViewerContext,
TAssociatedEntity,
TAssociatedPrivacyPolicy,
TAssociatedSelectedFields
>;
/**
+20 -3
View File
@@ -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);
}
}
+36 -2
View File
@@ -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,23 @@ 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
@@ -332,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;
@@ -342,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.
+7 -9
View File
@@ -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
);
+2 -2
View File
@@ -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>,
+38 -31
View File
@@ -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 = await 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 = (
await association.getAssociatedEntityClass()
).getCompanionDefinition().entityConfiguration;
const associatedConfiguration = this.companionProvider.getCompanionForEntity(
association.associatedEntityClass
).entityCompanionDefinition.entityConfiguration;
if (associatedConfiguration !== entityConfiguration) {
return;
}
+6 -1
View File
@@ -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,
+30 -4
View File
@@ -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 {
@@ -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))
);
}
}
@@ -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>;
}
+32 -2
View File
@@ -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;
}
+32 -32
View File
@@ -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,
+2 -5
View File
@@ -31,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>,
@@ -60,10 +60,7 @@ export default class ViewerContext {
TMPrivacyPolicy,
TMSelectedFields
> {
return this.viewerScopedEntityCompanionProvider.getViewerScopedCompanionForEntity(
entityClass,
entityClass.getCompanionDefinition()
);
return this.viewerScopedEntityCompanionProvider.getViewerScopedCompanionForEntity(entityClass);
}
/**
@@ -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,
@@ -1,5 +1,5 @@
import { IEntityClass } from './Entity';
import EntityCompanionProvider, { EntityCompanionDefinition } from './EntityCompanionProvider';
import EntityCompanionProvider from './EntityCompanionProvider';
import EntityPrivacyPolicy from './EntityPrivacyPolicy';
import ReadonlyEntity from './ReadonlyEntity';
import ViewerContext from './ViewerContext';
@@ -19,10 +19,10 @@ export default class ViewerScopedEntityCompanionProvider {
* companion is constructed using the configuration provided by the factory.
*
* @param entityClass - entity class to load
* @param factory - entity companion factory
* @param entityCompanionDefinitionFn - function defining entity companion definition
*/
getViewerScopedCompanionForEntity<
TFields,
TFields extends object,
TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
@@ -42,14 +42,6 @@ export default class ViewerScopedEntityCompanionProvider {
TEntity,
TPrivacyPolicy,
TSelectedFields
>,
entityCompanionDefinition: EntityCompanionDefinition<
TFields,
TID,
TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
>
): ViewerScopedEntityCompanion<
TFields,
@@ -60,7 +52,7 @@ export default class ViewerScopedEntityCompanionProvider {
TSelectedFields
> {
return new ViewerScopedEntityCompanion(
this.entityCompanionProvider.getCompanionForEntity(entityClass, entityCompanionDefinition),
this.entityCompanionProvider.getCompanionForEntity(entityClass),
this.viewerContext as TViewerContext
);
}
@@ -9,7 +9,7 @@ import ViewerContext from './ViewerContext';
* Provides a cleaner API for loading entities by passing through the ViewerContext.
*/
export default class ViewerScopedEntityLoaderFactory<
TFields,
TFields extends object,
TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
@@ -9,7 +9,7 @@ import ViewerContext from './ViewerContext';
* Provides a cleaner API for mutating entities by passing through the ViewerContext.
*/
export default class ViewerScopedEntityMutatorFactory<
TFields,
TFields extends object,
TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
@@ -1,9 +1,9 @@
import invariant from 'invariant';
import ComposedEntityCacheAdapter from '../ComposedEntityCacheAdapter';
import EntityCacheAdapter from '../EntityCacheAdapter';
import EntityConfiguration from '../EntityConfiguration';
import { UUIDField } from '../EntityFields';
import IEntityCacheAdapter from '../IEntityCacheAdapter';
import { CacheLoadResult, CacheStatus } from '../internal/ReadThroughEntityCache';
type BlahFields = {
@@ -23,13 +23,11 @@ const entityConfiguration = new EntityConfiguration<BlahFields>({
export const DOES_NOT_EXIST_LOCAL_MEMORY_CACHE = Symbol('doesNotExist');
type LocalMemoryCacheValue<TFields> = Readonly<TFields> | typeof DOES_NOT_EXIST_LOCAL_MEMORY_CACHE;
class TestLocalCacheAdapter<TFields> extends EntityCacheAdapter<TFields> {
class TestLocalCacheAdapter<TFields> implements IEntityCacheAdapter<TFields> {
constructor(
entityConfiguration: EntityConfiguration<TFields>,
private readonly entityConfiguration: EntityConfiguration<TFields>,
private readonly cache: Map<string, LocalMemoryCacheValue<TFields>>
) {
super(entityConfiguration);
}
) {}
public async loadManyAsync<N extends keyof TFields>(
fieldName: N,
@@ -123,10 +121,7 @@ function makeTestCacheAdapters(): {
const fallbackCache = new Map();
const fallbackCacheAdapter = new TestLocalCacheAdapter(entityConfiguration, fallbackCache);
const cacheAdapter = new ComposedEntityCacheAdapter(entityConfiguration, [
primaryCacheAdapter,
fallbackCacheAdapter,
]);
const cacheAdapter = new ComposedEntityCacheAdapter([primaryCacheAdapter, fallbackCacheAdapter]);
return {
primaryCache,
@@ -229,7 +224,7 @@ describe(ComposedEntityCacheAdapter, () => {
});
it('handles 0 cache adapter compose case', async () => {
const cacheAdapter = new ComposedEntityCacheAdapter(entityConfiguration, []);
const cacheAdapter = new ComposedEntityCacheAdapter<any>([]);
const results = await cacheAdapter.loadManyAsync('id', []);
expect(results).toEqual(new Map());
});
@@ -222,6 +222,49 @@ describe(EnforcingEntityLoader, () => {
});
});
describe('loadManyByIDsNullableAsync', () => {
it('throws when result is unsuccessful even when there is a null result', async () => {
const entityLoaderMock = mock<EntityLoader<any, any, any, any, any, any>>(EntityLoader);
const rejection = new Error();
when(entityLoaderMock.loadManyByIDsNullableAsync(anything())).thenResolve(
new Map(
Object.entries({
hello: result(rejection),
world: null,
})
)
);
const entityLoader = instance(entityLoaderMock);
const enforcingEntityLoader = new EnforcingEntityLoader(entityLoader);
await expect(enforcingEntityLoader.loadManyByIDsNullableAsync(anything())).rejects.toThrow(
rejection
);
});
it('returns value when result is successful', async () => {
const entityLoaderMock = mock<EntityLoader<any, any, any, any, any, any>>(EntityLoader);
const resolved = {};
when(entityLoaderMock.loadManyByIDsNullableAsync(anything())).thenResolve(
new Map(
Object.entries({
hello: result(resolved),
world: null,
})
)
);
const entityLoader = instance(entityLoaderMock);
const enforcingEntityLoader = new EnforcingEntityLoader(entityLoader);
await expect(enforcingEntityLoader.loadManyByIDsNullableAsync(anything())).resolves.toEqual(
new Map(
Object.entries({
hello: resolved,
world: null,
})
)
);
});
});
describe('loadFirstByFieldEqualityConjunction', () => {
it('throws when result is unsuccessful', async () => {
const entityLoaderMock = mock<EntityLoader<any, any, any, any, any, any>>(EntityLoader);
@@ -330,6 +373,7 @@ describe(EnforcingEntityLoader, () => {
'tryConstructEntities',
'validateFieldValues',
'constructAndAuthorizeEntitiesAsync',
'constructEntity',
];
expect(loaderProperties).toEqual(expect.arrayContaining(knownLoaderOnlyDifferences));
Loaded 100 of 146 files, more files were not shown because too many files have changed in this diff. Show more