Skip to content

Instantly share code, notes, and snippets.

@ancms2600
Last active August 11, 2019 23:05
Show Gist options
  • Save ancms2600/526b64b262561fb4ca1be824c2faec7f to your computer and use it in GitHub Desktop.
Save ancms2600/526b64b262561fb4ca1be824c2faec7f to your computer and use it in GitHub Desktop.
Schemaless GraphQL (in JavaScript)
const { Utils } = require('../../../../shared/public/components/utils');
const SharedCache = require('../../../../shared/models/sharedCache');
require('../../../../shared/public/components/flatten');
const uuidv4 = require('uuid/v4');
const makeId = prefix => (prefix||'') + uuidv4().replace(/-/g,'');
const sgql = require('../../../../shared/models/sgql');
const _ = require('lodash');
const MODELS = ['User'];
const root = {
// Authentication
auth({ params, ret }={}, ctx) {
// throw Error(`whats up?`);
return { _id: 'abcd-efgh-hij', a: { b: { c: 'hamster' }}};
},
deauth({ params, ret }={}, ctx) {
debugger;
},
// CRUD for all models
async read({ params, ret }={}, ctx) {
return await crudMaster('read', params, ret, ctx);
},
async create({ params, ret }={}, ctx) {
return await crudMaster('create', params, ret, ctx);
},
// TODO: mode: partial vs. replace, and upsert
// TODO: occ atomic __v check
// TODO: sort, pagination, filter
async update({ params, ret }={}) {
debugger;
},
async delete({ params, ret }={}) {
debugger;
},
};
const crudMaster = async (method, params, ret, ctx) => {
const type = _.get(params, 'type');
if (!MODELS.includes(type)) return null;
// summarize output plan
const flatOutput = JSON.flatten(ret);
// const select = Object.keys(flatOutput);
let mode = 'exclusive';
const inclusive = [], exclusive = [];
for (const key of _.keys(flatOutput)) {
if (true === flatOutput[key]) {
mode = 'inclusive';
inclusive.push(key);
}
else {
exclusive.push(key);
}
}
let result = {};
if ('read' === method) {
const _id = _.get(params, '_id');
const key = `${type}:${_id}`;
console.debug(`read ${key} returning ${inclusive.join(', ')} ${mode}`);
await SharedCache.Script.define('crud_read', 1,
`local hash = KEYS[1];
local _id = ARGV[1];
local mode = ARGV[2];
local length = tonumber(ARGV[3]);
local offset = 4;
local exists = redis.call('EXISTS', hash);
if 1 ~= exists then return nil; end
local result = {};
if 'exclusive' == mode then
local keys = redis.call('HKEYS', hash);
for i, k1 in ipairs(keys) do
local skip = false;
for ii=offset,offset+length-1 do
local k2 = ARGV[ii];
if k1 == k2 then skip = true; end
end
if not skip then
local v = redis.call('HGET', hash, k1);
if nil ~= v then
table.insert(result, k1);
table.insert(result, v);
end
end
end
elseif 'inclusive' == mode then
for i=offset,offset+length-1 do
local k = ARGV[i];
local v = redis.call('HGET', hash, k);
if nil ~= v then
table.insert(result, k);
table.insert(result, v);
end
end
end
return result;`
);
const raw = await SharedCache.Script.exec('crud_read',
// KEYS
/*[1]*/ key, // Hash
// ARGV
/*[1]*/ _id, // string
/*[2]*/ mode, // string
/*[3]*/ 'exclusive' === mode ? exclusive.length : inclusive.length, // integer
/*[4+]*/ ...('exclusive' === mode ? exclusive : inclusive), // string[]
);
if (null == raw) return null;
// deserialize
result = Utils.reduce(_.fromPairs(_.chunk(raw, 2)), (o,v,k) => {
if (null != v) o[k] = v; }, {});
}
else if ('create' === method) {
let doc = _.get(params, 'doc');
if (null == doc) doc = {};
// verify _id omitted
let _id = _.get(params, '_id', _.get(doc, '_id'))
if (null != _id) throw Error(JSON.stringify({
message: `Refusing to create record with predefined _id`, _id }));
// generate _id and attempt to create document
// retry in case of low probability that _id already exists
let key, exists, tries = 3;
do {
if (--tries <= 0) break;
_id = makeId();
key = `${type}:${_id}`;
doc = { _id, ...doc };
console.debug(`attempt create ${key} from ${JSON.stringify(doc)} returning ${inclusive.join(', ')} ${mode}`);
const flatInput = _.flatten(_.toPairs(Utils.reduce(JSON.flatten(doc), (o,v,k)=>o[k] = v)));
await SharedCache.Script.define('crud_create', 1,
`local hash = KEYS[1];
local length = tonumber(ARGV[1]);
local offset = 2;
local exists = redis.call('EXISTS', hash);
if 1 == exists then return 'exists'; end
for i=offset,offset+length-1,2 do
local k = ARGV[i];
local v = ARGV[i+1];
local v = redis.call('HSET', hash, k, v);
end;`
);
exists = await SharedCache.Script.exec('crud_create',
// KEYS
/*[1]*/ key, // Hash
// ARGV
/*[1]*/ flatInput.length, // integer
/*[2+]*/ ...flatInput, // string[]
);
} while('exists' === exists);
console.debug(`create ${key} ok`);
if ('exclusive' === mode) {
result = _.omit(doc, exclusive);
}
else if ('inclusive' === mode) {
result = _.pick(doc, inclusive);
}
}
// perform FK joins
if (null != result.createdBy) {
const matched = new Set();
for (const path of inclusive) {
let m;
if (null != (m = path.match(/((?:^|\.)creator)(?:\.|$)/))) {
const [,pathPart] = m;
if (matched.has(pathPart)) continue;
matched.add(pathPart);
const _ret = _.get(ret, pathPart);
const op = 'read';
_.set(result, pathPart, await sgql.execResolver(op, {
params: { type: 'User', _id: result.createdBy },
ret: _ret}, ctx));
}
}
}
return result;
};
module.exports = root;
const { assert } = require('chai');
const root = {
Validate: {
string: (obj, key) => 'string' === typeof obj[key],
object: (obj, key) => 'object' === typeof obj[key],
void: (obj, key) => void(0) === obj[key],
},
Fields: {
async createdBy(obj, fields) {
if (null == obj.createdBy) return null;
else if (1 === fields.length || '_id' === fields[0]) {
return { _id: obj.createdBy };
}
else {
return await User.findById(obj.createdBy, fields);
}
},
},
Parallel: {
// cRud for all models
read(obj, args, context, info) {
},
},
Serial: {
// Authentication
auth({ name, phrase }, args, context, info) {
// throw Error(`whats up?`);
return new SchemalessDoc({ _id: 'abcd-efgh-hij', a: { b: { c: 3 }}});
},
deauth(obj, args, context, info) {
debugger;
},
// CrUD for all models
create(obj, args, context, info) {
debugger;
},
update(obj, args, context, info) {
debugger;
},
delete(obj, args, context, info) {
debugger;
},
},
};
// Schemaless GraphQL
// changes from GraphQL:
// - Schema and Queries are case-sensitive
// - s/Query/Parallel and s/Mutation/Serial
// - instead of resolvers by Type, its resolvers by field name
// - context provided to resolvers greatly simplified to: parent doc, and list of fields needed
// - type system is greatly simplified:
// - only two root-level types are valid: Serial, Parallel
// - remaining types are all scalar
// - scalar types are all user-defined, in the form of simple validation functions (also resolvers) by the same name
// - commas are optional in param list
// - objects are allowed to be returned
// - schema is entirely removed; pointless
const sgql = (schema, root, query) => {
return {
async query(q) {
},
};
};
const parseDot = (s, to) => {
const RX_FSM = /(\w{1,99})|{([\w,]{1,999})}|(\s{1,9})/g;
const map = {};
let a;
s.replace(RX_FSM, (m, single, list) => {
const b =
null != single ? [single] :
null != list ? list.split(/,/g) :
[];
if (null != a && a.length > 0 && b.length > 0) {
for (const k of (to ? a : b)) {
if (null == map[k]) map[k] = [];
map[k].push(...(to ? b : a));
}
}
a = b;
});
return map;
};
sgql.parseSchema = (schema, { debug }={}) => {
const RX_TYPE = /(type\s{1,9})(\w{1,99})(\s{1,9}{\s{0,9})([^}]{1,9999})\s{0,9}}/gm;
const RX_SYMBOLS = /(\w{1,99})|(\()|(\))|([\s,:]{1,9})/g;
const RX_NEWLINE = /[\r\n]/g;
const FSM = parseDot(`
begin->{space1,resolver} space1->resolver resolver->{space2,open} space2->open->{space3,param}
{open,space3}->close space3->param->space4->pv->{space5,close}
space5->{param,close} close->{space6->rv} rv->end->begin
`);
const Types = {};
schema.replace(RX_TYPE, (match0, p1, Type, p2, def, offset1) => {
const stack = {};
let frame = {}, state = 'begin', resolver, param;
(def+' ').replace(RX_SYMBOLS, (match, word, open, close, space, offset2) => {
const matchIf = (nextState, friendlyName, cases) => {
if (!FSM[nextState].includes(state)) return;
for (const { test, next, cb } of cases) {
if (!test) continue;
if (null != cb) cb();
state = next || nextState;
return true;
}
if (null == friendlyName) return;
if (true === debug) console.debug(JSON.stringify({
Type, resolver, state, word, open, close, space, frame, stack, Types }));
const lines = schema.substr(0, offset1+p1.length+Type.length+p2.length+offset2).split(RX_NEWLINE);
throw Error(`expected ${friendlyName}. `+
`line: ${lines.length}, col: ${lines[lines.length-1].length}`);
};
matchIf('resolver', 'resolver',[
{ test: null != space, next: 'space1' },
{ test: null != word, cb: () => resolver = word },
]) ||
matchIf('open', 'opening parenthesis',[
{ test: null != space, next: 'space2' },
{ test: null != open, cb: () => frame.params = {} },
]) ||
matchIf('param', 'parameter name',[
{ test: null != space, next: 'space3' }, // or space5
{ test: null != close, next: 'close' },
{ test: null != word, cb: () => param = word },
]) ||
matchIf('space4', 'colon preceding parameter validator',[
{ test: null != space },
]) ||
matchIf('pv', 'parameter validator',[
{ test: null != space, next: 'space4' },
{ test: null != word, cb: () => frame.params[param] = word },
]) ||
matchIf('close', 'closing parenthesis',[
{ test: null != space, next: 'space5' },
{ test: null != close },
]) ||
matchIf('space6', 'colon preceding return validator',[
{ test: null != space },
]) ||
matchIf('rv', 'return validator',[
{ test: null != word, cb: () => {
frame.ret = word;
stack[resolver] = frame;
frame = {};
resolver = undefined;
}},
]) ||
(state = 'begin');
});
Types[Type] = stack;
});
return Types;
};
const SCHEMA = `
type Parallel {
read(type string _id string) object
}
type Serial {
auth(name:string, phrase:string):object
deauth():void
create(type:string, doc:object):object
update(type:string, _id:string, doc:object):object
delete(type:string, _id:string):void
}
`;
const QUERY1 = `
Serial {
auth(
name: "mike"
phrase: "turkey"
) {
_id
a {
b {
x
c
}
}
}
}
`;
describe('sgql', () => {
describe('sgql core', () => {
it('can parse schema syntax', () => {
schema = sgql.parseSchema(SCHEMA, { debug: true });
const EXPECTED = {Parallel:{read:{params:{type:'string',_id:'string'},ret:'object'}},Serial:{auth:{params:{name:'string',phrase:'string'},ret:'object'},deauth:{params:{},ret:'void'},create:{params:{type:'string',doc:'object'},ret:'object'},update:{params:{type:'string',_id:'string',doc:'object'},ret:'object'},'delete':{params:{type:'string',_id:'string'},ret:'void'}}};
assert.deepEqual(schema,EXPECTED);
});
it('can parse query syntax', () => {
QUERY1;
});
});
describe('application', () => {
let schema, query;
before(() => {
schema = sgql.parseSchema(SCHEMA);
query = sgql(schema, root).query;
});
it.skip('works', async () => {
const output = await query(QUERY1);
console.log(JSON.stringify(output)); // =>
// {"data":{"auth":{"_id":"abcd-efgh-hij","a":{"b":{"c":"hamster"}}}}}
debugger;
});
});
});
const _ = require('lodash');
// Schemaless GraphQL
// changes from GraphQL:
// - no need for mutation, only query; always parallel. if you want serial execution, wait for reply between queries.
// - context provided to resolvers greatly simplified to: doc, and list of fields needed
// - your resolvers are responsible to perform recursion, or not, and how deep
// - type system simplified out: only resolver name, field names, and return paths
// - return paths may include objects without further qualifying field names
// - return paths may be patterned inclusively or exclusively
// - field resolvers do not have to strictly perform one-query-per-field. your resolver can decide how granular.
// NOTICE: GraphQL is for describing input/output (in-transit) interfaces, NOT necessarily data storage/at-rest interfaces.
const sgql = async (root, query) => {
if (null == query || 'object' !== typeof query) return;
const ctx = { core: sgql.coreResolvers, user: root, errors: [], path: [] };
const response = { data: {} };
for (const key of Object.keys(query)) {
Object.assign(response.data, await sgql.execResolver(key, query[key], ctx));
}
if (ctx.errors.length >= 1) response.errors = ctx.errors;
return response;
};
sgql.execResolver = async (name, opts, ctx) => {
for (const tier of [ctx.core, ctx.user]) {
const resolver = tier[name];
if (null != resolver) {
ctx.path.push(name);
let result;
try {
result = await resolver(opts, ctx);
}
catch(e) {
ctx.errors.push({
error: ('{' === _.get(e, 'message[0]') ? JSON.parse(e.message) : e),
path: [...ctx.path],
// stack: e.stack.split(/[\r\n]/g),
});
}
ctx.path.pop();
return result;
}
}
throw Error(JSON.stringify({
message: `missing resolver: ${name}.`,
alternatives: [...Object.keys(ctx.core), ...Object.keys(ctx.user)],
}));
};
sgql.coreResolvers = {
invoke: async ({ method, params, ret }, ctx) => {
const result = await sgql.execResolver(method, { params, ret }, ctx);
return { [method]: result };
},
alias: async (o, ctx) => {
debugger;
if (null == o || 'object' !== typeof o) return;
const response = {};
for (const k of Object.keys(o)) {
response[k] = await sqgl.execResolver(k, o[k], ctx);
}
return response;
},
};
// TODO: later could implement parser supporting from this to JSON equivalent
// const QUERY1 = "query { auth( name: "mike" phrase: "turkey" ) { _id a { b { x c }}}}";
// TODO: could provide even more concise alternative syntax
// const QUERY1 = "auth(name mike phrase:turkey){_id a{b{x c";
// for now, going with simple elastic.co-style json ast
module.exports = sgql;
const { assert } = require('chai');
const _ = require('lodash');
describe('sqgl + app models', () => {
let root, sgql;
before(async () => {
root = require('../../apps/ui-portal/app/models/ui-graph');
sgql = require('../../shared/models/sgql');
});
const ID1 = '1b1875fb2fd94afab029ed122cea52c4';
it('User._id NOT exists', async () => {
const QUERY1 = {
invoke: { method: 'read', params: { type: 'User', _id: 'z' }, ret: {
_id: true }}};
const output = await sgql(root, QUERY1);
const EXPECT = { data: { read: null }};
assert.deepEqual(output, EXPECT);
});
it('missing resolver', async () => {
const QUERY2 = { invoke: { method: 'something', params: { any: 1 }, ret: { any: 2 }}};
const output = await sgql(root, QUERY2);
assert.deepEqual(output.data, {});
assert.equal(output.errors[0].error.message, 'missing resolver: something.');
});
it('create User error', async () => {
const QUERY3 = {
invoke: { method: 'create', params: { type: 'User', _id: ID1, doc: { username: 'mike', passphrase: 'turkey' } }, ret: {
_id: true }}};
const output = await sgql(root, QUERY3);
const EXPECT = {data:{create:undefined},errors:[{error:{_id:'1b1875fb2fd94afab029ed122cea52c4', message:'Refusing to create record with predefined _id'},path:['invoke','create']}]};
assert.deepEqual(output, EXPECT);
});
let userId;
it('create User', async () => {
const QUERY4 = {
invoke: { method: 'create', params: { type: 'User', doc: { username: 'mike', passphrase: 'turkey' } }, ret: {
_id: true }}};
const output = await sgql(root, QUERY4);
userId = _.get(output, 'data.create._id');
const EXPECT = {data:{create:{_id: _.get(output, 'data.create._id') }}};
assert.deepEqual(output, EXPECT);
});
it('User._id exists', async () => {
const QUERY5 = {
invoke: { method: 'read', params: { type: 'User', _id: userId }, ret: {
_id: true }}};
const output = await sgql(root, QUERY5);
const EXPECT = { data: { read: { _id: userId }}};
assert.deepEqual(output, EXPECT);
});
it('User join .creator', async () => {
const bob = await sgql(root, { invoke: { method: 'create', params: { type: 'User', doc: { username: 'bob', passphrase: 'builder' } }, ret: { _id: true }}});
const bobId = _.get(bob, 'data.create._id');
const sasquatch = await sgql(root, { invoke: { method: 'create', params: { type: 'User', doc: { username: 'sasquatch', passphrase: 'slimjim', createdBy: bobId } }, ret: { _id: true }}});
const sasquatchId = _.get(sasquatch, 'data.create._id');
const QUERY6 = {
invoke: { method: 'read', params: { type: 'User', _id: sasquatchId }, ret: {
_id: true, username: true, createdBy: true, creator: { _id: true, username: true } }}};
const output = await sgql(root, QUERY6);
const EXPECT = { data: { read: { _id: sasquatchId, username: 'sasquatch', createdBy: bobId, creator: { _id: bobId, username: 'bob' }}}};
assert.deepEqual(output, EXPECT);
});
it('Auth', async () => {
const QUERY7 = {
invoke: { method: 'auth', params: { username: 'mike', passphrase: 'turkey' }, ret: {
_id: true, a: { b: { x: true, c: true }}}}};
const output = await sgql(root, QUERY7);
const EXPECT = {"data":{"auth":{"_id":"abcd-efgh-hij","a":{"b":{"c":"hamster"}}}}};
assert.deepEqual(output, EXPECT);
});
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment