UNPKG

@bufbuild/cel

Version:

A CEL evaluator for ECMAScript

587 lines (586 loc) 19.7 kB
// Copyright 2024-2025 Buf Technologies, Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. import { ConcreteAttributeFactory, ErrorAttr, } from "./access.js"; import { VarActivation } from "./activation.js"; import * as opc from "./gen/dev/cel/expr/operator_const.js"; import { Namespace } from "./namespace.js"; import { celError, celErrorMerge, isCelError, } from "./error.js"; import { celList, EMPTY_LIST, isCelList } from "./list.js"; import { celMap, EMPTY_MAP, isCelMap } from "./map.js"; import { celUint, isCelUint } from "./uint.js"; import { celObject } from "./object.js"; import { celType } from "./type.js"; export class Planner { constructor(functions, registry, namespace = Namespace.ROOT) { this.functions = functions; this.registry = registry; this.namespace = namespace; this.factory = new ConcreteAttributeFactory(this.registry, this.namespace); } plan(expr) { const id = Number(expr.id); switch (expr.exprKind.case) { case "identExpr": return new EvalAttr(this.factory.createMaybe(id, expr.exprKind.value.name), false); case "constExpr": return new EvalConst(id, this.constVal(expr.exprKind.value)); case "callExpr": return this.planCall(id, expr.exprKind.value); case "listExpr": return this.planCreateList(id, expr.exprKind.value); case "structExpr": return this.planCreateStruct(id, expr.exprKind.value); case "selectExpr": return this.planSelect(id, expr.exprKind.value); case "comprehensionExpr": return this.planComprehension(id, expr.exprKind.value); default: return new EvalErr(id, "invalid expression"); } } planComprehension(id, value) { if (value.accuInit === undefined || value.iterRange === undefined || value.loopCondition === undefined || value.loopStep === undefined || value.result === undefined) { throw new Error("invalid comprehension"); } const accu = this.plan(value.accuInit); const iterRange = this.plan(value.iterRange); const cond = this.plan(value.loopCondition); const step = this.plan(value.loopStep); const result = this.plan(value.result); return new EvalFold(id, value.accuVar, value.iterVar, iterRange, accu, cond, step, result); } planSelect(id, expr) { if (expr.operand === undefined) { throw new Error("invalid select"); } const operand = this.plan(expr.operand); const attr = this.relativeAttr(id, operand, false); const acc = this.factory.newAccess(id, expr.field, false); if (acc instanceof ErrorAttr) { throw new Error(`invalid select: ${acc.error.message}`); } if (expr.testOnly) { return new EvalHas(id, attr, acc, expr.field); } attr.addAccess(acc); return attr; } planCreateObj(id, expr) { const typeName = this.resolveType(expr.messageName); if (typeName === undefined) { return new EvalErr(id, "unknown type: " + expr.messageName); } let optionals = undefined; const keys = []; const values = []; for (let i = 0; i < expr.entries.length; i++) { const entry = expr.entries[i]; if (entry.optionalEntry) { if (optionals === undefined) { optionals = new Array(expr.entries.length).fill(false); } optionals[i] = true; } switch (entry.keyKind.case) { case "fieldKey": keys.push(entry.keyKind.value); break; case "mapKey": throw new Error("invalid entry"); default: break; } if (entry.value === undefined) { throw new Error("invalid entry"); } values.push(this.plan(entry.value)); } return new EvalObj(id, typeName, keys, values, optionals); } planCreateStruct(id, expr) { if (expr.messageName !== "") { return this.planCreateObj(id, expr); } let optionals = undefined; const keys = []; const values = []; for (let i = 0; i < expr.entries.length; i++) { const entry = expr.entries[i]; if (entry.optionalEntry) { if (optionals === undefined) { optionals = new Array(expr.entries.length).fill(false); } optionals[i] = true; } switch (entry.keyKind.case) { case "fieldKey": throw new Error("unimplemented"); case "mapKey": keys.push(this.plan(entry.keyKind.value)); break; default: break; } if (entry.value === undefined) { return new EvalErr(id, "map entry missing value"); } values.push(this.plan(entry.value)); } return new EvalMap(id, keys, values, optionals); } planCreateList(id, expr) { const optionals = undefined; if (expr.optionalIndices.length > 0) { // set optionals to an array of booleans the same length as the list const optionals = new Array(expr.elements.length).fill(false); for (let i = 0; i < expr.optionalIndices.length; i++) { const index = expr.optionalIndices[i]; if (index < 0 || index >= expr.elements.length) { throw new Error("invalid optional index"); } optionals[index] = true; } } return new EvalList(id, expr.elements.map((arg) => this.plan(arg)), optionals); } planCall(id, call) { // Check if the function is a qualified name. if (call.target !== undefined) { const qualName = toQualifiedName(call.target); if (qualName !== undefined) { const funcName = qualName + "." + call.function; for (const candidate of this.namespace.resolveCandidateNames(funcName)) { const func = this.functions.find(candidate); if (func !== undefined) { return new EvalCall(id, candidate, "", func, call.args.map((arg) => this.plan(arg))); } } } } const args = call.target ? [this.plan(call.target), ...call.args.map((arg) => this.plan(arg))] : call.args.map((arg) => this.plan(arg)); switch (call.function) { case opc.INDEX: return this.planCallIndex(call, args, false); case opc.OPT_INDEX: case opc.OPT_SELECT: return this.planCallIndex(call, args, true); case opc.CONDITIONAL: return this.planCallConditional(id, call, args); default: break; } return new EvalCall(id, call.function, "", this.functions.find(call.function), args); } planCallConditional(id, _call, args) { const cond = args[0]; const t = args[1]; const f = args[2]; const tAttr = this.relativeAttr(t.id, t, false); const fAttr = this.relativeAttr(f.id, f, false); return new EvalAttr(this.factory.createConditional(id, cond, tAttr, fAttr), false); } planCallIndex(_call, args, opt) { const op = args[0]; const ind = args[1]; const attr = this.relativeAttr(op.id, op, false); let acc; if (ind instanceof EvalConst) { acc = this.factory.newAccess(op.id, ind.value, opt); } else if (ind instanceof EvalAttr) { acc = this.factory.newAccess(op.id, ind, opt); } else { acc = this.relativeAttr(op.id, ind, opt); } attr.addAccess(acc); return attr; } constVal(val) { switch (val.constantKind.case) { case "stringValue": return val.constantKind.value; case "bytesValue": return val.constantKind.value; case "doubleValue": return val.constantKind.value; case "boolValue": return val.constantKind.value; case "int64Value": return val.constantKind.value; case "uint64Value": return celUint(val.constantKind.value); case "nullValue": return null; case undefined: throw new Error("invalid constant"); default: throw new Error(`unimplemented: ${val.constantKind.case}`); } } relativeAttr(id, e, opt) { if (e instanceof EvalAttr) { return e; } return new EvalAttr(this.factory.createRelative(id, e), opt); } resolveType(name) { for (const candidate of this.namespace.resolveCandidateNames(name)) { if (this.isKnownType(candidate)) { return candidate; } } return undefined; } isKnownType(name) { switch (name) { case "google.protobuf.Value": case "google.protobuf.Struct": case "google.protobuf.ListValue": case "google.protobuf.NullValue": case "google.protobuf.BoolValue": case "google.protobuf.UInt32Value": case "google.protobuf.UInt64Value": case "google.protobuf.Int32Value": case "google.protobuf.Int64Value": case "google.protobuf.FloatValue": case "google.protobuf.DoubleValue": case "google.protobuf.StringValue": case "google.protobuf.BytesValue": case "google.protobuf.Timestamp": case "google.protobuf.Duration": case "google.protobuf.Any": return true; default: return this.registry.getMessage(name) !== undefined; } } } export class EvalHas { constructor(id, attr, access, field) { this.id = id; this.attr = attr; this.access = access; this.field = field; } eval(ctx) { const raw = this.attr.resolve(ctx); if (raw === undefined) { return false; } if (isCelError(raw)) { return raw; } return this.access.isPresent(ctx, raw); } } export class EvalErr { constructor(id, msg) { this.id = id; this.msg = msg; } eval(_ctx) { return celError(this.msg, this.id); } } export class EvalConst { constructor(id, value) { this.id = id; this.value = value; } eval(_ctx) { return this.value; } } export class EvalAttr { constructor(attr, opt) { this.attr = attr; this.opt = opt; this.id = attr.id; } access(vars, obj) { return this.attr.access(vars, obj); } isPresent(vars, obj) { return this.attr.isPresent(vars, obj); } accessIfPresent(vars, obj, presenceOnly) { return this.attr.accessIfPresent(vars, obj, presenceOnly); } isOptional() { return this.opt; } eval(ctx) { const val = this.attr.resolve(ctx); if (val === undefined) { return celError("unresolved attribute", this.id); } if (isCelError(val)) { return val; } return val; } resolve(vars) { return this.attr.resolve(vars); } addAccess(acc) { this.attr.addAccess(acc); } } export class EvalCall { constructor(id, name, overload, call, args) { this.id = id; this.name = name; this.overload = overload; this.call = call; this.args = args; } eval(ctx) { if (this.call === undefined) { return celError(`unbound function: ${this.name}`, this.id); } const argVals = this.args.map((x) => x.eval(ctx)); const result = this.call.dispatch(this.id, argVals); if (result !== undefined) { return result; } const vals = coerceToValues(argVals); if (isCelError(vals)) { return vals; } return celError(`found no matching overload for '${this.name}' applied to '(${vals .map((x) => celType(x)) .map((x) => x.name) .join(", ")})'`, this.id); } } export class EvalObj { constructor(id, typeName, fields, values, optionals) { this.id = id; this.typeName = typeName; this.fields = fields; this.values = values; this.optionals = optionals; } args() { return this.values; } eval(ctx) { const vals = coerceToValues(this.values.map((x) => x.eval(ctx))); if (isCelError(vals)) { return vals; } const obj = new Map(); for (let i = 0; i < vals.length; i++) { if (obj.has(this.fields[i])) { return celError(`map key conflict: ${this.fields[i]}`, this.id); } obj.set(this.fields[i], vals[i]); } try { return celObject(this.typeName, obj); } catch (ex) { if (ex instanceof Error) { ex = ex.message; } return celError(`${ex}`, this.id); } } } export class EvalList { constructor(id, elems, _) { this.id = id; this.elems = elems; } eval(ctx) { if (this.elems.length === 0) { return EMPTY_LIST; } const first = this.elems[0].eval(ctx); if (isCelError(first)) { return first; } const elemVals = [first]; for (let i = 1; i < this.elems.length; i++) { const elemVal = this.elems[i].eval(ctx); if (isCelError(elemVal)) { return elemVal; } elemVals.push(elemVal); } return celList(elemVals); } args() { return this.elems; } } export class EvalMap { constructor(id, keys, values, _) { this.id = id; this.keys = keys; this.values = values; } args() { return this.keys.concat(this.values); } eval(ctx) { if (this.keys.length === 0) { return EMPTY_MAP; } const entries = new Map(); const firstKey = this.mapKeyOrError(this.keys[0].eval(ctx)); if (isCelError(firstKey)) { return firstKey; } const firstVal = this.values[0].eval(ctx); if (isCelError(firstVal)) { return firstVal; } if (typeof firstKey === "number" && !Number.isInteger(firstKey)) { return unsupportedKeyType(this.id); } entries.set(firstKey, firstVal); for (let i = 1; i < this.keys.length; i++) { const key = this.mapKeyOrError(this.keys[i].eval(ctx)); if (isCelError(key)) { return key; } const val = this.values[i].eval(ctx); if (isCelError(val)) { return val; } if (entries.has(key)) { return celError(`map key conflict: ${key}`, this.id); } if (typeof key === "number" && !Number.isInteger(key)) { return unsupportedKeyType(this.id); } entries.set(key, val); } return celMap(entries); } mapKeyOrError(key) { switch (typeof key) { case "boolean": case "bigint": case "string": return key; case "object": if (isCelUint(key)) { return key; } return unsupportedKeyType(this.id); case "number": if (Number.isInteger(key)) { return BigInt(key); } return unsupportedKeyType(this.id); default: return unsupportedKeyType(this.id); } } } export class EvalFold { constructor(id, accuVar, iterVar, iterRange, accu, cond, step, result) { this.id = id; this.accuVar = accuVar; this.iterVar = iterVar; this.iterRange = iterRange; this.accu = accu; this.cond = cond; this.step = step; this.result = result; } eval(ctx) { const accuInit = this.accu.eval(ctx); if (isCelError(accuInit)) { return accuInit; } const accuCtx = new VarActivation(this.accuVar, accuInit, ctx); const iterRange = this.iterRange.eval(ctx); if (isCelError(iterRange)) { return iterRange; } let items = []; if (isCelMap(iterRange)) { items = Array.from(iterRange.keys()); } else if (isCelList(iterRange)) { items = Array.from(iterRange); } else { return celError(`type mismatch: iterable vs ${celType(iterRange)}`, this.id); } // Fold the items. for (const item of items) { if (isCelError(item)) { return item; } const iterCtx = new VarActivation(this.iterVar, item, accuCtx); const cond = this.cond.eval(iterCtx); if (isCelError(cond)) { return cond; } if (cond !== true) { break; } // Update the result. accuCtx.value = this.step.eval(iterCtx); } // Compute the result return this.result.eval(accuCtx); } } function toQualifiedName(expr) { switch (expr.exprKind.case) { case "identExpr": return expr.exprKind.value.name; case "selectExpr": { if (expr.exprKind.value.testOnly || expr.exprKind.value.operand === undefined) { return undefined; } const parent = toQualifiedName(expr.exprKind.value.operand); if (parent === undefined) { return undefined; } return parent + "." + expr.exprKind.value.field; } default: return undefined; } } function unsupportedKeyType(id) { return celError(`unsupported key type`, id); } function coerceToValues(args) { const errors = []; for (let i = 0; i < args.length; i++) { const arg = args[i]; if (isCelError(arg)) { errors.push(arg); } } if (errors.length > 0) { return celErrorMerge(errors[0], ...errors.slice(1)); } return args; }