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
+56
-50
@@ -1,7 +1,8 @@
|
||||
import { Filter } from "mongodb";
|
||||
import { EJSON } from "bson";
|
||||
import { complement } from "espresso-iisojs";
|
||||
import { parseLikePattern } from "../common/expression/parser.ts";
|
||||
import { Expression } from "../types.ts";
|
||||
import Expression from "../common/expression.ts";
|
||||
import { decodeTag } from "../util.ts";
|
||||
import {
|
||||
SynthContextBase,
|
||||
@@ -10,21 +11,14 @@ import {
|
||||
Clause,
|
||||
} from "../common/expression/synth.ts";
|
||||
import normalize from "../common/expression/normalize.ts";
|
||||
import * as BI from "../common/expression/bigint.ts";
|
||||
|
||||
type Minterm = number[];
|
||||
|
||||
function getParam(exp: Expression, collection: string): string {
|
||||
if (!Array.isArray(exp))
|
||||
if (!(exp instanceof Expression.Parameter))
|
||||
throw new Error("Left-hand operand must be a parameter");
|
||||
|
||||
if (exp[0] !== "PARAM")
|
||||
throw new Error("Left-hand operand must be a parameter");
|
||||
|
||||
if (typeof exp[1] !== "string")
|
||||
throw new Error("Left-hand operand must be a parameter");
|
||||
|
||||
const p = exp[1];
|
||||
const p = exp.path.toString();
|
||||
if (collection === "devices") {
|
||||
if (p === "DeviceID.ID") return "_id";
|
||||
else if (p === "DeviceID") return "_deviceId";
|
||||
@@ -117,12 +111,12 @@ function getTypes(parameter: string, collection: string): string[] {
|
||||
|
||||
function roundOid(oid: string, roundUp: boolean): string {
|
||||
const match = (oid.match(/^[0-9a-f]*/)?.[0] ?? "").slice(0, 24);
|
||||
let num = BI.BigInt("0x0" + match);
|
||||
let num = BigInt("0x0" + match);
|
||||
let lastChar = 0;
|
||||
if (oid.length > match.length) lastChar += oid.charCodeAt(match.length);
|
||||
if (match.length < 24) lastChar -= 48;
|
||||
if (lastChar > 0 && roundUp) num++;
|
||||
num <<= BI.BigInt(4 * (24 - match.length));
|
||||
num <<= BigInt(4 * (24 - match.length));
|
||||
if (lastChar < 0 && !roundUp) --num;
|
||||
const str = num.toString(16);
|
||||
if (str.length > 24 || str.startsWith("-")) return null;
|
||||
@@ -259,11 +253,12 @@ class MongoSynthContext extends SynthContextBase<Clause, MongoClause> {
|
||||
super();
|
||||
}
|
||||
|
||||
getMinterms(clause: Clause, res: true | false | null): number[][] {
|
||||
const exp = clause.expression();
|
||||
if (!Array.isArray(exp)) throw new Error("Invalid query expression");
|
||||
getMinterms(clause: Clause, res: number): number[][] {
|
||||
res = res & 0b111;
|
||||
if (res & (res - 1)) return complement(this.getMinterms(clause, ~res));
|
||||
|
||||
if (res === null) {
|
||||
const exp = clause.expression();
|
||||
if (res === 0b001) {
|
||||
const minterms: number[][] = [];
|
||||
for (const dep of clause.getNullables()) {
|
||||
const e = dep.operand.expression();
|
||||
@@ -280,36 +275,42 @@ class MongoSynthContext extends SynthContextBase<Clause, MongoClause> {
|
||||
return minterms;
|
||||
}
|
||||
|
||||
if ([">", "<", "="].includes(exp[0])) {
|
||||
const param = getParam(exp[1], this.collection);
|
||||
if (!(exp instanceof Expression.Binary))
|
||||
throw new Error("Invalid query expression");
|
||||
|
||||
let rhs = exp[2];
|
||||
if (typeof rhs === "boolean") rhs = +rhs;
|
||||
else if (typeof rhs !== "string" && typeof rhs !== "number")
|
||||
const truthy = res === 0b100;
|
||||
|
||||
if ([">", "<", "="].includes(exp.operator)) {
|
||||
const param = getParam(exp.left, this.collection);
|
||||
|
||||
if (!(exp.right instanceof Expression.Literal))
|
||||
throw new Error(`Right-hand operand must be a literal value`);
|
||||
|
||||
let rhs = exp.right.value;
|
||||
if (typeof rhs === "boolean") rhs = +rhs;
|
||||
|
||||
if (
|
||||
param.startsWith("Tags.") &&
|
||||
this.collection === "devices" &&
|
||||
exp[0] === "="
|
||||
exp.operator === "="
|
||||
) {
|
||||
const t = decodeTag(param.slice(5));
|
||||
const c = new MongoClauseArray("_tags", t);
|
||||
if (typeof rhs === "string") rhs = 2;
|
||||
if (exp[0] === "=") {
|
||||
if ((rhs === 1) !== res) return [];
|
||||
} else if (exp[0] === ">") {
|
||||
if ((res && rhs >= 1) || (!res && rhs < 1)) return [];
|
||||
} else if (exp[0] === "<") {
|
||||
if ((res && rhs <= 1) || (!res && rhs > 1)) return [];
|
||||
if (exp.operator === "=") {
|
||||
if ((rhs === 1) !== truthy) return [];
|
||||
} else if (exp.operator === ">") {
|
||||
if ((truthy && rhs >= 1) || (!truthy && rhs < 1)) return [];
|
||||
} else if (exp.operator === "<") {
|
||||
if ((truthy && rhs <= 1) || (!truthy && rhs > 1)) return [];
|
||||
}
|
||||
return [[(this.getVar(c) << 1) ^ 1]];
|
||||
}
|
||||
|
||||
let op: "$gt" | "$lt" | "$eq" | "$gte" | "$lte";
|
||||
if (exp[0] === "=") op = "$eq";
|
||||
else if (exp[0] === ">") op = res ? "$gt" : "$lte";
|
||||
else if (exp[0] === "<") op = res ? "$lt" : "$gte";
|
||||
if (exp.operator === "=") op = "$eq";
|
||||
else if (exp.operator === ">") op = truthy ? "$gt" : "$lte";
|
||||
else if (exp.operator === "<") op = truthy ? "$lt" : "$gte";
|
||||
|
||||
const possibleTypes = new Set(getTypes(param, this.collection));
|
||||
const clauses: MongoClause[] = [];
|
||||
@@ -333,7 +334,7 @@ class MongoSynthContext extends SynthContextBase<Clause, MongoClause> {
|
||||
}
|
||||
}
|
||||
|
||||
if (op === "$eq" && !res) {
|
||||
if (op === "$eq" && !truthy) {
|
||||
clauses.push(new MongoClauseCompare(param, "$eq", null, ""));
|
||||
return [clauses.map((c) => this.getVar(c) << 1)];
|
||||
}
|
||||
@@ -382,30 +383,35 @@ class MongoSynthContext extends SynthContextBase<Clause, MongoClause> {
|
||||
}
|
||||
|
||||
return clauses.map((c) => [(this.getVar(c) << 1) ^ 1]);
|
||||
} else if (exp[0] === "LIKE") {
|
||||
const pat = exp[2];
|
||||
if (typeof pat !== "string")
|
||||
} else if (exp.operator === "LIKE") {
|
||||
if (
|
||||
!(
|
||||
exp.right instanceof Expression.Literal &&
|
||||
typeof exp.right.value === "string"
|
||||
)
|
||||
)
|
||||
throw new Error("Right-hand operand of 'LIKE' must be a string");
|
||||
let p = exp[1];
|
||||
const pat = exp.right.value;
|
||||
|
||||
let p = exp.left;
|
||||
let caseSensitive = true;
|
||||
if (
|
||||
Array.isArray(p) &&
|
||||
p[0] === "FUNC" &&
|
||||
["UPPER", "LOWER"].includes(p[1])
|
||||
p instanceof Expression.FunctionCall &&
|
||||
["UPPER", "LOWER"].includes(p.name)
|
||||
) {
|
||||
if (p[1] === "UPPER" && pat !== pat.toUpperCase())
|
||||
return res ? [] : [[]];
|
||||
if (p[1] === "LOWER" && pat !== pat.toLowerCase())
|
||||
return res ? [] : [[]];
|
||||
if (p.name === "UPPER" && pat !== pat.toUpperCase())
|
||||
return truthy ? [] : [[]];
|
||||
if (p.name === "LOWER" && pat !== pat.toLowerCase())
|
||||
return truthy ? [] : [[]];
|
||||
caseSensitive = false;
|
||||
p = p[2];
|
||||
p = p.args[0];
|
||||
}
|
||||
|
||||
const param = getParam(p, this.collection);
|
||||
const c = new MongoClauseLike(param, pat, exp[3], caseSensitive);
|
||||
if (res) return [[(this.getVar(c) << 1) ^ 1]];
|
||||
const typeClase = new MongoClauseType(param, "string");
|
||||
const r = [[this.getVar(c) << 1, (this.getVar(typeClase) << 1) ^ 1]];
|
||||
const c = new MongoClauseLike(param, pat, null, caseSensitive);
|
||||
if (truthy) return [[(this.getVar(c) << 1) ^ 1]];
|
||||
const typeClause = new MongoClauseCompare(param, "$gte", "", "string");
|
||||
const r = [[this.getVar(c) << 1, (this.getVar(typeClause) << 1) ^ 1]];
|
||||
return r;
|
||||
} else {
|
||||
throw new Error("Invalid query expression");
|
||||
@@ -641,7 +647,7 @@ export function toMongoQuery(
|
||||
exp = normalize(exp);
|
||||
const clause = Clause.fromExpression(normalize(exp));
|
||||
const context = new MongoSynthContext(resource);
|
||||
const minterms = clause.true(context);
|
||||
const minterms = clause.getMinterms(context, 0b100);
|
||||
const minimized = context.minimize(minterms);
|
||||
if (!minimized.length) return false;
|
||||
return EJSON.deserialize(context.toQuery(minimized));
|
||||
@@ -650,5 +656,5 @@ export function toMongoQuery(
|
||||
export function validQuery(exp: Expression, resource: string): void {
|
||||
const clause = Clause.fromExpression(normalize(exp));
|
||||
const context = new MongoSynthContext(resource);
|
||||
clause.true(context);
|
||||
clause.getMinterms(context, 0b100);
|
||||
}
|
||||
+4
-3
@@ -1,5 +1,6 @@
|
||||
import { ObjectId } from "mongodb";
|
||||
import { Expression, FaultStruct } from "../types.ts";
|
||||
import { FaultStruct } from "../types.ts";
|
||||
import { Value } from "../common/expression.ts";
|
||||
|
||||
export interface Fault {
|
||||
_id: string;
|
||||
@@ -71,7 +72,7 @@ interface TaskDeleteObject extends TaskBase {
|
||||
|
||||
interface TaskProvisions extends TaskBase {
|
||||
name: "provisions";
|
||||
provisions?: [string, ...Expression[]][];
|
||||
provisions?: [string, ...Value[]][];
|
||||
}
|
||||
|
||||
export type Task =
|
||||
@@ -122,7 +123,7 @@ type Configuration =
|
||||
| { type: "delete_tag"; tag: string }
|
||||
| { type: "add_object"; name: string; object: string }
|
||||
| { type: "delete_object"; name: string; object: string }
|
||||
| { type: "provision"; name: string; args?: Expression[] };
|
||||
| { type: "provision"; name: string; args?: string[] };
|
||||
|
||||
export interface Preset {
|
||||
_id: string;
|
||||
|
||||
+101
-41
@@ -1,4 +1,5 @@
|
||||
import { Expression } from "../types.ts";
|
||||
import Expression, { Value } from "../common/expression.ts";
|
||||
import Path from "../common/path.ts";
|
||||
import { encodeTag } from "../util.ts";
|
||||
|
||||
// Optimize projection by removing overlaps
|
||||
@@ -26,44 +27,62 @@ export function optimizeProjection(obj: { [path: string]: 1 }): {
|
||||
|
||||
export function convertOldPrecondition(q: Record<string, unknown>): Expression {
|
||||
function recursive(_query): Expression {
|
||||
const expressions: Expression[] = [];
|
||||
let res: Expression = new Expression.Literal(true);
|
||||
for (const [k, v] of Object.entries(_query)) {
|
||||
if (k[0] === "$") {
|
||||
if (k === "$and") {
|
||||
const and: Expression = ["AND"];
|
||||
for (const vv of Object.values(v)) and.push(recursive(vv));
|
||||
expressions.push(and);
|
||||
for (const vv of Object.values(v))
|
||||
res = Expression.and(res, recursive(vv));
|
||||
} else if (k === "$or") {
|
||||
const or: Expression = ["OR"];
|
||||
for (const vv of Object.values(v)) or.push(recursive(vv));
|
||||
expressions.push(or);
|
||||
let or: Expression = new Expression.Literal(false);
|
||||
for (const vv of Object.values(v))
|
||||
or = Expression.or(or, recursive(vv));
|
||||
res = Expression.and(res, or);
|
||||
} else {
|
||||
throw new Error(`Operator ${k} not supported`);
|
||||
}
|
||||
} else if (k === "_tags") {
|
||||
if (typeof v === "object") {
|
||||
if (Array.isArray(v)) throw new Error(`Invalid type`);
|
||||
const conjs: Expression[] = [];
|
||||
for (const [op, val] of Object.entries(v)) {
|
||||
if (op === "$ne") {
|
||||
if (typeof v["$ne"] !== "string")
|
||||
throw new Error("Only string values are allowed for _tags");
|
||||
conjs.push(["IS NULL", ["PARAM", `Tags.${encodeTag(val)}`]]);
|
||||
res = Expression.and(
|
||||
res,
|
||||
new Expression.Unary(
|
||||
"IS NULL",
|
||||
new Expression.Parameter(
|
||||
Path.parse(`Tags.${encodeTag(val)}`),
|
||||
),
|
||||
),
|
||||
);
|
||||
} else if (op === "$eq") {
|
||||
if (typeof v["$eq"] !== "string")
|
||||
throw new Error("Only string values are allowed for _tags");
|
||||
conjs.push(["IS NOT NULL", ["PARAM", `Tags.${encodeTag(val)}`]]);
|
||||
res = Expression.and(
|
||||
res,
|
||||
new Expression.Unary(
|
||||
"IS NOT NULL",
|
||||
new Expression.Parameter(
|
||||
Path.parse(`Tags.${encodeTag(val)}`),
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
throw new Error(`Invalid tag query`);
|
||||
}
|
||||
}
|
||||
if (conjs.length === 1) expressions.push(conjs[0]);
|
||||
else if (conjs.length > 1) expressions.push(["AND", ...conjs]);
|
||||
} else {
|
||||
expressions.push([
|
||||
"IS NOT NULL",
|
||||
["PARAM", `Tags.${encodeTag(v as string)}`],
|
||||
]);
|
||||
res = Expression.and(
|
||||
res,
|
||||
new Expression.Unary(
|
||||
"IS NOT NULL",
|
||||
new Expression.Parameter(
|
||||
Path.parse(`Tags.${encodeTag(v as string)}`),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
} else if (k.startsWith("Tags.")) {
|
||||
let exists: boolean;
|
||||
@@ -73,51 +92,92 @@ export function convertOldPrecondition(q: Record<string, unknown>): Expression {
|
||||
else if (v.hasOwnProperty("$exists")) exists = !!v["$exists"];
|
||||
else throw new Error(`Invalid tag query`);
|
||||
|
||||
expressions.push([exists ? "IS NOT NULL" : "IS NULL", ["PARAM", k]]);
|
||||
res = Expression.and(
|
||||
res,
|
||||
new Expression.Unary(
|
||||
exists ? "IS NOT NULL" : "IS NULL",
|
||||
new Expression.Parameter(Path.parse(k)),
|
||||
),
|
||||
);
|
||||
} else if (typeof v === "object") {
|
||||
if (Array.isArray(v)) throw new Error(`Invalid type`);
|
||||
|
||||
const exps: Expression[] = [];
|
||||
for (const [kk, vv] of Object.entries(v)) {
|
||||
if (kk === "$eq") {
|
||||
exps.push(["=", ["PARAM", k], vv]);
|
||||
res = Expression.and(
|
||||
res,
|
||||
new Expression.Binary(
|
||||
"=",
|
||||
new Expression.Parameter(Path.parse(k)),
|
||||
new Expression.Literal(vv),
|
||||
),
|
||||
);
|
||||
} else if (kk === "$ne") {
|
||||
exps.push([
|
||||
"OR",
|
||||
["<>", ["PARAM", k], vv],
|
||||
["IS NULL", ["PARAM", k]],
|
||||
]);
|
||||
const p = new Expression.Parameter(Path.parse(k));
|
||||
res = Expression.and(
|
||||
res,
|
||||
Expression.or(
|
||||
new Expression.Binary("<>", p, new Expression.Literal(vv)),
|
||||
new Expression.Unary("IS NULL", p),
|
||||
),
|
||||
);
|
||||
} else if (kk === "$lt") {
|
||||
exps.push(["<", ["PARAM", k], vv]);
|
||||
res = Expression.and(
|
||||
res,
|
||||
new Expression.Binary(
|
||||
"<",
|
||||
new Expression.Parameter(Path.parse(k)),
|
||||
new Expression.Literal(vv),
|
||||
),
|
||||
);
|
||||
} else if (kk === "$lte") {
|
||||
exps.push(["<=", ["PARAM", k], vv]);
|
||||
res = Expression.and(
|
||||
res,
|
||||
new Expression.Binary(
|
||||
"<=",
|
||||
new Expression.Parameter(Path.parse(k)),
|
||||
new Expression.Literal(vv),
|
||||
),
|
||||
);
|
||||
} else if (kk === "$gt") {
|
||||
exps.push([">", ["PARAM", k], vv]);
|
||||
res = Expression.and(
|
||||
res,
|
||||
new Expression.Binary(
|
||||
">",
|
||||
new Expression.Parameter(Path.parse(k)),
|
||||
new Expression.Literal(vv),
|
||||
),
|
||||
);
|
||||
} else if (kk === "$gte") {
|
||||
exps.push([">=", ["PARAM", k], vv]);
|
||||
res = Expression.and(
|
||||
res,
|
||||
new Expression.Binary(
|
||||
">=",
|
||||
new Expression.Parameter(Path.parse(k)),
|
||||
new Expression.Literal(vv),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
throw new Error(`Operator ${kk} not supported`);
|
||||
}
|
||||
if (!["string", "number", "boolean"].includes(typeof vv))
|
||||
throw new Error(`Invalid value for ${kk} operator`);
|
||||
}
|
||||
if (exps.length === 1) {
|
||||
expressions.push(exps[0]);
|
||||
} else if (exps.length > 1) {
|
||||
const and: Expression = ["AND"];
|
||||
expressions.push(and.concat(exps));
|
||||
}
|
||||
} else {
|
||||
expressions.push(["=", ["PARAM", k], v]);
|
||||
res = Expression.and(
|
||||
res,
|
||||
new Expression.Binary(
|
||||
"=",
|
||||
new Expression.Parameter(Path.parse(k)),
|
||||
new Expression.Literal(v as Value),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (expressions.length === 1) return expressions[0];
|
||||
if (expressions.length === 0) return true;
|
||||
return ["AND", ...expressions];
|
||||
return res;
|
||||
}
|
||||
|
||||
// empty filter
|
||||
if (!Object.keys(q).length) return true;
|
||||
if (!Object.keys(q).length) return new Expression.Literal(true);
|
||||
|
||||
return recursive(q);
|
||||
}
|
||||
Reference in new issue
Block a user