Compare commits

..
Author SHA1 Message Date
Quinlan Jung fa44971d81 feat: parts caching abstraction, composed cachers 2022-02-18 00:18:51 -08:00
Quinlan Jung bc378b6c79 chore: better variable and method naming (#160) 2022-02-16 18:37:51 -08:00
Quinlan Jung fefa4100fc v0.24.0 (#159)
* v0.24.0

* changelog
2022-02-16 18:37:39 -08:00
Quinlan Jung 85d35752fa feat: add local memory cache package (#158)
* feat: add local memory cache package

* some pr feedback

* more tests
2022-02-15 18:44:20 -08:00
Quinlan Jung 09d48a617c chore: refactor cachers to use an abstract class and interface (#157)
* chore: refactor cachers to use an abstract class and interface

* fix lint

* changelog
2022-02-14 18:42:27 -08:00
Will Schurman 0adcf7238f [entity] Add docblocks for EntityFields and EntityFieldDefinition (#156) 2022-02-11 14:20:45 -08:00
Quinlan Jung 41bd0722c9 v0.23.0 (#155)
* v0.23.0

* update changelog
2022-02-09 13:59:40 -08:00
Quinlan Jung 03a1fce067 move responsibility of transformer to generic redis cacher (#153)
* will's commit

* integ tests

* fix codecov
2022-02-07 15:04:02 -08:00
49 changed files with 1657 additions and 362 deletions

No files matched your search

+24
View File
@@ -3,6 +3,30 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
### Features
* add local memory cache package ([#158](https://github.com/expo/entity/issues/158)) ([85d3575](https://github.com/expo/entity/commit/85d35752fa0919a62efb776a762a6829e83ca7af))
### Chores
* Refactor cachers to use an abstract class and interface ([#157](https://github.com/expo/entity/pull/157))
# [0.23.0](https://github.com/expo/entity/compare/v0.22.0...v0.23.0) (2022-02-09)
### Breaking changes
* Move data transforming responsibilities to the CacheAdapter instead of the ReadThroughEntityCache. Affects any classes extending the EntityCacheAdapter and relying on the transformation behavior of the ReadThroughEntityCache. ([#153](https://github.com/expo/entity/pull/153))
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04) # [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
+1 -1
View File
@@ -4,5 +4,5 @@
], ],
"npmClient": "yarn", "npmClient": "yarn",
"useWorkspaces": true, "useWorkspaces": true,
"version": "0.22.0" "version": "0.24.0"
} }
+2
View File
@@ -18,6 +18,7 @@
"devDependencies": { "devDependencies": {
"@expo/entity": "file:packages/entity", "@expo/entity": "file:packages/entity",
"@expo/results": "^1.0.0", "@expo/results": "^1.0.0",
"@types/lru-cache": "^5.1.1",
"@types/invariant": "^2.2.33", "@types/invariant": "^2.2.33",
"@types/ioredis": "^4.26.4", "@types/ioredis": "^4.26.4",
"@types/jest": "^26.0.10", "@types/jest": "^26.0.10",
@@ -43,6 +44,7 @@
}, },
"dependencies": { "dependencies": {
"@expo/entity": "file:packages/entity", "@expo/entity": "file:packages/entity",
"@expo/entity-cache-adapter-local-memory": "file:packages/entity-cache-adapter-local-memory",
"@expo/entity-cache-adapter-redis": "file:packages/entity-cache-adapter-redis", "@expo/entity-cache-adapter-redis": "file:packages/entity-cache-adapter-redis",
"@expo/entity-database-adapter-knex": "file:packages/entity-database-adapter-knex", "@expo/entity-database-adapter-knex": "file:packages/entity-database-adapter-knex",
"@expo/entity-secondary-cache-redis": "file:packages/entity-secondary-cache-redis", "@expo/entity-secondary-cache-redis": "file:packages/entity-secondary-cache-redis",
@@ -0,0 +1,11 @@
# Change Log
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
### Features
* add local memory cache package ([#158](https://github.com/expo/entity/issues/158)) ([85d3575](https://github.com/expo/entity/commit/85d35752fa0919a62efb776a762a6829e83ca7af))
@@ -0,0 +1,44 @@
# `@expo/entity-cache-adapter-local-memory`
Cross-request [LRU](https://github.com/isaacs/node-lru-cache) cache adapter for `@expo/entity`. Use
this cache with caution - it is nonstandard. The cache is shared between requests in the node process.
## Why NOT use this cache
Because this is an in-memory cache, cross-box invalidation is not possible. Do not use this cache
if you have the following use cases:
- The objects stored have high mutability
- Reading a stale object from the cache is not acceptable in your application
- Cross-box invalidation is not possible
## Typical use cases
If your application sees many requests fetching the same objects, you can save a trip to your cache
cluster and backing datastore by using this in-memory cache. Here are some good use cases:
- The objects stored are mostly immutable
- You have a low TTL setting in your cache
## Usage
During `EntityCompanionProvider` instantiation:
```typescript
export const createDefaultEntityCompanionProvider = (
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter()
): EntityCompanionProvider => {
return new EntityCompanionProvider(
metricsAdapter,
{
...
},
{
['local-memory']: {
cacheAdapterProvider: new LocalMemoryCacheAdapterProvider.getProvider(),
},
}
);
};
```
@@ -0,0 +1,38 @@
{
"name": "@expo/entity-cache-adapter-local-memory",
"version": "0.24.0",
"description": "Cross-request local memory cache adapter for @expo/entity",
"files": [
"build",
"src"
],
"main": "build/index.js",
"types": "build/index.d.ts",
"scripts": {
"tsc": "tsc",
"clean": "rm -rf build coverage coverage-integration",
"lint": "eslint src",
"lint-fix": "eslint src --fix",
"test": "jest --rootDir . --config ../../resources/jest.config.js",
"integration": "../../resources/run-with-docker yarn integration-no-setup",
"integration-no-setup": "jest --config ../../resources/jest-integration.config.js --rootDir . --runInBand --passWithNoTests",
"barrelsby": "barrelsby --directory src --location top --exclude tests__ --singleQuotes --exportDefault --delete"
},
"engines": {
"node": ">=12"
},
"keywords": [
"entity"
],
"author": "Expo",
"license": "MIT",
"peerDependencies": {
"@expo/entity": "*"
},
"dependencies": {
"lru-cache": "^7.3.1"
},
"devDependencies": {
"@expo/entity": "^0.24.0"
}
}
@@ -0,0 +1,146 @@
import { CacheLoadResult, CacheStatus, IEntityGenericCacher } from '@expo/entity';
import LRUCache from 'lru-cache';
// Sentinel value we store in local memory to negatively cache a database miss.
// The sentinel value is distinct from any (positively) cached value.
export const DOES_NOT_EXIST_LOCAL_MEMORY_CACHE = Symbol('doesNotExist');
type LocalMemoryCacheValue<TFields> = Readonly<TFields> | typeof DOES_NOT_EXIST_LOCAL_MEMORY_CACHE;
export type LocalMemoryCache<TFields> = LRUCache<string, LocalMemoryCacheValue<TFields>>;
type LRUCacheOptionsV7<K, V> = {
/**
* the number of most recently used items to keep.
* note that we may store fewer items than this if maxSize is hit.
*/
max: number;
/**
* if you wish to track item size, you must provide a maxSize
* note that we still will only keep up to max *actual items*,
* so size tracking may cause fewer than max items to be stored.
* At the extreme, a single item of maxSize size will cause everything
* else in the cache to be dropped when it is added. Use with caution!
* Note also that size tracking can negatively impact performance,
* though for most cases, only minimally.
*/
maxSize?: number;
/**
* function to calculate size of items. useful if storing strings or
* buffers or other items where memory size depends on the object itself.
* also note that oversized items do NOT immediately get dropped from
* the cache, though they will cause faster turnover in the storage.
*/
sizeCalculation?: (value: V, key: K) => number;
/**
* function to call when the item is removed from the cache
* Note that using this can negatively impact performance.
*/
dispose?: (value: V, key: K) => void;
/**
* max time to live for items before they are considered stale
* note that stale items are NOT preemptively removed by default,
* and MAY live in the cache, contributing to its LRU max, long after
* they have expired.
* Also, as this cache is optimized for LRU/MRU operations, some of
* the staleness/TTL checks will reduce performance, as they will incur
* overhead by deleting items.
* Must be a positive integer in ms, defaults to 0, which means "no TTL"
*/
ttl?: number;
/**
* return stale items from cache.get() before disposing of them
* boolean, default false
*/
allowStale?: boolean;
/**
* update the age of items on cache.get(), renewing their TTL
* boolean, default false
*/
updateAgeOnGet?: boolean;
/**
* update the age of items on cache.has(), renewing their TTL
* boolean, default false
*/
updateAgeOnHas?: boolean;
/**
* update the "recently-used"-ness of items on cache.has()
* boolean, default false
*/
updateRecencyOnHas?: boolean;
};
export default class GenericLocalMemoryCacher<TFields> implements IEntityGenericCacher<TFields> {
constructor(private readonly localMemoryCache: LocalMemoryCache<TFields>) {}
static createLRUCache<TFields>(
options: { maxSize?: number; ttlSeconds?: number } = {}
): LocalMemoryCache<TFields> {
const DEFAULT_LRU_CACHE_MAX_AGE_SECONDS = 10;
const DEFAULT_LRU_CACHE_SIZE = 10000;
const maxAgeSeconds = options.ttlSeconds ?? DEFAULT_LRU_CACHE_MAX_AGE_SECONDS;
const lruCacheOptions: LRUCacheOptionsV7<string, TFields> = {
max: options.maxSize ?? DEFAULT_LRU_CACHE_SIZE,
sizeCalculation: (value: LocalMemoryCacheValue<TFields>) =>
value === DOES_NOT_EXIST_LOCAL_MEMORY_CACHE ? 0 : 1,
ttl: maxAgeSeconds * 1000, // convert to ms
};
return new LRUCache<string, LocalMemoryCacheValue<TFields>>(lruCacheOptions as any);
}
static createNoOpCache<TFields>(): LocalMemoryCache<TFields> {
return new LRUCache<string, LocalMemoryCacheValue<TFields>>({
max: 0,
maxAge: -1,
});
}
public async loadManyAsync(
keys: readonly string[]
): Promise<ReadonlyMap<string, CacheLoadResult<TFields>>> {
const cacheResults = new Map<string, CacheLoadResult<TFields>>();
for (const key of keys) {
const cacheResult = this.localMemoryCache.get(key);
if (cacheResult === DOES_NOT_EXIST_LOCAL_MEMORY_CACHE) {
cacheResults.set(key, {
status: CacheStatus.NEGATIVE,
});
} else if (cacheResult) {
cacheResults.set(key, {
status: CacheStatus.HIT,
item: cacheResult as unknown as TFields,
});
} else {
cacheResults.set(key, {
status: CacheStatus.MISS,
});
}
}
return cacheResults;
}
public async cacheManyAsync(objectMap: ReadonlyMap<string, Readonly<TFields>>): Promise<void> {
for (const [key, item] of objectMap) {
this.localMemoryCache.set(key, item);
}
}
public async cacheDBMissesAsync(keys: readonly string[]): Promise<void> {
for (const key of keys) {
this.localMemoryCache.set(key, DOES_NOT_EXIST_LOCAL_MEMORY_CACHE);
}
}
public async invalidateManyAsync(keys: readonly string[]): Promise<void> {
for (const key of keys) {
this.localMemoryCache.del(key);
}
}
}
@@ -0,0 +1,28 @@
import { EntityConfiguration, PartsCacheAdapter, Parts } from '@expo/entity';
import SimplePartsCacher from '@expo/entity/build/SimplePartsCacher';
import invariant from 'invariant';
import GenericLocalMemoryCacher, { LocalMemoryCache } from './GenericLocalMemoryCacher';
export default class LocalMemoryCacheAdapter<TFields> extends PartsCacheAdapter<TFields> {
constructor(
entityConfiguration: EntityConfiguration<TFields>,
localMemoryCache: LocalMemoryCache<TFields>
) {
super(
entityConfiguration,
new SimplePartsCacher(new GenericLocalMemoryCacher(localMemoryCache))
);
}
getParts<N extends keyof TFields>(fieldName: N, fieldValue: NonNullable<TFields[N]>): Parts {
const columnName = this.entityConfiguration.entityToDBFieldsKeyMapping.get(fieldName);
invariant(columnName, `database field mapping missing for ${fieldName}`);
return [
this.entityConfiguration.tableName,
`${this.entityConfiguration.cacheKeyVersion}`,
columnName,
String(fieldValue),
];
}
}
@@ -0,0 +1,45 @@
import {
computeIfAbsent,
EntityCacheAdapter,
EntityConfiguration,
IEntityCacheAdapterProvider,
} from '@expo/entity';
import GenericLocalMemoryCacher, { LocalMemoryCache } from './GenericLocalMemoryCacher';
import LocalMemoryCacheAdapter from './LocalMemoryCacheAdapter';
export class LocalMemoryCacheAdapterProvider implements IEntityCacheAdapterProvider {
// local memory cache adapters should be shared/reused across requests
static localMemoryCacheAdapterMap = new Map<string, LocalMemoryCacheAdapter<any>>();
static getNoOpProvider(): IEntityCacheAdapterProvider {
return new LocalMemoryCacheAdapterProvider(<TFields>() =>
GenericLocalMemoryCacher.createNoOpCache<TFields>()
);
}
static getProvider(
options: { maxSize?: number; ttlSeconds?: number } = {}
): IEntityCacheAdapterProvider {
return new LocalMemoryCacheAdapterProvider(<TFields>() =>
GenericLocalMemoryCacher.createLRUCache<TFields>(options)
);
}
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);
}
);
}
}
@@ -0,0 +1,180 @@
import { 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 {
createLocalMemoryIntegrationTestEntityCompanionProvider,
createNoopLocalMemoryIntegrationTestEntityCompanionProvider,
} from '../testfixtures/createLocalMemoryIntegrationTestEntityCompanionProvider';
class TestViewerContext extends ViewerContext {}
describe(LocalMemoryCacheAdapter, () => {
beforeEach(async () => {
LocalMemoryCacheAdapterProvider.localMemoryCacheAdapterMap.clear();
});
it('has correct caching behavior', async () => {
const viewerContext = new TestViewerContext(
createLocalMemoryIntegrationTestEntityCompanionProvider()
);
const cacheAdapter = viewerContext.entityCompanionProvider.getCompanionForEntity(
LocalMemoryTestEntity,
LocalMemoryTestEntity.getCompanionDefinition()
)['tableDataCoordinator']['cacheAdapter'];
const cacheKeyMaker = cacheAdapter['makeCacheKey'].bind(cacheAdapter);
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
const entity1 = await LocalMemoryTestEntity.loader(viewerContext)
.enforcing()
.loadByIDAsync(entity1Created.getID());
const entitySpecificGenericCacher =
LocalMemoryCacheAdapterProvider.localMemoryCacheAdapterMap.get(
LocalMemoryTestEntity.getCompanionDefinition().entityConfiguration.tableName
)!['partsCacher']['cacher'];
const cachedResult = await entitySpecificGenericCacher.loadManyAsync([
cacheKeyMaker('id', entity1.getID()),
]);
const cachedValue = cachedResult.get(cacheKeyMaker('id', entity1.getID()))!;
expect(cachedValue).toMatchObject({
status: CacheStatus.HIT,
item: {
id: entity1.getID(),
name: 'blah',
dateField: date,
},
});
// simulate non existent db fetch, should write negative result ('') to cache
const nonExistentId = uuidv4();
const entityNonExistentResult = await LocalMemoryTestEntity.loader(viewerContext).loadByIDAsync(
nonExistentId
);
expect(entityNonExistentResult.ok).toBe(false);
const nonExistentCachedResult = await entitySpecificGenericCacher.loadManyAsync([
cacheKeyMaker('id', nonExistentId),
]);
expect(nonExistentCachedResult.get(cacheKeyMaker('id', nonExistentId))).toMatchObject({
status: CacheStatus.NEGATIVE,
});
// load again through entities framework to ensure it reads negative result
const entityNonExistentResult2 = await LocalMemoryTestEntity.loader(
viewerContext
).loadByIDAsync(nonExistentId);
expect(entityNonExistentResult2.ok).toBe(false);
// invalidate from cache to ensure it invalidates correctly
await LocalMemoryTestEntity.loader(viewerContext).invalidateFieldsAsync(entity1.getAllFields());
const cachedResultMiss = await entitySpecificGenericCacher.loadManyAsync([
cacheKeyMaker('id', entity1.getID()),
]);
const cachedValueMiss = cachedResultMiss.get(cacheKeyMaker('id', entity1.getID()));
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(
createLocalMemoryIntegrationTestEntityCompanionProvider()
);
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(
createLocalMemoryIntegrationTestEntityCompanionProvider()
);
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 date = new Date();
const entity1Created = await LocalMemoryTestEntity.creator(viewerContext)
.setField('name', 'blah')
.setField('dateField', date)
.enforceCreateAsync();
// loading an entity will try to put it in cache but it's a noop cache, so it should be a miss
const entity1 = await LocalMemoryTestEntity.loader(viewerContext)
.enforcing()
.loadByIDAsync(entity1Created.getID());
const entitySpecificGenericCacher =
LocalMemoryCacheAdapterProvider.localMemoryCacheAdapterMap.get(
LocalMemoryTestEntity.getCompanionDefinition().entityConfiguration.tableName
)!['partsCacher']['cacher'];
const cachedResult = await entitySpecificGenericCacher.loadManyAsync([
cacheKeyMaker('id', entity1.getID()),
]);
const cachedValue = cachedResult.get(cacheKeyMaker('id', entity1.getID()))!;
expect(cachedValue).toMatchObject({
status: CacheStatus.MISS,
});
// a non existent db fetch should try to write negative result ('') but it's a noop cache, so it should be a miss
const nonExistentId = uuidv4();
const entityNonExistentResult = await LocalMemoryTestEntity.loader(viewerContext).loadByIDAsync(
nonExistentId
);
expect(entityNonExistentResult.ok).toBe(false);
const nonExistentCachedResult = await entitySpecificGenericCacher.loadManyAsync([
cacheKeyMaker('id', nonExistentId),
]);
expect(nonExistentCachedResult.get(cacheKeyMaker('id', nonExistentId))).toMatchObject({
status: CacheStatus.MISS,
});
});
});
@@ -0,0 +1,125 @@
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({
maxSize: Number.MAX_SAFE_INTEGER,
ttlSeconds: Number.MAX_SAFE_INTEGER,
})
);
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({
maxSize: Number.MAX_SAFE_INTEGER,
ttlSeconds: Number.MAX_SAFE_INTEGER,
})
);
const results = await cacheAdapter.loadManyAsync('id', []);
expect(results).toEqual(new Map());
});
});
describe('cacheManyAsync', () => {
it('correctly caches all objects', async () => {
const localMemoryCache = GenericLocalMemoryCacher.createLRUCache<BlahFields>({
maxSize: Number.MAX_SAFE_INTEGER,
ttlSeconds: Number.MAX_SAFE_INTEGER,
});
const cacheAdapter = new LocalMemoryCacheAdapter(entityConfiguration, localMemoryCache);
await cacheAdapter.cacheManyAsync('id', new Map([['test-id-1', { id: 'test-id-1' }]]));
const cacheKey = cacheAdapter['partsCacher']['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>({
maxSize: Number.MAX_SAFE_INTEGER,
ttlSeconds: Number.MAX_SAFE_INTEGER,
});
const cacheAdapter = new LocalMemoryCacheAdapter(entityConfiguration, localMemoryCache);
await cacheAdapter.cacheDBMissesAsync('id', ['test-id-1']);
const cacheKey = cacheAdapter['partsCacher']['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>({
maxSize: Number.MAX_SAFE_INTEGER,
ttlSeconds: Number.MAX_SAFE_INTEGER,
});
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>({
maxSize: Number.MAX_SAFE_INTEGER,
ttlSeconds: Number.MAX_SAFE_INTEGER,
})
);
await cacheAdapter.invalidateManyAsync('id', []);
});
});
});
@@ -0,0 +1,100 @@
import {
AlwaysAllowPrivacyPolicyRule,
EntityPrivacyPolicy,
ViewerContext,
UUIDField,
DateField,
StringField,
EntityConfiguration,
EntityCompanionDefinition,
Entity,
} from '@expo/entity';
export type LocalMemoryTestEntityFields = {
id: string;
name: string;
dateField: Date | null;
};
export default class LocalMemoryTestEntity extends Entity<
LocalMemoryTestEntityFields,
string,
ViewerContext
> {
static getCompanionDefinition(): EntityCompanionDefinition<
LocalMemoryTestEntityFields,
string,
ViewerContext,
LocalMemoryTestEntity,
LocalMemoryTestEntityPrivacyPolicy
> {
return localMemoryTestEntityCompanionDefinition;
}
}
export class LocalMemoryTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
LocalMemoryTestEntityFields,
string,
ViewerContext,
LocalMemoryTestEntity
> {
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<
LocalMemoryTestEntityFields,
string,
ViewerContext,
LocalMemoryTestEntity
>(),
];
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<
LocalMemoryTestEntityFields,
string,
ViewerContext,
LocalMemoryTestEntity
>(),
];
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<
LocalMemoryTestEntityFields,
string,
ViewerContext,
LocalMemoryTestEntity
>(),
];
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<
LocalMemoryTestEntityFields,
string,
ViewerContext,
LocalMemoryTestEntity
>(),
];
}
export const localMemoryTestEntityConfiguration =
new EntityConfiguration<LocalMemoryTestEntityFields>({
idField: 'id',
tableName: 'local_memory_test_entities',
schema: {
id: new UUIDField({
columnName: 'id',
cache: true,
}),
name: new StringField({
columnName: 'name',
cache: true,
}),
dateField: new DateField({
columnName: 'date_field',
}),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'local-memory',
});
const localMemoryTestEntityCompanionDefinition = new EntityCompanionDefinition({
entityClass: LocalMemoryTestEntity,
entityConfiguration: localMemoryTestEntityConfiguration,
privacyPolicyClass: LocalMemoryTestEntityPrivacyPolicy,
});
@@ -0,0 +1,48 @@
import {
NoOpEntityMetricsAdapter,
IEntityMetricsAdapter,
EntityCompanionProvider,
StubQueryContextProvider,
StubDatabaseAdapterProvider,
} from '@expo/entity';
import { LocalMemoryCacheAdapterProvider } from '../LocalMemoryCacheAdapterProvider';
export const createLocalMemoryIntegrationTestEntityCompanionProvider = (
localMemoryOptions: { maxSize?: number; ttlSeconds?: number } = {},
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter()
): EntityCompanionProvider => {
const localMemoryCacheAdapterProvider =
localMemoryOptions.maxSize === 0 && localMemoryOptions.ttlSeconds === 0
? LocalMemoryCacheAdapterProvider.getNoOpProvider()
: LocalMemoryCacheAdapterProvider.getProvider(localMemoryOptions);
return new EntityCompanionProvider(
metricsAdapter,
new Map([
[
'postgres',
{
adapterProvider: new StubDatabaseAdapterProvider(),
queryContextProvider: StubQueryContextProvider,
},
],
]),
new Map([
[
'local-memory',
{
cacheAdapterProvider: localMemoryCacheAdapterProvider,
},
],
])
);
};
export const createNoopLocalMemoryIntegrationTestEntityCompanionProvider = (
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter()
): EntityCompanionProvider => {
return createLocalMemoryIntegrationTestEntityCompanionProvider(
{ maxSize: 0, ttlSeconds: 0 },
metricsAdapter
);
};
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "build",
},
"include": ["src"]
}
@@ -3,6 +3,25 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
### Chores
* Refactor cachers to use an abstract class and interface ([#157](https://github.com/expo/entity/pull/157))
# [0.23.0](https://github.com/expo/entity/compare/v0.22.0...v0.23.0) (2022-02-09)
### Chores
* Move data transforming responsibilities to the CacheAdapter instead of the ReadThroughEntityCache. ([#153](https://github.com/expo/entity/pull/153))
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04) # [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis **Note:** Version bump only for package @expo/entity-cache-adapter-redis
@@ -1,6 +1,6 @@
{ {
"name": "@expo/entity-cache-adapter-redis", "name": "@expo/entity-cache-adapter-redis",
"version": "0.22.0", "version": "0.24.0",
"description": "Redis cache adapter for @expo/entity", "description": "Redis cache adapter for @expo/entity",
"files": [ "files": [
"build", "build",
@@ -33,6 +33,6 @@
"ioredis": "^4.27.3" "ioredis": "^4.27.3"
}, },
"devDependencies": { "devDependencies": {
"@expo/entity": "^0.22.0" "@expo/entity": "^0.24.0"
} }
} }
@@ -1,6 +1,14 @@
import { CacheLoadResult, CacheStatus } from '@expo/entity'; import {
CacheLoadResult,
CacheStatus,
EntityConfiguration,
transformCacheObjectToFields,
transformFieldsToCacheObject,
IEntityGenericCacher,
} from '@expo/entity';
import { Redis } from 'ioredis'; import { Redis } from 'ioredis';
import { redisTransformerMap } from './RedisCommon';
import wrapNativeRedisCallAsync from './errors/wrapNativeRedisCallAsync'; import wrapNativeRedisCallAsync from './errors/wrapNativeRedisCallAsync';
// Sentinel value we store in Redis to negatively cache a database miss. // Sentinel value we store in Redis to negatively cache a database miss.
@@ -26,12 +34,15 @@ export interface GenericRedisCacheContext {
ttlSecondsNegative: number; ttlSecondsNegative: number;
} }
export default class GenericRedisCacher { export default class GenericRedisCacher<TFields> implements IEntityGenericCacher<TFields> {
constructor(private readonly context: GenericRedisCacheContext) {} constructor(
private readonly context: GenericRedisCacheContext,
private readonly entityConfiguration: EntityConfiguration<TFields>
) {}
public async loadManyAsync( public async loadManyAsync(
keys: readonly string[] keys: readonly string[]
): Promise<ReadonlyMap<string, CacheLoadResult>> { ): Promise<ReadonlyMap<string, CacheLoadResult<TFields>>> {
if (keys.length === 0) { if (keys.length === 0) {
return new Map(); return new Map();
} }
@@ -40,7 +51,7 @@ export default class GenericRedisCacher {
this.context.redisClient.mget(...keys) this.context.redisClient.mget(...keys)
); );
const results = new Map<string, CacheLoadResult>(); const results = new Map<string, CacheLoadResult<TFields>>();
for (let i = 0; i < keys.length; i++) { for (let i = 0; i < keys.length; i++) {
const key = keys[i]!; const key = keys[i]!;
const redisResult = redisResults[i]; const redisResult = redisResults[i];
@@ -52,7 +63,11 @@ export default class GenericRedisCacher {
} else if (redisResult) { } else if (redisResult) {
results.set(key, { results.set(key, {
status: CacheStatus.HIT, status: CacheStatus.HIT,
item: JSON.parse(redisResult), item: transformCacheObjectToFields(
this.entityConfiguration,
redisTransformerMap,
JSON.parse(redisResult)
),
}); });
} else { } else {
results.set(key, { results.set(key, {
@@ -63,7 +78,7 @@ export default class GenericRedisCacher {
return results; return results;
} }
public async cacheManyAsync(objectMap: ReadonlyMap<string, object>): Promise<void> { public async cacheManyAsync(objectMap: ReadonlyMap<string, Readonly<TFields>>): Promise<void> {
if (objectMap.size === 0) { if (objectMap.size === 0) {
return; return;
} }
@@ -72,7 +87,9 @@ export default class GenericRedisCacher {
objectMap.forEach((object, key) => { objectMap.forEach((object, key) => {
redisTransaction = redisTransaction.set( redisTransaction = redisTransaction.set(
key, key,
JSON.stringify(object), JSON.stringify(
transformFieldsToCacheObject(this.entityConfiguration, redisTransformerMap, object)
),
'EX', 'EX',
this.context.ttlSecondsPositive this.context.ttlSecondsPositive
); );
@@ -80,7 +97,7 @@ export default class GenericRedisCacher {
await wrapNativeRedisCallAsync(() => redisTransaction.exec()); await wrapNativeRedisCallAsync(() => redisTransaction.exec());
} }
public async cacheDBMissesAsync(keys: string[]): Promise<void> { public async cacheDBMissesAsync(keys: readonly string[]): Promise<void> {
if (keys.length === 0) { if (keys.length === 0) {
return; return;
} }
@@ -97,7 +114,7 @@ export default class GenericRedisCacher {
await wrapNativeRedisCallAsync(() => redisTransaction.exec()); await wrapNativeRedisCallAsync(() => redisTransaction.exec());
} }
public async invalidateManyAsync(keys: string[]): Promise<void> { public async invalidateManyAsync(keys: readonly string[]): Promise<void> {
if (keys.length === 0) { if (keys.length === 0) {
return; return;
} }
@@ -1,18 +1,9 @@
import { import { EntityConfiguration, PartsCacheAdapter, Parts } from '@expo/entity';
EntityCacheAdapter, import SimplePartsCacher from '@expo/entity/build/SimplePartsCacher';
EntityConfiguration,
CacheLoadResult,
FieldTransformerMap,
mapKeys,
transformCacheObjectToFields,
CacheStatus,
transformFieldsToCacheObject,
} from '@expo/entity';
import invariant from 'invariant'; import invariant from 'invariant';
import { Redis } from 'ioredis'; import { Redis } from 'ioredis';
import GenericRedisCacher from './GenericRedisCacher'; import GenericRedisCacher from './GenericRedisCacher';
import { redisTransformerMap } from './RedisCommon';
export interface RedisCacheAdapterContext { export interface RedisCacheAdapterContext {
/** /**
@@ -51,114 +42,36 @@ export interface RedisCacheAdapterContext {
ttlSecondsNegative: number; ttlSecondsNegative: number;
} }
export default class RedisCacheAdapter<TFields> extends EntityCacheAdapter<TFields> { export default class RedisCacheAdapter<TFields> extends PartsCacheAdapter<TFields> {
/**
* Transformer definitions for field types. Used to modify values as they are read from or written to
* the cache. Override in concrete subclasses to change transformation behavior.
* If a field type is not present in the map, then fields of that type will not be transformed.
*/
private readonly redisFieldTransformer: FieldTransformerMap = redisTransformerMap;
private readonly genericRedisCacher: GenericRedisCacher;
constructor( constructor(
entityConfiguration: EntityConfiguration<TFields>, entityConfiguration: EntityConfiguration<TFields>,
private readonly context: RedisCacheAdapterContext private readonly context: RedisCacheAdapterContext
) { ) {
super(entityConfiguration); super(
entityConfiguration,
this.genericRedisCacher = new GenericRedisCacher({ new SimplePartsCacher(
redisClient: context.redisClient, new GenericRedisCacher(
ttlSecondsNegative: context.ttlSecondsNegative, {
ttlSecondsPositive: context.ttlSecondsPositive, redisClient: context.redisClient,
}); ttlSecondsNegative: context.ttlSecondsNegative,
} ttlSecondsPositive: context.ttlSecondsPositive,
},
public async loadManyAsync<N extends keyof TFields>( entityConfiguration
fieldName: N, ),
fieldValues: readonly NonNullable<TFields[N]>[] context.makeKeyFn
): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult>> { )
const redisCacheKeyToFieldValueMapping = new Map(
fieldValues.map((fieldValue) => [this.makeCacheKey(fieldName, fieldValue), fieldValue])
);
const rawCacheResults = await this.genericRedisCacher.loadManyAsync(
Array.from(redisCacheKeyToFieldValueMapping.keys())
);
const cacheResults = new Map<string, CacheLoadResult>();
for (const [redisCacheKey, rawCacheResult] of rawCacheResults) {
if (rawCacheResult.status === CacheStatus.HIT) {
cacheResults.set(redisCacheKey, {
status: CacheStatus.HIT,
item: transformCacheObjectToFields(
this.entityConfiguration,
this.redisFieldTransformer,
rawCacheResult.item
),
});
} else {
cacheResults.set(redisCacheKey, rawCacheResult);
}
}
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]>, object>
): Promise<void> {
const cacheObjectMap = new Map<NonNullable<TFields[N]>, object>();
for (const [fieldValue, object] of objectMap) {
cacheObjectMap.set(
fieldValue,
transformFieldsToCacheObject(
this.entityConfiguration,
this.redisFieldTransformer,
object as Readonly<TFields>
)
);
}
await this.genericRedisCacher.cacheManyAsync(
mapKeys(cacheObjectMap, (fieldValue) => this.makeCacheKey(fieldName, fieldValue))
); );
} }
public async cacheDBMissesAsync<N extends keyof TFields>( getParts<N extends keyof TFields>(fieldName: N, fieldValue: NonNullable<TFields[N]>): Parts {
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); const columnName = this.entityConfiguration.entityToDBFieldsKeyMapping.get(fieldName);
invariant(columnName, `database field mapping missing for ${fieldName}`); invariant(columnName, `database field mapping missing for ${fieldName}`);
return this.context.makeKeyFn( return [
this.context.cacheKeyPrefix, this.context.cacheKeyPrefix,
this.entityConfiguration.tableName, this.entityConfiguration.tableName,
`v2.${this.entityConfiguration.cacheKeyVersion}`, `v2.${this.entityConfiguration.cacheKeyVersion}`,
columnName, columnName,
String(fieldValue) String(fieldValue),
); ];
} }
} }
@@ -0,0 +1,125 @@
import { CacheStatus, ViewerContext } from '@expo/entity';
import Redis from 'ioredis';
import { URL } from 'url';
import GenericRedisCacher from '../GenericRedisCacher';
import { RedisCacheAdapterContext } from '../RedisCacheAdapter';
import RedisTestEntity, {
redisTestEntityConfiguration,
RedisTestEntityFields,
} from '../testfixtures/RedisTestEntity';
import { createRedisIntegrationTestEntityCompanionProvider } from '../testfixtures/createRedisIntegrationTestEntityCompanionProvider';
class TestViewerContext extends ViewerContext {}
describe(GenericRedisCacher, () => {
let redisCacheAdapterContext: RedisCacheAdapterContext;
beforeAll(() => {
redisCacheAdapterContext = {
redisClient: new Redis(new URL(process.env['REDIS_URL']!).toString()),
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`)
);
return escapedParts.join(delimiter);
},
cacheKeyPrefix: 'test-',
cacheKeyVersion: 1,
ttlSecondsPositive: 86400, // 1 day
ttlSecondsNegative: 600, // 10 minutes
};
});
beforeEach(async () => {
await redisCacheAdapterContext.redisClient.flushdb();
});
afterAll(async () => {
redisCacheAdapterContext.redisClient.disconnect();
});
it('has correct caching and loading behavior', async () => {
const viewerContext = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
);
const genericRedisCacher = new GenericRedisCacher(
{
redisClient: redisCacheAdapterContext.redisClient,
ttlSecondsNegative: redisCacheAdapterContext.ttlSecondsNegative,
ttlSecondsPositive: redisCacheAdapterContext.ttlSecondsPositive,
},
redisTestEntityConfiguration
);
const date = new Date();
const entity1Created = await RedisTestEntity.creator(viewerContext)
.setField('name', 'blah')
.setField('dateField', date)
.enforceCreateAsync();
const testKey = `test-id-key-${entity1Created.getID()}`;
const objectMap = new Map<string, Readonly<RedisTestEntityFields>>([
[testKey, entity1Created.getAllFields()],
]);
await genericRedisCacher.cacheManyAsync(objectMap);
const cachedJSON = await redisCacheAdapterContext.redisClient.get(testKey);
const cachedValue = JSON.parse(cachedJSON!);
expect(cachedValue).toMatchObject({
id: entity1Created.getID(),
dateField: date.toISOString(),
});
const loadedObjectMap = await genericRedisCacher.loadManyAsync([testKey]);
const cacheLoadResult = loadedObjectMap.get(testKey)!;
expect(cacheLoadResult).toMatchObject({
status: CacheStatus.HIT,
item: entity1Created.getAllFields(),
});
expect(loadedObjectMap.size).toBe(1);
});
it('has correct negative caching behaviour', async () => {
const genericRedisCacher = new GenericRedisCacher(
{
redisClient: redisCacheAdapterContext.redisClient,
ttlSecondsNegative: redisCacheAdapterContext.ttlSecondsNegative,
ttlSecondsPositive: redisCacheAdapterContext.ttlSecondsPositive,
},
redisTestEntityConfiguration
);
const testKey = `test-id-key-non-existent-id`;
await genericRedisCacher.cacheDBMissesAsync([testKey]);
const loadedObjectMap = await genericRedisCacher.loadManyAsync([testKey]);
const cacheLoadResult = loadedObjectMap.get(testKey)!;
expect(cacheLoadResult.status).toBe(CacheStatus.NEGATIVE);
});
it('has correct invalidation behaviour', async () => {
const viewerContext = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
);
const genericRedisCacher = new GenericRedisCacher(
{
redisClient: redisCacheAdapterContext.redisClient,
ttlSecondsNegative: redisCacheAdapterContext.ttlSecondsNegative,
ttlSecondsPositive: redisCacheAdapterContext.ttlSecondsPositive,
},
redisTestEntityConfiguration
);
const date = new Date();
const entity1Created = await RedisTestEntity.creator(viewerContext)
.setField('name', 'blah')
.setField('dateField', date)
.enforceCreateAsync();
const testKey = `test-id-key-${entity1Created.getID()}`;
const objectMap = new Map<string, Readonly<RedisTestEntityFields>>([
[testKey, entity1Created.getAllFields()],
]);
await genericRedisCacher.cacheManyAsync(objectMap);
await genericRedisCacher.invalidateManyAsync([testKey]);
const loadedObjectMap = await genericRedisCacher.loadManyAsync([testKey]);
const cacheLoadResult = loadedObjectMap.get(testKey)!;
expect(cacheLoadResult.status).toBe(CacheStatus.MISS);
});
});
@@ -1,4 +1,4 @@
import { CacheStatus, UUIDField, EntityConfiguration, DateField } from '@expo/entity'; import { CacheStatus, UUIDField, EntityConfiguration } from '@expo/entity';
import { Redis, Pipeline } from 'ioredis'; import { Redis, Pipeline } from 'ioredis';
import { mock, when, instance, anything, verify } from 'ts-mockito'; import { mock, when, instance, anything, verify } from 'ts-mockito';
@@ -6,7 +6,6 @@ import RedisCacheAdapter from '../RedisCacheAdapter';
type BlahFields = { type BlahFields = {
id: string; id: string;
date: Date | null;
}; };
const entityConfiguration = new EntityConfiguration<BlahFields>({ const entityConfiguration = new EntityConfiguration<BlahFields>({
@@ -14,7 +13,6 @@ const entityConfiguration = new EntityConfiguration<BlahFields>({
tableName: 'blah', tableName: 'blah',
schema: { schema: {
id: new UUIDField({ columnName: 'id', cache: true }), id: new UUIDField({ columnName: 'id', cache: true }),
date: new DateField({ columnName: 'date' }),
}, },
databaseAdapterFlavor: 'postgres', databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis', cacheAdapterFlavor: 'redis',
@@ -41,8 +39,11 @@ describe(RedisCacheAdapter, () => {
ttlSecondsNegative: 2, ttlSecondsNegative: 2,
}); });
redisResults.set(cacheAdapter['makeCacheKey']('id', 'wat'), JSON.stringify({ id: 'wat' })); redisResults.set(
redisResults.set(cacheAdapter['makeCacheKey']('id', 'who'), ''); cacheAdapter['partsCacher']['makeCacheKey']('id', 'wat'),
JSON.stringify({ id: 'wat' })
);
redisResults.set(cacheAdapter['partsCacher']['makeCacheKey']('id', 'who'), '');
const results = await cacheAdapter.loadManyAsync('id', ['wat', 'who', 'why']); const results = await cacheAdapter.loadManyAsync('id', ['wat', 'who', 'why']);
@@ -64,40 +65,6 @@ describe(RedisCacheAdapter, () => {
const results = await cacheAdapter.loadManyAsync('id', []); const results = await cacheAdapter.loadManyAsync('id', []);
expect(results).toEqual(new Map()); expect(results).toEqual(new Map());
}); });
it('transforms a cache object to an Entity object', async () => {
const redisResults = new Map();
const mockRedisClient = mock<Redis>();
// need to have one anything() for each element of spread, in this case 1
when(mockRedisClient.mget(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,
});
const date = new Date();
redisResults.set(
cacheAdapter['makeCacheKey']('id', 'wat'),
JSON.stringify({ id: 'wat', date: date.toISOString() })
);
const results = await cacheAdapter.loadManyAsync('id', ['wat']);
expect(results.get('wat')).toMatchObject({
status: CacheStatus.HIT,
item: { id: 'wat', date },
});
expect(results.size).toBe(1);
});
}); });
describe('cacheManyAsync', () => { describe('cacheManyAsync', () => {
@@ -127,48 +94,13 @@ describe(RedisCacheAdapter, () => {
}); });
await cacheAdapter.cacheManyAsync('id', new Map([['wat', { id: 'wat' }]])); await cacheAdapter.cacheManyAsync('id', new Map([['wat', { id: 'wat' }]]));
const cacheKey = cacheAdapter['makeCacheKey']('id', 'wat'); const cacheKey = cacheAdapter['partsCacher']['makeCacheKey']('id', 'wat');
expect(redisResults.get(cacheKey)).toMatchObject({ expect(redisResults.get(cacheKey)).toMatchObject({
value: JSON.stringify({ id: 'wat' }), value: JSON.stringify({ id: 'wat' }),
code: 'EX', code: 'EX',
ttl: 1, ttl: 1,
}); });
}); });
it('transforms an Entity object to a cache object', 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,
});
const date = new Date();
await cacheAdapter.cacheManyAsync('id', new Map([['wat', { id: 'wat', date }]]));
const cacheKey = cacheAdapter['makeCacheKey']('id', 'wat');
expect(redisResults.get(cacheKey)).toMatchObject({
value: JSON.stringify({ id: 'wat', date: date.toISOString() }),
code: 'EX',
ttl: 1,
});
});
}); });
describe('cacheDBMissesAsync', () => { describe('cacheDBMissesAsync', () => {
@@ -197,7 +129,7 @@ describe(RedisCacheAdapter, () => {
}); });
await cacheAdapter.cacheDBMissesAsync('id', ['wat']); await cacheAdapter.cacheDBMissesAsync('id', ['wat']);
const cacheKey = cacheAdapter['makeCacheKey']('id', 'wat'); const cacheKey = cacheAdapter['partsCacher']['makeCacheKey']('id', 'wat');
expect(redisResults.get(cacheKey)).toMatchObject({ expect(redisResults.get(cacheKey)).toMatchObject({
value: '', value: '',
code: 'EX', code: 'EX',
@@ -221,7 +153,7 @@ describe(RedisCacheAdapter, () => {
}); });
await cacheAdapter.invalidateManyAsync('id', ['wat']); await cacheAdapter.invalidateManyAsync('id', ['wat']);
const cacheKey = cacheAdapter['makeCacheKey']('id', 'wat'); const cacheKey = cacheAdapter['partsCacher']['makeCacheKey']('id', 'wat');
verify(mockRedisClient.del(cacheKey)).once(); verify(mockRedisClient.del(cacheKey)).once();
}); });
@@ -3,6 +3,22 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
# [0.23.0](https://github.com/expo/entity/compare/v0.22.0...v0.23.0) (2022-02-09)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04) # [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
@@ -1,6 +1,6 @@
{ {
"name": "@expo/entity-database-adapter-knex", "name": "@expo/entity-database-adapter-knex",
"version": "0.22.0", "version": "0.24.0",
"description": "Knex database adapter for @expo/entity", "description": "Knex database adapter for @expo/entity",
"files": [ "files": [
"build", "build",
@@ -33,7 +33,7 @@
"knex": "^1.0.2" "knex": "^1.0.2"
}, },
"devDependencies": { "devDependencies": {
"@expo/entity": "^0.22.0", "@expo/entity": "^0.24.0",
"pg": "^8.7.1" "pg": "^8.7.1"
} }
} }
+16
View File
@@ -3,6 +3,22 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
**Note:** Version bump only for package @expo/entity-example
# [0.23.0](https://github.com/expo/entity/compare/v0.22.0...v0.23.0) (2022-02-09)
**Note:** Version bump only for package @expo/entity-example
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04) # [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
**Note:** Version bump only for package @expo/entity-example **Note:** Version bump only for package @expo/entity-example
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "@expo/entity-example", "name": "@expo/entity-example",
"private": true, "private": true,
"version": "0.22.0", "version": "0.24.0",
"description": "An example integration of the @expo/entity framework", "description": "An example integration of the @expo/entity framework",
"scripts": { "scripts": {
"tsc": "tsc", "tsc": "tsc",
@@ -23,7 +23,7 @@
"author": "Expo", "author": "Expo",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@expo/entity": "^0.22.0", "@expo/entity": "^0.24.0",
"apollo-server-koa": "^2.16.1", "apollo-server-koa": "^2.16.1",
"graphql": "^15.3.0", "graphql": "^15.3.0",
"koa": "^2.13.0", "koa": "^2.13.0",
@@ -3,6 +3,22 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
**Note:** Version bump only for package @expo/entity-full-integration-tests
# [0.23.0](https://github.com/expo/entity/compare/v0.22.0...v0.23.0) (2022-02-09)
**Note:** Version bump only for package @expo/entity-full-integration-tests
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04) # [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
**Note:** Version bump only for package @expo/entity-full-integration-tests **Note:** Version bump only for package @expo/entity-full-integration-tests
@@ -1,7 +1,7 @@
{ {
"name": "@expo/entity-full-integration-tests", "name": "@expo/entity-full-integration-tests",
"private": true, "private": true,
"version": "0.22.0", "version": "0.24.0",
"description": "Full redis and knex integration tests for the entity framework", "description": "Full redis and knex integration tests for the entity framework",
"scripts": { "scripts": {
"tsc": "tsc", "tsc": "tsc",
@@ -27,8 +27,8 @@
"@expo/entity-database-adapter-knex": "*" "@expo/entity-database-adapter-knex": "*"
}, },
"devDependencies": { "devDependencies": {
"@expo/entity": "^0.22.0", "@expo/entity": "^0.24.0",
"@expo/entity-cache-adapter-redis": "^0.22.0", "@expo/entity-cache-adapter-redis": "^0.24.0",
"@expo/entity-database-adapter-knex": "^0.22.0" "@expo/entity-database-adapter-knex": "^0.24.0"
} }
} }
@@ -3,6 +3,22 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
**Note:** Version bump only for package @expo/entity-ip-address-field
# [0.23.0](https://github.com/expo/entity/compare/v0.22.0...v0.23.0) (2022-02-09)
**Note:** Version bump only for package @expo/entity-ip-address-field
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04) # [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
**Note:** Version bump only for package @expo/entity-ip-address-field **Note:** Version bump only for package @expo/entity-ip-address-field
@@ -1,6 +1,6 @@
{ {
"name": "@expo/entity-ip-address-field", "name": "@expo/entity-ip-address-field",
"version": "0.22.0", "version": "0.24.0",
"description": "IP address EntityField definitions for @expo/entity", "description": "IP address EntityField definitions for @expo/entity",
"files": [ "files": [
"build", "build",
@@ -28,7 +28,7 @@
"ip-address": "^8.1.0" "ip-address": "^8.1.0"
}, },
"devDependencies": { "devDependencies": {
"@expo/entity": "^0.22.0", "@expo/entity": "^0.24.0",
"@types/jsbn": "^1.2.29" "@types/jsbn": "^1.2.29"
}, },
"peerDependencies": { "peerDependencies": {
@@ -3,6 +3,24 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
### Chores
* Refactor cachers to use an abstract class and interface ([#157](https://github.com/expo/entity/pull/157))
# [0.23.0](https://github.com/expo/entity/compare/v0.22.0...v0.23.0) (2022-02-09)
### Chores
* Move data transforming responsibilities to the CacheAdapter instead of the RedisSecondaryEntityCache. ([#153](https://github.com/expo/entity/pull/153))
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04) # [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
**Note:** Version bump only for package @expo/entity-secondary-cache-redis **Note:** Version bump only for package @expo/entity-secondary-cache-redis
@@ -1,6 +1,6 @@
{ {
"name": "@expo/entity-secondary-cache-redis", "name": "@expo/entity-secondary-cache-redis",
"version": "0.22.0", "version": "0.24.0",
"description": "Redis secondary cache for @expo/entity", "description": "Redis secondary cache for @expo/entity",
"files": [ "files": [
"build", "build",
@@ -34,8 +34,8 @@
"ioredis": "^4.17.3" "ioredis": "^4.17.3"
}, },
"devDependencies": { "devDependencies": {
"@expo/entity": "^0.22.0", "@expo/entity": "^0.24.0",
"@expo/entity-cache-adapter-redis": "^0.22.0", "@expo/entity-cache-adapter-redis": "^0.24.0",
"nullthrows": "^1.1.1" "nullthrows": "^1.1.1"
} }
} }
@@ -1,136 +1,32 @@
import { import {
CacheStatus,
EntityConfiguration, EntityConfiguration,
filterMap, GenericSecondaryEntityCache,
ISecondaryEntityCache, Parts,
transformCacheObjectToFields, SimplePartsCacher,
transformFieldsToCacheObject,
zipToMap,
} from '@expo/entity'; } from '@expo/entity';
import { import { GenericRedisCacheContext, GenericRedisCacher } from '@expo/entity-cache-adapter-redis';
GenericRedisCacheContext,
GenericRedisCacher,
redisTransformerMap,
} from '@expo/entity-cache-adapter-redis';
import invariant from 'invariant';
/** /**
* A custom secondary read-through entity cache is a way to add a custom second layer of caching for a particular * A custom secondary read-through entity cache is a way to add a custom second layer of caching for a particular
* single entity load. One common way this may be used is to add a second layer of caching in a hot path that makes * single entity load. One common way this may be used is to add a second layer of caching in a hot path that makes
* a call to {@link EntityLoader.loadManyByFieldEqualityConjunctionAsync} is guaranteed to return at most one entity. * a call to {@link EntityLoader.loadManyByFieldEqualityConjunctionAsync} is guaranteed to return at most one entity.
*/ */
export default class RedisSecondaryEntityCache<TFields, TLoadParams> export default class RedisSecondaryEntityCache<
implements ISecondaryEntityCache<TFields, TLoadParams> TFields,
{ TLoadParams
private readonly genericRedisCacher: GenericRedisCacher; > extends GenericSecondaryEntityCache<TFields, TLoadParams> {
constructor( constructor(
private readonly entityConfiguration: EntityConfiguration<TFields>, entityConfiguration: EntityConfiguration<TFields>,
genericRedisCacheContext: GenericRedisCacheContext, genericRedisCacheContext: GenericRedisCacheContext,
private readonly constructRedisKey: (params: Readonly<TLoadParams>) => string makeRedisSpecificKey: (...parts: Parts) => string,
getParts: (params: Readonly<TLoadParams>) => Parts
) { ) {
this.genericRedisCacher = new GenericRedisCacher(genericRedisCacheContext); super(
} new SimplePartsCacher(
new GenericRedisCacher(genericRedisCacheContext, entityConfiguration),
/** makeRedisSpecificKey
* Read-through cache function. Steps: ),
* getParts
* 1. Check for cached objects
* 2. Query the fetcher for loadParams not in the cache
* 3. Cache the results from the fetcher
* 4. Negatively cache anything missing from the fetcher
* 5. Return the full set of data for the query.
*
* @param loadParamsArray - array of loadParams to load from the cache
* @param fetcher - closure used to provide underlying data source objects for loadParams
* @returns map from loadParams to the entity field object
*/
public async loadManyThroughAsync(
loadParamsArray: readonly Readonly<TLoadParams>[],
fetcher: (
fetcherLoadParamsArray: readonly Readonly<TLoadParams>[]
) => Promise<ReadonlyMap<Readonly<TLoadParams>, Readonly<TFields> | null>>
): Promise<ReadonlyMap<Readonly<TLoadParams>, Readonly<TFields> | null>> {
const redisKeys = loadParamsArray.map(this.constructRedisKey);
const redisKeyToLoadParamsMap = zipToMap(redisKeys, loadParamsArray);
const cacheLoadResults = await this.genericRedisCacher.loadManyAsync(redisKeys);
invariant(
cacheLoadResults.size === loadParamsArray.length,
`${this.constructor.name} loadMany should return a result for each key`
); );
const redisKeysToFetch = Array.from(
filterMap(
cacheLoadResults,
(cacheLoadResult) => cacheLoadResult.status === CacheStatus.MISS
).keys()
);
// put transformed cache hits in result map
const results: Map<Readonly<TLoadParams>, Readonly<TFields> | null> = new Map();
cacheLoadResults.forEach((cacheLoadResult, redisKey) => {
if (cacheLoadResult.status === CacheStatus.HIT) {
const loadParams = redisKeyToLoadParamsMap.get(redisKey);
invariant(loadParams !== undefined, 'load params should be in redis key map');
results.set(
loadParams,
transformCacheObjectToFields(
this.entityConfiguration,
redisTransformerMap,
cacheLoadResult.item
)
);
}
});
// fetch any misses from DB, add DB objects to results, cache DB results, inform cache of any missing DB results
if (redisKeysToFetch.length > 0) {
const loadParamsToFetch = redisKeysToFetch.map((redisKey) => {
const loadParams = redisKeyToLoadParamsMap.get(redisKey);
invariant(loadParams !== undefined, 'load params should be in redis key map');
return loadParams;
});
const fetchResults = await fetcher(loadParamsToFetch);
const fetchMisses = loadParamsToFetch.filter((loadParams) => {
// all values of fetchResults should be field objects or undefined
return !fetchResults.get(loadParams);
});
for (const fetchMiss of fetchMisses) {
results.set(fetchMiss, null);
}
const objectsToCache: Map<string, object> = new Map();
for (const [loadParams, object] of fetchResults.entries()) {
if (object) {
objectsToCache.set(
this.constructRedisKey(loadParams),
transformFieldsToCacheObject(this.entityConfiguration, redisTransformerMap, object)
);
results.set(loadParams, object);
}
}
await Promise.all([
this.genericRedisCacher.cacheManyAsync(objectsToCache),
this.genericRedisCacher.cacheDBMissesAsync(fetchMisses.map(this.constructRedisKey)),
]);
}
return results;
}
/**
* Invalidate the cache for objects cached by constructRedisKey(loadParams).
*
* @param loadParamsArray - load params to invalidate
*/
public async invalidateManyAsync(
loadParamsArray: readonly Readonly<TLoadParams>[]
): Promise<void> {
await this.genericRedisCacher.invalidateManyAsync(loadParamsArray.map(this.constructRedisKey));
} }
} }
@@ -87,7 +87,8 @@ describe(RedisSecondaryEntityCache, () => {
new RedisSecondaryEntityCache( new RedisSecondaryEntityCache(
redisTestEntityConfiguration, redisTestEntityConfiguration,
redisCacheAdapterContext, redisCacheAdapterContext,
(loadParams) => `test-key-${loadParams.id}` (...parts) => parts.join(':redis-specific-delimiter:'),
(loadParams) => [`test-key-${loadParams.id}`]
), ),
RedisTestEntity.loader(viewerContext) RedisTestEntity.loader(viewerContext)
); );
@@ -126,7 +127,8 @@ describe(RedisSecondaryEntityCache, () => {
new RedisSecondaryEntityCache( new RedisSecondaryEntityCache(
redisTestEntityConfiguration, redisTestEntityConfiguration,
redisCacheAdapterContext, redisCacheAdapterContext,
(loadParams) => `test-key-${loadParams.id}` (...parts) => parts.join(':redis-specific-delimiter:'),
(loadParams) => [`test-key-${loadParams.id}`]
), ),
RedisTestEntity.loader(viewerContext) RedisTestEntity.loader(viewerContext)
); );
+21
View File
@@ -3,6 +3,27 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
**Note:** Version bump only for package @expo/entity
### Chores
* Refactor cachers to use an abstract class and interface ([#157](https://github.com/expo/entity/pull/157))
# [0.23.0](https://github.com/expo/entity/compare/v0.22.0...v0.23.0) (2022-02-09)
### Breaking changes
* Move data transforming responsibilities to the CacheAdapter instead of the ReadThroughEntityCache. Affects any classes extending the EntityCacheAdapter and relying on the transformation behavior of the ReadThroughEntityCache. ([#153](https://github.com/expo/entity/pull/153))
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04) # [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
**Note:** Version bump only for package @expo/entity **Note:** Version bump only for package @expo/entity
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@expo/entity", "name": "@expo/entity",
"version": "0.22.0", "version": "0.24.0",
"description": "A privacy-first data model", "description": "A privacy-first data model",
"files": [ "files": [
"build", "build",
@@ -0,0 +1,82 @@
import { CacheLoadResult } from '@expo/entity/src/internal/ReadThroughEntityCache';
import invariant from 'invariant';
import PartsCacher, { Parts, PartsKey } from './PartsCacher';
import { CacheStatus } from './internal/ReadThroughEntityCache';
/**
* A composed cacher by which objects are stored, fetched and removed in a series of caches
* */
export default class ComposedPartsCacher<TFields> extends PartsCacher<TFields> {
constructor(
/**
* The series of cachers to use, in order of evaluation
* For example, [cacheAdapterEvaluatedFirst, cacheAdapterEvaluatedSecond, cacheAdapterEvaluatedThird]
*/
private readonly partsCachers: PartsCacher<TFields>[]
) {
super();
}
public async loadManyAsync(
partsList: readonly Parts[]
): Promise<ReadonlyMap<PartsKey, CacheLoadResult<TFields>>> {
const resultsFromAllCachers = new Map<PartsKey, CacheLoadResult<TFields>>();
const partsKeysToParts = new Map(
partsList.map((parts) => [PartsCacher.getPartsKey(...parts), parts])
);
let partsKeysToLookup: PartsKey[] = partsList.map((parts) => PartsCacher.getPartsKey(...parts));
for (const partsCacher of this.partsCachers) {
const partsListToLookup = partsKeysToLookup.map((partsKey) => {
const parts = partsKeysToParts.get(partsKey);
invariant(parts, 'parts cannot be undefined');
return parts;
});
const cacheResults = await partsCacher.loadManyAsync(partsListToLookup);
const cacheMisses = [];
for (const [partsKey, cacheResult] of cacheResults) {
if (cacheResult.status === CacheStatus.MISS) {
cacheMisses.push(partsKey);
} else {
resultsFromAllCachers.set(partsKey, cacheResult);
}
}
partsKeysToLookup = cacheMisses;
}
// None of the cachers have these values, so they are misses
for (const partsKey of partsKeysToLookup) {
resultsFromAllCachers.set(partsKey, {
status: CacheStatus.MISS,
});
}
return resultsFromAllCachers;
}
public async cacheManyAsync(objectMap: ReadonlyMap<PartsKey, Readonly<TFields>>): Promise<void> {
// perform action on lower layers first
for (let i = this.partsCachers.length - 1; i >= 0; i--) {
const partsCacher = this.partsCachers[i];
invariant(partsCacher, 'parts cacher cannot be undefined');
await partsCacher.cacheManyAsync(objectMap);
}
}
public async cacheDBMissesAsync(partsList: readonly Parts[]): Promise<void> {
// perform action on lower layers first
for (let i = this.partsCachers.length - 1; i >= 0; i--) {
const partsCacher = this.partsCachers[i];
invariant(partsCacher, 'parts cacher cannot be undefined');
await partsCacher.cacheDBMissesAsync(partsList);
}
}
public async invalidateManyAsync(partsList: readonly Parts[]): Promise<void> {
// perform action on lower layers first
for (let i = this.partsCachers.length - 1; i >= 0; i--) {
const cacheAdapter = this.partsCachers[i];
invariant(cacheAdapter, 'parts cacher cannot be undefined');
await cacheAdapter.invalidateManyAsync(partsList);
}
}
}
+2 -2
View File
@@ -17,7 +17,7 @@ export default abstract class EntityCacheAdapter<TFields> {
public abstract loadManyAsync<N extends keyof TFields>( public abstract loadManyAsync<N extends keyof TFields>(
fieldName: N, fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[] fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult>>; ): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult<TFields>>>;
/** /**
* Cache many objects fetched from the DB. * Cache many objects fetched from the DB.
@@ -26,7 +26,7 @@ export default abstract class EntityCacheAdapter<TFields> {
*/ */
public abstract cacheManyAsync<N extends keyof TFields>( public abstract cacheManyAsync<N extends keyof TFields>(
fieldName: N, fieldName: N,
objectMap: ReadonlyMap<NonNullable<TFields[N]>, object> objectMap: ReadonlyMap<NonNullable<TFields[N]>, Readonly<TFields>>
): Promise<void>; ): Promise<void>;
/** /**
@@ -28,6 +28,9 @@ export enum EntityEdgeDeletionBehavior {
SET_NULL, SET_NULL,
} }
/**
* Defines an association between entities. An association is primarily used to define cascading deletion behavior.
*/
export interface EntityAssociationDefinition< export interface EntityAssociationDefinition<
TViewerContext extends ViewerContext, TViewerContext extends ViewerContext,
TAssociatedFields, TAssociatedFields,
@@ -83,6 +86,10 @@ export interface EntityAssociationDefinition<
edgeDeletionBehavior?: EntityEdgeDeletionBehavior; edgeDeletionBehavior?: EntityEdgeDeletionBehavior;
} }
/**
* Definition for a field referencing a column in the underlying database. Specifies things like
* cache behavior and associations, and handles input validation.
*/
export abstract class EntityFieldDefinition<T> { export abstract class EntityFieldDefinition<T> {
readonly columnName: string; readonly columnName: string;
readonly cache: boolean; readonly cache: boolean;
@@ -93,6 +100,7 @@ export abstract class EntityFieldDefinition<T> {
* @param cache - Whether or not to cache loaded instances of the entity by this field. The column name is * @param cache - Whether or not to cache loaded instances of the entity by this field. The column name is
* used to derive a cache key for the cache entry. If true, this column must be able uniquely * used to derive a cache key for the cache entry. If true, this column must be able uniquely
* identify the entity. * identify the entity.
* @param association - Defines the association behavior for an entity that this column references.
*/ */
constructor({ constructor({
columnName, columnName,
+45
View File
@@ -2,59 +2,104 @@ import { validate as validateUUID } from 'uuid';
import { EntityFieldDefinition } from './EntityFieldDefinition'; import { EntityFieldDefinition } from './EntityFieldDefinition';
/**
* {@link EntityFieldDefinition} for a column with a JS string type.
*/
export class StringField extends EntityFieldDefinition<string> { export class StringField extends EntityFieldDefinition<string> {
protected validateInputValueInternal(value: string): boolean { protected validateInputValueInternal(value: string): boolean {
return typeof value === 'string'; return typeof value === 'string';
} }
} }
/**
* {@link EntityFieldDefinition} for a column with a JS string type.
* Enforces that the string is a valid UUID.
*/
export class UUIDField extends StringField { export class UUIDField extends StringField {
protected override validateInputValueInternal(value: string): boolean { protected override validateInputValueInternal(value: string): boolean {
return validateUUID(value); return validateUUID(value);
} }
} }
/**
* {@link EntityFieldDefinition} for a column with a JS Date type.
*/
export class DateField extends EntityFieldDefinition<Date> { export class DateField extends EntityFieldDefinition<Date> {
protected validateInputValueInternal(value: Date): boolean { protected validateInputValueInternal(value: Date): boolean {
return value instanceof Date; return value instanceof Date;
} }
} }
/**
* {@link EntityFieldDefinition} for a column with a JS boolean type.
*/
export class BooleanField extends EntityFieldDefinition<boolean> { export class BooleanField extends EntityFieldDefinition<boolean> {
protected validateInputValueInternal(value: boolean): boolean { protected validateInputValueInternal(value: boolean): boolean {
return typeof value === 'boolean'; return typeof value === 'boolean';
} }
} }
/**
* {@link EntityFieldDefinition} for a column with a JS number type.
* Enforces that the number is an integer.
*/
export class IntField extends EntityFieldDefinition<number> { export class IntField extends EntityFieldDefinition<number> {
protected validateInputValueInternal(value: number): boolean { protected validateInputValueInternal(value: number): boolean {
return typeof value === 'number' && Number.isInteger(value); return typeof value === 'number' && Number.isInteger(value);
} }
} }
/**
* {@link EntityFieldDefinition} for a column with a JS number type.
* Enforces that the number is a float (which includes integers in JS).
*/
export class FloatField extends EntityFieldDefinition<number> { export class FloatField extends EntityFieldDefinition<number> {
protected validateInputValueInternal(value: number): boolean { protected validateInputValueInternal(value: number): boolean {
return typeof value === 'number'; return typeof value === 'number';
} }
} }
/**
* {@link EntityFieldDefinition} for a column with a JS string array type.
* Enforces that every member of the string array is a string.
*/
export class StringArrayField extends EntityFieldDefinition<string[]> { export class StringArrayField extends EntityFieldDefinition<string[]> {
protected validateInputValueInternal(value: string[]): boolean { protected validateInputValueInternal(value: string[]): boolean {
return Array.isArray(value) && value.every((subValue) => typeof subValue === 'string'); return Array.isArray(value) && value.every((subValue) => typeof subValue === 'string');
} }
} }
/**
* {@link EntityFieldDefinition} for a column with a JS JSON object type.
*/
export class JSONObjectField extends EntityFieldDefinition<object> { export class JSONObjectField extends EntityFieldDefinition<object> {
protected validateInputValueInternal(value: object): boolean { protected validateInputValueInternal(value: object): boolean {
return typeof value === 'object' && !Array.isArray(value); return typeof value === 'object' && !Array.isArray(value);
} }
} }
/**
* {@link EntityFieldDefinition} for a enum column with a JS string or number type.
*/
export class EnumField extends EntityFieldDefinition<string | number> { export class EnumField extends EntityFieldDefinition<string | number> {
protected validateInputValueInternal(value: string | number): boolean { protected validateInputValueInternal(value: string | number): boolean {
return typeof value === 'number' || typeof value === 'string'; return typeof value === 'number' || typeof value === 'string';
} }
} }
/**
* {@link EntityFieldDefinition} for a column with a JS JSON array type.
*/
export class JSONArrayField extends EntityFieldDefinition<any[]> { export class JSONArrayField extends EntityFieldDefinition<any[]> {
protected validateInputValueInternal(value: any[]): boolean { protected validateInputValueInternal(value: any[]): boolean {
return Array.isArray(value); return Array.isArray(value);
} }
} }
/**
* {@link EntityFieldDefinition} for a column that may be a JS JSON array type.
* Does not do any validation.
*/
export class MaybeJSONArrayField extends EntityFieldDefinition<any | any[]> { export class MaybeJSONArrayField extends EntityFieldDefinition<any | any[]> {
protected validateInputValueInternal(_value: any): boolean { protected validateInputValueInternal(_value: any): boolean {
return true; return true;
@@ -0,0 +1,106 @@
import invariant from 'invariant';
import { ISecondaryEntityCache } from './EntitySecondaryCacheLoader';
import PartsCacher, { Parts, PartsKey } from './PartsCacher';
import { CacheStatus } from './internal/ReadThroughEntityCache';
import { filterMap } from './utils/collections/maps';
/**
* A custom secondary read-through entity cache is a way to add a custom second layer of caching for a particular
* single entity load. One common way this may be used is to add a second layer of caching in a hot path that makes
* a call to {@link EntityLoader.loadManyByFieldEqualityConjunctionAsync} is guaranteed to return at most one entity.
*/
export default abstract class GenericSecondaryEntityCache<TFields, TLoadParams>
implements ISecondaryEntityCache<TFields, TLoadParams>
{
constructor(
protected readonly partsCacher: PartsCacher<TFields>,
protected readonly getParts: (params: Readonly<TLoadParams>) => Parts
) {}
public async loadManyThroughAsync(
loadParamsArray: readonly Readonly<TLoadParams>[],
fetcher: (
fetcherLoadParamsArray: readonly Readonly<TLoadParams>[]
) => Promise<ReadonlyMap<Readonly<TLoadParams>, Readonly<TFields> | null>>
): Promise<ReadonlyMap<Readonly<TLoadParams>, Readonly<TFields> | null>> {
const partsList = loadParamsArray.map(this.getParts);
const partsKeysToLoadParamsMap = new Map(
loadParamsArray.map((loadParams) => {
const parts = this.getParts(loadParams);
return [PartsCacher.getPartsKey(...parts), loadParams];
})
);
const cacheLoadResults = await this.partsCacher.loadManyAsync(partsList);
invariant(
cacheLoadResults.size === loadParamsArray.length,
`${this.constructor.name} loadMany should return a result for each key`
);
const partsKeysToFetch = Array.from(
filterMap(
cacheLoadResults,
(cacheLoadResult) => cacheLoadResult.status === CacheStatus.MISS
).keys()
);
// put cache hits in result map
const results: Map<Readonly<TLoadParams>, Readonly<TFields> | null> = new Map();
cacheLoadResults.forEach((cacheLoadResult, partsKey) => {
if (cacheLoadResult.status === CacheStatus.HIT) {
const loadParams = partsKeysToLoadParamsMap.get(partsKey);
invariant(loadParams !== undefined, 'load params should be in cache key map');
results.set(loadParams, cacheLoadResult.item);
}
});
// fetch any misses from DB, add DB objects to results, cache DB results, inform cache of any missing DB results
if (partsKeysToFetch.length > 0) {
const loadParamsToFetch = partsKeysToFetch.map((partKey) => {
const loadParams = partsKeysToLoadParamsMap.get(partKey);
invariant(loadParams !== undefined, 'load params should be in cache key map');
return loadParams;
});
const fetchResults = await fetcher(loadParamsToFetch);
const fetchMisses = loadParamsToFetch.filter((loadParams) => {
// all values of fetchResults should be field objects or undefined
return !fetchResults.get(loadParams);
});
for (const fetchMiss of fetchMisses) {
results.set(fetchMiss, null);
}
const objectsToCache: Map<PartsKey, Readonly<TFields>> = new Map();
for (const [loadParams, object] of fetchResults.entries()) {
if (object) {
const parts = this.getParts(loadParams);
objectsToCache.set(PartsCacher.getPartsKey(...parts), object);
results.set(loadParams, object);
}
}
await Promise.all([
this.partsCacher.cacheManyAsync(objectsToCache),
this.partsCacher.cacheDBMissesAsync(
fetchMisses.map((loadParams) => this.getParts(loadParams))
),
]);
}
return results;
}
/**
* Invalidate the cache for objects cached by constructCacheKey(loadParams).
*
* @param loadParamsArray - load params to invalidate
*/
public invalidateManyAsync(loadParamsArray: readonly Readonly<TLoadParams>[]): Promise<void> {
const partsList = loadParamsArray.map(this.getParts);
return this.partsCacher.invalidateManyAsync(partsList);
}
}
@@ -0,0 +1,15 @@
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.
*/
export default interface IEntityGenericCacher<TFields> {
loadManyAsync(keys: readonly string[]): Promise<ReadonlyMap<string, CacheLoadResult<TFields>>>;
cacheManyAsync(objectMap: ReadonlyMap<string, Readonly<TFields>>): Promise<void>;
cacheDBMissesAsync(keys: readonly string[]): Promise<void>;
invalidateManyAsync(keys: readonly string[]): Promise<void>;
}
@@ -0,0 +1,41 @@
import { RedisCacheAdapterContext } from '@expo/entity-cache-adapter-redis';
import invariant from 'invariant';
import ComposedPartsCacher from './ComposedPartsCacher';
import EntityConfiguration from './EntityConfiguration';
import PartsCacheAdapter from './PartsCacheAdapter';
import PartsCacher, { Parts } from './PartsCacher';
/**
* TODO: put this in www
*/
export default class LocalMemoryAndRedisPartsCacheAdapter<
TFields
> extends PartsCacheAdapter<TFields> {
constructor(
entityConfiguration: EntityConfiguration<TFields>,
localMemoryPartsCacher: PartsCacher<TFields>,
redisPartsCacher: PartsCacher<TFields>,
private readonly redisCacheAdapterContext: RedisCacheAdapterContext
) {
super(
entityConfiguration,
new ComposedPartsCacher<TFields>([localMemoryPartsCacher, redisPartsCacher])
);
}
/**
* TODO: this seems wrong - look at moving the redis specific bits into the makeCacheSpecificKey of the SimplePartsCacher
*/
getParts<N extends keyof TFields>(fieldName: N, fieldValue: NonNullable<TFields[N]>): Parts {
const columnName = this.entityConfiguration.entityToDBFieldsKeyMapping.get(fieldName);
invariant(columnName, `database field mapping missing for ${fieldName}`);
return [
this.redisCacheAdapterContext.cacheKeyPrefix,
this.entityConfiguration.tableName,
`v2.${this.entityConfiguration.cacheKeyVersion}`,
columnName,
String(fieldValue),
];
}
}
@@ -0,0 +1,19 @@
import ComposedPartsCacher from './ComposedPartsCacher';
import GenericSecondaryEntityCache from './GenericSecondaryEntityCache';
import PartsCacher, { Parts } from './PartsCacher';
/**
* TODO: put this in www
*/
export default class LocalMemoryAndRedisSecondaryEntityCache<
TFields,
TLoadParams
> extends GenericSecondaryEntityCache<TFields, TLoadParams> {
constructor(
localMemoryPartsCacher: PartsCacher<TFields>,
redisPartsCacher: PartsCacher<TFields>,
getParts: (params: Readonly<TLoadParams>) => Parts
) {
super(new ComposedPartsCacher([localMemoryPartsCacher, redisPartsCacher]), getParts);
}
}
+74
View File
@@ -0,0 +1,74 @@
import invariant from 'invariant';
import EntityCacheAdapter from './EntityCacheAdapter';
import EntityConfiguration from './EntityConfiguration';
import PartsCacher, { Parts } from './PartsCacher';
import { CacheLoadResult } from './internal/ReadThroughEntityCache';
import { mapKeys } from './utils/collections/maps';
export default abstract class PartsCacheAdapter<TFields> extends EntityCacheAdapter<TFields> {
constructor(
entityConfiguration: EntityConfiguration<TFields>,
private readonly partsCacher: PartsCacher<TFields>
) {
super(entityConfiguration);
}
public async loadManyAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult<TFields>>> {
const partsList = fieldValues.map((fieldValue) => this.getParts(fieldName, fieldValue));
const partsKeysToFieldValueMapping = new Map(
fieldValues.map((fieldValue) => {
const parts = this.getParts(fieldName, fieldValue);
return [PartsCacher.getPartsKey(...parts), fieldValue];
})
);
const cacheResults = await this.partsCacher.loadManyAsync(partsList);
return mapKeys(cacheResults, (partsKey) => {
const fieldValue = partsKeysToFieldValueMapping.get(partsKey);
invariant(
fieldValue !== undefined,
'Unspecified cache key %s returned from Redis parts cacher',
partsKey
);
return fieldValue;
});
}
public async cacheManyAsync<N extends keyof TFields>(
fieldName: N,
objectMap: ReadonlyMap<NonNullable<TFields[N]>, Readonly<TFields>>
): Promise<void> {
const partsKeyToObject = mapKeys(objectMap, (fieldValue) => {
const parts = this.getParts(fieldName, fieldValue);
return PartsCacher.getPartsKey(...parts);
});
await this.partsCacher.cacheManyAsync(partsKeyToObject);
}
public async cacheDBMissesAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<void> {
await this.partsCacher.cacheDBMissesAsync(
fieldValues.map((fieldValue) => this.getParts(fieldName, fieldValue))
);
}
public async invalidateManyAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<void> {
await this.partsCacher.invalidateManyAsync(
fieldValues.map((fieldValue) => this.getParts(fieldName, fieldValue))
);
}
abstract getParts<N extends keyof TFields>(
fieldName: N,
fieldValue: NonNullable<TFields[N]>
): Parts;
}
+25
View File
@@ -0,0 +1,25 @@
import { CacheLoadResult } from '@expo/entity/src/internal/ReadThroughEntityCache';
export type Parts = string[];
export type PartsKey = string;
export default abstract class PartsCacher<TFields> {
public abstract loadManyAsync(
partsList: readonly Parts[]
): Promise<ReadonlyMap<PartsKey, CacheLoadResult<TFields>>>;
public abstract cacheManyAsync(
objectMap: ReadonlyMap<PartsKey, Readonly<TFields>>
): Promise<void>;
public abstract cacheDBMissesAsync(partsList: readonly Parts[]): Promise<void>;
public abstract invalidateManyAsync(partsList: readonly Parts[]): Promise<void>;
public static getPartsKey(...parts: Parts): PartsKey {
return JSON.stringify(parts);
}
public static getPartsFromKey(partKey: PartsKey): Parts {
return JSON.parse(partKey);
}
}
+59
View File
@@ -0,0 +1,59 @@
import IEntityGenericCacher from '@expo/entity/src/IEntityGenericCacher';
import { CacheLoadResult } from '@expo/entity/src/internal/ReadThroughEntityCache';
import { mapKeys } from '@expo/entity/src/utils/collections/maps';
import invariant from 'invariant';
import PartsCacher, { Parts, PartsKey } from './PartsCacher';
export default class SimplePartsCacher<TFields> extends PartsCacher<TFields> {
constructor(
private readonly cacher: IEntityGenericCacher<TFields>,
private readonly makeCacheSpecificKey: (...parts: Parts) => string = PartsCacher.getPartsKey
) {
super();
}
public async loadManyAsync(
partsList: readonly Parts[]
): Promise<ReadonlyMap<PartsKey, CacheLoadResult<TFields>>> {
const cacheSpecificKeyToPartKeyMapping = new Map(
partsList.map((parts) => [
this.makeCacheSpecificKey(...parts),
PartsCacher.getPartsKey(...parts),
])
);
const cacheResults = await this.cacher.loadManyAsync(
Array.from(cacheSpecificKeyToPartKeyMapping.keys())
);
return mapKeys(cacheResults, (cacheKey) => {
const partKey = cacheSpecificKeyToPartKeyMapping.get(cacheKey);
invariant(
partKey !== undefined,
'Unspecified cache key %s returned from generic local memory cacher',
cacheKey
);
return partKey;
});
}
public async cacheManyAsync(objectMap: ReadonlyMap<PartsKey, Readonly<TFields>>): Promise<void> {
await this.cacher.cacheManyAsync(
mapKeys(objectMap, (partKey) =>
this.makeCacheSpecificKey(...PartsCacher.getPartsFromKey(partKey))
)
);
}
public async cacheDBMissesAsync(partsList: readonly Parts[]): Promise<void> {
await this.cacher.cacheDBMissesAsync(
partsList.map((parts) => this.makeCacheSpecificKey(...parts))
);
}
public async invalidateManyAsync(partsList: readonly Parts[]): Promise<void> {
await this.cacher.invalidateManyAsync(
partsList.map((parts) => this.makeCacheSpecificKey(...parts))
);
}
}
+7
View File
@@ -4,6 +4,7 @@
* @module @expo/entity * @module @expo/entity
*/ */
export { default as GenericSecondaryEntityCache } from './GenericSecondaryEntityCache';
export { default as EnforcingEntityLoader } from './EnforcingEntityLoader'; export { default as EnforcingEntityLoader } from './EnforcingEntityLoader';
export { default as Entity } from './Entity'; export { default as Entity } from './Entity';
export * from './Entity'; export * from './Entity';
@@ -43,6 +44,12 @@ export * from './EntityQueryContext';
export { default as IEntityCacheAdapterProvider } from './IEntityCacheAdapterProvider'; export { default as IEntityCacheAdapterProvider } from './IEntityCacheAdapterProvider';
export { default as IEntityDatabaseAdapterProvider } from './IEntityDatabaseAdapterProvider'; export { default as IEntityDatabaseAdapterProvider } from './IEntityDatabaseAdapterProvider';
export { default as EntityQueryContextProvider } from './EntityQueryContextProvider'; export { default as EntityQueryContextProvider } from './EntityQueryContextProvider';
export { default as IEntityGenericCacher } from './IEntityGenericCacher';
export { default as SimplePartsCacher } from './SimplePartsCacher';
export { default as PartsCacher } from './PartsCacher';
export * from './PartsCacher';
export { default as PartsCacheAdapter } from './PartsCacheAdapter';
export { default as ComposedPartsCacher } from './ComposedPartsCacher';
export { default as ReadonlyEntity } from './ReadonlyEntity'; export { default as ReadonlyEntity } from './ReadonlyEntity';
export { default as ViewerContext } from './ViewerContext'; export { default as ViewerContext } from './ViewerContext';
export { default as ViewerScopedEntityCompanion } from './ViewerScopedEntityCompanion'; export { default as ViewerScopedEntityCompanion } from './ViewerScopedEntityCompanion';
@@ -10,10 +10,10 @@ export enum CacheStatus {
NEGATIVE, NEGATIVE,
} }
export type CacheLoadResult = export type CacheLoadResult<TFields> =
| { | {
status: CacheStatus.HIT; status: CacheStatus.HIT;
item: Readonly<object>; item: Readonly<TFields>;
} }
| { | {
status: CacheStatus.MISS; status: CacheStatus.MISS;
@@ -82,7 +82,7 @@ export default class ReadThroughEntityCache<TFields> {
const results: Map<NonNullable<TFields[N]>, readonly Readonly<TFields>[]> = new Map(); const results: Map<NonNullable<TFields[N]>, readonly Readonly<TFields>[]> = new Map();
cacheLoadResults.forEach((cacheLoadResult, fieldValue) => { cacheLoadResults.forEach((cacheLoadResult, fieldValue) => {
if (cacheLoadResult.status === CacheStatus.HIT) { if (cacheLoadResult.status === CacheStatus.HIT) {
results.set(fieldValue, [cacheLoadResult.item as Readonly<TFields>]); results.set(fieldValue, [cacheLoadResult.item]);
} }
}); });
@@ -95,7 +95,7 @@ export default class ReadThroughEntityCache<TFields> {
return !objectsFromFulfillerForFv || objectsFromFulfillerForFv.length === 0; return !objectsFromFulfillerForFv || objectsFromFulfillerForFv.length === 0;
}); });
const objectsToCache: Map<NonNullable<TFields[N]>, object> = new Map(); const objectsToCache: Map<NonNullable<TFields[N]>, Readonly<TFields>> = new Map();
for (const [fieldValue, objects] of dbFetchResults.entries()) { for (const [fieldValue, objects] of dbFetchResults.entries()) {
if (objects.length > 1) { if (objects.length > 1) {
// multiple objects received for what was supposed to be a unique query, don't add to return map nor cache // multiple objects received for what was supposed to be a unique query, don't add to return map nor cache
@@ -3,7 +3,6 @@ import invariant from 'invariant';
import EntityCacheAdapter from '../../EntityCacheAdapter'; import EntityCacheAdapter from '../../EntityCacheAdapter';
import EntityConfiguration from '../../EntityConfiguration'; import EntityConfiguration from '../../EntityConfiguration';
import IEntityCacheAdapterProvider from '../../IEntityCacheAdapterProvider'; import IEntityCacheAdapterProvider from '../../IEntityCacheAdapterProvider';
import { FieldTransformerMap } from '../../internal/EntityFieldTransformationUtils';
import { CacheStatus, CacheLoadResult } from '../../internal/ReadThroughEntityCache'; import { CacheStatus, CacheLoadResult } from '../../internal/ReadThroughEntityCache';
export class NoCacheStubCacheAdapterProvider implements IEntityCacheAdapterProvider { export class NoCacheStubCacheAdapterProvider implements IEntityCacheAdapterProvider {
@@ -15,15 +14,11 @@ export class NoCacheStubCacheAdapterProvider implements IEntityCacheAdapterProvi
} }
export class NoCacheStubCacheAdapter<TFields> extends EntityCacheAdapter<TFields> { export class NoCacheStubCacheAdapter<TFields> extends EntityCacheAdapter<TFields> {
public getFieldTransformerMap(): FieldTransformerMap {
return new Map();
}
public async loadManyAsync<N extends keyof TFields>( public async loadManyAsync<N extends keyof TFields>(
_fieldName: N, _fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[] fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult>> { ): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult<TFields>>> {
return fieldValues.reduce((acc: Map<NonNullable<TFields[N]>, CacheLoadResult>, v) => { return fieldValues.reduce((acc: Map<NonNullable<TFields[N]>, CacheLoadResult<TFields>>, v) => {
acc.set(v, { acc.set(v, {
status: CacheStatus.MISS, status: CacheStatus.MISS,
}); });
@@ -33,7 +28,7 @@ export class NoCacheStubCacheAdapter<TFields> extends EntityCacheAdapter<TFields
public async cacheManyAsync<N extends keyof TFields>( public async cacheManyAsync<N extends keyof TFields>(
_fieldName: N, _fieldName: N,
_objectMap: ReadonlyMap<NonNullable<TFields[N]>, object> _objectMap: ReadonlyMap<NonNullable<TFields[N]>, Readonly<TFields>>
): Promise<void> {} ): Promise<void> {}
public async cacheDBMissesAsync<N extends keyof TFields>( public async cacheDBMissesAsync<N extends keyof TFields>(
@@ -53,27 +48,26 @@ export class InMemoryFullCacheStubCacheAdapterProvider implements IEntityCacheAd
getCacheAdapter<TFields>( getCacheAdapter<TFields>(
entityConfiguration: EntityConfiguration<TFields> entityConfiguration: EntityConfiguration<TFields>
): EntityCacheAdapter<TFields> { ): EntityCacheAdapter<TFields> {
return new InMemoryFullCacheStubCacheAdapter(entityConfiguration, this.cache); return new InMemoryFullCacheStubCacheAdapter(
entityConfiguration,
this.cache as Map<string, Readonly<TFields>>
);
} }
} }
export class InMemoryFullCacheStubCacheAdapter<TFields> extends EntityCacheAdapter<TFields> { export class InMemoryFullCacheStubCacheAdapter<TFields> extends EntityCacheAdapter<TFields> {
constructor( constructor(
entityConfiguration: EntityConfiguration<TFields>, entityConfiguration: EntityConfiguration<TFields>,
readonly cache: Map<string, Readonly<object>> readonly cache: Map<string, Readonly<TFields>>
) { ) {
super(entityConfiguration); super(entityConfiguration);
} }
public getFieldTransformerMap(): FieldTransformerMap {
return new Map();
}
public async loadManyAsync<N extends keyof TFields>( public async loadManyAsync<N extends keyof TFields>(
fieldName: N, fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[] fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult>> { ): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult<TFields>>> {
const results = new Map<NonNullable<TFields[N]>, CacheLoadResult>(); const results = new Map<NonNullable<TFields[N]>, CacheLoadResult<TFields>>();
fieldValues.forEach((fieldValue) => { fieldValues.forEach((fieldValue) => {
const cacheKey = this.createCacheKey(fieldName, fieldValue); const cacheKey = this.createCacheKey(fieldName, fieldValue);
if (!this.cache.has(cacheKey)) { if (!this.cache.has(cacheKey)) {
@@ -94,7 +88,7 @@ export class InMemoryFullCacheStubCacheAdapter<TFields> extends EntityCacheAdapt
public async cacheManyAsync<N extends keyof TFields>( public async cacheManyAsync<N extends keyof TFields>(
fieldName: N, fieldName: N,
objectMap: ReadonlyMap<NonNullable<TFields[N]>, object> objectMap: ReadonlyMap<NonNullable<TFields[N]>, Readonly<TFields>>
): Promise<void> { ): Promise<void> {
objectMap.forEach((obj, fieldValue) => { objectMap.forEach((obj, fieldValue) => {
const cacheKey = this.createCacheKey(fieldName, fieldValue); const cacheKey = this.createCacheKey(fieldName, fieldValue);
+20 -5
View File
@@ -507,28 +507,33 @@
exec-sh "^0.3.2" exec-sh "^0.3.2"
minimist "^1.2.0" minimist "^1.2.0"
"@expo/entity-cache-adapter-local-memory@file:packages/entity-cache-adapter-local-memory":
version "0.23.0"
dependencies:
lru-cache "^7.3.1"
"@expo/entity-cache-adapter-redis@file:packages/entity-cache-adapter-redis": "@expo/entity-cache-adapter-redis@file:packages/entity-cache-adapter-redis":
version "0.21.0" version "0.23.0"
dependencies: dependencies:
ioredis "^4.27.3" ioredis "^4.27.3"
"@expo/entity-database-adapter-knex@file:packages/entity-database-adapter-knex": "@expo/entity-database-adapter-knex@file:packages/entity-database-adapter-knex":
version "0.21.0" version "0.23.0"
dependencies: dependencies:
knex "^1.0.2" knex "^1.0.2"
"@expo/entity-ip-address-field@file:packages/entity-ip-address-field": "@expo/entity-ip-address-field@file:packages/entity-ip-address-field":
version "0.21.0" version "0.23.0"
dependencies: dependencies:
ip-address "^8.1.0" ip-address "^8.1.0"
"@expo/entity-secondary-cache-redis@file:packages/entity-secondary-cache-redis": "@expo/entity-secondary-cache-redis@file:packages/entity-secondary-cache-redis":
version "0.21.0" version "0.23.0"
dependencies: dependencies:
ioredis "^4.17.3" ioredis "^4.17.3"
"@expo/entity@file:packages/entity": "@expo/entity@file:packages/entity":
version "0.21.0" version "0.23.0"
dependencies: dependencies:
"@expo/results" "^1.0.0" "@expo/results" "^1.0.0"
dataloader "^2.0.0" dataloader "^2.0.0"
@@ -1995,6 +2000,11 @@
resolved "https://registry.yarnpkg.com/@types/long/-/long-4.0.1.tgz#459c65fa1867dafe6a8f322c4c51695663cc55e9" resolved "https://registry.yarnpkg.com/@types/long/-/long-4.0.1.tgz#459c65fa1867dafe6a8f322c4c51695663cc55e9"
integrity sha512-5tXH6Bx/kNGd3MgffdmP4dy2Z+G4eaXw0SE81Tq3BNadtnMR5/ySMzX4SLEzHJzSmPNn4HIdpQsBvXMUykr58w== integrity sha512-5tXH6Bx/kNGd3MgffdmP4dy2Z+G4eaXw0SE81Tq3BNadtnMR5/ySMzX4SLEzHJzSmPNn4HIdpQsBvXMUykr58w==
"@types/lru-cache@^5.1.1":
version "5.1.1"
resolved "https://registry.yarnpkg.com/@types/lru-cache/-/lru-cache-5.1.1.tgz#c48c2e27b65d2a153b19bfc1a317e30872e01eef"
integrity sha512-ssE3Vlrys7sdIzs5LOxCzTVMsU7i9oa/IaW92wF32JFb3CVczqOkru2xspuKczHEbG3nvmPY7IFqVmGGHdNbYw==
"@types/mime@*": "@types/mime@*":
version "2.0.1" version "2.0.1"
resolved "https://registry.yarnpkg.com/@types/mime/-/mime-2.0.1.tgz#dc488842312a7f075149312905b5e3c0b054c79d" resolved "https://registry.yarnpkg.com/@types/mime/-/mime-2.0.1.tgz#dc488842312a7f075149312905b5e3c0b054c79d"
@@ -6565,6 +6575,11 @@ lru-cache@^6.0.0:
dependencies: dependencies:
yallist "^4.0.0" yallist "^4.0.0"
lru-cache@^7.3.1:
version "7.3.1"
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-7.3.1.tgz#7702e80694ec2bf19865567a469f2b081fcf53f5"
integrity sha512-nX1x4qUrKqwbIAhv4s9et4FIUVzNOpeY07bsjGUy8gwJrXH/wScImSQqXErmo/b2jZY2r0mohbLA9zVj7u1cNw==
lunr@^2.3.9: lunr@^2.3.9:
version "2.3.9" version "2.3.9"
resolved "https://registry.yarnpkg.com/lunr/-/lunr-2.3.9.tgz#18b123142832337dd6e964df1a5a7707b25d35e1" resolved "https://registry.yarnpkg.com/lunr/-/lunr-2.3.9.tgz#18b123142832337dd6e964df1a5a7707b25d35e1"