From 6cbd59c6ec45902411b9a0d617a12659dbbed6b3 Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Sat, 15 Aug 2020 14:07:45 -0700 Subject: [PATCH] Implement CASE operator This also modifies the behavior of AND/OR operators to strictly return true/false/null whereas previously they could return the value of the first truthy/false operand. The purpose of this change is to maintain expression equivalency before and after minimization. --- lib/common/boolean-expression.ts | 518 +++++++++++++++++++++---------- lib/common/expression-parser.ts | 57 +++- lib/common/expression.ts | 264 +++++++++------- lib/mongodb-functions.ts | 4 + lib/ui/db.ts | 3 + npm-shrinkwrap.json | 6 +- package.json | 2 +- ui/filter-component.ts | 17 +- 8 files changed, 598 insertions(+), 273 deletions(-) diff --git a/lib/common/boolean-expression.ts b/lib/common/boolean-expression.ts index 65b5629..e6aa62e 100644 --- a/lib/common/boolean-expression.ts +++ b/lib/common/boolean-expression.ts @@ -21,6 +21,7 @@ import * as BI from "./bigint"; import { espresso, complement, tautology } from "yebool"; import { Expression } from "../types"; import { map } from "./expression-parser"; +import { and, evaluateCallback } from "./expression"; const ZERO = BI.BigInt(0); const ONE = BI.BigInt(1); @@ -420,20 +421,42 @@ class OrSynth extends BoolExprSynth { private exprSynths: BoolExprSynth[]; public constructor(...e: BoolExprSynth[]) { super(); - this.exprSynths = e; + this.exprSynths = e.filter((ee) => !(ee instanceof FalseSynth)); + const unpacked: BoolExprSynth[] = []; + this.exprSynths = this.exprSynths.filter((ee) => { + if (ee instanceof OrSynth) { + unpacked.push(...ee.exprSynths); + return false; + } + return true; + }); + this.exprSynths.push(...unpacked); } public true(context: Context): Minterm[] { + if (this.exprSynths.length === 0) return []; + if (this.exprSynths.length === 1) return this.exprSynths[0].true(context); + if (this.exprSynths.some((e) => e instanceof TrueSynth)) return [[]]; return this.exprSynths.map((e) => e.true(context)).flat(); } public false(context: Context): Minterm[] { + if (this.exprSynths.length === 0) return [[]]; + if (this.exprSynths.length === 1) return this.exprSynths[0].false(context); + if ( + this.exprSynths.some( + (e) => e instanceof TrueSynth || e instanceof NullSynth + ) + ) + return []; return complement( this.exprSynths.map((e) => complement(e.false(context))).flat() ); } public null(context: Context): Minterm[] { + if (this.exprSynths.length === 0) return []; + if (this.exprSynths.length === 1) return this.exprSynths[0].null(context); + const n = this.exprSynths.map((e) => e.null(context)).flat(); const t = this.exprSynths.map((e) => e.true(context)).flat(); - const f = this.exprSynths.map((e) => e.false(context)).flat(); - return complement([...t, ...f]); + return complement([...complement(n), ...t]); } } @@ -441,20 +464,109 @@ class AndSynth extends BoolExprSynth { private exprSynths: BoolExprSynth[]; public constructor(...e: BoolExprSynth[]) { super(); - this.exprSynths = e; + this.exprSynths = e.filter((ee) => !(ee instanceof TrueSynth)); + const unpacked: BoolExprSynth[] = []; + this.exprSynths = this.exprSynths.filter((ee) => { + if (ee instanceof AndSynth) { + unpacked.push(...ee.exprSynths); + return false; + } + return true; + }); + this.exprSynths.push(...unpacked); } public true(context: Context): Minterm[] { + if (this.exprSynths.length === 0) return [[]]; + if (this.exprSynths.length === 1) return this.exprSynths[0].true(context); + if ( + this.exprSynths.some( + (e) => e instanceof FalseSynth || e instanceof NullSynth + ) + ) + return []; return complement( this.exprSynths.map((e) => complement(e.true(context))).flat() ); } public false(context: Context): Minterm[] { + if (this.exprSynths.length === 0) return []; + if (this.exprSynths.length === 1) return this.exprSynths[0].false(context); + if (this.exprSynths.some((e) => e instanceof FalseSynth)) return [[]]; return this.exprSynths.map((e) => e.false(context)).flat(); } public null(context: Context): Minterm[] { - const t = this.exprSynths.map((e) => e.true(context)).flat(); + if (this.exprSynths.length === 0) return []; + if (this.exprSynths.length === 1) return this.exprSynths[0].null(context); + const n = this.exprSynths.map((e) => e.null(context)).flat(); const f = this.exprSynths.map((e) => e.false(context)).flat(); - return complement([...t, ...f]); + return complement([...complement(n), ...f]); + } +} + +class CaseSynth extends BoolExprSynth { + private exprSynths: BoolExprSynth[]; + public constructor(e: BoolExprSynth[]) { + super(); + this.exprSynths = e; + } + public true(context: Context): Minterm[] { + const minterms: Minterm[] = []; + const cumulative: Minterm[] = []; + for (let i = 0; i < this.exprSynths.length; i += 2) { + const w = this.exprSynths[i].true(context); + const t = this.exprSynths[i + 1].true(context); + minterms.push( + ...complement([...cumulative, ...complement(w), ...complement(t)]) + ); + if (i < this.exprSynths.length - 2) { + cumulative.push( + ...complement([ + ...this.exprSynths[i].false(context), + ...this.exprSynths[i].null(context), + ]) + ); + } + } + return minterms; + } + public false(context: Context): Minterm[] { + const minterms: Minterm[] = []; + const cumulative: Minterm[] = []; + for (let i = 0; i < this.exprSynths.length; i += 2) { + const w = this.exprSynths[i].true(context); + const t = this.exprSynths[i + 1].false(context); + minterms.push( + ...complement([...cumulative, ...complement(w), ...complement(t)]) + ); + if (i < this.exprSynths.length - 2) { + cumulative.push( + ...complement([ + ...this.exprSynths[i].false(context), + ...this.exprSynths[i].null(context), + ]) + ); + } + } + return minterms; + } + public null(context: Context): Minterm[] { + const minterms: Minterm[] = []; + const cumulative: Minterm[] = []; + for (let i = 0; i < this.exprSynths.length; i += 2) { + const w = this.exprSynths[i].true(context); + const t = this.exprSynths[i + 1].null(context); + minterms.push( + ...complement([...cumulative, ...complement(w), ...complement(t)]) + ); + cumulative.push( + ...complement([ + ...this.exprSynths[i].false(context), + ...this.exprSynths[i].null(context), + ]) + ); + } + minterms.push(...complement([...cumulative])); + return minterms; } } @@ -470,134 +582,203 @@ const SWAPPED_OPS = { "<=": ">=", }; -export function normalize(expr: Expression): Expression { - expr = map(expr, (exp) => { - if (!Array.isArray(exp)) return exp; - const op = exp[0]; +function normalizeCallback(exp: Expression): Expression { + if (!Array.isArray(exp)) return exp; + const op = exp[0]; - if (op === "+") { - const args = exp.slice(1).map((e) => { - if (e instanceof Polynomial) return e; - else if (typeof e === "number") return Polynomial.fromConstant(e); - else return Polynomial.fromIndeterminate(e); - }); - return (args.reduce( - (previousValue, currentValue) => previousValue.add(currentValue), - ADDITIVE_IDENTITY - ) as unknown) as Expression; - } else if (op === "*") { - const args = exp.slice(1).map((e) => { - if (e instanceof Polynomial) return e; - else if (typeof e === "number") return Polynomial.fromConstant(e); - else return Polynomial.fromIndeterminate(e); - }); - - return (args.reduce( - (previousValue, currentValue) => previousValue.multiply(currentValue), - MULTIPLICATIVE_IDENTITY - ) as unknown) as Expression; - } else if (op === "-") { - const args = exp.slice(1).map((e) => { - if (e instanceof Polynomial) return e; - else if (typeof e === "number") return Polynomial.fromConstant(e); - else return Polynomial.fromIndeterminate(e); - }); - return (args.reduce((previousValue, currentValue) => - previousValue.subtract(currentValue) - ) as unknown) as Expression; - } else if (op === "/") { - const args = exp.slice(1).map((e) => { - if (e instanceof Polynomial) return e; - else if (typeof e === "number") return Polynomial.fromConstant(e); - else return Polynomial.fromIndeterminate(e); - }); - return (args.reduce((previousValue, currentValue) => - previousValue.divide(currentValue) - ) as unknown) as Expression; - } else if (["=", "<>", ">", ">=", "<", "<="].includes(op)) { - let lhs: Polynomial, rhs: Polynomial; - if (exp[1] instanceof Polynomial) lhs = exp[1]; - else if (typeof exp[1] === "number") - lhs = Polynomial.fromConstant(exp[1]); - - if (exp[2] instanceof Polynomial) rhs = exp[2]; - else if (typeof exp[2] === "number") - rhs = Polynomial.fromConstant(exp[2]); - - if (lhs || rhs) { - if (!lhs) lhs = Polynomial.fromIndeterminate(exp[1]); - if (!rhs) rhs = Polynomial.fromIndeterminate(exp[2]); - - lhs = lhs.subtract(rhs); - rhs = lhs.constant().negation(); - lhs = lhs.add(rhs); - - if (!lhs.terms.length) { - exp = [op, JSON.parse(lhs.toString()), JSON.parse(rhs.toString())]; - - if (op === "=") return exp[1] === exp[2]; - else if (op === "<>") return exp[1] !== exp[2]; - else if (op === ">") return exp[1] > exp[2]; - else if (op === ">=") return exp[1] >= exp[2]; - else if (op === "<") return exp[1] < exp[2]; - else if (op === "<=") return exp[1] <= exp[2]; - } else { - let flipOp = 1; - - const n = lhs.terms[0].coefficientNumerator; - const d = lhs.terms[0].coefficientDenominator; - - if (BI.lt(n, ZERO) || BI.lt(d, ZERO)) flipOp *= -1; - - const reciprocal = new Polynomial([ - { - indeterminates: new Indeterminates(), - coefficientNumerator: d, - coefficientDenominator: n, - }, - ]); - - lhs = lhs.multiply(reciprocal); - rhs = rhs.multiply(reciprocal); - - const keys = lhs.terms[0].indeterminates.sortedKeys; - let invert = - lhs.terms[0].indeterminates.map.get(keys[0]) < 0 ? -1 : 0; - - for (const t of lhs.terms) - for (const v of t.indeterminates.map.values()) invert += v; - - if (invert < 0) { - flipOp *= -1; - lhs = lhs.reciprocal(); - rhs = rhs.reciprocal(); - } - - if (flipOp < 0) exp = [SWAPPED_OPS[op], lhs, rhs]; - else exp = [op, lhs, rhs]; - } + if (op === "CASE") { + const res = [] as [Expression, Expression][]; + for (let i = 1; i < exp.length; i += 2) { + let w = exp[i]; + if (w instanceof Polynomial) w = JSON.parse(w.toString()); + if (!Array.isArray(w) && !w) continue; + const t = exp[i + 1]; + if (!Array.isArray(t) || t[0] !== "CASE") { + res.push([w, t]); + continue; } + for (let j = 1; j < t.length; j += 2) res.push([and(w, t[j]), t[j + 1]]); + res.push([w, null]); + if (!Array.isArray(w) && w) break; + } + while (res[res.length - 1][1] == null) res.pop(); + return ["CASE", ...res.flat()]; + } + + const permutations: Map = new Map(); + for (const [i, e] of exp.entries()) { + if (!Array.isArray(e)) continue; + if (e[0] !== "CASE") continue; + const perms: [Expression, Expression][] = []; + for (let j = 1; j < e.length; j += 2) perms.push([e[j], e[j + 1]]); + permutations.set(i, perms); + } + + if (permutations.size) { + let res: [Expression, Expression][] = [[true, exp]]; + for (const [i, perms] of permutations) { + const res2: [Expression, Expression][] = []; + for (const [w, t] of perms) { + res2.push( + ...res.map((r) => { + const e = (r[1] as Expression[]).slice(); + e[i] = t; + return [and(w, r[0]), e] as [Expression, Expression]; + }) + ); + } + res = res2; } - // Restore polynomial expressions - exp = exp.map((e) => - e instanceof Polynomial ? JSON.parse(e.toString()) : e - ); + for (const r of res) r[1] = normalizeCallback(r[1]); - return exp; - }); + if (res[0][0] === true) return res[0][1]; + while (res[res.length - 1][1] == null) res.pop(); + return ["CASE", ...res.flat()]; + } + + function toPolynomial(e: Expression): Polynomial { + if (e == null) return null; + if (e instanceof Polynomial) return e; + if (typeof e === "number") return Polynomial.fromConstant(e); + if (typeof e === "string") + return Polynomial.fromConstant(parseFloat(e) || 0); + if (typeof e === "boolean") return Polynomial.fromConstant(+e); + return Polynomial.fromIndeterminate(e); + } + + if (op === "+") { + const args: Polynomial[] = []; + for (let i = 1; i < exp.length; ++i) { + const p = toPolynomial(exp[i]); + if (p == null) return null; + args.push(p); + } + return (args.reduce( + (previousValue, currentValue) => previousValue.add(currentValue), + ADDITIVE_IDENTITY + ) as unknown) as Expression; + } else if (op === "*") { + const args: Polynomial[] = []; + for (let i = 1; i < exp.length; ++i) { + const p = toPolynomial(exp[i]); + if (p == null) return null; + args.push(p); + } + return (args.reduce( + (previousValue, currentValue) => previousValue.multiply(currentValue), + MULTIPLICATIVE_IDENTITY + ) as unknown) as Expression; + } else if (op === "-") { + const args: Polynomial[] = []; + for (let i = 1; i < exp.length; ++i) { + const p = toPolynomial(exp[i]); + if (p == null) return null; + args.push(p); + } + return (args.reduce((previousValue, currentValue) => + previousValue.subtract(currentValue) + ) as unknown) as Expression; + } else if (op === "/") { + const args: Polynomial[] = []; + for (let i = 1; i < exp.length; ++i) { + const p = toPolynomial(exp[i]); + if (p == null) return null; + args.push(p); + } + return (args.reduce((previousValue, currentValue) => + previousValue.divide(currentValue) + ) as unknown) as Expression; + } else if (["=", "<>", ">", ">=", "<", "<="].includes(op)) { + if (exp[1] == null || exp[2] == null) return null; + let lhs: Polynomial, rhs: Polynomial; + if (exp[1] instanceof Polynomial) lhs = exp[1]; + else if (typeof exp[1] === "number") lhs = Polynomial.fromConstant(exp[1]); + + if (exp[2] instanceof Polynomial) rhs = exp[2]; + else if (typeof exp[2] === "number") rhs = Polynomial.fromConstant(exp[2]); + + if (lhs || rhs) { + if (!lhs) lhs = Polynomial.fromIndeterminate(exp[1]); + if (!rhs) rhs = Polynomial.fromIndeterminate(exp[2]); + + lhs = lhs.subtract(rhs); + rhs = lhs.constant().negation(); + lhs = lhs.add(rhs); + + if (!lhs.terms.length) { + exp = [op, JSON.parse(lhs.toString()), JSON.parse(rhs.toString())]; + + if (op === "=") return exp[1] === exp[2]; + else if (op === "<>") return exp[1] !== exp[2]; + else if (op === ">") return exp[1] > exp[2]; + else if (op === ">=") return exp[1] >= exp[2]; + else if (op === "<") return exp[1] < exp[2]; + else if (op === "<=") return exp[1] <= exp[2]; + } else { + let flipOp = 1; + + const n = lhs.terms[0].coefficientNumerator; + const d = lhs.terms[0].coefficientDenominator; + + if (BI.lt(n, ZERO) || BI.lt(d, ZERO)) flipOp *= -1; + + const reciprocal = new Polynomial([ + { + indeterminates: new Indeterminates(), + coefficientNumerator: d, + coefficientDenominator: n, + }, + ]); + + lhs = lhs.multiply(reciprocal); + rhs = rhs.multiply(reciprocal); + + const keys = lhs.terms[0].indeterminates.sortedKeys; + let invert = lhs.terms[0].indeterminates.map.get(keys[0]) < 0 ? -1 : 0; + + for (const t of lhs.terms) + for (const v of t.indeterminates.map.values()) invert += v; + + if (invert < 0) { + flipOp *= -1; + lhs = lhs.reciprocal(); + rhs = rhs.reciprocal(); + } + + if (flipOp < 0) exp = [SWAPPED_OPS[op], lhs, rhs]; + else exp = [op, lhs, rhs]; + } + } + } + + // Restore polynomial expressions + exp = exp.map((e) => + e instanceof Polynomial ? JSON.parse(e.toString()) : e + ); + + exp = evaluateCallback(exp); + + return exp; +} + +export function normalize(expr: Expression): Expression { + expr = map(expr, normalizeCallback); if (expr instanceof Polynomial) expr = JSON.parse(expr.toString()); - return expr; } function toBoolExprSynth(e: Expression | BoolExprSynth): BoolExprSynth { if (e instanceof BoolExprSynth) return e; - else if (Array.isArray(e)) return new VarSynth(e); - else if (e == null) return new NullSynth(); - else if (e) return new TrueSynth(); - else return new FalseSynth(); + if (Array.isArray(e)) { + if (e[0] === "CASE") + return new CaseSynth(e.slice(1).map((ee) => toBoolExprSynth(ee))); + return new VarSynth(e); + } + if (e == null) return new NullSynth(); + if (e) return new TrueSynth(); + return new FalseSynth(); } function sopToExpression( @@ -608,7 +789,7 @@ function sopToExpression( const res: Expression[] = []; for (const s of sop) { if (!s.length) return true; - const and: Expression[] = []; + const conjs: Expression[] = []; for (const i of s) { let expr = expressions.get(i >>> 2); if (!(i & 1) !== !(i & 2)) expr = ["NOT", expr]; @@ -624,10 +805,10 @@ function sopToExpression( else if (e[0] === "NOT") expr = e[1]; else expr = ["NOT", e]; } - and.push(expr); + conjs.push(expr); } - if (and.length > 1) res.push(["AND" as any].concat(and)); - else res.push(and[0]); + if (conjs.length > 1) res.push(["AND" as any].concat(conjs)); + else res.push(conjs[0]); } if (res.length > 1) return ["OR" as any].concat(res); return res[0]; @@ -825,25 +1006,20 @@ function boolExprSynthToExpression(boolExpr: BoolExprSynth): Expression { const { dcSet, isNull } = generateDcSetAndIsNull(variables); minterms = sanitizeMinterms(minterms, isNull); - function canRaise(idx: number, set: Set): boolean { - if (set.size === 1) return true; - const i = idx >>> 2; - const n = isNull.get(i); - if (n == null) { - for (const [k, v] of isNull) { - if (v !== i) continue; - if (set.has((k << 2) ^ 1) || set.has((k << 2) ^ 3)) return true; - } - return false; - } - return (idx & 1) === 0 || set.has((n << 2) ^ 2) || set.has((n << 2) ^ 3); - } + const canRaise = (idx: number, set: Set): boolean => + !isNull.has(idx >> 2) || !(idx & 1) || !set.has(idx ^ 3); minterms = espresso(minterms, dcSet, { canRaise }); return sopToExpression(minterms, variables); } -function mapCallback(exp: Expression): Expression { - if (!Array.isArray(exp)) return exp; +function mapCallback(exp: Expression | BoolExprSynth): Expression { + if (!Array.isArray(exp)) return exp as Expression; + if (exp[0] === "CASE") { + exp = exp.slice(); + for (let i = 1; i < exp.length; i += 2) exp[i] = toBoolExprSynth(exp[i]); + return exp; + } + const op = exp[0]; if (op === "IS NULL") return (new IsNullSynth(toBoolExprSynth(exp[1])) as unknown) as Expression; @@ -864,16 +1040,53 @@ function mapCallback(exp: Expression): Expression { ) as unknown) as Expression; } for (let i = 1; i < exp.length; ++i) { - if (exp[i] instanceof BoolExprSynth) + if (exp[i] instanceof BoolExprSynth) { exp[i] = boolExprSynthToExpression(exp[i]); + } else if (Array.isArray(exp[i]) && exp[i][0] === "CASE") { + for (let j = 2; j < exp[i].length; j += 2) { + if (exp[i][j] instanceof BoolExprSynth) + exp[i][j] = boolExprSynthToExpression(exp[i][j]); + } + } } return exp; } -export function minimize(expr: Expression): Expression { +export function minimize(expr: Expression, boolean = false): Expression { expr = normalize(expr); expr = map(expr, mapCallback); - if (expr instanceof BoolExprSynth) return boolExprSynthToExpression(expr); + if (Array.isArray(expr) && expr[0] === "CASE") { + if (!boolean) { + const context: Map = new Map(); + const whens = expr.filter((e, i) => i % 2).map((e) => e.true(context)); + const variables = new Map( + Array.from(context).map(([k, v]) => [v, JSON.parse(k) as Expression]) + ); + const { dcSet, isNull } = generateDcSetAndIsNull(variables); + const canRaise = (idx: number, set: Set): boolean => + !isNull.has(idx >> 2) || !(idx & 1) || !set.has(idx ^ 3); + const res: Expression = ["CASE"]; + for (let i = 1; i < expr.length; i += 2) { + let minterms = sanitizeMinterms(whens[(i - 1) / 2], isNull); + minterms = espresso(minterms, dcSet, { canRaise }); + if (!minterms.length) continue; + const w = sopToExpression(minterms, variables); + let t = expr[i + 1]; + if (t instanceof BoolExprSynth) t = boolExprSynthToExpression(t); + res.push(w, t); + if (w === true) break; + dcSet.push(...minterms); + } + while (res[res.length - 1] == null) res.splice(-2); + if (res.length < 3) return null; + return res; + } + expr = (toBoolExprSynth(expr) as unknown) as Expression; + } + + if (boolean) expr = (toBoolExprSynth(expr) as unknown) as Expression; + + if (expr instanceof BoolExprSynth) expr = boolExprSynthToExpression(expr); return expr; } @@ -888,6 +1101,8 @@ export function unionDiff( expr2 = map(expr2, mapCallback); if (!expr1) { + if (Array.isArray(expr2) && expr2[0] === "CASE") + expr2 = (toBoolExprSynth(expr2) as unknown) as Expression; if (expr2 instanceof BoolExprSynth) expr2 = boolExprSynthToExpression(expr2); return [expr2, expr2]; @@ -921,19 +1136,8 @@ export function unionDiff( isNull ); - function canRaise(idx: number, set: Set): boolean { - if (set.size === 1) return true; - const i = idx >>> 2; - const n = isNull.get(i); - if (n == null) { - for (const [k, v] of isNull) { - if (v !== i) continue; - if (set.has((k << 2) ^ 1) || set.has((k << 2) ^ 3)) return true; - } - return false; - } - return (idx & 1) === 0 || set.has((n << 2) ^ 2) || set.has((n << 2) ^ 3); - } + const canRaise = (idx: number, set: Set): boolean => + !isNull.has(idx >> 2) || !(idx & 1) || !set.has(idx ^ 3); const union = espresso(unionMinterms, dcSet, { canRaise }); const diff = espresso(diffMinterms, dcSet, { canRaise }); diff --git a/lib/common/expression-parser.ts b/lib/common/expression-parser.ts index 493330e..b23e73d 100644 --- a/lib/common/expression-parser.ts +++ b/lib/common/expression-parser.ts @@ -187,6 +187,7 @@ const lang = parsimmon.createLanguage({ NumberValue: function () { return parsimmon .regexp(/-?(0|[1-9][0-9]*)([.][0-9]+)?([eE][+-]?[0-9]+)?/) + .notFollowedBy(parsimmon.regexp(/[a-zA-Z0-9_]/)) .skip(parsimmon.optWhitespace) .map(Number) .desc("number"); @@ -223,6 +224,48 @@ const lang = parsimmon.createLanguage({ (f, args) => ["FUNC", f.toUpperCase()].concat(args) ); }, + WhenPair: function (r) { + return parsimmon.seq( + parsimmon + .regexp(/when/i) + .notFollowedBy(parsimmon.regexp(/[a-zA-Z0-9_]/)) + .skip(parsimmon.optWhitespace) + .desc("WHEN") + .then(r.Expression), + parsimmon + .regexp(/then/i) + .notFollowedBy(parsimmon.regexp(/[a-zA-Z0-9_]/)) + .skip(parsimmon.optWhitespace) + .desc("THEN") + .then(r.Expression) + ); + }, + CaseStatement: function (r) { + return parsimmon.seqMap( + parsimmon + .regexp(/case/i) + .result("CASE") + .notFollowedBy(parsimmon.regexp(/[a-zA-Z0-9_]/)) + .skip(parsimmon.optWhitespace) + .desc("CASE"), + r.WhenPair.many(), + parsimmon + .regexp(/else/i) + .notFollowedBy(parsimmon.regexp(/[a-zA-Z0-9_]/)) + .skip(parsimmon.optWhitespace) + .desc("ELSE") + .then(r.Expression) + .map((e) => [[true, e]]) + .fallback(null) + .skip( + parsimmon + .regex(/end/i) + .notFollowedBy(parsimmon.regexp(/[a-zA-Z0-9_]/)) + ) + .skip(parsimmon.optWhitespace), + (...arr) => arr.flat(2) + ); + }, Value: function (r) { return parsimmon.alt( r.NullValue, @@ -230,7 +273,8 @@ const lang = parsimmon.createLanguage({ r.NumberValue, r.StringValueSql, r.StringValueJs, - r.FuncValue + r.FuncValue, + r.CaseStatement ); }, ValueExpression: function (r) { @@ -378,6 +422,17 @@ export function stringify(exp: Expression, level = 0): string { )}` ); } + } else if (op === "CASE") { + const parts: string[] = ["CASE"]; + for (let i = 1; i < exp.length - 1; i += 2) { + if (!Array.isArray(exp[i]) && exp[i]) { + if (exp[i + 1] != null) parts.push("ELSE", stringify(exp[i + 1])); + break; + } + parts.push("WHEN", stringify(exp[i]), "THEN", stringify(exp[i + 1])); + } + parts.push("END"); + return parts.join(" "); } else if (op in opLevels) { const parts = exp.slice(1).map((e, i) => { return stringify(e, opLevels[exp[0]] + Math.min(i - 1, 0)); diff --git a/lib/common/expression.ts b/lib/common/expression.ts index e08b8d7..97dc82a 100644 --- a/lib/common/expression.ts +++ b/lib/common/expression.ts @@ -72,124 +72,162 @@ export function likePatternToRegExp(pat: string, esc = "", flags = ""): RegExp { return new RegExp(chars.join(""), flags); } -function evalExp(e: Expression): Expression { - if (e[0] === "AND") { - return reduce(e, (a, b) => { - if (!isArray(a)) return a ? b : a; - if (!isArray(b)) return b ? a : b; - return REDUCE_SKIP; - }); - } else if (e[0] === "OR") { - return reduce(e, (a, b) => { - if (!isArray(a)) return a ? a : b; - if (!isArray(b)) return b ? b : a; - return REDUCE_SKIP; - }); - } else if (e[0] === "NOT") { - if (!isArray(e[1])) return !e[1]; - else if (e[1][0] === "NOT") return e[1][1]; - } else if (e[0] === "IS NULL") { - if (isArray(e[1])) return e; - else if (e[1] == null) return true; +function compare( + a: boolean | number | string, + b: boolean | number | string +): number { + if (typeof a === "boolean") a = +a; + if (typeof b === "boolean") b = +b; + if (typeof a !== typeof b) return typeof a === "string" ? 1 : -1; + return a > b ? 1 : a < b ? -1 : 0; +} + +function toNumber(a: boolean | number | string): number { + switch (typeof a) { + case "number": + return a; + case "boolean": + return +a; + case "string": + return parseFloat(a) || 0; + } +} + +export function evaluateCallback(exp: Expression): Expression { + if (!Array.isArray(exp)) return exp; + if (exp[0] === "CASE") { + for (let i = 1; i < exp.length; i += 2) { + if (Array.isArray(exp[i])) return exp; + if (exp[i]) return exp[i + 1]; + } + return null; + } else if (exp[0] === "AND") { + for (let i = 1; i < exp.length; ++i) + if (!Array.isArray(exp[i]) && exp[i] != null && !exp[i]) return false; + const args: Expression[] = []; + for (let i = 1; i < exp.length; ++i) { + const ee = exp[i]; + if (ee == null) return null; + if (Array.isArray(ee)) { + if (ee[0] === "AND") args.push(...ee.slice(1)); + else args.push(ee); + } + } + if (!args.length) return true; + if (args.length === 1) args.push(true); + return ["AND", ...args]; + } else if (exp[0] === "OR") { + const args: Expression[] = []; + for (let i = 1; i < exp.length; ++i) { + const ee = exp[i]; + if (Array.isArray(ee)) { + if (ee[0] === "OR") args.push(...ee.slice(1)); + else args.push(ee); + } else if (ee) { + return true; + } + } + if (!args.length) return exp.some((ee) => ee == null) ? null : false; + if (args.length === 1) args.push(false); + return ["OR", ...args]; + } else if (exp[0] === "NOT") { + if (exp[1] == null) return null; + if (!isArray(exp[1])) return !exp[1]; + else if (exp[1][0] === "NOT") return exp[1][1]; + } else if (exp[0] === "IS NULL") { + if (isArray(exp[1])) return exp; + else if (exp[1] == null) return true; else return null; - } else if (e[0] === "IS NOT NULL") { - if (isArray(e[1])) return e; - else if (e[1] != null) return true; + } else if (exp[0] === "IS NOT NULL") { + if (isArray(exp[1])) return exp; + else if (exp[1] != null) return true; else return null; - } else if (e[0] === "LIKE") { - if (isArray(e[1]) || isArray(e[2]) || isArray(e[3])) return e; + } else if (exp[0] === "LIKE") { + if (isArray(exp[1]) || isArray(exp[2]) || isArray(exp[3])) return exp; else if ( - e[1] == null || - e[2] == null || - ((e as any[]).length >= 4 && e[3] == null) + exp[1] == null || + exp[2] == null || + ((exp as any[]).length >= 4 && exp[3] == null) ) return null; - let r = regExpCache.get(e as any[]); + let r = regExpCache.get(exp as any[]); if (!r) { - r = likePatternToRegExp(e[2], e[3]); - regExpCache.set(e as any[], r); + r = likePatternToRegExp(exp[2], exp[3]); + regExpCache.set(exp as any[], r); } - return r.test(e[1]); - } else if (e[0] === "NOT LIKE") { - if (isArray(e[1]) || isArray(e[2]) || isArray(e[3])) return e; + return r.test(exp[1]); + } else if (exp[0] === "NOT LIKE") { + if (isArray(exp[1]) || isArray(exp[2]) || isArray(exp[3])) return exp; else if ( - e[1] == null || - e[2] == null || - ((e as any[]).length >= 4 && e[3] == null) + exp[1] == null || + exp[2] == null || + ((exp as any[]).length >= 4 && exp[3] == null) ) return null; - let r = regExpCache.get(e as any[]); + let r = regExpCache.get(exp as any[]); if (!r) { - r = likePatternToRegExp(e[2], e[3]); - regExpCache.set(e as any[], r); + r = likePatternToRegExp(exp[2], exp[3]); + regExpCache.set(exp as any[], r); } - return !r.test(e[1]); - } else if (e[0] === "=") { - if (isArray(e[1]) || isArray(e[2])) return e; - if (e[1] == null || e[2] == null) return null; - return e[1] === e[2]; - } else if (e[0] === "<>") { - if (isArray(e[1]) || isArray(e[2])) return e; - if (e[1] == null || e[2] == null) return null; - return e[1] !== e[2]; - } else if (e[0] === ">") { - if (isArray(e[1]) || isArray(e[2])) return e; - if (e[1] == null || e[2] == null) return null; - return e[1] > e[2]; - } else if (e[0] === ">=") { - if (isArray(e[1]) || isArray(e[2])) return e; - if (e[1] == null || e[2] == null) return null; - return e[1] >= e[2]; - } else if (e[0] === "<") { - if (isArray(e[1]) || isArray(e[2])) return e; - if (e[1] == null || e[2] == null) return null; - return e[1] < e[2]; - } else if (e[0] === "<=") { - if (isArray(e[1]) || isArray(e[2])) return e; - if (e[1] == null || e[2] == null) return null; - return e[1] <= e[2]; - } else if (e[0] === "*") { - return reduce(e, (a, b) => { - if (!isArray(a) && !isArray(b)) { - if (a == null || b == null) return null; - return a * b; - } + return !r.test(exp[1]); + } else if (exp[0] === "=") { + if (exp[1] == null || exp[2] == null) return null; + if (isArray(exp[1]) || isArray(exp[2])) return exp; + return compare(exp[1], exp[2]) === 0; + } else if (exp[0] === "<>") { + if (exp[1] == null || exp[2] == null) return null; + if (isArray(exp[1]) || isArray(exp[2])) return exp; + return compare(exp[1], exp[2]) !== 0; + } else if (exp[0] === ">") { + if (exp[1] == null || exp[2] == null) return null; + if (isArray(exp[1]) || isArray(exp[2])) return exp; + return compare(exp[1], exp[2]) > 0; + } else if (exp[0] === ">=") { + if (exp[1] == null || exp[2] == null) return null; + if (isArray(exp[1]) || isArray(exp[2])) return exp; + return compare(exp[1], exp[2]) >= 0; + } else if (exp[0] === "<") { + if (exp[1] == null || exp[2] == null) return null; + if (isArray(exp[1]) || isArray(exp[2])) return exp; + return compare(exp[1], exp[2]) < 0; + } else if (exp[0] === "<=") { + if (exp[1] == null || exp[2] == null) return null; + if (isArray(exp[1]) || isArray(exp[2])) return exp; + return compare(exp[1], exp[2]) <= 0; + } else if (exp[0] === "*") { + return reduce(exp, (a, b) => { + if (a == null || b == null) return null; + if (!isArray(a) && !isArray(b)) return toNumber(a) * toNumber(b); return REDUCE_SKIP; }); - } else if (e[0] === "/") { - return reduce(e, (a, b, i) => { - if (!isArray(a) && !isArray(b)) { - if (a == null || b == null) return null; - return i === 0 ? a / b : a * b; - } + } else if (exp[0] === "/") { + return reduce(exp, (a, b, i) => { + if (a == null || b == null) return null; + if (!isArray(a) && !isArray(b)) + return i === 0 ? toNumber(a) / toNumber(b) : toNumber(a) * toNumber(b); return REDUCE_SKIP; }); - } else if (e[0] === "+") { - return reduce(e, (a, b) => { - if (!isArray(a) && !isArray(b)) { - if (a == null || b == null) return null; - return a + b; - } + } else if (exp[0] === "+") { + return reduce(exp, (a, b) => { + if (a == null || b == null) return null; + if (!isArray(a) && !isArray(b)) return toNumber(a) + toNumber(b); return REDUCE_SKIP; }); - } else if (e[0] === "-") { - return reduce(e, (a, b, i) => { - if (!isArray(a) && !isArray(b)) { - if (a == null || b == null) return null; - return i === 0 ? a - b : a + b; - } + } else if (exp[0] === "-") { + return reduce(exp, (a, b, i) => { + if (a == null || b == null) return null; + if (!isArray(a) && !isArray(b)) + return i === 0 ? toNumber(a) - toNumber(b) : toNumber(a) + toNumber(b); return REDUCE_SKIP; }); - } else if (e[0] === "||") { - return reduce(e, (a, b) => { - if (!isArray(a) && !isArray(b)) { - if (a == null || b == null) return null; - return `${a}${b}`; - } + } else if (exp[0] === "||") { + return reduce(exp, (a, b) => { + if (a == null || b == null) return null; + if (!isArray(a) && !isArray(b)) return `${a}${b}`; return REDUCE_SKIP; }); } - return e; + return exp; } export function evaluate( @@ -236,7 +274,7 @@ export function evaluate( return v; } } - return evalExp(e); + return evaluateCallback(e); }); } @@ -281,35 +319,47 @@ export async function evaluateAsync( return v as Expression; } } - return evalExp(e); + return evaluateCallback(e); }); } export function and(exp1: Expression, exp2: Expression): Expression { - if (!isArray(exp1)) return exp1 ? exp2 : exp1; - if (!isArray(exp2)) return exp2 ? exp1 : exp2; + if (exp1 != null && !exp1) return false; + if (exp2 != null && !exp2) return false; + if (!Array.isArray(exp1) && !Array.isArray(exp2)) { + if (exp1 == null || exp2 == null) return null; + return true; + } + if (!Array.isArray(exp2) && exp1[0] === "AND") return exp1; + if (!Array.isArray(exp1) && exp2[0] === "AND") return exp2; - let res: Expression = ["AND"]; + const res: Expression = ["AND"]; - if (exp1[0] === "AND") res = res.concat(exp1.slice(1)); + if (Array.isArray(exp1) && exp1[0] === "AND") res.push(...exp1.slice(1)); else res.push(exp1); - if (exp2[0] === "AND") res = res.concat(exp2.slice(1)); + if (Array.isArray(exp2) && exp2[0] === "AND") res.push(...exp2.slice(1)); else res.push(exp2); return res; } export function or(exp1: Expression, exp2: Expression): Expression { - if (!isArray(exp1)) return exp1 ? exp1 : exp2; - if (!isArray(exp2)) return exp2 ? exp2 : exp1; + if (!Array.isArray(exp1) && exp1) return true; + if (!Array.isArray(exp2) && exp2) return true; + if (!Array.isArray(exp1) && !Array.isArray(exp2)) { + if (exp1 == null || exp2 == null) return null; + return false; + } + if (!Array.isArray(exp2) && exp1[0] === "OR") return exp1; + if (!Array.isArray(exp1) && exp2[0] === "OR") return exp2; - let res: Expression = ["OR"]; + const res: Expression = ["OR"]; - if (exp1[0] === "OR") res = res.concat(exp1.slice(1)); + if (Array.isArray(exp1) && exp1[0] === "OR") res.push(...exp1.slice(1)); else res.push(exp1); - if (exp2[0] === "OR") res = res.concat(exp2.slice(1)); + if (Array.isArray(exp2) && exp2[0] === "OR") res.push(...exp2.slice(1)); else res.push(exp2); return res; diff --git a/lib/mongodb-functions.ts b/lib/mongodb-functions.ts index bdd0a09..c36e92e 100644 --- a/lib/mongodb-functions.ts +++ b/lib/mongodb-functions.ts @@ -137,6 +137,10 @@ export function filterToMongoQuery(exp: Expression): Record { const op = filter[0]; if (ops[op] === 0) { + if (op === "AND") filter = filter.filter((f) => f !== true); + if (op === "OR") filter = filter.filter((f) => f !== false); + if (filter.length === 2) return recursive(filter[1], negate, res); + for (let i = 1; i < filter.length; ++i) { if (!isArray(filter[i]) || ops[filter[i][0]] == null) throw new Error(`Invalid expression in ${op} clause`); diff --git a/lib/ui/db.ts b/lib/ui/db.ts index d173e8e..265c1bc 100644 --- a/lib/ui/db.ts +++ b/lib/ui/db.ts @@ -24,6 +24,7 @@ import * as mongodbFunctions from "../mongodb-functions"; import * as expression from "../common/expression"; import { QueryOptions, Expression } from "../types"; import { Readable } from "stream"; +import { minimize } from "../common/boolean-expression"; const CACHE_TTL = 300000; @@ -105,6 +106,7 @@ export function query( options = options || {}; let q; filter = expression.evaluate(filter, null, Date.now()); + filter = minimize(filter, true); if (Array.isArray(filter)) { if (resource === "devices") { @@ -204,6 +206,7 @@ export function query( export function count(resource: string, filter: Expression): Promise { let q; filter = expression.evaluate(filter, null, Date.now()); + filter = minimize(filter, true); if (Array.isArray(filter)) { if (resource === "devices") diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 4bd5ce8..8aa0ac2 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -10479,9 +10479,9 @@ } }, "yebool": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/yebool/-/yebool-1.0.1.tgz", - "integrity": "sha512-PrD+4pRGLSS3c8Z+l4V1EUPGUxtqpCDOobldAijBDAgdRHVtrIcDeZ6BFZDKk6QMmGTCCZhbQYdFKSz5fdBXAw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/yebool/-/yebool-1.0.2.tgz", + "integrity": "sha512-3sgfZ1jSZNWyU5Xu0uIlA98tNl/Aj1sZVt89qZL4IXJVNG2+eWF4hp9NzQrOng6nO/ohvIPnzbIfp8qtBykBfw==", "requires": { "jsbi": "^3.1.3" } diff --git a/package.json b/package.json index 6483d4c..6af8991 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "parsimmon": "^1.13.0", "seedrandom": "^3.0.1", "yaml": "^1.6.0", - "yebool": "^1.0.1" + "yebool": "^1.0.2" }, "devDependencies": { "@rollup/plugin-commonjs": "^14.0.0", diff --git a/ui/filter-component.ts b/ui/filter-component.ts index d9abe00..769b6f9 100644 --- a/ui/filter-component.ts +++ b/ui/filter-component.ts @@ -24,6 +24,7 @@ import Autocomplete from "./autocomplete-compnent"; import * as smartQuery from "./smart-query"; import { filterToMongoQuery } from "../lib/mongodb-functions"; import { Expression } from "../lib/types"; +import { normalize } from "../lib/common/boolean-expression"; const getAutocomplete = memoize((resource) => { const labels = smartQuery.getLabels(resource); @@ -41,6 +42,12 @@ const getAutocomplete = memoize((resource) => { return autocomplete; }); +function validateQuery(q: Expression): void { + q = normalize(q); + if (Array.isArray(q) && q[0] === "CASE") q = ["AND", ...q.slice(1)]; + filterToMongoQuery(q); +} + function parseFilter(resource, f): Expression { let exp; if (/^[\s0-9a-zA-Z]+:/.test(f)) { @@ -51,11 +58,13 @@ function parseFilter(resource, f): Expression { exp = parse(f); } - // Throw exception if invalid Mongo query - filterToMongoQuery( + // Throws exception if invalid Mongo query + validateQuery( map(exp, (e) => { - if (Array.isArray(e) && e[0] === "FUNC" && e[1] === "Q") - return smartQuery.unpack(resource, e[2], e[3]); + if (Array.isArray(e) && e[0] === "FUNC") { + if (e[1] === "Q") return smartQuery.unpack(resource, e[2], e[3]); + else if (e[1] === "NOW") return Date.now(); + } return e; }) );