break apart parser logic; add tests

This commit is contained in:
Rowan 2024-11-10 03:15:43 -06:00
parent 34d09432c1
commit f154a5fc9c
13 changed files with 330 additions and 82 deletions

View file

@ -5,7 +5,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "node src/index.js", "start": "node src/index.js",
"test": "echo \"Error: no test specified\" && exit 1" "test": "node --test ./tests"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",

View file

@ -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 fail = curry((msg, state, err = undefined) => Result.Err(new ParseError(msg, state, err)))
const next = ([, rest]) => rest.next() 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 diff = ([a], [b]) => b.slice(-Math.max(0, b.length - a.length))
const tokenize = str => str.split('') const tokenize = str => str.split('')
const tokenizeInto = curry((fn, str) => tokenize(str).map(v => fn(v))) 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) 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) => { export const many = curry((parser, state) => {
let result = Result.Ok(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) => { export const char = curry((ch, state) => {
return next(state) === ch ? succeed(ch, state) : fail(`could not parse ${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) map(join(''), seq(...tokenizeInto(char, str)))(state)
) )
export const anyChar = curry((str, state) => export const anyOf = curry((str, state) =>
any(...tokenizeInto(char, str))(state) any(...tokenizeInto(char, str))(state)
) )
export const digit = anyChar(Digits) export const between = curry((left, value, right, state) => (
export const lowerAlpha = anyChar(LowerAlpha) seq(skip(left), value, skip(right))(state)
export const upperAlpha = anyChar(UpperAlpha) ))
export const alpha = anyChar(Alpha)
export const alphanumeric = anyChar(Alphanumeric) 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) => ( export const noCaseString = curry((str, state) => (
seq(...tokenizeInto(noCaseChar, str))(state) seq(...tokenizeInto(noCaseChar, str))(state)

View file

@ -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) // MATCH (a:Label)-[e:Label]->(b:Label)
// RETURN a, b // 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
View 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
View 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
View 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
View 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
View 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)
)

View 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
View 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]
// )
// })
//})

View 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
View 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
View 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)