break apart parser logic; add tests
This commit is contained in:
parent
34d09432c1
commit
f154a5fc9c
13 changed files with 330 additions and 82 deletions
|
@ -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": "",
|
||||
|
|
|
@ -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)
|
||||
|
|
72
src/query.js
72
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])
|
||||
|
||||
|
||||
//
|
||||
|
|
51
src/query/common.js
Normal file
51
src/query/common.js
Normal file
|
@ -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))
|
52
src/query/match.js
Normal file
52
src/query/match.js
Normal file
|
@ -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)
|
||||
|
17
src/query/return.js
Normal file
17
src/query/return.js
Normal file
|
@ -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)
|
||||
|
56
src/query/types.js
Normal file
56
src/query/types.js
Normal file
|
@ -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
|
||||
}
|
||||
}
|
11
src/query/use.js
Normal file
11
src/query/use.js
Normal file
|
@ -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)
|
||||
)
|
||||
|
30
tests/query/common.test.js
Normal file
30
tests/query/common.test.js
Normal file
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
21
tests/query/match.test.js
Normal file
21
tests/query/match.test.js
Normal file
|
@ -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]
|
||||
// )
|
||||
// })
|
||||
//})
|
||||
|
23
tests/query/return.test.js
Normal file
23
tests/query/return.test.js
Normal file
|
@ -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'])
|
||||
//})
|
||||
})
|
||||
|
19
tests/query/use.test.js
Normal file
19
tests/query/use.test.js
Normal file
|
@ -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())
|
||||
})
|
||||
})
|
||||
|
10
tests/run.js
Normal file
10
tests/run.js
Normal file
|
@ -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)
|
Loading…
Reference in a new issue