From f154a5fc9ce94ddad532672051f546b3689c39f9 Mon Sep 17 00:00:00 2001 From: kitsunecafe Date: Sun, 10 Nov 2024 03:15:43 -0600 Subject: [PATCH] break apart parser logic; add tests --- package.json | 2 +- src/parser.js | 48 +++++++++++++++++++------ src/query.js | 72 +------------------------------------- src/query/common.js | 51 +++++++++++++++++++++++++++ src/query/match.js | 52 +++++++++++++++++++++++++++ src/query/return.js | 17 +++++++++ src/query/types.js | 56 +++++++++++++++++++++++++++++ src/query/use.js | 11 ++++++ tests/query/common.test.js | 30 ++++++++++++++++ tests/query/match.test.js | 21 +++++++++++ tests/query/return.test.js | 23 ++++++++++++ tests/query/use.test.js | 19 ++++++++++ tests/run.js | 10 ++++++ 13 files changed, 330 insertions(+), 82 deletions(-) create mode 100644 src/query/common.js create mode 100644 src/query/match.js create mode 100644 src/query/return.js create mode 100644 src/query/types.js create mode 100644 src/query/use.js create mode 100644 tests/query/common.test.js create mode 100644 tests/query/match.test.js create mode 100644 tests/query/return.test.js create mode 100644 tests/query/use.test.js create mode 100644 tests/run.js diff --git a/package.json b/package.json index 5de5863..526ab53 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "start": "node src/index.js", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "node --test ./tests" }, "keywords": [], "author": "", diff --git a/src/parser.js b/src/parser.js index b129cde..6fa4a2b 100644 --- a/src/parser.js +++ b/src/parser.js @@ -63,10 +63,6 @@ const succeed = curry((v, [p, rest]) => Result.Ok([p.concat(of(v)), rest])) const fail = curry((msg, state, err = undefined) => Result.Err(new ParseError(msg, state, err))) const next = ([, rest]) => rest.next() -// b.len - a.len -// b.slice(-diff) -//const difflen = result.value[0].length - parsed.length -//console.log(difflen, result.value[0], result.value[0].slice(-difflen)) const diff = ([a], [b]) => b.slice(-Math.max(0, b.length - a.length)) const tokenize = str => str.split('') const tokenizeInto = curry((fn, str) => tokenize(str).map(v => fn(v))) @@ -100,6 +96,21 @@ export const any = (...parsers) => (state) => { return fail('no matching parsers', state) } +export const until = curry((parser, state) => { + let acc = Result.Ok(state) + + while (acc.isOk()) { + const result = parser(clone(acc.value)) + if (result.isOk()) { + break + } else { + acc = anyChar(acc.value) + } + } + + return acc +}) + export const many = curry((parser, state) => { let result = Result.Ok(state) @@ -146,6 +157,11 @@ export const map = curry((fn, parser, state) => { }) +export const anyChar = state => { + const ch = next(state) + return !!ch ? succeed(ch, state) : fail('end of stream', state) +} + export const char = curry((ch, state) => { return next(state) === ch ? succeed(ch, state) : fail(`could not parse ${ch}`, state) }) @@ -158,15 +174,27 @@ export const string = curry((str, state) => map(join(''), seq(...tokenizeInto(char, str)))(state) ) -export const anyChar = curry((str, state) => +export const anyOf = curry((str, state) => any(...tokenizeInto(char, str))(state) ) -export const digit = anyChar(Digits) -export const lowerAlpha = anyChar(LowerAlpha) -export const upperAlpha = anyChar(UpperAlpha) -export const alpha = anyChar(Alpha) -export const alphanumeric = anyChar(Alphanumeric) +export const between = curry((left, value, right, state) => ( + seq(skip(left), value, skip(right))(state) +)) + +export const separated = curry((a, sep, b, state) => ( + seq(a, skip(sep), b)(state) +)) + +export const list = curry((sep, value, state) => ( + seq(value, many(seq(skip(sep), value)))(state) +)) + +export const digit = anyOf(Digits) +export const lowerAlpha = anyOf(LowerAlpha) +export const upperAlpha = anyOf(UpperAlpha) +export const alpha = anyOf(Alpha) +export const alphanumeric = anyOf(Alphanumeric) export const noCaseString = curry((str, state) => ( seq(...tokenizeInto(noCaseChar, str))(state) diff --git a/src/query.js b/src/query.js index 8d4e56b..e98bc09 100644 --- a/src/query.js +++ b/src/query.js @@ -1,73 +1,3 @@ -import { join } from './fn.js' -import { alpha, alphanumeric, any, char, many, map, noCaseString, parse, seq, skip } from './parser.js' - // MATCH (a:Label)-[e:Label]->(b:Label) // RETURN a, b - -const Identifier = (name, label) => ({ name, label }) - -const ObjectType = Object.freeze({ - Node: 0, - Edge: 1 -}) - -const GraphObject = ({ name, label }, type, properties = []) => Object.freeze({ - name, - label, - type, - properties -}) - -const whitespace = char(' ') -const matchKeyword = noCaseString('match') -const returnKeyword = noCaseString('return') - -const name = map( - join(''), - seq(alpha, many(alphanumeric)) -) -const colon = char(':') -const label = seq(skip(colon), name) - -const id = any( - map( - ([name, label]) => Identifier(name, label), - seq(name, label) - ), - map( - ([name]) => Identifier(name), - name - ), - map( - ([label]) => Identifier(undefined, label), - label - ) -) - -const lparen = char('(') -const rparen = char(')') -const node = map( - ([id]) => GraphObject(id, ObjectType.Node), - seq(skip(lparen), id, skip(rparen)) -) - -const lsquare = char('[') -const rsquare = char(']') -const edge = map( - ([id]) => GraphObject(id, ObjectType.Edge), - seq(skip(lsquare), id, skip(rsquare)) -) - -const hyphen = char('-') -const rchevron = char('>') -const arrow = seq(hyphen, rchevron) -const relationship = seq(skip(hyphen), edge, skip(arrow), node) - -const matchParameters = seq(node, many(relationship)) -const matchStmt = seq(skip(matchKeyword), skip(many(whitespace)), matchParameters) - -const query = 'MATCH (:Label)-[e:Label]->(b:Label)' -const result = parse(matchStmt, query) -console.log(result.value[0]) - - +// diff --git a/src/query/common.js b/src/query/common.js new file mode 100644 index 0000000..df80b55 --- /dev/null +++ b/src/query/common.js @@ -0,0 +1,51 @@ +import { join } from '../fn.js' +import { alpha, alphanumeric, any, char, digit, list, many, map, maybe, noCaseString, separated, seq, skip, string, until } from '../parser.js' + +export const Symbol = Object.freeze({ + Bracket: Object.freeze({ + Angle: Object.freeze({ + Left: char('<'), + Right: char('>') + }), + Curly: Object.freeze({ + Left: char('{'), + Right: char('}'), + }), + Round: Object.freeze({ + Left: char('('), + Right: char(')'), + }), + Square: Object.freeze({ + Left: char('['), + Right: char(']'), + }), + }), + Colon: char(':'), + Comma: char(','), + Hyphen: char('-'), + Newline: char('\n'), + Period: char('.'), + Plus: char('+'), + Space: char(' '), + Quote: char('"') +}) + + + +export const collect = parser => map(join(''), parser) +export const quoted = collect(seq(skip(Symbol.Quote), until(Symbol.Quote), skip(Symbol.Quote))) +export const number = seq(digit, many(digit)) +export const signed = seq(maybe(any(Symbol.Plus, Symbol.Hyphen)), number) +export const toBoolean = v => v === 'false' ? false : true + +export const Literal = Object.freeze({ + Float: map(([n]) => parseFloat(n, 10), collect(seq(signed, Symbol.Period, number))), + Integer: map(([n]) => parseInt(n, 10), collect(signed)), + String: quoted, + Boolean: map(([v]) => toBoolean(v), any(string('true'), string('false'))) +}) + +export const literal = any(...Object.values(Literal)) +export const ws = skip(many(Symbol.Space)) +export const identifier = map(join(''), seq(alpha, many(alphanumeric))) +export const accessor = seq(identifier, list(Symbol.Period, identifier)) diff --git a/src/query/match.js b/src/query/match.js new file mode 100644 index 0000000..bfae9f3 --- /dev/null +++ b/src/query/match.js @@ -0,0 +1,52 @@ +import { Symbol, ws } from './common.js' +import { Identifier, Node, Edge, KeyValuePair } from './types.js' +import { curry, join } from '../fn.js' +import { alpha, alphanumeric, many, maybe, map, parse, seq, skip, between, noCaseString, separated, until, list } from '../parser.js' + +const { Bracket, Colon, Comma, Hyphen, Quote } = Symbol + +const name = map( + join(''), + seq(alpha, many(alphanumeric)) +) +const label = seq(skip(Colon), name) + +const trim = curry((parser, state) => ( + between(ws, parser, ws, state) +)) + +const bracketed = curry((value, { Left, Right }, state) => ( + between(trim(Left), value, trim(Right), state) +)) + +const quoted = map(join(''), seq(skip(Quote), until(Quote), skip(Quote))) +const kvp = map( + ([k, v]) => new KeyValuePair(k, v), + separated(name, trim(Colon), quoted) +) +const kvps = list(trim(Comma), kvp) +export const properties = bracketed(kvps, Bracket.Curly) + +const id = map( + ([name, label]) => new Identifier(name, label), + seq(maybe(name), label) +) + + +export const node = map( + ([id, ...properties]) => new Node(id, properties), + bracketed(seq(id, ws, maybe(properties)), Bracket.Round) +) + +export const edge = map( + ([id]) => new Edge(id), + bracketed(id, Bracket.Square) +) + +const rightArrow = seq(Hyphen, Bracket.Angle.Right) +const relationship = seq(skip(Hyphen), edge, skip(rightArrow), node) + +const keyword = noCaseString('match') +const params = seq(node, many(relationship)) +export const statement = seq(skip(keyword), ws, params) + diff --git a/src/query/return.js b/src/query/return.js new file mode 100644 index 0000000..29f5708 --- /dev/null +++ b/src/query/return.js @@ -0,0 +1,17 @@ +import { ReturnValues } from './types.js' +import { list, map, maybe, noCaseString, parse, seq, skip } from '../parser.js' +import { accessor, identifier, Symbol, ws } from './common.js' + +const as = noCaseString('as') +const alias = seq(as, identifier) +const aliasId = seq(accessor, maybe(alias)) + +const keyword = noCaseString('return') + +const params = map( + values => new ReturnValues(values), + seq(list(seq(Symbol.Comma, ws), aliasId)) +) + +export const statement = seq(skip(keyword), ws, params) + diff --git a/src/query/types.js b/src/query/types.js new file mode 100644 index 0000000..eedf341 --- /dev/null +++ b/src/query/types.js @@ -0,0 +1,56 @@ +export class Identifier { + constructor(name, label) { + if (label == null) { + this.label = name + } else { + this.name = name + this.label = label + } + } +} + +export class SelectedGraph { + constructor(identifier) { + this.identifier = identifier + } +} + +class GraphObject { + constructor({ name, label }, properties = []) { + this.name = name + this.label = label + this.properties = properties + } +} + +export class Node extends GraphObject { + constructor(id, properties) { + super(id, properties) + } +} + +export class Edge extends GraphObject { + constructor(id, properties) { + super(id, properties) + } +} + +export class ReturnValues extends Array { + constructor(args) { + super(args) + } +} + +export class KeyValuePair { + constructor(key, value) { + this.key = key + this.value = value + } +} + +export class Alias { + constructor(value, alias) { + this.value = value + this.alias = alias + } +} diff --git a/src/query/use.js b/src/query/use.js new file mode 100644 index 0000000..67a3b9f --- /dev/null +++ b/src/query/use.js @@ -0,0 +1,11 @@ +import { identifier, ws } from './common.js' +import { map, noCaseString, parse, seq, skip } from '../parser.js' +import { SelectedGraph } from './types.js' + +const keyword = noCaseString('use') + +export const statement = map( + ([graph]) => new SelectedGraph(graph), + seq(skip(keyword), ws, identifier) +) + diff --git a/tests/query/common.test.js b/tests/query/common.test.js new file mode 100644 index 0000000..31cd17d --- /dev/null +++ b/tests/query/common.test.js @@ -0,0 +1,30 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert' +import { digit, many, parse, seq } from '../../src/parser.js' +import * as Common from '../../src/query/common.js' + +describe('common parser library', () => { + it('literals should match literal types', () => { + const tBool = parse(Common.literal, 'true') + const fBool = parse(Common.literal, 'false') + const uint = parse(Common.literal, '5') + const posInt = parse(Common.literal, '+16') + const negInt = parse(Common.literal, '-710') + const ufloat = parse(Common.literal, '2.1') + const posFloat = parse(Common.literal, '+0.1') + const negFloat = parse(Common.literal, '-12.01') + const string = parse(Common.literal, '"akjsdfuaio"') + + const v = r => r.value[0][0] + assert.strictEqual(v(tBool), true) + assert.strictEqual(v(fBool), false) + assert.strictEqual(v(uint), 5) + assert.strictEqual(v(posInt), 16) + assert.strictEqual(v(negInt), -710) + assert.strictEqual(v(ufloat), 2.1) + assert.strictEqual(v(posFloat), 0.1) + assert.strictEqual(v(negFloat), -12.01) + assert.strictEqual(v(string), 'akjsdfuaio') + }) +}) + diff --git a/tests/query/match.test.js b/tests/query/match.test.js new file mode 100644 index 0000000..da2fc45 --- /dev/null +++ b/tests/query/match.test.js @@ -0,0 +1,21 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert' +import { parse } from '../../src/parser.js' +import { node, edge, properties, statement } from '../../src/query/match.js' + +//describe('match parser', () => { +// it('node should match a label', () => { +// const result = parse(node, '(:Node)') +// console.log(result.error.state) +// assert(result.isOk()) +// }) +// +// it('properties should do something', () => { +// const q = '(a:Node { name: "Rowan", species: "???" })' +// console.log( +// `parsing "${q}"\n`, +// parse(node, q).value[0][0] +// ) +// }) +//}) + diff --git a/tests/query/return.test.js b/tests/query/return.test.js new file mode 100644 index 0000000..0d83ce9 --- /dev/null +++ b/tests/query/return.test.js @@ -0,0 +1,23 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert' +import { parse } from '../../src/parser.js' +import { statement } from '../../src/query/return.js' + +describe('return parser', () => { + it('should collect a single value for a query to return', () => { + const result = parse(statement, 'RETURN folklore AS f') + console.log(result.error) + assert(result.isOk()) + const [[selected]] = result.value + assert.deepStrictEqual(selected, ['folklore']) + }) + + //it('should collect multiple values for a query to return', () => { + // const result = parse(statement, 'RETURN sybil, mercury, rowan') + // console.log(result.error.state[0]) + // assert(result.isOk()) + // const [[selected]] = result.value + // assert.deepStrictEqual(selected, ['sybil', 'mercury', 'rowan']) + //}) +}) + diff --git a/tests/query/use.test.js b/tests/query/use.test.js new file mode 100644 index 0000000..7c3cd0a --- /dev/null +++ b/tests/query/use.test.js @@ -0,0 +1,19 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert' +import { parse } from '../../src/parser.js' +import { statement } from '../../src/query/use.js' + +describe('use parser', () => { + it('should select a graph to query', () => { + const result = parse(statement, 'USE default') + assert(result.isOk()) + const [[selected]] = result.value + assert.strictEqual(selected.identifier, 'default') + }) + + it('should return an error if no graph identifier is provided', () => { + const result = parse(statement, 'USE') + assert(result.isErr()) + }) +}) + diff --git a/tests/run.js b/tests/run.js new file mode 100644 index 0000000..2261065 --- /dev/null +++ b/tests/run.js @@ -0,0 +1,10 @@ +import { tap } from 'node:test/reporters' +import { run } from 'node:test' +import path from 'node:path' + +run({ files: [path.resolve('./tests/query/use.js')] }) + .on('test:fail', () => { + process.exitCode = 1 + }) + .compose(tap) + .pipe(process.stdout)