@aws-amplify/datastore
Version:
AppSyncLocal support for aws-amplify
608 lines (519 loc) • 16.4 kB
text/typescript
import { ConsoleLogger as Logger } from '@aws-amplify/core';
import AsyncStorageDatabase from './AsyncStorageDatabase';
import { Adapter } from './index';
import { ModelInstanceCreator } from '../../datastore/datastore';
import {
ModelPredicateCreator,
ModelSortPredicateCreator,
} from '../../predicates';
import {
InternalSchema,
isPredicateObj,
ModelInstanceMetadata,
ModelPredicate,
NamespaceResolver,
OpType,
PaginationInput,
PersistentModel,
PersistentModelConstructor,
PredicateObject,
PredicatesGroup,
QueryOne,
RelationType,
} from '../../types';
import {
exhaustiveCheck,
getIndex,
getIndexFromAssociation,
isModelConstructor,
traverseModel,
validatePredicate,
sortCompareFunction,
} from '../../util';
const logger = new Logger('DataStore');
export class AsyncStorageAdapter implements Adapter {
private schema: InternalSchema;
private namespaceResolver: NamespaceResolver;
private modelInstanceCreator: ModelInstanceCreator;
private getModelConstructorByModelName: (
namsespaceName: string,
modelName: string
) => PersistentModelConstructor<any>;
private db: AsyncStorageDatabase;
private initPromise: Promise<void>;
private resolve: (value?: any) => void;
private reject: (value?: any) => void;
private getStorenameForModel(
modelConstructor: PersistentModelConstructor<any>
) {
const namespace = this.namespaceResolver(modelConstructor);
const { name: modelName } = modelConstructor;
return this.getStorename(namespace, modelName);
}
private getStorename(namespace: string, modelName: string) {
const storeName = `${namespace}_${modelName}`;
return storeName;
}
async setUp(
theSchema: InternalSchema,
namespaceResolver: NamespaceResolver,
modelInstanceCreator: ModelInstanceCreator,
getModelConstructorByModelName: (
namsespaceName: string,
modelName: string
) => PersistentModelConstructor<any>
) {
if (!this.initPromise) {
this.initPromise = new Promise((res, rej) => {
this.resolve = res;
this.reject = rej;
});
} else {
await this.initPromise;
return;
}
this.schema = theSchema;
this.namespaceResolver = namespaceResolver;
this.modelInstanceCreator = modelInstanceCreator;
this.getModelConstructorByModelName = getModelConstructorByModelName;
try {
if (!this.db) {
this.db = new AsyncStorageDatabase();
await this.db.init();
this.resolve();
}
} catch (error) {
this.reject(error);
}
}
async save<T extends PersistentModel>(
model: T,
condition?: ModelPredicate<T>
): Promise<[T, OpType.INSERT | OpType.UPDATE][]> {
const modelConstructor = Object.getPrototypeOf(model)
.constructor as PersistentModelConstructor<T>;
const storeName = this.getStorenameForModel(modelConstructor);
const connectedModels = traverseModel(
modelConstructor.name,
model,
this.schema.namespaces[this.namespaceResolver(modelConstructor)],
this.modelInstanceCreator,
this.getModelConstructorByModelName
);
const namespaceName = this.namespaceResolver(modelConstructor);
const set = new Set<string>();
const connectionStoreNames = Object.values(connectedModels).map(
({ modelName, item, instance }) => {
const storeName = this.getStorename(namespaceName, modelName);
set.add(storeName);
return { storeName, item, instance };
}
);
const fromDB = await this.db.get(model.id, storeName);
if (condition && fromDB) {
const predicates = ModelPredicateCreator.getPredicates(condition);
const { predicates: predicateObjs, type } = predicates;
const isValid = validatePredicate(fromDB, type, predicateObjs);
if (!isValid) {
const msg = 'Conditional update failed';
logger.error(msg, { model: fromDB, condition: predicateObjs });
throw new Error(msg);
}
}
const result: [T, OpType.INSERT | OpType.UPDATE][] = [];
for await (const resItem of connectionStoreNames) {
const { storeName, item, instance } = resItem;
const { id } = item;
const fromDB = <T>await this.db.get(id, storeName);
const opType: OpType = fromDB ? OpType.UPDATE : OpType.INSERT;
if (id === model.id || opType === OpType.INSERT) {
await this.db.save(item, storeName);
result.push([instance, opType]);
}
}
return result;
}
private async load<T>(
namespaceName: string,
srcModelName: string,
records: T[]
): Promise<T[]> {
const namespace = this.schema.namespaces[namespaceName];
const relations = namespace.relationships[srcModelName].relationTypes;
const connectionStoreNames = relations.map(({ modelName }) => {
return this.getStorename(namespaceName, modelName);
});
const modelConstructor = this.getModelConstructorByModelName(
namespaceName,
srcModelName
);
if (connectionStoreNames.length === 0) {
return records.map(record =>
this.modelInstanceCreator(modelConstructor, record)
);
}
for await (const relation of relations) {
const { fieldName, modelName, targetName, relationType } = relation;
const storeName = this.getStorename(namespaceName, modelName);
const modelConstructor = this.getModelConstructorByModelName(
namespaceName,
modelName
);
switch (relationType) {
case 'HAS_ONE':
for await (const recordItem of records) {
const getByfield = recordItem[targetName] ? targetName : fieldName;
if (!recordItem[getByfield]) break;
const connectionRecord = await this.db.get(
recordItem[getByfield],
storeName
);
recordItem[fieldName] =
connectionRecord &&
this.modelInstanceCreator(modelConstructor, connectionRecord);
}
break;
case 'BELONGS_TO':
for await (const recordItem of records) {
if (recordItem[targetName]) {
const connectionRecord = await this.db.get(
recordItem[targetName],
storeName
);
recordItem[fieldName] =
connectionRecord &&
this.modelInstanceCreator(modelConstructor, connectionRecord);
delete recordItem[targetName];
}
}
break;
case 'HAS_MANY':
// TODO: Lazy loading
break;
default:
exhaustiveCheck(relationType);
break;
}
}
return records.map(record =>
this.modelInstanceCreator(modelConstructor, record)
);
}
async query<T extends PersistentModel>(
modelConstructor: PersistentModelConstructor<T>,
predicate?: ModelPredicate<T>,
pagination?: PaginationInput<T>
): Promise<T[]> {
const storeName = this.getStorenameForModel(modelConstructor);
const namespaceName = this.namespaceResolver(modelConstructor);
const predicates =
predicate && ModelPredicateCreator.getPredicates(predicate);
const queryById = predicates && this.idFromPredicate(predicates);
const hasSort = pagination && pagination.sort;
const hasPagination = pagination && pagination.limit;
const records: T[] = await (async () => {
if (queryById) {
const record = await this.getById(storeName, queryById);
return record ? [record] : [];
}
if (predicates) {
const filtered = await this.filterOnPredicate(storeName, predicates);
return this.inMemoryPagination(filtered, pagination);
}
if (hasSort || hasPagination) {
const all = await this.getAll(storeName);
return this.inMemoryPagination(all, pagination);
}
return this.getAll(storeName);
})();
return await this.load(namespaceName, modelConstructor.name, records);
}
private async getById<T extends PersistentModel>(
storeName: string,
id: string
): Promise<T> {
const record = <T>await this.db.get(id, storeName);
return record;
}
private async getAll<T extends PersistentModel>(
storeName: string
): Promise<T[]> {
return await this.db.getAll(storeName);
}
private idFromPredicate<T extends PersistentModel>(
predicates: PredicatesGroup<T>
) {
const { predicates: predicateObjs } = predicates;
const idPredicate =
predicateObjs.length === 1 &&
(predicateObjs.find(
p => isPredicateObj(p) && p.field === 'id' && p.operator === 'eq'
) as PredicateObject<T>);
return idPredicate && idPredicate.operand;
}
private async filterOnPredicate<T extends PersistentModel>(
storeName: string,
predicates: PredicatesGroup<T>
) {
const { predicates: predicateObjs, type } = predicates;
const all = <T[]>await this.getAll(storeName);
const filtered = predicateObjs
? all.filter(m => validatePredicate(m, type, predicateObjs))
: all;
return filtered;
}
private inMemoryPagination<T extends PersistentModel>(
records: T[],
pagination?: PaginationInput<T>
): T[] {
if (pagination && records.length > 1) {
if (pagination.sort) {
const sortPredicates = ModelSortPredicateCreator.getPredicates(
pagination.sort
);
if (sortPredicates.length) {
const compareFn = sortCompareFunction(sortPredicates);
records.sort(compareFn);
}
}
const { page = 0, limit = 0 } = pagination;
const start = Math.max(0, page * limit) || 0;
const end = limit > 0 ? start + limit : records.length;
return records.slice(start, end);
}
return records;
}
async queryOne<T extends PersistentModel>(
modelConstructor: PersistentModelConstructor<T>,
firstOrLast: QueryOne = QueryOne.FIRST
): Promise<T | undefined> {
const storeName = this.getStorenameForModel(modelConstructor);
const result = <T>await this.db.getOne(firstOrLast, storeName);
return result && this.modelInstanceCreator(modelConstructor, result);
}
async delete<T extends PersistentModel>(
modelOrModelConstructor: T | PersistentModelConstructor<T>,
condition?: ModelPredicate<T>
): Promise<[T[], T[]]> {
const deleteQueue: { storeName: string; items: T[] }[] = [];
if (isModelConstructor(modelOrModelConstructor)) {
const modelConstructor = modelOrModelConstructor;
const nameSpace = this.namespaceResolver(modelConstructor);
// models to be deleted.
const models = await this.query(modelConstructor, condition);
// TODO: refactor this to use a function like getRelations()
const relations =
this.schema.namespaces[nameSpace].relationships[modelConstructor.name]
.relationTypes;
if (condition !== undefined) {
await this.deleteTraverse(
relations,
models,
modelConstructor.name,
nameSpace,
deleteQueue
);
await this.deleteItem(deleteQueue);
const deletedModels = deleteQueue.reduce(
(acc, { items }) => acc.concat(items),
<T[]>[]
);
return [models, deletedModels];
} else {
await this.deleteTraverse(
relations,
models,
modelConstructor.name,
nameSpace,
deleteQueue
);
await this.deleteItem(deleteQueue);
const deletedModels = deleteQueue.reduce(
(acc, { items }) => acc.concat(items),
<T[]>[]
);
return [models, deletedModels];
}
} else {
const model = modelOrModelConstructor;
const modelConstructor = Object.getPrototypeOf(model)
.constructor as PersistentModelConstructor<T>;
const nameSpace = this.namespaceResolver(modelConstructor);
const storeName = this.getStorenameForModel(modelConstructor);
if (condition) {
const fromDB = await this.db.get(model.id, storeName);
if (fromDB === undefined) {
const msg = 'Model instance not found in storage';
logger.warn(msg, { model });
return [[model], []];
}
const predicates = ModelPredicateCreator.getPredicates(condition);
const { predicates: predicateObjs, type } = predicates;
const isValid = validatePredicate(fromDB, type, predicateObjs);
if (!isValid) {
const msg = 'Conditional update failed';
logger.error(msg, { model: fromDB, condition: predicateObjs });
throw new Error(msg);
}
const relations =
this.schema.namespaces[nameSpace].relationships[modelConstructor.name]
.relationTypes;
await this.deleteTraverse(
relations,
[model],
modelConstructor.name,
nameSpace,
deleteQueue
);
} else {
const relations =
this.schema.namespaces[nameSpace].relationships[modelConstructor.name]
.relationTypes;
await this.deleteTraverse(
relations,
[model],
modelConstructor.name,
nameSpace,
deleteQueue
);
}
await this.deleteItem(deleteQueue);
const deletedModels = deleteQueue.reduce(
(acc, { items }) => acc.concat(items),
<T[]>[]
);
return [[model], deletedModels];
}
}
private async deleteItem<T extends PersistentModel>(
deleteQueue?: { storeName: string; items: T[] | IDBValidKey[] }[]
) {
for await (const deleteItem of deleteQueue) {
const { storeName, items } = deleteItem;
for await (const item of items) {
if (item) {
if (typeof item === 'object') {
const id = item['id'];
await this.db.delete(id, storeName);
}
}
}
}
}
/**
* Populates the delete Queue with all the items to delete
* @param relations
* @param models
* @param srcModel
* @param nameSpace
* @param deleteQueue
*/
private async deleteTraverse<T extends PersistentModel>(
relations: RelationType[],
models: T[],
srcModel: string,
nameSpace: string,
deleteQueue: { storeName: string; items: T[] }[]
): Promise<void> {
for await (const rel of relations) {
const { relationType, modelName, targetName } = rel;
const storeName = this.getStorename(nameSpace, modelName);
const index: string =
getIndex(
this.schema.namespaces[nameSpace].relationships[modelName]
.relationTypes,
srcModel
) ||
// if we were unable to find an index via relationTypes
// i.e. for keyName connections, attempt to find one by the
// associatedWith property
getIndexFromAssociation(
this.schema.namespaces[nameSpace].relationships[modelName].indexes,
rel.associatedWith
);
switch (relationType) {
case 'HAS_ONE':
for await (const model of models) {
const hasOneIndex = index || 'byId';
const hasOneCustomField = targetName in model;
const value = hasOneCustomField ? model[targetName] : model.id;
if (!value) break;
const allRecords = await this.db.getAll(storeName);
const recordToDelete = allRecords.filter(
childItem => childItem[hasOneIndex] === value
);
await this.deleteTraverse(
this.schema.namespaces[nameSpace].relationships[modelName]
.relationTypes,
recordToDelete,
modelName,
nameSpace,
deleteQueue
);
}
break;
case 'HAS_MANY':
for await (const model of models) {
const allRecords = await this.db.getAll(storeName);
const childrenArray = allRecords.filter(
childItem => childItem[index] === model.id
);
await this.deleteTraverse(
this.schema.namespaces[nameSpace].relationships[modelName]
.relationTypes,
childrenArray,
modelName,
nameSpace,
deleteQueue
);
}
break;
case 'BELONGS_TO':
// Intentionally blank
break;
default:
exhaustiveCheck(relationType);
break;
}
}
deleteQueue.push({
storeName: this.getStorename(nameSpace, srcModel),
items: models.map(record =>
this.modelInstanceCreator(
this.getModelConstructorByModelName(nameSpace, srcModel),
record
)
),
});
}
async clear(): Promise<void> {
await this.db.clear();
this.db = undefined;
this.initPromise = undefined;
}
async batchSave<T extends PersistentModel>(
modelConstructor: PersistentModelConstructor<any>,
items: ModelInstanceMetadata[]
): Promise<[T, OpType][]> {
const { name: modelName } = modelConstructor;
const namespaceName = this.namespaceResolver(modelConstructor);
const storeName = this.getStorename(namespaceName, modelName);
const batch: ModelInstanceMetadata[] = [];
for (const item of items) {
const { id } = item;
const connectedModels = traverseModel(
modelConstructor.name,
this.modelInstanceCreator(modelConstructor, item),
this.schema.namespaces[this.namespaceResolver(modelConstructor)],
this.modelInstanceCreator,
this.getModelConstructorByModelName
);
const { instance } = connectedModels.find(
({ instance }) => instance.id === id
);
batch.push(instance);
}
return await this.db.batchSave(storeName, batch);
}
}
export default new AsyncStorageAdapter();