mirror of
https://github.com/expo/entity.git
synced 2026-11-15 03:48:08 -10:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f102ef29d | ||
|
|
0dbf5e98bb | ||
|
|
4347d9d577 | ||
|
|
9a91b19466 | ||
|
|
cb7aaad979 | ||
|
|
9fcfbf746a | ||
|
|
9c44e8e8d4 |
No files matched your search
@@ -14,7 +14,7 @@ jobs:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
- uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 12.x
|
||||
node-version: 18.x
|
||||
- name: Cache Node.js modules
|
||||
uses: actions/cache@v1
|
||||
with:
|
||||
|
||||
+2
-2
@@ -19,7 +19,6 @@
|
||||
"@expo/entity": "file:packages/entity",
|
||||
"@expo/results": "^1.0.0",
|
||||
"@types/invariant": "^2.2.33",
|
||||
"@types/ioredis": "^4.26.4",
|
||||
"@types/jest": "^26.0.10",
|
||||
"@types/jsbn": "^1.2.29",
|
||||
"@types/lru-cache": "^5.1.1",
|
||||
@@ -31,6 +30,7 @@
|
||||
"eslint": "^7.7.0",
|
||||
"eslint-config-universe": "^7.0.0",
|
||||
"eslint-plugin-tsdoc": "^0.2.11",
|
||||
"ioredis": "^5.0.4",
|
||||
"jest": "^26.6.3",
|
||||
"lerna": "^4.0.0",
|
||||
"lru-cache": "^6.0.0",
|
||||
@@ -40,7 +40,7 @@
|
||||
"ts-jest": "~26.4.4",
|
||||
"ts-mockito": "^2.6.1",
|
||||
"typedoc": "^0.21.9",
|
||||
"typescript": "^4.4.2",
|
||||
"typescript": "^4.6.3",
|
||||
"uuid": "^8.3.0"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -68,7 +68,7 @@ export default class LocalMemoryCacheAdapter<TFields> extends EntityCacheAdapter
|
||||
fieldValue: NonNullable<TFields[N]>
|
||||
): string {
|
||||
const columnName = this.entityConfiguration.entityToDBFieldsKeyMapping.get(fieldName);
|
||||
invariant(columnName, `database field mapping missing for ${fieldName}`);
|
||||
invariant(columnName, `database field mapping missing for ${String(fieldName)}`);
|
||||
const parts = [
|
||||
this.entityConfiguration.tableName,
|
||||
`${this.entityConfiguration.cacheKeyVersion}`,
|
||||
|
||||
@@ -27,12 +27,11 @@
|
||||
"author": "Expo",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@expo/entity": "*"
|
||||
},
|
||||
"dependencies": {
|
||||
"ioredis": "^4.27.3"
|
||||
"@expo/entity": "*",
|
||||
"ioredis": ">=5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/entity": "^0.25.3"
|
||||
"@expo/entity": "^0.25.3",
|
||||
"ioredis": "^5.0.4"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { EntityCacheAdapter, EntityConfiguration, CacheLoadResult, mapKeys } from '@expo/entity';
|
||||
import invariant from 'invariant';
|
||||
import { Redis } from 'ioredis';
|
||||
import type { Redis } from 'ioredis';
|
||||
|
||||
import GenericRedisCacher from './GenericRedisCacher';
|
||||
|
||||
@@ -113,7 +113,7 @@ export default class RedisCacheAdapter<TFields> extends EntityCacheAdapter<TFiel
|
||||
fieldValue: NonNullable<TFields[N]>
|
||||
): string {
|
||||
const columnName = this.entityConfiguration.entityToDBFieldsKeyMapping.get(fieldName);
|
||||
invariant(columnName, `database field mapping missing for ${fieldName}`);
|
||||
invariant(columnName, `database field mapping missing for ${String(fieldName)}`);
|
||||
return this.context.makeKeyFn(
|
||||
this.context.cacheKeyPrefix,
|
||||
this.entityConfiguration.tableName,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DateField } from '@expo/entity';
|
||||
import { DateField, LowercaseStringField } from '@expo/entity';
|
||||
|
||||
export const redisTransformerMap = new Map([
|
||||
[
|
||||
@@ -22,4 +22,10 @@ export const redisTransformerMap = new Map([
|
||||
read: (val: any) => (val ? new Date(val) : val),
|
||||
},
|
||||
],
|
||||
[
|
||||
LowercaseStringField.name,
|
||||
{
|
||||
write: (val: any) => (typeof val === 'string' ? val.toLowerCase() : val),
|
||||
},
|
||||
],
|
||||
]);
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
TableQuerySelectionModifiers,
|
||||
TableFieldSingleValueEqualityCondition,
|
||||
TableFieldMultiValueEqualityCondition,
|
||||
LowercaseStringField,
|
||||
} from '@expo/entity';
|
||||
import { Knex } from 'knex';
|
||||
|
||||
@@ -36,6 +37,12 @@ export default class PostgresEntityDatabaseAdapter<TFields> extends EntityDataba
|
||||
write: (val: any[] | any) => (Array.isArray(val) ? JSON.stringify(val) : val),
|
||||
},
|
||||
],
|
||||
[
|
||||
LowercaseStringField.name,
|
||||
{
|
||||
write: (val: any) => (typeof val === 'string' ? val.toLowerCase() : val),
|
||||
},
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ router.get('/', async (ctx: ExampleContext) => {
|
||||
|
||||
router.get('/:id', async (ctx: ExampleContext) => {
|
||||
const viewerContext = ctx.state.viewerContext;
|
||||
const noteResult = await NoteEntity.loader(viewerContext).loadByIDAsync(ctx.params.id);
|
||||
const noteResult = await NoteEntity.loader(viewerContext).loadByIDAsync(ctx.params['id']!);
|
||||
if (!noteResult.ok) {
|
||||
ctx.throw(403, noteResult.reason);
|
||||
}
|
||||
@@ -69,7 +69,7 @@ router.put('/:id', async (ctx: ExampleContext) => {
|
||||
const viewerContext = ctx.state.viewerContext;
|
||||
const { title, body } = ctx.request.body;
|
||||
|
||||
const noteLoadResult = await NoteEntity.loader(viewerContext).loadByIDAsync(ctx.params.id);
|
||||
const noteLoadResult = await NoteEntity.loader(viewerContext).loadByIDAsync(ctx.params['id']!);
|
||||
if (!noteLoadResult.ok) {
|
||||
ctx.throw(403, noteLoadResult.reason);
|
||||
}
|
||||
@@ -90,7 +90,7 @@ router.put('/:id', async (ctx: ExampleContext) => {
|
||||
router.delete('/:id', async (ctx: ExampleContext) => {
|
||||
const viewerContext = ctx.state.viewerContext;
|
||||
|
||||
const noteLoadResult = await NoteEntity.loader(viewerContext).loadByIDAsync(ctx.params.id);
|
||||
const noteLoadResult = await NoteEntity.loader(viewerContext).loadByIDAsync(ctx.params['id']!);
|
||||
if (!noteLoadResult.ok) {
|
||||
ctx.throw(403, noteLoadResult.reason);
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import { EntityFieldDefinition } from '@expo/entity';
|
||||
import { Address4, Address6 } from 'ip-address';
|
||||
|
||||
export class IPAddressField extends EntityFieldDefinition<string> {
|
||||
protected validateInputValueInternal(value: string): boolean {
|
||||
return Address4.isValid(value) || Address6.isValid(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { EntityFieldDefinition } from '@expo/entity';
|
||||
import { Address4, Address6 } from 'ip-address';
|
||||
|
||||
export class IPAddressField extends EntityFieldDefinition<string> {
|
||||
protected validateAndTransformInputValueInternal(
|
||||
value: string
|
||||
): { isValid: false } | { isValid: true; transformedValue: string } {
|
||||
return Address4.isValid(value) || Address6.isValid(value)
|
||||
? { isValid: true, transformedValue: value }
|
||||
: { isValid: false };
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
import { describeFieldTestCase } from '@expo/entity';
|
||||
|
||||
import { IPAddressField } from '../EntityFields';
|
||||
import { IPAddressField } from '../IPAddressField';
|
||||
|
||||
describeFieldTestCase(
|
||||
new IPAddressField({ columnName: 'wat' }),
|
||||
@@ -12,5 +12,6 @@ describeFieldTestCase(
|
||||
'1:2:3:4:5:6:7:8/64',
|
||||
'fedc:ba98:7654:3210:fedc:ba98:7654:3210',
|
||||
],
|
||||
['', 'abs', '0.0.0.x/0', '123.21.23', '1:2:3:4:5:6', '198.10/8']
|
||||
['', 'abs', '0.0.0.x/0', '123.21.23', '1:2:3:4:5:6', '198.10/8'],
|
||||
[{ in: '192.168.1.1', out: '192.168.1.1' }]
|
||||
);
|
||||
@@ -4,4 +4,4 @@
|
||||
* @module @expo/entity-ip-address-field
|
||||
*/
|
||||
|
||||
export * from './EntityFields';
|
||||
export * from './IPAddressField';
|
||||
@@ -30,12 +30,10 @@
|
||||
"@expo/entity": "*",
|
||||
"@expo/entity-cache-adapter-redis": "*"
|
||||
},
|
||||
"dependencies": {
|
||||
"ioredis": "^4.17.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/entity": "^0.25.3",
|
||||
"@expo/entity-cache-adapter-redis": "^0.25.3",
|
||||
"ioredis": "^5.0.4",
|
||||
"nullthrows": "^1.1.1"
|
||||
}
|
||||
}
|
||||
@@ -121,12 +121,20 @@ export abstract class EntityFieldDefinition<T> {
|
||||
* - EntityLoader.loadByFieldValue - to ensure the value being loaded by is a valid value
|
||||
* - EntityMutator.setField - to ensure the value being set is a valid value
|
||||
*/
|
||||
public validateInputValue(value: T | null | undefined): boolean {
|
||||
public validateAndTransformInputValue(
|
||||
value: T | null | undefined
|
||||
): { isValid: false } | { isValid: true; transformedValue: T | null | undefined } {
|
||||
if (value === null || value === undefined) {
|
||||
return true;
|
||||
return { isValid: true, transformedValue: value };
|
||||
}
|
||||
|
||||
return this.validateInputValueInternal(value);
|
||||
return this.validateAndTransformInputValueInternal(value);
|
||||
}
|
||||
protected abstract validateAndTransformInputValueInternal(
|
||||
value: T
|
||||
): { isValid: false } | { isValid: true; transformedValue: T };
|
||||
|
||||
public transformInputValue(value: T): T {
|
||||
return value;
|
||||
}
|
||||
protected abstract validateInputValueInternal(value: T): boolean;
|
||||
}
|
||||
@@ -6,8 +6,28 @@ import { EntityFieldDefinition } from './EntityFieldDefinition';
|
||||
* {@link EntityFieldDefinition} for a column with a JS string type.
|
||||
*/
|
||||
export class StringField extends EntityFieldDefinition<string> {
|
||||
protected validateInputValueInternal(value: string): boolean {
|
||||
return typeof value === 'string';
|
||||
protected validateAndTransformInputValueInternal(
|
||||
value: string
|
||||
): { isValid: false } | { isValid: true; transformedValue: string } {
|
||||
return typeof value === 'string'
|
||||
? { isValid: true, transformedValue: value }
|
||||
: { isValid: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link EntityFieldDefinition} for a column with a JS string type in which
|
||||
* stored values are lower-cased.
|
||||
*/
|
||||
export class LowercaseStringField extends StringField {
|
||||
protected override validateAndTransformInputValueInternal(
|
||||
value: string
|
||||
): { isValid: false } | { isValid: true; transformedValue: string } {
|
||||
const superValue = super.validateAndTransformInputValueInternal(value);
|
||||
if (superValue.isValid) {
|
||||
return { isValid: true, transformedValue: superValue.transformedValue.toLowerCase() };
|
||||
}
|
||||
return { isValid: false };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,8 +36,14 @@ export class StringField extends EntityFieldDefinition<string> {
|
||||
* Enforces that the string is a valid UUID.
|
||||
*/
|
||||
export class UUIDField extends StringField {
|
||||
protected override validateInputValueInternal(value: string): boolean {
|
||||
return validateUUID(value);
|
||||
public override validateAndTransformInputValueInternal(
|
||||
value: string
|
||||
): { isValid: false } | { isValid: true; transformedValue: string } {
|
||||
const superValue = super.validateAndTransformInputValueInternal(value);
|
||||
if (superValue.isValid && validateUUID(superValue.transformedValue)) {
|
||||
return { isValid: true, transformedValue: superValue.transformedValue };
|
||||
}
|
||||
return { isValid: false };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +51,10 @@ export class UUIDField extends StringField {
|
||||
* {@link EntityFieldDefinition} for a column with a JS Date type.
|
||||
*/
|
||||
export class DateField extends EntityFieldDefinition<Date> {
|
||||
protected validateInputValueInternal(value: Date): boolean {
|
||||
return value instanceof Date;
|
||||
protected validateAndTransformInputValueInternal(
|
||||
value: Date
|
||||
): { isValid: false } | { isValid: true; transformedValue: Date } {
|
||||
return value instanceof Date ? { isValid: true, transformedValue: value } : { isValid: false };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,8 +62,12 @@ export class DateField extends EntityFieldDefinition<Date> {
|
||||
* {@link EntityFieldDefinition} for a column with a JS boolean type.
|
||||
*/
|
||||
export class BooleanField extends EntityFieldDefinition<boolean> {
|
||||
protected validateInputValueInternal(value: boolean): boolean {
|
||||
return typeof value === 'boolean';
|
||||
protected validateAndTransformInputValueInternal(
|
||||
value: boolean
|
||||
): { isValid: false } | { isValid: true; transformedValue: boolean } {
|
||||
return typeof value === 'boolean'
|
||||
? { isValid: true, transformedValue: value }
|
||||
: { isValid: false };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,8 +76,12 @@ export class BooleanField extends EntityFieldDefinition<boolean> {
|
||||
* Enforces that the number is an integer.
|
||||
*/
|
||||
export class IntField extends EntityFieldDefinition<number> {
|
||||
protected validateInputValueInternal(value: number): boolean {
|
||||
return typeof value === 'number' && Number.isInteger(value);
|
||||
protected validateAndTransformInputValueInternal(
|
||||
value: number
|
||||
): { isValid: false } | { isValid: true; transformedValue: number } {
|
||||
return typeof value === 'number' && Number.isInteger(value)
|
||||
? { isValid: true, transformedValue: value }
|
||||
: { isValid: false };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,8 +90,12 @@ export class IntField extends EntityFieldDefinition<number> {
|
||||
* Enforces that the number is a float (which includes integers in JS).
|
||||
*/
|
||||
export class FloatField extends EntityFieldDefinition<number> {
|
||||
protected validateInputValueInternal(value: number): boolean {
|
||||
return typeof value === 'number';
|
||||
protected validateAndTransformInputValueInternal(
|
||||
value: number
|
||||
): { isValid: false } | { isValid: true; transformedValue: number } {
|
||||
return typeof value === 'number'
|
||||
? { isValid: true, transformedValue: value }
|
||||
: { isValid: false };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,8 +104,12 @@ export class FloatField extends EntityFieldDefinition<number> {
|
||||
* Enforces that every member of the string array is a string.
|
||||
*/
|
||||
export class StringArrayField extends EntityFieldDefinition<string[]> {
|
||||
protected validateInputValueInternal(value: string[]): boolean {
|
||||
return Array.isArray(value) && value.every((subValue) => typeof subValue === 'string');
|
||||
protected validateAndTransformInputValueInternal(
|
||||
value: string[]
|
||||
): { isValid: false } | { isValid: true; transformedValue: string[] } {
|
||||
return Array.isArray(value) && value.every((subValue) => typeof subValue === 'string')
|
||||
? { isValid: true, transformedValue: value }
|
||||
: { isValid: false };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,8 +117,12 @@ export class StringArrayField extends EntityFieldDefinition<string[]> {
|
||||
* {@link EntityFieldDefinition} for a column with a JS JSON object type.
|
||||
*/
|
||||
export class JSONObjectField extends EntityFieldDefinition<object> {
|
||||
protected validateInputValueInternal(value: object): boolean {
|
||||
return typeof value === 'object' && !Array.isArray(value);
|
||||
protected validateAndTransformInputValueInternal(
|
||||
value: object
|
||||
): { isValid: false } | { isValid: true; transformedValue: object } {
|
||||
return typeof value === 'object' && !Array.isArray(value)
|
||||
? { isValid: true, transformedValue: value }
|
||||
: { isValid: false };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,8 +130,12 @@ export class JSONObjectField extends EntityFieldDefinition<object> {
|
||||
* {@link EntityFieldDefinition} for a enum column with a JS string or number type.
|
||||
*/
|
||||
export class EnumField extends EntityFieldDefinition<string | number> {
|
||||
protected validateInputValueInternal(value: string | number): boolean {
|
||||
return typeof value === 'number' || typeof value === 'string';
|
||||
protected validateAndTransformInputValueInternal(
|
||||
value: string | number
|
||||
): { isValid: false } | { isValid: true; transformedValue: string | number } {
|
||||
return typeof value === 'number' || typeof value === 'string'
|
||||
? { isValid: true, transformedValue: value }
|
||||
: { isValid: false };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,8 +143,10 @@ export class EnumField extends EntityFieldDefinition<string | number> {
|
||||
* {@link EntityFieldDefinition} for a column with a JS JSON array type.
|
||||
*/
|
||||
export class JSONArrayField extends EntityFieldDefinition<any[]> {
|
||||
protected validateInputValueInternal(value: any[]): boolean {
|
||||
return Array.isArray(value);
|
||||
protected validateAndTransformInputValueInternal(
|
||||
value: any[]
|
||||
): { isValid: false } | { isValid: true; transformedValue: any[] } {
|
||||
return Array.isArray(value) ? { isValid: true, transformedValue: value } : { isValid: false };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,7 +155,9 @@ export class JSONArrayField extends EntityFieldDefinition<any[]> {
|
||||
* Does not do any validation.
|
||||
*/
|
||||
export class MaybeJSONArrayField extends EntityFieldDefinition<any | any[]> {
|
||||
protected validateInputValueInternal(_value: any): boolean {
|
||||
return true;
|
||||
protected validateAndTransformInputValueInternal(
|
||||
value: any
|
||||
): { isValid: false } | { isValid: true; transformedValue: any } {
|
||||
return { isValid: true, transformedValue: value };
|
||||
}
|
||||
}
|
||||
@@ -82,12 +82,15 @@ export default class EntityLoader<
|
||||
fieldName: N,
|
||||
fieldValues: readonly NonNullable<TFields[N]>[]
|
||||
): Promise<ReadonlyMap<NonNullable<TFields[N]>, readonly Result<TEntity>[]>> {
|
||||
this.validateFieldValues(fieldName, fieldValues);
|
||||
const validatedAndTransformedFieldValues = this.validateAndTransformFieldValues(
|
||||
fieldName,
|
||||
fieldValues
|
||||
);
|
||||
|
||||
const fieldValuesToFieldObjects = await this.dataManager.loadManyByFieldEqualingAsync(
|
||||
this.queryContext,
|
||||
fieldName,
|
||||
fieldValues
|
||||
validatedAndTransformedFieldValues
|
||||
);
|
||||
|
||||
return await this.constructAndAuthorizeEntitiesAsync(fieldValuesToFieldObjects);
|
||||
@@ -126,7 +129,9 @@ export default class EntityLoader<
|
||||
const entityResults = await this.loadManyByFieldEqualingAsync(uniqueFieldName, fieldValue);
|
||||
invariant(
|
||||
entityResults.length <= 1,
|
||||
`loadByFieldEqualing: Multiple entities of type ${this.entityClass.name} found for ${uniqueFieldName}=${fieldValue}`
|
||||
`loadByFieldEqualing: Multiple entities of type ${this.entityClass.name} found for ${String(
|
||||
uniqueFieldName
|
||||
)}=${fieldValue}`
|
||||
);
|
||||
return entityResults[0] ?? null;
|
||||
}
|
||||
@@ -197,16 +202,29 @@ export default class EntityLoader<
|
||||
fieldEqualityOperands: FieldEqualityCondition<TFields, N>[],
|
||||
querySelectionModifiers: QuerySelectionModifiers<TFields> = {}
|
||||
): Promise<readonly Result<TEntity>[]> {
|
||||
for (const fieldEqualityOperand of fieldEqualityOperands) {
|
||||
const fieldValues = isSingleValueFieldEqualityCondition(fieldEqualityOperand)
|
||||
? [fieldEqualityOperand.fieldValue]
|
||||
: fieldEqualityOperand.fieldValues;
|
||||
this.validateFieldValues(fieldEqualityOperand.fieldName, fieldValues);
|
||||
}
|
||||
const validatedAndTransformedFieldEqualityOperands: FieldEqualityCondition<TFields, N>[] =
|
||||
fieldEqualityOperands.map((fieldEqualityOperand) => {
|
||||
if (isSingleValueFieldEqualityCondition(fieldEqualityOperand)) {
|
||||
return {
|
||||
...fieldEqualityOperand,
|
||||
fieldValue: this.validateAndTransformFieldValues(fieldEqualityOperand.fieldName, [
|
||||
fieldEqualityOperand.fieldValue,
|
||||
])[0]!,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
...fieldEqualityOperand,
|
||||
fieldValues: this.validateAndTransformFieldValues(
|
||||
fieldEqualityOperand.fieldName,
|
||||
fieldEqualityOperand.fieldValues
|
||||
),
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const fieldObjects = await this.dataManager.loadManyByFieldEqualityConjunctionAsync(
|
||||
this.queryContext,
|
||||
fieldEqualityOperands,
|
||||
validatedAndTransformedFieldEqualityOperands,
|
||||
querySelectionModifiers
|
||||
);
|
||||
const uncheckedEntityResults = this.tryConstructEntities(fieldObjects);
|
||||
@@ -345,17 +363,19 @@ export default class EntityLoader<
|
||||
});
|
||||
}
|
||||
|
||||
private validateFieldValues<N extends keyof Pick<TFields, TSelectedFields>>(
|
||||
fieldName: N,
|
||||
fieldValues: readonly TFields[N][]
|
||||
): void {
|
||||
private validateAndTransformFieldValues<
|
||||
N extends keyof Pick<TFields, TSelectedFields>,
|
||||
T extends TFields[N]
|
||||
>(fieldName: N, fieldValues: readonly T[]): readonly T[] {
|
||||
const fieldDefinition = this.entityConfiguration.schema.get(fieldName);
|
||||
invariant(fieldDefinition, `must have field definition for field = ${fieldName}`);
|
||||
for (const fieldValue of fieldValues) {
|
||||
const isInputValid = fieldDefinition.validateInputValue(fieldValue);
|
||||
if (!isInputValid) {
|
||||
invariant(fieldDefinition, `must have field definition for field = ${String(fieldName)}`);
|
||||
return fieldValues.map((fieldValue) => {
|
||||
const result = fieldDefinition.validateAndTransformInputValue(fieldValue);
|
||||
if (result.isValid) {
|
||||
return result.transformedValue;
|
||||
} else {
|
||||
throw new EntityInvalidFieldValueError(this.entityClass, fieldName, fieldValue);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -81,16 +81,22 @@ abstract class BaseMutator<
|
||||
protected readonly metricsAdapter: IEntityMetricsAdapter
|
||||
) {}
|
||||
|
||||
protected validateFields(fields: Partial<TFields>): void {
|
||||
protected validateAndTransformFields(fields: Partial<TFields>): Partial<TFields> {
|
||||
const result: Partial<TFields> = {};
|
||||
|
||||
for (const fieldName in fields) {
|
||||
const fieldValue = fields[fieldName];
|
||||
const fieldDefinition = this.entityConfiguration.schema.get(fieldName);
|
||||
invariant(fieldDefinition, `must have field definition for field = ${fieldName}`);
|
||||
const isInputValid = fieldDefinition.validateInputValue(fieldValue);
|
||||
if (!isInputValid) {
|
||||
const validationResult = fieldDefinition.validateAndTransformInputValue(fieldValue);
|
||||
if (validationResult.isValid) {
|
||||
result[fieldName] = validationResult.transformedValue;
|
||||
} else {
|
||||
throw new EntityInvalidFieldValueError(this.entityClass, fieldName, fieldValue);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
protected async executeMutationValidatorsAsync(
|
||||
@@ -183,7 +189,7 @@ export class CreateMutator<
|
||||
|
||||
/**
|
||||
* Commit the new entity after authorizing against creation privacy rules. Invalidates all caches for
|
||||
* queries that would return new entity and caches the new entity if not in a transactional query context.
|
||||
* queries that would return new entity.
|
||||
* @returns authorized, cached, newly-created entity result, where result error can be UnauthorizedError
|
||||
*/
|
||||
async createAsync(): Promise<Result<TEntity>> {
|
||||
@@ -210,11 +216,13 @@ export class CreateMutator<
|
||||
private async createInternalAsync(
|
||||
queryContext: EntityTransactionalQueryContext
|
||||
): Promise<Result<TEntity>> {
|
||||
this.validateFields(this.fieldsForEntity);
|
||||
const transformedAndValidatedFieldsForEntity = this.validateAndTransformFields(
|
||||
this.fieldsForEntity
|
||||
);
|
||||
|
||||
const temporaryEntityForPrivacyCheck = new this.entityClass(this.viewerContext, {
|
||||
[this.entityConfiguration.idField]: '00000000-0000-0000-0000-000000000000', // zero UUID
|
||||
...this.fieldsForEntity,
|
||||
...transformedAndValidatedFieldsForEntity,
|
||||
} as unknown as TFields);
|
||||
|
||||
const authorizeCreateResult = await asyncResult(
|
||||
@@ -249,7 +257,10 @@ export class CreateMutator<
|
||||
{ type: EntityMutationType.CREATE }
|
||||
);
|
||||
|
||||
const insertResult = await this.databaseAdapter.insertAsync(queryContext, this.fieldsForEntity);
|
||||
const insertResult = await this.databaseAdapter.insertAsync(
|
||||
queryContext,
|
||||
transformedAndValidatedFieldsForEntity
|
||||
);
|
||||
|
||||
const entityLoader = this.entityLoaderFactory.forLoad(
|
||||
this.viewerContext,
|
||||
@@ -382,8 +393,7 @@ export class UpdateMutator<
|
||||
|
||||
/**
|
||||
* Commit the changes to the entity after authorizing against update privacy rules.
|
||||
* Invalidates all caches for pre-update entity and caches the updated entity if not in a
|
||||
* transactional query context.
|
||||
* Invalidates all caches for pre-update entity.
|
||||
* @returns authorized updated entity result, where result error can be UnauthorizedError
|
||||
*/
|
||||
async updateAsync(): Promise<Result<TEntity>> {
|
||||
@@ -410,9 +420,14 @@ export class UpdateMutator<
|
||||
private async updateInternalAsync(
|
||||
queryContext: EntityTransactionalQueryContext
|
||||
): Promise<Result<TEntity>> {
|
||||
this.validateFields(this.updatedFields);
|
||||
const transformedAndValidatedFieldsForEntity = this.validateAndTransformFields(
|
||||
this.fieldsForEntity
|
||||
) as TFields;
|
||||
|
||||
const entityAboutToBeUpdated = new this.entityClass(this.viewerContext, this.fieldsForEntity);
|
||||
const entityAboutToBeUpdated = new this.entityClass(
|
||||
this.viewerContext,
|
||||
transformedAndValidatedFieldsForEntity
|
||||
);
|
||||
const authorizeUpdateResult = await asyncResult(
|
||||
this.privacyPolicy.authorizeUpdateAsync(
|
||||
this.viewerContext,
|
||||
@@ -465,7 +480,7 @@ export class UpdateMutator<
|
||||
)
|
||||
);
|
||||
queryContext.appendPostCommitInvalidationCallback(
|
||||
entityLoader.invalidateFieldsAsync.bind(entityLoader, this.fieldsForEntity)
|
||||
entityLoader.invalidateFieldsAsync.bind(entityLoader, transformedAndValidatedFieldsForEntity)
|
||||
);
|
||||
|
||||
const unauthorizedEntityAfterUpdate = new this.entityClass(this.viewerContext, updateResult);
|
||||
|
||||
@@ -19,9 +19,24 @@ export type EntityPrivacyPolicyEvaluationContext = {
|
||||
cascadingDeleteCause: EntityCascadingDeletionInfo | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Evaluation mode for a {@link EntityPrivacyPolicy}. Useful when transitioning to
|
||||
* using Entity for privacy.
|
||||
*/
|
||||
export enum EntityPrivacyPolicyEvaluationMode {
|
||||
/**
|
||||
* Enforce this privacy policy. Throw upon denial.
|
||||
*/
|
||||
ENFORCE,
|
||||
|
||||
/**
|
||||
* Do not enforce this privacy policy. Always allow but log when it would have denied.
|
||||
*/
|
||||
DRY_RUN,
|
||||
|
||||
/**
|
||||
* Enforce this privacy policy. Throw and log upon denial.
|
||||
*/
|
||||
ENFORCE_AND_LOG,
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,12 @@ export abstract class EntityQueryContext {
|
||||
): Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Entity framework representation of a non-transactional query execution unit.
|
||||
* When supplied to {@link EntityMutator} and {@link EntityLoader} methods, they will be
|
||||
* run independently of any running transaction (though mutations start their own
|
||||
* independent transactions internally when not being run in a transaction).
|
||||
*/
|
||||
export class EntityNonTransactionalQueryContext extends EntityQueryContext {
|
||||
constructor(
|
||||
queryInterface: any,
|
||||
@@ -48,6 +54,11 @@ export class EntityNonTransactionalQueryContext extends EntityQueryContext {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Entity framework representation of a transactional query execution unit. When supplied
|
||||
* to {@link EntityMutator} and {@link EntityLoader} methods, those methods and their
|
||||
* dependent triggers and validators will run within the transaction.
|
||||
*/
|
||||
export class EntityTransactionalQueryContext extends EntityQueryContext {
|
||||
private readonly postCommitInvalidationCallbacks: PostCommitCallback[] = [];
|
||||
private readonly postCommitCallbacks: PostCommitCallback[] = [];
|
||||
|
||||
@@ -95,7 +95,7 @@ class TestLocalCacheAdapter<TFields> extends EntityCacheAdapter<TFields> {
|
||||
fieldValue: NonNullable<TFields[N]>
|
||||
): string {
|
||||
const columnName = this.entityConfiguration.entityToDBFieldsKeyMapping.get(fieldName);
|
||||
invariant(columnName, `database field mapping missing for ${fieldName}`);
|
||||
invariant(columnName, `database field mapping missing for ${String(fieldName)}`);
|
||||
const parts = [
|
||||
this.entityConfiguration.tableName,
|
||||
`${this.entityConfiguration.cacheKeyVersion}`,
|
||||
|
||||
@@ -288,7 +288,7 @@ describe(EnforcingEntityLoader, () => {
|
||||
'invalidateFieldsAsync',
|
||||
'invalidateEntityAsync',
|
||||
'tryConstructEntities',
|
||||
'validateFieldValues',
|
||||
'validateAndTransformFieldValues',
|
||||
'constructAndAuthorizeEntitiesAsync',
|
||||
];
|
||||
expect(loaderProperties).toEqual(expect.arrayContaining(knownLoaderOnlyDifferences));
|
||||
|
||||
@@ -13,12 +13,15 @@ import {
|
||||
EnumField,
|
||||
JSONArrayField,
|
||||
MaybeJSONArrayField,
|
||||
LowercaseStringField,
|
||||
} from '../EntityFields';
|
||||
import describeFieldTestCase from '../utils/testing/describeFieldTestCase';
|
||||
|
||||
class TestFieldDefinition extends EntityFieldDefinition<string> {
|
||||
protected validateInputValueInternal(value: string): boolean {
|
||||
return value === 'helloworld';
|
||||
protected validateAndTransformInputValueInternal(
|
||||
value: string
|
||||
): { isValid: false } | { isValid: true; transformedValue: string } {
|
||||
return value === 'helloworld' ? { isValid: true, transformedValue: value } : { isValid: false };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,49 +38,97 @@ describe(EntityFieldDefinition, () => {
|
||||
|
||||
test('validator returns true when value is null', () => {
|
||||
const fieldDefinition = new TestFieldDefinition({ columnName: 'wat', cache: true });
|
||||
expect(fieldDefinition.validateInputValue(null)).toBe(true);
|
||||
expect(fieldDefinition.validateAndTransformInputValue(null).isValid).toBe(true);
|
||||
});
|
||||
|
||||
test('validator returns true when value is undefined', () => {
|
||||
const fieldDefinition = new TestFieldDefinition({ columnName: 'wat', cache: true });
|
||||
expect(fieldDefinition.validateInputValue(undefined)).toBe(true);
|
||||
expect(fieldDefinition.validateAndTransformInputValue(undefined).isValid).toBe(true);
|
||||
});
|
||||
|
||||
test('validator returns false when value is invalid', () => {
|
||||
const fieldDefinition = new TestFieldDefinition({ columnName: 'wat', cache: true });
|
||||
expect(fieldDefinition.validateInputValue('nothelloworld')).toBe(false);
|
||||
expect(fieldDefinition.validateAndTransformInputValue('nothelloworld').isValid).toBe(false);
|
||||
});
|
||||
|
||||
test('validator returns true when value is valid', () => {
|
||||
const fieldDefinition = new TestFieldDefinition({ columnName: 'wat', cache: true });
|
||||
expect(fieldDefinition.validateInputValue('helloworld')).toBe(true);
|
||||
expect(fieldDefinition.validateAndTransformInputValue('helloworld').isValid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describeFieldTestCase(new StringField({ columnName: 'wat' }), ['hello', ''], [1, true, {}, [[]]]);
|
||||
describeFieldTestCase(
|
||||
new StringField({ columnName: 'wat' }),
|
||||
['hello', ''],
|
||||
[1, true, {}, [[]]],
|
||||
[{ in: 'hello', out: 'hello' }]
|
||||
);
|
||||
describeFieldTestCase(
|
||||
new LowercaseStringField({ columnName: 'wat' }),
|
||||
['HELLO', 'hello'],
|
||||
[1, true, {}, [[]]],
|
||||
[
|
||||
{ in: 'low', out: 'low' },
|
||||
{ in: 'UP', out: 'up' },
|
||||
]
|
||||
);
|
||||
describeFieldTestCase(
|
||||
new UUIDField({ columnName: 'wat' }),
|
||||
[uuidv1(), uuidv3('wat', uuidv3.DNS), uuidv4(), uuidv5('wat', uuidv5.DNS)],
|
||||
[uuidv4().replace('-', ''), '', 'hello']
|
||||
[uuidv4().replace('-', ''), '', 'hello'],
|
||||
[uuidv4()].map((u) => ({ in: u, out: u }))
|
||||
);
|
||||
describeFieldTestCase(
|
||||
new DateField({ columnName: 'wat' }),
|
||||
[new Date()],
|
||||
[Date.now()],
|
||||
[new Date()].map((u) => ({ in: u, out: u }))
|
||||
);
|
||||
describeFieldTestCase(
|
||||
new BooleanField({ columnName: 'wat' }),
|
||||
[true, false],
|
||||
[0, 1, ''],
|
||||
[{ in: true, out: true }]
|
||||
);
|
||||
describeFieldTestCase(
|
||||
new IntField({ columnName: 'wat' }),
|
||||
[1],
|
||||
['1', 0.5, true],
|
||||
[{ in: 1, out: 1 }]
|
||||
);
|
||||
describeFieldTestCase(
|
||||
new FloatField({ columnName: 'wat' }),
|
||||
[1, 0.5, -0.5],
|
||||
['1'],
|
||||
[{ in: 3.5, out: 3.5 }]
|
||||
);
|
||||
describeFieldTestCase(new DateField({ columnName: 'wat' }), [new Date()], [Date.now()]);
|
||||
describeFieldTestCase(new BooleanField({ columnName: 'wat' }), [true, false], [0, 1, '']);
|
||||
describeFieldTestCase(new IntField({ columnName: 'wat' }), [1], ['1', 0.5, true]);
|
||||
describeFieldTestCase(new FloatField({ columnName: 'wat' }), [1, 0.5, -0.5], ['1']);
|
||||
describeFieldTestCase(
|
||||
new StringArrayField({ columnName: 'wat' }),
|
||||
[[['what']] as any, [[]] as any], // jest test cases need extra wrapping array
|
||||
['hello']
|
||||
['hello'],
|
||||
[{ in: ['hello'], out: ['hello'] }]
|
||||
);
|
||||
describeFieldTestCase(
|
||||
new JSONObjectField({ columnName: 'wat' }),
|
||||
[{}],
|
||||
[true, 'hello'],
|
||||
[{ in: { hello: 1 }, out: { hello: 1 } }]
|
||||
);
|
||||
describeFieldTestCase(
|
||||
new EnumField({ columnName: 'wat' }),
|
||||
['hello', 1],
|
||||
[true],
|
||||
[{ in: 1, out: 1 }]
|
||||
);
|
||||
describeFieldTestCase(new JSONObjectField({ columnName: 'wat' }), [{}], [true, 'hello']);
|
||||
describeFieldTestCase(new EnumField({ columnName: 'wat' }), ['hello', 1], [true]);
|
||||
describeFieldTestCase(
|
||||
new JSONArrayField({ columnName: 'wat' }),
|
||||
[[[1, 2]] as any, [['hello']] as any], // jest test cases need extra wrapping array
|
||||
[1, 'hello']
|
||||
[1, 'hello'],
|
||||
[{ in: [1, 2], out: [1, 2] }]
|
||||
);
|
||||
describeFieldTestCase(
|
||||
new MaybeJSONArrayField({ columnName: 'wat' }),
|
||||
[1, 'hello', [['hello']]], // jest test cases need extra wrapping array
|
||||
[]
|
||||
[],
|
||||
[{ in: 1, out: 1 }]
|
||||
);
|
||||
@@ -41,6 +41,7 @@ describe(EntityLoader, () => {
|
||||
testIndexedField: 'h1',
|
||||
intField: 5,
|
||||
stringField: 'huh',
|
||||
lowercasedField: 'lw1',
|
||||
dateField: dateToInsert,
|
||||
nullableField: null,
|
||||
},
|
||||
@@ -49,6 +50,7 @@ describe(EntityLoader, () => {
|
||||
testIndexedField: 'h2',
|
||||
intField: 3,
|
||||
stringField: 'huh',
|
||||
lowercasedField: 'lw2',
|
||||
dateField: dateToInsert,
|
||||
nullableField: null,
|
||||
},
|
||||
@@ -87,6 +89,11 @@ describe(EntityLoader, () => {
|
||||
);
|
||||
expect(entities.map((m) => m.getID())).toEqual([id1, id2]);
|
||||
|
||||
const entitiesLoadedTransformedField = await enforceResultsAsync(
|
||||
entityLoader.loadManyByFieldEqualingAsync('lowercasedField', 'LW1')
|
||||
);
|
||||
expect(entitiesLoadedTransformedField.map((m) => m.getID())).toEqual([id1]);
|
||||
|
||||
const entityResultNumber3 = await entityLoader.loadByFieldEqualingAsync('intField', 3);
|
||||
expect(entityResultNumber3).not.toBeNull();
|
||||
expect(entityResultNumber3!.enforceValue().getID()).toEqual(id2);
|
||||
@@ -134,6 +141,7 @@ describe(EntityLoader, () => {
|
||||
{
|
||||
customIdField: id1,
|
||||
stringField: 'huh',
|
||||
lowercasedField: 'lw1',
|
||||
intField: 4,
|
||||
testIndexedField: '4',
|
||||
dateField: new Date(),
|
||||
@@ -142,6 +150,7 @@ describe(EntityLoader, () => {
|
||||
{
|
||||
customIdField: id2,
|
||||
stringField: 'huh',
|
||||
lowercasedField: 'lw2',
|
||||
intField: 4,
|
||||
testIndexedField: '5',
|
||||
dateField: new Date(),
|
||||
@@ -150,6 +159,7 @@ describe(EntityLoader, () => {
|
||||
{
|
||||
customIdField: id3,
|
||||
stringField: 'huh2',
|
||||
lowercasedField: 'lw3',
|
||||
intField: 4,
|
||||
testIndexedField: '6',
|
||||
dateField: new Date(),
|
||||
@@ -231,6 +241,7 @@ describe(EntityLoader, () => {
|
||||
{
|
||||
customIdField: id1,
|
||||
stringField: 'huh',
|
||||
lowercasedField: 'lw1',
|
||||
testIndexedField: '1',
|
||||
intField: 3,
|
||||
dateField: new Date(),
|
||||
|
||||
@@ -34,6 +34,6 @@ export default class EntityInvalidFieldValueError<
|
||||
fieldName: N,
|
||||
fieldValue?: TFields[N]
|
||||
) {
|
||||
super(`Entity field not valid: ${entityClass.name} (${fieldName} = ${fieldValue})`);
|
||||
super(`Entity field not valid: ${entityClass.name} (${String(fieldName)} = ${fieldValue})`);
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,6 @@ export default class EntityNotFoundError<
|
||||
fieldName: N,
|
||||
fieldValue: TFields[N]
|
||||
) {
|
||||
super(`Entity not found: ${entityClass.name} (${fieldName} = ${fieldValue})`);
|
||||
super(`Entity not found: ${entityClass.name} (${String(fieldName)} = ${fieldValue})`);
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,10 @@ import {
|
||||
timeAndLogLoadEventAsync,
|
||||
timeAndLogLoadMapEventAsync,
|
||||
} from '../metrics/EntityMetricsUtils';
|
||||
import IEntityMetricsAdapter, { EntityMetricsLoadType } from '../metrics/IEntityMetricsAdapter';
|
||||
import IEntityMetricsAdapter, {
|
||||
EntityMetricsLoadType,
|
||||
IncrementLoadCountEventType,
|
||||
} from '../metrics/IEntityMetricsAdapter';
|
||||
import { computeIfAbsent, zipToMap } from '../utils/collections/maps';
|
||||
import ReadThroughEntityCache from './ReadThroughEntityCache';
|
||||
|
||||
@@ -57,7 +60,8 @@ export default class EntityDataManager<TFields> {
|
||||
fieldName: N,
|
||||
fieldValues: readonly NonNullable<TFields[N]>[]
|
||||
): Promise<ReadonlyMap<NonNullable<TFields[N]>, readonly Readonly<TFields>[]>> {
|
||||
this.metricsAdapter.incrementDataManagerCacheLoadCount({
|
||||
this.metricsAdapter.incrementDataManagerLoadCount({
|
||||
type: IncrementLoadCountEventType.CACHE,
|
||||
fieldValueCount: fieldValues.length,
|
||||
entityClassName: this.entityClassName,
|
||||
});
|
||||
@@ -65,7 +69,8 @@ export default class EntityDataManager<TFields> {
|
||||
fieldName,
|
||||
fieldValues,
|
||||
async (fetcherValues) => {
|
||||
this.metricsAdapter.incrementDataManagerDatabaseLoadCount({
|
||||
this.metricsAdapter.incrementDataManagerLoadCount({
|
||||
type: IncrementLoadCountEventType.DATABASE,
|
||||
fieldValueCount: fieldValues.length,
|
||||
entityClassName: this.entityClassName,
|
||||
});
|
||||
@@ -108,7 +113,9 @@ export default class EntityDataManager<TFields> {
|
||||
);
|
||||
if (nullOrUndefinedValueIndex >= 0) {
|
||||
throw new Error(
|
||||
`Invalid load: ${this.entityClassName} (${fieldName} = ${fieldValues[nullOrUndefinedValueIndex]})`
|
||||
`Invalid load: ${this.entityClassName} (${String(fieldName)} = ${
|
||||
fieldValues[nullOrUndefinedValueIndex]
|
||||
})`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -117,7 +124,8 @@ export default class EntityDataManager<TFields> {
|
||||
return await this.databaseAdapter.fetchManyWhereAsync(queryContext, fieldName, fieldValues);
|
||||
}
|
||||
|
||||
this.metricsAdapter.incrementDataManagerDataloaderLoadCount({
|
||||
this.metricsAdapter.incrementDataManagerLoadCount({
|
||||
type: IncrementLoadCountEventType.DATALOADER,
|
||||
fieldValueCount: fieldValues.length,
|
||||
entityClassName: this.entityClassName,
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@ export const getDatabaseFieldForEntityField = <TFields>(
|
||||
entityField: keyof TFields
|
||||
): string => {
|
||||
const databaseField = entityConfiguration.entityToDBFieldsKeyMapping.get(entityField);
|
||||
invariant(databaseField, `database field mapping missing for ${entityField}`);
|
||||
invariant(databaseField, `database field mapping missing for ${String(entityField)}`);
|
||||
return databaseField!;
|
||||
};
|
||||
|
||||
|
||||
@@ -102,7 +102,9 @@ export default class ReadThroughEntityCache<TFields> {
|
||||
// TODO(wschurman): emit or throw here since console may not be available
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`unique key ${fieldName} in ${this.entityConfiguration.tableName} returned multiple rows for ${fieldValue}`
|
||||
`unique key ${String(fieldName)} in ${
|
||||
this.entityConfiguration.tableName
|
||||
} returned multiple rows for ${fieldValue}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
when,
|
||||
anything,
|
||||
verify,
|
||||
anyNumber,
|
||||
objectContaining,
|
||||
spy,
|
||||
anyString,
|
||||
@@ -13,7 +12,10 @@ import {
|
||||
} from 'ts-mockito';
|
||||
|
||||
import EntityDatabaseAdapter from '../../EntityDatabaseAdapter';
|
||||
import IEntityMetricsAdapter, { EntityMetricsLoadType } from '../../metrics/IEntityMetricsAdapter';
|
||||
import IEntityMetricsAdapter, {
|
||||
EntityMetricsLoadType,
|
||||
IncrementLoadCountEventType,
|
||||
} from '../../metrics/IEntityMetricsAdapter';
|
||||
import NoOpEntityMetricsAdapter from '../../metrics/NoOpEntityMetricsAdapter';
|
||||
import TestEntity, { testEntityConfiguration, TestFields } from '../../testfixtures/TestEntity';
|
||||
import {
|
||||
@@ -519,24 +521,27 @@ describe(EntityDataManager, () => {
|
||||
).once();
|
||||
|
||||
verify(
|
||||
metricsAdapterMock.incrementDataManagerDataloaderLoadCount(
|
||||
metricsAdapterMock.incrementDataManagerLoadCount(
|
||||
deepEqual({
|
||||
type: IncrementLoadCountEventType.DATALOADER,
|
||||
fieldValueCount: 1,
|
||||
entityClassName: TestEntity.name,
|
||||
})
|
||||
)
|
||||
).once();
|
||||
verify(
|
||||
metricsAdapterMock.incrementDataManagerCacheLoadCount(
|
||||
metricsAdapterMock.incrementDataManagerLoadCount(
|
||||
deepEqual({
|
||||
type: IncrementLoadCountEventType.CACHE,
|
||||
fieldValueCount: 1,
|
||||
entityClassName: TestEntity.name,
|
||||
})
|
||||
)
|
||||
).once();
|
||||
verify(
|
||||
metricsAdapterMock.incrementDataManagerDatabaseLoadCount(
|
||||
metricsAdapterMock.incrementDataManagerLoadCount(
|
||||
deepEqual({
|
||||
type: IncrementLoadCountEventType.DATABASE,
|
||||
fieldValueCount: 1,
|
||||
entityClassName: TestEntity.name,
|
||||
})
|
||||
@@ -565,9 +570,7 @@ describe(EntityDataManager, () => {
|
||||
)
|
||||
).once();
|
||||
|
||||
verify(metricsAdapterMock.incrementDataManagerDataloaderLoadCount(anyNumber())).never();
|
||||
verify(metricsAdapterMock.incrementDataManagerCacheLoadCount(anyNumber())).never();
|
||||
verify(metricsAdapterMock.incrementDataManagerDatabaseLoadCount(anyNumber())).never();
|
||||
verify(metricsAdapterMock.incrementDataManagerLoadCount(anything())).never();
|
||||
});
|
||||
|
||||
it('throws when a load-by value is null or undefined', async () => {
|
||||
|
||||
@@ -9,10 +9,28 @@ export enum EntityMetricsLoadType {
|
||||
LOAD_MANY_RAW,
|
||||
}
|
||||
|
||||
/**
|
||||
* Event about a single call to an {@link EntityLoader} method.
|
||||
*/
|
||||
export interface EntityMetricsLoadEvent {
|
||||
/**
|
||||
* {@link EntityMetricsLoadType} for this load.
|
||||
*/
|
||||
type: EntityMetricsLoadType;
|
||||
|
||||
/**
|
||||
* Class name of the {@link Entity} being loaded.
|
||||
*/
|
||||
entityClassName: string;
|
||||
|
||||
/**
|
||||
* Total duration of this load, including fetch and construction of entities.
|
||||
*/
|
||||
duration: number;
|
||||
|
||||
/**
|
||||
* Number of entities returned for this load.
|
||||
*/
|
||||
count: number;
|
||||
}
|
||||
|
||||
@@ -23,13 +41,57 @@ export enum EntityMetricsMutationType {
|
||||
}
|
||||
|
||||
export interface EntityMetricsMutationEvent {
|
||||
/**
|
||||
* {@link EntityMetricsMutationType} for this mutation.
|
||||
*/
|
||||
type: EntityMetricsMutationType;
|
||||
|
||||
/**
|
||||
* Class name of the {@link Entity} being mutated.
|
||||
*/
|
||||
entityClassName: string;
|
||||
|
||||
/**
|
||||
* Total duration of this mutation.
|
||||
*/
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export enum IncrementLoadCountEventType {
|
||||
/**
|
||||
* Type for when a dataloader load is initiated via the standard load methods
|
||||
* since all loads go through a dataloader.
|
||||
*/
|
||||
DATALOADER,
|
||||
|
||||
/**
|
||||
* Type for when a cache load is initiated due to a dataloader miss.
|
||||
*/
|
||||
CACHE,
|
||||
|
||||
/**
|
||||
* Type for when a database load is initiated due to a dataloader and cache miss, when an entity query doesn't support caching, or during a transaction.
|
||||
*/
|
||||
DATABASE,
|
||||
}
|
||||
|
||||
/**
|
||||
* Event used to record dataloader, cache, and database load counts in {@link EntityDataManager}.
|
||||
*/
|
||||
export interface IncrementLoadCountEvent {
|
||||
/**
|
||||
* Type of this event.
|
||||
*/
|
||||
type: IncrementLoadCountEventType;
|
||||
|
||||
/**
|
||||
* Number of field values being loaded for this call.
|
||||
*/
|
||||
fieldValueCount: number;
|
||||
|
||||
/**
|
||||
* Class name of the {@link Entity} being loaded.
|
||||
*/
|
||||
entityClassName: string;
|
||||
}
|
||||
|
||||
@@ -38,7 +100,13 @@ export enum EntityMetricsAuthorizationResult {
|
||||
ALLOW,
|
||||
}
|
||||
|
||||
/**
|
||||
* Event used to record a singe {@link EntityPrivacyPolicy} authorization.
|
||||
*/
|
||||
export interface EntityMetricsAuthorizationEvent {
|
||||
/**
|
||||
* Class name of the {@link Entity} being authorized.
|
||||
*/
|
||||
entityClassName: string;
|
||||
action: EntityAuthorizationAction;
|
||||
evaluationResult: EntityMetricsAuthorizationResult;
|
||||
@@ -69,25 +137,10 @@ export default interface IEntityMetricsAdapter {
|
||||
logMutatorMutationEvent(mutationEvent: EntityMetricsMutationEvent): void;
|
||||
|
||||
/**
|
||||
* Called when a dataloader load is initiated via the standard
|
||||
* load methods (not equality conjunction or raw).
|
||||
* Called when a dataloader, cache, or database load is initiated via the standard
|
||||
* load methods (not equality conjunction or raw). Most commonly used for logging
|
||||
* a waterfall to determine dataloader and cache hit rates and ratios.
|
||||
* @param fieldValueCount - count of field values being loaded for a field
|
||||
*/
|
||||
incrementDataManagerDataloaderLoadCount(incrementLoadCountEvent: IncrementLoadCountEvent): void;
|
||||
|
||||
/**
|
||||
* Called when a cache load is initiated via the standard
|
||||
* load methods (not equality conjunction or raw). Occurs upon a dataloader
|
||||
* miss.
|
||||
* @param fieldValueCount - count of field values being loaded for a field
|
||||
*/
|
||||
incrementDataManagerCacheLoadCount(incrementLoadCountEvent: IncrementLoadCountEvent): void;
|
||||
|
||||
/**
|
||||
* Called when a database load is initiated via the standard
|
||||
* load methods (not equality conjunction or raw). Occurs upon a cache
|
||||
* miss or when fetching an uncacheable field.
|
||||
* @param fieldValueCount - count of field values being loaded for a field
|
||||
*/
|
||||
incrementDataManagerDatabaseLoadCount(incrementLoadCountEvent: IncrementLoadCountEvent): void;
|
||||
incrementDataManagerLoadCount(incrementLoadCountEvent: IncrementLoadCountEvent): void;
|
||||
}
|
||||
@@ -9,9 +9,5 @@ export default class NoOpEntityMetricsAdapter implements IEntityMetricsAdapter {
|
||||
logAuthorizationEvent(_authorizationEvent: EntityMetricsAuthorizationEvent): void {}
|
||||
logDataManagerLoadEvent(_loadEvent: EntityMetricsLoadEvent): void {}
|
||||
logMutatorMutationEvent(_mutationEvent: EntityMetricsMutationEvent): void {}
|
||||
incrementDataManagerDataloaderLoadCount(
|
||||
_incrementLoadCountEvent: IncrementLoadCountEvent
|
||||
): void {}
|
||||
incrementDataManagerCacheLoadCount(_incrementLoadCountEvent: IncrementLoadCountEvent): void {}
|
||||
incrementDataManagerDatabaseLoadCount(_incrementLoadCountEvent: IncrementLoadCountEvent): void {}
|
||||
incrementDataManagerLoadCount(_incrementLoadCountEvent: IncrementLoadCountEvent): void {}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { result, Result } from '@expo/results';
|
||||
import Entity from '../Entity';
|
||||
import { EntityCompanionDefinition } from '../EntityCompanionProvider';
|
||||
import EntityConfiguration from '../EntityConfiguration';
|
||||
import { UUIDField, StringField, DateField, IntField } from '../EntityFields';
|
||||
import { UUIDField, StringField, DateField, IntField, LowercaseStringField } from '../EntityFields';
|
||||
import EntityPrivacyPolicy from '../EntityPrivacyPolicy';
|
||||
import ViewerContext from '../ViewerContext';
|
||||
import AlwaysAllowPrivacyPolicyRule from '../rules/AlwaysAllowPrivacyPolicyRule';
|
||||
@@ -12,6 +12,7 @@ export type TestFields = {
|
||||
customIdField: string;
|
||||
testIndexedField: string;
|
||||
stringField: string;
|
||||
lowercasedField: string;
|
||||
intField: number;
|
||||
dateField: Date;
|
||||
nullableField: string | null;
|
||||
@@ -31,6 +32,9 @@ export const testEntityConfiguration = new EntityConfiguration<TestFields>({
|
||||
stringField: new StringField({
|
||||
columnName: 'string_field',
|
||||
}),
|
||||
lowercasedField: new LowercaseStringField({
|
||||
columnName: 'lowercase_field',
|
||||
}),
|
||||
intField: new IntField({
|
||||
columnName: 'number_field',
|
||||
}),
|
||||
@@ -86,6 +90,7 @@ export default class TestEntity extends Entity<TestFields, string, ViewerContext
|
||||
customIdField: testValue,
|
||||
testIndexedField: 'hello',
|
||||
stringField: 'hello',
|
||||
lowercasedField: 'hello',
|
||||
intField: 1,
|
||||
dateField: new Date(),
|
||||
nullableField: null,
|
||||
|
||||
@@ -158,7 +158,10 @@ export default class StubDatabaseAdapter<T> extends EntityDatabaseAdapter<T> {
|
||||
|
||||
private generateRandomID(): any {
|
||||
const idSchemaField = this.entityConfiguration2.schema.get(this.entityConfiguration2.idField);
|
||||
invariant(idSchemaField, `No schema field found for ${this.entityConfiguration2.idField}`);
|
||||
invariant(
|
||||
idSchemaField,
|
||||
`No schema field found for ${String(this.entityConfiguration2.idField)}`
|
||||
);
|
||||
if (idSchemaField instanceof StringField) {
|
||||
return uuidv4();
|
||||
} else if (idSchemaField instanceof IntField) {
|
||||
|
||||
@@ -3,18 +3,32 @@ import { EntityFieldDefinition } from '../../EntityFieldDefinition';
|
||||
export default function describeFieldTestCase<T>(
|
||||
fieldDefinition: EntityFieldDefinition<T>,
|
||||
validValues: T[],
|
||||
invalidValues: any[]
|
||||
invalidValues: any[],
|
||||
transformValues: {
|
||||
in: T;
|
||||
out: T;
|
||||
}[]
|
||||
): void {
|
||||
describe(fieldDefinition.constructor.name, () => {
|
||||
if (validValues.length > 0) {
|
||||
test.each(validValues)(`${fieldDefinition.constructor.name}.valid %p`, (value) => {
|
||||
expect(fieldDefinition.validateInputValue(value)).toBe(true);
|
||||
expect(fieldDefinition.validateAndTransformInputValue(value).isValid).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
if (invalidValues.length > 0) {
|
||||
test.each(invalidValues)(`${fieldDefinition.constructor.name}.invalid %p`, (value) => {
|
||||
expect(fieldDefinition.validateInputValue(value)).toBe(false);
|
||||
expect(fieldDefinition.validateAndTransformInputValue(value).isValid).toBe(false);
|
||||
});
|
||||
}
|
||||
|
||||
if (transformValues.length > 0) {
|
||||
test.each(transformValues)(`${fieldDefinition.constructor.name}.transformed %p`, (value) => {
|
||||
const result = fieldDefinition.validateAndTransformInputValue(value.in);
|
||||
expect(result.isValid).toBe(true);
|
||||
if (result.isValid) {
|
||||
expect(result.transformedValue).toEqual(value.out);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user