mirror of
https://github.com/expo/entity.git
synced 2026-11-14 09:58:09 -10:00
Compare 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 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
> {}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
Reference in new issue
Block a user