Implement LIKE operator

This commit is contained in:
Zaid Abdulla committed 2018-12-26 00:22:07 -08:00
1 parent 9fb2d84324
commit afd1a61696
4 files changed
+294 -61

No files matched your search

+108 -27
View File
@@ -1,28 +1,17 @@
"use strict"; "use strict";
function collectRanges(exp, ranges = new Map()) { const expressionParser = require("./expression-parser");
if (["AND", "OR", "NOT"].includes(exp[0]))
for (let i = 1; i < exp.length; ++i) collectRanges(exp[i], ranges);
else if ([">", ">=", "<", "<=", "=", "<>"].includes(exp[0]))
if (!Array.isArray(exp[2])) {
const key = `${JSON.stringify(exp[1])}:${typeof exp[2]}`;
let s = ranges.get(key);
if (!s) ranges.set(key, (s = new Set()));
s.add(exp[2]);
} else if (!Array.isArray(exp[1])) {
const key = `${JSON.stringify(exp[2])}:${typeof exp[1]}`;
let s = ranges.get(key);
if (!s) ranges.set(key, (s = new Set()));
s.add(exp[2]);
}
return ranges;
}
function or(cnf1, cnf2) { function or(cnf1, cnf2) {
if (cnf1.length === 0) return cnf2; if (cnf1.length === 0) return cnf2;
else if (cnf2.length === 0) return cnf1; else if (cnf2.length === 0) return cnf1;
if (cnf1.length > cnf2.length) {
let c = cnf1;
cnf1 = cnf2;
cnf2 = c;
}
const res = []; const res = [];
for (let i = 0; i < cnf1.length; ++i) for (let i = 0; i < cnf1.length; ++i)
for (let j = i; j < cnf2.length; ++j) res.push(cnf1[i].concat(cnf2[j])); for (let j = i; j < cnf2.length; ++j) res.push(cnf1[i].concat(cnf2[j]));
@@ -31,7 +20,39 @@ function or(cnf1, cnf2) {
} }
function booleanCnf(exp) { function booleanCnf(exp) {
const ranges = collectRanges(exp); const ranges = new Map();
const likePatterns = new Map();
expressionParser.map(exp, e => {
if (!Array.isArray(e)) return e;
if (e[0] === "LIKE" || e[0] === "NOT LIKE") {
if (
typeof e[2] === "string" &&
(e.length >= 4 || (e[3] != null && !Array.isArray(e[3])))
) {
const key = JSON.stringify(e[1]);
let s = likePatterns.get(key);
if (!s) likePatterns.set(key, (s = new Map()));
s.set(
`${e[3] || ""}:${e[2]}`,
expressionParser.parseLikePattern(e[2], e[3])
);
}
} else if ([">", ">=", "<", "<=", "=", "<>"].includes(e[0])) {
if (!Array.isArray(e[2])) {
const key = `${JSON.stringify(e[1])}:${typeof e[2]}`;
let s = ranges.get(key);
if (!s) ranges.set(key, (s = new Set()));
s.add(e[2]);
} else if (!Array.isArray(e[1])) {
const key = `${JSON.stringify(e[2])}:${typeof e[1]}`;
let s = ranges.get(key);
if (!s) ranges.set(key, (s = new Set()));
s.add(e[2]);
}
}
return e;
});
for (let [k, v] of ranges) for (let [k, v] of ranges)
if (k.endsWith(":number")) if (k.endsWith(":number"))
ranges.set(k, Array.from(v).sort((a, b) => a - b)); ranges.set(k, Array.from(v).sort((a, b) => a - b));
@@ -59,16 +80,24 @@ function booleanCnf(exp) {
const vars = []; const vars = [];
if (from == null) vars.push(`${lhs}:--${range[0]}`); if (from == null) vars.push(`${lhs}:--${range[0]}`);
if (to == null) vars.push(`${lhs}:${range[range.length - 1]}--`);
if (fromInclusive) vars.push(`${lhs}:${from}`);
if (toInclusive) vars.push(`${lhs}:${to}`);
for (let i = 1; i < range.length; ++i) for (let i = 0; i < range.length; ++i) {
if ((from == null || range[i] > from) && (to == null || range[i] <= to)) { if (
vars.push(`${lhs}:${range[i - 1]}`); i > 0 &&
if (range[i] !== to) vars.push(`${lhs}:${range[i]}`); (from == null || range[i] > from) &&
(to == null || range[i] <= to)
)
vars.push(`${lhs}:${range[i - 1]}--${range[i]}`); vars.push(`${lhs}:${range[i - 1]}--${range[i]}`);
}
if (
(range[i] > from && range[i] < to) ||
(range[i] === from && fromInclusive) ||
(range[i] === to && toInclusive)
)
vars.push(`${lhs}:${range[i]}`);
}
if (to == null) vars.push(`${lhs}:${range[range.length - 1]}--`);
return vars.map(key => { return vars.map(key => {
let f = expressions.get(key); let f = expressions.get(key);
@@ -82,6 +111,21 @@ function booleanCnf(exp) {
}); });
} }
function getLikeVariables(lhs, pat, esc) {
const vars = [];
let pats = likePatterns.get(lhs);
pat = pats.get(`${esc || ""}:${pat}`);
for (let p of pats.values())
if (pat === p || likePatternIncludes(pat, p))
vars.push(`${lhs}:like:${JSON.stringify(p)}`);
return vars.map(key => {
let f = expressions.get(key);
if (!f) expressions.set(key, (f = expressions.size + 1));
return f;
});
}
const comparisonOperators = { const comparisonOperators = {
"=": v => [v, true, v, true], "=": v => [v, true, v, true],
">": v => [v, false, null, null], ">": v => [v, false, null, null],
@@ -151,6 +195,20 @@ function booleanCnf(exp) {
if (negate) return [[0 - v]]; if (negate) return [[0 - v]];
else return [[v]]; else return [[v]];
} }
} else if (
(op === "LIKE" || op === "NOT LIKE") &&
!Array.isArray(clause[2]) &&
clause[2] != null &&
(clause.length >= 4 || clause[3] != null)
) {
if (op === "NOT LIKE") negate = !negate;
const vars = getLikeVariables(
JSON.stringify(clause[1]),
clause[2],
clause[3]
);
if (negate) return vars.map(x => [0 - x]);
else return [vars];
} else { } else {
const v = getVariable(JSON.stringify(clause), ""); const v = getVariable(JSON.stringify(clause), "");
if (negate) return [[0 - v]]; if (negate) return [[0 - v]];
@@ -169,4 +227,27 @@ function booleanCnf(exp) {
return { vars: expressions.size, clauses: cnf }; return { vars: expressions.size, clauses: cnf };
} }
function likePatternIncludes(pat1, pat2, idx1 = 0, idx2 = 0) {
while (idx1 < pat1.length && idx2 < pat2.length) {
if (pat1[idx1] === "\\%") {
for (let i = idx2; i <= pat2.length; ++i)
if (likePatternIncludes(pat1, pat2, idx1 + 1, i)) return true;
return false;
} else if (pat2[idx2] === "\\%") {
if (pat1[idx1] !== "\\%") return false;
} else if (pat1[idx1] === "\\_") {
// Ignore
} else if (pat2[idx2] === "\\_") {
return false;
} else if (pat2[idx2] !== pat1[idx1]) {
return false;
}
++idx1;
++idx2;
}
if (idx1 === pat1.length && idx2 === pat2.length) return true;
return false;
}
exports.booleanCnf = booleanCnf; exports.booleanCnf = booleanCnf;
+105 -33
View File
@@ -65,6 +65,21 @@ const lang = parsimmon.createLanguage({
) )
.skip(parsimmon.optWhitespace); .skip(parsimmon.optWhitespace);
}, },
LikeOperator: function() {
return parsimmon
.alt(
parsimmon
.regexp(/like/i)
.result("LIKE")
.desc("LIKE"),
parsimmon
.regexp(/not\s+like/i)
.result("NOT LIKE")
.desc("NOT LIKE")
)
.notFollowedBy(parsimmon.regexp(/[a-zA-Z0-9_]/))
.skip(parsimmon.optWhitespace);
},
IsNullOperator: function() { IsNullOperator: function() {
return parsimmon return parsimmon
.alt( .alt(
@@ -213,12 +228,31 @@ const lang = parsimmon.createLanguage({
}, },
Comparison: function(r) { Comparison: function(r) {
return parsimmon.alt( return parsimmon.alt(
parsimmon.seqMap(r.Parameter, r.IsNullOperator, (p, o) => [o, p]), parsimmon.seqMap(r.ValueExpression, r.IsNullOperator, (p, o) => [o, p]),
parsimmon.seqMap( parsimmon.seqMap(
r.ValueExpression, r.ValueExpression,
r.ComparisonOperator, r.ComparisonOperator,
r.ValueExpression, r.ValueExpression,
(p, o, v) => [o, p, v] (p, o, v) => [o, p, v]
),
parsimmon.seqMap(
r.ValueExpression,
r.LikeOperator,
r.ValueExpression.skip(
parsimmon
.regexp(/escape/i)
.result("ESCAPE")
.skip(parsimmon.whitespace)
.desc("ESCAPE")
),
r.ValueExpression,
(a, b, c, d) => [b, a, c, d]
),
parsimmon.seqMap(
r.ValueExpression,
r.LikeOperator,
r.ValueExpression,
(a, b, c) => [b, a, c]
) )
); );
}, },
@@ -242,7 +276,7 @@ function parse(str) {
return lang.Expression.tryParse(str); return lang.Expression.tryParse(str);
} }
function stringify(exp) { function stringify(exp, level = 0) {
if (!Array.isArray(exp)) return JSON.stringify(exp); if (!Array.isArray(exp)) return JSON.stringify(exp);
const opLevels = { const opLevels = {
@@ -250,11 +284,15 @@ function stringify(exp) {
AND: 11, AND: 11,
NOT: 12, NOT: 12,
"=": 20, "=": 20,
"<>": 21, "<>": 20,
">": 22, ">": 20,
">=": 23, ">=": 20,
"<": 24, "<": 20,
"<=": 25, "<=": 20,
LIKE: 20,
"NOT LIKE": 20,
"IS NULL": 20,
"IS NOT NULL": 20,
"||": 30, "||": 30,
"+": 31, "+": 31,
"-": 31, "-": 31,
@@ -264,45 +302,79 @@ function stringify(exp) {
const op = exp[0].toUpperCase(); const op = exp[0].toUpperCase();
function wrap(e) {
if (opLevels[op] <= level) return `(${e})`;
else return e;
}
if (op === "FUNC") { if (op === "FUNC") {
return `${exp[1]}(${exp return wrap(
.slice(2) `${exp[1]}(${exp
.map(e => stringify(e)) .slice(2)
.join(", ")})`; .map(e => stringify(e))
.join(", ")})`
);
} else if (op === "PARAM") { } else if (op === "PARAM") {
if (typeof exp[1] === "string") return exp[1]; if (typeof exp[1] === "string") return wrap(exp[1]);
else if (Array.isArray(exp[1]) && exp[1][0] === "||") else if (Array.isArray(exp[1]) && exp[1][0] === "||")
return exp[1] return wrap(
.slice(1) exp[1]
.map(p => { .slice(1)
if (typeof p === "string") return p; .map(p => {
else return `{${stringify(p)}}`; if (typeof p === "string") return p;
}) else return `{${stringify(p)}}`;
.join(""); })
else return `{${stringify(exp[1])}}`; .join("")
);
else return wrap(`{${stringify(exp[1])}}`);
} else if (op === "IS NULL" || op === "IS NOT NULL") { } else if (op === "IS NULL" || op === "IS NOT NULL") {
return `${stringify(exp[1])} ${op}`; return wrap(`${stringify(exp[1], opLevels[op])} ${op}`);
} else if (op === "LIKE" || op === "NOT LIKE") {
if (exp[3])
return wrap(
`${stringify(exp[1], opLevels[op])} ${op} ${stringify(
exp[2],
opLevels[op]
)} ESCAPE ${stringify(exp[3], opLevels[op])}`
);
else
return wrap(
`${stringify(exp[1], opLevels[op])} ${op} ${stringify(
exp[2],
opLevels[op]
)}`
);
} else if (op in opLevels) { } else if (op in opLevels) {
const parts = exp.slice(1).map((e, i) => { const parts = exp.slice(1).map((e, i) => {
if ( return stringify(e, opLevels[exp[0]] + Math.min(i - 1, 0));
Array.isArray(e) &&
(opLevels[e[0]] < opLevels[exp[0]] ||
(opLevels[e[0]] < opLevels[exp[0]] && i > 0) ||
(opLevels[exp[0]] >= 20 &&
opLevels[e[0]] >= 20 &&
opLevels[e[0]] < 30))
)
return `(${stringify(e)})`;
else return stringify(e);
}); });
if (op === "NOT") return `${op} ${parts[0]}`; if (op === "NOT") return wrap(`${op} ${parts[0]}`);
else return parts.join(` ${op} `); else return wrap(parts.join(` ${op} `));
} else { } else {
throw new Error(`Unrecognized operator ${exp[0]}`); throw new Error(`Unrecognized operator ${exp[0]}`);
} }
} }
function parseLikePattern(pat, esc) {
let chars = pat.split("");
for (let i = 0; i < chars.length; ++i) {
let c = chars[i];
if (c === esc) {
chars[i] = chars[i + 1] || "";
chars[i + 1] = "";
} else if (c === "_") {
chars[i] = "\\_";
} else if (c === "%") {
chars[i] = "\\%";
while (chars[i + 1] === "%") chars[++i] = "";
}
}
return chars.filter(c => c);
}
exports.parse = parse; exports.parse = parse;
exports.stringify = stringify; exports.stringify = stringify;
exports.map = map; exports.map = map;
exports.parseLikePattern = parseLikePattern;
+55 -1
View File
@@ -6,6 +6,8 @@ const satSolver = require("./sat-solver");
const isArray = Array.isArray; const isArray = Array.isArray;
const regExpCache = new WeakMap();
function* permute(arr) { function* permute(arr) {
if (arr.length <= 1) { if (arr.length <= 1) {
for (let i = 0; i < arr[0]; ++i) yield [i]; for (let i = 0; i < arr[0]; ++i) yield [i];
@@ -35,6 +37,14 @@ function reduce(exp, callback) {
} }
function evaluate(exp, obj, now) { function evaluate(exp, obj, now) {
function getRegExp(pat, esc) {
const k = `${esc || ""}:${pat}`;
let c = regExpCache.get(exp);
if (!c) regExpCache.set(exp, (c = {}));
if (!c[k]) c[k] = likePatternToRegExp(pat, esc);
return c[k];
}
return expressionParser.map(exp, e => { return expressionParser.map(exp, e => {
if (!isArray(e)) return e; if (!isArray(e)) return e;
@@ -45,7 +55,7 @@ function evaluate(exp, obj, now) {
if (e[2] == null) return null; if (e[2] == null) return null;
if (!isArray(e[2])) return `${e[2]}`.toUpperCase(); if (!isArray(e[2])) return `${e[2]}`.toUpperCase();
} else if (e[1] === "LOWER") { } else if (e[1] === "LOWER") {
if (e[12] == null) return null; if (e[2] == null) return null;
if (!isArray(e[2])) return `${e[2]}`.toLowerCase(); if (!isArray(e[2])) return `${e[2]}`.toLowerCase();
} }
} else if (e[0] === "PARAM") { } else if (e[0] === "PARAM") {
@@ -77,6 +87,19 @@ function evaluate(exp, obj, now) {
if (isArray(e[1])) return e; if (isArray(e[1])) return e;
else if (e[1] != null) return true; else if (e[1] != null) return true;
else return null; else return null;
} else if (e[0] === "LIKE") {
if (isArray(e[1]) || isArray(e[2]) || isArray(e[3])) return e;
else if (e[1] == null || e[2] == null || (e.length >= 4 && e[3] == null))
return null;
const r = getRegExp(e[2], e[3]);
return r.test(e[1]);
} else if (e[0] === "NOT LIKE") {
if (isArray(e[1]) || isArray(e[2]) || isArray(e[3])) return e;
else if (e[1] == null || e[2] == null || (e.length >= 4 && e[3] == null))
return null;
const r = getRegExp(e[2], e[3]);
return !r.test(e[1]);
} else if (e[0] === "=") { } else if (e[0] === "=") {
if (isArray(e[1]) || isArray(e[2])) return e; if (isArray(e[1]) || isArray(e[2])) return e;
if (e[1] == null || e[2] == null) return null; if (e[1] == null || e[2] == null) return null;
@@ -183,6 +206,36 @@ function subset(exp1, exp2) {
return !satSolver.naiveDpll(clauses, vars); return !satSolver.naiveDpll(clauses, vars);
} }
function likePatternToRegExp(pat, esc = "", flags = "") {
const convChars = {
"-": "\\-",
"/": "\\/",
"\\": "\\/",
"^": "\\^",
$: "\\$",
"*": "\\*",
"+": "\\+",
"?": "\\?",
".": "\\.",
"(": "\\(",
")": "\\)",
"|": "\\|",
"[": "\\[",
"]": "\\]",
"{": "\\{",
"}": "\\}",
"\\%": ".*",
"\\_": "."
};
let chars = expressionParser.parseLikePattern(pat, esc);
if (!chars.length) return new RegExp("", flags);
chars = chars.map(c => convChars[c] || c);
chars[0] = chars[0] === ".*" ? "" : "^" + chars[0];
const l = chars.length - 1;
chars[l] = [".*", ""].includes(chars[l]) ? "" : chars[l] + "$";
return new RegExp(chars.join(""), flags);
}
exports.evaluate = evaluate; exports.evaluate = evaluate;
exports.and = and; exports.and = and;
exports.or = or; exports.or = or;
@@ -190,3 +243,4 @@ exports.not = not;
exports.subset = subset; exports.subset = subset;
exports.parse = expressionParser.parse; exports.parse = expressionParser.parse;
exports.stringify = expressionParser.stringify; exports.stringify = expressionParser.stringify;
exports.likePatternToRegExp = likePatternToRegExp;
+26
View File
@@ -1,6 +1,7 @@
"use strict"; "use strict";
const mongodb = require("mongodb"); const mongodb = require("mongodb");
const expressionParser = require("../common/expression-parser"); const expressionParser = require("../common/expression-parser");
const expression = require("../common/expression");
function processDeviceFilter(filter) { function processDeviceFilter(filter) {
return expressionParser.map(filter, exp => { return expressionParser.map(filter, exp => {
@@ -130,6 +131,31 @@ function filterToMongoQuery(filter, negate = false, res = {}) {
} else if (op === "IS NOT NULL") { } else if (op === "IS NOT NULL") {
const param = filter[1][1]; const param = filter[1][1];
res[param] = { $exists: !negate }; res[param] = { $exists: !negate };
} else if (op === "LIKE" || op === "NOT LIKE") {
if (op === "NOT LIKE") negate = !negate;
let param;
let flags;
if (filter[1][0] === "FUNC" && filter[1][1] === "UPPER") {
if (filter[2] !== filter[2].toUpperCase())
throw new Error(
"Cannot compare UPPER() against non upper case pattern"
);
param = filter[1][2][1];
flags = "i";
} else if (filter[1][0] === "FUNC" && filter[1][1] === "LOWER") {
if (filter[2] !== filter[2].toLowerCase())
throw new Error(
"Cannot compare LOWER() against non lower case pattern"
);
param = filter[1][2][1];
flags = "i";
} else {
param = filter[1][1];
flags = "";
}
const r = expression.likePatternToRegExp(filter[2], filter[3], flags);
if (negate) res[param] = { $not: r };
else res[param] = r;
} else { } else {
throw new Error(`Unrecognized operator ${op}`); throw new Error(`Unrecognized operator ${op}`);
} }