From 7f04a656c2e2a626dca0e631a708674292ea10f3 Mon Sep 17 00:00:00 2001 From: Kayne Ruse Date: Tue, 22 Sep 2020 08:14:25 +1000 Subject: [PATCH] Split the library into smaller files --- index.js | 319 ---------------------------------- package.json | 2 +- source/build-type-graph.js | 93 ++++++++++ source/index.js | 55 ++++++ source/keywords.json | 1 + source/parse-input.js | 53 ++++++ source/parse-query.js | 110 ++++++++++++ source/utils.js | 26 +++ test-server/package-lock.json | 2 +- test-server/server.js | 4 +- 10 files changed, 342 insertions(+), 323 deletions(-) delete mode 100644 index.js create mode 100644 source/build-type-graph.js create mode 100644 source/index.js create mode 100644 source/keywords.json create mode 100644 source/parse-input.js create mode 100644 source/parse-query.js create mode 100644 source/utils.js diff --git a/index.js b/index.js deleted file mode 100644 index 8b1865e..0000000 --- a/index.js +++ /dev/null @@ -1,319 +0,0 @@ -//reserved keywords that can't be used as identifiers -const keywords = ['type', 'scalar', 'create', 'update', 'delete', 'set', 'match']; - -//the main function to be returned -const main = (schema, handler, options = {}) => { - let typeGraph; - - try { - typeGraph = buildTypeGraph(schema, options); - } - catch(e) { - console.log('Type Graph Error:', e); - return null; - } - - //the receiving function - this will be called multiple times - return async reqBody => { - try { - //parse the query - const tokens = lexify(reqBody, true, options); - let pos = 0; - - //check for keywords - switch(tokens[pos]) { - case 'create': - case 'update': - case 'delete': - return [501, 'Keyword not implemented: ' + tokens[pos]]; - //TODO: implement these keywords - break; - - //no leading keyword - regular query - default: - const [result, endPos] = await parseQuery(handler, tokens, pos, typeGraph); - - //reject the request, despite finishing processing it - if (tokens[endPos]) { - throw 'Unexpected data found at the end of the token list (found ' + tokens[endPos] + ')'; - } - - return [200, result]; - - break; - } - } - catch(e) { - console.log('Error:', e); - return [400, e.stack || e]; - } - }; -}; - -//parse the schema into a type graph -const buildTypeGraph = (schema, options) => { - //the default graph - let graph = { - String: { scalar: true }, - Integer: { scalar: true }, - Float: { scalar: true }, - Boolean: { scalar: true }, - }; - - //parse the schema - const tokens = lexify(schema, false, options); - let pos = 0; - - while (tokens[pos++]) { - //check for keywords - switch(tokens[pos - 1]) { - case 'type': - graph[tokens[pos++]] = parseCompoundType(tokens, pos, options); - - //advance to the end of the compound type - pos = eatBlock(tokens, pos); - - break; - - case 'scalar': - if (keywords.includes(graph[tokens[pos - 1]])) { - throw 'Unexpected keyword ' + graph[tokens[pos - 1]]; - } - - graph[tokens[pos++]] = { scalar: true }; - break; - - default: - throw 'Unknown token ' + tokens[pos - 1]; - } - } - - if (options.debug) { - console.log('Type Graph:\n', graph); - } - - return graph; -}; - -const parseCompoundType = (tokens, pos, options) => { - //format check (not strictly necessary, but it looks nice) - if (tokens[pos] !== '{') { - throw 'Expected \'{\' in compound type definition'; - } - - //graph component to be returned - const compound = {}; - - //for each line of the compound type - while (tokens[pos++] && tokens[pos] !== '}') { - let type = tokens[pos++]; - const name = tokens[pos]; - - //no mangled types or names - checkAlphaNumeric(type); - checkAlphaNumeric(name); - - //can't use keywords - if (keywords.includes(type) || keywords.includes(name)) { - throw 'Unexpected keyword found as type field or type name (' + type + ' ' + name + ')'; - } - - //check for duplicate fields - if (Object.keys(compound).includes(name)) { - throw 'Unexpected duplicate field name'; - } - - //finally, push to the compound definition - compound[name] = { - typeName: type - }; - } - - if (options.debug) { - console.log('Compound Type:\n', compound); - } - - return compound; -}; - -const parseQuery = async (handler, tokens, pos, typeGraph, parent = null) => { - //returns an object result from handler for all custom types - - //determine this type - let queryType; - - //only read past tokens - pos++; - - if (typeGraph[tokens[pos - 1]] && typeGraph[tokens[pos - 1]].scalar) { - queryType = tokens[pos - 1]; - } - - else if (parent && typeGraph[parent.typeName][tokens[pos - 1]]) { - queryType = typeGraph[parent.typeName][tokens[pos - 1]].typeName; - } else { - queryType = tokens[pos - 1]; - } - - if (tokens[pos++] != '{') { - throw 'Expected \'{\' after queried type'; - } - - //the scalars to pass to the handler - these are NEIGHBOURS in the hierarchy - const scalarFields = []; - const deferredCalls = []; //functions (promises) that will be called at the end of this function - - while(tokens[pos++] && tokens[pos - 1] !== '}') { //while not at the end of this block - let match = false; - - if (tokens[pos - 1] === 'match') { - match = true; - pos++; - } - - //prevent using keywords - if (keywords.includes(tokens[pos - 1])) { - throw 'Unexpected keyword ' + tokens[pos - 1]; - } - - //type is a scalar, and can be queried - if (typeGraph[queryType] && typeGraph[queryType][tokens[pos - 1]] && typeGraph[typeGraph[queryType][tokens[pos - 1]].typeName].scalar) { - //push the scalar object to the queryFields - scalarFields.push({ typeName: typeGraph[queryType][tokens[pos - 1]].typeName, name: tokens[pos - 1], match: match ? tokens[pos++] : null }); - - //if I am a scalar child of a match amd I do not match - if (parent && parent.match && !match) { - throw 'Broken match chain in scalar type ' + tokens[pos - 1]; - } - } - - else if (typeGraph[queryType] && typeGraph[queryType][tokens[pos - 1]] && !typeGraph[typeGraph[queryType][tokens[pos - 1]].typeName].scalar) { - const pos2 = pos; //cache the value to keep it from changing - - //recurse - deferredCalls.push(async (result) => { - //if I am a compound child of a match amd I do not match - if (parent && parent.match && !match) { - throw 'Broken match chain in compound type ' + tokens[pos2 - 1]; - } - - const [queryResult, dummyPos] = await parseQuery( - handler, - tokens, - pos2 - 1, - typeGraph, - { typeName: queryType, scalars: scalarFields, context: result, match: match } //parent object (this one) - ); - - return [tokens[pos2 - 1], queryResult, match]; //HACK: match piggybacking on the tuple - }); - - pos = eatBlock(tokens, pos + 2); - } else { - //token is something else? - throw 'Found something not in the type graph: ' + tokens[pos - 1] + " " + (pos - 1); - } - } - - //eat the end bracket - if (tokens[pos - 1] !== '}') { - throw 'Expected \'}\' at the end of query (found ' + tokens[pos - 1] + ')'; - } - - if (!handler[queryType]) { - throw 'Unrecognized type ' + queryType; - } - - let results = handler[queryType](parent, scalarFields); - - //WTF: related to the recusion above - results = await Promise.all(results.map(async res => { - const tuples = await Promise.all(deferredCalls.map(async call => await call(res))); - - if (!tuples.every(tuple => !tuple[2] || tuple[1].length > 0)) { - return []; - } - - tuples.forEach(tuple => res[tuple[0]] = tuple[1]); - - return res; - })); - - results = results.filter(r => Array.isArray(r) && r.length == 0 ? false : true); - return [results, pos]; -}; - -//utils -const checkAlphaNumeric = (str) => { - if (!/^[_a-z][_a-z0-9]*$/i.test(str)) { - throw 'Unexpected string ' + str; - } -}; - -const eatBlock = (tokens, pos) => { - while (tokens[pos++] && tokens[pos - 1] !== '}') { - if (tokens[pos] == '{') { - pos = eatBlock(tokens, pos); - } - } - - if (tokens[pos - 1] !== '}') { //eat the final '}' - throw 'Expected \'}\' while eating block (found ' + tokens[pos - 1] + ')'; - } - - return pos; -}; - -const lexify = (body, allowStrings, options) => { - let current = 0; - tokens = []; - - while(body[current++]) { - switch(body[current - 1]) { - case '{': - case '}': - //push just this symbol - tokens.push(body.substring(current - 1, current)); - break; - - case '"': { - if (!allowStrings) { - throw 'Can\'t lex strings'; - } - - const start = current; - while (body[current++] !== '"') { //find the terminating ' - if (!body[current - 1]) { - throw 'Unterminated string'; - } - } - tokens.push(body.substring(start, current - 1)); - break; - } - - default: { - //ignore whitespace - if (/\s/.test(body[current - 1])) { - break; - } - - //anything else is a multi-character token - const start = current; - while(body[current] && !/[{}"\s]/.test(body[current])) { - current++; - } - tokens.push(body.substring(start - 1, current)); - break; - } - } - } - - if (options.debug) { - console.log('Lexify:\n', tokens); - } - - return tokens; -}; - -//return -module.exports = main; diff --git a/package.json b/package.json index 38375ee..7ef0303 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "sineql", "version": "0.2.1", "description": "A simple to use graphQL clone", - "main": "index.js", + "main": "source/index.js", "scripts": { "test": "cd test-server && npm install && npm run node" }, diff --git a/source/build-type-graph.js b/source/build-type-graph.js new file mode 100644 index 0000000..f1deba7 --- /dev/null +++ b/source/build-type-graph.js @@ -0,0 +1,93 @@ +//reserved keywords that can't be used as identifiers +const keywords = require('./keywords.json'); +const { eatBlock, checkAlphaNumeric } = require('./utils'); +const parseInput = require('./parse-input'); + +//parse the schema into a type graph +const buildTypeGraph = (schema, options) => { + //the default graph + let graph = { + String: { scalar: true }, + Integer: { scalar: true }, + Float: { scalar: true }, + Boolean: { scalar: true }, + }; + + //parse the schema + const tokens = parseInput(schema, false, options); + let pos = 0; + + while (tokens[pos++]) { + //check for keywords + switch(tokens[pos - 1]) { + case 'type': + graph[tokens[pos++]] = parseCompoundType(tokens, pos, options); + + //advance to the end of the compound type + pos = eatBlock(tokens, pos); + + break; + + case 'scalar': + if (keywords.includes(graph[tokens[pos - 1]])) { + throw 'Unexpected keyword ' + graph[tokens[pos - 1]]; + } + + graph[tokens[pos++]] = { scalar: true }; + break; + + default: + throw 'Unknown token ' + tokens[pos - 1]; + } + } + + if (options.debug) { + console.log('Type Graph:\n', graph, '\n'); + } + + return graph; +}; + +//moved this routine to a separate function for clarity +const parseCompoundType = (tokens, pos, options) => { + //format check (not strictly necessary, but it looks nice) + if (tokens[pos] !== '{') { + throw 'Expected \'{\' in compound type definition'; + } + + //graph component to be returned + const compound = {}; + + //for each line of the compound type + while (tokens[pos++] && tokens[pos] !== '}') { + let type = tokens[pos++]; + const name = tokens[pos]; + + //no mangled types or names + checkAlphaNumeric(type); + checkAlphaNumeric(name); + + //can't use keywords + if (keywords.includes(type) || keywords.includes(name)) { + throw 'Unexpected keyword found as type field or type name (' + type + ' ' + name + ')'; + } + + //check for duplicate fields + if (Object.keys(compound).includes(name)) { + throw 'Unexpected duplicate field name'; + } + + //finally, push to the compound definition + compound[name] = { + typeName: type + }; + } + + if (options.debug) { + console.log('Compound Type:\n', compound, '\n'); + } + + return compound; +}; + +module.exports = buildTypeGraph; diff --git a/source/index.js b/source/index.js new file mode 100644 index 0000000..220ff01 --- /dev/null +++ b/source/index.js @@ -0,0 +1,55 @@ +const buildTypeGraph = require('./build-type-graph'); +const parseInput = require('./parse-input'); +const parseQuery = require('./parse-query'); + +//the main function to be returned (sineQL()) +const main = (schema, handler, options = {}) => { + let typeGraph; + + try { + typeGraph = buildTypeGraph(schema, options); + } + catch(e) { + console.log('Type Graph Error:', e); + return null; + } + + //the receiving function (sine()) - this will be called multiple times + return async (reqBody) => { + try { + //parse the query + const tokens = parseInput(reqBody, true, options); + let pos = 0; + + //check for keywords + switch(tokens[pos]) { + case 'create': + case 'update': + case 'delete': + return [501, 'Keyword not implemented: ' + tokens[pos]]; + //TODO: implement these keywords + break; + + //no leading keyword - regular query + default: + const [result, endPos] = await parseQuery(handler, tokens, pos, typeGraph); + + //reject the request, despite finishing processing it + if (tokens[endPos]) { + throw 'Unexpected data found at the end of the token list (found ' + tokens[endPos] + ')'; + } + + return [200, result]; + + break; + } + } + catch(e) { + console.log('Error:', e); + return [400, e.stack || e]; + } + }; +}; + +//return to the caller +module.exports = main; diff --git a/source/keywords.json b/source/keywords.json new file mode 100644 index 0000000..4219b60 --- /dev/null +++ b/source/keywords.json @@ -0,0 +1 @@ +["type", "scalar", "create", "update", "delete", "set", "match"] \ No newline at end of file diff --git a/source/parse-input.js b/source/parse-input.js new file mode 100644 index 0000000..9878d0e --- /dev/null +++ b/source/parse-input.js @@ -0,0 +1,53 @@ +//break the body down into tokens +const parseInput = (body, allowStrings, options) => { + let current = 0; + tokens = []; + + while(body[current++]) { + switch(body[current - 1]) { + case '{': + case '}': + //push just this symbol + tokens.push(body.substring(current - 1, current)); + break; + + case '"': { + if (!allowStrings) { + throw 'Can\'t lex strings'; + } + + const start = current; + while (body[current++] !== '"') { //find the terminating " + if (!body[current - 1]) { + throw 'Unterminated string'; + } + } + tokens.push(body.substring(start, current - 1)); + break; + } + + default: { + //ignore whitespace + if (/\s/.test(body[current - 1])) { + break; + } + + //anything else is a multi-character token + const start = current; + while(body[current] && !/[{}"\s]/.test(body[current])) { + current++; + } + tokens.push(body.substring(start - 1, current)); + break; + } + } + } + + if (options.debug) { + console.log('Input:\n', tokens, '\n'); + } + + return tokens; +}; + +module.exports = parseInput; \ No newline at end of file diff --git a/source/parse-query.js b/source/parse-query.js new file mode 100644 index 0000000..69928a6 --- /dev/null +++ b/source/parse-query.js @@ -0,0 +1,110 @@ +const keywords = require('./keywords.json'); +const { eatBlock } = require('./utils'); + +//returns an object result from handler for all custom types +const parseQuery = async (handler, tokens, pos, typeGraph, parent = null) => { + //only read past tokens + pos++; + + //determine this query's supertype + let queryType; + + if (typeGraph[tokens[pos - 1]] && typeGraph[tokens[pos - 1]].scalar) { + queryType = tokens[pos - 1]; + } + + else if (parent && typeGraph[parent.typeName][tokens[pos - 1]]) { + queryType = typeGraph[parent.typeName][tokens[pos - 1]].typeName; + } else { + queryType = tokens[pos - 1]; + } + + if (tokens[pos++] != '{') { + throw 'Expected \'{\' after queried type'; + } + + //the scalars to pass to the handler - these are NEIGHBOURS in the hierarchy + const scalarFields = []; + const deferredCalls = []; //functions (promises) that will be called at the end of this function + + while(tokens[pos++] && tokens[pos - 1] !== '}') { //while not at the end of this block + let match = false; + + if (tokens[pos - 1] === 'match') { + match = true; + pos++; + } + + //prevent using keywords + if (keywords.includes(tokens[pos - 1])) { + throw 'Unexpected keyword ' + tokens[pos - 1]; + } + + //type is a scalar, and can be queried + if (typeGraph[queryType] && typeGraph[queryType][tokens[pos - 1]] && typeGraph[typeGraph[queryType][tokens[pos - 1]].typeName].scalar) { + //push the scalar object to the queryFields + scalarFields.push({ typeName: typeGraph[queryType][tokens[pos - 1]].typeName, name: tokens[pos - 1], match: match ? tokens[pos++] : null }); + + //if I am a scalar child of a match amd I do not match + if (parent && parent.match && !match) { + throw 'Broken match chain in scalar type ' + tokens[pos - 1]; + } + } + + else if (typeGraph[queryType] && typeGraph[queryType][tokens[pos - 1]] && !typeGraph[typeGraph[queryType][tokens[pos - 1]].typeName].scalar) { + const pos2 = pos; //cache the value to keep it from changing + + //recurse + deferredCalls.push(async (result) => { + //if I am a compound child of a match amd I do not match + if (parent && parent.match && !match) { + throw 'Broken match chain in compound type ' + tokens[pos2 - 1]; + } + + const [queryResult, dummyPos] = await parseQuery( + handler, + tokens, + pos2 - 1, + typeGraph, + { typeName: queryType, scalars: scalarFields, context: result, match: match } //parent object (this one) + ); + + return [tokens[pos2 - 1], queryResult, match]; //HACK: match piggybacking on the tuple + }); + + pos = eatBlock(tokens, pos + 2); + } else { + //token is something else? + throw 'Found something not in the type graph: ' + tokens[pos - 1] + " " + (pos - 1); + } + } + + //eat the end bracket + if (tokens[pos - 1] !== '}') { + throw 'Expected \'}\' at the end of query (found ' + tokens[pos - 1] + ')'; + } + + if (!handler[queryType]) { + throw 'Unrecognized type ' + queryType; + } + + let results = handler[queryType](parent, scalarFields); + + //WTF: related to the recusion above + results = await Promise.all(results.map(async res => { + const tuples = await Promise.all(deferredCalls.map(async call => await call(res))); + + if (!tuples.every(tuple => !tuple[2] || tuple[1].length > 0)) { + return []; + } + + tuples.forEach(tuple => res[tuple[0]] = tuple[1]); + + return res; + })); + + results = results.filter(r => Array.isArray(r) && r.length == 0 ? false : true); + return [results, pos]; +}; + +module.exports = parseQuery; \ No newline at end of file diff --git a/source/utils.js b/source/utils.js new file mode 100644 index 0000000..f55f702 --- /dev/null +++ b/source/utils.js @@ -0,0 +1,26 @@ +//legal identifiers can only begin with letters or underscore - can contain numbers otherwise +const checkAlphaNumeric = (str) => { + if (!/^[_a-z][_a-z0-9]*$/i.test(str)) { + throw 'Unexpected string ' + str; + } +}; + +//eats a "block" from the tokens list +const eatBlock = (tokens, pos) => { + while (tokens[pos++] && tokens[pos - 1] !== '}') { + if (tokens[pos] == '{') { + pos = eatBlock(tokens, pos); + } + } + + if (tokens[pos - 1] !== '}') { //eat the final '}' + throw 'Expected \'}\' while eating block (found ' + tokens[pos - 1] + ')'; + } + + return pos; +}; + +module.exports = { + checkAlphaNumeric, + eatBlock, +} \ No newline at end of file diff --git a/test-server/package-lock.json b/test-server/package-lock.json index 9462689..2dfcfab 100644 --- a/test-server/package-lock.json +++ b/test-server/package-lock.json @@ -1,6 +1,6 @@ { "name": "test-server", - "version": "0.2.0", + "version": "0.2.1", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/test-server/server.js b/test-server/server.js index b461e78..2cf41a7 100644 --- a/test-server/server.js +++ b/test-server/server.js @@ -11,9 +11,9 @@ app.use(bodyParser.text()); //test the library const schema = require('./schema.js'); const handler = require('./handler.js'); -const sineQL = require('../index.js'); +const sineQL = require('../source/index.js'); -const sine = sineQL(schema, handler); +const sine = sineQL(schema, handler, { debug: true }); //open the end app.post('/sineql', async (req, res) => {