graphql-genie
Version:
651 lines (599 loc) • 23.4 kB
text/typescript
import { atob, btoa } from 'abab';
import fortune from 'fortune';
import { IntrospectionInterfaceType, IntrospectionType } from 'graphql';
import { each, find, findIndex, forOwn, get, has, isArray, isEmpty, isEqual, isPlainObject, isString, keys, merge, set, toString } from 'lodash';
import * as fortuneCommon from 'fortune/lib/adapter/adapters/common';
import { Connection, DataResolver, DataResolverInputHook, DataResolverOutputHook, Features, FortuneOptions, FortuneRecordTypeDefinitions, GenericObject } from './GraphQLGenieInterfaces';
import { computeRelations } from './TypeGeneratorUtilities';
interface FortuneUpdate {
id: string;
push?: object;
replace?: object;
pull?: object;
}
export default class FortuneGraph implements DataResolver {
private fortuneOptions: FortuneOptions;
public fortuneTypeNames: Map<string, string>;
private uniqueIndexes: Map<string, string[]>;
private schemaInfo: IntrospectionType[];
private inputHooks: Map<string, DataResolverInputHook[]>;
private outputHooks: Map<string, DataResolverOutputHook[]>;
private store;
private transaction;
constructor(fortuneOptions: FortuneOptions, schemaInfo: IntrospectionType[], fortuneRecordTypeDefinitions?: FortuneRecordTypeDefinitions) {
this.fortuneOptions = fortuneOptions;
if (!this.fortuneOptions || !this.fortuneOptions.hooks) {
set(this.fortuneOptions, 'hooks', {});
}
this.schemaInfo = schemaInfo;
this.uniqueIndexes = new Map<string, string[]>();
this.inputHooks = new Map<string, DataResolverInputHook[]>();
this.outputHooks = new Map<string, DataResolverOutputHook[]>();
this.store = this.buildFortune(fortuneRecordTypeDefinitions);
}
public computeId = (graphType: string, id?: string): string => {
id = id || fortuneCommon.generateId();
return btoa(`${id}:${graphType}`);
}
public getTypeFromId = (inputId: string): string => {
let result: string;
try {
result = atob(inputId).split(':')[1];
} catch (e) {
result = inputId;
}
return result;
}
public getOriginalIdFromObjectId = (inputId: string): string => {
let result: string;
try {
result = atob(inputId).split(':')[0];
} catch (e) {
result = inputId;
}
return result;
}
public getValueByUnique = async (returnTypeName: string, args, meta): Promise<Object> => {
let currValue;
// tslint:disable-next-line:prefer-conditional-expression
if (args.id) {
currValue = await this.find(returnTypeName, [args.id], undefined, meta);
} else {
currValue = await this.find(returnTypeName, undefined, { match: args }, meta);
}
return isArray(currValue) ? currValue[0] : currValue;
}
public canAdd = async (graphQLTypeName: string, inputRecords: object[], meta): Promise<boolean> => {
let canAdd = true;
if (inputRecords && this.uniqueIndexes.has(graphQLTypeName)) {
await Promise.all(this.uniqueIndexes.get(graphQLTypeName).map(async (fieldName) => {
await Promise.all(inputRecords.map(async (inputRecord) => {
if (canAdd && inputRecord[fieldName]) {
const dbRecord = await this.getValueByUnique(graphQLTypeName, { [fieldName]: inputRecord[fieldName] }, meta);
if (dbRecord) {
canAdd = false;
}
}
}));
}));
}
return canAdd;
}
public getConnection = (allEdges: any[], before: string, after: string, first: number, last: number): Connection => {
const connection: Connection = new Connection();
const edgesWithCursorApplied = this.applyCursorsToEdges(allEdges, before, after);
connection.aggregate.count = allEdges.length;
connection.edges = this.edgesToReturn(edgesWithCursorApplied, first, last);
if (typeof last !== 'undefined') {
if (edgesWithCursorApplied.length > last) {
connection.pageInfo.hasPreviousPage = true;
}
} else if (typeof after !== 'undefined' && get(allEdges, '[0].id', 'id0') !== get(edgesWithCursorApplied, '[0].id', 'id1')) {
connection.pageInfo.hasPreviousPage = true;
}
if (typeof first !== 'undefined') {
if (edgesWithCursorApplied.length > first) {
connection.pageInfo.hasNextPage = true;
}
} else if (typeof before !== 'undefined' && get(allEdges, `[${allEdges.length - 1}].id`, 'id0') !== get(edgesWithCursorApplied, `[${edgesWithCursorApplied.length - 1}].id`, 'id1')) {
connection.pageInfo.hasNextPage = true;
}
connection.pageInfo.startCursor = get(connection.edges, '[0].id');
connection.pageInfo.endCursor = get(connection.edges, `[${connection.edges.length - 1}].id`);
return connection;
}
private edgesToReturn = (edgesWithCursorApplied: any[], first: number, last: number): any[] => {
if (typeof first !== 'undefined') {
if (first < 0) {
throw new Error('first must be greater than 0');
} else if (edgesWithCursorApplied.length > first) {
edgesWithCursorApplied = edgesWithCursorApplied.slice(0, first);
}
}
if (typeof last !== 'undefined') {
if (last < 0) {
throw new Error('first must be greater than 0');
} else if (edgesWithCursorApplied.length > last) {
edgesWithCursorApplied = edgesWithCursorApplied.slice(edgesWithCursorApplied.length - last, edgesWithCursorApplied.length);
}
}
return edgesWithCursorApplied;
}
private applyCursorsToEdges = (allEdges: any[], before: string, after: string): any[] => {
let edges = allEdges;
if (after) {
const afterEdgeIndex = findIndex(edges, ['id', after]);
if (afterEdgeIndex > -1) {
edges = edges.slice(afterEdgeIndex + 1, edges.length);
}
}
if (before) {
const beforeEdgeIndex = findIndex(edges, ['id', before]);
if (beforeEdgeIndex > -1) {
edges = edges.slice(0, beforeEdgeIndex);
}
}
return edges;
}
private getDataTypeName(graphQLTypeName: string): string {
graphQLTypeName = graphQLTypeName.endsWith('Connection') ? graphQLTypeName.replace(/Connection$/g, '') : graphQLTypeName;
graphQLTypeName = graphQLTypeName.endsWith('Edge') ? graphQLTypeName.replace(/Edge$/g, '') : graphQLTypeName;
return graphQLTypeName;
}
public create = async (graphQLTypeName: string, records, meta?) => {
const fortuneType = this.getFortuneTypeName(graphQLTypeName);
records['__typename'] = graphQLTypeName;
if (!records['id']) {
records['id'] = this.computeId(graphQLTypeName);
}
let results = this.transaction ? await this.transaction.create(fortuneType, records, undefined, meta) : await this.store.create(fortuneType, records, undefined, meta);
results = results.payload.records;
return isArray(records) ? results : results[0];
}
public find = async (graphQLTypeName: string, ids?: string[], options?, meta?) => {
let results;
let fortuneType: string;
if (graphQLTypeName === 'Node') {
fortuneType = this.getFortuneTypeName(this.getTypeFromId(ids[0]));
results = this.transaction ? await this.transaction.find(fortuneType, ids[0], options, undefined, meta) : await this.store.find(fortuneType, ids[0], options, undefined, meta);
} else {
fortuneType = this.getFortuneTypeName(graphQLTypeName);
// pull the id out of the match options
if (get(options, 'match.id')) {
ids = get(options, 'match.id');
delete options.match.id;
}
if (ids) {
ids = isArray(ids) ? ids : [ids];
}
options = this.generateOptions(options, graphQLTypeName, ids);
results = this.transaction ? await this.transaction.find(fortuneType, ids, options, undefined, meta) : await this.store.find(fortuneType, ids, options, undefined, meta);
}
let graphReturn: any[] = get(results, 'payload.records');
if (graphReturn) {
graphReturn = graphReturn.map((element) => {
element.id = isString(element.id) ? element.id : toString(element.id);
return element;
});
if (!get(options, 'sort')) {
// to support relay plural identifying root fields results should be in the order in which they were requested,
// with null being returned by failed finds
if (ids) {
const newReturn = [];
ids.forEach((value) => {
const foundResult = find(graphReturn, ['id', value]);
if (foundResult) {
newReturn.push(foundResult);
} else {
newReturn.push(null);
}
});
graphReturn = newReturn;
} else if (this.uniqueIndexes.has(graphQLTypeName)) {
const matches: object = options.match;
if (matches['__typename']) {
delete matches['__typename'];
}
const matchesKeys = Object.keys(matches);
if (matchesKeys.length === 1) {
const key = matchesKeys[0];
if (this.uniqueIndexes.get(graphQLTypeName).includes(key)) {
const theMatch = isArray(matches[key]) ? matches[key] : [matches[key]];
const newReturn = [];
theMatch.forEach((value) => {
const foundResult = find(graphReturn, [key, value]);
if (foundResult) {
newReturn.push(foundResult);
} else {
newReturn.push(null);
}
});
graphReturn = newReturn;
}
}
}
}
// if one id sent in we just want to return the value not an array
graphReturn = ids && ids.length === 1 ? graphReturn[0] : graphReturn;
}
if (!graphReturn) {
// console.log('Nothing Found ' + graphQLTypeName + ' ' + JSON.stringify(ids) + ' ' + JSON.stringify(options));
}
return graphReturn;
}
public update = async (graphQLTypeName: string, records, meta?, options?: object) => {
const fortuneType = this.getFortuneTypeName(graphQLTypeName);
let updates = records;
if (!options || !options['fortuneFormatted']) {
updates = isArray(records) ? records.map(value => this.generateUpdates(value, options)) : this.generateUpdates(records, options);
}
let results = this.transaction ? await this.transaction.update(fortuneType, updates, undefined, meta) : await this.store.update(fortuneType, updates, undefined, meta);
results = results.payload.records;
return isArray(records) ? results : results[0];
}
public delete = async (graphQLTypeName: string, ids?: string[], meta?) => {
const fortuneType = this.getFortuneTypeName(graphQLTypeName);
if (ids.length > 0) {
this.transaction ? await this.transaction.delete(fortuneType, ids, undefined, meta) : await this.store.delete(fortuneType, ids, undefined, meta);
}
return true;
}
public generateUpdates = (record, options: object = {}): FortuneUpdate => {
const updates: FortuneUpdate = { id: record['id'] };
for (const argName in record) {
const arg = record[argName];
if (argName !== 'id') {
if (isArray(arg)) {
if (options['pull']) {
updates.pull = updates.pull || {};
updates.pull[argName] = arg;
} else {
updates.push = updates.push || {};
updates.push[argName] = arg;
}
} else if (isPlainObject(arg)) {
if (arg.push) {
updates.push = updates.push || {};
updates.push[argName] = arg.push;
}
if (arg.pull) {
updates.pull = updates.pull || {};
updates.pull[argName] = arg.pull;
}
if (arg.set) {
updates.replace = updates.replace || {};
updates.replace[argName] = arg.set;
}
} else {
updates.replace = updates.replace || {};
updates.replace[argName] = arg;
}
}
}
return updates;
}
public getLink = (graphQLTypeName: string, field: string): string => {
const fortuneType = this.getFortuneTypeName(graphQLTypeName);
return get(this.store, `recordTypes.${fortuneType}.${field}.link`);
}
public getStore = () => {
if (!this.store) {
this.store = this.buildFortune();
}
return this.store;
}
private computeFortuneTypeNames = (): Map<string, string> => {
this.fortuneTypeNames = new Map<string, string>();
each(keys(this.schemaInfo), (typeName) => {
if (typeName !== 'Node' && !this.fortuneTypeNames.has(typeName)) {
const type = <IntrospectionInterfaceType>this.schemaInfo[typeName];
let storeObj = this.getStoreName(type, null, null);
let storeName = storeObj.storeName;
let storeTypeName = storeObj.typeName;
if (!isEmpty(type.possibleTypes)) {
const possibleTypes = [type.name];
each(type.possibleTypes, possibleType => {
if (possibleTypes.indexOf(possibleType.name) < 0) {
possibleTypes.push(possibleType.name);
}
possibleType = this.schemaInfo[possibleType.name];
storeObj = this.getStoreName(possibleType, storeName, storeTypeName);
storeName = storeObj.storeName;
storeTypeName = storeObj.typeName;
each(possibleType['interfaces'], currInterface => {
currInterface = this.schemaInfo[currInterface.name];
if (currInterface.name !== 'Node' && currInterface.name !== storeTypeName) {
storeObj = this.getStoreName(currInterface, storeName, storeTypeName);
storeName = storeObj.storeName;
storeTypeName = storeObj.typeName;
if (possibleTypes.indexOf(currInterface.name) < 0) {
possibleTypes.push(currInterface.name);
}
if (!isEmpty(currInterface.possibleTypes)) {
each(currInterface.possibleTypes, currInterfacePossibleType => {
if (possibleTypes.indexOf(currInterfacePossibleType.name) < 0) {
currInterfacePossibleType = this.schemaInfo[currInterfacePossibleType.name];
storeObj = this.getStoreName(currInterfacePossibleType, storeName, storeTypeName);
storeName = storeObj.storeName;
storeTypeName = storeObj.typeName;
possibleTypes.push(currInterfacePossibleType.name);
}
});
}
}
});
each(possibleType['unions'], currUnion => {
currUnion = this.schemaInfo[currUnion.name];
if (currUnion.name !== storeTypeName) {
if (possibleTypes.indexOf(currUnion.name) < 0) {
storeObj = this.getStoreName(currUnion, storeName, storeTypeName);
storeName = storeObj.storeName;
storeTypeName = storeObj.typeName;
possibleTypes.push(currUnion.name);
}
if (!isEmpty(currUnion.possibleTypes)) {
each(currUnion.possibleTypes, currUnionPossibleType => {
if (possibleTypes.indexOf(currUnionPossibleType.name) < 0) {
currUnionPossibleType = this.schemaInfo[currUnionPossibleType.name];
storeObj = this.getStoreName(currUnionPossibleType, storeName, storeTypeName);
storeName = storeObj.storeName;
storeTypeName = storeObj.typeName;
possibleTypes.push(currUnionPossibleType.name);
}
});
}
}
});
});
const fortuneTypeName = storeName || (possibleTypes.sort() && possibleTypes.join('_'));
each(possibleTypes, currTypeName => {
this.fortuneTypeNames.set(currTypeName, fortuneTypeName);
});
} else if (!isEmpty(storeName)) {
this.fortuneTypeNames.set(typeName, storeName);
}
}
});
return this.fortuneTypeNames;
}
private getStoreName = (type, currStoreName: string, currTypeName: string): { storeName: string, typeName: string } => {
let storeName = get(type, 'metadata.storeName', null);
let typeName = type.name;
if (!isEmpty(storeName) && currStoreName && currStoreName !== storeName) {
throw new Error(`Conflictiing store names which need to be the same
${storeName} on ${typeName}
does not match
${currStoreName} on ${currTypeName}
`);
}
typeName = !isEmpty(storeName) ? typeName : currTypeName;
storeName = !isEmpty(storeName) ? storeName : currStoreName;
return {
storeName,
typeName
};
}
public getFortuneTypeName = (name: string): string => {
name = this.getDataTypeName(name);
return this.fortuneTypeNames.has(name) ? this.fortuneTypeNames.get(name) : name;
}
private buildFortune = (fortuneRecordTypeDefinitions?: FortuneRecordTypeDefinitions) => {
this.computeFortuneTypeNames();
const relations = computeRelations(this.schemaInfo, this.getFortuneTypeName);
const fortuneConfig = {};
forOwn(this.schemaInfo, (type: any, name: string) => {
if (type.kind === 'OBJECT' && name !== 'Query' && name !== 'Mutation' && name !== 'Subscription') {
const fields: GenericObject = {};
forOwn(type.fields, (field) => {
if (field.name !== 'id') {
let currType = field.type;
let isArray = false;
while (currType.kind === 'NON_NULL' || currType.kind === 'LIST') {
if (currType.kind === 'LIST') {
isArray = true;
}
currType = currType.ofType;
}
if (get(field, 'metadata.unique') === true) {
if (isArray) {
console.error('Unique may not work on list types', name, field.name);
}
if (!this.uniqueIndexes.has(name)) {
this.uniqueIndexes.set(name, []);
}
this.uniqueIndexes.get(name).push(field.name);
}
if (get(field, 'metadata.createdTimestamp') === true) {
this.addInputHook(name, (context, record) => {
switch (context.request.method) {
case 'create':
if (!record.hasOwnProperty(field.name)) {
record[field.name] = new Date();
}
return record;
}
});
}
if (get(field, 'metadata.updatedTimestamp') === true) {
this.addInputHook(name, (context, _record, update) => {
switch (context.request.method) {
case 'update':
if (!('replace' in update)) update.replace = {};
if (!update.replace.hasOwnProperty(field.name)) {
update.replace[field.name] = new Date();
}
return update;
}
});
}
const currKind = currType.kind;
currType = currType.kind === 'ENUM' ? 'String' : currType.name;
if (currType === 'ID' || currType === 'String') {
currType = String;
} else if (currType === 'Int' || currType === 'Float') {
currType = Number;
} else if (currType === 'Boolean') {
currType = Boolean;
} else if (currType === 'JSON') {
currType = Object;
} else if (currType === 'Date' || currType === 'Time' || currType === 'DateTime') {
currType = Date;
} else if (currKind === 'SCALAR') {
currType = Object;
}
let inverse: string;
if (isString(currType)) {
currType = this.getFortuneTypeName(currType);
inverse = relations.getInverseWithoutName(currType, field.name, this.getFortuneTypeName(type.name));
}
currType = isArray ? Array(currType) : currType;
if (inverse) {
currType = [currType, inverse];
}
fields[field.name] = currType;
}
fields.__typename = String;
});
const fortuneName = this.getFortuneTypeName(name);
const fortuneConfigForName = fortuneConfig[fortuneName] ? fortuneConfig[fortuneName] : {};
each(keys(fields), (fieldName) => {
const currType = fortuneConfigForName[fieldName];
const newType = fields[fieldName];
if (!currType) {
fortuneConfigForName[fieldName] = newType;
} else {
let badSchema = typeof newType !== typeof currType;
badSchema = badSchema ? badSchema : !isEqual(fortuneConfigForName[fieldName], fields[fieldName]);
if (badSchema) {
console.error('Bad schema. Types that share unions/interfaces have fields of the same name but different types. This is not allowed\n',
'fortune type', fortuneName, '\n',
'field name', fieldName, '\n',
'currType', fortuneConfigForName[fieldName], '\n',
'newType', fields[fieldName]);
}
}
});
fortuneConfig[fortuneName] = fortuneConfigForName;
if (!this.fortuneOptions.hooks[fortuneName]) {
this.fortuneOptions.hooks[fortuneName] = [this.inputHook(fortuneName), this.outputHook(fortuneName)];
}
}
});
if (fortuneRecordTypeDefinitions) {
merge(fortuneConfig, fortuneRecordTypeDefinitions);
}
const store = fortune(fortuneConfig, this.fortuneOptions);
return store;
}
private inputHook(_fortuneType: string) {
return (context, record, update) => {
const promises = [];
if (record['__typename'] && this.inputHooks.has(record['__typename'])) {
this.inputHooks.get(record['__typename']).forEach(hook => {
const returnVal = hook(context, record, update);
if (returnVal instanceof Promise) {
promises.push(returnVal);
}
});
}
return new Promise((resolve, reject) => {
Promise.all(promises).then(() => {
switch (context.request.method) {
// If it's a create request, return the record.
case 'create':
resolve(record);
break;
// If it's an update request, return the update.
case 'update':
resolve(update);
break;
// If it's a delete request, the return value doesn't matter.
case 'delete':
resolve(null);
break;
}
}).catch(reason => {
reject(reason);
});
});
};
}
private outputHook(_fortuneType: string) {
return (context, record) => {
if (record['__typename'] && this.outputHooks.has(record['__typename'])) {
const promises = [];
this.outputHooks.get(record['__typename']).forEach(hook => {
const returnVal = hook(context, record);
if (returnVal instanceof Promise) {
promises.push(returnVal);
}
});
return new Promise((resolve, reject) => {
Promise.all(promises).then(() => {
resolve(record);
}).catch(reason => {
reject(reason);
});
});
}
};
}
public getFeatures(): Features {
return this.store.adapter.features || {};
}
private generateOptions = (options, graphQLTypeName?: string, ids?): Object => {
options = options ? Object.assign({}, options) : {};
// if no ids we need to make sure we only get the necessary typename so that this works with interfaces/unions
if (graphQLTypeName && (!ids || ids.length < 1)) {
set(options, 'match.__typename', this.getDataTypeName(graphQLTypeName));
}
if (has(options, 'exists.id')) {
delete options.exists.id;
}
// make sure sort is boolean rather than enum
if (options.orderBy) {
const sort = {};
for (const fieldName in options.orderBy) {
if (options.orderBy[fieldName] === 'ASCENDING' || options.orderBy[fieldName] === 'ASC') {
sort[fieldName] = true;
} else if (options.orderBy[fieldName] === 'DESCENDING' || options.orderBy[fieldName] === 'DESC') {
sort[fieldName] = false;
}
}
options.sort = sort;
delete options.orderBy;
}
return options;
}
public applyOptions(graphQLTypeName: string, records, options, meta?) {
records = isArray(records) ? records : [records];
options = this.generateOptions(options);
const fortuneType = this.getFortuneTypeName(graphQLTypeName);
return fortuneCommon.applyOptions(this.store.recordTypes[fortuneType], records, options, meta);
}
public addOutputHook(graphQLTypeName: string, hook: DataResolverOutputHook) {
if (!this.outputHooks.has(graphQLTypeName)) {
this.outputHooks.set(graphQLTypeName, [hook]);
} else {
this.outputHooks.get(graphQLTypeName).push(hook);
}
}
public addInputHook(graphQLTypeName: string, hook: DataResolverInputHook) {
if (!this.inputHooks.has(graphQLTypeName)) {
this.inputHooks.set(graphQLTypeName, [hook]);
} else {
this.inputHooks.get(graphQLTypeName).push(hook);
}
}
public beginTransaction = async () => {
if (!this.transaction && this.store.beginTransaction) {
this.transaction = await this.store.beginTransaction();
}
}
public endTransaction = async () => {
if (this.transaction && this.store.endTransaction) {
await this.store.endTransaction();
}
this.transaction = null;
}
}