From 6f3ce6f7c8106a3dd34e589d0a4850840901fddc Mon Sep 17 00:00:00 2001 From: Zaid Humaimidi Date: Wed, 11 Mar 2026 19:43:09 -0700 Subject: [PATCH] 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 --- ARCHITECTURE.md | 76 ++- build/build.ts | 8 +- eslint.config.mjs | 2 +- lib/api-functions.ts | 139 ++--- lib/common/authorizer.ts | 60 +- lib/common/expression.ts | 291 +++++++++ lib/common/expression/bigint.ts | 87 --- lib/common/expression/evaluate.ts | 218 +++++++ lib/common/expression/normalize.ts | 460 +++++++------- lib/common/expression/pagination.ts | 72 ++- lib/common/expression/parser.ts | 894 +++++++++++++++------------- lib/common/expression/synth.ts | 669 +++++++++------------ lib/common/expression/util.ts | 393 ------------ lib/common/path-set.ts | 134 +++-- lib/common/path.ts | 245 +++----- lib/connection-request.ts | 45 +- lib/cwmp.ts | 204 ++++--- lib/cwmp/db.ts | 47 +- lib/cwmp/local-cache.ts | 128 ++-- lib/db/synth.ts | 106 ++-- lib/db/types.ts | 7 +- lib/db/util.ts | 142 +++-- lib/device.ts | 55 +- lib/init.ts | 19 +- lib/nbi.ts | 42 +- lib/session.ts | 331 +++++----- lib/types.ts | 20 +- lib/ui.ts | 17 +- lib/ui/api.ts | 354 ++++++----- lib/ui/db.ts | 202 ++----- lib/ui/local-cache.ts | 105 ++-- lib/versioned-map.ts | 8 + npm-shrinkwrap.json | 24 - package.json | 2 - test/db.ts | 15 +- test/device.ts | 41 +- test/pagination.ts | 20 +- test/path-set.ts | 52 +- test/path.ts | 153 ++++- test/synth.ts | 48 +- ui/app.ts | 24 +- ui/components/all-parameters.ts | 92 +-- ui/components/container.ts | 31 +- ui/components/device-actions.ts | 8 +- ui/components/device-faults.ts | 19 +- ui/components/device-link.ts | 8 +- ui/components/overview-dot.ts | 21 +- ui/components/parameter-list.ts | 10 +- ui/components/parameter-table.ts | 73 ++- ui/components/parameter.ts | 90 ++- ui/components/summon-button.ts | 20 +- ui/components/tags.ts | 9 +- ui/config-page.ts | 6 +- ui/config.ts | 112 +++- ui/device-page.ts | 29 +- ui/devices-page.ts | 124 ++-- ui/drawer-component.ts | 3 +- ui/faults-page.ts | 48 +- ui/files-page.ts | 47 +- ui/filter-component.ts | 84 ++- ui/index-table-component.ts | 8 +- ui/overview-page.ts | 53 +- ui/permissions-page.ts | 52 +- ui/pie-chart-component.ts | 35 +- ui/presets-page.ts | 55 +- ui/provisions-page.ts | 47 +- ui/smart-query.ts | 222 ++++--- ui/store.ts | 126 ++-- ui/ui-config-component.ts | 4 +- ui/users-page.ts | 52 +- ui/virtual-parameters-page.ts | 47 +- 71 files changed, 4045 insertions(+), 3449 deletions(-) create mode 100644 lib/common/expression.ts delete mode 100644 lib/common/expression/bigint.ts create mode 100644 lib/common/expression/evaluate.ts delete mode 100644 lib/common/expression/util.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 633b0fb..2218b82 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -46,6 +46,7 @@ connect to MongoDB and start an HTTP(S) server. bin/ Service entry points (5 executables) lib/ All backend logic common/ Shared utilities (expression engine, Path, errors) + expression.ts Expression class hierarchy (base + subclasses) expression/ Expression parser, evaluator, normalizer, minimizer cwmp/ CWMP-service-specific DB and caching db/ Database layer (MongoDB collections, query synthesis) @@ -78,15 +79,18 @@ MongoDB and start the HTTP server in each worker. by the extensions subsystem. It communicates with its parent via IPC, executing user-defined extension scripts in isolation. -### The Expression System (`lib/common/expression/`) +### The Expression System (`lib/common/expression.ts`, `lib/common/expression/`) -The `Expression` type (`string | number | boolean | null | any[]`) is the most -important abstraction in the codebase. It is a recursive, Lisp-like S-expression -where scalars are literal values and arrays are operations (the operator at -index 0, operands following). For example: +The `Expression` class (defined in `lib/common/expression.ts`) is the most +important abstraction in the codebase. It uses a typed class hierarchy: +`Expression.Literal` (wraps `string | number | boolean | null`), +`Expression.Parameter` (wraps a `Path`), `Expression.Binary` (operator + +left/right), `Expression.Unary` (operator + operand), `Expression.FunctionCall` +(name + args), and `Expression.Conditional` (condition/then/otherwise). For +example, in the SQL-like text syntax: ``` -["AND", ["=", ["PARAM", "Device.ModelName"], "BrandX"], [">", ["PARAM", "Events.Inform"], 1000]] +Device.ModelName = "BrandX" AND Events.Inform > 1000 ``` Expressions are used pervasively: @@ -101,23 +105,28 @@ Expressions are used pervasively: The expression pipeline flows through four modules: -1. `parser.ts` -- Parses a SQL-like text syntax into the AST (uses Parsimmon). - Also provides `map()` / `mapAsync()` tree-walking primitives and - `stringify()` for serialization. +1. `parser.ts` -- Parses a SQL-like text syntax into the AST using a hand-rolled + recursive descent parser with a `Cursor`-based scanner. Also provides + `stringifyExpression()` for serialization. The `map()` / `mapAsync()` + tree-walking primitives are abstract methods on the `Expression` class, and + `stringify()` is now `Expression.toString()`. 2. `normalize.ts` -- Algebraic normalization using exact rational polynomial - arithmetic over bigints. Ensures equivalent expressions have the same - canonical form (e.g., `a + 2 > b` and `a > b - 2` normalize identically). + arithmetic over native `bigint` values. The `Polynomial` class extends + `Expression` as an intermediate representation. Ensures equivalent + expressions have the same canonical form (e.g., `a + 2 > b` and `a > b - 2` + normalize identically). 3. `synth.ts` -- Boolean logic minimization via the Espresso algorithm (espresso-iisojs). Converts expressions to minimal sum-of-products form with three-valued logic (true/false/null). Handles domain-specific constraints like comparison ordering and LIKE pattern relationships. -4. `util.ts` -- Runtime evaluation. Resolves `PARAM` references against concrete - data, evaluates operators, and supports partial evaluation (returns a reduced - expression if some values are unknown). +4. `evaluate.ts` -- Runtime evaluation. The `reduce()` function evaluates + operators on literal values and supports partial evaluation (returns a + reduced expression if some values are unknown). Parameter resolution is + handled by the `Expression.evaluate()` method (in `lib/common/expression.ts`) + which calls `reduce()` after mapping children via a user-supplied callback. -`bigint.ts` provides a named-function wrapper around native bigint operations -for readability. `pagination.ts` implements cursor-based pagination by -generating filter expressions from sort-key bookmarks. +`pagination.ts` implements cursor-based pagination by generating filter +expressions from sort-key bookmarks. ### The Path System (`lib/common/path.ts`, `lib/common/path-set.ts`) @@ -127,17 +136,22 @@ query-based addressing. Key design decisions: -- **Interned/flyweight** -- All `Path` instances are cached. `Path.parse()` is - the only way to create them; the constructor is protected. Two-generation LRU - cache rotated every 120 seconds. +- **Cached** -- `Path.parse()` caches instances in a two-generation LRU cache + rotated every 120 seconds. The constructor is public (used directly by the + parser and by methods like `slice()`, `concat()`, and `stripAlias()`). - **Bitmask encoding** -- The `wildcard` and `alias` fields are bitfields for - O(1) segment-type checking. This limits paths to 32 segments. + O(1) segment-type checking. This limits paths to 32 segments. A `colon` field + tracks the number of attribute path segments (after a `:` separator), enabling + `paramLength` and `attrLength` accessors. - **Immutable** -- Segment arrays are `Object.freeze()`-d. `PathSet` is a multi-indexed collection of paths supporting pattern-matching -queries. It indexes paths by length, by segment-at-position, and by string -representation. The `find()` method uses set intersection to efficiently match -wildcard patterns. +queries. It maintains separate `paramSegmentIndex` and `attrSegmentIndex` arrays +(one `Map>` per position), plus a `stringIndex` map. The +`find()` method takes bitmasks to control which segments require exact matches +vs. wildcard compatibility, then uses set intersection across the smallest index +sets. A higher-level `findCompat()` method computes the appropriate bitmasks for +superset/subset matching. ### CWMP Protocol Layer (`lib/cwmp.ts`, `lib/soap.ts`, `lib/xml-parser.ts`) @@ -272,13 +286,17 @@ and CSV export. `lib/ui/db.ts` translates between the UI's flat parameter representation and MongoDB's nested document structure. The `flattenDevice()` function is the key transformation: it recursively walks the nested device document and produces a -flat key-value map (e.g., -`Device.WiFi.SSID.1.Name -> {value: [...], writable: true}`). +flat key-value map with colon-delimited attribute keys (e.g., +`"Device.WiFi.SSID.1.Name" -> value`, +`"Device.WiFi.SSID.1.Name:type" -> "xsd:string"`, +`"Device.WiFi.SSID.1.Name:writable" -> true`). The `FlatDevice` type is +`Record` where `Value = string | number | boolean | null`. `lib/ui/local-cache.ts` caches permissions, users, and config in-process with -hash-based revision tracking. The `getConfig()` function resolves config -expressions with dynamic context and backward compatibility for legacy config -keys. +hash-based revision tracking. The `getConfig()` function uses typed overloads -- +it takes a typed default value (`string`, `number`, or `boolean`) and an +expression evaluation callback, returning a value of the same type as the +default. ### Database Layer (`lib/db/`) diff --git a/build/build.ts b/build/build.ts index d07b70e..5000f8c 100644 --- a/build/build.ts +++ b/build/build.ts @@ -107,13 +107,7 @@ const packageDotJsonPlugin = { const inlineDepsPlugin = { name: "inlineDeps", setup(build) { - const deps = [ - "parsimmon", - "espresso-iisojs", - "@codemirror", - "mithril", - "yaml", - ]; + const deps = ["espresso-iisojs", "@codemirror", "mithril", "yaml"]; const depFiles = new Set(); build.onResolve({ filter: /^[^.]/ }, async (args) => { if (args.pluginData === "inlineDeps") return undefined; diff --git a/eslint.config.mjs b/eslint.config.mjs index 7518990..4c34b96 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -23,7 +23,7 @@ export default defineConfig( }, }, rules: { - "no-shadow": ["error", { allow: ["err"] }], + "@typescript-eslint/no-shadow": ["error", { allow: ["err"] }], "handle-callback-err": "error", "prefer-arrow-callback": "error", "no-buffer-constructor": "error", diff --git a/lib/api-functions.ts b/lib/api-functions.ts index 28cb580..16efcf4 100644 --- a/lib/api-functions.ts +++ b/lib/api-functions.ts @@ -31,8 +31,8 @@ import { udpConnectionRequest, xmppConnectionRequest, } from "./connection-request.ts"; -import { Expression, Task } from "./types.ts"; -import { evaluate } from "./common/expression/util.ts"; +import { Task } from "./types.ts"; +import Expression, { Value } from "./common/expression.ts"; import { hashPassword } from "./auth.ts"; import { flattenDevice } from "./ui/db.ts"; import { ResourceLockedError } from "./common/errors.ts"; @@ -42,7 +42,7 @@ const XMPP_CONFIGURED = !!config.get("XMPP_JID"); export async function connectionRequest( deviceId: string, - device?: Record, + device?: Record, ): Promise { if (!device) { const res = await collections.devices.findOne({ _id: deviceId }); @@ -58,47 +58,36 @@ export async function connectionRequest( password; if (device["InternetGatewayDevice.ManagementServer.ConnectionRequestURL"]) { - connectionRequestUrl = (device[ - "InternetGatewayDevice.ManagementServer.ConnectionRequestURL" - ].value || [""])[0]; - udpConnectionRequestAddress = (( + connectionRequestUrl = + device["InternetGatewayDevice.ManagementServer.ConnectionRequestURL"] || + ""; + udpConnectionRequestAddress = device[ "InternetGatewayDevice.ManagementServer.UDPConnectionRequestAddress" - ] || {} - ).value || [""])[0]; - stunEnable = (( - device["InternetGatewayDevice.ManagementServer.STUNEnable"] || {} - ).value || [""])[0]; - connReqJabberId = (( - device["InternetGatewayDevice.ManagementServer.ConnReqJabberID"] || {} - ).value || [""])[0]; - username = (( + ] || ""; + stunEnable = + device["InternetGatewayDevice.ManagementServer.STUNEnable"] || ""; + connReqJabberId = + device["InternetGatewayDevice.ManagementServer.ConnReqJabberID"] || ""; + username = device[ "InternetGatewayDevice.ManagementServer.ConnectionRequestUsername" - ] || {} - ).value || [""])[0]; - password = (( + ] || ""; + password = device[ "InternetGatewayDevice.ManagementServer.ConnectionRequestPassword" - ] || {} - ).value || [""])[0]; + ] || ""; } else { - connectionRequestUrl = (device[ - "Device.ManagementServer.ConnectionRequestURL" - ].value || [""])[0]; - udpConnectionRequestAddress = (( - device["Device.ManagementServer.UDPConnectionRequestAddress"] || {} - ).value || [""])[0]; - stunEnable = ((device["Device.ManagementServer.STUNEnable"] || {}) - .value || [""])[0]; - connReqJabberId = ((device["Device.ManagementServer.ConnReqJabberID"] || {}) - .value || [""])[0]; - username = (( - device["Device.ManagementServer.ConnectionRequestUsername"] || {} - ).value || [""])[0]; - password = (( - device["Device.ManagementServer.ConnectionRequestPassword"] || {} - ).value || [""])[0]; + connectionRequestUrl = + device["Device.ManagementServer.ConnectionRequestURL"] || ""; + udpConnectionRequestAddress = + device["Device.ManagementServer.UDPConnectionRequestAddress"] || ""; + stunEnable = device["Device.ManagementServer.STUNEnable"] || ""; + connReqJabberId = device["Device.ManagementServer.ConnReqJabberID"] || ""; + username = + device["Device.ManagementServer.ConnectionRequestUsername"] || ""; + password = + device["Device.ManagementServer.ConnectionRequestPassword"] || ""; } let remoteAddress; try { @@ -107,68 +96,70 @@ export async function connectionRequest( return "Invalid connection request URL"; } - const evalCallback = (exp): Expression => { - if (!Array.isArray(exp)) return exp; - if (exp[0] === "PARAM" && typeof exp[1] === "string") { - let name = exp[1]; + const snapshot = await getRevision(); + const now = Date.now(); + + const evalCallback = (exp: Expression): Expression => { + if (exp instanceof Expression.Parameter) { + let name = exp.path.toString(); if (name === "id") name = "DeviceID.ID"; else if (name === "serialNumber") name = "DeviceID.SerialNumber"; else if (name === "productClass") name = "DeviceID.ProductClass"; else if (name === "oui") name = "DeviceID.OUI"; - else if (name === "remoteAddress") return remoteAddress; - else if (name === "username") return username; - else if (name === "password") return password; - - const p = device[name]; - if (p?.value) return p.value[0]; - } else if (exp[0] === "FUNC") { - if (exp[1] === "REMOTE_ADDRESS") return remoteAddress; - else if (exp[1] === "USERNAME") return username; - else if (exp[1] === "PASSWORD") return password; + else if (name === "remoteAddress") + return new Expression.Literal(remoteAddress); + else if (name === "username") return new Expression.Literal(username); + else if (name === "password") return new Expression.Literal(password); + return new Expression.Literal(device[name] ?? null); + } else if (exp instanceof Expression.FunctionCall) { + if (exp.name === "NOW") return new Expression.Literal(now); + else if (exp.name === "REMOTE_ADDRESS") + return new Expression.Literal(remoteAddress); + else if (exp.name === "USERNAME") return new Expression.Literal(username); + else if (exp.name === "PASSWORD") return new Expression.Literal(password); } return exp; }; - const snapshot = await getRevision(); - const now = Date.now(); - const UDP_CONNECTION_REQUEST_PORT = +getConfig( + const configCallback = (exp: Expression): Expression.Literal => { + const e = evalCallback(exp); + if (e instanceof Expression.Literal) return e; + return new Expression.Literal(null); + }; + + const UDP_CONNECTION_REQUEST_PORT = getConfig( snapshot, "cwmp.udpConnectionRequestPort", - {}, - now, - evalCallback, + 0, + configCallback, ); - const CONNECTION_REQUEST_TIMEOUT = +getConfig( + const CONNECTION_REQUEST_TIMEOUT = getConfig( snapshot, "cwmp.connectionRequestTimeout", - {}, - now, - evalCallback, + 2000, + configCallback, ); - const CONNECTION_REQUEST_ALLOW_BASIC_AUTH = !!getConfig( + const CONNECTION_REQUEST_ALLOW_BASIC_AUTH = getConfig( snapshot, "cwmp.connectionRequestAllowBasicAuth", - {}, - now, - evalCallback, + false, + configCallback, ); let authExp: Expression = getConfigExpression( snapshot, "cwmp.connectionRequestAuth", ); - if (authExp === undefined) { - authExp = [ - "FUNC", - "AUTH", - ["PARAM", "username"], - ["PARAM", "password"], - ] as Expression; + if (!authExp) { + authExp = new Expression.FunctionCall("AUTH", [ + new Expression.FunctionCall("USERNAME", []), + new Expression.FunctionCall("PASSWORD", []), + ]); } - authExp = evaluate(authExp, {}, now, evalCallback); + authExp = authExp.evaluate(evalCallback); - const debug = !!getConfig(snapshot, "cwmp.debug", {}, now, evalCallback); + const debug = getConfig(snapshot, "cwmp.debug", false, configCallback); let udpProm = Promise.resolve(false); if (udpConnectionRequestAddress && +stunEnable) { diff --git a/lib/common/authorizer.ts b/lib/common/authorizer.ts index f27983b..ab5a58d 100644 --- a/lib/common/authorizer.ts +++ b/lib/common/authorizer.ts @@ -1,5 +1,5 @@ -import { PermissionSet, Expression } from "../types.ts"; -import { evaluate, or } from "./expression/util.ts"; +import { PermissionSet } from "../types.ts"; +import Expression from "./expression.ts"; export default class Authorizer { declare private permissionSets: PermissionSet[]; @@ -43,12 +43,12 @@ export default class Authorizer { if (this.getFilterCache.has(cacheKey)) return this.getFilterCache.get(cacheKey); - let filter: Expression = null; + let filter: Expression = new Expression.Literal(false); for (const permissionSet of this.permissionSets) { for (const perm of permissionSet) { if (perm[resourceType]) { if (perm[resourceType].access >= access) - filter = or(filter, perm[resourceType].filter); + filter = Expression.or(filter, perm[resourceType].filter); } } } @@ -64,7 +64,7 @@ export default class Authorizer { if (this.validatorCache.has(resource)) return this.validatorCache.get(resource); - const validators: Expression[] = []; + let validators: Expression = new Expression.Literal(false); for (const permissionSet of this.permissionSets) { for (const perm of permissionSet) { @@ -73,7 +73,7 @@ export default class Authorizer { perm[resourceType].access >= 3 && perm[resourceType].validate ) - validators.push(perm[resourceType].validate); + validators = Expression.or(validators, perm[resourceType].validate); } } @@ -82,8 +82,6 @@ export default class Authorizer { mutation: any, any: any, ): boolean => { - if (!validators.length) return false; - const object = { mutationType, mutation, @@ -92,31 +90,33 @@ export default class Authorizer { options: any, }; - const valueFunction = (paramName): any => { - const entry = paramName.split(".", 1)[0]; - paramName = paramName.slice(entry.length + 1); - let value = null; - if (["mutation", "options"].includes(entry)) { - value = object[entry]; - for (const seg of paramName.split(".")) { - if (value == null) break; - if (typeof value !== "object") value = null; - else value = value[seg]; + const now = Date.now(); + const res = validators.evaluate((exp) => { + if (exp instanceof Expression.Literal) return exp; + if (exp instanceof Expression.Parameter) { + if (exp.path.colon) return new Expression.Literal(null); + const entry = exp.path.segments[0] as string; + const paramName = exp.path.slice(1); + let value = null; + if (["mutation", "options"].includes(entry)) { + value = object[entry]; + for (const seg of paramName.segments) { + if (value == null) break; + if (typeof value !== "object") value = null; + else value = value[seg as string]; + } + } else if (object[entry]) { + if (paramName.length) value = object[entry][paramName.toString()]; + else value = object[entry]; } - } else if (object[entry]) { - if (paramName) value = object[entry][paramName]; - else value = object[entry]; + return new Expression.Literal(value); + } else if (exp instanceof Expression.FunctionCall) { + if (exp.name === "NOW") return new Expression.Literal(now); } + return new Expression.Literal(null); + }).value; - return value; - }; - - const res = evaluate( - validators.length > 1 ? ["OR", validators] : validators[0], - valueFunction, - Date.now(), - ); - return !Array.isArray(res) && !!res; + return !!res; }; this.validatorCache.set(resource, validator); diff --git a/lib/common/expression.ts b/lib/common/expression.ts new file mode 100644 index 0000000..d6a5241 --- /dev/null +++ b/lib/common/expression.ts @@ -0,0 +1,291 @@ +import { + Cursor, + parseExpression, + stringifyExpression, +} from "./expression/parser.ts"; +import Path from "./path.ts"; +import { reduce } from "./expression/evaluate.ts"; + +export type Value = string | number | boolean | null; + +export abstract class Expression { + private _string: string; + + abstract map(fn: (e: Expression, i: number) => Expression): Expression; + abstract mapAsync( + fn: (e: Expression, i: number) => Promise, + ): Promise; + + toString(): string { + if (!this._string) this._string = stringifyExpression(this); + return this._string; + } + + evaluate(fn: (e: Expression) => T = (e) => e as T): T { + return fn(reduce(this.map((e) => e.evaluate(fn)))); + } + + async evaluateAsync( + fn: (e: Expression) => Promise, + ): Promise { + return await fn( + reduce(await this.mapAsync(async (e) => await e.evaluateAsync(fn))), + ); + } + + static parse(input: string): Expression { + const cursor = new Cursor(input); + const exp = parseExpression(cursor); + if (cursor.charCode) throw new Error("Unexpected character"); + return exp; + } + + static and(left: Expression, right: Expression): Expression { + // Flatten same-operator tree into operands + const operands: Expression[] = []; + const stack: Expression[] = [right, left]; + while (stack.length) { + const e = stack.pop()!; + if (e instanceof Expression.Binary && e.operator === "AND") + stack.push(e.right, e.left); + else operands.push(e); + } + + // Fold literals using three-valued AND logic + let folded: boolean | null = true; + let i = 0; + while (i < operands.length) { + const e = operands[i]; + if (e instanceof Expression.Literal) { + operands.splice(i, 1); + if (e.value == null) folded = folded === false ? false : null; + else if (!e.value) return new Expression.Literal(false); + // truthy is identity for AND; discard + } else { + i++; + } + } + + // Rebuild: folded value + remaining non-literal operands + if (!operands.length) return new Expression.Literal(folded); + let result = operands.reduce((a, b) => new Expression.Binary("AND", a, b)); + if (folded === null) + result = new Expression.Binary( + "AND", + new Expression.Literal(null), + result, + ); + return result; + } + + static or(left: Expression, right: Expression): Expression { + // Flatten same-operator tree into operands + const operands: Expression[] = []; + const stack: Expression[] = [right, left]; + while (stack.length) { + const e = stack.pop()!; + if (e instanceof Expression.Binary && e.operator === "OR") + stack.push(e.right, e.left); + else operands.push(e); + } + + // Fold literals using three-valued OR logic + let folded: boolean | null = false; + let i = 0; + while (i < operands.length) { + const e = operands[i]; + if (e instanceof Expression.Literal) { + operands.splice(i, 1); + if (e.value == null) folded = folded === true ? true : null; + else if (e.value) return new Expression.Literal(true); + // falsy is identity for OR; discard + } else { + i++; + } + } + + // Rebuild: folded value + remaining non-literal operands + if (!operands.length) return new Expression.Literal(folded); + let result = operands.reduce((a, b) => new Expression.Binary("OR", a, b)); + if (folded === null) + result = new Expression.Binary( + "OR", + new Expression.Literal(null), + result, + ); + return result; + } +} + +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace Expression { + export class Literal extends Expression { + constructor(public readonly value: Value) { + super(); + } + + map(): Literal { + return this; + } + + async mapAsync(): Promise { + return this; + } + } + + export class Parameter extends Expression { + constructor(public readonly path: Path) { + super(); + } + + map(): Parameter { + return this; + } + + async mapAsync(): Promise { + return this; + } + + toString(): string { + return this.path.toString(); + } + } + + export class Unary extends Expression { + constructor( + public readonly operator: string, + public readonly operand: Expression, + ) { + super(); + } + + map(fn: (e: Expression, i: number) => Expression): Unary { + const operand = fn(this.operand, 0); + if (operand === this.operand) return this; + return Reflect.construct(this.constructor, [this.operator, operand]); + } + + async mapAsync( + fn: (e: Expression, i: number) => Promise, + ): Promise { + const operand = await fn(this.operand, 0); + if (operand === this.operand) return this; + return Reflect.construct(this.constructor, [this.operator, operand]); + } + } + + export class Binary extends Expression { + constructor( + public readonly operator: string, + public readonly left: Expression, + public readonly right: Expression, + ) { + super(); + } + + map(fn: (e: Expression, i: number) => Expression): Binary { + const left = fn(this.left, 0); + const right = fn(this.right, 1); + if (left === this.left && right === this.right) return this; + return Reflect.construct(this.constructor, [this.operator, left, right]); + } + + async mapAsync( + fn: (e: Expression, i: number) => Promise, + ): Promise { + const left = await fn(this.left, 0); + const right = await fn(this.right, 1); + if (left === this.left && right === this.right) return this; + return Reflect.construct(this.constructor, [this.operator, left, right]); + } + } + + export class FunctionCall extends Expression { + constructor( + public readonly name: string, + public readonly args: Expression[], + ) { + super(); + } + + map(fn: (e: Expression, i: number) => Expression): FunctionCall { + const args = this.args.map(fn); + if (args.every((arg, i) => arg === this.args[i])) return this; + return new FunctionCall(this.name, args); + } + + async mapAsync( + fn: (e: Expression, i: number) => Promise, + ): Promise { + const args = await Promise.all(this.args.map(fn)); + if (args.every((arg, i) => arg === this.args[i])) return this; + return new FunctionCall(this.name, args); + } + } + + export class Conditional extends Expression { + constructor( + public readonly condition: Expression, + public readonly then: Expression, + public readonly otherwise: Expression, + ) { + super(); + } + + map(fn: (e: Expression, i: number) => Expression): Conditional { + const condition = fn(this.condition, 0); + const then = fn(this.then, 1); + const otherwise = fn(this.otherwise, 2); + if ( + condition === this.condition && + then === this.then && + otherwise === this.otherwise + ) + return this; + return new Conditional(condition, then, otherwise); + } + + async mapAsync( + fn: (e: Expression, i: number) => Promise, + ): Promise { + const condition = await fn(this.condition, 0); + const then = await fn(this.then, 1); + const otherwise = await fn(this.otherwise, 2); + if ( + condition === this.condition && + then === this.then && + otherwise === this.otherwise + ) + return this; + return new Conditional(condition, then, otherwise); + } + } +} + +export function extractPaths(exp: Expression): Path[] { + if (exp instanceof Expression.Parameter) return [exp.path]; + const paths: Path[] = []; + exp.map((e) => { + if (e instanceof Expression.Parameter) paths.push(e.path); + else paths.push(...extractPaths(e)); + return e; + }); + return paths; +} + +export function parseList(input: string): Expression[] { + const CHAR_COMMA = 44; + const res: Expression[] = []; + const cursor = new Cursor(input); + cursor.skipwhitespace(); + if (!cursor.charCode) return res; + res.push(parseExpression(cursor)); + while (cursor.charCode === CHAR_COMMA) { + cursor.step(); + res.push(parseExpression(cursor)); + } + if (cursor.charCode) throw new Error("Unexpected character"); + return res; +} + +export default Expression; diff --git a/lib/common/expression/bigint.ts b/lib/common/expression/bigint.ts deleted file mode 100644 index 25c93ca..0000000 --- a/lib/common/expression/bigint.ts +++ /dev/null @@ -1,87 +0,0 @@ -export function and(a: bigint, b: bigint): bigint { - return a & b; -} - -export function or(a: bigint, b: bigint): bigint { - return a | b; -} - -export function xor(a: bigint, b: bigint): bigint { - return a ^ b; -} - -export function not(a: bigint): bigint { - return ~a; -} - -export function lshift(a: bigint, b: bigint): bigint { - return a << b; -} - -export function rshift(a: bigint, b: bigint): bigint { - return a >> b; -} - -export function add(a: bigint, b: bigint): bigint { - return a + b; -} - -export function sub(a: bigint, b: bigint): bigint { - return a - b; -} - -export function mul(a: bigint, b: bigint): bigint { - return a * b; -} - -export function div(a: bigint, b: bigint): bigint { - return a / b; -} - -export function exp(a: bigint, b: bigint): bigint { - return a ** b; -} - -export function rem(a: bigint, b: bigint): bigint { - return a % b; -} - -export function toNumber(a: bigint): number { - return Number(a); -} - -export function eq(a: bigint, b: bigint): boolean { - return a === b; -} - -export function ne(a: bigint, b: bigint): boolean { - return a !== b; -} - -export function lt(a: bigint, b: bigint): boolean { - return a < b; -} - -export function lte(a: bigint, b: bigint): boolean { - return a <= b; -} - -export function gt(a: bigint, b: bigint): boolean { - return a > b; -} - -export function gte(a: bigint, b: bigint): boolean { - return a >= b; -} - -export function asUintN(a: number, b: bigint): bigint { - return BigInt.asUintN(a, b); -} - -export function asIntN(a: number, b: bigint): bigint { - return BigInt.asIntN(a, b); -} - -const _BigInt = BigInt; -type _bigint = bigint; -export { _BigInt as BigInt, type _bigint as bigint }; diff --git a/lib/common/expression/evaluate.ts b/lib/common/expression/evaluate.ts new file mode 100644 index 0000000..c507adb --- /dev/null +++ b/lib/common/expression/evaluate.ts @@ -0,0 +1,218 @@ +import Expression from "../expression.ts"; +import { likePatternToRegExp } from "./parser.ts"; + +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; + } +} + +function toString(a: boolean | number | string): string { + switch (typeof a) { + case "string": + return a; + case "number": + return a.toString(); + case "boolean": + return (+a).toString(); + } +} + +const regExpCache: WeakMap = new WeakMap(); + +export function reduce(exp: Expression): Expression { + if (exp instanceof Expression.Literal) return exp; + + if (exp instanceof Expression.Unary) { + if (exp.operator === "NOT") { + if (exp.operand instanceof Expression.Literal) { + if (exp.operand.value == null) return exp.operand; + return new Expression.Literal(!exp.operand.value); + } else if (exp.operand instanceof Expression.Unary) { + if (exp.operand.operator === "NOT") return exp.operand.operand; + } + } else if (exp.operator === "IS NULL") { + if (exp.operand instanceof Expression.Literal) { + return new Expression.Literal(exp.operand.value == null); + } + } else if (exp.operator === "IS NOT NULL") { + if (exp.operand instanceof Expression.Literal) { + return new Expression.Literal(exp.operand.value != null); + } + } + } else if (exp instanceof Expression.Binary) { + if (exp.operator === "AND") { + return Expression.and(exp.left, exp.right); + } else if (exp.operator === "OR") { + return Expression.or(exp.left, exp.right); + } else if (["=", ">", "<", "<>", ">=", "<="].includes(exp.operator)) { + if (exp.left instanceof Expression.Literal && exp.left.value == null) + return exp.left; + if (exp.right instanceof Expression.Literal && exp.right.value == null) + return exp.right; + if ( + exp.left instanceof Expression.Literal && + exp.right instanceof Expression.Literal + ) { + const c = compare(exp.left.value, exp.right.value); + switch (exp.operator) { + case "=": + return new Expression.Literal(c === 0); + case ">": + return new Expression.Literal(c > 0); + case "<": + return new Expression.Literal(c < 0); + case "<>": + return new Expression.Literal(c !== 0); + case ">=": + return new Expression.Literal(c >= 0); + case "<=": + return new Expression.Literal(c <= 0); + } + } + } else if (["+", "-", "*", "/"].includes(exp.operator)) { + if (exp.left instanceof Expression.Literal && exp.left.value == null) + return exp.left; + if (exp.right instanceof Expression.Literal && exp.right.value == null) + return exp.right; + if ( + exp.left instanceof Expression.Literal && + exp.right instanceof Expression.Literal + ) { + const a = toNumber(exp.left.value); + const b = toNumber(exp.right.value); + switch (exp.operator) { + case "+": + return new Expression.Literal(a + b); + case "-": + return new Expression.Literal(a - b); + case "*": + return new Expression.Literal(a * b); + case "/": + return new Expression.Literal(a / b); + } + } + } else if (exp.operator === "%") { + if (exp.left instanceof Expression.Literal && exp.left.value == null) + return exp.left; + if (exp.right instanceof Expression.Literal && exp.right.value == null) + return exp.right; + if ( + exp.left instanceof Expression.Literal && + exp.right instanceof Expression.Literal + ) { + const a = toNumber(exp.left.value); + const b = Math.trunc(toNumber(exp.right.value)); + if (b === 0) return new Expression.Literal(null); + return new Expression.Literal(a % b); + } + } else if (exp.operator === "||") { + if (exp.left instanceof Expression.Literal && exp.left.value == null) + return exp.left; + if (exp.right instanceof Expression.Literal && exp.right.value == null) + return exp.right; + if ( + exp.left instanceof Expression.Literal && + exp.right instanceof Expression.Literal + ) { + const a = toString(exp.left.value); + const b = toString(exp.right.value); + return new Expression.Literal(a + b); + } + } else if (exp.operator === "LIKE") { + if (exp.left instanceof Expression.Literal && exp.left.value == null) + return exp.left; + if (exp.right instanceof Expression.Literal && exp.right.value == null) + return exp.right; + if ( + exp.left instanceof Expression.Literal && + exp.right instanceof Expression.Literal + ) { + const s = toString(exp.left.value); + let r = regExpCache.get(exp.right); + if (!r) { + r = likePatternToRegExp(toString(exp.right.value)); + regExpCache.set(exp.right, r); + } + return new Expression.Literal(r.test(s)); + } + } else if (exp.operator === "NOT LIKE") { + if (exp.left instanceof Expression.Literal && exp.left.value == null) + return exp.left; + if (exp.right instanceof Expression.Literal && exp.right.value == null) + return exp.right; + if ( + exp.left instanceof Expression.Literal && + exp.right instanceof Expression.Literal + ) { + const s = toString(exp.left.value); + let r = regExpCache.get(exp.right); + if (!r) { + r = likePatternToRegExp(toString(exp.right.value)); + regExpCache.set(exp.right, r); + } + return new Expression.Literal(!r.test(s)); + } + } + } else if (exp instanceof Expression.FunctionCall) { + if (exp.name === "COALESCE") { + const args = exp.args.filter( + (arg) => !(arg instanceof Expression.Literal && arg.value == null), + ); + if (!args.length) return new Expression.Literal(null); + if (args.length === 1) return args[0]; + if (args[0] instanceof Expression.Literal) return args[0]; + if (args.length !== exp.args.length) + return new Expression.FunctionCall("COALESCE", args); + } else if (exp.name === "UPPER") { + if (exp.args[0] instanceof Expression.Literal) { + if (exp.args[0].value == null) return exp.args[0]; + const a = toString(exp.args[0].value); + return new Expression.Literal(a.toUpperCase()); + } + } else if (exp.name === "LOWER") { + if (exp.args[0] instanceof Expression.Literal) { + if (exp.args[0].value == null) return exp.args[0]; + const a = toString(exp.args[0].value); + return new Expression.Literal(a.toLowerCase()); + } + } else if (exp.name === "ROUND") { + let p = 0; + if (exp.args.length > 1) { + if (exp.args[1] instanceof Expression.Literal) { + if (exp.args[1].value == null) return exp.args[1]; + p = Math.trunc(toNumber(exp.args[1].value)); + } + } + if (exp.args[0] instanceof Expression.Literal) { + if (exp.args[0].value == null) return exp.args[0]; + const n = toNumber(exp.args[0].value); + const d = 10 ** p; + const m = n * d * (1 + Number.EPSILON); + return new Expression.Literal(Math.round(m) / d); + } + } + } else if (exp instanceof Expression.Conditional) { + if (exp.condition instanceof Expression.Literal) { + if (exp.condition.value) return exp.then; + return exp.otherwise; + } + } + + return exp; +} diff --git a/lib/common/expression/normalize.ts b/lib/common/expression/normalize.ts index b330e64..da56aec 100644 --- a/lib/common/expression/normalize.ts +++ b/lib/common/expression/normalize.ts @@ -1,22 +1,15 @@ -import * as BI from "./bigint.ts"; -import { Expression } from "../../types.ts"; -import { map } from "./parser.ts"; -import { and, evaluateCallback } from "./util.ts"; - -const ZERO = BI.BigInt(0); -const ONE = BI.BigInt(1); -const TWO = BI.BigInt(2); -const NEGATIVE_ONE = BI.BigInt(-1); +import Expression from "../expression.ts"; +import { reduce } from "./evaluate.ts"; class Indeterminates { - declare public map: Map; - declare public sortedKeys: string[]; + declare public map: Map; + declare public sortedKeys: Expression[]; - public constructor(str?: string) { + public constructor(exp?: Expression) { this.map = new Map(); - if (str) { - this.map.set(str, 1); - this.sortedKeys = [str]; + if (exp) { + this.map.set(exp, 1); + this.sortedKeys = [exp]; } else { this.sortedKeys = []; } @@ -37,27 +30,31 @@ class Indeterminates { const res = new Indeterminates(); res.sortedKeys = indeterminates1.sortedKeys.slice(); res.map = new Map(indeterminates1.map); + const strMap: Map = new Map(); + for (const k of res.map.keys()) strMap.set(k.toString(), k); for (const [key, val] of indeterminates2.map) { - const v = res.map.get(key); - if (!v) { + const k = strMap.get(key.toString()); + if (!k) { res.map.set(key, val); res.sortedKeys.push(key); } else { - const v2 = val + v; + const v2 = val + res.map.get(k); if (!v2) { - res.map.delete(key); - res.sortedKeys = res.sortedKeys.filter((s) => s !== key); + res.map.delete(k); + res.sortedKeys = res.sortedKeys.filter((s) => s !== k); } else { - res.map.set(key, v2); + res.map.set(k, v2); } } } res.sortedKeys.sort((a, b) => { - if (a.length !== b.length) return b.length - a.length; - else if (a > b) return 1; - else if (a < b) return -1; + const str1 = a.toString(); + const str2 = b.toString(); + if (str1.length !== str2.length) return str2.length - str1.length; + else if (str1 > str2) return 1; + else if (str1 < str2) return -1; return 0; }); @@ -73,10 +70,10 @@ class Indeterminates { const k2 = b.sortedKeys[i]; const w2 = b.map.get(k2); if (w1 !== w2) return w2 - w1; - if (k1.length > k2.length) return -1; - else if (k1.length < k2.length) return 1; - else if (k1 > k2) return 1; - else if (k1 < k2) return -1; + if (k1.toString().length > k2.toString().length) return -1; + else if (k1.toString().length < k2.toString().length) return 1; + else if (k1.toString() > k2.toString()) return 1; + else if (k1.toString() < k2.toString()) return -1; } return 0; } @@ -84,26 +81,35 @@ class Indeterminates { interface Term { indeterminates: Indeterminates; - coefficientNumerator: BI.bigint; - coefficientDenominator: BI.bigint; + coefficientNumerator: bigint; + coefficientDenominator: bigint; } -function findGcd(a: BI.bigint, b: BI.bigint): BI.bigint { - while (BI.ne(b, ZERO)) { +function findGcd(a: bigint, b: bigint): bigint { + while (b !== 0n) { const t = b; - b = BI.rem(a, b); + b = a % b; a = t; } return a; } -class Polynomial { +class Polynomial extends Expression { declare public terms: Term[]; public constructor(terms: Term[]) { + super(); this.terms = terms; } + map(): Polynomial { + return this; + } + + async mapAsync(): Promise { + return this; + } + public static simplifyTerms(terms: Term[]): Term[] { const ts = terms .slice() @@ -115,40 +121,37 @@ class Polynomial { const t1 = ts[i - 1]; const t2 = ts[i]; if (Indeterminates.compare(t1.indeterminates, t2.indeterminates) === 0) { - const numerator = BI.add( - BI.mul(t1.coefficientNumerator, t2.coefficientDenominator), - BI.mul(t2.coefficientNumerator, t1.coefficientDenominator), - ); + const numerator = + t1.coefficientNumerator * t2.coefficientDenominator + + t2.coefficientNumerator * t1.coefficientDenominator; - const denominator = BI.mul( - t1.coefficientDenominator, - t2.coefficientDenominator, - ); + const denominator = + t1.coefficientDenominator * t2.coefficientDenominator; const gcd = findGcd(numerator, denominator); ts[i] = { indeterminates: t2.indeterminates, - coefficientNumerator: BI.div(numerator, gcd), - coefficientDenominator: BI.div(denominator, gcd), + coefficientNumerator: numerator / gcd, + coefficientDenominator: denominator / gcd, }; ts[i - 1] = { indeterminates: t1.indeterminates, - coefficientNumerator: ZERO, + coefficientNumerator: 0n, coefficientDenominator: t1.coefficientDenominator, }; } } - return ts.filter((v) => BI.ne(v.coefficientNumerator, ZERO)); + return ts.filter((v) => v.coefficientNumerator !== 0n); } public static fromIndeterminate(indeterminate: Expression): Polynomial { - const indeterminates = new Indeterminates(JSON.stringify(indeterminate)); + const indeterminates = new Indeterminates(indeterminate); const terms = [ { indeterminates: indeterminates, - coefficientNumerator: ONE, - coefficientDenominator: ONE, + coefficientNumerator: 1n, + coefficientDenominator: 1n, }, ]; return new Polynomial(terms); @@ -156,15 +159,12 @@ class Polynomial { public static fromConstant(constant: number): Polynomial { const [int, frac] = Math.abs(constant).toString(2).split(".", 2); - let numerator = BI.BigInt("0b" + int); - if (constant < 0) numerator = BI.mul(numerator, NEGATIVE_ONE); - let denominator = ONE; + let numerator = BigInt("0b" + int); + if (constant < 0) numerator = numerator / -1n; + let denominator = 1n; if (frac) { - denominator = BI.exp(TWO, BI.BigInt(frac.length)); - numerator = BI.add( - BI.mul(numerator, denominator), - BI.BigInt("0b" + frac), - ); + denominator = 2n ** BigInt(frac.length); + numerator = numerator * denominator + BigInt("0b" + frac); } const terms = [ @@ -181,7 +181,7 @@ class Polynomial { public negation(): Polynomial { const terms = this.terms.map((t) => ({ indeterminates: t.indeterminates, - coefficientNumerator: BI.mul(t.coefficientNumerator, NEGATIVE_ONE), + coefficientNumerator: t.coefficientNumerator * -1n, coefficientDenominator: t.coefficientDenominator, })); @@ -217,14 +217,9 @@ class Polynomial { for (const t1 of this.terms) { for (const t2 of rhs.terms) { - const numerator = BI.mul( - t1.coefficientNumerator, - t2.coefficientNumerator, - ); - const denominator = BI.mul( - t1.coefficientDenominator, - t2.coefficientDenominator, - ); + const numerator = t1.coefficientNumerator * t2.coefficientNumerator; + const denominator = + t1.coefficientDenominator * t2.coefficientDenominator; const gcd = findGcd(numerator, denominator); terms.push({ @@ -232,8 +227,8 @@ class Polynomial { t1.indeterminates, t2.indeterminates, ), - coefficientNumerator: BI.div(numerator, gcd), - coefficientDenominator: BI.div(denominator, gcd), + coefficientNumerator: numerator / gcd, + coefficientDenominator: denominator / gcd, }); } } @@ -245,41 +240,49 @@ class Polynomial { return this.multiply(rhs.reciprocal()); } - public toString(): string { - const add: string[] = []; + public toExpression(): Expression { + const add: Expression[] = []; for (const t of this.terms) { const coefficient = - BI.toNumber(t.coefficientNumerator) / - BI.toNumber(t.coefficientDenominator); + Number(t.coefficientNumerator) / Number(t.coefficientDenominator); - const mul: string[] = []; + const mul: Expression[] = []; if (t.indeterminates.sortedKeys.length) { for (const k of t.indeterminates.sortedKeys) { const w = t.indeterminates.map.get(k); for (let i = Math.abs(w); i > 0; --i) { if (w > 0) mul.push(k); - else mul.push(`["/",1,${k}]`); + else + mul.push( + new Expression.Binary("/", new Expression.Literal(1), k), + ); } } - if (coefficient !== 1) mul.push(coefficient.toString()); + if (coefficient !== 1) mul.push(new Expression.Literal(coefficient)); - if (mul.length > 1) add.push(`["*",${mul.join(",")}]`); - else add.push(mul["0"]); + while (mul.length > 1) { + const r = mul.pop(); + const l = mul.pop(); + mul.push(new Expression.Binary("*", l, r)); + } + add.push(mul[0]); } else { - add.push(coefficient.toString()); + add.push(new Expression.Literal(coefficient)); } } - if (!add.length) return "0"; - else if (add.length === 1) return add[0]; - else return `["+",${add.join(",")}]`; + while (add.length > 1) { + const r = add.pop(); + const l = add.pop(); + add.push(new Expression.Binary("+", l, r)); + } + + if (!add.length) return new Expression.Literal(0); + return add[0]; } } -const ADDITIVE_IDENTITY = Polynomial.fromConstant(0); -const MULTIPLICATIVE_IDENTITY = Polynomial.fromConstant(1); - const SWAPPED_OPS = { "=": "=", "<>": "<>", @@ -289,156 +292,163 @@ const SWAPPED_OPS = { "<=": ">=", }; +function cartesianProduct(arrays: T[][]): T[][] { + return arrays.reduce( + (acc, cur) => acc.flatMap((a) => cur.map((item) => [...a, item])), + [[]], + ); +} + +function toPolynomial(e: Expression): Polynomial { + if (e instanceof Polynomial) return e; + if (e instanceof Expression.Literal) { + if (e.value == null) return null; + if (typeof e.value === "number") return Polynomial.fromConstant(e.value); + if (typeof e.value === "string") + return Polynomial.fromConstant(parseFloat(e.value) || 0); + if (typeof e.value === "boolean") return Polynomial.fromConstant(+e.value); + } + return Polynomial.fromIndeterminate(e); +} + +function fromPolynomial(e: Expression): Expression { + if (e instanceof Polynomial) return e.toExpression(); + if (e instanceof Expression.Conditional) return e.map(fromPolynomial); + return e; +} + function normalizeCallback(exp: Expression): Expression { - if (!Array.isArray(exp)) return exp; - const op = exp[0]; - - if (op === "FUNC" && exp[1] === "COALESCE") { - const res: Expression[] = ["CASE"]; - for (let i = 2; i < exp.length; ++i) - res.push(normalizeCallback(["IS NOT NULL", exp[i]]), exp[i]); - return normalizeCallback(res); - } - - 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]]); - perms.push([true, null]); - 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]; - }), + if (exp instanceof Expression.FunctionCall) { + if (exp.name === "COALESCE") { + let e: Expression = new Expression.Literal(null); + for (let i = exp.args.length - 1; i >= 0; --i) { + e = new Expression.Conditional( + normalizeCallback(new Expression.Unary("IS NOT NULL", exp.args[i])), + exp.args[i], + e, ); - if (!Array.isArray(w) && w) break; } - res = res2; + return normalizeCallback(e); } - - for (const r of res) r[1] = normalizeCallback(r[1]); - - 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 (exp instanceof Expression.Conditional) { + let e = exp; + + if (e.condition instanceof Polynomial) + e = new Expression.Conditional( + e.condition.toExpression(), + e.then, + e.otherwise, + ); + + if (e.then instanceof Expression.Conditional) { + e = new Expression.Conditional( + Expression.and(e.condition, e.then.condition), + e.then.then, + new Expression.Conditional(e.condition, e.then.otherwise, e.otherwise), + ); + } + + return 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); + const combs: [Expression, Expression][][] = []; + const callback: (e: Expression) => Expression = (e) => { + if (e instanceof Expression.Conditional) { + combs[combs.length - 1].push([e.condition, e.then]); + callback(e.otherwise); + } else { + combs[combs.length - 1].push([new Expression.Literal(true), e]); } - 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 e; + }; + + exp.map((e) => { + combs.push([]); + callback(e); + return e; + }); + + if (combs.some((a) => a.length > 1)) { + let res: Expression = new Expression.Literal(null); + for (const p of cartesianProduct(combs).reverse()) { + let condition: Expression = new Expression.Literal(true); + const e = reduce( + normalizeCallback( + exp.map((_, i) => { + condition = Expression.and(condition, p[i][0]); + return p[i][1]; + }), + ), + ); + + if (!(condition instanceof Expression.Literal)) + res = new Expression.Conditional(condition, e, res); + else if (condition.value) res = e; } - 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]); + return res; + } - if (exp[2] instanceof Polynomial) rhs = exp[2]; - else if (typeof exp[2] === "number") rhs = Polynomial.fromConstant(exp[2]); + if (exp instanceof Expression.Binary) { + if (["+", "-", "*", "/"].includes(exp.operator)) { + const lhs = toPolynomial(exp.left); + const rhs = toPolynomial(exp.right); + if (lhs == null || rhs == null) return new Expression.Literal(null); + if (exp.operator === "+") return lhs.add(rhs); + if (exp.operator === "-") return lhs.subtract(rhs); + if (exp.operator === "*") return lhs.multiply(rhs); + if (exp.operator === "/") return lhs.divide(rhs); + } else if ([">", ">=", "<", "<=", "=", "<>"].includes(exp.operator)) { + let lhs: Polynomial, rhs: Polynomial; - if (lhs || rhs) { - if (!lhs) lhs = Polynomial.fromIndeterminate(exp[1]); - if (!rhs) rhs = Polynomial.fromIndeterminate(exp[2]); + if (exp.left instanceof Polynomial) lhs = exp.left; + else if (exp.left instanceof Expression.Literal) { + if (exp.left.value == null) return exp.left; + else if (typeof exp.left.value === "number") + lhs = Polynomial.fromConstant(exp.left.value); + } - lhs = lhs.subtract(rhs); - rhs = lhs.constant().negation(); - lhs = lhs.add(rhs); + if (exp.right instanceof Polynomial) rhs = exp.right; + else if (exp.right instanceof Expression.Literal) { + if (exp.right.value == null) return exp.right; + else if (typeof exp.right.value === "number") + rhs = Polynomial.fromConstant(exp.right.value); + } - if (!lhs.terms.length) { - exp = [op, JSON.parse(lhs.toString()), JSON.parse(rhs.toString())]; + if (lhs || rhs) { + if (!lhs) lhs = Polynomial.fromIndeterminate(exp.left); + if (!rhs) rhs = Polynomial.fromIndeterminate(exp.right); + + lhs = lhs.subtract(rhs); + rhs = lhs.constant().negation(); + lhs = lhs.add(rhs); + + if (!lhs.terms.length) { + const l = lhs.toExpression() as Expression.Literal; + const r = rhs.toExpression() as Expression.Literal; + + if (exp.operator === "=") + return new Expression.Literal(l.value === r.value); + else if (exp.operator === "<>") + return new Expression.Literal(l.value !== r.value); + else if (exp.operator === ">") + return new Expression.Literal(l.value > r.value); + else if (exp.operator === ">=") + return new Expression.Literal(l.value >= r.value); + else if (exp.operator === "<") + return new Expression.Literal(l.value < r.value); + else if (exp.operator === "<=") + return new Expression.Literal(l.value <= r.value); + else throw new Error("Invalid operator"); + } - 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; + if (n < 0n || d < 0n) flipOp *= -1; const reciprocal = new Polynomial([ { @@ -463,30 +473,18 @@ function normalizeCallback(exp: Expression): Expression { rhs = rhs.reciprocal(); } - if (flipOp < 0) exp = [SWAPPED_OPS[op], lhs, rhs]; - else exp = [op, lhs, rhs]; + if (flipOp > 0) exp = new Expression.Binary(exp.operator, lhs, rhs); + else exp = new Expression.Binary(SWAPPED_OPS[exp.operator], lhs, rhs); } } } // Restore polynomial expressions - exp = exp.map((e) => - e instanceof Polynomial ? JSON.parse(e.toString()) : e, - ); - - exp = evaluateCallback(exp); + exp = exp.map(fromPolynomial); return exp; } -export default function normalize(expr: Expression): Expression { - expr = map(expr, normalizeCallback); - if (expr instanceof Polynomial) { - expr = JSON.parse(expr.toString()); - } else if (Array.isArray(expr) && expr[0] === "CASE") { - expr = expr.map((e) => - e instanceof Polynomial ? JSON.parse(e.toString()) : e, - ); - } - return expr; +export default function normalize(exp: Expression): Expression { + return fromPolynomial(exp.evaluate(normalizeCallback)); } diff --git a/lib/common/expression/pagination.ts b/lib/common/expression/pagination.ts index f232b64..9ca6825 100644 --- a/lib/common/expression/pagination.ts +++ b/lib/common/expression/pagination.ts @@ -1,8 +1,8 @@ import { complement } from "espresso-iisojs"; -import { Expression } from "../../types.ts"; +import Expression from "../expression.ts"; import normalize from "./normalize.ts"; import { Clause, SynthContext } from "./synth.ts"; -import { and, or } from "./util.ts"; +import Path from "../path.ts"; type Bookmark = Record; @@ -27,25 +27,35 @@ export function bookmarkToExpression( ): Expression { return Object.entries(sort) .reverse() - .reduce((cur, kv) => { - const [param, asc] = kv; - if (asc < 0) { - if (bookmark[param] == null) { - return or( - ["IS NOT NULL", ["PARAM", param]], - and(["IS NULL", ["PARAM", param]], cur), + .reduce( + (cur, kv) => { + const [param, asc] = kv; + const p = new Expression.Parameter(Path.parse(param)); + const b = new Expression.Literal(bookmark[param]); + if (asc < 0) { + if (bookmark[param] == null) { + return Expression.or( + new Expression.Unary("IS NOT NULL", p), + Expression.and(new Expression.Unary("IS NULL", p), cur), + ); + } + return new Expression.Binary( + "OR", + new Expression.Binary(">", p, b), + new Expression.Binary("AND", new Expression.Binary("=", p, b), cur), + ); + } else { + let f: Expression = new Expression.Unary("IS NULL", p); + if (bookmark[param] == null) return Expression.and(f, cur); + f = Expression.or(f, new Expression.Binary("<", p, b)); + return Expression.or( + f, + Expression.and(new Expression.Binary("=", p, b), cur), ); } - let f = null; - f = or(f, [">", ["PARAM", param], bookmark[param]]); - return or(f, and(["=", ["PARAM", param], bookmark[param]], cur)); - } else { - let f: Expression = ["IS NULL", ["PARAM", param]]; - if (bookmark[param] == null) return and(f, cur); - f = or(f, ["<", ["PARAM", param], bookmark[param]]); - return or(f, and(["=", ["PARAM", param], bookmark[param]], cur)); - } - }, true as Expression); + }, + new Expression.Literal(true) as Expression, + ); } function getCover( @@ -60,7 +70,7 @@ function getCover( const nextCov: Minterm = []; if (sort > 0) { - const lhs = new Clause.Exp(["PARAM", param]); + const lhs = new Clause.Exp(new Expression.Parameter(Path.parse(param))); const isNull = context.getVar(new Clause.IsNull(lhs)); cov.push((isNull << 2) ^ 2); } @@ -68,15 +78,15 @@ function getCover( for (const m of minterm) { const clause = context.getClause(m >>> 2); if (clause instanceof Clause.IsNull) { - const lhs = clause.operand.expression(); - if (!Array.isArray(lhs) || lhs[0] !== "PARAM") continue; - if (lhs[1] !== param) continue; + if (!(clause.operand instanceof Clause.Exp)) continue; + if (!(clause.operand.exp instanceof Expression.Parameter)) continue; + if (clause.operand.exp.path.toString() !== param) continue; nextCov.push(m); if (sort < 0 && m & 1) cov.push(m, m ^ 1); } else if (clause instanceof Clause.Compare) { - const lhs = clause.lhs.expression(); - if (!Array.isArray(lhs) || lhs[0] !== "PARAM") continue; - if (lhs[1] !== param) continue; + if (!(clause.lhs instanceof Clause.Exp)) continue; + if (!(clause.lhs.exp instanceof Expression.Parameter)) continue; + if (clause.lhs.exp.path.toString() !== param) continue; if (!(m & 1) && sort > 0) continue; nextCov.push(m); @@ -117,18 +127,20 @@ export function paginate( sort: Record, ): [Expression, Expression] { fetched = normalize(fetched); - if (!fetched) return [false, toFetch]; + if (fetched instanceof Expression.Literal && !fetched.value) + return [new Expression.Literal(false), toFetch]; toFetch = normalize(toFetch); - if (!toFetch) return [false, toFetch]; + if (toFetch instanceof Expression.Literal && !toFetch.value) + return [new Expression.Literal(false), toFetch]; const synth1 = Clause.fromExpression(fetched); const synth2 = Clause.fromExpression(toFetch); const context = new SynthContext(); - const expr1Minterms = synth1.true(context); - const expr2MintermsC = complement(synth2.true(context)); + const expr1Minterms = synth1.getMinterms(context, 0b100); + const expr2MintermsC = synth2.getMinterms(context, 0b011); const gaps = context.sanitizeMinterms( complement([ diff --git a/lib/common/expression/parser.ts b/lib/common/expression/parser.ts index d2c8634..41647f8 100644 --- a/lib/common/expression/parser.ts +++ b/lib/common/expression/parser.ts @@ -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 { + for (let i = s; i < e; i++) yield i; +} + +const PATH_CHARS = new Set([ + ...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(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, -): Promise { - 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, - nextParser: parsimmon.Parser, -): parsimmon.Parser { - 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, - nextParser: parsimmon.Parser, - ): parsimmon.Parser { - 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); +} diff --git a/lib/common/expression/synth.ts b/lib/common/expression/synth.ts index 61edcc6..cf773e9 100644 --- a/lib/common/expression/synth.ts +++ b/lib/common/expression/synth.ts @@ -1,7 +1,7 @@ import { espresso, complement, tautology } from "espresso-iisojs"; -import { Expression } from "../../types.ts"; +import Expression from "../expression.ts"; +import { parseLikePattern } from "./parser.ts"; import normalize from "./normalize.ts"; -import { map, parseLikePattern } from "./parser.ts"; type Minterm = number[]; @@ -27,7 +27,7 @@ export abstract class SynthContextBase< return this.clauses.get(v); } - abstract getMinterms(exp: T, res: true | false | null): Minterm[]; + abstract getMinterms(exp: T, res: number): Minterm[]; abstract getDcSet(minterms: Minterm[]): Minterm[]; // eslint-disable-next-line @typescript-eslint/no-unused-vars canRaise(idx: number, set: Set): boolean { @@ -67,36 +67,34 @@ export abstract class SynthContextBase< } function* findIsNullDeps(exp: Expression): IterableIterator { - if (!Array.isArray(exp)) return; - const op = exp[0]; - if (op === "IS NULL" || op === "IS NOT NULL") return; - - if (op === "FUNC") { - if (exp[1] === "NOW") { - return; - } else if (exp[1] === "LOWER" || exp[1] === "UPPER") { - yield* findIsNullDeps(exp[2]); - } else if (exp[1] === "ROUND") { - for (const e of exp.slice(2, 4)) yield* findIsNullDeps(e); - return; + if (exp instanceof Expression.Literal) return; + else if (exp instanceof Expression.Unary) { + if (exp.operator === "IS NULL" || exp.operator === "IS NOT NULL") return; + yield* findIsNullDeps(exp.operand); + } else if (exp instanceof Expression.Binary) { + yield* findIsNullDeps(exp.left); + yield* findIsNullDeps(exp.right); + } else if (exp instanceof Expression.FunctionCall) { + if (exp.name === "NOW") return; + else if (exp.name === "LOWER" || exp.name === "UPPER") + yield* findIsNullDeps(exp.args[0]); + else if (exp.name === "ROUND") { + for (const e of exp.args.slice(0, 2)) yield* findIsNullDeps(e); } - } else if (op !== "PARAM") { - for (const e of exp.slice(1)) yield* findIsNullDeps(e); - return; - } - yield exp; + } else yield exp; } export abstract class Clause { private _isNullable: Set; protected _expression: Expression; - abstract true(context: SynthContextBase): Minterm[]; - abstract false(context: SynthContextBase): Minterm[]; - abstract null(context: SynthContextBase): Minterm[]; + abstract getMinterms( + context: SynthContextBase, + res: number, + ): Minterm[]; expression(): Expression { if (this._expression !== undefined) return this._expression; const context = createSynthContext(); - const minterms = this.true(context); + const minterms = this.getMinterms(context, 0b100); const minimized = context.minimize(minterms); this._expression = context.toExpression(minimized) as Expression; return this._expression; @@ -120,7 +118,7 @@ export abstract class Clause { } } toString(): string { - return JSON.stringify(this.expression()); + return this.expression().toString(); } } @@ -130,68 +128,11 @@ export namespace Clause { constructor(public operand: Clause) { super(); } - true(context: SynthContextBase): Minterm[] { - return this.operand.false(context); - } - false(context: SynthContextBase): Minterm[] { - return this.operand.true(context); - } - null(context: SynthContextBase): Minterm[] { - return this.operand.null(context); - } - } - - export class Or extends Clause { - constructor(public operands: Clause[]) { - super(); - } - true(context: SynthContextBase): Minterm[] { - const minterms: Minterm[] = []; - for (const s of this.operands) { - const m = s.true(context); - if (m.length === 1 && !m[0].length) return [[]]; - minterms.push(...m); - } - return minterms; - } - false(context: SynthContextBase): Minterm[] { - const minterms: Minterm[][] = []; - const operands = [...this.operands]; - for (const e of operands) { - if (e instanceof Or) { - operands.push(...e.operands); - continue; - } - const m = e.false(context); - if (!m.length) return []; - minterms.push(m); - } - if (!minterms.length) return [[]]; - if (minterms.length === 1) return minterms[0]; - return complement(minterms.map((m) => complement(m)).flat()); - } - null(context: SynthContextBase): Minterm[] { - const trueMinterms: Minterm[] = []; - const operands = [...this.operands]; - for (const s of operands) { - if (s instanceof Or) { - operands.push(...s.operands); - continue; - } - const t = s.true(context); - if (t.length === 1 && !t[0].length) return []; - trueMinterms.push(...t); - } - - const nullMinterms: Minterm[] = []; - for (const s of operands) { - const n = s.null(context); - if (n.length === 1 && !n[0].length && !trueMinterms.length) return [[]]; - nullMinterms.push(...n); - } - - if (!trueMinterms.length) return nullMinterms; - return complement([...complement(nullMinterms), ...trueMinterms]); + getMinterms(context: SynthContextBase, res: number): Minterm[] { + let r = res & 0b001; + if (res & 0b010) r |= 0b100; + if (res & 0b100) r |= 0b010; + return this.operand.getMinterms(context, r); } } @@ -199,175 +140,43 @@ export namespace Clause { constructor(public operands: Clause[]) { super(); } - true(context: SynthContextBase): Minterm[] { - const minterms: Minterm[][] = []; - const operands = [...this.operands]; - for (const s of operands) { - if (s instanceof And) { - operands.push(...s.operands); - continue; - } - const m = s.true(context); - if (!m.length) return []; - if (m.length === 1 && !m[0].length) continue; - minterms.push(m); - } - if (!minterms.length) return [[]]; - if (minterms.length === 1) return minterms[0]; - return complement(minterms.map((m) => complement(m)).flat()); - } - false(context: SynthContextBase): Minterm[] { + getMinterms(context: SynthContextBase, res: number): Minterm[] { + res = res & 0b111; + if (!res) return []; + if (res === 0b111) return [[]]; + + if (res === 0b110) + return [ + ...this.getMinterms(context, 0b010), + ...this.getMinterms(context, 0b100), + ]; + + if (!(res & 0b010)) return complement(this.getMinterms(context, ~res)); + const minterms: Minterm[] = []; - for (const s of this.operands) { - const m = s.false(context); + for (const o of this.operands) { + const m = o.getMinterms(context, res); if (m.length === 1 && !m[0].length) return [[]]; minterms.push(...m); } - return minterms; - } - null(context: SynthContextBase): Minterm[] { - const falseMinterms: Minterm[] = []; - const operands = [...this.operands]; - for (const s of operands) { - if (s instanceof And) { - operands.push(...s.operands); - continue; - } - const f = s.false(context); - if (f.length === 1 && !f[0].length) return []; - falseMinterms.push(...f); - } - const nullMinterms: Minterm[] = []; - for (const s of operands) { - const n = s.null(context); - if (n.length === 1 && !n[0].length && !falseMinterms.length) - return [[]]; - nullMinterms.push(...n); - } - - if (!falseMinterms.length) return nullMinterms; - return complement([...complement(nullMinterms), ...falseMinterms]); - } - } - - export class Case extends Clause { - constructor(public clauses: Clause[]) { - super(); - } - true(context: SynthContextBase): Minterm[] { - const minterms: Minterm[] = []; - const cumulative: Minterm[] = []; - for (let i = 0; i < this.clauses.length; i += 2) { - const w = this.clauses[i].true(context); - const t = this.clauses[i + 1].true(context); - minterms.push( - ...complement([...cumulative, ...complement(w), ...complement(t)]), - ); - if (i < this.clauses.length - 2) { - cumulative.push( - ...complement([ - ...this.clauses[i].false(context), - ...this.clauses[i].null(context), - ]), - ); - } - } return minterms; } - false(context: SynthContextBase): Minterm[] { - const minterms: Minterm[] = []; - const cumulative: Minterm[] = []; - for (let i = 0; i < this.clauses.length; i += 2) { - const w = this.clauses[i].true(context); - const t = this.clauses[i + 1].false(context); - minterms.push( - ...complement([...cumulative, ...complement(w), ...complement(t)]), - ); - if (i < this.clauses.length - 2) { - cumulative.push( - ...complement([ - ...this.clauses[i].false(context), - ...this.clauses[i].null(context), - ]), - ); - } - } - return minterms; - } - null(context: SynthContextBase): Minterm[] { - const minterms: Minterm[] = []; - const cumulative: Minterm[] = []; - for (let i = 0; i < this.clauses.length; i += 2) { - const w = this.clauses[i].true(context); - const t = this.clauses[i + 1].null(context); - minterms.push( - ...complement([...cumulative, ...complement(w), ...complement(t)]), - ); - cumulative.push( - ...complement([ - ...this.clauses[i].false(context), - ...this.clauses[i].null(context), - ]), - ); - } - minterms.push(...complement([...cumulative])); - return minterms; - } - expression(): Expression { - if (this._expression != null) return this._expression; - if (this.isBoolean()) { - this._expression = new Clause.Not(new Clause.Not(this)).expression(); - return this._expression; - } - const context = createSynthContext(); - const cases: { when: Minterm[]; then: Expression }[] = []; - for (let i = 0; i < this.clauses.length; i += 2) { - let minterms = this.clauses[i].true(context); - const then = this.clauses[i + 1].expression(); - if ( - cases.length && - JSON.stringify(then) === JSON.stringify(cases[cases.length - 1].then) - ) - minterms.push(...cases.pop().when); - minterms = context.minimize( - minterms, - cases.flatMap((c) => c.when), - ); - if (!minterms.length) continue; - cases.push({ when: minterms, then }); - if (minterms.length === 1 && !minterms[0].length) break; - } - while (cases[cases.length - 1].then == null) cases.pop(); - if (!cases.length) return null; - this._expression = [ - "CASE", - ...cases.flatMap((c) => [context.toExpression(c.when), c.then]), - ]; - return this._expression; - } - - isBoolean(): boolean { - for (let i = 1; i < this.clauses.length; i += 2) - if (!this.clauses[i].isBoolean()) return false; - return true; - } } export class IsNull extends Clause { - private _boolean: Clause; constructor(public operand: Clause) { super(); - this._boolean = new Not(new Not(this)); } - true(context: SynthContextBase): Minterm[] { - return this.operand.null(context); - } - false(context: SynthContextBase): Minterm[] { - return complement(this.true(context)); - } - null(): Minterm[] { - return []; + getMinterms(context: SynthContextBase, res: number): Minterm[] { + res = res & 0b111; + const minterms: Minterm[] = []; + if ((res & 0b110) === 0b110) return [[]]; + if (res & 0b100) + minterms.push(...this.operand.getMinterms(context, 0b001)); + if (res & 0b010) + minterms.push(...this.operand.getMinterms(context, 0b110)); + return minterms; } isBoolean(): boolean { return true; @@ -378,7 +187,7 @@ export namespace Clause { expression(): Expression { const nullables = [...this.operand.getNullables()]; if (nullables.length === 1 && nullables[0].operand === this.operand) - return ["IS NULL", this.operand.expression()]; + return new Expression.Unary("IS NULL", this.operand.expression()); return super.expression(); } } @@ -387,26 +196,26 @@ export namespace Clause { constructor(public exp: Expression) { super(); } - true(context: SynthContextBase): Minterm[] { - if (Array.isArray(this.exp)) return context.getMinterms(this, true); - if (this.exp) return [[]]; - return []; - } - false(context: SynthContextBase): Minterm[] { - if (Array.isArray(this.exp)) return context.getMinterms(this, false); - if (!this.exp && this.exp != null) return [[]]; - return []; - } - null(context: SynthContextBase): Minterm[] { - if (Array.isArray(this.exp)) return context.getMinterms(this, null); - if (this.exp == null) return [[]]; + getMinterms(context: SynthContextBase, res: number): Minterm[] { + if (!(this.exp instanceof Expression.Literal)) + return context.getMinterms(this, res); + if (this.exp.value == null) return res & 0b001 ? [[]] : []; + if (this.exp.value && res & 0b100) return [[]]; + if (!this.exp.value && res & 0b010) return [[]]; return []; } expression(): Expression { return this.exp; } isBoolean(): boolean { - return this.exp === true || this.exp === false || this.exp == null; + if (this.exp instanceof Expression.Literal) { + return ( + this.exp.value === true || + this.exp.value === false || + this.exp.value == null + ); + } + return false; } } @@ -418,14 +227,13 @@ export namespace Clause { ) { super(); } - true(context: SynthContextBase): Minterm[] { - return context.getMinterms(this, true); - } - false(context: SynthContextBase): Minterm[] { - return context.getMinterms(this, false); - } - null(context: SynthContextBase): Minterm[] { - return this.lhs.null(context); + getMinterms(context: SynthContextBase, res: number): Minterm[] { + res = res & 0b111; + if (!res) return []; + if (res === 0b111) return [[]]; + if (res === 0b001 || res === 0b110) + return this.lhs.getMinterms(context, res); + return context.getMinterms(this, res); } *getNullables(): IterableIterator { yield* this.lhs.getNullables(); @@ -434,7 +242,11 @@ export namespace Clause { return true; } expression(): Expression { - return [this.op, this.lhs.expression(), this.rhs]; + return new Expression.Binary( + this.op, + this.lhs.expression(), + new Expression.Literal(this.rhs), + ); } } @@ -453,15 +265,13 @@ export namespace Clause { const exp = lhs.expression(); let caseSensitive = true; let contradiction = false; - if (Array.isArray(exp)) { - if (exp[0] === "FUNC") { - const func = exp[1]; - if (func === "UPPER" || func === "LOWER") { - const p = func === "UPPER" ? rhs.toUpperCase() : rhs.toLowerCase(); - if (p === rhs) caseSensitive = false; - else contradiction = true; - lhs = new Exp(exp[2]); - } + if (exp instanceof Expression.FunctionCall) { + if (exp.name === "UPPER" || exp.name === "LOWER") { + const p = + exp.name === "UPPER" ? rhs.toUpperCase() : rhs.toLowerCase(); + if (p === rhs) caseSensitive = false; + else contradiction = true; + lhs = new Exp(exp.args[0]); } } this.lhs = lhs; @@ -469,14 +279,13 @@ export namespace Clause { this.caseSensitive = caseSensitive; this.contradiction = contradiction; } - true(context: SynthContextBase): Minterm[] { - return context.getMinterms(this, true); - } - false(context: SynthContextBase): Minterm[] { - return context.getMinterms(this, false); - } - null(context: SynthContextBase): Minterm[] { - return this.lhs.null(context); + getMinterms(context: SynthContextBase, res: number): Minterm[] { + res = res & 0b111; + if (!res) return []; + if (res === 0b111) return [[]]; + if (res === 0b001 || res === 0b110) + return this.lhs.getMinterms(context, res); + return context.getMinterms(this, res); } isBoolean(): boolean { return true; @@ -491,67 +300,157 @@ export namespace Clause { let lhs = this.lhs.expression(); if (this.contradiction) { if (this.rhs === this.rhs.toLocaleUpperCase()) - lhs = ["FUNC", "LOWER", lhs]; - else lhs = ["FUNC", "UPPER", lhs]; + lhs = new Expression.FunctionCall("LOWER", [lhs]); + else lhs = new Expression.FunctionCall("UPPER", [lhs]); } else if (!this.caseSensitive) { if (this.rhs === this.rhs.toLocaleUpperCase()) - lhs = ["FUNC", "UPPER", lhs]; - else lhs = ["FUNC", "LOWER", lhs]; + lhs = new Expression.FunctionCall("UPPER", [lhs]); + else lhs = new Expression.FunctionCall("LOWER", [lhs]); } - return ["LIKE", lhs, this.rhs, ...(this.esc ? [this.esc] : [])]; + return new Expression.Binary( + "LIKE", + lhs, + new Expression.Literal(this.rhs), + ); + } + } + + export class Conditional extends Clause { + constructor( + public condition: Clause, + public then: Clause, + public otherwise: Clause, + ) { + super(); + } + getMinterms(context: SynthContextBase, res: number): Minterm[] { + const condition = this.condition.getMinterms(context, 0b011); + if (!condition.length) return this.then.getMinterms(context, res); + return [ + ...complement([...condition, ...this.then.getMinterms(context, ~res)]), + ...complement([ + ...complement(condition), + ...this.otherwise.getMinterms(context, ~res), + ]), + ]; + } + expression(): Expression { + if (this._expression != null) return this._expression; + if (this.isBoolean()) { + this._expression = new Clause.Not(new Clause.Not(this)).expression(); + return this._expression; + } + const context = createSynthContext(); + const cases: { when: Minterm[]; then: Expression }[] = []; + let clause = this as Conditional; + + for (;;) { + let minterms = clause.condition.getMinterms(context, 0b100); + const then = clause.then.expression(); + if ( + cases.length && + then.toString() === cases[cases.length - 1].then.toString() + ) + minterms.push(...cases.pop().when); + minterms = context.minimize( + minterms, + cases.flatMap((c) => c.when), + ); + if (!minterms.length) continue; + cases.push({ when: minterms, then }); + if (minterms.length === 1 && !minterms[0].length) break; + if (clause.otherwise instanceof Conditional) clause = clause.otherwise; + else + clause = new Conditional( + new Exp(new Expression.Literal(true)), + clause.otherwise, + new Exp(new Expression.Literal(null)), + ); + } + while ( + cases[cases.length - 1].then instanceof Expression.Literal && + (cases[cases.length - 1].then as Expression.Literal).value == null + ) + cases.pop(); + let res: Expression = new Expression.Literal(null); + while (cases.length) { + const c = cases.pop(); + if (c.when.length === 1 && !c.when[0].length) { + res = c.then; + } else { + res = new Expression.Conditional( + context.toExpression(c.when), + c.then, + res, + ); + } + } + + this._expression = res; + return this._expression; + } + + isBoolean(): boolean { + return this.then.isBoolean() && this.otherwise.isBoolean(); } } export function fromExpression(exp: Expression): Clause { - const res = map(exp, (e) => { - if (!Array.isArray(e)) return new Clause.Exp(e); - let op = e[0]; - let negate = true; + let res: Clause; + let negate = false; + if (exp instanceof Expression.Unary) { + let op = exp.operator; + negate = true; + if (op === "IS NOT NULL") op = "IS NULL"; + else negate = false; + if (op === "NOT") res = new Clause.Not(fromExpression(exp.operand)); + else if (op === "IS NULL") res = new IsNull(fromExpression(exp.operand)); + } else if (exp instanceof Expression.Binary) { + let op = exp.operator; + negate = true; if (op === "NOT LIKE") op = "LIKE"; - else if (op === "IS NOT NULL") op = "IS NULL"; else if (op === "<>") op = "="; else if (op === ">=") op = "<"; else if (op === "<=") op = ">"; else negate = false; - let clause: Clause; - if (op === "IS NULL") { - clause = new Clause.IsNull(e[1]); - } else if (op === "NOT") { - clause = new Clause.Not(e[1]); + if (op === "AND") { + res = new Clause.And([ + fromExpression(exp.left), + fromExpression(exp.right), + ]); } else if (op === "OR") { - clause = new Clause.Or(e.slice(1)); - } else if (op === "AND") { - clause = new Clause.And(e.slice(1)); - } else if (op === "CASE") { - clause = new Clause.Case(e.slice(1)); - } else if (op === "LIKE") { - const rhs = e[2] instanceof Exp ? e[2].expression() : null; - const esc = e[3] instanceof Exp ? e[3].expression() : null; - if (typeof rhs === "string" && typeof esc === "string") - clause = new Clause.Like(e[1], rhs, esc); - else if (typeof rhs === "string" && esc == null) - clause = new Clause.Like(e[1], rhs); - } else if (op === ">" || op === "<" || op === "=") { - const rhs = e[2] instanceof Exp ? e[2].expression() : null; - if (["boolean", "number", "string"].includes(typeof rhs)) { - clause = new Clause.Compare( - e[1], - op, - rhs as boolean | number | string, - ); + negate = true; + res = new Clause.And([ + new Clause.Not(fromExpression(exp.left)), + new Clause.Not(fromExpression(exp.right)), + ]); + } else if (exp.right instanceof Expression.Literal) { + if (["=", ">", "<"].includes(op)) { + if ( + ["boolean", "number", "string"].includes(typeof exp.right.value) + ) { + res = new Compare( + fromExpression(exp.left), + op as ">" | "<" | "=", + exp.right.value, + ); + } + } else if (op === "LIKE") { + if (typeof exp.right.value === "string") + res = new Like(fromExpression(exp.left), exp.right.value); } } + } else if (exp instanceof Expression.Conditional) { + res = new Conditional( + fromExpression(exp.condition), + fromExpression(exp.then), + fromExpression(exp.otherwise), + ); + } - if (!clause) { - const args = e.slice(1).map((a) => (a as Clause).expression()); - clause = new Clause.Exp([op, ...args]); - } - - if (negate) clause = new Clause.Not(clause); - return clause; - }); - + if (!res) res = new Exp(exp); + if (negate) res = new Not(res); return res; } } @@ -576,11 +475,24 @@ export class SynthContext extends SynthContextBase { super(); } - getMinterms(clause: Clause, res: true | false | null): number[][] { + getMinterms(clause: Clause, res: number): number[][] { const v = this.getVar(clause); - if (res === true) return [[(v << 2) ^ 3]]; - else if (res === false) return [[(v << 2) ^ 1]]; - else return [[v << 2, (v << 2) ^ 2]]; + switch (res & 0b111) { + case 0b100: + return [[(v << 2) ^ 3]]; + case 0b010: + return [[(v << 2) ^ 1]]; + case 0b001: + return [[v << 2, (v << 2) ^ 2]]; + case 0b110: + return [[(v << 2) ^ 3], [(v << 2) ^ 1]]; + case 0b101: + return [[(v << 2) ^ 3], [v << 2, (v << 2) ^ 2]]; + case 0b011: + return [[(v << 2) ^ 1], [v << 2, (v << 2) ^ 2]]; + default: + throw new Error("Invalid minterms"); + } } getDcSet(minterms: Minterm[]): number[][] { @@ -594,9 +506,7 @@ export class SynthContext extends SynthContextBase { ) as Clause.Compare[]; // Comparisons - for (const [, clauses] of groupBy(comparisons, (c) => - JSON.stringify(c.lhs), - )) { + for (const [, clauses] of groupBy(comparisons, (c) => c.lhs.toString())) { const lhs = clauses[0].lhs; const values = new Set(clauses.map((c) => c.rhs)); const valuesSorted = [...values].sort((a, b) => { @@ -657,7 +567,7 @@ export class SynthContext extends SynthContextBase { (c) => c instanceof Clause.Like, ) as Clause.Like[]; - for (const [, clauses] of groupBy(likes, (c) => JSON.stringify(c.lhs))) { + for (const [, clauses] of groupBy(likes, (c) => c.lhs.toString())) { for (let i1 = 0; i1 < clauses.length; ++i1) { const l1 = clauses[i1]; if (l1.contradiction) { @@ -724,9 +634,13 @@ export class SynthContext extends SynthContextBase { } if (clause instanceof Clause.IsNull) { - const str = clause.operand.toString(); - if (str === '["PARAM","DeviceID.ID"]' || str === '["PARAM","_id"]') - dcSet.push([(v << 2) ^ 3]); + if (clause.operand instanceof Clause.Exp) { + if (clause.operand.exp instanceof Expression.Parameter) { + const str = clause.operand.exp.path.toString(); + if (str === "DeviceID.ID" || str === "_id") + dcSet.push([(v << 2) ^ 3]); + } + } } } return dcSet.filter((m) => m.every((v) => whitelist.has(v >> 2))); @@ -808,11 +722,9 @@ export class SynthContext extends SynthContextBase { } toExpression(sop: number[][]): Expression { - if (!sop.length) return false; - const res: Expression[] = []; + let res: Expression = new Expression.Literal(false); for (const s of sop) { - if (!s.length) return true; - const conjs: Expression[] = []; + let conjs: Expression = new Expression.Literal(true); for (const i of s) { const clause = this.getClause(i >>> 2); if (!clause) throw new Error("Invalid literal"); @@ -823,7 +735,8 @@ export class SynthContext extends SynthContextBase { const isNullVars = [...clause.getNullables()].map((c) => this.getVar(c), ); - conjs.push( + conjs = Expression.and( + conjs, this.toExpression([ [i ^ 3], ...isNullVars.map((n) => [(n << 2) ^ 3]), @@ -832,31 +745,38 @@ export class SynthContext extends SynthContextBase { continue; } - let expr = clause.expression() as Expression; - if (!(i & 1) !== !(i & 2)) expr = ["NOT", expr]; - if ( - Array.isArray(expr) && - expr[0] === "NOT" && - Array.isArray(expr[1]) - ) { - const e: Expression[] = expr[1]; - if (e[0] === "IS NULL") expr = ["IS NOT NULL", ...e.slice(1)]; - else if (e[0] === "LIKE") expr = ["NOT LIKE", ...e.slice(1)]; - else if (e[0] === "=") expr = ["<>", ...e.slice(1)]; - else if (e[0] === "<>") expr = ["=", ...e.slice(1)]; - else if (e[0] === ">") expr = ["<=", ...e.slice(1)]; - else if (e[0] === ">=") expr = ["<", ...e.slice(1)]; - else if (e[0] === "<") expr = [">=", ...e.slice(1)]; - else if (e[0] === "<=") expr = [">", ...e.slice(1)]; - else if (e[0] === "NOT") expr = e[1]; + let expr = clause.expression(); + if (!(i & 1) !== !(i & 2)) expr = new Expression.Unary("NOT", expr); + if (expr instanceof Expression.Unary && expr.operator === "NOT") { + const e = expr.operand; + if (e instanceof Expression.Unary) { + if (e.operator === "IS NULL") + expr = new Expression.Unary("IS NOT NULL", e.operand); + else if (e.operator === "IS NOT NULL") + expr = new Expression.Unary("IS NULL", e.operand); + else if (e.operator === "NOT") expr = e.operand; + } else if (e instanceof Expression.Binary) { + if (e.operator === "LIKE") + expr = new Expression.Binary("NOT LIKE", e.left, e.right); + else if (e.operator === "=") + expr = new Expression.Binary("<>", e.left, e.right); + else if (e.operator === "<>") + expr = new Expression.Binary("=", e.left, e.right); + else if (e.operator === ">") + expr = new Expression.Binary("<=", e.left, e.right); + else if (e.operator === ">=") + expr = new Expression.Binary("<", e.left, e.right); + else if (e.operator === "<") + expr = new Expression.Binary(">=", e.left, e.right); + else if (e.operator === "<=") + expr = new Expression.Binary(">", e.left, e.right); + } } - conjs.push(expr); + conjs = Expression.and(conjs, expr); } - if (conjs.length > 1) res.push(["AND", ...conjs]); - else res.push(conjs[0]); + res = Expression.or(res, conjs); } - if (res.length > 1) return ["OR", ...res]; - return res[0]; + return res; } } @@ -925,11 +845,12 @@ export function unionDiff( ): [Expression, Expression] { expr2 = normalize(expr2); - if (!expr2) return [expr1, false]; + if (expr2 instanceof Expression.Literal && !expr2.value) + return [expr1, new Expression.Literal(false)]; const synth2 = Clause.fromExpression(expr2); - if (!expr1) { + if (expr1 instanceof Expression.Literal && !expr1.value) { const e = synth2.expression(); return [e, e]; } @@ -939,8 +860,8 @@ export function unionDiff( const context = new SynthContext(); - const expr2Minterms = synth2.true(context); - const expr1Minterms = synth1.true(context); + const expr2Minterms = synth2.getMinterms(context, 0b100); + const expr1Minterms = synth1.getMinterms(context, 0b100); const union = context.minimize([...expr1Minterms, ...expr2Minterms]); @@ -953,16 +874,16 @@ export function unionDiff( export function covers(expr1: Expression, expr2: Expression): boolean { expr2 = normalize(expr2); - if (!expr2) return true; + if (expr2 instanceof Expression.Literal && !expr2.value) return true; expr1 = normalize(expr1); - if (!Array.isArray(expr1) && expr1) return true; + if (expr1 instanceof Expression.Literal && expr1.value) return true; const synt1 = Clause.fromExpression(expr1); const synt2 = Clause.fromExpression(expr2); const context = new SynthContext(); - const expr1Minterms = synt1.true(context); - const expr2Minterms = synt2.true(context); + const expr1Minterms = synt1.getMinterms(context, 0b100); + const expr2Minterms = synt2.getMinterms(context, 0b100); return tautology([ ...context.sanitizeMinterms(complement(expr2Minterms)), diff --git a/lib/common/expression/util.ts b/lib/common/expression/util.ts deleted file mode 100644 index cebd2c4..0000000 --- a/lib/common/expression/util.ts +++ /dev/null @@ -1,393 +0,0 @@ -import { map, mapAsync, parseLikePattern } from "./parser.ts"; -import { Expression } from "../../types.ts"; - -const isArray = Array.isArray; - -const regExpCache = new WeakMap(); - -const REDUCE_SKIP = {}; -function reduce(exp, callback): Expression { - let loop = true; - while (loop) { - loop = false; - for (let i = 2; i < exp.length; ++i) { - const res = callback(exp[i - 1], exp[i], i - 2); - if (res !== REDUCE_SKIP) { - loop = true; - exp = exp.slice(); - exp.splice(i - 1, 2, res); - } - } - } - if (exp.length === 2) return exp[1]; - return exp; -} - -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); -} - -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; - } -} - -function toString(a: boolean | number | string): string { - switch (typeof a) { - case "string": - return a; - case "number": - return a.toString(); - case "boolean": - return (+a).toString(); - } -} - -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] === "FUNC") { - if (exp[1] === "COALESCE") { - const args: Expression[] = []; - for (let i = 2; i < exp.length; ++i) { - const e = exp[i]; - if (e == null) continue; - args.push(e); - if (!Array.isArray(e)) break; - } - if (!args.length) return null; - if (args.length === 1) return args[0]; - return ["FUNC", "COALESCE", ...args]; - } else if (exp[1] === "UPPER") { - if (exp[2] == null) return null; - if (!isArray(exp[2])) return toString(exp[2]).toUpperCase(); - } else if (exp[1] === "LOWER") { - if (exp[2] == null) return null; - if (!isArray(exp[2])) return toString(exp[2]).toLowerCase(); - } else if (exp[1] === "ROUND") { - const n = exp[2]; - const p = exp.length > 3 ? exp[3] : 0; - if (n == null || p == null) return null; - if (!isArray(n) && !isArray(p)) { - const d = 10 ** p; - const m = n * d * (1 + Number.EPSILON); - return Math.round(m) / d; - } - } - } else if (exp[0] === "PARAM") { - if (exp[1] == null) 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 false; - } else if (exp[0] === "IS NOT NULL") { - if (isArray(exp[1])) return exp; - else if (exp[1] != null) return true; - else return false; - } else if (exp[0] === "LIKE") { - if (isArray(exp[1]) || isArray(exp[2]) || isArray(exp[3])) return exp; - else if ( - exp[1] == null || - exp[2] == null || - ((exp as any[]).length >= 4 && exp[3] == null) - ) - return null; - let r = regExpCache.get(exp as any[]); - if (!r) { - r = likePatternToRegExp(exp[2], exp[3]); - regExpCache.set(exp as any[], r); - } - 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 ( - exp[1] == null || - exp[2] == null || - ((exp as any[]).length >= 4 && exp[3] == null) - ) - return null; - let r = regExpCache.get(exp as any[]); - if (!r) { - r = likePatternToRegExp(exp[2], exp[3]); - regExpCache.set(exp as any[], r); - } - 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 (exp[0] === "/") { - return reduce(exp, (a, b, i) => { - if (a == null || b == null) return null; - if (isArray(a) || isArray(b)) return REDUCE_SKIP; - const n1 = toNumber(a); - const n2 = toNumber(b); - if (i !== 0) return n1 * n2; - if (n2 === 0) return null; - return n1 / n2; - }); - } 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 (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 (exp[0] === "%") { - return reduce(exp, (a, b, i) => { - if (a == null || b == null) return null; - if (isArray(a) || isArray(b) || i !== 0) return REDUCE_SKIP; - const n1 = toNumber(a); - const n2 = Math.trunc(toNumber(b)); - if (n2 === 0) return null; - return n1 % n2; - }); - } else if (exp[0] === "||") { - return reduce(exp, (a, b) => { - if (a == null || b == null) return null; - if (!isArray(a) && !isArray(b)) return toString(a) + toString(b); - return REDUCE_SKIP; - }); - } - return exp; -} - -export function evaluate( - exp: Expression, - obj: Record | ((e: string) => Expression), - now: number, - cb?: (e: Expression) => Expression, -): string | number | boolean | null; -export function evaluate( - exp: Expression, - obj?: Record | ((e: string) => Expression), - now?: number, - cb?: (e: Expression) => Expression, -): Expression; -export function evaluate( - exp: Expression, - obj?: Record | ((e: string) => Expression), - now?: number, - cb?: (e: Expression) => Expression, -): Expression { - return map(exp, (e) => { - if (cb) e = cb(e); - if (!isArray(e)) return e; - - if (e[0] === "FUNC" && e[1] === "NOW") { - if (now) return now; - } else if (e[0] === "PARAM") { - if (e[1] == null) return null; - if (obj && !isArray(e[1])) { - let v; - if (typeof obj === "function") v = obj(e[1]); - else v = obj[e[1]]; - - if (v == null) return null; - if (typeof v === "object") v = v.value ? v.value[0] : null; - return v; - } - } - return evaluateCallback(e); - }); -} - -export async function evaluateAsync( - exp: Expression, - obj: Record, - now: number, - cb?: (e: Expression) => Promise, -): Promise; -export async function evaluateAsync( - exp: Expression, - obj?: Record, - now?: number, - cb?: (e: Expression) => Promise, -): Promise; -export async function evaluateAsync( - exp: Expression, - obj?: Record, - now?: number, - cb?: (e: Expression) => Promise, -): Promise { - return mapAsync(exp, async (e) => { - if (cb) e = await cb(e); - if (!isArray(e)) return e; - - if (e[0] === "FUNC") { - if (e[1] === "NOW") if (now) return now; - } else if (e[0] === "PARAM") { - if (e[1] == null) return null; - if (obj && !isArray(e[1])) { - let v = obj[e[1]]; - if (v == null) return null; - if (typeof v === "object") v = v["value"] ? v["value"][0] : null; - return v as Expression; - } - } - return evaluateCallback(e); - }); -} - -export function and(exp1: Expression, exp2: Expression): Expression { - 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; - - const res: Expression = ["AND"]; - - if (Array.isArray(exp1) && exp1[0] === "AND") res.push(...exp1.slice(1)); - else res.push(exp1); - - 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 (!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; - - const res: Expression = ["OR"]; - - if (Array.isArray(exp1) && exp1[0] === "OR") res.push(...exp1.slice(1)); - else res.push(exp1); - - if (Array.isArray(exp2) && exp2[0] === "OR") res.push(...exp2.slice(1)); - else res.push(exp2); - - return res; -} - -export function extractParams(exp: Expression): Expression[] { - const params = new Set(); - map(exp, (e) => { - if (isArray(e) && e[0] === "PARAM") params.add(e[1]); - return e; - }); - return Array.from(params); -} diff --git a/lib/common/path-set.ts b/lib/common/path-set.ts index 79a711d..7c3a51d 100644 --- a/lib/common/path-set.ts +++ b/lib/common/path-set.ts @@ -1,40 +1,32 @@ import Path from "./path.ts"; export default class PathSet { - declare private lengthIndex: Set[]; - declare private fragmentIndex: Map>[]; - declare private stringIndex: Map; + private paramSegmentIndex: Map>[] = []; + private attrSegmentIndex: Map>[] = []; + private stringIndex: Map = new Map(); - public constructor() { - this.lengthIndex = []; - this.fragmentIndex = []; - this.stringIndex = new Map(); - } + public constructor() {} - public get depth(): number { - return this.lengthIndex.length; - } - - public add(path: Path): Path { + public add(pathStr: string): Path { + let path: Path = this.get(pathStr); + if (path) return path; + path = Path.parse(pathStr); if (path.alias) throw new Error("PathSet does not support aliased paths"); - const p = this.get(path); - - if (p) return p; this.stringIndex.set(path.toString(), path); - while (this.lengthIndex.length <= path.length) { - this.lengthIndex.push(new Set()); - // fragmentIndex is one less than lengthIndex - if (this.lengthIndex.length > 1) this.fragmentIndex.push(new Map()); - } + while (this.paramSegmentIndex.length < path.paramLength) + this.paramSegmentIndex.push(new Map()); - const lengthIndex = this.lengthIndex[path.length]; - lengthIndex.add(path); + while (this.attrSegmentIndex.length < path.attrLength) + this.attrSegmentIndex.push(new Map()); for (let i = 0; i < path.length; ++i) { const fragment = path.segments[i] as string; - const fragmentIndex = this.fragmentIndex[i]; + const fragmentIndex = + i < path.paramLength + ? this.paramSegmentIndex[i] + : this.attrSegmentIndex[i - path.paramLength]; let fragmentIndexSet = fragmentIndex.get(fragment); if (!fragmentIndexSet) { @@ -48,64 +40,76 @@ export default class PathSet { return path; } - public get(path: Path): Path { - return this.stringIndex.get(path.toString()) || null; + public get(path: string): Path { + return this.stringIndex.get(path); } - public find( + public findCompat( path: Path, superset = false, subset = false, - depth: number = path.length, + depth = path.length, ): Path[] { + if (path.attrLength) + throw new Error("findCompat() does not support attribute paths"); + + depth = Math.min(31, depth); + const paramMask = (1 << path.paramLength) - 1; + let mask = ((0b10 << depth) - 1) & ~paramMask; + if (superset) mask |= ~path.wildcard & paramMask; + if (subset) mask |= path.wildcard; + return this.find(path, mask, 1); + } + + public find(path: Path, paramMask: number, attrMask: number): Path[] { if (path.alias) throw new Error("PathSet does not support aliased paths"); + if (path.paramLength > this.paramSegmentIndex.length) return []; + if (path.attrLength > this.attrSegmentIndex.length) return []; - const len = path.length; + const emptySet: Set = new Set(); - if (!superset && depth === len && (!subset || !path.wildcard)) { - const p = this.get(path); - return p ? [p] : []; + const indexes: [Set, Set][] = []; + + const paramLengthMask = (1 << path.paramLength) - 1; + const attrLengthMask = (1 << path.attrLength) - 1; + + const mask = (paramMask & paramLengthMask) | (attrMask << path.paramLength); + + for (const [i, s] of path.segments.entries()) { + const b = 1 << i; + const m = mask & b; + const w = b & path.wildcard; + if (w && m) continue; + const idxSet = + i < path.paramLength + ? this.paramSegmentIndex[i] + : this.attrSegmentIndex[i - path.paramLength]; + const idx1 = idxSet.get(s as string) || emptySet; + let idx2 = emptySet; + if (m) idx2 = idxSet.get("*") || emptySet; + indexes.push([idx1, idx2]); } - const lengthIndex = this.lengthIndex.slice(len, depth + 1); - if (!lengthIndex.length) return []; + indexes.sort((a, b) => a[0].size + a[1].size - (b[0].size + b[1].size)); - let res; - for (let i = len - 1; i >= 0; --i) { - let fragmentIndexSet2; - const fragmentIndex = this.fragmentIndex[i]; + let res: Path[]; - if ((path.wildcard >> i) & 1) { - if (subset) continue; - } else if (superset) { - fragmentIndexSet2 = fragmentIndex.get("*"); - } + if (!indexes.length) res = [...this.stringIndex.values()]; + else res = [...indexes[0][0], ...indexes[0][1]]; - const fragment = path.segments[i] as string; - const fragmentIndexSet1 = fragmentIndex.get(fragment); + const paramCover = ~paramLengthMask & paramMask; + const attrCover = ~attrLengthMask & attrMask; - if (!fragmentIndexSet1) { - if (!fragmentIndexSet2) return []; - if (!res) res = [...fragmentIndexSet2]; - else res = res.filter((r) => fragmentIndexSet2.has(r)); - } else if (!fragmentIndexSet2) { - if (!res) res = [...fragmentIndexSet1]; - else res = res.filter((r) => fragmentIndexSet1.has(r)); - } else { - if (!res) { - res = [...fragmentIndexSet1, ...fragmentIndexSet2]; - } else { - res = res.filter( - (r) => fragmentIndexSet1.has(r) || fragmentIndexSet2.has(r), - ); - } - } - if (!res.length) return res; + res = res.filter( + (p) => + (1 << p.paramLength) & paramCover && (1 << p.attrLength) & attrCover, + ); + + for (let i = 1; i < indexes.length; ++i) { + const [idx1, idx2] = indexes[i]; + res = res.filter((p) => idx1.has(p) || idx2.has(p)); } - if (!res) res = [].concat(...lengthIndex.map((a) => [...a])); - else res = res.filter((r) => lengthIndex.some((a) => a.has(r))); - return res; } } diff --git a/lib/common/path.ts b/lib/common/path.ts index 8c3435f..4cecc1a 100644 --- a/lib/common/path.ts +++ b/lib/common/path.ts @@ -1,162 +1,31 @@ -type Segments = (string | Alias)[]; -type Alias = [Path, string][]; +import Expression from "./expression.ts"; +import { Cursor, parsePath } from "./expression/parser.ts"; + +type Segments = (string | Expression)[]; let cache1 = new Map(); let cache2 = new Map(); -const CHAR_SPACE = 32; -const CHAR_BACKSLASH = 92; -const CHAR_COLON = 58; -const CHAR_QUOTE = 34; -const CHAR_OPEN_BRACKET = 91; -const CHAR_CLOSE_BRACKET = 93; -const CHAR_ASTERISK = 42; -const CHAR_COMMA = 44; -const CHAR_DOT = 46; - -function charCodeAt(str: string, index: number): number { - if (index >= str.length) return 0; - return str.charCodeAt(index); -} - -function legalChar(c: number): boolean { - return ( - (c >= 97 && c <= 122) || - (c >= 65 && c <= 90) || - (c >= 48 && c <= 57) || - c === 95 || - c === 45 - ); -} - export default class Path { declare public readonly segments: Segments; + declare public readonly colon: number; declare public readonly wildcard: number; declare public readonly alias: number; declare protected _string: string; declare protected _stringIndex: number[]; - protected static parseAliasValue( - pattern: string, - index: number, - ): { index: number; value: string } { - let i = index; - while (charCodeAt(pattern, i) === CHAR_SPACE) ++i; - if (charCodeAt(pattern, i) === CHAR_QUOTE) { - for (let j = i + 1; j < pattern.length; ++j) { - if ( - pattern.charCodeAt(j) === CHAR_QUOTE && - pattern.charCodeAt(j - 1) !== CHAR_BACKSLASH - ) { - try { - ++j; - const v = JSON.parse(pattern.slice(i, j)); - return { index: j, value: v }; - } catch { - return { index, value: null }; - } - } - } - return { index, value: null }; - } - - for (; i < pattern.length; ++i) { - const c = pattern.charCodeAt(i); - if (c === CHAR_CLOSE_BRACKET || c === CHAR_COMMA) break; - } - - return { index: i, value: pattern.slice(index, i).trim() }; - } - - protected static parseAliasPath( - pattern: string, - index: number, - ): { index: number; path: Path } { - let i = index; - while (charCodeAt(pattern, i) === CHAR_SPACE) ++i; - const { index: idx, segments } = Path.parsePath(pattern, i); - if (idx === i) return { index, path: null }; - i = idx; - while (charCodeAt(pattern, i) === CHAR_SPACE) ++i; - if (charCodeAt(pattern, i) !== CHAR_COLON) return { index, path: null }; - return { index: i + 1, path: new Path(segments) }; - } - - protected static parseAlias( - pattern: string, - index: number, - ): { index: number; alias: Alias } { - const alias: Alias = []; - let i = index; - for (;;) { - let path: Path; - let value: string; - ({ index: i, path } = Path.parseAliasPath(pattern, i)); - if (!path) break; - ({ index: i, value } = Path.parseAliasValue(pattern, i)); - if (value == null) break; - alias.push([path, value]); - index = i; - - if (charCodeAt(pattern, index) !== CHAR_COMMA) break; - ++i; - } - - // Ensure identical expressions have idential string representation - alias.sort((a, b) => { - if (a[0].toString() > b[0].toString()) return 1; - else if (a[0].toString() < b[0].toString()) return -1; - else if (a[1] > b[1]) return 1; - else if (a[1] < b[1]) return -1; - else return 0; - }); - - Object.freeze(alias); - return { index, alias }; - } - - protected static parsePath( - pattern: string, - index: number, - ): { index: number; segments: Segments } { - const segments = []; - - let segStart = index; - let wildcard = -1; - for (let i = index; i <= pattern.length; ++i) { - const c = charCodeAt(pattern, i); - if (c === CHAR_OPEN_BRACKET && i === segStart) { - const { index: idx, alias } = Path.parseAlias(pattern, i + 1); - if (charCodeAt(pattern, idx) !== CHAR_CLOSE_BRACKET) break; - segments.push(alias); - index = i = idx + 1; - segStart = i + 1; - } else if (c === CHAR_ASTERISK && i === segStart) { - wildcard = i + 1; - } else if (!legalChar(c) || i === wildcard) { - if (i === segStart) break; - const s = pattern.slice(segStart, i); - segments.push(s); - index = i; - if (c !== CHAR_DOT) break; - segStart = i + 1; - } - } + constructor(segments: Segments, colon: number) { + if (!(colon <= segments.length)) throw new Error("Invalid path"); + if (segments.length > 32) throw new Error("Path too long"); Object.freeze(segments); - return { index, segments }; - } - protected constructor(segments: Segments) { let alias = 0; let wildcard = 0; const arr = segments.map((s, i) => { - if (Array.isArray(s)) { + if (s instanceof Expression) { alias |= 1 << i; - const parts = s.map( - (al) => `${al[0].toString()}:${JSON.stringify(al[1])}`, - ); - return `[${parts.join(",")}]`; + return `[${s.toString()}]`; } else if (s === "*") { wildcard |= 1 << i; } @@ -167,23 +36,27 @@ export default class Path { const stringIndex = arr.map((s, i) => (offset += s.length) + i); this.segments = segments; + this.colon = colon; this.wildcard = wildcard; this.alias = alias; - this._string = arr.join("."); + if (!colon) this._string = arr.join("."); + else + this._string = + arr.slice(0, -colon).join(".") + ":" + arr.slice(-colon).join("."); this._stringIndex = stringIndex; } - public static parse(str: string): Path { - let path = cache1.get(str); + public static parse(input: string): Path { + let path = cache1.get(input); if (!path) { - path = cache2.get(str); + path = cache2.get(input); if (!path) { - const { index, segments } = Path.parsePath(str, 0); - if (index < str.length) throw new Error("Invalid parameter path"); - path = new Path(segments); - if (path.toString() !== str) cache1.set(path.toString(), path); + const cursor = new Cursor(input); + path = parsePath(cursor); + if (cursor.charCode) throw new Error("Unexpected character"); + if (path.toString() !== input) cache1.set(path.toString(), path); } - cache1.set(str, path); + cache1.set(input, path); } return path; } @@ -192,6 +65,14 @@ export default class Path { return this.segments.length; } + public get paramLength(): number { + return this.segments.length - this.colon; + } + + public get attrLength(): number { + return this.colon; + } + public toString(): string { return this._string; } @@ -200,25 +81,27 @@ export default class Path { if (start < 0) start = Math.max(0, this.segments.length + start); if (end < 0) end = Math.max(0, this.segments.length + end); - let str; - if (start >= end) { - str = ""; - } else { - const i1 = start > 0 ? this._stringIndex[start - 1] + 1 : 0; - const i2 = - end <= this.segments.length - ? this._stringIndex[end - 1] - : this._string.length; - str = this._string.slice(i1, i2); - } + if (start >= end) return Path.root; + + let i1 = start > 0 ? this._stringIndex[start - 1] + 1 : 0; + // Include the ":" when slicing exactly at the colon boundary + if (this.colon && start === this.segments.length - this.colon) --i1; + const i2 = + end <= this.segments.length + ? this._stringIndex[end - 1] + : this._string.length; + const str = this._string.slice(i1, i2); let path = cache1.get(str); if (!path) { path = cache2.get(str); if (!path) { const segments = this.segments.slice(start, end); - Object.freeze(segments); - path = new Path(segments); + const colon = + start <= this.segments.length - this.colon + ? Math.max(0, this.colon - this.segments.length + end) + : 0; + path = new Path(segments, colon); } cache1.set(str, path); } @@ -230,15 +113,28 @@ export default class Path { if (!path2._string) return this; else if (!this._string) return path2; - const str = `${this._string}.${path2._string}`; + if (this.colon && path2.colon && path2.colon < path2.segments.length) + throw new Error("Invalid path"); + + const colon = this.colon ? this.colon + path2.segments.length : path2.colon; + + let str; + if (this.colon && path2.colon === path2.segments.length) { + // Right is all-colon; strip its ":" prefix and join with "." + str = `${this._string}.${path2._string.slice(1)}`; + } else if (path2.colon === path2.segments.length) { + // Left has no colon; right is all-colon; concatenate directly + str = `${this._string}${path2._string}`; + } else { + str = `${this._string}.${path2._string}`; + } let path = cache1.get(str); if (!path) { path = cache2.get(str); if (!path) { const segments = this.segments.concat(path2.segments); - Object.freeze(segments); - path = new Path(segments); + path = new Path(segments, colon); } cache1.set(str, path); } @@ -248,21 +144,30 @@ export default class Path { public stripAlias(): Path { if (!this.alias) return this; - const segments = this.segments.map((s) => (Array.isArray(s) ? "*" : s)); - const str = segments.join("."); + const segments = this.segments.map((s) => + s instanceof Expression ? "*" : s, + ); + let str: string; + if (!this.colon) str = segments.join("."); + else + str = + segments.slice(0, -this.colon).join(".") + + ":" + + segments.slice(-this.colon).join("."); let path = cache1.get(str); if (!path) { path = cache2.get(str); if (!path) { - Object.freeze(segments); - path = new Path(segments); + path = new Path(segments, this.colon); } cache1.set(str, path); } return path; } + + static root = new Path([], 0); } const interval = setInterval(() => { diff --git a/lib/connection-request.ts b/lib/connection-request.ts index 2ff6090..c8a299b 100644 --- a/lib/connection-request.ts +++ b/lib/connection-request.ts @@ -1,8 +1,7 @@ import * as crypto from "node:crypto"; import * as dgram from "node:dgram"; import * as http from "node:http"; -import { evaluateAsync } from "./common/expression/util.ts"; -import { Expression } from "./types.ts"; +import Expression, { Value } from "./common/expression.ts"; import * as auth from "./auth.ts"; import * as extensions from "./extensions.ts"; import * as debug from "./debug.ts"; @@ -13,29 +12,33 @@ import * as logger from "../lib/logger.ts"; async function extractAuth( exp: Expression, - dflt: any, + dflt: Value, ): Promise<[string, string, Expression]> { - let username, password; - const _exp = await evaluateAsync( - exp, - {}, - 0, + let username: string, password: string; + const _exp = await exp.evaluateAsync( async (e: Expression): Promise => { - if (!username && Array.isArray(e) && e[0] === "FUNC") { - if (e[1] === "EXT") { - if (typeof e[2] !== "string" || typeof e[3] !== "string") return null; + if (e instanceof Expression.Parameter) + return new Expression.Literal(null); + if (e instanceof Expression.FunctionCall) { + if (e.name === "NOW") return new Expression.Literal(0); + if (!username) { + if (e.name === "EXT") { + if (!e.args.every((a) => a instanceof Expression.Literal)) + return new Expression.Literal(null); + const args = e.args.map((a) => a.value.toString()); + if (typeof args[0] !== "string" || typeof args[1] !== "string") + return new Expression.Literal(null); - for (let i = 4; i < e.length; i++) - if (Array.isArray(e[i])) return null; - - const { fault, value } = await extensions.run(e.slice(2)); - return fault ? null : value; - } else if (e[1] === "AUTH") { - if (!Array.isArray(e[2]) && !Array.isArray(e[3])) { - username = e[2] || ""; - password = e[3] || ""; + const { fault, value } = await extensions.run(args); + if (fault) return new Expression.Literal(null); + return new Expression.Literal(value); + } else if (e.name === "AUTH") { + if (e.args.every((a) => a instanceof Expression.Literal)) { + username = `${e.args[0].value ?? ""}`; + password = `${e.args[1].value ?? ""}`; + } + return new Expression.Literal(dflt); } - return dflt; } } return e; diff --git a/lib/cwmp.ts b/lib/cwmp.ts index 72e3ab9..d0c619d 100644 --- a/lib/cwmp.ts +++ b/lib/cwmp.ts @@ -10,11 +10,7 @@ import * as config from "./config.ts"; import { generateDeviceId, once, setTimeoutPromise } from "./util.ts"; import * as soap from "./soap.ts"; import * as session from "./session.ts"; -import { - evaluateAsync, - evaluate, - extractParams, -} from "./common/expression/util.ts"; +import Expression, { Value } from "./common/expression.ts"; import * as cache from "./cache.ts"; import * as lock from "./lock.ts"; import * as localCache from "./cwmp/local-cache.ts"; @@ -39,7 +35,6 @@ import { AcsRequest, SessionFault, Fault, - Expression, SoapMessage, InformRequest, Preset, @@ -80,7 +75,7 @@ async function authenticate( sessionContext.cacheSnapshot, "cwmp.auth", ); - if (authExpression == null) return true; + if (!authExpression) return true; let authentication; @@ -107,56 +102,65 @@ async function authenticate( authentication["body"] = body; } - const res = await evaluateAsync( - authExpression, - {}, - sessionContext.timestamp, + const res = await authExpression.evaluateAsync( async (e: Expression): Promise => { e = session.configContextCallback(sessionContext, e); - if (Array.isArray(e) && e[0] === "FUNC") { - if (e[1] === "EXT") { - if (typeof e[2] !== "string" || typeof e[3] !== "string") return null; + if (e instanceof Expression.Parameter) + return new Expression.Literal(null); - for (let i = 4; i < e.length; i++) - if (Array.isArray(e[i])) return null; + if (e instanceof Expression.FunctionCall) { + if (e.name === "NOW") + return new Expression.Literal(sessionContext.timestamp); + if (e.name === "EXT") { + if (!e.args.every((a) => a instanceof Expression.Literal)) + return new Expression.Literal(null); - const { fault, value } = await extensions.run(e.slice(2)); - return fault ? null : value; - } else if (e[1] === "AUTH") { - const username = e[2]; - const password = e[3]; - if (username != null && password != null && authentication) { - if (authentication["method"] === "Basic") { - return ( - authentication["username"] === e[2] && - authentication["password"] === e[3] - ); - } + const args = e.args.map((a) => a.value.toString()); + if (typeof args[0] !== "string" || typeof args[1] !== "string") + return new Expression.Literal(null); - if (authentication["method"] === "Digest") { - const expected = auth.digest( - username, - REALM, - password, - authentication["nonce"], - "POST", - authentication["uri"], - authentication["qop"], - authentication["body"], - authentication["cnonce"], - authentication["nc"], - ); - return expected === authentication["response"]; + const { fault, value } = await extensions.run(args); + if (fault) return new Expression.Literal(null); + return new Expression.Literal(value); + } else if (e.name === "AUTH") { + if (e.args.every((a) => a instanceof Expression.Literal)) { + const username = e.args[0].value; + const password = e.args[1].value; + if (username != null && password != null && authentication) { + if (authentication["method"] === "Basic") { + return new Expression.Literal( + authentication["username"] === username.toString() && + authentication["password"] === password.toString(), + ); + } + + if (authentication["method"] === "Digest") { + const expected = auth.digest( + username.toString(), + REALM, + password.toString(), + authentication["nonce"], + "POST", + authentication["uri"], + authentication["qop"], + body, + authentication["cnonce"], + authentication["nc"], + ); + return new Expression.Literal( + expected === authentication["response"], + ); + } } } - return false; + return new Expression.Literal(false); } } return e; }, ); - if (res && !Array.isArray(res)) return true; + if (res instanceof Expression.Literal) return !!res.value; return false; } @@ -315,8 +319,7 @@ async function inform( const cookiesPath = localCache.getConfig( sessionContext.cacheSnapshot, "cwmp.cookiesPath", - {}, - sessionContext.timestamp, + "", (e) => session.configContextCallback(sessionContext, e), ); @@ -406,8 +409,7 @@ async function applyPresets(sessionContext: SessionContext): Promise { const RETRY_DELAY = +localCache.getConfig( sessionContext.cacheSnapshot, "cwmp.retryDelay", - {}, - sessionContext.timestamp, + 300, (e) => session.configContextCallback(sessionContext, e), ); @@ -434,14 +436,14 @@ async function applyPresets(sessionContext: SessionContext): Promise { deviceData.attributes.revision = 1; const deviceEvents = {}; - for (const p of deviceData.paths.find(Path.parse("Events.*"), false, true)) { + for (const p of deviceData.paths.find(Path.parse("Events"), 0b001, 0b1)) { const attrs = deviceData.attributes.get(p); const t = attrs?.value[1][0] as number; if (t >= sessionContext.timestamp) deviceEvents[p.segments[1] as string] = true; } - const parameters: { [name: string]: Path } = {}; + const parameters = new Set(); const filteredPresets: Preset[] = []; for (const preset of presets) { @@ -470,29 +472,31 @@ async function applyPresets(sessionContext: SessionContext): Promise { continue; } - filteredPresets.push(preset); - for (const k of extractParams( - evaluate(preset.precondition, null, sessionContext.timestamp), - )) { - // Mark channel in case of fault during fetching precondition - sessionContext.channels[preset.channel] = 0; - if (typeof k === "string") parameters[k] = Path.parse(k); - } - for (const prov of preset.provisions) { - for (const arg of prov.slice(1)) { - for (const k of extractParams( - evaluate(arg, null, sessionContext.timestamp), - )) { - // Mark channel in case of fault during fetching precondition - sessionContext.channels[preset.channel] = 0; - if (typeof k === "string") parameters[k] = Path.parse(k); - } + const pre = { ...preset }; + const evalCallback = (e: Expression): Expression => { + if (e instanceof Expression.FunctionCall && e.name === "NOW") + return new Expression.Literal(sessionContext.timestamp); + if (e instanceof Expression.Parameter) { + // Mark channel in case of fault during fetching precondition + sessionContext.channels[preset.channel] = 0; + parameters.add(e.path.toString()); } - } + return e; + }; + + pre.precondition = preset.precondition.evaluate(evalCallback); + + pre.provisions = pre.provisions.map((prov) => { + let args = prov.slice(1) as Expression[]; + args = args.map((arg) => arg.evaluate(evalCallback)); + return [prov[0], ...args]; + }); + + filteredPresets.push(pre); } - const declarations = Object.values(parameters).map((v) => ({ - path: v, + const declarations = [...parameters].map((v) => ({ + path: Path.parse(v), pathGet: 1, pathSet: null, attrGet: { value: 1 }, @@ -520,22 +524,21 @@ async function applyPresets(sessionContext: SessionContext): Promise { session.addProvisions(sessionContext, whiteList, whiteListProvisions); const appendProvisionsToFaults = {}; + + const evalCallback2 = (e: Expression): Expression.Literal => { + e = session.configContextCallback(sessionContext, e); + if (!(e instanceof Expression.Literal)) return new Expression.Literal(null); + return e; + }; + for (const p of filteredPresets) { - if ( - evaluate(p.precondition, {}, sessionContext.timestamp, (e) => - session.configContextCallback(sessionContext, e), - ) - ) { + if (p.precondition.evaluate(evalCallback2).value) { const provs = p.provisions.map((pp) => [ pp[0], ...pp .slice(1) - .map((arg) => - evaluate(arg, {}, sessionContext.timestamp, (e) => - session.configContextCallback(sessionContext, e), - ), - ), - ]) as [string, ...Expression[]][]; + .map((arg) => (arg as Expression).evaluate(evalCallback2).value), + ]) as [string, ...Value[]][]; if (blackList[p.channel] === 2) { appendProvisionsToFaults[p.channel] = ( appendProvisionsToFaults[p.channel] || [] @@ -925,16 +928,18 @@ export async function onConnection(socket: Socket): Promise { export async function onClientError(err: Error, socket: Socket): Promise { const remoteAddress = getSocketEndpoints(socket).remoteAddress; const cacheSnapshot = await localCache.getRevision(); - const debugEnabled = !!localCache.getConfig( + const debugEnabled = localCache.getConfig( cacheSnapshot, "cwmp.debug", - { - remoteAddress: remoteAddress, - }, - Date.now(), + false, (e) => { - if (Array.isArray(e) && e[0] === "FUNC" && e[1] === "REMOTE_ADDRESS") - return remoteAddress; + if (e instanceof Expression.FunctionCall) { + if (e.name === "REMOTE_ADDRESS") + return new Expression.Literal(remoteAddress); + if (e.name === "NOW") return new Expression.Literal(Date.now()); + } + if (!(e instanceof Expression.Literal)) + return new Expression.Literal(null); return e; }, ); @@ -1030,8 +1035,7 @@ async function processRequest( sessionContext.debug = !!localCache.getConfig( sessionContext.cacheSnapshot, "cwmp.debug", - {}, - sessionContext.timestamp, + false, (e) => session.configContextCallback(sessionContext, e), ); @@ -1039,8 +1043,7 @@ async function processRequest( sessionContext.timeout = +localCache.getConfig( sessionContext.cacheSnapshot, "cwmp.sessionTimeout", - {}, - sessionContext.timestamp, + 30, (e) => session.configContextCallback(sessionContext, e), ); } @@ -1282,16 +1285,19 @@ async function clientError( deviceId = sessionContext.deviceId; } else { const cacheSnapshot = await localCache.getRevision(); - debugEnabled = !!localCache.getConfig( + const remoteAddress = getRequestOrigin(httpRequest).remoteAddress; + debugEnabled = localCache.getConfig( cacheSnapshot, "cwmp.debug", - { - remoteAddress: getRequestOrigin(httpRequest).remoteAddress, - }, - Date.now(), + false, (e) => { - if (Array.isArray(e) && e[0] === "FUNC" && e[1] === "REMOTE_ADDRESS") - return getRequestOrigin(httpRequest).remoteAddress; + if (e instanceof Expression.FunctionCall) { + if (e.name === "REMOTE_ADDRESS") + return new Expression.Literal(remoteAddress); + if (e.name === "NOW") return new Expression.Literal(Date.now()); + } + if (!(e instanceof Expression.Literal)) + return new Expression.Literal(null); return e; }, ); diff --git a/lib/cwmp/db.ts b/lib/cwmp/db.ts index eafc443..15ff8a6 100644 --- a/lib/cwmp/db.ts +++ b/lib/cwmp/db.ts @@ -7,7 +7,6 @@ import { Task, Operation, } from "../types.ts"; -import Path from "../common/path.ts"; import { collections } from "../db/db.ts"; import { optimizeProjection } from "../db/util.ts"; import * as MongoTypes from "../db/types.ts"; @@ -23,21 +22,17 @@ function compareAccessLists(list1: string[], list2: string[]): boolean { export async function fetchDevice( id: string, timestamp: number, -): Promise<[Path, number, Attributes?][]> { - const res: [Path, number, Attributes?][] = [ +): Promise<[string, number, Attributes?][]> { + const res: [string, number, Attributes?][] = [ + ["Events", timestamp, { object: [timestamp, 1], writable: [timestamp, 0] }], [ - Path.parse("Events"), - timestamp, - { object: [timestamp, 1], writable: [timestamp, 0] }, - ], - [ - Path.parse("DeviceID"), + "DeviceID", timestamp, { object: [timestamp, 1], writable: [timestamp, 0] }, ], ]; - const device = await collections.devices.findOne({ _id: id }); + const device = (await collections.devices.findOne({ _id: id })) as any; if (!device) return null; function storeParams( @@ -77,7 +72,7 @@ export async function fetchDevice( attrs.accessList = [obj["_attributesTimestamp"] || 1, obj["_accessList"]]; try { - res.push([Path.parse(path), t, attrs]); + res.push([path, t, attrs]); } catch { // The path parser is now more strict so we might be in a situation where // the database contains invalid paths from before this change So here we @@ -86,7 +81,7 @@ export async function fetchDevice( splits[splits.length - 1] = encodeTag(splits[splits.length - 1]) + INVALID_PATH_SUFFIX; path = splits.join("."); - res.push([Path.parse(path), t, attrs]); + res.push([path, t, attrs]); return; } @@ -98,17 +93,17 @@ export async function fetchDevice( } if (obj["_object"] && obj["_timestamp"]) - res.push([Path.parse(path + ".*"), obj["_timestamp"]]); + res.push([path + ".*", obj["_timestamp"]]); } const ts: number = +device["_timestamp"] || 0; - if (ts) res.push([Path.parse("*"), ts]); + if (ts) res.push(["*", ts]); for (const [k, v] of Object.entries(device)) { switch (k) { case "_lastInform": res.push([ - Path.parse("Events.Inform"), + "Events.Inform", +v, { object: [+v, 0], @@ -119,7 +114,7 @@ export async function fetchDevice( break; case "_lastBoot": res.push([ - Path.parse("Events.1_BOOT"), + "Events.1_BOOT", +v, { object: [+v, 0], @@ -130,7 +125,7 @@ export async function fetchDevice( break; case "_lastBootstrap": res.push([ - Path.parse("Events.0_BOOTSTRAP"), + "Events.0_BOOTSTRAP", +v, { object: [+v, 0], @@ -142,7 +137,7 @@ export async function fetchDevice( case "_registered": // Use current timestamp for registered event attribute timestamps res.push([ - Path.parse("Events.Registered"), + "Events.Registered", timestamp, { object: [timestamp, 0], @@ -153,7 +148,7 @@ export async function fetchDevice( break; case "_id": res.push([ - Path.parse("DeviceID.ID"), + "DeviceID.ID", timestamp, { object: [timestamp, 0], @@ -165,7 +160,7 @@ export async function fetchDevice( case "_tags": if ((v as string[]).length) { res.push([ - Path.parse("Tags"), + "Tags", timestamp, { object: [timestamp, 1], writable: [timestamp, 0] }, ]); @@ -173,7 +168,7 @@ export async function fetchDevice( for (const t of v as string[]) { res.push([ - Path.parse("Tags." + encodeTag(t)), + "Tags." + encodeTag(t), timestamp, { object: [timestamp, 0], @@ -186,7 +181,7 @@ export async function fetchDevice( case "_deviceId": if (v["_Manufacturer"] != null) { res.push([ - Path.parse("DeviceID.Manufacturer"), + "DeviceID.Manufacturer", timestamp, { object: [timestamp, 0], @@ -198,7 +193,7 @@ export async function fetchDevice( if (v["_OUI"] != null) { res.push([ - Path.parse("DeviceID.OUI"), + "DeviceID.OUI", timestamp, { object: [timestamp, 0], @@ -210,7 +205,7 @@ export async function fetchDevice( if (v["_ProductClass"] != null) { res.push([ - Path.parse("DeviceID.ProductClass"), + "DeviceID.ProductClass", timestamp, { object: [timestamp, 0], @@ -222,7 +217,7 @@ export async function fetchDevice( if (v["_SerialNumber"] != null) { res.push([ - Path.parse("DeviceID.SerialNumber"), + "DeviceID.SerialNumber", timestamp, { object: [timestamp, 0], @@ -257,7 +252,7 @@ export async function saveDevice( ) continue; - const parent = deviceData.paths.get(diff[0].slice(0, -1)); + const parent = deviceData.paths.get(diff[0].slice(0, -1).toString()); // Param timestamps may be greater than session timestamp to track revisions if (diff[2] > sessionTimestamp) diff[2] = sessionTimestamp; diff --git a/lib/cwmp/local-cache.ts b/lib/cwmp/local-cache.ts index 64a2453..31e2e77 100644 --- a/lib/cwmp/local-cache.ts +++ b/lib/cwmp/local-cache.ts @@ -1,15 +1,12 @@ import * as vm from "node:vm"; import * as crypto from "node:crypto"; -import * as config from "../config.ts"; import { collections } from "../db/db.ts"; import { convertOldPrecondition } from "../db/util.ts"; import * as logger from "../logger.ts"; import * as scheduling from "../scheduling.ts"; -import * as expression from "../common/expression/util.ts"; -import { parse } from "../common/expression/parser.ts"; +import Expression, { Value } from "../common/expression.ts"; import { Preset, - Expression, Provisions, VirtualParameters, Files, @@ -93,10 +90,10 @@ async function fetchPresets(): Promise<[string, Preset[]]> { } const events = preset["events"] || {}; - let precondition = true as Expression; + let precondition: Expression = new Expression.Literal(true); if (preset["precondition"]) { try { - precondition = parse(preset["precondition"]); + precondition = Expression.parse(preset["precondition"]); } catch { precondition = convertOldPrecondition( JSON.parse(preset["precondition"]), @@ -104,7 +101,7 @@ async function fetchPresets(): Promise<[string, Preset[]]> { } // Simplify expression - precondition = expression.evaluate(precondition); + precondition = precondition.evaluate((e) => e); } const _provisions: Preset["provisions"] = []; @@ -113,23 +110,42 @@ async function fetchPresets(): Promise<[string, Preset[]]> { for (const c of preset.configurations) { switch (c.type) { case "age": - _provisions.push(["refresh", c.name, +c.age]); + _provisions.push([ + "refresh", + new Expression.Literal(c.name), + new Expression.Literal(+c.age), + ]); break; case "value": - _provisions.push(["value", c.name, c.value]); + _provisions.push([ + "value", + new Expression.Literal(c.name), + new Expression.Literal(c.value), + ]); break; case "add_tag": - _provisions.push(["tag", c.tag, true]); + _provisions.push([ + "tag", + new Expression.Literal(c.tag), + new Expression.Literal(true), + ]); break; case "delete_tag": - _provisions.push(["tag", c.tag, false]); + _provisions.push([ + "tag", + new Expression.Literal(c.tag), + new Expression.Literal(false), + ]); break; case "provision": - _provisions.push([c.name, ...(c.args || [])]); + _provisions.push([ + c.name, + ...(c.args || []).map((a) => Expression.parse(a)), + ]); break; case "add_object": @@ -139,11 +155,19 @@ async function fetchPresets(): Promise<[string, Preset[]]> { .map((k) => `${k}:${JSON.stringify(obj[k])}`) .join(","); const p = `${c.name}.[${alias}]`; - _provisions.push(["instances", p, 1]); + _provisions.push([ + "instances", + new Expression.Literal(p), + new Expression.Literal(1), + ]); for (const k in obj) { if (!k.startsWith("_") && !(obj["_keys"].indexOf(k) !== -1)) - _provisions.push(["value", `${p}.${k}`, obj[k]]); + _provisions.push([ + "value", + new Expression.Literal(`${p}.${k}`), + new Expression.Literal(obj[k]), + ]); } } } @@ -157,7 +181,11 @@ async function fetchPresets(): Promise<[string, Preset[]]> { .map((k) => `${k}:${JSON.stringify(obj[k])}`) .join(","); const p = `${c.name}.[${alias}]`; - _provisions.push(["instances", p, 0]); + _provisions.push([ + "instances", + new Expression.Literal(p), + new Expression.Literal(0), + ]); } } @@ -251,7 +279,7 @@ async function fetchConfig(): Promise<[string, Config]> { for (const c of conf) { // Evaluate expressions to simplify them - _config[c._id] = expression.evaluate(parse(c.value)); + _config[c._id] = Expression.parse(c.value).evaluate((e) => e); } return [h, _config]; @@ -303,49 +331,37 @@ export function getFiles(revision: string): Files { } export function getConfig( - snapshotKey: string, + revision: string, key: string, - context: Record, - now: number, - cb?: (e: Expression) => Expression, -): string | number | boolean | null { - const snapshot = localCache.get(snapshotKey); + dflt: string, + fn: (e: Expression) => Expression.Literal, +): string; +export function getConfig( + revision: string, + key: string, + dflt: number, + fn: (e: Expression) => Expression.Literal, +): number; +export function getConfig( + revision: string, + key: string, + dflt: boolean, + fn: (e: Expression) => Expression.Literal, +): boolean; +export function getConfig( + revision: string, + key: string, + dflt: Value, + fn: (e: Expression) => Expression.Literal, +): Value { + const snapshot = localCache.get(revision); if (!snapshot) throw new Error("Cache snapshot does not exist"); - const oldOpts = { - "cwmp.downloadTimeout": "DOWNLOAD_TIMEOUT", - "cwmp.debug": "DEBUG", - "cwmp.retryDelay": "RETRY_DELAY", - "cwmp.sessionTimeout": "SESSION_TIMEOUT", - "cwmp.connectionRequestTimeout": "CONNECTION_REQUEST_TIMEOUT", - "cwmp.gpnNextLevel": "GPN_NEXT_LEVEL", - "cwmp.gpvBatchSize": "GPV_BATCH_SIZE", - "cwmp.cookiesPath": "COOKIES_PATH", - "cwmp.datetimeMilliseconds": "DATETIME_MILLISECONDS", - "cwmp.booleanLiteral": "BOOLEAN_LITERAL", - "cwmp.connectionRequestAllowBasicAuth": - "CONNECTION_REQUEST_ALLOW_BASIC_AUTH", - "cwmp.maxCommitIterations": "MAX_COMMIT_ITERATIONS", - "cwmp.deviceOnlineThreshold": "DEVICE_ONLINE_THRESHOLD", - "cwmp.udpConnectionRequestPort": "UDP_CONNECTION_REQUEST_PORT", - }; - - if (!(key in snapshot.config)) { - if (key in oldOpts) { - let id; - if (context?.["id"]) { - id = context["id"]; - } else if (cb) { - id = cb(["PARAM", "DeviceID.ID"]); - if (Array.isArray(id)) id = null; - } - return config.get(oldOpts[key], id); - } - return null; - } - - const v = expression.evaluate(snapshot.config[key], context, now, cb); - return Array.isArray(v) ? null : v; + const e = snapshot.config[key]; + if (!e) return dflt; + const v = e.evaluate(fn).value; + if (typeof v !== typeof dflt) return dflt; + return v; } export function getConfigExpression( diff --git a/lib/db/synth.ts b/lib/db/synth.ts index 470748f..ab302fe 100644 --- a/lib/db/synth.ts +++ b/lib/db/synth.ts @@ -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 { 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 { 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 { } } - 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 { } 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); } diff --git a/lib/db/types.ts b/lib/db/types.ts index ac9cc7e..c28bd37 100644 --- a/lib/db/types.ts +++ b/lib/db/types.ts @@ -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; diff --git a/lib/db/util.ts b/lib/db/util.ts index 1103259..853de61 100644 --- a/lib/db/util.ts +++ b/lib/db/util.ts @@ -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): 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): 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); } diff --git a/lib/device.ts b/lib/device.ts index 5e9a5a2..3be4a5f 100644 --- a/lib/device.ts +++ b/lib/device.ts @@ -1,3 +1,4 @@ +import Expression, { Value } from "./common/expression.ts"; import Path from "./common/path.ts"; import { DeviceData, @@ -84,9 +85,9 @@ export function getAliasDeclarations( if (path.alias) { for (const [i, alias] of path.segments.entries()) { - if (Array.isArray(alias)) { + if (alias instanceof Expression) { const parent = stripped.slice(0, i + 1); - for (const [p] of alias as [Path, string][]) { + for (const [p] of expressionToAlias(alias)) { decs = decs.concat( getAliasDeclarations(parent.concat(p), timestamp, { value: timestamp, @@ -100,6 +101,22 @@ export function getAliasDeclarations( return decs; } +export function expressionToAlias(exp: Expression): [Path, Value][] { + if (exp instanceof Expression.Literal && exp.value === true) return []; + if (exp instanceof Expression.Binary) { + if (exp.operator === "AND") + return [...expressionToAlias(exp.left), ...expressionToAlias(exp.right)]; + else if (exp.operator === "=") { + if ( + exp.left instanceof Expression.Parameter && + exp.right instanceof Expression.Literal + ) + return [[exp.left.path, exp.right.value]]; + } + } + throw new Error("Invalid alias expression"); +} + export function unpack( deviceData: DeviceData, path: Path, @@ -107,17 +124,19 @@ export function unpack( ): Path[] { let allMatches = [] as Path[]; if (!path.alias) { - for (const p of deviceData.paths.find(path, false, true)) + for (const p of deviceData.paths.findCompat(path, false, true)) if (deviceData.attributes.has(p, revision)) allMatches.push(p); } else { const wildcardPath = path.stripAlias(); - for (const p of deviceData.paths.find(wildcardPath, false, true)) + for (const p of deviceData.paths.findCompat(wildcardPath, false, true)) if (deviceData.attributes.has(p, revision)) allMatches.push(p); for (let i = path.length - 1; i >= 0; --i) { if (path.alias & (1 << i)) { - for (const [param, val] of path.segments[i] as [Path, string][]) { + for (const [param, val] of expressionToAlias( + path.segments[i] as Expression, + )) { const p = wildcardPath.slice(0, i + 1).concat(param); const unpacked = unpack(deviceData, p, revision); const filtered: Path[] = []; @@ -189,7 +208,7 @@ export function clear( attributes.value = attributes.object; } - for (const p of deviceData.paths.find( + for (const p of deviceData.paths.findCompat( path, true, true, @@ -252,12 +271,12 @@ function compareEquality(a, b): boolean { export function set( deviceData: DeviceData, - path: Path, + pathStr: string, timestamp: number, attributes: Attributes, toClear?: Clear[], ): Clear[] { - path = deviceData.paths.add(path); + const path = deviceData.paths.add(pathStr); const currentTimestamp = deviceData.timestamps.get(path); @@ -312,7 +331,7 @@ export function set( if (path.length > 1) { toClear = set( deviceData, - path.slice(0, path.length - 1), + path.slice(0, path.length - 1).toString(), timestamp, { object: [timestamp, 1] }, toClear, @@ -326,7 +345,12 @@ export function set( deviceData.attributes.delete(path); changeFlags |= 1; } else if (path.wildcard) { - for (const p of deviceData.paths.find(path, false, true, path.length)) { + for (const p of deviceData.paths.findCompat( + path, + false, + true, + path.length, + )) { if (timestamp > deviceData.timestamps.get(p)) { toClear = toClear || []; toClear.push([p, timestamp]); @@ -343,7 +367,12 @@ export function set( toClear = toClear || []; toClear.push([path, 0, { object: attributes.object[0] }, changeFlags]); } else { - for (const p of deviceData.paths.find(path, true, false, path.length)) { + for (const p of deviceData.paths.findCompat( + path, + true, + false, + path.length, + )) { const tracker = deviceData.trackers.get(p); for (const k in tracker) if (tracker[k] & changeFlags) deviceData.changes.add(k); @@ -356,11 +385,11 @@ export function set( export function track( deviceData: DeviceData, - path: Path, + pathStr: string, marker: string, attributes?: string[], ): void { - path = deviceData.paths.add(path); + const path = deviceData.paths.add(pathStr); let f = 1; if (attributes) diff --git a/lib/init.ts b/lib/init.ts index 4e7f90f..4c912db 100644 --- a/lib/init.ts +++ b/lib/init.ts @@ -79,14 +79,23 @@ export async function getStatus(): Promise { const users = getUsers(configSnapshot); const ui = getUiConfig(configSnapshot); - return { + const status = { users: !Object.keys(users).length, presets: !presetCount, - filters: !Object.keys(ui["filters"]).length, - device: !Object.keys(ui["device"]).length, - index: !Object.keys(ui["index"]).length, - overview: !Object.keys(ui["overview"]).length, + filters: true, + device: true, + index: true, + overview: true, }; + + for (const k of Object.keys(ui)) { + if (k.startsWith("filters.")) status.filters = false; + if (k.startsWith("device.")) status.device = false; + if (k.startsWith("index.")) status.index = false; + if (k.startsWith("overview.")) status.overview = false; + } + + return status; } export async function seed(options: Record): Promise { diff --git a/lib/nbi.ts b/lib/nbi.ts index e028cc4..e42f59e 100644 --- a/lib/nbi.ts +++ b/lib/nbi.ts @@ -14,6 +14,7 @@ import { flattenDevice } from "./ui/db.ts"; import { getRequestOrigin } from "./forwarded.ts"; import { acquireLock, releaseLock } from "./lock.ts"; import { ResourceLockedError } from "./common/errors.ts"; +import Expression from "./common/expression.ts"; const DEVICE_TASKS_REGEX = /^\/devices\/([a-zA-Z0-9\-_%]+)\/tasks\/?$/; const TASKS_REGEX = /^\/tasks\/([a-zA-Z0-9\-_%]+)(\/[a-zA-Z_]*)?$/; @@ -349,6 +350,24 @@ async function handler( const lastInform = (dev["_lastInform"] as Date).getTime(); const device = flattenDevice(dev); + const configCallback = (e: Expression): Expression.Literal => { + if (e instanceof Expression.Literal) return e; + else if (e instanceof Expression.Parameter) { + const p = device[e.path.toString()]; + if (p != null) return new Expression.Literal(p); + } else if (e instanceof Expression.FunctionCall) { + if (e.name === "NOW") return new Expression.Literal(Date.now()); + if (e.name === "REMOTE_ADDRESS") { + for (const root of ["InternetGatewayDevice", "Device"]) { + const p = device[`${root}.ManagementServer.ConnectionRequestURL`]; + if (p != null) + return new Expression.Literal(new URL(p as string).hostname); + } + } + } + return new Expression.Literal(null); + }; + let onlineThreshold: number; if (url.searchParams.has("timeout")) { onlineThreshold = parseInt(url.searchParams.get("timeout")); @@ -357,26 +376,9 @@ async function handler( onlineThreshold = getConfig( revision, "cwmp.deviceOnlineThreshold", - {}, - Date.now(), - (exp) => { - if (!Array.isArray(exp)) return exp; - if (exp[0] === "PARAM") { - const p = device[exp[1]]; - if (p?.value) return p.value[0]; - } else if (exp[0] === "FUNC") { - if (exp[1] === "REMOTE_ADDRESS") { - for (const root of ["InternetGatewayDevice", "Device"]) { - const p = - device[`${root}.ManagementServer.ConnectionRequestURL`]; - if (p?.value) return new URL(p.value[0] as string).hostname; - } - return null; - } - } - return exp; - }, - ) as number; + 4000, + configCallback, + ); } let status = await apiFunctions.connectionRequest(deviceId, device); diff --git a/lib/session.ts b/lib/session.ts index 3b858d6..a1fdbd1 100644 --- a/lib/session.ts +++ b/lib/session.ts @@ -25,7 +25,6 @@ import { TransferCompleteRequest, Operation, ScriptResult, - Expression, GetParameterValues, GetParameterAttributes, GetParameterNames, @@ -42,6 +41,7 @@ import { import { getRequestOrigin } from "./forwarded.ts"; import * as logger from "./logger.ts"; import { encodeTag } from "./util.ts"; +import Expression, { Value } from "./common/expression.ts"; const VALID_PARAM_TYPES = new Set([ "xsd:int", @@ -102,29 +102,25 @@ function generateRpcId(sessionContext: SessionContext): string { export function configContextCallback( sessionContext: SessionContext, exp: Expression, -): Expression { - if (!Array.isArray(exp)) return exp; - if (exp[0] === "PARAM" && typeof exp[1] === "string") { - let name = exp[1]; - if (name === "id") name = "DeviceID.ID"; - else if (name === "serialNumber") name = "DeviceID.SerialNumber"; - else if (name === "productClass") name = "DeviceID.ProductClass"; - else if (name === "oui") name = "DeviceID.OUI"; - else if (name === "remoteAddress") - return getRequestOrigin(sessionContext.httpRequest).remoteAddress; - +): Expression.Literal { + if (exp instanceof Expression.Literal) return exp; + else if (exp instanceof Expression.FunctionCall) { + if (exp.name === "NOW") + return new Expression.Literal(sessionContext.timestamp); + if (exp.name === "REMOTE_ADDRESS") + return new Expression.Literal( + getRequestOrigin(sessionContext.httpRequest).remoteAddress, + ); + } else if (exp instanceof Expression.Parameter) { const deviceData = sessionContext.deviceData; const paths = deviceData.paths; - const path = paths.get(Path.parse(name)); + const path = paths.get(exp.path.toString()); if (path) { const attrs = deviceData.attributes.get(path, 1); - if (attrs?.value?.[1]) return attrs.value[1][0]; + if (attrs?.value?.[1]) return new Expression.Literal(attrs.value[1][0]); } - } else if (exp[0] === "FUNC") { - if (exp[1] === "REMOTE_ADDRESS") - return getRequestOrigin(sessionContext.httpRequest).remoteAddress; } - return exp; + return new Expression.Literal(null); } export async function inform( @@ -132,9 +128,9 @@ export async function inform( rpcReq: InformRequest, ): Promise { const timestamp = sessionContext.timestamp + sessionContext.iteration + 1; - const params: [Path, number, Attributes][] = [ + const params: [string, number, Attributes][] = [ [ - Path.parse("DeviceID.Manufacturer"), + "DeviceID.Manufacturer", timestamp, { object: [timestamp, 0], @@ -144,7 +140,7 @@ export async function inform( ], [ - Path.parse("DeviceID.OUI"), + "DeviceID.OUI", timestamp, { object: [timestamp, 0], @@ -154,7 +150,7 @@ export async function inform( ], [ - Path.parse("DeviceID.ProductClass"), + "DeviceID.ProductClass", timestamp, { object: [timestamp, 0], @@ -164,7 +160,7 @@ export async function inform( ], [ - Path.parse("DeviceID.SerialNumber"), + "DeviceID.SerialNumber", timestamp, { object: [timestamp, 0], @@ -177,7 +173,7 @@ export async function inform( for (const p of rpcReq.parameterList) { const path = p[0]; params.push([ - path, + path.toString(), timestamp, { object: [timestamp, 0], @@ -187,7 +183,7 @@ export async function inform( } params.push([ - Path.parse("Events.Inform"), + "Events.Inform", timestamp, { object: [timestamp, 0], @@ -198,7 +194,7 @@ export async function inform( for (const e of rpcReq.event) { params.push([ - Path.parse(`Events.${encodeTag(e.replace(/\s+/g, "_"))}`), + `Events.${encodeTag(e.replace(/\s+/g, "_"))}`, timestamp, { object: [timestamp, 0], @@ -210,7 +206,7 @@ export async function inform( if (sessionContext.new) { params.push([ - Path.parse("DeviceID.ID"), + "DeviceID.ID", timestamp, { object: [timestamp, 0], @@ -219,7 +215,7 @@ export async function inform( }, ]); params.push([ - Path.parse("Events.Registered"), + "Events.Registered", timestamp, { object: [timestamp, 0], @@ -235,7 +231,7 @@ export async function inform( let toClear = null; for (const p of params) { // Don't need to clear wildcards for Events - if (p[0].segments[0] === "Events") { + if (p[0].startsWith("Events.")) { device.set(sessionContext.deviceData, p[0], p[1], p[2]); } else { toClear = device.set( @@ -301,69 +297,49 @@ export async function transferComplete( let toClear = null; const timestamp = sessionContext.timestamp + sessionContext.iteration + 1; - let p; - - p = sessionContext.deviceData.paths.add( - Path.parse(`Downloads.${instance}.LastDownload`), - ); toClear = device.set( sessionContext.deviceData, - p, + `Downloads.${instance}.LastDownload`, timestamp, { value: [timestamp, [operation.timestamp, "xsd:dateTime"]] }, toClear, ); - p = sessionContext.deviceData.paths.add( - Path.parse(`Downloads.${instance}.LastFileType`), - ); toClear = device.set( sessionContext.deviceData, - p, + `Downloads.${instance}.LastFileType`, timestamp, { value: [timestamp, [operation.args.fileType, "xsd:string"]] }, toClear, ); - p = sessionContext.deviceData.paths.add( - Path.parse(`Downloads.${instance}.LastFileName`), - ); toClear = device.set( sessionContext.deviceData, - p, + `Downloads.${instance}.LastFileName`, timestamp, { value: [timestamp, [operation.args.fileName, "xsd:string"]] }, toClear, ); - p = sessionContext.deviceData.paths.add( - Path.parse(`Downloads.${instance}.LastTargetFileName`), - ); toClear = device.set( sessionContext.deviceData, - p, + `Downloads.${instance}.LastTargetFileName`, timestamp, { value: [timestamp, [operation.args.targetFileName, "xsd:string"]] }, toClear, ); - p = sessionContext.deviceData.paths.add( - Path.parse(`Downloads.${instance}.StartTime`), - ); toClear = device.set( sessionContext.deviceData, - p, + `Downloads.${instance}.StartTime`, timestamp, { value: [timestamp, [+rpcReq.startTime, "xsd:dateTime"]] }, toClear, ); - p = sessionContext.deviceData.paths.add( - Path.parse(`Downloads.${instance}.CompleteTime`), - ); toClear = device.set( sessionContext.deviceData, - p, + `Downloads.${instance}.CompleteTime`, timestamp, { value: [timestamp, [+rpcReq.completeTime, "xsd:dateTime"]] }, toClear, @@ -387,22 +363,22 @@ function revertDownloadParameters( ): void { const timestamp = sessionContext.timestamp + sessionContext.iteration + 1; - let p; - - p = sessionContext.deviceData.paths.add( - Path.parse(`Downloads.${instance}.LastDownload`), + const lastDownloadPath = sessionContext.deviceData.paths.add( + `Downloads.${instance}.LastDownload`, ); - const lastDownload = sessionContext.deviceData.attributes.get(p); + const lastDownload = + sessionContext.deviceData.attributes.get(lastDownloadPath); - p = sessionContext.deviceData.paths.add( - Path.parse(`Downloads.${instance}.Download`), + const toClear = device.set( + sessionContext.deviceData, + `Downloads.${instance}.Download`, + timestamp, + { + value: [timestamp, [lastDownload?.value[1]?.[0] || 0, "xsd:dateTime"]], + }, ); - const toClear = device.set(sessionContext.deviceData, p, timestamp, { - value: [timestamp, [lastDownload?.value[1]?.[0] || 0, "xsd:dateTime"]], - }); - if (toClear) { for (const c of toClear) device.clear(sessionContext.deviceData, c[0], c[1], c[2], c[3]); @@ -426,11 +402,10 @@ export async function timeoutOperations( throw new Error(`Unknown operation name ${operation.name}`); const DOWNLOAD_TIMEOUT = - +localCache.getConfig( + localCache.getConfig( sessionContext.cacheSnapshot, "cwmp.downloadTimeout", - {}, - sessionContext.timestamp, + 3600, (e) => configContextCallback(sessionContext, e), ) * 1000; @@ -443,11 +418,10 @@ export async function timeoutOperations( commandKey: commandKey, }); - const SUCCESS_ON_TIMEOUT = +localCache.getConfig( + const SUCCESS_ON_TIMEOUT = localCache.getConfig( sessionContext.cacheSnapshot, "cwmp.downloadSuccessOnTimeout", - {}, - sessionContext.timestamp, + false, (e) => configContextCallback(sessionContext, e), ); @@ -486,16 +460,15 @@ export async function timeoutOperations( export function addProvisions( sessionContext: SessionContext, channel: string, - provisions: [string, ...Expression[]][], + provisions: [string, ...Value[]][], ): void { // Multiply by two because every iteration is two // phases: read and update const MAX_ITERATIONS = - +localCache.getConfig( + localCache.getConfig( sessionContext.cacheSnapshot, "cwmp.maxCommitIterations", - {}, - sessionContext.timestamp, + 32, (e) => configContextCallback(sessionContext, e), ) * 2; @@ -549,8 +522,7 @@ export function clearProvisions(sessionContext: SessionContext): void { +localCache.getConfig( sessionContext.cacheSnapshot, "cwmp.maxCommitIterations", - {}, - sessionContext.timestamp, + 32, (e) => configContextCallback(sessionContext, e), ) * 2; @@ -848,22 +820,20 @@ function runDeclarations( (path.alias | path.wildcard) & 1 || path.segments[0] === "VirtualParameters" ) { - sessionContext.deviceData.paths.add(Path.parse("VirtualParameters")); + sessionContext.deviceData.paths.add("VirtualParameters"); if ((path.alias | path.wildcard) & 2) { - sessionContext.deviceData.paths.add(Path.parse("VirtualParameters.*")); + sessionContext.deviceData.paths.add("VirtualParameters.*"); for (const k of Object.keys(allVirtualParameters)) { - sessionContext.deviceData.paths.add( - Path.parse(`VirtualParameters.${k}`), - ); + sessionContext.deviceData.paths.add(`VirtualParameters.${k}`); } } } if ((path.alias | path.wildcard) & 1 || path.segments[0] === "Reboot") - sessionContext.deviceData.paths.add(Path.parse("Reboot")); + sessionContext.deviceData.paths.add("Reboot"); if ((path.alias | path.wildcard) & 1 || path.segments[0] === "FactoryReset") - sessionContext.deviceData.paths.add(Path.parse("FactoryReset")); + sessionContext.deviceData.paths.add("FactoryReset"); if (path.alias) { const aliasDecs = device.getAliasDeclarations( @@ -871,12 +841,12 @@ function runDeclarations( declaration.pathGet || 1, ); for (const ad of aliasDecs) { - const p = sessionContext.deviceData.paths.add(ad.path); + const p = sessionContext.deviceData.paths.add(ad.path.toString()); allDeclareTimestamps.set( p, Math.max(ad.pathGet || 1, allDeclareTimestamps.get(p) || 0), ); - let attrTrackers; + let attrTrackers: string[]; if (ad.attrGet) { attrTrackers = Object.keys(ad.attrGet); mergeAttributeTimestamps(p, ad.attrGet); @@ -884,7 +854,7 @@ function runDeclarations( device.track( sessionContext.deviceData, - p, + p.toString(), "prerequisite", attrTrackers, ); @@ -900,14 +870,14 @@ function runDeclarations( mergeAttributeTimestamps(u, declaration.attrGet); } } else { - path = sessionContext.deviceData.paths.add(path); + path = sessionContext.deviceData.paths.add(path.toString()); allDeclareTimestamps.set( path, Math.max(declaration.pathGet || 1, allDeclareTimestamps.get(path) || 0), ); if (declaration.attrGet) mergeAttributeTimestamps(path, declaration.attrGet); - device.track(sessionContext.deviceData, path, "prerequisite"); + device.track(sessionContext.deviceData, path.toString(), "prerequisite"); } if (declaration.attrSet) { @@ -928,7 +898,7 @@ function runDeclarations( } if (declaration.pathSet != null) { - let minInstances, maxInstances; + let minInstances: number, maxInstances: number; if (Array.isArray(declaration.pathSet)) { minInstances = declaration.pathSet[0]; maxInstances = declaration.pathSet[1]; @@ -938,17 +908,19 @@ function runDeclarations( let parent = path.slice(0, -1); - let keys; - if (Array.isArray(path.segments[path.length - 1])) { + let keys: Record; + if (path.segments[path.length - 1] instanceof Expression) { keys = {}; - for (const [p, v] of path.segments[path.length - 1]) - keys[p.toString()] = v; + for (const [p, v] of device.expressionToAlias( + path.segments[path.length - 1] as Expression, + )) + keys[p.toString()] = v as string; } else if (path.segments[path.length - 1] === "*") { keys = {}; } if (!parent.wildcard && !parent.alias) { - parent = sessionContext.deviceData.paths.add(parent); + parent = sessionContext.deviceData.paths.add(parent.toString()); if (!unpacked) unpacked = device.unpack(sessionContext.deviceData, path); @@ -1143,8 +1115,7 @@ export async function rpcRequest( +localCache.getConfig( sessionContext.cacheSnapshot, "cwmp.maxCommitIterations", - {}, - sessionContext.timestamp, + 32, (e) => configContextCallback(sessionContext, e), ) * 2; @@ -1184,7 +1155,7 @@ export async function rpcRequest( ); const timestamp = sessionContext.timestamp + sessionContext.iteration; - let toClear; + let toClear: Clear[]; const allVirtualParameters = localCache.getVirtualParameters( sessionContext.cacheSnapshot, @@ -1198,7 +1169,7 @@ export async function rpcRequest( if (!sessionContext.deviceData.attributes.has(declaration[0])) { toClear = device.set( sessionContext.deviceData, - declaration[0], + declaration[0].toString(), timestamp, { object: [timestamp, 1], writable: [timestamp, 0] }, toClear, @@ -1211,7 +1182,7 @@ export async function rpcRequest( for (const k of Object.keys(allVirtualParameters)) { toClear = device.set( sessionContext.deviceData, - Path.parse(`VirtualParameters.${k}`), + `VirtualParameters.${k}`, timestamp, { object: [timestamp, 0], @@ -1221,7 +1192,7 @@ export async function rpcRequest( } toClear = device.set( sessionContext.deviceData, - declaration[0], + declaration[0].toString(), timestamp, null, toClear, @@ -1235,7 +1206,7 @@ export async function rpcRequest( if (!sessionContext.deviceData.attributes.has(declaration[0])) { toClear = device.set( sessionContext.deviceData, - declaration[0], + declaration[0].toString(), timestamp, { object: [timestamp, 0] }, toClear, @@ -1247,7 +1218,7 @@ export async function rpcRequest( } } - for (const p of sessionContext.deviceData.paths.find( + for (const p of sessionContext.deviceData.paths.findCompat( declaration[0], false, true, @@ -1290,7 +1261,7 @@ export async function rpcRequest( return rpcRequest(sessionContext, null); } - let toClear; + let toClear: Clear[]; const timestamp = sessionContext.timestamp + sessionContext.iteration + 1; // Update tags @@ -1299,7 +1270,7 @@ export async function rpcRequest( if (v && !c) { toClear = device.set( sessionContext.deviceData, - p, + p.toString(), timestamp, { object: [timestamp, 0], @@ -1311,7 +1282,7 @@ export async function rpcRequest( } else if (c && !v) { toClear = device.set( sessionContext.deviceData, - p, + p.toString(), timestamp, null, toClear, @@ -1320,11 +1291,11 @@ export async function rpcRequest( } // Downloads - let index; + let index: number; for (const instance of sessionContext.syncState.downloadsToCreate) { if (index == null) { index = 0; - for (const p of sessionContext.deviceData.paths.find( + for (const p of sessionContext.deviceData.paths.findCompat( Path.parse("Downloads.*"), false, true, @@ -1341,7 +1312,7 @@ export async function rpcRequest( toClear = device.set( sessionContext.deviceData, - Path.parse("Downloads"), + "Downloads", timestamp, { object: [timestamp, 1], writable: [timestamp, 1] }, toClear, @@ -1349,7 +1320,7 @@ export async function rpcRequest( toClear = device.set( sessionContext.deviceData, - Path.parse(`Downloads.${index}`), + `Downloads.${index}`, timestamp, { object: [timestamp, 1], writable: [timestamp, 1] }, toClear, @@ -1383,7 +1354,7 @@ export async function rpcRequest( for (const [k, v] of Object.entries(params)) { toClear = device.set( sessionContext.deviceData, - Path.parse(`Downloads.${index}.${k}`), + `Downloads.${index}.${k}`, timestamp, { object: [timestamp, 0], @@ -1399,7 +1370,7 @@ export async function rpcRequest( toClear = device.set( sessionContext.deviceData, - Path.parse(`Downloads.${index}.*`), + `Downloads.${index}.*`, timestamp, null, toClear, @@ -1411,7 +1382,7 @@ export async function rpcRequest( for (const instance of sessionContext.syncState.downloadsToDelete) { toClear = device.set( sessionContext.deviceData, - instance, + instance.toString(), timestamp, null, toClear, @@ -1432,7 +1403,7 @@ export async function rpcRequest( if (val[0] !== attrs.value[1][0]) { toClear = device.set( sessionContext.deviceData, - p, + p.toString(), timestamp, { value: [timestamp, val] }, toClear, @@ -1504,7 +1475,7 @@ export async function rpcRequest( toClear = device.set( sessionContext.deviceData, - Path.parse(`VirtualParameters.${vparams[i][0]}`), + `VirtualParameters.${vparams[i][0]}`, timestamp, vpu, toClear, @@ -1527,7 +1498,7 @@ function generateGetRpcRequest( for (const path of syncState.refreshAttributes.exist) { let found = false; - for (const p of sessionContext.deviceData.paths.find( + for (const p of sessionContext.deviceData.paths.findCompat( path, false, true, @@ -1547,7 +1518,9 @@ function generateGetRpcRequest( } if (!found) { - const p = sessionContext.deviceData.paths.add(path.slice(0, -1)); + const p = sessionContext.deviceData.paths.add( + path.slice(0, -1).toString(), + ); syncState.gpn.add(p); const f = 1 << p.length; syncState.gpnPatterns.set(p, f | syncState.gpnPatterns.get(p)); @@ -1557,7 +1530,7 @@ function generateGetRpcRequest( for (const path of syncState.refreshAttributes.object) { let found = false; - for (const p of sessionContext.deviceData.paths.find( + for (const p of sessionContext.deviceData.paths.findCompat( path, false, true, @@ -1577,7 +1550,9 @@ function generateGetRpcRequest( } if (!found) { - const p = sessionContext.deviceData.paths.add(path.slice(0, -1)); + const p = sessionContext.deviceData.paths.add( + path.slice(0, -1).toString(), + ); syncState.gpn.add(p); const f = 1 << p.length; syncState.gpnPatterns.set(p, f | syncState.gpnPatterns.get(p)); @@ -1586,7 +1561,7 @@ function generateGetRpcRequest( syncState.refreshAttributes.object.clear(); for (const path of syncState.refreshAttributes.writable) { - const p = sessionContext.deviceData.paths.add(path.slice(0, -1)); + const p = sessionContext.deviceData.paths.add(path.slice(0, -1).toString()); syncState.gpn.add(p); const f = 1 << p.length; syncState.gpnPatterns.set(p, f | syncState.gpnPatterns.get(p)); @@ -1597,8 +1572,7 @@ function generateGetRpcRequest( const GPN_NEXT_LEVEL = localCache.getConfig( sessionContext.cacheSnapshot, "cwmp.gpnNextLevel", - {}, - sessionContext.timestamp, + 0, (e) => configContextCallback(sessionContext, e), ) as number; @@ -1609,11 +1583,10 @@ function generateGetRpcRequest( // Skip root GPN workaround if (path && !path.length) { - const SKIP_ROOT_GPN = !!localCache.getConfig( + const SKIP_ROOT_GPN = localCache.getConfig( sessionContext.cacheSnapshot, "cwmp.skipRootGpn", - {}, - sessionContext.timestamp, + false, (e) => configContextCallback(sessionContext, e), ); @@ -1634,7 +1607,7 @@ function generateGetRpcRequest( let est = 0; if (path.length >= GPN_NEXT_LEVEL) { const patterns: [Path, number][] = [[path, 0]]; - for (const p of sessionContext.deviceData.paths.find( + for (const p of sessionContext.deviceData.paths.findCompat( path, true, false, @@ -1651,7 +1624,7 @@ function generateGetRpcRequest( syncState.gpn.delete(path); } else { nextLevel = false; - for (const p of sessionContext.deviceData.paths.find( + for (const p of sessionContext.deviceData.paths.findCompat( path, false, true, @@ -1672,8 +1645,7 @@ function generateGetRpcRequest( const GPV_BATCH_SIZE = localCache.getConfig( sessionContext.cacheSnapshot, "cwmp.gpvBatchSize", - {}, - sessionContext.timestamp, + 32, (e) => configContextCallback(sessionContext, e), ) as number; @@ -1703,8 +1675,7 @@ function generateGetRpcRequest( const GPV_BATCH_SIZE = localCache.getConfig( sessionContext.cacheSnapshot, "cwmp.gpvBatchSize", - {}, - sessionContext.timestamp, + 32, (e) => configContextCallback(sessionContext, e), ) as number; @@ -1764,11 +1735,10 @@ function generateSetRpcRequest( const deviceData = sessionContext.deviceData; - const SKIP_WRITABLE_CHECK = !!localCache.getConfig( + const SKIP_WRITABLE_CHECK = localCache.getConfig( sessionContext.cacheSnapshot, "cwmp.skipWritableCheck", - {}, - sessionContext.timestamp, + false, (e) => configContextCallback(sessionContext, e), ); @@ -1810,24 +1780,21 @@ function generateSetRpcRequest( const GPV_BATCH_SIZE = localCache.getConfig( sessionContext.cacheSnapshot, "cwmp.gpvBatchSize", - {}, - sessionContext.timestamp, + 32, (e) => configContextCallback(sessionContext, e), ) as number; - const DATETIME_MILLISECONDS = !!localCache.getConfig( + const DATETIME_MILLISECONDS = localCache.getConfig( sessionContext.cacheSnapshot, "cwmp.datetimeMilliseconds", - {}, - sessionContext.timestamp, + true, (e) => configContextCallback(sessionContext, e), ); - const BOOLEAN_LITERAL = !!localCache.getConfig( + const BOOLEAN_LITERAL = localCache.getConfig( sessionContext.cacheSnapshot, "cwmp.booleanLiteral", - {}, - sessionContext.timestamp, + true, (e) => configContextCallback(sessionContext, e), ); @@ -1904,15 +1871,13 @@ function generateSetRpcRequest( const t2 = attrs?.value?.[1]?.[0] as number; if (!(t <= t2)) { const fileTypeAttrs = deviceData.attributes.get( - deviceData.paths.get(p.slice(0, -1).concat(Path.parse("FileType"))), + deviceData.paths.get(p.slice(0, -1).toString() + ".FileType"), ); const fileNameAttrs = deviceData.attributes.get( - deviceData.paths.get(p.slice(0, -1).concat(Path.parse("FileName"))), + deviceData.paths.get(p.slice(0, -1).toString() + ".FileName"), ); const targetFileNameAttrs = deviceData.attributes.get( - deviceData.paths.get( - p.slice(0, -1).concat(Path.parse("TargetFileName")), - ), + deviceData.paths.get(p.slice(0, -1).toString() + ".TargetFileName"), ); return { @@ -1928,7 +1893,7 @@ function generateSetRpcRequest( // Reboot if (syncState.reboot > 0 && syncState.reboot <= sessionContext.timestamp) { - const p = sessionContext.deviceData.paths.get(Path.parse("Reboot")); + const p = sessionContext.deviceData.paths.get("Reboot"); const attrs = p ? sessionContext.deviceData.attributes.get(p) : null; const t = attrs?.value?.[1][0] as number; if (!(t >= syncState.reboot)) { @@ -1942,7 +1907,7 @@ function generateSetRpcRequest( syncState.factoryReset > 0 && syncState.factoryReset <= sessionContext.timestamp ) { - const p = sessionContext.deviceData.paths.get(Path.parse("FactoryReset")); + const p = sessionContext.deviceData.paths.get("FactoryReset"); const attrs = p ? sessionContext.deviceData.attributes.get(p) : null; const t = attrs?.value?.[1][0] as number; if (!(t >= syncState.factoryReset)) { @@ -2064,8 +2029,7 @@ function processDeclarations( const deviceData = sessionContext.deviceData; const syncState = sessionContext.syncState; - const root = sessionContext.deviceData.paths.add(Path.parse("")); - const paths = deviceData.paths.find(root, false, true, 99); + const paths = deviceData.paths.findCompat(Path.root, false, true, 99); paths.sort((a, b): number => a.wildcard === b.wildcard ? a.length - b.length : a.wildcard - b.wildcard, ); @@ -2358,7 +2322,7 @@ function processDeclarations( allDeclareAttributeTimestamps.size || allDeclareAttributeValues.size ) - func(root, 1, 0, paths); + func(Path.root, 1, 0, [Path.root, ...paths]); return virtualParameterDeclarations; } @@ -2372,7 +2336,7 @@ function processInstances( maxInstances: number, defer: boolean, ): void { - parent = sessionContext.deviceData.paths.add(parent); + parent = sessionContext.deviceData.paths.add(parent.toString()); let instancesToCreate: InstanceSet, instancesToDelete: Set; if (parent.segments[0] === "Downloads") { if (parent.length !== 1) return; @@ -2491,19 +2455,17 @@ export async function rpcResponse( if (!parameterList.length) { sessionContext.rpcRequest = null; } else { - const DATETIME_MILLISECONDS = !!localCache.getConfig( + const DATETIME_MILLISECONDS = localCache.getConfig( sessionContext.cacheSnapshot, "cwmp.datetimeMilliseconds", - {}, - sessionContext.timestamp, + true, (e) => configContextCallback(sessionContext, e), ); - const BOOLEAN_LITERAL = !!localCache.getConfig( + const BOOLEAN_LITERAL = localCache.getConfig( sessionContext.cacheSnapshot, "cwmp.booleanLiteral", - {}, - sessionContext.timestamp, + true, (e) => configContextCallback(sessionContext, e), ); @@ -2543,7 +2505,7 @@ export async function rpcResponse( } toClear = device.set( sessionContext.deviceData, - path, + path.toString(), timestamp, { object: [timestamp, 0], @@ -2562,7 +2524,7 @@ export async function rpcResponse( }); toClear = device.set( sessionContext.deviceData, - Path.parse(p), + p, timestamp, { object: [timestamp, 0], @@ -2589,7 +2551,7 @@ export async function rpcResponse( } toClear = device.set( sessionContext.deviceData, - path, + path.toString(), timestamp, { notification: [timestamp, notification], @@ -2608,7 +2570,7 @@ export async function rpcResponse( }); toClear = device.set( sessionContext.deviceData, - Path.parse(p), + p, timestamp, { notification: [timestamp, 0], @@ -2623,7 +2585,8 @@ export async function rpcResponse( return invalidResponse("Response name does not match request name"); let root: Path; - if (rpcReq.parameterPath.endsWith(".")) + if (!rpcReq.parameterPath) root = Path.root; + else if (rpcReq.parameterPath.endsWith(".")) root = Path.parse(rpcReq.parameterPath.slice(0, -1)); else root = Path.parse(rpcReq.parameterPath); @@ -2667,7 +2630,7 @@ export async function rpcResponse( "VirtualParameters", "Downloads", ]) { - const p = sessionContext.deviceData.paths.get(Path.parse(n)); + const p = sessionContext.deviceData.paths.get(n); if (p && sessionContext.deviceData.attributes.has(p)) sessionContext.deviceData.timestamps.set(p, timestamp); } @@ -2693,7 +2656,7 @@ export async function rpcResponse( toClear = device.set( sessionContext.deviceData, - path, + path.toString(), timestamp, { object: [timestamp, object ? 1 : 0], @@ -2706,7 +2669,7 @@ export async function rpcResponse( for (const path of wildcardParams) { toClear = device.set( sessionContext.deviceData, - path, + path.toString(), timestamp, null, toClear, @@ -2719,7 +2682,7 @@ export async function rpcResponse( for (const p of rpcReq.parameterList) { toClear = device.set( sessionContext.deviceData, - Path.parse(p[0]), + p[0], timestamp + 1, { object: [timestamp + 1, 0], @@ -2755,7 +2718,7 @@ export async function rpcResponse( toClear = device.set( sessionContext.deviceData, - Path.parse(p[0]), + p[0], timestamp + 1, attrs, toClear, @@ -2767,7 +2730,7 @@ export async function rpcResponse( toClear = device.set( sessionContext.deviceData, - Path.parse(rpcReq.objectName + rpcRes.instanceNumber), + rpcReq.objectName + rpcRes.instanceNumber, timestamp + 1, { object: [timestamp + 1, 1] }, toClear, @@ -2778,7 +2741,7 @@ export async function rpcResponse( toClear = device.set( sessionContext.deviceData, - Path.parse(rpcReq.objectName.slice(0, -1)), + rpcReq.objectName.slice(0, -1), timestamp + 1, null, toClear, @@ -2789,7 +2752,7 @@ export async function rpcResponse( toClear = device.set( sessionContext.deviceData, - Path.parse("Reboot"), + "Reboot", timestamp + 1, { value: [timestamp + 1, [sessionContext.timestamp, "xsd:dateTime"]] }, toClear, @@ -2800,7 +2763,7 @@ export async function rpcResponse( toClear = device.set( sessionContext.deviceData, - Path.parse("FactoryReset"), + "FactoryReset", timestamp + 1, { value: [timestamp + 1, [sessionContext.timestamp, "xsd:dateTime"]] }, toClear, @@ -2811,7 +2774,7 @@ export async function rpcResponse( toClear = device.set( sessionContext.deviceData, - Path.parse(`Downloads.${rpcReq.instance}.Download`), + `Downloads.${rpcReq.instance}.Download`, timestamp + 1, { value: [timestamp + 1, [sessionContext.timestamp, "xsd:dateTime"]] }, toClear, @@ -2820,7 +2783,7 @@ export async function rpcResponse( if (rpcRes.status === 0) { toClear = device.set( sessionContext.deviceData, - Path.parse(`Downloads.${rpcReq.instance}.LastDownload`), + `Downloads.${rpcReq.instance}.LastDownload`, timestamp + 1, { value: [timestamp + 1, [sessionContext.timestamp, "xsd:dateTime"]], @@ -2830,7 +2793,7 @@ export async function rpcResponse( toClear = device.set( sessionContext.deviceData, - Path.parse(`Downloads.${rpcReq.instance}.LastFileType`), + `Downloads.${rpcReq.instance}.LastFileType`, timestamp + 1, { value: [timestamp + 1, [rpcReq.fileType, "xsd:string"]] }, toClear, @@ -2838,7 +2801,7 @@ export async function rpcResponse( toClear = device.set( sessionContext.deviceData, - Path.parse(`Downloads.${rpcReq.instance}.LastFileName`), + `Downloads.${rpcReq.instance}.LastFileName`, timestamp + 1, { value: [timestamp + 1, [rpcReq.fileType, "xsd:string"]] }, toClear, @@ -2846,7 +2809,7 @@ export async function rpcResponse( toClear = device.set( sessionContext.deviceData, - Path.parse(`Downloads.${rpcReq.instance}.LastTargetFileName`), + `Downloads.${rpcReq.instance}.LastTargetFileName`, timestamp + 1, { value: [timestamp + 1, [rpcReq.fileType, "xsd:string"]] }, toClear, @@ -2854,7 +2817,7 @@ export async function rpcResponse( toClear = device.set( sessionContext.deviceData, - Path.parse(`Downloads.${rpcReq.instance}.StartTime`), + `Downloads.${rpcReq.instance}.StartTime`, timestamp + 1, { value: [timestamp + 1, [+rpcRes.startTime, "xsd:dateTime"]] }, toClear, @@ -2862,7 +2825,7 @@ export async function rpcResponse( toClear = device.set( sessionContext.deviceData, - Path.parse(`Downloads.${rpcReq.instance}.CompleteTime`), + `Downloads.${rpcReq.instance}.CompleteTime`, timestamp + 1, { value: [timestamp + 1, [+rpcRes.completeTime, "xsd:dateTime"]] }, toClear, @@ -2977,7 +2940,7 @@ export async function deserialize( const deviceData = initDeviceData(); for (const r of sessionContext.deviceData as unknown as any[]) { - const path = deviceData.paths.add(Path.parse(r[0])); + const path = deviceData.paths.add(r[0]); if (r[1]) deviceData.trackers.set(path, r[1]); @@ -2999,8 +2962,8 @@ export async function serialize( ): Promise { const deviceData = []; - for (const path of sessionContext.deviceData.paths.find( - Path.parse(""), + for (const path of sessionContext.deviceData.paths.findCompat( + Path.root, false, false, 99, diff --git a/lib/types.ts b/lib/types.ts index 77dd949..4191a94 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -4,8 +4,7 @@ import Path from "./common/path.ts"; import PathSet from "./common/path-set.ts"; import VersionedMap from "./versioned-map.ts"; import InstanceSet from "./instance-set.ts"; - -export type Expression = string | number | boolean | null | any[]; +import Expression, { Value } from "./common/expression.ts"; export interface Fault { code: string; @@ -160,7 +159,7 @@ export interface Task { fileName?: string; targetFileName?: string; expiry?: number; - provisions?: [string, ...Expression[]][]; + provisions?: [string, ...Value[]][]; } export interface Operation { @@ -470,7 +469,7 @@ export interface Permissions { [resource: string]: { access: number; filter: Expression; - validate?: Expression; + validate: Expression; }; }; }; @@ -479,7 +478,7 @@ export interface Permissions { export type PermissionSet = { [resource: string]: { access: number; - validate?: Expression; + validate: Expression; filter: Expression; }; }[]; @@ -488,16 +487,7 @@ export interface Config { [name: string]: Expression; } -export interface UiConfig { - filters: Record; - device: Record; - index: Record; - overview: { - charts?: Record; - groups?: Record; - }; - pageSize?: Expression; -} +export type UiConfig = Record; export interface SoapMessage { id: string; diff --git a/lib/ui.ts b/lib/ui.ts index 8a1c444..0c972b3 100644 --- a/lib/ui.ts +++ b/lib/ui.ts @@ -214,8 +214,17 @@ router.get("/", async (ctx) => { // be problematic when using relatives asset paths in HTML if (ctx.path.endsWith("//")) return; - const permissionSets: PermissionSet[] = - ctx.state.authorizer.getPermissionSets(); + const ps: PermissionSet[] = ctx.state.authorizer.getPermissionSets(); + const permissionSets = ps.map((p) => + p.map((s) => + Object.fromEntries( + Object.entries(s).map(([resource, { access, validate, filter }]) => [ + resource, + { access, validate: validate.toString(), filter: filter.toString() }, + ]), + ), + ), + ); let wizard = ""; if (!Object.keys(localCache.getUsers(ctx.state.configSnapshot)).length) @@ -231,9 +240,7 @@ router.get("/", async (ctx) => {