Compare commits

..
39 changed files with 740 additions and 205 deletions

No files matched your search

+46
View File
@@ -3,6 +3,52 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [0.9.1](https://github.com/expo/entity/compare/v0.9.0...v0.9.1) (2020-11-06)
### Bug Fixes
* edge deletion behavior cyclic import structure again ([#93](https://github.com/expo/entity/issues/93)) ([012c05d](https://github.com/expo/entity/commit/012c05d62d2ecd559eb331c6ca358052bfe66dc8))
# [0.9.0](https://github.com/expo/entity/compare/v0.8.1...v0.9.0) (2020-11-05)
### Bug Fixes
* edge deletion behavior cyclic import structure ([#89](https://github.com/expo/entity/issues/89)) ([cca8fc1](https://github.com/expo/entity/commit/cca8fc19870057ebc7033081b59fab4a142fbf1a))
### Features
* add ability to invalidate an entity manually ([#91](https://github.com/expo/entity/issues/91)) ([d1a6f13](https://github.com/expo/entity/commit/d1a6f1339648942f12b3fb605eb329aefe5cdd63))
## [0.8.1](https://github.com/expo/entity/compare/v0.8.0...v0.8.1) (2020-10-09)
**Note:** Version bump only for package root
# [0.8.0](https://github.com/expo/entity/compare/v0.7.1...v0.8.0) (2020-10-09)
### Features
* add entityClassName to metrics adapter events ([#83](https://github.com/expo/entity/issues/83)) ([f4cdc01](https://github.com/expo/entity/commit/f4cdc01c6cb8868c24ce3da2274f0901687e05bd))
## [0.7.1](https://github.com/expo/entity/compare/v0.7.0...v0.7.1) (2020-10-01)
+15
View File
@@ -78,6 +78,21 @@ Entity is not limited in where it can or should be used, but was designed for us
Note: The entity framework instance should not be shared across multiple requests since it contains a unique memoized [Dataloader](https://github.com/graphql/dataloader#class-dataloader). A long-lived instance is prone to data synchronization issues, especially when the application is scaled horizontally and multiple shared caches would exist for the same data.
## Releasing
To release a new version, let's say `v1.0.0` for example:
1. `git checkout -b bump-v1.0.0`
1. `yarn lerna version minor -- --no-push --conventional-commits`
1. `git push -u origin bump-v1.0.0`
1. Create a PR from the `bump-v1.0.0` branch, wait for tests, and commit the PR using GitHub interface.
1. `git checkout master && git pull`
1. `git tag -d v1.0.0`
1. `git tag v1.0.0`
1. `git push origin v1.0.0`
1. In GitHub release interface, create a new release from the tag, copy changelog changes to release description.
1. `yarn clean && yarn tsc`
1. In each public subpackage, run `npm publish` and enter 2FA token.
## License
The Entity source code is made available under the [MIT license](LICENSE).
+1 -1
View File
@@ -4,5 +4,5 @@
],
"npmClient": "yarn",
"useWorkspaces": true,
"version": "0.7.1"
"version": "0.9.1"
}
@@ -3,6 +3,38 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [0.9.1](https://github.com/expo/entity/compare/v0.9.0...v0.9.1) (2020-11-06)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.9.0](https://github.com/expo/entity/compare/v0.8.1...v0.9.0) (2020-11-05)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
## [0.8.1](https://github.com/expo/entity/compare/v0.8.0...v0.8.1) (2020-10-09)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.8.0](https://github.com/expo/entity/compare/v0.7.1...v0.8.0) (2020-10-09)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
## [0.7.1](https://github.com/expo/entity/compare/v0.7.0...v0.7.1) (2020-10-01)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
@@ -1,6 +1,6 @@
{
"name": "@expo/entity-cache-adapter-redis",
"version": "0.7.1",
"version": "0.9.1",
"description": "Redis cache adapter for @expo/entity",
"files": [
"build",
@@ -32,6 +32,6 @@
"ioredis": "^4.17.3"
},
"devDependencies": {
"@expo/entity": "^0.7.1"
"@expo/entity": "^0.9.1"
}
}
@@ -3,6 +3,38 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [0.9.1](https://github.com/expo/entity/compare/v0.9.0...v0.9.1) (2020-11-06)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
# [0.9.0](https://github.com/expo/entity/compare/v0.8.1...v0.9.0) (2020-11-05)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
## [0.8.1](https://github.com/expo/entity/compare/v0.8.0...v0.8.1) (2020-10-09)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
# [0.8.0](https://github.com/expo/entity/compare/v0.7.1...v0.8.0) (2020-10-09)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
## [0.7.1](https://github.com/expo/entity/compare/v0.7.0...v0.7.1) (2020-10-01)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
@@ -1,6 +1,6 @@
{
"name": "@expo/entity-database-adapter-knex",
"version": "0.7.1",
"version": "0.9.1",
"description": "Knex database adapter for @expo/entity",
"files": [
"build",
@@ -32,6 +32,6 @@
"knex": "^0.21.5"
},
"devDependencies": {
"@expo/entity": "^0.7.1"
"@expo/entity": "^0.9.1"
}
}
+32
View File
@@ -3,6 +3,38 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [0.9.1](https://github.com/expo/entity/compare/v0.9.0...v0.9.1) (2020-11-06)
**Note:** Version bump only for package @expo/entity-example
# [0.9.0](https://github.com/expo/entity/compare/v0.8.1...v0.9.0) (2020-11-05)
**Note:** Version bump only for package @expo/entity-example
## [0.8.1](https://github.com/expo/entity/compare/v0.8.0...v0.8.1) (2020-10-09)
**Note:** Version bump only for package @expo/entity-example
# [0.8.0](https://github.com/expo/entity/compare/v0.7.1...v0.8.0) (2020-10-09)
**Note:** Version bump only for package @expo/entity-example
## [0.7.1](https://github.com/expo/entity/compare/v0.7.0...v0.7.1) (2020-10-01)
**Note:** Version bump only for package @expo/entity-example
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@expo/entity-example",
"private": true,
"version": "0.7.1",
"version": "0.9.1",
"description": "An example integration of the @expo/entity framework",
"scripts": {
"tsc": "tsc",
@@ -22,7 +22,7 @@
"author": "Expo",
"license": "MIT",
"dependencies": {
"@expo/entity": "^0.7.1",
"@expo/entity": "^0.9.1",
"apollo-server-koa": "^2.16.1",
"graphql": "^15.3.0",
"koa": "^2.13.0",
@@ -3,6 +3,44 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [0.9.1](https://github.com/expo/entity/compare/v0.9.0...v0.9.1) (2020-11-06)
### Bug Fixes
* edge deletion behavior cyclic import structure again ([#93](https://github.com/expo/entity/issues/93)) ([012c05d](https://github.com/expo/entity/commit/012c05d62d2ecd559eb331c6ca358052bfe66dc8))
# [0.9.0](https://github.com/expo/entity/compare/v0.8.1...v0.9.0) (2020-11-05)
### Bug Fixes
* edge deletion behavior cyclic import structure ([#89](https://github.com/expo/entity/issues/89)) ([cca8fc1](https://github.com/expo/entity/commit/cca8fc19870057ebc7033081b59fab4a142fbf1a))
## [0.8.1](https://github.com/expo/entity/compare/v0.8.0...v0.8.1) (2020-10-09)
**Note:** Version bump only for package @expo/entity-full-integration-tests
# [0.8.0](https://github.com/expo/entity/compare/v0.7.1...v0.8.0) (2020-10-09)
**Note:** Version bump only for package @expo/entity-full-integration-tests
## [0.7.1](https://github.com/expo/entity/compare/v0.7.0...v0.7.1) (2020-10-01)
**Note:** Version bump only for package @expo/entity-full-integration-tests
@@ -1,7 +1,7 @@
{
"name": "@expo/entity-full-integration-tests",
"private": true,
"version": "0.7.1",
"version": "0.9.1",
"description": "Full redis and knex integration tests for the entity framework",
"scripts": {
"tsc": "tsc",
@@ -26,8 +26,8 @@
"@expo/entity-database-adapter-knex": "*"
},
"devDependencies": {
"@expo/entity": "^0.7.1",
"@expo/entity-cache-adapter-redis": "^0.7.1",
"@expo/entity-database-adapter-knex": "^0.7.1"
"@expo/entity": "^0.9.1",
"@expo/entity-cache-adapter-redis": "^0.9.1",
"@expo/entity-database-adapter-knex": "^0.9.1"
}
}
@@ -1,108 +1,12 @@
import {
EntityPrivacyPolicy,
ViewerContext,
AlwaysAllowPrivacyPolicyRule,
Entity,
EntityCompanionDefinition,
EntityConfiguration,
DatabaseAdapterFlavor,
CacheAdapterFlavor,
UUIDField,
EntityEdgeDeletionBehavior,
} from '@expo/entity';
import { ViewerContext } from '@expo/entity';
import { RedisCacheAdapterContext } from '@expo/entity-cache-adapter-redis';
import Redis from 'ioredis';
import Knex from 'knex';
import { URL } from 'url';
import { createFullIntegrationTestEntityCompanionProvider } from '../testfixtures/createFullIntegrationTestEntityCompanionProvider';
interface ParentFields {
id: string;
}
interface ChildFields {
id: string;
parent_id: string;
}
class TestEntityPrivacyPolicy extends EntityPrivacyPolicy<any, string, ViewerContext, any, any> {
protected readonly readRules = [new AlwaysAllowPrivacyPolicyRule()];
protected readonly createRules = [new AlwaysAllowPrivacyPolicyRule()];
protected readonly updateRules = [new AlwaysAllowPrivacyPolicyRule()];
protected readonly deleteRules = [new AlwaysAllowPrivacyPolicyRule()];
}
class ParentEntity extends Entity<ParentFields, string, ViewerContext> {
static getCompanionDefinition(): EntityCompanionDefinition<
ParentFields,
string,
ViewerContext,
ParentEntity,
TestEntityPrivacyPolicy
> {
return parentEntityCompanion;
}
}
class ChildEntity extends Entity<ChildFields, string, ViewerContext> {
static getCompanionDefinition(): EntityCompanionDefinition<
ChildFields,
string,
ViewerContext,
ChildEntity,
TestEntityPrivacyPolicy
> {
return childEntityCompanion;
}
}
const parentEntityConfiguration = new EntityConfiguration<ParentFields>({
idField: 'id',
tableName: 'parents',
inboundEdges: [ChildEntity],
schema: {
id: new UUIDField({
columnName: 'id',
cache: true,
}),
},
databaseAdapterFlavor: DatabaseAdapterFlavor.POSTGRES,
cacheAdapterFlavor: CacheAdapterFlavor.REDIS,
});
const childEntityConfiguration = new EntityConfiguration<ChildFields>({
idField: 'id',
tableName: 'children',
schema: {
id: new UUIDField({
columnName: 'id',
cache: true,
}),
parent_id: new UUIDField({
columnName: 'parent_id',
cache: true,
association: {
associatedEntityClass: ParentEntity,
edgeDeletionBehavior: EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE,
},
}),
},
databaseAdapterFlavor: DatabaseAdapterFlavor.POSTGRES,
cacheAdapterFlavor: CacheAdapterFlavor.REDIS,
});
const parentEntityCompanion = new EntityCompanionDefinition({
entityClass: ParentEntity,
entityConfiguration: parentEntityConfiguration,
privacyPolicyClass: TestEntityPrivacyPolicy,
});
const childEntityCompanion = new EntityCompanionDefinition({
entityClass: ChildEntity,
entityConfiguration: childEntityConfiguration,
privacyPolicyClass: TestEntityPrivacyPolicy,
});
import ChildEntity from './entities/ChildEntity';
import ParentEntity from './entities/ParentEntity';
async function createOrTruncatePostgresTables(knex: Knex): Promise<void> {
await knex.raw('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"'); // for uuid_generate_v4()
@@ -67,7 +67,7 @@ const makeEntityClasses = async (knex: Knex, edgeDeletionBehavior: EntityEdgeDel
const categoryEntityConfiguration = new EntityConfiguration<CategoryFields>({
idField: 'id',
tableName: categoriesTableName,
inboundEdges: [OtherEntity],
getInboundEdges: () => [OtherEntity],
schema: {
id: new UUIDField({
columnName: 'id',
@@ -77,7 +77,7 @@ const makeEntityClasses = async (knex: Knex, edgeDeletionBehavior: EntityEdgeDel
columnName: 'parent_other_id',
cache: true,
association: {
associatedEntityClass: OtherEntity,
getAssociatedEntityClass: () => OtherEntity,
edgeDeletionBehavior,
},
}),
@@ -95,7 +95,7 @@ const makeEntityClasses = async (knex: Knex, edgeDeletionBehavior: EntityEdgeDel
const otherEntityConfiguration = new EntityConfiguration<OtherFields>({
idField: 'id',
tableName: othersTableName,
inboundEdges: [CategoryEntity],
getInboundEdges: () => [CategoryEntity],
schema: {
id: new UUIDField({
columnName: 'id',
@@ -105,7 +105,7 @@ const makeEntityClasses = async (knex: Knex, edgeDeletionBehavior: EntityEdgeDel
columnName: 'parent_category_id',
cache: true,
association: {
associatedEntityClass: CategoryEntity,
getAssociatedEntityClass: () => CategoryEntity,
edgeDeletionBehavior,
},
}),
@@ -0,0 +1,65 @@
import {
AlwaysAllowPrivacyPolicyRule,
CacheAdapterFlavor,
DatabaseAdapterFlavor,
Entity,
EntityCompanionDefinition,
EntityConfiguration,
EntityEdgeDeletionBehavior,
EntityPrivacyPolicy,
UUIDField,
ViewerContext,
} from '@expo/entity';
import ParentEntity from './ParentEntity';
interface ChildFields {
id: string;
parent_id: string;
}
class TestEntityPrivacyPolicy extends EntityPrivacyPolicy<any, string, ViewerContext, any, any> {
protected readonly readRules = [new AlwaysAllowPrivacyPolicyRule()];
protected readonly createRules = [new AlwaysAllowPrivacyPolicyRule()];
protected readonly updateRules = [new AlwaysAllowPrivacyPolicyRule()];
protected readonly deleteRules = [new AlwaysAllowPrivacyPolicyRule()];
}
export default class ChildEntity extends Entity<ChildFields, string, ViewerContext> {
static getCompanionDefinition(): EntityCompanionDefinition<
ChildFields,
string,
ViewerContext,
ChildEntity,
TestEntityPrivacyPolicy
> {
return childEntityCompanion;
}
}
const childEntityConfiguration = new EntityConfiguration<ChildFields>({
idField: 'id',
tableName: 'children',
schema: {
id: new UUIDField({
columnName: 'id',
cache: true,
}),
parent_id: new UUIDField({
columnName: 'parent_id',
cache: true,
association: {
getAssociatedEntityClass: () => ParentEntity,
edgeDeletionBehavior: EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE,
},
}),
},
databaseAdapterFlavor: DatabaseAdapterFlavor.POSTGRES,
cacheAdapterFlavor: CacheAdapterFlavor.REDIS,
});
const childEntityCompanion = new EntityCompanionDefinition({
entityClass: ChildEntity,
entityConfiguration: childEntityConfiguration,
privacyPolicyClass: TestEntityPrivacyPolicy,
});
@@ -0,0 +1,56 @@
import {
AlwaysAllowPrivacyPolicyRule,
CacheAdapterFlavor,
DatabaseAdapterFlavor,
Entity,
EntityCompanionDefinition,
EntityConfiguration,
EntityPrivacyPolicy,
UUIDField,
ViewerContext,
} from '@expo/entity';
import ChildEntity from './ChildEntity';
interface ParentFields {
id: string;
}
class TestEntityPrivacyPolicy extends EntityPrivacyPolicy<any, string, ViewerContext, any, any> {
protected readonly readRules = [new AlwaysAllowPrivacyPolicyRule()];
protected readonly createRules = [new AlwaysAllowPrivacyPolicyRule()];
protected readonly updateRules = [new AlwaysAllowPrivacyPolicyRule()];
protected readonly deleteRules = [new AlwaysAllowPrivacyPolicyRule()];
}
export default class ParentEntity extends Entity<ParentFields, string, ViewerContext> {
static getCompanionDefinition(): EntityCompanionDefinition<
ParentFields,
string,
ViewerContext,
ParentEntity,
TestEntityPrivacyPolicy
> {
return parentEntityCompanion;
}
}
const parentEntityConfiguration = new EntityConfiguration<ParentFields>({
idField: 'id',
tableName: 'parents',
getInboundEdges: () => [ChildEntity],
schema: {
id: new UUIDField({
columnName: 'id',
cache: true,
}),
},
databaseAdapterFlavor: DatabaseAdapterFlavor.POSTGRES,
cacheAdapterFlavor: CacheAdapterFlavor.REDIS,
});
const parentEntityCompanion = new EntityCompanionDefinition({
entityClass: ParentEntity,
entityConfiguration: parentEntityConfiguration,
privacyPolicyClass: TestEntityPrivacyPolicy,
});
+46
View File
@@ -3,6 +3,52 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [0.9.1](https://github.com/expo/entity/compare/v0.9.0...v0.9.1) (2020-11-06)
### Bug Fixes
* edge deletion behavior cyclic import structure again ([#93](https://github.com/expo/entity/issues/93)) ([012c05d](https://github.com/expo/entity/commit/012c05d62d2ecd559eb331c6ca358052bfe66dc8))
# [0.9.0](https://github.com/expo/entity/compare/v0.8.1...v0.9.0) (2020-11-05)
### Bug Fixes
* edge deletion behavior cyclic import structure ([#89](https://github.com/expo/entity/issues/89)) ([cca8fc1](https://github.com/expo/entity/commit/cca8fc19870057ebc7033081b59fab4a142fbf1a))
### Features
* add ability to invalidate an entity manually ([#91](https://github.com/expo/entity/issues/91)) ([d1a6f13](https://github.com/expo/entity/commit/d1a6f1339648942f12b3fb605eb329aefe5cdd63))
## [0.8.1](https://github.com/expo/entity/compare/v0.8.0...v0.8.1) (2020-10-09)
**Note:** Version bump only for package @expo/entity
# [0.8.0](https://github.com/expo/entity/compare/v0.7.1...v0.8.0) (2020-10-09)
### Features
* add entityClassName to metrics adapter events ([#83](https://github.com/expo/entity/issues/83)) ([f4cdc01](https://github.com/expo/entity/commit/f4cdc01c6cb8868c24ce3da2274f0901687e05bd))
## [0.7.1](https://github.com/expo/entity/compare/v0.7.0...v0.7.1) (2020-10-01)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/entity",
"version": "0.7.1",
"version": "0.9.1",
"description": "A privacy-first data model",
"files": [
"build",
+2 -2
View File
@@ -79,7 +79,7 @@ export default class EnforcingEntityLoader<
uniqueFieldName,
fieldValue
);
return entityResult ? entityResult.enforceValue() : null;
return entityResult.enforceValue();
}
/**
@@ -98,7 +98,7 @@ export default class EnforcingEntityLoader<
*/
async loadByIDNullableAsync(id: TID): Promise<TEntity | null> {
const entityResult = await this.entityLoader.loadByIDNullableAsync(id);
return entityResult ? entityResult.enforceValue() : null;
return entityResult.enforceValue();
}
/**
+32 -15
View File
@@ -23,6 +23,7 @@ export default class EntityAssociationLoader<
* @param queryContext - query context in which to perform the load
*/
async loadAssociatedEntityAsync<
TIdentifyingField extends keyof Pick<TFields, TSelectedFields>,
TAssociatedFields,
TAssociatedID,
TAssociatedEntity extends ReadonlyEntity<
@@ -40,7 +41,7 @@ export default class EntityAssociationLoader<
>,
TAssociatedSelectedFields extends keyof TAssociatedFields = keyof TAssociatedFields
>(
fieldIdentifyingAssociatedEntity: keyof Pick<TFields, TSelectedFields>,
fieldIdentifyingAssociatedEntity: TIdentifyingField,
associatedEntityClass: IEntityClass<
TAssociatedFields,
TAssociatedID,
@@ -54,14 +55,20 @@ export default class EntityAssociationLoader<
.getViewerScopedEntityCompanionForClass(associatedEntityClass)
.getQueryContextProvider()
.getQueryContext()
): Promise<Result<TAssociatedEntity>> {
): Promise<
Result<null extends TFields[TIdentifyingField] ? TAssociatedEntity | null : TAssociatedEntity>
> {
const associatedEntityID = this.entity.getField(fieldIdentifyingAssociatedEntity);
if (!associatedEntityID) {
return result(null) as any;
}
const loader = this.entity
.getViewerContext()
.getViewerScopedEntityCompanionForClass(associatedEntityClass)
.getLoaderFactory()
.forLoad(queryContext);
return await loader.loadByIDAsync((associatedEntityID as unknown) as TAssociatedID);
return (await loader.loadByIDAsync((associatedEntityID as unknown) as TAssociatedID)) as any;
}
/**
@@ -127,6 +134,7 @@ export default class EntityAssociationLoader<
* @param queryContext - query context in which to perform the load
*/
async loadAssociatedEntityByFieldEqualingAsync<
TIdentifyingField extends keyof Pick<TFields, TSelectedFields>,
TAssociatedFields,
TAssociatedID,
TAssociatedEntity extends ReadonlyEntity<
@@ -144,7 +152,7 @@ export default class EntityAssociationLoader<
>,
TAssociatedSelectedFields extends keyof TAssociatedFields = keyof TAssociatedFields
>(
fieldIdentifyingAssociatedEntity: keyof Pick<TFields, TSelectedFields>,
fieldIdentifyingAssociatedEntity: TIdentifyingField,
associatedEntityClass: IEntityClass<
TAssociatedFields,
TAssociatedID,
@@ -159,8 +167,12 @@ export default class EntityAssociationLoader<
.getViewerScopedEntityCompanionForClass(associatedEntityClass)
.getQueryContextProvider()
.getQueryContext()
): Promise<Result<TAssociatedEntity> | null> {
): Promise<Result<TAssociatedEntity | null>> {
const associatedFieldValue = this.entity.getField(fieldIdentifyingAssociatedEntity);
if (!associatedFieldValue) {
return result(null);
}
const loader = this.entity
.getViewerContext()
.getViewerScopedEntityCompanionForClass(associatedEntityClass)
@@ -215,6 +227,10 @@ export default class EntityAssociationLoader<
.getQueryContext()
): Promise<readonly Result<TAssociatedEntity>[]> {
const associatedFieldValue = this.entity.getField(fieldIdentifyingAssociatedEntity);
if (!associatedFieldValue) {
return [];
}
const loader = this.entity
.getViewerContext()
.getViewerScopedEntityCompanionForClass(associatedEntityClass)
@@ -258,7 +274,7 @@ export default class EntityAssociationLoader<
>
],
queryContext?: EntityQueryContext
): Promise<Result<TEntity2> | null>;
): Promise<Result<TEntity2 | null>>;
/**
* Load an associated entity by folding a sequence of {@link EntityLoadThroughDirective}. At each
@@ -313,7 +329,7 @@ export default class EntityAssociationLoader<
>
],
queryContext?: EntityQueryContext
): Promise<Result<TEntity3> | null>;
): Promise<Result<TEntity3 | null>>;
/**
* Load an associated entity by folding a sequence of {@link EntityLoadThroughDirective}. At each
@@ -389,7 +405,7 @@ export default class EntityAssociationLoader<
>
],
queryContext?: EntityQueryContext
): Promise<Result<TEntity4> | null>;
): Promise<Result<TEntity4 | null>>;
/**
* Load an associated entity by folding a sequence of {@link EntityLoadThroughDirective}. At each
@@ -400,12 +416,12 @@ export default class EntityAssociationLoader<
async loadAssociatedEntityThroughAsync(
loadDirectives: EntityLoadThroughDirective<TViewerContext, any, any, any, any, any, any, any>[],
queryContext?: EntityQueryContext
): Promise<Result<ReadonlyEntity<any, any, any, any>> | null>;
): Promise<Result<ReadonlyEntity<any, any, any, any> | null>>;
async loadAssociatedEntityThroughAsync(
loadDirectives: EntityLoadThroughDirective<TViewerContext, any, any, any, any, any, any, any>[],
queryContext?: EntityQueryContext
): Promise<Result<ReadonlyEntity<any, any, any, any>> | null> {
): Promise<Result<ReadonlyEntity<any, any, any, any> | null>> {
let currentEntity: ReadonlyEntity<any, any, any, any> = this.entity;
for (const loadDirective of loadDirectives) {
const {
@@ -413,7 +429,7 @@ export default class EntityAssociationLoader<
fieldIdentifyingAssociatedEntity,
associatedEntityLookupByField,
} = loadDirective;
let associatedEntityResult: Result<ReadonlyEntity<any, any, any, any>> | null;
let associatedEntityResult: Result<ReadonlyEntity<any, any, any, any> | null>;
if (associatedEntityLookupByField) {
associatedEntityResult = await currentEntity
.associationLoader()
@@ -433,13 +449,14 @@ export default class EntityAssociationLoader<
);
}
if (!associatedEntityResult) {
return null;
}
if (!associatedEntityResult.ok) {
return result(associatedEntityResult.reason);
}
if (!associatedEntityResult.value) {
return result(null);
}
currentEntity = associatedEntityResult.value;
}
return result(currentEntity);
@@ -211,7 +211,8 @@ export default class EntityCompanionProvider {
>
): EntityCompanion<TFields, TID, TViewerContext, TEntity, TPrivacyPolicy, TSelectedFields> {
const tableDataCoordinator = this.getTableDataCoordinatorForEntity(
entityCompanionDefinition.entityConfiguration
entityCompanionDefinition.entityConfiguration,
entityClass.name
);
return computeIfAbsent(this.companionMap, entityClass.name, () => {
return new EntityCompanion(
@@ -226,7 +227,8 @@ export default class EntityCompanionProvider {
}
private getTableDataCoordinatorForEntity<TFields>(
entityConfiguration: EntityConfiguration<TFields>
entityConfiguration: EntityConfiguration<TFields>,
entityClassName: string
): EntityTableDataCoordinator<TFields> {
return computeIfAbsent(this.tableDataCoordinatorMap, entityConfiguration.tableName, () => {
const entityDatabaseAdapterFlavor = this.databaseAdapterFlavors[
@@ -240,7 +242,8 @@ export default class EntityCompanionProvider {
entityDatabaseAdapterFlavor.adapterProvider,
entityCacheAdapterFlavor.cacheAdapterProvider,
entityDatabaseAdapterFlavor.queryContextProvider,
this.metricsAdapter
this.metricsAdapter,
entityClassName
);
});
}
+4 -4
View File
@@ -13,7 +13,7 @@ export default class EntityConfiguration<TFields> {
readonly cacheableKeys: ReadonlySet<keyof TFields>;
readonly cacheKeyVersion: number;
readonly inboundEdges: IEntityClass<any, any, any, any, any, any>[];
readonly getInboundEdges: () => IEntityClass<any, any, any, any, any, any>[];
readonly schema: ReadonlyMap<keyof TFields, EntityFieldDefinition>;
readonly entityToDBFieldsKeyMapping: ReadonlyMap<keyof TFields, string>;
readonly dbToEntityFieldsKeyMapping: ReadonlyMap<string, keyof TFields>;
@@ -25,7 +25,7 @@ export default class EntityConfiguration<TFields> {
idField,
tableName,
schema,
inboundEdges = [],
getInboundEdges = () => [],
cacheKeyVersion = 0,
databaseAdapterFlavor,
cacheAdapterFlavor,
@@ -33,7 +33,7 @@ export default class EntityConfiguration<TFields> {
idField: keyof TFields;
tableName: string;
schema: Record<keyof TFields, EntityFieldDefinition>;
inboundEdges?: IEntityClass<any, any, any, any, any, any>[];
getInboundEdges?: () => IEntityClass<any, any, any, any, any, any>[];
cacheKeyVersion?: number;
databaseAdapterFlavor: DatabaseAdapterFlavor;
cacheAdapterFlavor: CacheAdapterFlavor;
@@ -44,7 +44,7 @@ export default class EntityConfiguration<TFields> {
this.databaseAdapterFlavor = databaseAdapterFlavor;
this.cacheAdapterFlavor = cacheAdapterFlavor;
this.inboundEdges = inboundEdges;
this.getInboundEdges = getInboundEdges;
// external schema is a Record to typecheck that all fields have FieldDefinitions,
// but internally the most useful representation is a map for lookups
+1 -1
View File
@@ -50,7 +50,7 @@ export interface EntityAssociationDefinition<
/**
* Class of entity on the other end of this edge.
*/
associatedEntityClass: IEntityClass<
getAssociatedEntityClass: () => IEntityClass<
TAssociatedFields,
TAssociatedID,
TViewerContext,
+13 -4
View File
@@ -132,13 +132,13 @@ export default class EntityLoader<
async loadByFieldEqualingAsync<N extends keyof Pick<TFields, TSelectedFields>>(
uniqueFieldName: N,
fieldValue: NonNullable<TFields[N]>
): Promise<Result<TEntity> | null> {
): Promise<Result<TEntity | null>> {
const entityResults = await this.loadManyByFieldEqualingAsync(uniqueFieldName, fieldValue);
invariant(
entityResults.length <= 1,
`loadByFieldEqualing: Multiple entities of type ${this.entityClass.name} found for ${uniqueFieldName}=${fieldValue}`
);
return entityResults[0] ?? null;
return entityResults[0] ?? result(null);
}
/**
@@ -160,7 +160,7 @@ export default class EntityLoader<
* @param id - ID of the entity
* @returns entity result for matching ID, or null if no entity exists for ID.
*/
async loadByIDNullableAsync(id: TID): Promise<Result<TEntity> | null> {
async loadByIDNullableAsync(id: TID): Promise<Result<TEntity | null>> {
return await this.loadByFieldEqualingAsync(this.idField, id as any);
}
@@ -284,10 +284,19 @@ export default class EntityLoader<
await this.dataManager.invalidateObjectFieldsAsync(objectFields);
}
/**
* Invalidate all caches for an entity. One potential use case would be to keep the entity
* framework in sync with changes made to data outside of the framework.
* @param entity - entity to be invalidated
*/
async invalidateEntityAsync(entity: TEntity): Promise<void> {
await this.invalidateFieldsAsync(entity.getAllDatabaseFields());
}
/**
* Wrap entity construction in try/catch and return Results. Used to prevent constructor errors
* thrown for one entity instantiation from interfering with other entity instantiatons of the same query.
* @param fieldsObject - array of entity data objects to construct entities
* @param fieldsObjects - array of entity data objects to construct entities
*/
private tryConstructEntities(fieldsObjects: readonly TFields[]): readonly Result<TEntity>[] {
return fieldsObjects.map((fieldsObject) => {
+10 -6
View File
@@ -146,7 +146,8 @@ export class CreateMutator<
async createAsync(): Promise<Result<TEntity>> {
return await timeAndLogMutationEventAsync(
this.metricsAdapter,
EntityMetricsMutationType.CREATE
EntityMetricsMutationType.CREATE,
this.entityClass.name
)(this.createInTransactionAsync());
}
@@ -329,7 +330,8 @@ export class UpdateMutator<
async updateAsync(): Promise<Result<TEntity>> {
return await timeAndLogMutationEventAsync(
this.metricsAdapter,
EntityMetricsMutationType.UPDATE
EntityMetricsMutationType.UPDATE,
this.entityClass.name
)(this.updateInTransactionAsync());
}
@@ -498,7 +500,8 @@ export class DeleteMutator<
async deleteAsync(): Promise<Result<void>> {
return await timeAndLogMutationEventAsync(
this.metricsAdapter,
EntityMetricsMutationType.DELETE
EntityMetricsMutationType.DELETE,
this.entityClass.name
)(this.deleteInTransactionAsync());
}
@@ -626,7 +629,7 @@ export class DeleteMutator<
TMSelectedFields
>;
const entityConfiguration = companionDefinition.entityConfiguration;
const inboundEdges = entityConfiguration.inboundEdges;
const inboundEdges = entityConfiguration.getInboundEdges();
await Promise.all(
inboundEdges.map(async (entityClass) => {
return await mapMapAsync(
@@ -637,8 +640,9 @@ export class DeleteMutator<
return;
}
const associatedConfiguration = association.associatedEntityClass.getCompanionDefinition()
.entityConfiguration;
const associatedConfiguration = association
.getAssociatedEntityClass()
.getCompanionDefinition().entityConfiguration;
if (associatedConfiguration !== entityConfiguration) {
return;
}
@@ -286,6 +286,7 @@ describe(EnforcingEntityLoader, () => {
const knownLoaderOnlyDifferences = [
'enforcing',
'invalidateFieldsAsync',
'invalidateEntityAsync',
'tryConstructEntities',
];
expect(loaderProperties).toEqual(expect.arrayContaining(knownLoaderOnlyDifferences));
@@ -9,7 +9,7 @@ import { createUnitTestEntityCompanionProvider } from '../utils/testing/createUn
describe(EntityAssociationLoader, () => {
describe('loadAssociatedEntityAsync', () => {
it('loads associated entities by ID', async () => {
it('loads associated entities by ID and correctly handles a null value', async () => {
const companionProvider = createUnitTestEntityCompanionProvider();
const viewerContext = new TestViewerContext(companionProvider);
const testOtherEntity = await enforceAsyncResult(
@@ -24,6 +24,11 @@ describe(EntityAssociationLoader, () => {
testEntity.associationLoader().loadAssociatedEntityAsync('stringField', TestEntity)
);
expect(loadedOther.getID()).toEqual(testOtherEntity.getID());
const loadedOther2 = await enforceAsyncResult(
testEntity.associationLoader().loadAssociatedEntityAsync('nullableField', TestEntity)
);
expect(loadedOther2).toBeNull();
});
});
@@ -62,7 +67,7 @@ describe(EntityAssociationLoader, () => {
const loadedOtherResult = await testEntity
.associationLoader()
.loadAssociatedEntityByFieldEqualingAsync('stringField', TestEntity, 'customIdField');
expect(loadedOtherResult?.enforceValue().getID()).toEqual(testOtherEntity.getID());
expect(loadedOtherResult.enforceValue()?.getID()).toEqual(testOtherEntity.getID());
});
it('returns null when loading associated entities by field equaling a non existent association', async () => {
@@ -74,7 +79,19 @@ describe(EntityAssociationLoader, () => {
const loadedOtherResult = await testEntity
.associationLoader()
.loadAssociatedEntityByFieldEqualingAsync('stringField', TestEntity, 'customIdField');
expect(loadedOtherResult).toBeNull();
expect(loadedOtherResult.enforceValue()).toBeNull();
});
it('returns null when load-by field is null', async () => {
const companionProvider = createUnitTestEntityCompanionProvider();
const viewerContext = new TestViewerContext(companionProvider);
const testEntity = await enforceAsyncResult(
TestEntity.creator(viewerContext).setField('stringField', 'blah').createAsync()
);
const loadedOtherResult = await testEntity
.associationLoader()
.loadAssociatedEntityByFieldEqualingAsync('nullableField', TestEntity, 'customIdField');
expect(loadedOtherResult.enforceValue()).toBeNull();
});
});
@@ -102,6 +119,22 @@ describe(EntityAssociationLoader, () => {
expect(loaded.find((e) => e.getID() === testOtherEntity1.getID())).not.toBeUndefined();
expect(loaded.find((e) => e.getID() === testOtherEntity2.getID())).not.toBeUndefined();
});
it('returns empty results when field being queried by is null', async () => {
const companionProvider = createUnitTestEntityCompanionProvider();
const viewerContext = new TestViewerContext(companionProvider);
const testEntity = await enforceAsyncResult(TestEntity.creator(viewerContext).createAsync());
const loaded = await enforceResultsAsync(
testEntity
.associationLoader()
.loadManyAssociatedEntitiesByFieldEqualingAsync(
'nullableField',
TestEntity,
'stringField'
)
);
expect(loaded).toHaveLength(0);
});
});
describe('loadAssociatedEntityThroughAsync', () => {
@@ -127,7 +160,7 @@ describe(EntityAssociationLoader, () => {
fieldIdentifyingAssociatedEntity: 'foreignKey',
},
]);
expect(loaded2Result?.enforceValue().getID()).toEqual(testEntity2.getID());
expect(loaded2Result.enforceValue()?.getID()).toEqual(testEntity2.getID());
const loaded3Result = await testEntity.associationLoader().loadAssociatedEntityThroughAsync([
{
@@ -139,7 +172,7 @@ describe(EntityAssociationLoader, () => {
fieldIdentifyingAssociatedEntity: 'testIndexedField',
},
]);
expect(loaded3Result?.enforceValue().getID()).toEqual(testEntity3.getID());
expect(loaded3Result.enforceValue()?.getID()).toEqual(testEntity3.getID());
const loaded4Result = await testEntity.associationLoader().loadAssociatedEntityThroughAsync([
{
@@ -155,7 +188,7 @@ describe(EntityAssociationLoader, () => {
fieldIdentifyingAssociatedEntity: 'foreignKey',
},
]);
expect(loaded4Result?.enforceValue().getID()).toEqual(testEntity4.getID());
expect(loaded4Result.enforceValue()?.getID()).toEqual(testEntity4.getID());
});
it('fails when chain loading associated entity fails', async () => {
@@ -193,7 +226,7 @@ describe(EntityAssociationLoader, () => {
associatedEntityLookupByField: 'stringField',
},
]);
expect(loaded2Result?.enforceValue().getID()).toEqual(testEntity2.getID());
expect(loaded2Result.enforceValue()?.getID()).toEqual(testEntity2.getID());
});
it('returns null when chain loading by field equality returns null', async () => {
@@ -211,7 +244,24 @@ describe(EntityAssociationLoader, () => {
associatedEntityLookupByField: 'stringField',
},
]);
expect(loaded2Result).toBeNull();
expect(loaded2Result.enforceValue()).toBeNull();
});
it('returns null when chain loading by field is null', async () => {
const companionProvider = createUnitTestEntityCompanionProvider();
const viewerContext = new TestViewerContext(companionProvider);
const testEntity = await enforceAsyncResult(
TestEntity.creator(viewerContext).setField('nullableField', null).createAsync()
);
const loadedResult = await testEntity.associationLoader().loadAssociatedEntityThroughAsync([
{
associatedEntityClass: TestEntity,
fieldIdentifyingAssociatedEntity: 'nullableField',
},
]);
expect(loadedResult.enforceValue()).toBeNull();
});
});
});
@@ -81,7 +81,7 @@ const makeEntityClasses = (edgeDeletionBehavior: EntityEdgeDeletionBehavior) =>
const parentEntityConfiguration = new EntityConfiguration<ParentFields>({
idField: 'id',
tableName: 'parents',
inboundEdges: [ChildEntity],
getInboundEdges: () => [ChildEntity],
schema: {
id: new UUIDField({
columnName: 'id',
@@ -95,7 +95,7 @@ const makeEntityClasses = (edgeDeletionBehavior: EntityEdgeDeletionBehavior) =>
const childEntityConfiguration = new EntityConfiguration<ChildFields>({
idField: 'id',
tableName: 'children',
inboundEdges: [GrandChildEntity],
getInboundEdges: () => [GrandChildEntity],
schema: {
id: new UUIDField({
columnName: 'id',
@@ -105,7 +105,7 @@ const makeEntityClasses = (edgeDeletionBehavior: EntityEdgeDeletionBehavior) =>
columnName: 'parent_id',
cache: true,
association: {
associatedEntityClass: ParentEntity,
getAssociatedEntityClass: () => ParentEntity,
edgeDeletionBehavior,
},
}),
@@ -126,7 +126,7 @@ const makeEntityClasses = (edgeDeletionBehavior: EntityEdgeDeletionBehavior) =>
columnName: 'parent_id',
cache: true,
association: {
associatedEntityClass: ChildEntity,
getAssociatedEntityClass: () => ChildEntity,
edgeDeletionBehavior,
},
}),
@@ -36,6 +36,7 @@ describe(EntityLoader, () => {
numberField: 5,
stringField: 'huh',
dateField: dateToInsert,
nullableField: null,
},
{
customIdField: 'world',
@@ -43,6 +44,7 @@ describe(EntityLoader, () => {
numberField: 3,
stringField: 'huh',
dateField: dateToInsert,
nullableField: null,
},
],
],
@@ -57,7 +59,8 @@ describe(EntityLoader, () => {
databaseAdapter,
entityCache,
StubQueryContextProvider,
instance(mock<IEntityMetricsAdapter>())
instance(mock<IEntityMetricsAdapter>()),
TestEntity.name
);
const entityLoader = new EntityLoader(
viewerContext,
@@ -78,16 +81,16 @@ describe(EntityLoader, () => {
const entityResultNumber3 = await entityLoader.loadByFieldEqualingAsync('numberField', 3);
expect(entityResultNumber3).not.toBeNull();
expect(entityResultNumber3!.enforceValue().getID()).toEqual('world');
expect(entityResultNumber3.enforceValue()?.getID()).toEqual('world');
const entityResultNumber4 = await entityLoader.loadByFieldEqualingAsync('numberField', 4);
expect(entityResultNumber4).toBeNull();
expect(entityResultNumber4.enforceValue()).toBeNull();
await expect(entityLoader.loadByFieldEqualingAsync('stringField', 'huh')).rejects.toThrowError(
'loadByFieldEqualing: Multiple entities of type TestEntity found for stringField=huh'
);
await expect(entityLoader.loadByIDNullableAsync('fake')).resolves.toBeNull();
expect((await entityLoader.loadByIDNullableAsync('fake')).enforceValue()).toBeNull();
await expect(entityLoader.loadByIDNullableAsync('hello')).resolves.not.toBeNull();
});
@@ -111,6 +114,7 @@ describe(EntityLoader, () => {
numberField: 4,
testIndexedField: '4',
dateField: new Date(),
nullableField: null,
},
{
customIdField: 'world',
@@ -118,6 +122,7 @@ describe(EntityLoader, () => {
numberField: 4,
testIndexedField: '5',
dateField: new Date(),
nullableField: null,
},
{
customIdField: 'blah',
@@ -125,6 +130,7 @@ describe(EntityLoader, () => {
numberField: 4,
testIndexedField: '6',
dateField: new Date(),
nullableField: null,
},
],
],
@@ -138,7 +144,8 @@ describe(EntityLoader, () => {
databaseAdapter,
entityCache,
StubQueryContextProvider,
instance(mock<IEntityMetricsAdapter>())
instance(mock<IEntityMetricsAdapter>()),
TestEntity.name
);
const entityLoader = new EntityLoader(
viewerContext,
@@ -187,6 +194,7 @@ describe(EntityLoader, () => {
testIndexedField: '1',
numberField: 3,
dateField: new Date(),
nullableField: null,
},
],
],
@@ -200,7 +208,8 @@ describe(EntityLoader, () => {
databaseAdapter,
entityCache,
StubQueryContextProvider,
instance(mock<IEntityMetricsAdapter>())
instance(mock<IEntityMetricsAdapter>()),
TestEntity.name
);
const entityLoader = new EntityLoader(
viewerContext,
@@ -257,6 +266,31 @@ describe(EntityLoader, () => {
).once();
});
it('invalidates upon invalidate by entity', async () => {
const viewerContext = instance(mock(ViewerContext));
const queryContext = StubQueryContextProvider.getQueryContext();
const privacyPolicy = instance(mock(TestEntityPrivacyPolicy));
const dataManagerMock = mock(EntityDataManager);
const dataManagerInstance = instance(dataManagerMock);
const entityMock = mock(TestEntity);
when(entityMock.getAllDatabaseFields()).thenReturn({ customIdField: 'hello' } as any);
const entityInstance = instance(entityMock);
const entityLoader = new EntityLoader(
viewerContext,
queryContext,
testEntityConfiguration.idField,
TestEntity,
privacyPolicy,
dataManagerInstance
);
await entityLoader.invalidateEntityAsync(entityInstance);
verify(
dataManagerMock.invalidateObjectFieldsAsync(deepEqual({ customIdField: 'hello' }))
).once();
});
it('returns error result when not allowed', async () => {
const viewerContext = instance(mock(ViewerContext));
const queryContext = StubQueryContextProvider.getQueryContext();
@@ -269,7 +269,8 @@ const createEntityMutatorFactory = (
databaseAdapter,
entityCache,
StubQueryContextProvider,
metricsAdapter
metricsAdapter,
TestEntity.name
);
const entityLoaderFactory = new EntityLoaderFactory(
testEntityConfiguration.idField,
@@ -309,6 +310,7 @@ describe(EntityMutatorFactory, () => {
testIndexedField: '4',
numberField: 1,
dateField: new Date(),
nullableField: null,
},
{
customIdField: 'world',
@@ -316,6 +318,7 @@ describe(EntityMutatorFactory, () => {
testIndexedField: '5',
numberField: 1,
dateField: new Date(),
nullableField: null,
},
]);
const newEntity = await entityMutatorFactory
@@ -335,6 +338,7 @@ describe(EntityMutatorFactory, () => {
testIndexedField: '4',
numberField: 1,
dateField: new Date(),
nullableField: null,
},
{
customIdField: 'world',
@@ -342,6 +346,7 @@ describe(EntityMutatorFactory, () => {
testIndexedField: '5',
numberField: 1,
dateField: new Date(),
nullableField: null,
},
]);
@@ -371,6 +376,7 @@ describe(EntityMutatorFactory, () => {
testIndexedField: '4',
numberField: 1,
dateField: new Date(),
nullableField: null,
},
{
customIdField: 'world',
@@ -378,6 +384,7 @@ describe(EntityMutatorFactory, () => {
testIndexedField: '5',
numberField: 1,
dateField: new Date(),
nullableField: null,
},
]);
@@ -408,6 +415,7 @@ describe(EntityMutatorFactory, () => {
testIndexedField: '4',
numberField: 1,
dateField: new Date(),
nullableField: null,
},
{
customIdField: 'world',
@@ -415,6 +423,7 @@ describe(EntityMutatorFactory, () => {
testIndexedField: '5',
numberField: 1,
dateField: new Date(),
nullableField: null,
},
]);
@@ -440,6 +449,7 @@ describe(EntityMutatorFactory, () => {
testIndexedField: '3',
numberField: 3,
dateField: new Date(),
nullableField: null,
},
{
customIdField: 'world',
@@ -447,6 +457,7 @@ describe(EntityMutatorFactory, () => {
testIndexedField: '4',
numberField: 3,
dateField: new Date(),
nullableField: null,
},
]);
@@ -483,6 +494,7 @@ describe(EntityMutatorFactory, () => {
testIndexedField: '3',
numberField: 3,
dateField: new Date(),
nullableField: null,
},
{
customIdField: 'world',
@@ -490,6 +502,7 @@ describe(EntityMutatorFactory, () => {
testIndexedField: '4',
numberField: 3,
dateField: new Date(),
nullableField: null,
},
]);
@@ -527,6 +540,7 @@ describe(EntityMutatorFactory, () => {
testIndexedField: '3',
numberField: 3,
dateField: new Date(),
nullableField: null,
},
{
customIdField: 'world',
@@ -534,6 +548,7 @@ describe(EntityMutatorFactory, () => {
testIndexedField: '4',
numberField: 3,
dateField: new Date(),
nullableField: null,
},
]);
@@ -572,6 +587,7 @@ describe(EntityMutatorFactory, () => {
testIndexedField: '3',
numberField: 3,
dateField: new Date(),
nullableField: null,
},
{
customIdField: 'world',
@@ -579,6 +595,7 @@ describe(EntityMutatorFactory, () => {
testIndexedField: '4',
numberField: 3,
dateField: new Date(),
nullableField: null,
},
]);
@@ -608,6 +625,7 @@ describe(EntityMutatorFactory, () => {
testIndexedField: '3',
numberField: 3,
dateField: new Date(),
nullableField: null,
},
]);
@@ -639,6 +657,7 @@ describe(EntityMutatorFactory, () => {
testIndexedField: '3',
numberField: 3,
dateField: new Date(),
nullableField: null,
},
]);
@@ -673,6 +692,7 @@ describe(EntityMutatorFactory, () => {
testIndexedField: '3',
numberField: 3,
dateField: new Date(),
nullableField: null,
},
]);
@@ -708,6 +728,7 @@ describe(EntityMutatorFactory, () => {
testIndexedField: '3',
numberField: 3,
dateField: new Date(),
nullableField: null,
},
]);
@@ -733,6 +754,7 @@ describe(EntityMutatorFactory, () => {
testIndexedField: '3',
numberField: 3,
dateField: new Date(),
nullableField: null,
},
]);
@@ -930,6 +952,7 @@ describe(EntityMutatorFactory, () => {
spiedMetricsAdapter.logMutatorMutationEvent(
objectContaining({
type: EntityMetricsMutationType.CREATE,
entityClassName: TestEntity.name,
})
)
).once();
@@ -937,6 +960,7 @@ describe(EntityMutatorFactory, () => {
spiedMetricsAdapter.logMutatorMutationEvent(
objectContaining({
type: EntityMetricsMutationType.UPDATE,
entityClassName: TestEntity.name,
})
)
).once();
@@ -944,6 +968,7 @@ describe(EntityMutatorFactory, () => {
spiedMetricsAdapter.logMutatorMutationEvent(
objectContaining({
type: EntityMetricsMutationType.DELETE,
entityClassName: TestEntity.name,
})
)
).once();
@@ -48,7 +48,7 @@ const makeEntityClass = (edgeDeletionBehavior: EntityEdgeDeletionBehavior) => {
const categoryEntityConfiguration = new EntityConfiguration<CategoryFields>({
idField: 'id',
tableName: 'categories',
inboundEdges: [CategoryEntity],
getInboundEdges: () => [CategoryEntity],
schema: {
id: new UUIDField({
columnName: 'id',
@@ -58,7 +58,7 @@ const makeEntityClass = (edgeDeletionBehavior: EntityEdgeDeletionBehavior) => {
columnName: 'parent_category_id',
cache: true,
association: {
associatedEntityClass: CategoryEntity,
getAssociatedEntityClass: () => CategoryEntity,
edgeDeletionBehavior,
},
}),
@@ -63,6 +63,7 @@ describe(ReadonlyEntity, () => {
stringField: '3',
numberField: 4,
dateField: new Date(),
nullableField: null,
});
expect(testEntity.getUniqueIdentifier()).not.toEqual(testEntity2.getUniqueIdentifier());
});
@@ -31,7 +31,8 @@ export default class EntityDataManager<TFields> {
private readonly databaseAdapter: EntityDatabaseAdapter<TFields>,
private readonly entityCache: ReadThroughEntityCache<TFields>,
private readonly queryContextProvider: EntityQueryContextProvider,
private readonly metricsAdapter: IEntityMetricsAdapter
private readonly metricsAdapter: IEntityMetricsAdapter,
private readonly entityClassName: string
) {}
private getFieldDataLoaderForFieldName<N extends keyof TFields>(
@@ -56,12 +57,18 @@ export default class EntityDataManager<TFields> {
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<ReadonlyMap<NonNullable<TFields[N]>, readonly Readonly<TFields>[]>> {
this.metricsAdapter.incrementDataManagerCacheLoadCount(fieldValues.length);
this.metricsAdapter.incrementDataManagerCacheLoadCount({
fieldValueCount: fieldValues.length,
entityClassName: this.entityClassName,
});
return await this.entityCache.readManyThroughAsync(
fieldName,
fieldValues,
async (fetcherValues) => {
this.metricsAdapter.incrementDataManagerDatabaseLoadCount(fieldValues.length);
this.metricsAdapter.incrementDataManagerDatabaseLoadCount({
fieldValueCount: fieldValues.length,
entityClassName: this.entityClassName,
});
return await this.databaseAdapter.fetchManyWhereAsync(
this.queryContextProvider.getQueryContext(),
fieldName,
@@ -86,7 +93,8 @@ export default class EntityDataManager<TFields> {
): Promise<ReadonlyMap<NonNullable<TFields[N]>, readonly Readonly<TFields>[]>> {
return await timeAndLogLoadMapEventAsync(
this.metricsAdapter,
EntityMetricsLoadType.LOAD_MANY
EntityMetricsLoadType.LOAD_MANY,
this.entityClassName
)(this.loadManyByFieldEqualingInternalAsync(queryContext, fieldName, fieldValues));
}
@@ -95,12 +103,24 @@ export default class EntityDataManager<TFields> {
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<ReadonlyMap<NonNullable<TFields[N]>, readonly Readonly<TFields>[]>> {
const nullOrUndefinedValueIndex = fieldValues.findIndex(
(value) => value === null || value === undefined
);
if (nullOrUndefinedValueIndex >= 0) {
throw new Error(
`Invalid load: ${this.entityClassName} (${fieldName} = ${fieldValues[nullOrUndefinedValueIndex]})`
);
}
// don't cache when in transaction, as rollbacks complicate things significantly
if (queryContext.isInTransaction()) {
return await this.databaseAdapter.fetchManyWhereAsync(queryContext, fieldName, fieldValues);
}
this.metricsAdapter.incrementDataManagerDataloaderLoadCount(fieldValues.length);
this.metricsAdapter.incrementDataManagerDataloaderLoadCount({
fieldValueCount: fieldValues.length,
entityClassName: this.entityClassName,
});
const dataLoader = this.getFieldDataLoaderForFieldName(fieldName);
const results = await dataLoader.loadMany(fieldValues);
const [values, errors] = partitionErrors(results);
@@ -127,7 +147,8 @@ export default class EntityDataManager<TFields> {
): Promise<readonly Readonly<TFields>[]> {
return await timeAndLogLoadEventAsync(
this.metricsAdapter,
EntityMetricsLoadType.LOAD_MANY_EQUALITY_CONJUNCTION
EntityMetricsLoadType.LOAD_MANY_EQUALITY_CONJUNCTION,
this.entityClassName
)(
this.databaseAdapter.fetchManyByFieldEqualityConjunctionAsync(
queryContext,
@@ -154,7 +175,8 @@ export default class EntityDataManager<TFields> {
): Promise<readonly Readonly<TFields>[]> {
return await timeAndLogLoadEventAsync(
this.metricsAdapter,
EntityMetricsLoadType.LOAD_MANY_RAW
EntityMetricsLoadType.LOAD_MANY_RAW,
this.entityClassName
)(
this.databaseAdapter.fetchManyByRawWhereClauseAsync(
queryContext,
@@ -23,7 +23,8 @@ export default class EntityTableDataCoordinator<TFields> {
databaseAdapterProvider: IEntityDatabaseAdapterProvider,
cacheAdapterProvider: IEntityCacheAdapterProvider,
private readonly queryContextProvider: EntityQueryContextProvider,
metricsAdapter: IEntityMetricsAdapter
metricsAdapter: IEntityMetricsAdapter,
entityClassName: string
) {
this.databaseAdapter = databaseAdapterProvider.getDatabaseAdapter(entityConfiguration);
this.cacheAdapter = cacheAdapterProvider.getCacheAdapter(entityConfiguration);
@@ -31,7 +32,8 @@ export default class EntityTableDataCoordinator<TFields> {
this.databaseAdapter,
new ReadThroughEntityCache(entityConfiguration, this.cacheAdapter),
queryContextProvider,
metricsAdapter
metricsAdapter,
entityClassName
);
}
@@ -9,12 +9,13 @@ import {
spy,
anyString,
resetCalls,
deepEqual,
} from 'ts-mockito';
import EntityDatabaseAdapter from '../../EntityDatabaseAdapter';
import IEntityMetricsAdapter, { EntityMetricsLoadType } from '../../metrics/IEntityMetricsAdapter';
import NoOpEntityMetricsAdapter from '../../metrics/NoOpEntityMetricsAdapter';
import { testEntityConfiguration, TestFields } from '../../testfixtures/TestEntity';
import TestEntity, { testEntityConfiguration, TestFields } from '../../testfixtures/TestEntity';
import {
NoCacheStubCacheAdapterProvider,
InMemoryFullCacheStubCacheAdapterProvider,
@@ -35,6 +36,7 @@ const getObjects = (): Map<string, TestFields[]> =>
stringField: 'hello',
numberField: 1,
dateField: new Date(),
nullableField: null,
},
{
customIdField: '2',
@@ -42,6 +44,7 @@ const getObjects = (): Map<string, TestFields[]> =>
stringField: 'hello',
numberField: 1,
dateField: new Date(),
nullableField: null,
},
{
customIdField: '3',
@@ -49,6 +52,7 @@ const getObjects = (): Map<string, TestFields[]> =>
stringField: 'world',
numberField: 1,
dateField: new Date(),
nullableField: null,
},
],
],
@@ -69,7 +73,8 @@ describe(EntityDataManager, () => {
databaseAdapter,
entityCache,
StubQueryContextProvider,
new NoOpEntityMetricsAdapter()
new NoOpEntityMetricsAdapter(),
TestEntity.name
);
const queryContext = StubQueryContextProvider.getQueryContext();
@@ -116,7 +121,8 @@ describe(EntityDataManager, () => {
databaseAdapter,
entityCache,
StubQueryContextProvider,
new NoOpEntityMetricsAdapter()
new NoOpEntityMetricsAdapter(),
TestEntity.name
);
const queryContext = StubQueryContextProvider.getQueryContext();
@@ -163,7 +169,8 @@ describe(EntityDataManager, () => {
databaseAdapter,
entityCache,
StubQueryContextProvider,
new NoOpEntityMetricsAdapter()
new NoOpEntityMetricsAdapter(),
TestEntity.name
);
const queryContext = StubQueryContextProvider.getQueryContext();
// use second data manager to ensure that cache is hit instead of data loader
@@ -171,7 +178,8 @@ describe(EntityDataManager, () => {
databaseAdapter,
entityCache,
StubQueryContextProvider,
new NoOpEntityMetricsAdapter()
new NoOpEntityMetricsAdapter(),
TestEntity.name
);
const dbSpy = jest.spyOn(databaseAdapter, 'fetchManyWhereAsync');
@@ -205,7 +213,8 @@ describe(EntityDataManager, () => {
databaseAdapter,
entityCache,
StubQueryContextProvider,
new NoOpEntityMetricsAdapter()
new NoOpEntityMetricsAdapter(),
TestEntity.name
);
const queryContext = StubQueryContextProvider.getQueryContext();
@@ -240,7 +249,8 @@ describe(EntityDataManager, () => {
databaseAdapter,
entityCache,
StubQueryContextProvider,
new NoOpEntityMetricsAdapter()
new NoOpEntityMetricsAdapter(),
TestEntity.name
);
const queryContext = StubQueryContextProvider.getQueryContext();
@@ -283,7 +293,8 @@ describe(EntityDataManager, () => {
databaseAdapter,
entityCache,
StubQueryContextProvider,
new NoOpEntityMetricsAdapter()
new NoOpEntityMetricsAdapter(),
TestEntity.name
);
const queryContext = StubQueryContextProvider.getQueryContext();
@@ -321,7 +332,8 @@ describe(EntityDataManager, () => {
databaseAdapter,
entityCache,
StubQueryContextProvider,
new NoOpEntityMetricsAdapter()
new NoOpEntityMetricsAdapter(),
TestEntity.name
);
const queryContext = StubQueryContextProvider.getQueryContext();
@@ -359,7 +371,8 @@ describe(EntityDataManager, () => {
databaseAdapter,
entityCache,
StubQueryContextProvider,
new NoOpEntityMetricsAdapter()
new NoOpEntityMetricsAdapter(),
TestEntity.name
);
const dbSpy = jest.spyOn(databaseAdapter, 'fetchManyWhereAsync');
@@ -396,7 +409,8 @@ describe(EntityDataManager, () => {
databaseAdapter,
entityCache,
StubQueryContextProvider,
new NoOpEntityMetricsAdapter()
new NoOpEntityMetricsAdapter(),
TestEntity.name
);
const queryContext = StubQueryContextProvider.getQueryContext();
@@ -441,7 +455,8 @@ describe(EntityDataManager, () => {
databaseAdapter,
entityCache,
StubQueryContextProvider,
new NoOpEntityMetricsAdapter()
new NoOpEntityMetricsAdapter(),
TestEntity.name
);
const queryContext = StubQueryContextProvider.getQueryContext();
@@ -467,7 +482,8 @@ describe(EntityDataManager, () => {
databaseAdapter,
entityCache,
StubQueryContextProvider,
metricsAdapter
metricsAdapter,
TestEntity.name
);
const queryContext = StubQueryContextProvider.getQueryContext();
@@ -476,6 +492,7 @@ describe(EntityDataManager, () => {
metricsAdapterMock.logDataManagerLoadEvent(
objectContaining({
type: EntityMetricsLoadType.LOAD_MANY,
entityClassName: TestEntity.name,
count: 1,
})
)
@@ -495,14 +512,36 @@ describe(EntityDataManager, () => {
metricsAdapterMock.logDataManagerLoadEvent(
objectContaining({
type: EntityMetricsLoadType.LOAD_MANY_EQUALITY_CONJUNCTION,
entityClassName: TestEntity.name,
count: 1,
})
)
).once();
verify(metricsAdapterMock.incrementDataManagerDataloaderLoadCount(1)).once();
verify(metricsAdapterMock.incrementDataManagerCacheLoadCount(1)).once();
verify(metricsAdapterMock.incrementDataManagerDatabaseLoadCount(1)).once();
verify(
metricsAdapterMock.incrementDataManagerDataloaderLoadCount(
deepEqual({
fieldValueCount: 1,
entityClassName: TestEntity.name,
})
)
).once();
verify(
metricsAdapterMock.incrementDataManagerCacheLoadCount(
deepEqual({
fieldValueCount: 1,
entityClassName: TestEntity.name,
})
)
).once();
verify(
metricsAdapterMock.incrementDataManagerDatabaseLoadCount(
deepEqual({
fieldValueCount: 1,
entityClassName: TestEntity.name,
})
)
).once();
resetCalls(metricsAdapterMock);
@@ -520,6 +559,7 @@ describe(EntityDataManager, () => {
metricsAdapterMock.logDataManagerLoadEvent(
objectContaining({
type: EntityMetricsLoadType.LOAD_MANY_RAW,
entityClassName: TestEntity.name,
count: 0,
})
)
@@ -529,4 +569,34 @@ describe(EntityDataManager, () => {
verify(metricsAdapterMock.incrementDataManagerCacheLoadCount(anyNumber())).never();
verify(metricsAdapterMock.incrementDataManagerDatabaseLoadCount(anyNumber())).never();
});
it('throws when a load by value is null or undefined', async () => {
const objects = getObjects();
const dataStore = StubDatabaseAdapter.convertFieldObjectsToDataStore(
testEntityConfiguration,
objects
);
const databaseAdapter = new StubDatabaseAdapter<TestFields>(testEntityConfiguration, dataStore);
const cacheAdapterProvider = new NoCacheStubCacheAdapterProvider();
const cacheAdapter = cacheAdapterProvider.getCacheAdapter(testEntityConfiguration);
const entityCache = new ReadThroughEntityCache(testEntityConfiguration, cacheAdapter);
const entityDataManager = new EntityDataManager(
databaseAdapter,
entityCache,
StubQueryContextProvider,
new NoOpEntityMetricsAdapter(),
TestEntity.name
);
const queryContext = StubQueryContextProvider.getQueryContext();
await expect(
entityDataManager.loadManyByFieldEqualingAsync(queryContext, 'nullableField', [null as any])
).rejects.toThrowError('Invalid load: TestEntity (nullableField = null)');
await expect(
entityDataManager.loadManyByFieldEqualingAsync(queryContext, 'nullableField', [
undefined as any,
])
).rejects.toThrowError('Invalid load: TestEntity (nullableField = undefined)');
});
});
@@ -6,7 +6,8 @@ import IEntityMetricsAdapter, {
export const timeAndLogLoadEventAsync = (
metricsAdapter: IEntityMetricsAdapter,
loadType: EntityMetricsLoadType
loadType: EntityMetricsLoadType,
entityClassName: string
) => async <TFields>(promise: Promise<readonly Readonly<TFields>[]>) => {
const startTime = Date.now();
const result = await promise;
@@ -14,6 +15,7 @@ export const timeAndLogLoadEventAsync = (
metricsAdapter.logDataManagerLoadEvent({
type: loadType,
entityClassName,
duration: endTime - startTime,
count: result.length,
});
@@ -23,7 +25,8 @@ export const timeAndLogLoadEventAsync = (
export const timeAndLogLoadMapEventAsync = (
metricsAdapter: IEntityMetricsAdapter,
loadType: EntityMetricsLoadType
loadType: EntityMetricsLoadType,
entityClassName: string
) => async <TFields, N extends keyof TFields>(
promise: Promise<ReadonlyMap<NonNullable<TFields[N]>, readonly Readonly<TFields>[]>>
) => {
@@ -35,6 +38,7 @@ export const timeAndLogLoadMapEventAsync = (
metricsAdapter.logDataManagerLoadEvent({
type: loadType,
entityClassName,
duration: endTime - startTime,
count,
});
@@ -44,7 +48,8 @@ export const timeAndLogLoadMapEventAsync = (
export const timeAndLogMutationEventAsync = (
metricsAdapter: IEntityMetricsAdapter,
mutationType: EntityMetricsMutationType
mutationType: EntityMetricsMutationType,
entityClassName: string
) => async <T>(promise: Promise<T>) => {
const startTime = Date.now();
const result = await promise;
@@ -52,6 +57,7 @@ export const timeAndLogMutationEventAsync = (
metricsAdapter.logMutatorMutationEvent({
type: mutationType,
entityClassName,
duration: endTime - startTime,
});
@@ -6,6 +6,7 @@ export enum EntityMetricsLoadType {
export interface EntityMetricsLoadEvent {
type: EntityMetricsLoadType;
entityClassName: string;
duration: number;
count: number;
}
@@ -18,9 +19,15 @@ export enum EntityMetricsMutationType {
export interface EntityMetricsMutationEvent {
type: EntityMetricsMutationType;
entityClassName: string;
duration: number;
}
export interface IncrementLoadCountEvent {
fieldValueCount: number;
entityClassName: string;
}
/**
* An interface for gathering metrics about the Entity framework. Information about
* entity load and mutation operations is piped to an instance of this adapter.
@@ -43,7 +50,7 @@ export default interface IEntityMetricsAdapter {
* load methods (not equality conjunction or raw).
* @param fieldValueCount - count of field values being loaded for a field
*/
incrementDataManagerDataloaderLoadCount(fieldValueCount: number): void;
incrementDataManagerDataloaderLoadCount(incrementLoadCountEvent: IncrementLoadCountEvent): void;
/**
* Called when a cache load is initiated via the standard
@@ -51,7 +58,7 @@ export default interface IEntityMetricsAdapter {
* miss.
* @param fieldValueCount - count of field values being loaded for a field
*/
incrementDataManagerCacheLoadCount(fieldValueCount: number): void;
incrementDataManagerCacheLoadCount(incrementLoadCountEvent: IncrementLoadCountEvent): void;
/**
* Called when a database load is initiated via the standard
@@ -59,5 +66,5 @@ export default interface IEntityMetricsAdapter {
* miss or when fetching an uncacheable field.
* @param fieldValueCount - count of field values being loaded for a field
*/
incrementDataManagerDatabaseLoadCount(fieldValueCount: number): void;
incrementDataManagerDatabaseLoadCount(incrementLoadCountEvent: IncrementLoadCountEvent): void;
}
@@ -1,12 +1,15 @@
import IEntityMetricsAdapter, {
EntityMetricsLoadEvent,
EntityMetricsMutationEvent,
IncrementLoadCountEvent,
} from './IEntityMetricsAdapter';
export default class NoOpEntityMetricsAdapter implements IEntityMetricsAdapter {
logDataManagerLoadEvent(_loadEvent: EntityMetricsLoadEvent): void {}
logMutatorMutationEvent(_mutationEvent: EntityMetricsMutationEvent): void {}
incrementDataManagerDataloaderLoadCount(_fieldValueCount: number): void {}
incrementDataManagerCacheLoadCount(_fieldValueCount: number): void {}
incrementDataManagerDatabaseLoadCount(_fieldValueCount: number): void {}
incrementDataManagerDataloaderLoadCount(
_incrementLoadCountEvent: IncrementLoadCountEvent
): void {}
incrementDataManagerCacheLoadCount(_incrementLoadCountEvent: IncrementLoadCountEvent): void {}
incrementDataManagerDatabaseLoadCount(_incrementLoadCountEvent: IncrementLoadCountEvent): void {}
}
@@ -18,6 +18,7 @@ export type TestFields = {
stringField: string;
numberField: number;
dateField: Date;
nullableField: string | null;
};
export const testEntityConfiguration = new EntityConfiguration<TestFields>({
@@ -40,6 +41,9 @@ export const testEntityConfiguration = new EntityConfiguration<TestFields>({
dateField: new DateField({
columnName: 'date_field',
}),
nullableField: new StringField({
columnName: 'nullable_field',
}),
},
databaseAdapterFlavor: DatabaseAdapterFlavor.POSTGRES,
cacheAdapterFlavor: CacheAdapterFlavor.REDIS,
@@ -88,6 +92,7 @@ export default class TestEntity extends Entity<TestFields, string, ViewerContext
stringField: 'hello',
numberField: 1,
dateField: new Date(),
nullableField: null,
})
);
}
@@ -35,6 +35,7 @@ describe(StubDatabaseAdapter, () => {
numberField: 5,
stringField: 'huh',
dateField: new Date(),
nullableField: null,
},
{
customIdField: 'world',
@@ -42,6 +43,7 @@ describe(StubDatabaseAdapter, () => {
numberField: 3,
stringField: 'wat',
dateField: new Date(),
nullableField: null,
},
],
],
@@ -73,6 +75,7 @@ describe(StubDatabaseAdapter, () => {
numberField: 3,
stringField: 'a',
dateField: new Date(),
nullableField: null,
},
{
customIdField: 'world',
@@ -80,6 +83,7 @@ describe(StubDatabaseAdapter, () => {
numberField: 3,
stringField: 'b',
dateField: new Date(),
nullableField: null,
},
{
customIdField: 'world',
@@ -87,6 +91,7 @@ describe(StubDatabaseAdapter, () => {
numberField: 3,
stringField: 'c',
dateField: new Date(),
nullableField: null,
},
],
],
@@ -138,6 +143,7 @@ describe(StubDatabaseAdapter, () => {
numberField: 3,
stringField: 'a',
dateField: new Date(),
nullableField: null,
},
{
customIdField: 'world',
@@ -145,6 +151,7 @@ describe(StubDatabaseAdapter, () => {
numberField: 3,
stringField: 'b',
dateField: new Date(),
nullableField: null,
},
{
customIdField: 'world',
@@ -152,6 +159,7 @@ describe(StubDatabaseAdapter, () => {
numberField: 3,
stringField: 'c',
dateField: new Date(),
nullableField: null,
},
],
],
@@ -236,6 +244,7 @@ describe(StubDatabaseAdapter, () => {
numberField: 3,
stringField: 'a',
dateField: new Date(),
nullableField: null,
},
],
],
@@ -269,6 +278,7 @@ describe(StubDatabaseAdapter, () => {
numberField: 3,
stringField: 'a',
dateField: new Date(),
nullableField: null,
},
],
],