refactor: replace array-based expressions with typed class hierarchy
Replace the old Lisp-like array expression representation (["AND", ["PARAM", ...], ...]) with a class hierarchy rooted in an abstract Expression class (Literal, Parameter, Binary, Unary, FunctionCall, Conditional). This gives compile-time type safety to expression construction, evaluation, and pattern matching. Key changes: - New lib/common/expression.ts defines the class hierarchy with map/evaluate/toString methods - Rewrite parser as hand-rolled recursive descent, removing the parsimmon dependency - Extend Path with a colon separator to unify parameter identity and attributes into a single address space (e.g. "Device.WiFi.SSID.1.Name:writable"). The colon field tracks how many trailing segments are attribute qualifiers. PathSet rewritten with separate param/attr segment indexes - Flatten device attribute representation in the UI to colon-delimited keys mirroring the new path syntax
This commit is contained in:
1 parent
75908e624f
commit
6f3ce6f7c8
71 files changed
+4045
-3449
No files matched your search
+496
-398
@@ -1,5 +1,166 @@
|
||||
import parsimmon from "parsimmon";
|
||||
import { Expression } from "../../types.ts";
|
||||
import Path from "../path.ts";
|
||||
import Expression from "../expression.ts";
|
||||
|
||||
export class Cursor {
|
||||
input: string;
|
||||
pos: number;
|
||||
boundryStack: number[][];
|
||||
boundry: number[];
|
||||
charCode: number;
|
||||
|
||||
constructor(input: string) {
|
||||
this.input = input;
|
||||
this.pos = 0;
|
||||
this.boundryStack = [];
|
||||
this.boundry = [];
|
||||
this.charCode = input.charCodeAt(0) || 0;
|
||||
}
|
||||
|
||||
fork(): Cursor {
|
||||
const cursor = new Cursor(this.input);
|
||||
cursor.pos = this.pos;
|
||||
cursor.boundryStack = this.boundryStack.slice();
|
||||
cursor.boundry = this.boundry;
|
||||
cursor.charCode = this.charCode;
|
||||
return cursor;
|
||||
}
|
||||
|
||||
sync(cursor: Cursor): void {
|
||||
this.pos = cursor.pos;
|
||||
this.boundryStack = cursor.boundryStack.slice();
|
||||
this.boundry = cursor.boundry;
|
||||
this.charCode = cursor.charCode;
|
||||
}
|
||||
|
||||
read(cur: Cursor): string {
|
||||
return this.input.slice(this.pos, cur.pos);
|
||||
}
|
||||
|
||||
step(): Cursor {
|
||||
if (this.charCode === 0) return this;
|
||||
++this.pos;
|
||||
this.charCode = this.input.charCodeAt(this.pos) || 0;
|
||||
if (this.boundry.includes(this.charCode)) this.charCode = 0;
|
||||
return this;
|
||||
}
|
||||
|
||||
walk(callback: (charCode: number) => boolean): Cursor {
|
||||
while (this.charCode && callback(this.charCode)) this.step();
|
||||
return this;
|
||||
}
|
||||
|
||||
descend(chars: number[], override = true): void {
|
||||
this.boundryStack.push(this.boundry);
|
||||
if (override) this.boundry = chars;
|
||||
else this.boundry = [...this.boundry, ...chars];
|
||||
this.charCode = this.input.charCodeAt(this.pos) || 0;
|
||||
if (this.boundry.includes(this.charCode)) this.charCode = 0;
|
||||
}
|
||||
|
||||
ascend(): void {
|
||||
if (!this.boundryStack.length) throw new Error("Unmatched boundry");
|
||||
this.boundry = this.boundryStack.pop();
|
||||
this.charCode = this.input.charCodeAt(this.pos) || 0;
|
||||
}
|
||||
|
||||
skipwhitespace(): Cursor {
|
||||
return this.walk((c) => c <= 32);
|
||||
}
|
||||
}
|
||||
|
||||
const CHAR_SINGLE_QUOTE = 39;
|
||||
const CHAR_DOUBLE_QUOTE = 34;
|
||||
const CHAR_OPEN_PAREN = 40;
|
||||
const CHAR_CLOSE_PAREN = 41;
|
||||
const CHAR_COMMA = 44;
|
||||
const CHAR_PERIOD = 46;
|
||||
const CHAR_COLON = 58;
|
||||
const CHAR_OPEN_BRACKET = 91;
|
||||
const CHAR_BACKSLASH = 92;
|
||||
const CHAR_CLOSE_BRACKET = 93;
|
||||
|
||||
const BINARY_OPERATORS = [
|
||||
">=",
|
||||
"<=",
|
||||
"<>",
|
||||
"=",
|
||||
">",
|
||||
"<",
|
||||
"LIKE",
|
||||
"NOT LIKE",
|
||||
"AND",
|
||||
"OR",
|
||||
"*",
|
||||
"/",
|
||||
"%",
|
||||
"||",
|
||||
"-",
|
||||
"+",
|
||||
];
|
||||
|
||||
const PRECEDENCE = {
|
||||
OR: 10,
|
||||
AND: 11,
|
||||
NOT: 12,
|
||||
"=": 20,
|
||||
"<>": 20,
|
||||
">": 20,
|
||||
">=": 20,
|
||||
"<": 20,
|
||||
"<=": 20,
|
||||
LIKE: 20,
|
||||
"NOT LIKE": 20,
|
||||
"IS NULL": 20,
|
||||
"IS NOT NULL": 20,
|
||||
"||": 30,
|
||||
"+": 31,
|
||||
"-": 31,
|
||||
"*": 32,
|
||||
"/": 32,
|
||||
"%": 32,
|
||||
};
|
||||
|
||||
function* range(s: number, e: number): Generator<number> {
|
||||
for (let i = s; i < e; i++) yield i;
|
||||
}
|
||||
|
||||
const PATH_CHARS = new Set<number>([
|
||||
...range(65, 91),
|
||||
...range(97, 123),
|
||||
...range(48, 58),
|
||||
95,
|
||||
45,
|
||||
42,
|
||||
123,
|
||||
125,
|
||||
]);
|
||||
|
||||
function findOperator(cursor: Cursor): string {
|
||||
cursor.skipwhitespace();
|
||||
let found = "";
|
||||
let foundCursor = cursor;
|
||||
let operators = [...BINARY_OPERATORS, "IS NULL", "IS NOT NULL"];
|
||||
for (let i = 0; operators.length && cursor.charCode; ++i) {
|
||||
let c = cursor.charCode;
|
||||
cursor.step();
|
||||
if (c >= 97 && c <= 122) c -= 32;
|
||||
if (c <= 32) {
|
||||
cursor.skipwhitespace();
|
||||
c = 32;
|
||||
}
|
||||
operators = operators.filter((o) => {
|
||||
if (o.charCodeAt(i) !== c) return false;
|
||||
if (o.length === i + 1) {
|
||||
found = o;
|
||||
foundCursor = cursor.fork();
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
if (found) cursor.sync(foundCursor);
|
||||
return found;
|
||||
}
|
||||
|
||||
// Turn escaped characters into real ones (e.g. "\\n" becomes "\n").
|
||||
function interpretEscapes(str): string {
|
||||
@@ -21,423 +182,330 @@ function interpretEscapes(str): string {
|
||||
});
|
||||
}
|
||||
|
||||
export function map<T>(exp: Expression, callback: (e: Expression) => T): T {
|
||||
if (!Array.isArray(exp)) return callback(exp);
|
||||
export function parseExpression(cursor: Cursor, presedence = 0): Expression {
|
||||
cursor.skipwhitespace();
|
||||
const char = cursor.charCode;
|
||||
let lhs: Expression;
|
||||
if (char === CHAR_OPEN_PAREN) {
|
||||
cursor.step();
|
||||
cursor.descend([CHAR_CLOSE_PAREN]);
|
||||
lhs = parseExpression(cursor, 0);
|
||||
cursor.ascend();
|
||||
|
||||
let clone;
|
||||
for (let i = 1; i < exp.length; ++i) {
|
||||
const sub = map(exp[i], callback);
|
||||
if (sub !== exp[i]) {
|
||||
clone = clone || exp.slice();
|
||||
clone[i] = sub;
|
||||
cursor.skipwhitespace();
|
||||
if (cursor.charCode !== CHAR_CLOSE_PAREN)
|
||||
throw new Error("Expected ')'" + cursor.pos + " " + cursor.charCode);
|
||||
cursor.step();
|
||||
} else if (char === CHAR_DOUBLE_QUOTE) {
|
||||
cursor.step();
|
||||
const cursor2 = cursor.fork();
|
||||
for (
|
||||
cursor2.descend([]);
|
||||
cursor2.charCode !== CHAR_DOUBLE_QUOTE;
|
||||
cursor2.step()
|
||||
) {
|
||||
if (!cursor2.charCode) throw new Error("Unterminated string");
|
||||
if (cursor2.charCode === CHAR_BACKSLASH) cursor2.step();
|
||||
}
|
||||
cursor2.ascend();
|
||||
const str = cursor.read(cursor2);
|
||||
cursor.sync(cursor2);
|
||||
cursor.step();
|
||||
return new Expression.Literal(interpretEscapes(str));
|
||||
} else if (char === CHAR_SINGLE_QUOTE) {
|
||||
cursor.step();
|
||||
const cursor2 = cursor.fork();
|
||||
for (
|
||||
cursor2.descend([]);
|
||||
cursor2.charCode !== CHAR_SINGLE_QUOTE;
|
||||
cursor2.step()
|
||||
) {
|
||||
if (!cursor2.charCode) throw new Error("Unterminated string");
|
||||
if (cursor2.charCode === CHAR_BACKSLASH) cursor2.step();
|
||||
}
|
||||
cursor2.ascend();
|
||||
const str = cursor.read(cursor2);
|
||||
cursor.sync(cursor2);
|
||||
cursor.step();
|
||||
return new Expression.Literal(str.replaceAll("''", "'"));
|
||||
} else {
|
||||
const cursor2 = cursor.fork();
|
||||
cursor.walk(
|
||||
(c) => c > 32 && c !== CHAR_OPEN_PAREN && c !== CHAR_OPEN_BRACKET,
|
||||
);
|
||||
const token = cursor2.read(cursor);
|
||||
if (!token) throw new Error("Invalid expression");
|
||||
if (/^true$/i.test(token)) lhs = new Expression.Literal(true);
|
||||
else if (/^false$/i.test(token)) lhs = new Expression.Literal(false);
|
||||
else if (/^null$/i.test(token)) lhs = new Expression.Literal(null);
|
||||
else if (/^-?(0|[1-9][0-9]*)([.][0-9]+)?([eE][+-]?[0-9]+)?$/.test(token))
|
||||
lhs = new Expression.Literal(Number(token));
|
||||
else if (/^not$/i.test(token)) {
|
||||
lhs = new Expression.Unary(
|
||||
"NOT",
|
||||
parseExpression(cursor, PRECEDENCE["NOT"]),
|
||||
);
|
||||
} else if (/^case$/i.test(token)) {
|
||||
const pairs: [Expression, Expression][] = [];
|
||||
for (;;) {
|
||||
cursor.skipwhitespace();
|
||||
const whenStr = cursor.fork().read(cursor.walk((c) => c > 32));
|
||||
if (/^else$/i.test(whenStr)) {
|
||||
lhs = parseExpression(cursor);
|
||||
continue;
|
||||
}
|
||||
if (/^end$/i.test(whenStr)) break;
|
||||
else if (lhs) throw new Error("Expected END");
|
||||
if (!/^when$/i.test(whenStr)) throw new Error("Expected WHEN");
|
||||
const condition = parseExpression(cursor);
|
||||
cursor.skipwhitespace();
|
||||
const thenStr = cursor.fork().read(cursor.walk((c) => c > 32));
|
||||
if (!/^then$/i.test(thenStr)) throw new Error("Expected THEN");
|
||||
const then = parseExpression(cursor);
|
||||
pairs.push([condition, then]);
|
||||
}
|
||||
if (!lhs) lhs = new Expression.Literal(null);
|
||||
while (pairs.length) {
|
||||
const [condition, then] = pairs.pop();
|
||||
lhs = new Expression.Conditional(condition, then, lhs);
|
||||
}
|
||||
} else if (cursor.charCode === CHAR_OPEN_PAREN) {
|
||||
cursor.step();
|
||||
cursor.descend([CHAR_CLOSE_PAREN]);
|
||||
cursor.skipwhitespace();
|
||||
const args = [] as Expression[];
|
||||
while (cursor.charCode) {
|
||||
cursor.descend([CHAR_COMMA], false);
|
||||
const e = parseExpression(cursor);
|
||||
args.push(e);
|
||||
cursor.ascend();
|
||||
cursor.skipwhitespace();
|
||||
if ((cursor.charCode as number) !== CHAR_COMMA) break;
|
||||
cursor.step();
|
||||
}
|
||||
cursor.ascend();
|
||||
cursor.step();
|
||||
lhs = new Expression.FunctionCall(token, args);
|
||||
} else {
|
||||
const p = parsePath(cursor2);
|
||||
lhs = new Expression.Parameter(p);
|
||||
cursor.sync(cursor2);
|
||||
}
|
||||
}
|
||||
|
||||
return callback(clone || exp);
|
||||
}
|
||||
|
||||
export async function mapAsync(
|
||||
exp: Expression,
|
||||
callback: (e: Expression) => Promise<Expression>,
|
||||
): Promise<Expression> {
|
||||
if (!Array.isArray(exp)) return callback(exp);
|
||||
|
||||
let clone;
|
||||
for (let i = 1; i < exp.length; ++i) {
|
||||
const sub = await mapAsync(exp[i], callback);
|
||||
if (sub !== exp[i]) {
|
||||
clone = clone || exp.slice();
|
||||
clone[i] = sub;
|
||||
}
|
||||
for (;;) {
|
||||
cursor.skipwhitespace();
|
||||
const cursor2 = cursor.fork();
|
||||
const op = findOperator(cursor2);
|
||||
const p = PRECEDENCE[op];
|
||||
if (p <= presedence) return lhs;
|
||||
if (op === "IS NULL") {
|
||||
lhs = new Expression.Unary("IS NULL", lhs);
|
||||
} else if (op === "IS NOT NULL") {
|
||||
lhs = new Expression.Unary("IS NOT NULL", lhs);
|
||||
} else if (BINARY_OPERATORS.includes(op)) {
|
||||
lhs = new Expression.Binary(op, lhs, parseExpression(cursor2, p));
|
||||
} else if (op) throw new Error("Unrecognized operator: " + op);
|
||||
else break;
|
||||
cursor.sync(cursor2);
|
||||
}
|
||||
|
||||
return callback(clone || exp);
|
||||
return lhs;
|
||||
}
|
||||
|
||||
function binaryLeft(
|
||||
operatorsParser: parsimmon.Parser<string>,
|
||||
nextParser: parsimmon.Parser<Expression>,
|
||||
): parsimmon.Parser<Expression> {
|
||||
return parsimmon.seqMap(
|
||||
nextParser,
|
||||
parsimmon.seq(operatorsParser, nextParser).many(),
|
||||
(first, rest) =>
|
||||
rest.reduce((acc, ch) => {
|
||||
const [op, another] = ch;
|
||||
if (Array.isArray(acc) && op === acc[0]) return acc.concat([another]);
|
||||
return [op, acc, another];
|
||||
}, first),
|
||||
);
|
||||
// Parse old-format alias value: unquoted, double-quoted, or single-quoted.
|
||||
// Returns the parsed string value. Cursor is advanced past the value.
|
||||
function parseOldAliasValue(cursor: Cursor): string {
|
||||
cursor.walk((c) => c <= 32); // skip leading whitespace
|
||||
const c = cursor.charCode;
|
||||
if (c === CHAR_DOUBLE_QUOTE) {
|
||||
// Double-quoted: use JSON.parse semantics (handles \", \n, \uXXXX etc.)
|
||||
// Descend with no boundaries so commas/brackets inside quotes are not
|
||||
// treated as terminators.
|
||||
const start = cursor.pos;
|
||||
cursor.step();
|
||||
cursor.descend([]);
|
||||
while (cursor.charCode) {
|
||||
if (cursor.charCode === CHAR_BACKSLASH) {
|
||||
cursor.step();
|
||||
if (!cursor.charCode) break;
|
||||
} else if (cursor.charCode === CHAR_DOUBLE_QUOTE) {
|
||||
cursor.ascend();
|
||||
cursor.step();
|
||||
return JSON.parse(cursor.input.slice(start, cursor.pos)) as string;
|
||||
}
|
||||
cursor.step();
|
||||
}
|
||||
throw new Error("Unterminated string");
|
||||
}
|
||||
if (c === CHAR_SINGLE_QUOTE) {
|
||||
// Single-quoted: strip quotes, no escape processing
|
||||
cursor.step();
|
||||
cursor.descend([]);
|
||||
const start = cursor.pos;
|
||||
while (cursor.charCode && cursor.charCode !== CHAR_SINGLE_QUOTE)
|
||||
cursor.step();
|
||||
if (!cursor.charCode) throw new Error("Unterminated string");
|
||||
const value = cursor.input.slice(start, cursor.pos);
|
||||
cursor.ascend();
|
||||
cursor.step();
|
||||
return value;
|
||||
}
|
||||
// Unquoted: read until , or ] (respecting boundary), trim result
|
||||
const start = cursor.pos;
|
||||
cursor.walk(() => true);
|
||||
return cursor.input.slice(start, cursor.pos).trim();
|
||||
}
|
||||
|
||||
const lang = parsimmon.createLanguage({
|
||||
ComparisonOperator: function () {
|
||||
return parsimmon
|
||||
.alt(
|
||||
parsimmon.string(">="),
|
||||
parsimmon.string("<>"),
|
||||
parsimmon.string("<="),
|
||||
parsimmon.string("="),
|
||||
parsimmon.string(">"),
|
||||
parsimmon.string("<"),
|
||||
)
|
||||
.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 () {
|
||||
return parsimmon
|
||||
.alt(
|
||||
parsimmon
|
||||
.regexp(/is\s+null/i)
|
||||
.result("IS NULL")
|
||||
.desc("IS NULL"),
|
||||
parsimmon
|
||||
.regexp(/is\s+not\s+null/i)
|
||||
.result("IS NOT NULL")
|
||||
.desc("IS NOT NULL"),
|
||||
)
|
||||
.notFollowedBy(parsimmon.regexp(/[a-zA-Z0-9_]/))
|
||||
.skip(parsimmon.optWhitespace);
|
||||
},
|
||||
NotOperator: function () {
|
||||
return parsimmon
|
||||
.regexp(/not/i)
|
||||
.result("NOT")
|
||||
.notFollowedBy(parsimmon.regexp(/[a-zA-Z0-9_]/))
|
||||
.skip(parsimmon.optWhitespace)
|
||||
.desc("NOT");
|
||||
},
|
||||
AndOperator: function () {
|
||||
return parsimmon
|
||||
.regexp(/and/i)
|
||||
.result("AND")
|
||||
.notFollowedBy(parsimmon.regexp(/[a-zA-Z0-9_]/))
|
||||
.skip(parsimmon.optWhitespace)
|
||||
.desc("AND");
|
||||
},
|
||||
OrOperator: function () {
|
||||
return parsimmon
|
||||
.regexp(/or/i)
|
||||
.result("OR")
|
||||
.notFollowedBy(parsimmon.regexp(/[a-zA-Z0-9_]/))
|
||||
.skip(parsimmon.optWhitespace)
|
||||
.desc("OR");
|
||||
},
|
||||
Parameter: function (r) {
|
||||
return parsimmon
|
||||
.alt(
|
||||
parsimmon.regexp(/[a-zA-Z0-9_.*-]+/),
|
||||
r.Expression.wrap(
|
||||
parsimmon.string("{").skip(parsimmon.optWhitespace),
|
||||
parsimmon.string("}"),
|
||||
),
|
||||
)
|
||||
.atLeast(1)
|
||||
.map((x) => ["PARAM", x.length > 1 ? ["||"].concat(x) : x[0]])
|
||||
.skip(parsimmon.optWhitespace)
|
||||
.desc("parameter");
|
||||
},
|
||||
StringValueSql: function () {
|
||||
return parsimmon
|
||||
.regexp(/'([^']*)'/, 1)
|
||||
.atLeast(1)
|
||||
.skip(parsimmon.optWhitespace)
|
||||
.map((s) => s.join("'"))
|
||||
.desc("string");
|
||||
},
|
||||
StringValueJs: function () {
|
||||
return parsimmon
|
||||
.regexp(/"((?:\\.|.)*?)"/, 1)
|
||||
.skip(parsimmon.optWhitespace)
|
||||
.map(interpretEscapes)
|
||||
.desc("string");
|
||||
},
|
||||
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");
|
||||
},
|
||||
BooleanValue: function () {
|
||||
return parsimmon
|
||||
.alt(
|
||||
parsimmon.regexp(/true/i).result(true).desc("TRUE"),
|
||||
parsimmon.regexp(/false/i).result(false).desc("FALSE"),
|
||||
)
|
||||
.notFollowedBy(parsimmon.regexp(/[a-zA-Z0-9_]/))
|
||||
.skip(parsimmon.optWhitespace);
|
||||
},
|
||||
NullValue: function () {
|
||||
return parsimmon
|
||||
.regexp(/null/i)
|
||||
.notFollowedBy(parsimmon.regexp(/[a-zA-Z0-9_]/))
|
||||
.skip(parsimmon.optWhitespace)
|
||||
.result(null)
|
||||
.desc("NULL");
|
||||
},
|
||||
FuncValue: function (r) {
|
||||
return parsimmon.seqMap(
|
||||
parsimmon
|
||||
.regexp(/([a-zA-Z0-9_]+)/, 1)
|
||||
.skip(parsimmon.optWhitespace)
|
||||
.desc("function"),
|
||||
r.ExpressionList.wrap(
|
||||
parsimmon.string("(").skip(parsimmon.optWhitespace),
|
||||
parsimmon.string(")").skip(parsimmon.optWhitespace),
|
||||
),
|
||||
(f, args) => ["FUNC", f.toUpperCase()].concat(args),
|
||||
// Parse old-format alias content: key:value,key:value,...
|
||||
// Cursor should be positioned at the start of bracket content
|
||||
// (after '[', with ']' set as boundary).
|
||||
// Returns an Expression: Binary("AND", ...) chain of Binary("=", param, literal).
|
||||
// Empty content returns Literal(true).
|
||||
function parseOldAlias(cursor: Cursor): Expression {
|
||||
cursor.skipwhitespace();
|
||||
if (!cursor.charCode) return new Expression.Literal(true);
|
||||
|
||||
let result: Expression | null = null;
|
||||
for (;;) {
|
||||
cursor.skipwhitespace();
|
||||
// Parse key as a path (supports nested aliases via parsePath).
|
||||
// Add colon and comma as boundaries so the path stops there.
|
||||
cursor.descend([CHAR_COLON, CHAR_COMMA], false);
|
||||
const keyPath = parsePath(cursor);
|
||||
cursor.ascend();
|
||||
|
||||
// After ascend, check for the colon separator
|
||||
cursor.skipwhitespace();
|
||||
if ((cursor.charCode as number) !== CHAR_COLON)
|
||||
throw new Error("Expected ':'");
|
||||
cursor.step();
|
||||
|
||||
// Parse value (reads until boundary , or ])
|
||||
cursor.descend([CHAR_COMMA], false);
|
||||
const value = parseOldAliasValue(cursor);
|
||||
cursor.ascend();
|
||||
|
||||
const pair = new Expression.Binary(
|
||||
"=",
|
||||
new Expression.Parameter(keyPath),
|
||||
new Expression.Literal(value),
|
||||
);
|
||||
},
|
||||
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,
|
||||
r.BooleanValue,
|
||||
r.NumberValue,
|
||||
r.StringValueSql,
|
||||
r.StringValueJs,
|
||||
r.FuncValue,
|
||||
r.CaseStatement,
|
||||
);
|
||||
},
|
||||
ValueExpression: function (r) {
|
||||
return binaryLeft(
|
||||
parsimmon.string("||").skip(parsimmon.optWhitespace),
|
||||
binaryLeft(
|
||||
parsimmon
|
||||
.alt(parsimmon.string("+"), parsimmon.string("-"))
|
||||
.skip(parsimmon.optWhitespace),
|
||||
binaryLeft(
|
||||
parsimmon
|
||||
.alt(
|
||||
parsimmon.string("*"),
|
||||
parsimmon.string("/"),
|
||||
parsimmon.string("%"),
|
||||
)
|
||||
.skip(parsimmon.optWhitespace),
|
||||
parsimmon.alt(
|
||||
r.Value,
|
||||
r.Parameter,
|
||||
r.Expression.wrap(
|
||||
parsimmon.string("(").skip(parsimmon.optWhitespace),
|
||||
parsimmon.string(")").skip(parsimmon.optWhitespace),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
Comparison: function (r) {
|
||||
return parsimmon.alt(
|
||||
parsimmon.seqMap(r.ValueExpression, r.IsNullOperator, (p, o) => [o, p]),
|
||||
parsimmon.seqMap(
|
||||
r.ValueExpression,
|
||||
r.ComparisonOperator,
|
||||
r.ValueExpression,
|
||||
(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],
|
||||
),
|
||||
);
|
||||
},
|
||||
ExpressionList: function (r) {
|
||||
return r.Expression.sepBy(
|
||||
parsimmon.string(",").skip(parsimmon.optWhitespace),
|
||||
);
|
||||
},
|
||||
Expression: function (r) {
|
||||
function unary(
|
||||
operatorsParser: parsimmon.Parser<string>,
|
||||
nextParser: parsimmon.Parser<Expression>,
|
||||
): parsimmon.Parser<Expression> {
|
||||
return parsimmon.seq(operatorsParser, nextParser).or(nextParser);
|
||||
result = result ? new Expression.Binary("AND", result, pair) : pair;
|
||||
|
||||
cursor.skipwhitespace();
|
||||
if ((cursor.charCode as number) !== CHAR_COMMA) break;
|
||||
cursor.step();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parsePath(cur: Cursor): Path {
|
||||
const segments: (string | Expression)[] = [];
|
||||
let colon = 0;
|
||||
const cur2 = cur.fork();
|
||||
for (;;) {
|
||||
let char = cur2.charCode;
|
||||
let exp: Expression;
|
||||
|
||||
if (char === CHAR_OPEN_BRACKET) {
|
||||
if (cur2.pos !== cur.pos) throw new Error("Invalid path");
|
||||
cur2.step();
|
||||
cur2.descend([CHAR_CLOSE_BRACKET]);
|
||||
// Try parsing as expression first
|
||||
const cur3 = cur2.fork();
|
||||
let err: unknown;
|
||||
try {
|
||||
exp = parseExpression(cur3);
|
||||
} catch (e) {
|
||||
err = e;
|
||||
}
|
||||
// Fall back to old alias format if not a valid expression or produced
|
||||
// a bare Parameter (old format like [a:1] mis-parsed as a path).
|
||||
if (err || exp instanceof Expression.Parameter) {
|
||||
exp = parseOldAlias(cur2);
|
||||
} else {
|
||||
cur2.sync(cur3);
|
||||
}
|
||||
cur2.ascend();
|
||||
char = cur2.charCode;
|
||||
if (char !== CHAR_CLOSE_BRACKET) throw new Error("Expected ']'");
|
||||
char = cur2.step().charCode;
|
||||
}
|
||||
|
||||
return binaryLeft(
|
||||
r.OrOperator,
|
||||
binaryLeft(
|
||||
r.AndOperator,
|
||||
unary(r.NotOperator, r.Comparison.or(r.ValueExpression)),
|
||||
),
|
||||
).trim(parsimmon.optWhitespace);
|
||||
},
|
||||
});
|
||||
if (!PATH_CHARS.has(char)) {
|
||||
if (cur2.pos <= cur.pos) throw new Error("Invalid path");
|
||||
|
||||
export function parse(str: string): Expression {
|
||||
if (!str) return null;
|
||||
return lang.Expression.tryParse(str);
|
||||
if (char === CHAR_COLON) {
|
||||
if (colon) throw new Error("Multiple colons");
|
||||
colon = segments.length + 1;
|
||||
}
|
||||
|
||||
if (exp) segments.push(exp);
|
||||
else segments.push(cur.read(cur2));
|
||||
|
||||
if (char !== CHAR_PERIOD && char !== CHAR_COLON) break;
|
||||
cur2.step();
|
||||
cur.sync(cur2);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (exp) throw new Error("Invalid path");
|
||||
cur2.step();
|
||||
}
|
||||
cur.sync(cur2);
|
||||
|
||||
if (colon) colon = segments.length - colon;
|
||||
return new Path(segments, colon);
|
||||
}
|
||||
|
||||
export function parseList(str: string): Expression[] {
|
||||
if (!str) return [];
|
||||
return lang.ExpressionList.tryParse(str);
|
||||
}
|
||||
|
||||
export function stringify(exp: Expression, level = 0): string {
|
||||
if (!Array.isArray(exp)) return JSON.stringify(exp);
|
||||
|
||||
const opLevels = {
|
||||
OR: 10,
|
||||
AND: 11,
|
||||
NOT: 12,
|
||||
"=": 20,
|
||||
"<>": 20,
|
||||
">": 20,
|
||||
">=": 20,
|
||||
"<": 20,
|
||||
"<=": 20,
|
||||
LIKE: 20,
|
||||
"NOT LIKE": 20,
|
||||
"IS NULL": 20,
|
||||
"IS NOT NULL": 20,
|
||||
"||": 30,
|
||||
"+": 31,
|
||||
"-": 31,
|
||||
"*": 32,
|
||||
"/": 32,
|
||||
"%": 32,
|
||||
};
|
||||
|
||||
const op = exp[0].toUpperCase();
|
||||
|
||||
function wrap(e): string {
|
||||
if (opLevels[op] <= level) return `(${e})`;
|
||||
export function stringifyExpression(exp: Expression, level = 0): string {
|
||||
function wrap(e: string, op: string): string {
|
||||
if (PRECEDENCE[op] <= level) return `(${e})`;
|
||||
else return e;
|
||||
}
|
||||
|
||||
if (op === "FUNC") {
|
||||
if (exp instanceof Expression.Literal) {
|
||||
if (exp.value == null) return "NULL";
|
||||
if (exp.value === true) return "TRUE";
|
||||
if (exp.value === false) return "FALSE";
|
||||
return JSON.stringify(exp.value);
|
||||
} else if (exp instanceof Expression.Unary) {
|
||||
if (exp.operator === "NOT") {
|
||||
return wrap(
|
||||
`NOT ${stringifyExpression(exp.operand, PRECEDENCE[exp.operator])}`,
|
||||
"NOT",
|
||||
);
|
||||
} else if (exp.operator === "IS NULL" || exp.operator === "IS NOT NULL") {
|
||||
return wrap(
|
||||
`${stringifyExpression(exp.operand, PRECEDENCE[exp.operator])} ${exp.operator}`,
|
||||
exp.operator,
|
||||
);
|
||||
}
|
||||
} else if (exp instanceof Expression.Binary) {
|
||||
const op = exp.operator;
|
||||
if (!(op in PRECEDENCE)) throw new Error("Invalid operator");
|
||||
return wrap(
|
||||
`${exp[1]}(${exp
|
||||
.slice(2)
|
||||
.map((e) => stringify(e))
|
||||
.join(", ")})`,
|
||||
`${stringifyExpression(exp.left, PRECEDENCE[op] - 1)} ${exp.operator} ${stringifyExpression(exp.right, PRECEDENCE[op])}`,
|
||||
op,
|
||||
);
|
||||
} else if (op === "PARAM") {
|
||||
if (typeof exp[1] === "string") {
|
||||
return wrap(exp[1]);
|
||||
} else if (Array.isArray(exp[1]) && exp[1][0] === "||") {
|
||||
return wrap(
|
||||
exp[1]
|
||||
.slice(1)
|
||||
.map((p) => {
|
||||
if (typeof p === "string") return p;
|
||||
else return `{${stringify(p)}}`;
|
||||
})
|
||||
.join(""),
|
||||
);
|
||||
} else {
|
||||
return wrap(`{${stringify(exp[1])}}`);
|
||||
}
|
||||
} else if (op === "IS NULL" || op === "IS NOT NULL") {
|
||||
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 === "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));
|
||||
});
|
||||
|
||||
if (op === "NOT") return wrap(`${op} ${parts[0]}`);
|
||||
else return wrap(parts.join(` ${op} `));
|
||||
} else {
|
||||
throw new Error(`Unrecognized operator ${exp[0]}`);
|
||||
} else if (exp instanceof Expression.Parameter) {
|
||||
return exp.path.toString();
|
||||
} else if (exp instanceof Expression.FunctionCall) {
|
||||
return `${exp.name}(${exp.args.map((a) => stringifyExpression(a)).join(", ")})`;
|
||||
} else if (exp instanceof Expression.Conditional) {
|
||||
let str = `CASE WHEN ${stringifyExpression(exp.condition)} THEN ${stringifyExpression(exp.then)}`;
|
||||
if (
|
||||
exp.otherwise instanceof Expression.Literal &&
|
||||
exp.otherwise.value == null
|
||||
)
|
||||
str += " END";
|
||||
else if (exp.otherwise instanceof Expression.Conditional)
|
||||
str += stringifyExpression(exp.otherwise).slice(4);
|
||||
else str += ` ELSE ${stringifyExpression(exp.otherwise)} END`;
|
||||
return str;
|
||||
}
|
||||
|
||||
throw new Error("Invalid expression");
|
||||
}
|
||||
|
||||
export function parseLikePattern(pat: string, esc: string): string[] {
|
||||
@@ -457,3 +525,33 @@ export function parseLikePattern(pat: string, esc: string): string[] {
|
||||
}
|
||||
return chars.filter((c) => c);
|
||||
}
|
||||
|
||||
export function likePatternToRegExp(pat: string, esc = "", flags = ""): RegExp {
|
||||
const convChars = {
|
||||
"-": "\\-",
|
||||
"/": "\\/",
|
||||
"\\": "\\/",
|
||||
"^": "\\^",
|
||||
$: "\\$",
|
||||
"*": "\\*",
|
||||
"+": "\\+",
|
||||
"?": "\\?",
|
||||
".": "\\.",
|
||||
"(": "\\(",
|
||||
")": "\\)",
|
||||
"|": "\\|",
|
||||
"[": "\\[",
|
||||
"]": "\\]",
|
||||
"{": "\\{",
|
||||
"}": "\\}",
|
||||
"\\%": ".*",
|
||||
"\\_": ".",
|
||||
};
|
||||
let chars = 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);
|
||||
}
|
||||
Reference in new issue
Block a user