diff --git a/src/parser.js b/src/parser.js index c83eb63..6fb4394 100644 --- a/src/parser.js +++ b/src/parser.js @@ -1,7 +1,6 @@ import { Result } from './result.js' import { Iterator, Stream } from './stream.js' -import { curry, head, join, of } from './fn.js' -import { pipe } from 'bitecs' +import { pipe, curry, head, join, of } from './fn.js' class ParseError extends Error { constructor(message, state, source) { @@ -159,6 +158,8 @@ export const map = curry((fn, parser, state) => { return result }) +export const map1 = curry((fn, parser, state) => map(([x]) => fn(x), parser, state)) +export const always = curry((value, parser, state) => map(() => value, parser, state)) export const anyChar = state => { const ch = next(state) diff --git a/src/query-parser/common.js b/src/query-parser/common.js index 829e653..f05e216 100644 --- a/src/query-parser/common.js +++ b/src/query-parser/common.js @@ -2,7 +2,7 @@ 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 { Alias, Identifier, Literal, ObjectPath, Property } from './types.js' -export const Symbol = Object.freeze({ +export const Symbols = Object.freeze({ Bracket: Object.freeze({ Angle: Object.freeze({ Left: char('<'), @@ -23,6 +23,7 @@ export const Symbol = Object.freeze({ }), Colon: char(':'), Comma: char(','), + Equal: char('='), Hyphen: char('-'), Newline: char('\n'), Tab: char('\t'), @@ -34,20 +35,20 @@ export const Symbol = Object.freeze({ export const word = value => seq(skip(ws), value, skip(ws)) export const collect = parser => map(join(''), parser) -export const quoted = collect(seq(skip(Symbol.Quote), until(Symbol.Quote), skip(Symbol.Quote))) +export const quoted = collect(seq(skip(Symbols.Quote), until(Symbols.Quote), skip(Symbols.Quote))) export const number = seq(digit, many(digit)) -export const signed = seq(maybe(any(Symbol.Plus, Symbol.Hyphen)), number) -export const ws = skip(many(any(Symbol.Newline, Symbol.Space, Symbol.Tab))) +export const signed = seq(maybe(any(Symbols.Plus, Symbols.Hyphen)), number) +export const ws = skip(many(any(Symbols.Newline, Symbols.Space, Symbols.Tab))) export const identifier = map(([x]) => new Identifier(x), collect(seq(alpha, many(alphanumeric)))) -const float = map(([n]) => parseFloat(n, 10), collect(seq(signed, Symbol.Period, number))) +const float = map(([n]) => parseFloat(n, 10), collect(seq(signed, Symbols.Period, number))) const integer = map(([n]) => parseInt(n, 10), collect(signed)) const str = quoted const toBoolean = v => v === 'false' ? false : true const boolean = map(([v]) => toBoolean(v), any(string('true'), string('false'))) -const property = map(([x]) => new Property(x), seq(skip(Symbol.Period), identifier)) +const property = map(([x]) => new Property(x), seq(skip(Symbols.Period), identifier)) const accessor = map(x => new ObjectPath(...x), seq(identifier, property, many(property))) export const literal = map(([x]) => new Literal(x), any(float, integer, str, boolean)) diff --git a/src/query-parser/types.js b/src/query-parser/types.js index e82ff1b..3a3b008 100644 --- a/src/query-parser/types.js +++ b/src/query-parser/types.js @@ -11,8 +11,8 @@ class Value { return this.#value.toString() } - get value() { - return this.#value?.value ?? this.#value + valueOf() { + return this.#value?.valueOf() ?? this.#value } equals(other) { @@ -86,6 +86,69 @@ export class Literal { } } +export const None = new Literal(null) + +export class Operator { + #fn + + constructor(fn, length = fn.length) { + this.#fn = fn + this.length = length + } + + #pop(n, stack) { + let result = [] + + for(let i = 0; i < n; i++) { + result.push(stack.pop()) + } + + result.reverse() + return result + } + + apply(stack) { + const args = this.#pop(this.length, stack) + return this.#fn.apply(undefined, args) + } +} + +export class UnaryExpression { + constructor(op, value) { + this.op = op + this.value = value + } +} + +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 class Filter { + constructor(...args) { + this.values = this.#shufflingYard(args) + } + + #shufflingYard(args) { + // do the thing + return args + } +} export class Alias extends Identifier { constructor(value, alias) { @@ -101,7 +164,7 @@ export class ObjectPath extends Identifier { } toString() { - return `${this.value}.${this.path.join('.')}` + return [this.valueOf()].concat(path).join('.') } from(component) { @@ -231,11 +294,10 @@ class ReturnValue { } withEntity(entity) { - return assocPath(this.#value.toString(), this.#getter(entity), {}) + return assocPath(this.#value.toString(), this.#getter(entity), {}) } } - export class KeyValuePair { constructor(key, value) { this.key = key diff --git a/src/query-parser/where.js b/src/query-parser/where.js new file mode 100644 index 0000000..653b289 --- /dev/null +++ b/src/query-parser/where.js @@ -0,0 +1,36 @@ +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 { And, Equal, Filter, GreaterThan, GreaterThanEqual, LesserThan, LesserThanEqual, Literal, None, Not, Or, Xor } from './types.js' + +const { Left, Right } = Symbols.Bracket.Round +const lparen = map1(construct(Literal), Left) +const rparen = map1(construct(Literal), Right) + +// type and existence +export const nil = always(None, noCaseString('null')) +export const is = noCaseString('is') + +// binary +export const gt = always(GreaterThan, char('>')) +export const gte = always(GreaterThanEqual, string('>=')) +export const lt = always(LesserThan, char('<')) +export const lte = always(LesserThanEqual, string('<=')) +export const eq = always(Equal, char('=')) + +export const and = always(And, noCaseString('and')) +export const or = always(Or, noCaseString('or')) +export const xor = always(Xor, noCaseString('xor')) + +export const compare = any(gte, lte, gt, lt, eq) +export const logic = any(and, or, xor) + +// unary +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))) + +const keyword = noCaseString('where') +export const whereClause = seq(skip(keyword), ws, params) diff --git a/tests/query-parser/where.test.js b/tests/query-parser/where.test.js new file mode 100644 index 0000000..c438672 --- /dev/null +++ b/tests/query-parser/where.test.js @@ -0,0 +1,90 @@ +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' + +const l = x => new Literal(x) +describe('where operators', () => { + it('or', () => { + assert.parseOk(or, 'OR', ([actual]) => { + assert.strictEqual(actual.apply([true, true]), true) + assert.strictEqual(actual.apply([false, true]), true) + assert.strictEqual(actual.apply([true, false]), true) + assert.strictEqual(actual.apply([false, false]), false) + }) + }) + + it('and', () => { + assert.parseOk(and, 'AND', ([actual]) => { + assert.strictEqual(actual.apply([true, true]), true) + assert.strictEqual(actual.apply([false, true]), false) + assert.strictEqual(actual.apply([true, false]), false) + assert.strictEqual(actual.apply([false, false]), false) + }) + }) + + it('xor', () => { + assert.parseOk(xor, 'XOR', ([actual]) => { + assert.strictEqual(actual.apply([true, true]), false) + assert.strictEqual(actual.apply([false, true]), true) + assert.strictEqual(actual.apply([true, false]), true) + assert.strictEqual(actual.apply([false, false]), false) + }) + }) + + it('not', () => { + assert.parseOk(not, 'NOT', ([actual]) => { + assert.strictEqual(actual.apply([true]), false) + assert.strictEqual(actual.apply([false]), true) + }) + }) + + it('gt', () => { + assert.parseOk(gt, '>', ([actual]) => { + assert.strictEqual(actual.apply([1, 2]), false) + assert.strictEqual(actual.apply([2, 1]), true) + assert.strictEqual(actual.apply([1, 1]), false) + }) + }) + + it('gte', () => { + assert.parseOk(gte, '>=', ([actual]) => { + assert.strictEqual(actual.apply([1, 2]), false) + assert.strictEqual(actual.apply([2, 1]), true) + assert.strictEqual(actual.apply([1, 1]), true) + }) + }) + + it('lt', () => { + assert.parseOk(lt, '<', ([actual]) => { + assert.strictEqual(actual.apply([1, 2]), true) + assert.strictEqual(actual.apply([2, 1]), false) + assert.strictEqual(actual.apply([1, 1]), false) + }) + }) + + it('lte', () => { + assert.parseOk(lte, '<=', ([actual]) => { + assert.strictEqual(actual.apply([1, 2]), true) + assert.strictEqual(actual.apply([2, 1]), false) + assert.strictEqual(actual.apply([1, 1]), true) + }) + }) + + it('eq', () => { + assert.parseOk(eq, '=', ([actual]) => { + assert.strictEqual(actual.apply([1, 2]), false) + assert.strictEqual(actual.apply([2, 1]), false) + assert.strictEqual(actual.apply([1, 1]), true) + }) + }) +}) + +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) + }) + }) +}) \ No newline at end of file