mirror of
https://github.com/expo/entity.git
synced 2026-11-13 20:28:06 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39093c3498 |
No files matched your search
@@ -11,7 +11,7 @@ export default class EntityConfiguration<TFields> {
|
||||
readonly cacheableKeys: ReadonlySet<keyof TFields>;
|
||||
readonly cacheKeyVersion: number;
|
||||
|
||||
readonly schema: ReadonlyMap<keyof TFields, EntityFieldDefinition>;
|
||||
readonly schema: ReadonlyMap<keyof TFields, EntityFieldDefinition<any>>;
|
||||
readonly entityToDBFieldsKeyMapping: ReadonlyMap<keyof TFields, string>;
|
||||
readonly dbToEntityFieldsKeyMapping: ReadonlyMap<string, keyof TFields>;
|
||||
|
||||
@@ -23,7 +23,7 @@ export default class EntityConfiguration<TFields> {
|
||||
}: {
|
||||
idField: keyof TFields;
|
||||
tableName: string;
|
||||
schema: Record<keyof TFields, EntityFieldDefinition>;
|
||||
schema: Record<keyof TFields, EntityFieldDefinition<any>>;
|
||||
cacheKeyVersion?: number;
|
||||
}) {
|
||||
this.idField = idField;
|
||||
@@ -43,7 +43,7 @@ export default class EntityConfiguration<TFields> {
|
||||
}
|
||||
|
||||
private static computeCacheableKeys<TFields>(
|
||||
schema: ReadonlyMap<keyof TFields, EntityFieldDefinition>
|
||||
schema: ReadonlyMap<keyof TFields, EntityFieldDefinition<any>>
|
||||
): ReadonlySet<keyof TFields> {
|
||||
return reduceMap(
|
||||
schema,
|
||||
@@ -58,7 +58,7 @@ export default class EntityConfiguration<TFields> {
|
||||
}
|
||||
|
||||
private static computeEntityToDBFieldsKeyMapping<TFields>(
|
||||
schema: ReadonlyMap<keyof TFields, EntityFieldDefinition>
|
||||
schema: ReadonlyMap<keyof TFields, EntityFieldDefinition<any>>
|
||||
): ReadonlyMap<keyof TFields, string> {
|
||||
return mapMap(schema, (v) => v.columnName);
|
||||
}
|
||||
|
||||
@@ -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 cache: boolean;
|
||||
readonly validator: FieldValidator<T>;
|
||||
|
||||
/**
|
||||
*
|
||||
* @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
|
||||
* 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.cache = cache;
|
||||
this.validator = validator;
|
||||
}
|
||||
}
|
||||
|
||||
export class StringField extends EntityFieldDefinition {}
|
||||
export class StringField extends EntityFieldDefinition<string> {}
|
||||
export class UUIDField extends StringField {}
|
||||
export class DateField extends EntityFieldDefinition {}
|
||||
export class BooleanField extends EntityFieldDefinition {}
|
||||
export class NumberField extends EntityFieldDefinition {}
|
||||
export class StringArrayField extends EntityFieldDefinition {}
|
||||
export class JSONObjectField extends EntityFieldDefinition {}
|
||||
export class EnumField extends EntityFieldDefinition {}
|
||||
export class JSONArrayField extends EntityFieldDefinition {}
|
||||
export class MaybeJSONArrayField extends EntityFieldDefinition {}
|
||||
export class DateField extends EntityFieldDefinition<Date> {}
|
||||
export class BooleanField extends EntityFieldDefinition<boolean> {}
|
||||
export class NumberField extends EntityFieldDefinition<number> {}
|
||||
export class StringArrayField extends EntityFieldDefinition<string[]> {}
|
||||
export class JSONObjectField<T extends object> extends EntityFieldDefinition<T> {}
|
||||
export class EnumField<T> extends EntityFieldDefinition<T> {}
|
||||
export class JSONArrayField<T> extends EntityFieldDefinition<T[]> {}
|
||||
export class MaybeJSONArrayField<TArray, TNotArray> extends EntityFieldDefinition<
|
||||
TArray[] | TNotArray
|
||||
> {}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Result, asyncResult, result, enforceAsyncResult } from '@expo/results';
|
||||
import invariant from 'invariant';
|
||||
import _ from 'lodash';
|
||||
|
||||
import Entity, { IEntityClass } from './Entity';
|
||||
@@ -40,6 +41,25 @@ abstract class BaseMutator<
|
||||
protected readonly databaseAdapter: EntityDatabaseAdapter<TFields>,
|
||||
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>> {
|
||||
const validatedFieldsResult = await this.validateFields(this.fieldsForEntity);
|
||||
if (!validatedFieldsResult.ok) {
|
||||
return result(validatedFieldsResult.reason);
|
||||
}
|
||||
|
||||
const temporaryEntityForPrivacyCheck = new this.entityClass(this.viewerContext, ({
|
||||
[this.entityConfiguration.idField]: '00000000-0000-0000-0000-000000000000', // zero UUID
|
||||
...this.fieldsForEntity,
|
||||
...validatedFieldsResult.value,
|
||||
} as unknown) as TFields);
|
||||
|
||||
const authorizeCreateResult = await asyncResult(
|
||||
@@ -102,7 +127,7 @@ export class CreateMutator<
|
||||
|
||||
const insertResult = await this.databaseAdapter.insertAsync(
|
||||
this.queryContext,
|
||||
this.fieldsForEntity
|
||||
validatedFieldsResult.value
|
||||
);
|
||||
|
||||
const entityLoader = this.entityLoaderFactory.forLoad(this.viewerContext, this.queryContext);
|
||||
@@ -184,6 +209,11 @@ export class UpdateMutator<
|
||||
}
|
||||
|
||||
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 authorizeUpdateResult = await asyncResult(
|
||||
this.privacyPolicy.authorizeUpdateAsync(
|
||||
@@ -200,7 +230,7 @@ export class UpdateMutator<
|
||||
this.queryContext,
|
||||
this.entityConfiguration.idField,
|
||||
entityAboutToBeUpdated.getID(),
|
||||
this.updatedFields
|
||||
validatedUpdatedFieldsResult.value
|
||||
);
|
||||
|
||||
const entityLoader = this.entityLoaderFactory.forLoad(this.viewerContext, this.queryContext);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { EntityFieldDefinition } from '../EntityFields';
|
||||
|
||||
class TestFieldDefinition extends EntityFieldDefinition {}
|
||||
class TestFieldDefinition extends EntityFieldDefinition<any> {}
|
||||
|
||||
describe(EntityFieldDefinition, () => {
|
||||
it('returns correct column name and defaults cache to false', () => {
|
||||
@@ -12,4 +12,21 @@ describe(EntityFieldDefinition, () => {
|
||||
expect(fieldDefinition2.columnName).toEqual('wat');
|
||||
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,
|
||||
testEntityConfiguration,
|
||||
} from '../testfixtures/TestEntity';
|
||||
import ValidationTestEntity, {
|
||||
ValidationTestEntityPrivacyPolicy,
|
||||
ValidationTestFields,
|
||||
validationTestEntityConfiguration,
|
||||
} from '../testfixtures/ValidationTestEntity';
|
||||
import { NoCacheStubCacheAdapterProvider } from '../utils/testing/StubCacheAdapter';
|
||||
import StubDatabaseAdapter from '../utils/testing/StubDatabaseAdapter';
|
||||
import StubQueryContextProvider from '../utils/testing/StubQueryContextProvider';
|
||||
@@ -95,7 +100,7 @@ const createEntityMutatorFactory = (
|
||||
};
|
||||
|
||||
describe(EntityMutatorFactory, () => {
|
||||
it('creates entities and checks privacy', async () => {
|
||||
it('creates entities checks privacy', async () => {
|
||||
const viewerContext = mock<ViewerContext>();
|
||||
const queryContext = mock<EntityQueryContext>();
|
||||
const { privacyPolicy, entityMutatorFactory } = createEntityMutatorFactory([
|
||||
@@ -246,6 +251,73 @@ describe(EntityMutatorFactory, () => {
|
||||
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 () => {
|
||||
const viewerContext = instance(mock(ViewerContext));
|
||||
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,
|
||||
};
|
||||
Reference in new issue
Block a user