diff --git a/package.json b/package.json index 8d70f8e..6c49ad2 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "start": "node src/index.js", - "test": "node --test ./tests/**/*.test.js" + "test": "node --test ./tests/*.test.js ./tests/**/*.test.js" }, "keywords": [], "author": "", diff --git a/src/parser.js b/src/parser.js index 6fb4394..c268e1c 100644 --- a/src/parser.js +++ b/src/parser.js @@ -209,8 +209,23 @@ export const maybe = curry((parser, state) => { return result.isOk() ? result : succeed([], state) }) +export const manyUntil = curry((failParser, parser, state) => { + let result = Result.Ok(state) + + while (failParser(clone(result.unwrap())).isErr()) { + const res = parser(clone(result.unwrap())) + if (res.isOk()) { + result = res + } else { + break + } + } + + return result +}) + export const eof = state => { - return state[1].done() ? succeed([], state) : fail('eof did not match', state) + return state[1].done() ? succeed([], state) : fail('not at end of file', state) } export const parse = curry((parser, input) => pipe(ParserState, parser)(input)) diff --git a/src/query-parser/common.js b/src/query-parser/common.js index f05e216..d7f6565 100644 --- a/src/query-parser/common.js +++ b/src/query-parser/common.js @@ -1,5 +1,5 @@ -import { join } from '../fn.js' -import { alpha, alphanumeric, any, char, digit, list, many, map, maybe, noCaseString, separated, seq, skip, string, until } from '../parser.js' +import { join, values } from '../fn.js' +import { alpha, alphanumeric, any, char, digit, many, map, maybe, noCaseString, seq, skip, string, until } from '../parser.js' import { Alias, Identifier, Literal, ObjectPath, Property } from './types.js' export const Symbols = Object.freeze({ @@ -33,7 +33,14 @@ export const Symbols = Object.freeze({ Quote: char('"') }) -export const word = value => seq(skip(ws), value, skip(ws)) +export const Keyword = Object.freeze({ + Use: noCaseString('use'), + Match: noCaseString('match'), + Where: noCaseString('where'), + Return: noCaseString('return') +}) + +export const word = value => seq(ws, value, ws) export const collect = parser => map(join(''), parser) export const quoted = collect(seq(skip(Symbols.Quote), until(Symbols.Quote), skip(Symbols.Quote))) export const number = seq(digit, many(digit)) @@ -57,4 +64,5 @@ export const baseValue = any(literal, accessor, identifier) const as = noCaseString('as') export const alias = map(([value, name]) => new Alias(value, name), seq(baseValue, word(skip(as)), identifier)) export const value = any(alias, baseValue) +export const keyword = any(...values(Keyword)) diff --git a/src/query-parser/index.js b/src/query-parser/index.js index 170b3ab..ef2c18a 100644 --- a/src/query-parser/index.js +++ b/src/query-parser/index.js @@ -1,30 +1,32 @@ -import { construct, head, ifElse, is, pipe, prepend } from '../fn.js' +import { filter, find, is } from '../fn.js' import { map, maybe, seq } from '../parser.js' import { word } from './common.js' -import { matchClause } from './match.js' -import { returnClause } from './return.js' -import { Query, SelectedGraph } from './types.js' import { useClause } from './use.js' +import { matchClause } from './match.js' +import { whereClause } from './where.js' +import { returnClause } from './return.js' +import { Filter, Match, Query, ReturnValues, SelectedGraph } from './types.js' -const hasUseClause = pipe( - head, - is(SelectedGraph) -) +const use = find(is(SelectedGraph)) +const match = filter(is(Match)) +const where = filter(is(Filter)) +const rv = find(is(ReturnValues)) -const constructQuery = construct(Query) -const noUseQuery = pipe(prepend(undefined), constructQuery) - -const collect = ifElse( - hasUseClause, - constructQuery, - noUseQuery -) +const collect = clauses => { + return new Query( + use(clauses), + match(clauses), + where(clauses), + rv(clauses) + ) +} export const query = map( collect, seq( - maybe(useClause), + maybe(word(useClause)), word(matchClause), + maybe(word(whereClause)), word(returnClause) ) ) diff --git a/src/query-parser/match.js b/src/query-parser/match.js index 1aa600d..e7a87f3 100644 --- a/src/query-parser/match.js +++ b/src/query-parser/match.js @@ -1,9 +1,9 @@ -import { identifier, literal, Symbol, ws } from './common.js' -import { Node, Edge, KeyValuePair, Label, Name, Direction, DirectedEdge, Relationship, Component } from './types.js' -import { construct, curry, is } from '../fn.js' +import { identifier, Keyword, literal, Symbols, ws } from './common.js' +import { Node, Edge, KeyValuePair, Label, Name, Direction, DirectedEdge, Relationship, Component, Match } from './types.js' +import { construct, curry, head, is } from '../fn.js' import { many, maybe, map, seq, skip, between, noCaseString, separated, list, any } from '../parser.js' -const { Bracket, Colon, Comma, Hyphen } = Symbol +const { Bracket, Colon, Comma, Hyphen } = Symbols const name = map( construct(Name), @@ -67,7 +67,7 @@ const makeRelationship = args => { const len = args.length - 1 if (len <= 0) { - return args + return new Match(head(args)) } let right = args[len] @@ -78,14 +78,13 @@ const makeRelationship = args => { right = new Relationship(left, edge, right) } - return right + return new Match(right) } -const keyword = noCaseString('match') const params = map( makeRelationship, seq(node, many(relationship)) ) -export const matchClause = seq(skip(keyword), ws, params) +export const matchClause = seq(skip(Keyword.Match), ws, params) diff --git a/src/query-parser/return.js b/src/query-parser/return.js index 9869323..f2ab057 100644 --- a/src/query-parser/return.js +++ b/src/query-parser/return.js @@ -1,13 +1,11 @@ import { ReturnValues } from './types.js' -import { list, map, noCaseString, seq, skip } from '../parser.js' -import { value, Symbol, ws } from './common.js' - -const keyword = noCaseString('return') +import { list, map, seq, skip } from '../parser.js' +import { value, Symbols, ws, Keyword } from './common.js' const params = map( x => new ReturnValues(...x), - seq(list(seq(ws, Symbol.Comma, ws), value)) + seq(list(seq(ws, Symbols.Comma, ws), value)) ) -export const returnClause = seq(skip(keyword), ws, params) +export const returnClause = seq(skip(Keyword.Return), ws, params) diff --git a/src/query-parser/types.js b/src/query-parser/types.js index 3a3b008..1e2fe82 100644 --- a/src/query-parser/types.js +++ b/src/query-parser/types.js @@ -1,18 +1,19 @@ -import { assocPath, is } from '../fn.js' +import { assocPath, curry, identity, is, last, mergeRightDeep, path } from '../fn.js' +import { Stream } from '../stream.js' class Value { - #value + value constructor(value) { - this.#value = value + this.value = value } toString() { - return this.#value.toString() + return this.value.toString() } valueOf() { - return this.#value?.valueOf() ?? this.#value + return this.value?.valueOf() ?? this.value } equals(other) { @@ -20,9 +21,9 @@ class Value { return false } - const value = this.value + const value = this.valueOf() return value === other - || value === other.value + || value === other.valueOf() } } @@ -31,24 +32,13 @@ export class Identifier extends Value { super(value) } - from(component) { - return component - } -} - -export class ObjectIdentifier extends Identifier { - constructor(value) { - super(value) + from(node) { + return node[this.valueOf()] } - from(component) { - return new Proxy(component, { - get(target, entity, _receiver) { - return Object.fromEntries( - Object.entries(target).map(([k, v]) => [k, v[entity]]) - ) - } - }) + pluck(node) { + const key = this.valueOf() + return { [key]: node[key] } } } @@ -57,8 +47,8 @@ export class Property extends Identifier { super(value) } - from(component) { - return component[this.value] + pluck(node) { + return node[this.valueOf()] } } @@ -84,69 +74,166 @@ export class Literal { constructor(value) { this.value = value } + + from(node) { + return this.valueOf() + } + + valueOf() { + return this.value.valueOf() + } } export const None = new Literal(null) -export class Operator { +export class Function { #fn - constructor(fn, length = fn.length) { + constructor(name, fn, length = fn.length) { + this.name = name this.#fn = fn this.length = length } - #pop(n, stack) { - let result = [] + #pop(stack) { + const result = [] + const len = stack.length - 1 + const n = Math.min(this.length, len) - for(let i = 0; i < n; i++) { + for(let i = len; i >= len - n; i--) { result.push(stack.pop()) } result.reverse() return result } - + apply(stack) { - const args = this.#pop(this.length, stack) + const args = this.#pop(stack) return this.#fn.apply(undefined, args) } } -export class UnaryExpression { - constructor(op, value) { - this.op = op - this.value = value +const Associativity = Object.freeze({ + Left: 0, + Right: 1 +}) + +export class Operator extends Function { + constructor(name, fn, priority, associativity = Associativity.Left, length = fn.length) { + super(name, fn, length) + this.priority = priority + this.associativity = associativity + } + + compare(other) { + if (other.priority > this.priority) { + return 1 + } else if (other.priority === this.priority) { + if (this.associativity === Associativity.Left) { + return 1 + } else { + return 0 + } + } else { + return -1 + } } } -export class BinaryExpression { - constructor(left, op, right) { - this.left = left - this.op = op - this.right = right - } -} - -export const Is = new Operator((x, y) => typeof x === y) -export const Not = new Operator(x => !x) -export const And = new Operator((x, y) => x && y) -export const Or = new Operator((x, y) => x || y) -export const Xor = new Operator((x, y) => x != y) -export const GreaterThan = new Operator((x, y) => x > y) -export const GreaterThanEqual = new Operator((x, y) => x >= y) -export const LesserThan = new Operator((x, y) => x < y) -export const LesserThanEqual = new Operator((x, y) => x <= y) -export const Equal = new Operator((x, y) => x === y) +export const Is = new Operator('IS', (x, y) => typeof x === y, 2, Associativity.Right) +export const Not = new Operator('NOT', x => !x, 2, Associativity.Right) +export const And = new Operator('AND', (x, y) => x && y, 11) +export const Or = new Operator('OR', (x, y) => x || y, 12) +export const Xor = new Operator('XOR', (x, y) => x != y, 14) +export const GreaterThan = new Operator('GT', (x, y) => x > y, 6) +export const GreaterThanEqual = new Operator('GTE', (x, y) => x >= y, 6) +export const LesserThan = new Operator('LT', (x, y) => x < y, 6) +export const LesserThanEqual = new Operator('LTE', (x, y) => x <= y, 6) +export const Equal = new Operator('EQ', (x, y) => x === y, 7) export class Filter { constructor(...args) { - this.values = this.#shufflingYard(args) + this.values = this.#shuntingYard(args) } - #shufflingYard(args) { - // do the thing - return args + #takeWhile(fn, stack) { + const result = [] + let val = last(stack) + + while (stack.length > 0 && fn(val)) { + result.push(stack.pop()) + val = last(stack) + } + return result + } + + #takeUntil(fn, stack) { + return this.#takeWhile(x => !fn(x), stack) + } + + #shuntingYard(args) { + const stream = new Stream(args) + const output = [] + const operators = [] + const valueOf = curry((x, y) => y.valueOf() === x) + const leftParen = valueOf('(') + const comma = valueOf(',') + const rightParen = valueOf(')') + + while (!stream.done()) { + const next = stream.next() + if (is(Function, next)) { + if (is(Operator, next)) { + const ops = this.#takeUntil(op => leftParen(op) && next.compare(op) <= 0, operators) + output.push.apply(output, ops) + } + operators.push(next) + } else if (comma(next)) { + output.push.apply(output, this.#takeUntil(leftParen, operators)) + } else if (leftParen(next)) { + operators.push(next) + } else if (rightParen(next)) { + const ops = this.#takeUntil(leftParen, operators) + output.push.apply(output, ops) + if (!leftParen(operators.pop())) { + throw new SyntaxError('mismatched parenthesis') + } + if (is(Function, last(operators))) { + output.push(operators.pop()) + } + } else { + output.push(next) + } + } + + const rest = this.#takeUntil(leftParen, operators) + output.push.apply(output, rest) + + if (operators.length > 0) { + throw new SyntaxError('mismatched parenthesis') + } + + return output + } + + passes(nodes) { + return nodes.every(node => { + const values = this.values.map(v => v.from ? v.from(node.values) : v) + const stream = new Stream(values) + const stack = [] + while (!stream.done()) { + const next = stream.next() + if (is(Operator, next)) { + const result = next.apply(stack) + stack.unshift(result) + } else { + stack.push(next) + } + } + + return stack.every(identity) + }) } } @@ -155,6 +242,10 @@ export class Alias extends Identifier { super(value) this.alias = alias } + + pluck(node) { + return { [this.alias.valueOf()]: this.value.from(node) } + } } export class ObjectPath extends Identifier { @@ -164,11 +255,16 @@ export class ObjectPath extends Identifier { } toString() { - return [this.valueOf()].concat(path).join('.') + return [this.valueOf()].concat(this.path).join('.') } - from(component) { - return this.path.reduce((acc, v) => v.from(acc), component) + from(node) { + return path(this.toString(), node) + } + + pluck(node) { + const values = this.path.reduce((acc, v) => ({ [v.valueOf()]: v.pluck(acc) }), node[this.valueOf()]) + return { [this.valueOf()]: values } } equals(value) { @@ -236,65 +332,33 @@ export class Relationship { } } -const isExactly = (Type, value) => Object.getPrototypeOf(value) === Type.prototype - export class ReturnValues { constructor(...args) { - this.values = args.map(x => isExactly(Identifier, x) ? new ObjectIdentifier(x) : x) + this.values = args } - #isTag(type) { - type && Object.getOwnPropertySymbols(type).find( - (s) => s.description === 'tagStore' - ) || false + from(values) { + // TODO: pass with entries + const returns = Object.keys(values).map(key => this.#pluck(values, key)) + return Object.assign({}, ...returns) } - #forComponentType(value, type) { - if (this.#isTag(type)) { - return {} - } else if (typeof type === 'function') { - return type - } else { - return entity => value.from(type)[entity] - } - } - - // TODO: adjust froms to accent entity, return data with built up objects as necessary - from(components) { - const values = components.map(cmp => ({ ...cmp, rv: this.findName(cmp.name) })) - .map(({ type, rv }) => new ReturnValue(rv, this.#forComponentType(rv, type))) - - return entity => values.reduce((acc, rv) => Object.assign(acc, rv.withEntity(entity)), {}) + #pluck(values, name) { + return this.findName(name) + .map(value => value.pluck(values)) + .reduce(mergeRightDeep, {}) } find(fn) { return this.values.find(fn) } - findName(name) { - return this.find(value => value.equals(name)) - } - filter(fn) { return this.values.filter(fn) } - includes(value) { - return this.find(x => x.equals(value)) - } -} - -class ReturnValue { - #value - #getter - - constructor(value, getter) { - this.#value = value - this.#getter = getter - } - - withEntity(entity) { - return assocPath(this.#value.toString(), this.#getter(entity), {}) + findName(value) { + return this.filter(x => x.equals(value)) } } @@ -305,10 +369,21 @@ export class KeyValuePair { } } +export class Match { + constructor(value) { + this.value = value + } + + valueOf() { + return this.value + } +} + export class Query { - constructor(use, match, returnValues) { + constructor(use, matches, filters, returnValues) { this.use = use - this.match = match + this.matches = matches + this.filters = filters this.returnValues = returnValues } } diff --git a/src/query-parser/use.js b/src/query-parser/use.js index ad96d18..ebadf8e 100644 --- a/src/query-parser/use.js +++ b/src/query-parser/use.js @@ -1,11 +1,11 @@ -import { identifier, ws } from './common.js' +import { identifier, Keyword, ws } from './common.js' import { map, noCaseString, seq, skip } from '../parser.js' import { SelectedGraph } from './types.js' -const keyword = noCaseString('use') +export const useKeyword = noCaseString('use') export const useClause = map( ([graph]) => new SelectedGraph(graph), - seq(skip(keyword), ws, identifier) + seq(skip(Keyword.Use), ws, identifier) ) diff --git a/src/query-parser/where.js b/src/query-parser/where.js index 653b289..72e9419 100644 --- a/src/query-parser/where.js +++ b/src/query-parser/where.js @@ -1,6 +1,6 @@ import { construct } from '../fn.js' -import { always, any, char, many1, map, map1, noCaseString, seq, skip, string } from '../parser.js' -import { Symbols, value, word, ws } from './common.js' +import { always, any, char, many1, manyUntil, map, map1, noCaseString, seq, skip, string } from '../parser.js' +import { keyword, Keyword, Symbols, value, word, ws } from './common.js' import { And, Equal, Filter, GreaterThan, GreaterThanEqual, LesserThan, LesserThanEqual, Literal, None, Not, Or, Xor } from './types.js' const { Left, Right } = Symbols.Bracket.Round @@ -30,7 +30,6 @@ export const not = always(Not, noCaseString('not')) export const unary = any(not) export const expr = any(lparen, rparen, compare, logic, unary, value) -export const params = map(construct(Filter), many1(word(expr))) +export const params = map(construct(Filter), manyUntil(keyword, word(expr))) -const keyword = noCaseString('where') -export const whereClause = seq(skip(keyword), ws, params) +export const whereClause = seq(skip(Keyword.Where), ws, params) diff --git a/src/query.js b/src/query.js index 5aa033b..e546049 100644 --- a/src/query.js +++ b/src/query.js @@ -1,32 +1,31 @@ import { defineQuery, hasComponent } from 'bitecs' import { query as q } from './query-parser/index.js' import { parseAll } from './parser.js' -import { curry, of, is, map, path, pipe, always, assocPath, prop, identity } from './fn.js' +import { curry, of, is, map, always, assocPath, prop, identity, mergeRightDeep, reduce } from './fn.js' import { Relationship } from './query-parser/types.js' const nodeComponent = component => node => { - const label = node.label?.value - const name = node.name?.value - const type = label && component[node.label.value] || identity + const label = node.label?.valueOf() + const name = node.name?.valueOf() + const type = label && component[label] || identity return { name, label, type } } -const notEntity = ({ label }) => label != null && label !== 'Entity' +const notEntity = ({ label }) => label != null && label.valueOf() !== 'Entity' -const prepareQuery = (query, component) => { - const { match, returnValues } = query +const prepareQuery = (match, component) => { const relationship = is(Relationship, match) if (relationship) { const { from: f, to: t, edge: e } = match - const [from, to, edge] = map(q => prepareQuery({ match: q, returnValues }, component), [f, t, e]) + const [from, to, edge] = map(match => prepareQuery(match, component), [f, t, e]) return { from, to, edge, relationship, schema: query } } else { const components = match.components.map(nodeComponent(component)) const types = components.filter(notEntity).map(prop('type')) - return { components, relationship, getReturns: returnValues.from(components), query: defineQuery(types), schema: query } + return { components, relationship, query: defineQuery(types), schema: match } } } @@ -95,27 +94,47 @@ const executeQuery = curry((query, world) => { } }) +const isTag = (type) => ( + type && Object.getOwnPropertySymbols(type).find( + (s) => s.description === 'tagStore' + ) || false +) -const resolveNode = curry((node, obj) => { - const { entity, query: { getReturns } } = node - return { ...obj, ...getReturns(entity) } +const getValue = curry((type, entity) => { + if (isTag(type)) { + return {} + } else if (typeof type === 'function') { + return type(entity) + } else { + return Object.fromEntries( + Object.entries(type).map(([k, v]) => [k, v[entity]]) + ) + } }) +const resolveNode = node => { + const values = node.query.components.map(({ name, type }) => ({ [name]: getValue(type, node.entity) })) + return { + ...node, + values: Object.assign({}, ...values) + } +} + const resolveRelationship = edges => { const { entity, query } = edges const Edge = query.edge.components[0].type const to = query.to?.from ?? query.to - return pipe( + return [ resolveNode({ entity: Edge.from[entity], query: query.from }), resolveNode({ entity, query: query.edge }), resolveNode({ entity: Edge.to[entity], query: to }), - )({}) + ] } -const resolveReturns = query => { +const resolveValues = query => { return query.map(edges => { - const returns = edges.map(edge => { + const values = edges.map(edge => { if (edge.query.relationship) { return resolveRelationship(edge) } else { @@ -123,17 +142,28 @@ const resolveReturns = query => { } }) - return Object.assign({}, ...returns) + return values.flat() }) } +const filterValues = curry((filters, values) => { + return values.filter(v => filters.every(f => f.passes(v))) +}) + +const resolveReturns = curry((returnValues, values) => { + return values.map(x => x.map(y => returnValues.from(y.values))) +}) + export const query = curry((input, { world, component }) => { - return parseAll(q, input).map(({ use, ...rest }) => { - const useWorld = world[use?.value ?? 'default'] - const preparedQuery = prepareQuery(rest, component) + return parseAll(q, input).map(({ use, matches, filters, returnValues, ...rest }) => { + const useWorld = world[use?.valueOf() ?? 'default'] + const match = matches[0].valueOf() + const preparedQuery = prepareQuery(match, component) const results = executeQuery(preparedQuery, useWorld) - const returns = resolveReturns(results) - return returns + const values = resolveValues(results) + const filtered = filterValues(filters, values) + const returns = resolveReturns(returnValues, filtered) + return map(reduce(mergeRightDeep, {}), returns) }) }) diff --git a/src/stream.js b/src/stream.js index 51fc305..b115948 100644 --- a/src/stream.js +++ b/src/stream.js @@ -33,13 +33,16 @@ export class Iterator { } done() { - return !!this.peek() + return this.peek() == null } take(n) { let accumulator = [] for (let i = 0; i < n; i++) { + if(this.done()) { + break + } accumulator.push(this.next()) } @@ -54,10 +57,10 @@ export class Iterator { class ArrayIterator extends Iterator { static { Iterator.handledTypes.set(Array, ArrayIterator) } - #index = -1 + #index = 0 #value - constructor(value, index = -1) { + constructor(value, index = 0) { super() if (!Array.isArray) { @@ -70,8 +73,9 @@ class ArrayIterator extends Iterator { next() { if (this.#index <= this.#value.length) { + const result = this.#value[this.#index] this.#index += 1 - return this.#value[this.#index] + return result } } @@ -93,6 +97,8 @@ class StringIterator extends ArrayIterator { } export class Stream extends Iterator { + iterator + constructor(value) { super() if (!value.next || !typeof value.next === 'function') { @@ -114,6 +120,10 @@ export class Stream extends Iterator { return this.iterator.peek() } + done() { + return this.iterator.done() + } + clone() { return new Stream(this.iterator.clone()) } diff --git a/tests/query-parser/match.test.js b/tests/query-parser/match.test.js index 96d8c6b..e3c86ac 100644 --- a/tests/query-parser/match.test.js +++ b/tests/query-parser/match.test.js @@ -2,6 +2,7 @@ import { describe, it } from 'node:test' import assert from '../assert.js' import { node, edge, matchClause } from '../../src/query-parser/match.js' import { makeDirectedEdge, makeEdge, makeNode, makeRelationship } from '../utils.js' +import { Match } from '../../src/query-parser/types.js' describe('node', () => { it('should match a node with a name, label, and properties', () => { @@ -69,58 +70,58 @@ describe('edge', () => { describe('MATCH keyword', () => { it('should match a single node with no relationships', () => { assert.parseOk(matchClause, 'MATCH (:Label)', ([actual]) => { - const expected = makeNode(undefined, 'Label') + const expected = new Match(makeNode(undefined, 'Label')) assert.deepEqual(actual, expected) }) assert.parseOk(matchClause, 'MATCH (node:Label)', ([actual]) => { - const expected = makeNode('node', 'Label') + const expected = new Match(makeNode('node', 'Label')) assert.deepEqual(actual, expected) }) assert.parseOk(matchClause, 'MATCH (node:Label { prop: true, value: "test" })', ([actual]) => { - const expected = makeNode('node', 'Label', [['prop', true], ['value', 'test']]) + const expected = new Match(makeNode('node', 'Label', [['prop', true], ['value', 'test']])) assert.deepEqual(actual, expected) }) }) it('should match nodes with a relationship to another another node', () => { assert.parseOk(matchClause, 'MATCH (:Node)-[:Edge]->(:Node)', ([actual]) => { - const expected = makeRelationship( + const expected = new Match(makeRelationship( makeNode(undefined, 'Node'), makeDirectedEdge(undefined, 'Edge', 1), makeNode(undefined, 'Node'), - ) + )) assert.deepEqual(actual, expected) }) assert.parseOk(matchClause, 'MATCH (a:Node)-[e:Edge]->(b:Node)', ([actual]) => { - const expected = makeRelationship( + const expected = new Match(makeRelationship( makeNode('a', 'Node'), makeDirectedEdge('e', 'Edge', 1), makeNode('b', 'Node'), - ) + )) assert.deepEqual(actual, expected) }) assert.parseOk(matchClause, 'MATCH (a:Node { db: 0.7 })-[e:Edge]->(b:Node { db: 0.95 })', ([actual]) => { - const expected = makeRelationship( + const expected = new Match(makeRelationship( makeNode('a', 'Node', [['db', 0.7]]), makeDirectedEdge('e', 'Edge', 1), makeNode('b', 'Node', [['db', 0.95]]), - ) + )) assert.deepEqual(actual, expected) }) assert.parseOk(matchClause, 'MATCH (:Node { db: 0.7 })-[:Edge]->(:Node { db: 0.95 })', ([actual]) => { - const expected = makeRelationship( + const expected = new Match(makeRelationship( makeNode(undefined, 'Node', [['db', 0.7]]), makeDirectedEdge(undefined, 'Edge', 1), makeNode(undefined, 'Node', [['db', 0.95]]), - ) + )) assert.deepEqual(actual, expected) }) @@ -128,7 +129,7 @@ describe('MATCH keyword', () => { it('should handle multiple relationships', () => { assert.parseOk(matchClause, 'MATCH (player:Player)-[:Knows]->(a:NPC)-[:Knows]->(b:NPC)', ([actual]) => { - const expected = makeRelationship( + const expected = new Match(makeRelationship( makeNode('player', 'Player'), makeDirectedEdge(undefined, 'Knows', 1), makeRelationship( @@ -136,7 +137,7 @@ describe('MATCH keyword', () => { makeDirectedEdge(undefined, 'Knows', 1), makeNode('b', 'NPC'), ) - ) + )) assert.deepEqual(actual, expected) }) diff --git a/tests/query-parser/query.test.js b/tests/query-parser/query.test.js index 48c980f..3a583d2 100644 --- a/tests/query-parser/query.test.js +++ b/tests/query-parser/query.test.js @@ -1,22 +1,25 @@ import { describe, it } from 'node:test' import assert from '../assert.js' import { query } from '../../src/query-parser/index.js' -import { Alias, Identifier, ObjectPath, Query, ReturnValues, SelectedGraph } from '../../src/query-parser/types.js' +import { Alias, Identifier, Match, ObjectPath, Query, ReturnValues, SelectedGraph } from '../../src/query-parser/types.js' import { makeNode, makeRelationship, makeRightEdge } from '../utils.js' +import { map } from '../../src/fn.js' -const path = (...v) => new ObjectPath(...v) -const alias = (...v) => new Alias(...v) +const path = (...x) => new ObjectPath(...x) +const alias = (...x) => new Alias(...x) const identifier = n => new Identifier(n) const graph = n => new SelectedGraph(identifier(n)) -const returnVals = (...v) => new ReturnValues(...v) -const q = (u, m, rv) => new Query(u, m, rv) +const returnVals = (...x) => new ReturnValues(...x) +const matches = (...args) => map(x => new Match(x), args) +const q = (u, m, w, rv) => new Query(u, m, w, rv) describe('query', () => { it('should match a node', () => { assert.parseOk(query, 'MATCH (node:Label) RETURN node', ([actual]) => { const expected = q( undefined, // no use clause - makeNode('node', 'Label'), + matches(makeNode('node', 'Label')), + [], returnVals(identifier('node')) ) @@ -28,11 +31,12 @@ describe('query', () => { assert.parseOk(query, 'MATCH (rown:Creature)-[:Petpats]->(kbity:NetCat) RETURN rown, kbity', ([actual]) => { const expected = q( undefined, // no use clause - makeRelationship( + matches(makeRelationship( makeNode('rown', 'Creature'), makeRightEdge(undefined, 'Petpats'), makeNode('kbity', 'NetCat'), - ), + )), + [], returnVals(identifier('rown'), identifier('kbity')) ) @@ -44,7 +48,8 @@ describe('query', () => { assert.parseOk(query, 'USE aminals MATCH (kbity:Cat) RETURN kbity', ([actual]) => { const expected = q( graph('aminals'), - makeNode('kbity', 'Cat'), + matches(makeNode('kbity', 'Cat')), + [], returnVals(identifier('kbity')) ) @@ -56,11 +61,12 @@ describe('query', () => { assert.parseOk(query, 'USE aminals MATCH (kbity:Cat { type: "cute" })-[:Eats { schedule: "daily" }]->(snacc:Feesh { type: "vegan", weight: 0.7 }) RETURN kbity.name AS name, snacc AS food', ([actual]) => { const expected = q( graph('aminals'), - makeRelationship( + matches(makeRelationship( makeNode('kbity', 'Cat', [['type', 'cute']]), makeRightEdge(undefined, 'Eats', [['schedule', 'daily']]), makeNode('snacc', 'Feesh', [['type', 'vegan'], ['weight', 0.7]]) - ), + )), + [], returnVals( alias(path(identifier('kbity'), identifier('name')), identifier('name')), alias(identifier('snacc'), identifier('food')) diff --git a/tests/query-parser/where.test.js b/tests/query-parser/where.test.js index c438672..28a38c5 100644 --- a/tests/query-parser/where.test.js +++ b/tests/query-parser/where.test.js @@ -1,7 +1,8 @@ import { describe, it } from 'node:test' import assert from '../assert.js' import { and, eq, expr, gt, gte, lt, lte, not, or, params, whereClause, xor } from '../../src/query-parser/where.js' -import { And, Equal, Filter, GreaterThan, GreaterThanEqual, LesserThan, LesserThanEqual, Literal, Or } from '../../src/query-parser/types.js' +import { And, Equal, Filter, GreaterThan, GreaterThanEqual, Identifier, LesserThan, LesserThanEqual, Literal, ObjectPath, Or, Property } from '../../src/query-parser/types.js' +import { parse } from '../../src/parser.js' const l = x => new Literal(x) describe('where operators', () => { @@ -82,9 +83,25 @@ describe('where operators', () => { describe('WHERE keyword', () => { it('handles complex filters', () => { - assert.parseOk(whereClause, 'WHERE 1.1 > 5 AND (1 = 500 OR 2 > 3) OR 5 <= 2', ([actual]) => { - const expected = new Filter(l(1.1), GreaterThan, l(5), And, l('('), l(1), Equal, l(500), Or, l(2), GreaterThan, l(3), l(')'), Or, l(5), LesserThanEqual, l(2)) - assert.deepEqual(actual, expected) + assert.parseOk(whereClause, 'WHERE h.max > 500 AND (h.current < 250 OR a.value = 10) OR a.value <= 2', ([actual]) => { + const expected = [ + new ObjectPath(new Identifier('h'), new Property('max')), + new Literal(500), + GreaterThan, + new ObjectPath(new Identifier('h'), new Property('current')), + new Literal(250), + LesserThan, + new ObjectPath(new Identifier('a'), new Property('value')), + Or, + new Literal(10), + Equal, + And, + new ObjectPath(new Identifier('a'), new Property('value')), + Or, + new Literal(2), + LesserThanEqual + ] + assert.deepEqual(actual.values, expected) }) }) }) \ No newline at end of file diff --git a/tests/query.test.js b/tests/query.test.js index 132417c..17c9199 100644 --- a/tests/query.test.js +++ b/tests/query.test.js @@ -11,16 +11,19 @@ const create = (world, ...components) => { return entity } -const relate = (world, a, type, ...b) => { - return b.map(v => { +const relate = (world, from, type, ...b) => { + return b.map(to => { const edge = addEntity(world) addComponent(world, type, edge) - type.from[edge] = a - type.to[edge] = v + update(type, { from, to }, edge) return edge }) } +const update = (type, values, entity) => { + Object.entries(values).forEach(([k, v]) => { type[k][entity] = v }) +} + describe('query', () => { before(() => { const world = { default: createWorld() } @@ -55,7 +58,7 @@ describe('query', () => { relate(world, b, Knows, a) // 9 relate(world, c, Knows, player) // 10 - // tag components should return an object object + // tag components should return an empty object assert.deepEqual( query('MATCH (player:Player) RETURN player', engine).unwrap(), [{ player: {} }] @@ -88,12 +91,12 @@ describe('query', () => { // an unspecified component should return an Entity assert.deepEqual( - query('MATCH (e, h:Health) WHERE e, h.max', engine).unwrap(), + query('MATCH (e, h:Health) RETURN e, h.max', engine).unwrap(), [{ e: 11, h: { max: 50 } }] ) assert.deepEqual( - query('MATCH (e:Entity, h:Health) WHERE e, h.max', engine).unwrap(), + query('MATCH (e:Entity, h:Health) RETURN e, h.max', engine).unwrap(), [{ e: 11, h: { max: 50 } }] ) }) @@ -101,14 +104,28 @@ describe('query', () => { it('should support filtering', () => { const world = engine.world.default const { Player, Health } = engine.component - const player = create(world, Player, Health) + const player = create(world, Player, Health) // 12 + const enemy = create(world, Health) // 13 - Health.max[player] = 50 - Health.current[player] = 25 + update(Health, { max: 50, current: 25 }, player) + update(Health, { max: 50, current: 35 }, enemy) assert.deepEqual( - query('MATCH (h:Health) WHERE h.current < 30 RETURN h.max', engine).unwrap(), - [{ e: 11, h: { max: 50 } }] + query('MATCH (e, h:Health) WHERE h.current < 30 RETURN e, h.max', engine).unwrap(), + [{ e: 12, h: { max: 50 } }] + ) + + assert.deepEqual( + query('MATCH (e, h:Health) WHERE e = 13 AND h.max = 50 OR h.current < 25 RETURN e, h.max', engine).unwrap(), + [{ e: 13, h: { max: 50 } }] + ) + + assert.deepEqual( + query('MATCH (e, h:Health) WHERE h.max = 50 OR h.current < 50 RETURN e, h.max AS maxHealth', engine).unwrap(), + [ + { e: 12, maxHealth: 50 }, + { e: 13, maxHealth: 50 } + ] ) }) })