Last active
August 11, 2019 23:05
-
-
Save ancms2600/526b64b262561fb4ca1be824c2faec7f to your computer and use it in GitHub Desktop.
Schemaless GraphQL (in JavaScript)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
}); | |
}); | |
}); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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