Compare commits

...
Author SHA1 Message Date
William Schurman 39093c3498 feat: add entity write validation 2020-06-23 20:43:20 -07:00
6 changed files with 233 additions and 20 deletions

No files matched your search

+4 -4
View File
@@ -11,7 +11,7 @@ export default class EntityConfiguration<TFields> {
readonly cacheableKeys: ReadonlySet<keyof TFields>; readonly cacheableKeys: ReadonlySet<keyof TFields>;
readonly cacheKeyVersion: number; readonly cacheKeyVersion: number;
readonly schema: ReadonlyMap<keyof TFields, EntityFieldDefinition>; readonly schema: ReadonlyMap<keyof TFields, EntityFieldDefinition<any>>;
readonly entityToDBFieldsKeyMapping: ReadonlyMap<keyof TFields, string>; readonly entityToDBFieldsKeyMapping: ReadonlyMap<keyof TFields, string>;
readonly dbToEntityFieldsKeyMapping: ReadonlyMap<string, keyof TFields>; readonly dbToEntityFieldsKeyMapping: ReadonlyMap<string, keyof TFields>;
@@ -23,7 +23,7 @@ export default class EntityConfiguration<TFields> {
}: { }: {
idField: keyof TFields; idField: keyof TFields;
tableName: string; tableName: string;
schema: Record<keyof TFields, EntityFieldDefinition>; schema: Record<keyof TFields, EntityFieldDefinition<any>>;
cacheKeyVersion?: number; cacheKeyVersion?: number;
}) { }) {
this.idField = idField; this.idField = idField;
@@ -43,7 +43,7 @@ export default class EntityConfiguration<TFields> {
} }
private static computeCacheableKeys<TFields>( private static computeCacheableKeys<TFields>(
schema: ReadonlyMap<keyof TFields, EntityFieldDefinition> schema: ReadonlyMap<keyof TFields, EntityFieldDefinition<any>>
): ReadonlySet<keyof TFields> { ): ReadonlySet<keyof TFields> {
return reduceMap( return reduceMap(
schema, schema,
@@ -58,7 +58,7 @@ export default class EntityConfiguration<TFields> {
} }
private static computeEntityToDBFieldsKeyMapping<TFields>( private static computeEntityToDBFieldsKeyMapping<TFields>(
schema: ReadonlyMap<keyof TFields, EntityFieldDefinition> schema: ReadonlyMap<keyof TFields, EntityFieldDefinition<any>>
): ReadonlyMap<keyof TFields, string> { ): ReadonlyMap<keyof TFields, string> {
return mapMap(schema, (v) => v.columnName); return mapMap(schema, (v) => v.columnName);
} }
+33 -11
View File
@@ -1,6 +1,17 @@
export abstract class EntityFieldDefinition { export interface FieldValidator<T> {
/**
* Validation to apply for this field before an entity is created or updated.
* Only applies to fields changed during a mutation; any existing fields will
* not be re-validated in order to allow for changing of validator logic.
*/
write?: (value: T) => Promise<void>;
}
export abstract class EntityFieldDefinition<T> {
readonly columnName: string; readonly columnName: string;
readonly cache: boolean; readonly cache: boolean;
readonly validator: FieldValidator<T>;
/** /**
* *
* @param columnName - Column name in the database. * @param columnName - Column name in the database.
@@ -8,19 +19,30 @@ export abstract class EntityFieldDefinition {
* 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.
*/ */
constructor({ columnName, cache = false }: { columnName: string; cache?: boolean }) { constructor({
columnName,
cache = false,
validator = {},
}: {
columnName: string;
cache?: boolean;
validator?: FieldValidator<T>;
}) {
this.columnName = columnName; this.columnName = columnName;
this.cache = cache; this.cache = cache;
this.validator = validator;
} }
} }
export class StringField extends EntityFieldDefinition {} export class StringField extends EntityFieldDefinition<string> {}
export class UUIDField extends StringField {} export class UUIDField extends StringField {}
export class DateField extends EntityFieldDefinition {} export class DateField extends EntityFieldDefinition<Date> {}
export class BooleanField extends EntityFieldDefinition {} export class BooleanField extends EntityFieldDefinition<boolean> {}
export class NumberField extends EntityFieldDefinition {} export class NumberField extends EntityFieldDefinition<number> {}
export class StringArrayField extends EntityFieldDefinition {} export class StringArrayField extends EntityFieldDefinition<string[]> {}
export class JSONObjectField extends EntityFieldDefinition {} export class JSONObjectField<T extends object> extends EntityFieldDefinition<T> {}
export class EnumField extends EntityFieldDefinition {} export class EnumField<T> extends EntityFieldDefinition<T> {}
export class JSONArrayField extends EntityFieldDefinition {} export class JSONArrayField<T> extends EntityFieldDefinition<T[]> {}
export class MaybeJSONArrayField extends EntityFieldDefinition {} export class MaybeJSONArrayField<TArray, TNotArray> extends EntityFieldDefinition<
TArray[] | TNotArray
> {}
+33 -3
View File
@@ -1,4 +1,5 @@
import { Result, asyncResult, result, enforceAsyncResult } from '@expo/results'; import { Result, asyncResult, result, enforceAsyncResult } from '@expo/results';
import invariant from 'invariant';
import _ from 'lodash'; import _ from 'lodash';
import Entity, { IEntityClass } from './Entity'; import Entity, { IEntityClass } from './Entity';
@@ -40,6 +41,25 @@ abstract class BaseMutator<
protected readonly databaseAdapter: EntityDatabaseAdapter<TFields>, protected readonly databaseAdapter: EntityDatabaseAdapter<TFields>,
protected readonly metricsAdapter: IEntityMetricsAdapter protected readonly metricsAdapter: IEntityMetricsAdapter
) {} ) {}
protected async validateFields(fields: Partial<TFields>): Promise<Result<Partial<TFields>>> {
const validatorResults = await asyncResult(
Promise.all(
Object.entries(fields).map(async ([fieldName, fieldValue]) => {
const fieldDefinition = this.entityConfiguration.schema.get(fieldName as keyof TFields);
invariant(fieldDefinition, `must have field definition for field = ${fieldName}`);
const writeValidator = fieldDefinition.validator.write;
if (writeValidator) {
await writeValidator(fieldValue);
}
})
)
);
if (!validatorResults.ok) {
return result(validatorResults.reason);
}
return result(fields);
}
} }
/** /**
@@ -84,9 +104,14 @@ export class CreateMutator<
} }
private async createInternalAsync(): Promise<Result<TEntity>> { private async createInternalAsync(): Promise<Result<TEntity>> {
const validatedFieldsResult = await this.validateFields(this.fieldsForEntity);
if (!validatedFieldsResult.ok) {
return result(validatedFieldsResult.reason);
}
const temporaryEntityForPrivacyCheck = new this.entityClass(this.viewerContext, ({ const temporaryEntityForPrivacyCheck = new this.entityClass(this.viewerContext, ({
[this.entityConfiguration.idField]: '00000000-0000-0000-0000-000000000000', // zero UUID [this.entityConfiguration.idField]: '00000000-0000-0000-0000-000000000000', // zero UUID
...this.fieldsForEntity, ...validatedFieldsResult.value,
} as unknown) as TFields); } as unknown) as TFields);
const authorizeCreateResult = await asyncResult( const authorizeCreateResult = await asyncResult(
@@ -102,7 +127,7 @@ export class CreateMutator<
const insertResult = await this.databaseAdapter.insertAsync( const insertResult = await this.databaseAdapter.insertAsync(
this.queryContext, this.queryContext,
this.fieldsForEntity validatedFieldsResult.value
); );
const entityLoader = this.entityLoaderFactory.forLoad(this.viewerContext, this.queryContext); const entityLoader = this.entityLoaderFactory.forLoad(this.viewerContext, this.queryContext);
@@ -184,6 +209,11 @@ export class UpdateMutator<
} }
private async updateInternalAsync(): Promise<Result<TEntity>> { private async updateInternalAsync(): Promise<Result<TEntity>> {
const validatedUpdatedFieldsResult = await this.validateFields(this.updatedFields);
if (!validatedUpdatedFieldsResult.ok) {
return result(validatedUpdatedFieldsResult.reason);
}
const entityAboutToBeUpdated = new this.entityClass(this.viewerContext, this.fieldsForEntity); const entityAboutToBeUpdated = new this.entityClass(this.viewerContext, this.fieldsForEntity);
const authorizeUpdateResult = await asyncResult( const authorizeUpdateResult = await asyncResult(
this.privacyPolicy.authorizeUpdateAsync( this.privacyPolicy.authorizeUpdateAsync(
@@ -200,7 +230,7 @@ export class UpdateMutator<
this.queryContext, this.queryContext,
this.entityConfiguration.idField, this.entityConfiguration.idField,
entityAboutToBeUpdated.getID(), entityAboutToBeUpdated.getID(),
this.updatedFields validatedUpdatedFieldsResult.value
); );
const entityLoader = this.entityLoaderFactory.forLoad(this.viewerContext, this.queryContext); const entityLoader = this.entityLoaderFactory.forLoad(this.viewerContext, this.queryContext);
@@ -1,6 +1,6 @@
import { EntityFieldDefinition } from '../EntityFields'; import { EntityFieldDefinition } from '../EntityFields';
class TestFieldDefinition extends EntityFieldDefinition {} class TestFieldDefinition extends EntityFieldDefinition<any> {}
describe(EntityFieldDefinition, () => { describe(EntityFieldDefinition, () => {
it('returns correct column name and defaults cache to false', () => { it('returns correct column name and defaults cache to false', () => {
@@ -12,4 +12,21 @@ describe(EntityFieldDefinition, () => {
expect(fieldDefinition2.columnName).toEqual('wat'); expect(fieldDefinition2.columnName).toEqual('wat');
expect(fieldDefinition2.cache).toEqual(true); expect(fieldDefinition2.cache).toEqual(true);
}); });
it('defaults validator to no-op', () => {
const fieldDefinition = new TestFieldDefinition({ columnName: 'wat', cache: true });
expect(fieldDefinition.validator).toEqual({});
const fn = async (): Promise<void> => {
throw new Error();
};
const fieldDefinition2 = new TestFieldDefinition({
columnName: 'wat',
cache: true,
validator: {
write: fn,
},
});
expect(fieldDefinition2.validator.write).toEqual(fn);
});
}); });
@@ -30,6 +30,11 @@ import TestEntity, {
TestEntityPrivacyPolicy, TestEntityPrivacyPolicy,
testEntityConfiguration, testEntityConfiguration,
} from '../testfixtures/TestEntity'; } from '../testfixtures/TestEntity';
import ValidationTestEntity, {
ValidationTestEntityPrivacyPolicy,
ValidationTestFields,
validationTestEntityConfiguration,
} from '../testfixtures/ValidationTestEntity';
import { NoCacheStubCacheAdapterProvider } from '../utils/testing/StubCacheAdapter'; import { NoCacheStubCacheAdapterProvider } from '../utils/testing/StubCacheAdapter';
import StubDatabaseAdapter from '../utils/testing/StubDatabaseAdapter'; import StubDatabaseAdapter from '../utils/testing/StubDatabaseAdapter';
import StubQueryContextProvider from '../utils/testing/StubQueryContextProvider'; import StubQueryContextProvider from '../utils/testing/StubQueryContextProvider';
@@ -95,7 +100,7 @@ const createEntityMutatorFactory = (
}; };
describe(EntityMutatorFactory, () => { describe(EntityMutatorFactory, () => {
it('creates entities and checks privacy', async () => { it('creates entities checks privacy', async () => {
const viewerContext = mock<ViewerContext>(); const viewerContext = mock<ViewerContext>();
const queryContext = mock<EntityQueryContext>(); const queryContext = mock<EntityQueryContext>();
const { privacyPolicy, entityMutatorFactory } = createEntityMutatorFactory([ const { privacyPolicy, entityMutatorFactory } = createEntityMutatorFactory([
@@ -246,6 +251,73 @@ describe(EntityMutatorFactory, () => {
expect(entities2).toHaveLength(2); expect(entities2).toHaveLength(2);
}); });
it('returns error when field not valid', async () => {
const viewerContext = mock<ViewerContext>();
const queryContext = mock<EntityQueryContext>();
const privacyPolicy = new ValidationTestEntityPrivacyPolicy();
const databaseAdapter = new StubDatabaseAdapter<ValidationTestFields>(
validationTestEntityConfiguration,
StubDatabaseAdapter.convertFieldObjectsToDataStore(
validationTestEntityConfiguration,
new Map([[validationTestEntityConfiguration.tableName, []]])
)
);
const metricsAdapter = new NoOpEntityMetricsAdapter();
const cacheAdapterProvider = new NoCacheStubCacheAdapterProvider();
const cacheAdapter = cacheAdapterProvider.getCacheAdapter(validationTestEntityConfiguration);
const entityCache = new ReadThroughEntityCache<ValidationTestFields>(
validationTestEntityConfiguration,
cacheAdapter
);
const dataManager = new EntityDataManager(
databaseAdapter,
entityCache,
StubQueryContextProvider,
metricsAdapter
);
const entityLoaderFactory = new EntityLoaderFactory(
validationTestEntityConfiguration,
ValidationTestEntity,
privacyPolicy,
dataManager
);
const entityMutatorFactory = new EntityMutatorFactory(
validationTestEntityConfiguration,
ValidationTestEntity,
privacyPolicy,
entityLoaderFactory,
databaseAdapter,
metricsAdapter
);
const entityCreateInvalidResult = await entityMutatorFactory
.forCreate(viewerContext, queryContext)
.setField('numberOtherThanTen', 10)
.createAsync();
expect(entityCreateInvalidResult.ok).toBe(false);
expect(entityCreateInvalidResult.reason?.message).toEqual('should not be ten');
const entityCreateValidResult = await entityMutatorFactory
.forCreate(viewerContext, queryContext)
.setField('numberOtherThanTen', 11)
.createAsync();
expect(entityCreateValidResult.ok).toBe(true);
const entityUpdateInvalidResult = await entityMutatorFactory
.forUpdate(entityCreateValidResult.enforceValue(), queryContext)
.setField('numberOtherThanTen', 10)
.updateAsync();
expect(entityUpdateInvalidResult.ok).toBe(false);
expect(entityUpdateInvalidResult.reason?.message).toEqual('should not be ten');
const entityUpdateValidResult = await entityMutatorFactory
.forUpdate(entityCreateValidResult.enforceValue(), queryContext)
.setField('numberOtherThanTen', 12)
.updateAsync();
expect(entityUpdateValidResult.ok).toBe(true);
});
it('returns error result when not authorized to create', async () => { it('returns error result when not authorized to create', async () => {
const viewerContext = instance(mock(ViewerContext)); const viewerContext = instance(mock(ViewerContext));
const queryContext = instance(mock(EntityNonTransactionalQueryContext)); const queryContext = instance(mock(EntityNonTransactionalQueryContext));
@@ -0,0 +1,72 @@
import Entity from '../Entity';
import {
DatabaseAdapterFlavor,
CacheAdapterFlavor,
EntityCompanionDefinition,
} from '../EntityCompanionProvider';
import EntityConfiguration from '../EntityConfiguration';
import { UUIDField, NumberField } from '../EntityFields';
import EntityPrivacyPolicy from '../EntityPrivacyPolicy';
import ViewerContext from '../ViewerContext';
import AlwaysAllowPrivacyPolicyRule from '../rules/AlwaysAllowPrivacyPolicyRule';
export type ValidationTestFields = {
id: string;
numberOtherThanTen: number;
};
export const validationTestEntityConfiguration = new EntityConfiguration<ValidationTestFields>({
idField: 'id',
tableName: 'validation_test_entity',
schema: {
id: new UUIDField({
columnName: 'custom_id',
}),
numberOtherThanTen: new NumberField({
columnName: 'not_ten',
validator: {
async write(value) {
if (value === 10) {
throw new Error('should not be ten');
}
},
},
}),
},
});
export class ValidationTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
ValidationTestFields,
string,
ViewerContext,
ValidationTestEntity
> {
protected readonly readRules = [new AlwaysAllowPrivacyPolicyRule()];
protected readonly createRules = [new AlwaysAllowPrivacyPolicyRule()];
protected readonly updateRules = [new AlwaysAllowPrivacyPolicyRule()];
protected readonly deleteRules = [new AlwaysAllowPrivacyPolicyRule()];
}
export default class ValidationTestEntity extends Entity<
ValidationTestFields,
string,
ViewerContext
> {
static getCompanionDefinition(): EntityCompanionDefinition<
ValidationTestFields,
string,
ViewerContext,
ValidationTestEntity,
ValidationTestEntityPrivacyPolicy
> {
return validationTestEntityCompanion;
}
}
export const validationTestEntityCompanion = {
entityClass: ValidationTestEntity,
entityConfiguration: validationTestEntityConfiguration,
databaseAdaptorFlavor: DatabaseAdapterFlavor.POSTGRES,
cacheAdaptorFlavor: CacheAdapterFlavor.REDIS,
privacyPolicyClass: ValidationTestEntityPrivacyPolicy,
};