@objectstack/formula 9.11.0 → 10.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -26,12 +26,16 @@ __export(index_exports, {
26
26
  TEMPLATE_FORMATTERS: () => TEMPLATE_FORMATTERS,
27
27
  buildScope: () => buildScope,
28
28
  celEngine: () => celEngine,
29
+ compileCelToFilter: () => compileCelToFilter,
29
30
  cronEngine: () => cronEngine,
30
31
  expectedDialect: () => expectedDialect,
31
32
  formatValue: () => formatValue,
32
33
  getEngine: () => getEngine,
33
34
  hasDialect: () => hasDialect,
34
35
  introspectScope: () => introspectScope,
36
+ isPushdownableCel: () => isPushdownableCel,
37
+ lowerCelAst: () => lowerCelAst,
38
+ matchesFilterCondition: () => matchesFilterCondition,
35
39
  normalizeExpression: () => normalizeExpression,
36
40
  normalizeExpressionTree: () => normalizeExpressionTree,
37
41
  register: () => register,
@@ -219,7 +223,14 @@ var SCOPE_ROOTS = [
219
223
  "data",
220
224
  "params",
221
225
  "config",
222
- "settings"
226
+ "settings",
227
+ // UI action / predicate context (ActionEngine, renderers): the current
228
+ // record plus ambient globals exposed to `visible`/`disabled` predicates.
229
+ "ctx",
230
+ "features",
231
+ // Master-detail inline grids inject the header record as `parent` for a
232
+ // child field's `readonlyWhen`/`requiredWhen` predicate (ADR-0036, #1581).
233
+ "parent"
223
234
  ];
224
235
  function buildScopedEnv(knownFields) {
225
236
  const env = new import_cel_js.Environment({
@@ -791,13 +802,364 @@ function looksLikeExpression(value) {
791
802
  return import_spec2.ExpressionSchema.safeParse(v).success;
792
803
  }
793
804
 
805
+ // src/cel-to-filter.ts
806
+ var import_cel_js2 = require("@marcbachmann/cel-js");
807
+ var SHAPE_VALUE = /* @__PURE__ */ Symbol("cel-filter-shape-placeholder");
808
+ var CompileError = class extends Error {
809
+ constructor(reason, message) {
810
+ super(message);
811
+ this.reason = reason;
812
+ this.name = "CelFilterCompileError";
813
+ }
814
+ };
815
+ var parseEnv;
816
+ function getParseEnv() {
817
+ if (!parseEnv) {
818
+ parseEnv = new import_cel_js2.Environment({ unlistedVariablesAreDyn: true, enableOptionalTypes: true });
819
+ }
820
+ return parseEnv;
821
+ }
822
+ function toSource(input) {
823
+ if (typeof input === "string") return input.trim() || null;
824
+ if (input && typeof input === "object" && typeof input.source === "string") {
825
+ return input.source.trim() || null;
826
+ }
827
+ return null;
828
+ }
829
+ function compileCelToFilter(input, opts = {}) {
830
+ const source = toSource(input);
831
+ if (!source) return { ok: false, reason: "parse-error", detail: "empty expression" };
832
+ let ast;
833
+ try {
834
+ ast = getParseEnv().parse(source).ast;
835
+ } catch (err) {
836
+ return { ok: false, reason: "parse-error", detail: err.message?.split("\n")[0] ?? "parse error" };
837
+ }
838
+ return lowerCelAst(ast, opts, "value");
839
+ }
840
+ function isPushdownableCel(input, opts = {}) {
841
+ const source = toSource(input);
842
+ if (!source) return { ok: false, reason: "parse-error", detail: "empty expression" };
843
+ let ast;
844
+ try {
845
+ ast = getParseEnv().parse(source).ast;
846
+ } catch (err) {
847
+ return { ok: false, reason: "parse-error", detail: err.message?.split("\n")[0] ?? "parse error" };
848
+ }
849
+ const res = lowerCelAst(ast, opts, "shape");
850
+ return res.ok ? { ok: true } : { ok: false, reason: res.reason, detail: res.detail };
851
+ }
852
+ function lowerCelAst(ast, opts = {}, mode = "value") {
853
+ const ctx = {
854
+ fieldRoots: new Set(opts.fieldRoots ?? ["record"]),
855
+ variableRoots: new Set(opts.variableRoots ?? ["current_user"]),
856
+ variables: opts.variables ?? {},
857
+ mode
858
+ };
859
+ try {
860
+ return { ok: true, filter: lowerCondition(ast, ctx) };
861
+ } catch (err) {
862
+ if (err instanceof CompileError) return { ok: false, reason: err.reason, detail: err.message };
863
+ return { ok: false, reason: "unsupported", detail: err.message ?? "compile error" };
864
+ }
865
+ }
866
+ var FLIP = { ">": "<", "<": ">", ">=": "<=", "<=": ">=", "==": "==", "!=": "!=" };
867
+ var CMP_OP = { ">": "$gt", ">=": "$gte", "<": "$lt", "<=": "$lte" };
868
+ var STRING_METHOD = { startsWith: "$startsWith", endsWith: "$endsWith", contains: "$contains" };
869
+ function lowerCondition(node, ctx) {
870
+ const op = node.op;
871
+ const args = node.args;
872
+ switch (op) {
873
+ case "&&":
874
+ return combine("$and", node, ctx);
875
+ case "||":
876
+ return combine("$or", node, ctx);
877
+ case "!_":
878
+ return { $not: lowerCondition(args, ctx) };
879
+ case "==":
880
+ case "!=":
881
+ case ">":
882
+ case ">=":
883
+ case "<":
884
+ case "<=":
885
+ return lowerComparison(op, args[0], args[1], ctx);
886
+ case "in":
887
+ return lowerMembership(args[0], args[1], ctx);
888
+ case "rcall":
889
+ return lowerStringMethod(args, ctx);
890
+ case "value": {
891
+ const v = coerceLiteral(args);
892
+ if (v === true) return {};
893
+ throw new CompileError("unsupported", `constant non-true predicate (${String(v)})`);
894
+ }
895
+ default:
896
+ throw new CompileError("unsupported", `unsupported operator "${String(op)}"`);
897
+ }
898
+ }
899
+ function combine(key, node, ctx) {
900
+ const [l, r] = node.args;
901
+ const parts = [];
902
+ for (const child of [lowerCondition(l, ctx), lowerCondition(r, ctx)]) {
903
+ const nested = child[key];
904
+ if (Array.isArray(nested) && Object.keys(child).length === 1) parts.push(...nested);
905
+ else parts.push(child);
906
+ }
907
+ return { [key]: parts };
908
+ }
909
+ function lowerComparison(op, lNode, rNode, ctx) {
910
+ const L = classify(lNode, ctx);
911
+ const R = classify(rNode, ctx);
912
+ const lField = L.kind === "field";
913
+ const rField = R.kind === "field";
914
+ if (lField && rField) {
915
+ return emit(L.path, op, { $field: R.path }, true);
916
+ }
917
+ if (lField) return emit(L.path, op, resolveValue(R, ctx), false);
918
+ if (rField) return emit(R.path, FLIP[op] ?? op, resolveValue(L, ctx), false);
919
+ const lv = resolveValue(L, ctx);
920
+ const rv = resolveValue(R, ctx);
921
+ if (ctx.mode === "shape") return {};
922
+ const truth = constFold(op, lv, rv);
923
+ if (truth === true) return {};
924
+ throw new CompileError("unsupported", `constant ${op} predicate that is not always-true`);
925
+ }
926
+ function lowerMembership(elemNode, containerNode, ctx) {
927
+ const elem = classify(elemNode, ctx);
928
+ if (elem.kind !== "field") {
929
+ throw new CompileError("unsupported", `\`in\` requires a field on the left (got ${elem.kind})`);
930
+ }
931
+ const container = classify(containerNode, ctx);
932
+ const value = resolveValue(container, ctx);
933
+ if (value !== SHAPE_VALUE && !Array.isArray(value)) {
934
+ throw new CompileError("unsupported", `\`in\` requires an array/list on the right`);
935
+ }
936
+ return { [elem.path]: { $in: value } };
937
+ }
938
+ function lowerStringMethod(args, ctx) {
939
+ const [method, receiver, callArgs] = args;
940
+ const mapped = STRING_METHOD[method];
941
+ if (!mapped) throw new CompileError("unsupported", `unsupported method "${method}()"`);
942
+ const recv = classify(receiver, ctx);
943
+ if (recv.kind !== "field") throw new CompileError("unsupported", `"${method}()" must be called on a field`);
944
+ if (!Array.isArray(callArgs) || callArgs.length !== 1) {
945
+ throw new CompileError("unsupported", `"${method}()" takes exactly one argument`);
946
+ }
947
+ const arg = resolveValue(classify(callArgs[0], ctx), ctx);
948
+ if (arg !== SHAPE_VALUE && typeof arg !== "string") {
949
+ throw new CompileError("unsupported", `"${method}()" argument must be a string literal`);
950
+ }
951
+ return { [recv.path]: { [mapped]: arg } };
952
+ }
953
+ function emit(field, op, value, isRef) {
954
+ if (op === "==") {
955
+ if (!isRef && value === null) return { [field]: { $null: true } };
956
+ if (isRef) return { [field]: { $eq: value } };
957
+ return { [field]: value };
958
+ }
959
+ if (op === "!=") {
960
+ if (!isRef && value === null) return { [field]: { $null: false } };
961
+ return { [field]: { $ne: value } };
962
+ }
963
+ const cmp = CMP_OP[op];
964
+ if (cmp) return { [field]: { [cmp]: value } };
965
+ throw new CompileError("unsupported", `unsupported comparison "${op}"`);
966
+ }
967
+ function classify(node, ctx) {
968
+ switch (node.op) {
969
+ case "value":
970
+ return { kind: "literal", value: coerceLiteral(node.args) };
971
+ case "list": {
972
+ const items = node.args.map((n) => {
973
+ const leaf = classify(n, ctx);
974
+ if (leaf.kind !== "literal") {
975
+ throw new CompileError("unsupported", "list elements must be literals");
976
+ }
977
+ return leaf.value;
978
+ });
979
+ return { kind: "literal", value: items };
980
+ }
981
+ case "id": {
982
+ const name = node.args;
983
+ if (ctx.variableRoots.has(name)) return { kind: "var", path: [name] };
984
+ return { kind: "field", path: name };
985
+ }
986
+ case ".":
987
+ case ".?": {
988
+ const [recv, field] = node.args;
989
+ const chain = memberChain(recv, field);
990
+ if (!chain) throw new CompileError("unsupported", "unsupported member-access expression");
991
+ const [root, ...rest] = chain;
992
+ if (ctx.variableRoots.has(root)) return { kind: "var", path: chain };
993
+ if (ctx.fieldRoots.has(root)) {
994
+ if (rest.length !== 1) {
995
+ throw new CompileError("unsupported", `cross-object/nested field path "${chain.join(".")}" is not pushdown-able`);
996
+ }
997
+ return { kind: "field", path: rest[0] };
998
+ }
999
+ throw new CompileError("unsupported", `cross-object field path "${chain.join(".")}" is not pushdown-able`);
1000
+ }
1001
+ default:
1002
+ throw new CompileError("unsupported", `unsupported operand "${String(node.op)}"`);
1003
+ }
1004
+ }
1005
+ function memberChain(recv, field) {
1006
+ if (recv.op === "id") return [recv.args, field];
1007
+ if (recv.op === "." || recv.op === ".?") {
1008
+ const [innerRecv, innerField] = recv.args;
1009
+ const inner = memberChain(innerRecv, innerField);
1010
+ return inner ? [...inner, field] : null;
1011
+ }
1012
+ return null;
1013
+ }
1014
+ function resolveValue(leaf, ctx) {
1015
+ if (leaf.kind === "literal") return leaf.value;
1016
+ if (leaf.kind === "field") {
1017
+ throw new CompileError("unsupported", `expected a value but got field "${leaf.path}"`);
1018
+ }
1019
+ if (ctx.mode === "shape") return SHAPE_VALUE;
1020
+ let cur = ctx.variables;
1021
+ for (const seg of leaf.path) {
1022
+ if (cur == null || typeof cur !== "object") {
1023
+ throw new CompileError("unresolved-variable", `variable "${leaf.path.join(".")}" is not resolvable`);
1024
+ }
1025
+ cur = cur[seg];
1026
+ }
1027
+ if (cur === void 0 || cur === null) {
1028
+ throw new CompileError("unresolved-variable", `variable "${leaf.path.join(".")}" is ${String(cur)}`);
1029
+ }
1030
+ return cur;
1031
+ }
1032
+ function coerceLiteral(v) {
1033
+ if (typeof v === "bigint") return Number(v);
1034
+ if (v === null || typeof v === "string" || typeof v === "number" || typeof v === "boolean") return v;
1035
+ if (typeof v === "object" && typeof v.valueOf === "function") {
1036
+ const prim = v.valueOf();
1037
+ if (typeof prim === "bigint") return Number(prim);
1038
+ if (typeof prim === "number" || typeof prim === "string" || typeof prim === "boolean") return prim;
1039
+ }
1040
+ throw new CompileError("unsupported", `unsupported literal type "${typeof v}"`);
1041
+ }
1042
+ function constFold(op, l, r) {
1043
+ switch (op) {
1044
+ case "==":
1045
+ return l === r;
1046
+ case "!=":
1047
+ return l !== r;
1048
+ case ">":
1049
+ return l > r;
1050
+ case ">=":
1051
+ return l >= r;
1052
+ case "<":
1053
+ return l < r;
1054
+ case "<=":
1055
+ return l <= r;
1056
+ default:
1057
+ return void 0;
1058
+ }
1059
+ }
1060
+
1061
+ // src/matches-filter.ts
1062
+ function matchesFilterCondition(record, filter) {
1063
+ if (filter == null) return true;
1064
+ if (typeof filter !== "object" || Array.isArray(filter)) return false;
1065
+ return evalNode(record, filter);
1066
+ }
1067
+ function evalNode(record, node) {
1068
+ for (const [key, val] of Object.entries(node)) {
1069
+ if (key === "$and") {
1070
+ if (!Array.isArray(val) || !val.every((c) => evalNode(record, c))) return false;
1071
+ } else if (key === "$or") {
1072
+ if (!Array.isArray(val) || val.length === 0 || !val.some((c) => evalNode(record, c))) return false;
1073
+ } else if (key === "$not") {
1074
+ if (val == null || typeof val !== "object") return false;
1075
+ if (evalNode(record, val)) return false;
1076
+ } else if (key.startsWith("$")) {
1077
+ return false;
1078
+ } else {
1079
+ if (!evalField(record, key, val)) return false;
1080
+ }
1081
+ }
1082
+ return true;
1083
+ }
1084
+ function evalField(record, field, spec) {
1085
+ const actual = getPath(record, field);
1086
+ if (spec === null) return actual == null;
1087
+ if (typeof spec !== "object" || spec instanceof Date) return looseEq(actual, spec);
1088
+ if (Array.isArray(spec)) return false;
1089
+ const ops = spec;
1090
+ const keys = Object.keys(ops);
1091
+ if (keys.length === 0 || keys.some((k) => !k.startsWith("$"))) return false;
1092
+ for (const op of keys) {
1093
+ if (!evalOp(actual, op, ops[op], record)) return false;
1094
+ }
1095
+ return true;
1096
+ }
1097
+ function evalOp(actual, op, raw, record) {
1098
+ const v = resolveValue2(raw, record);
1099
+ switch (op) {
1100
+ case "$eq":
1101
+ return v === null ? actual == null : looseEq(actual, v);
1102
+ case "$ne":
1103
+ return v === null ? actual != null : !looseEq(actual, v);
1104
+ case "$gt":
1105
+ return actual != null && v != null && actual > v;
1106
+ case "$gte":
1107
+ return actual != null && v != null && actual >= v;
1108
+ case "$lt":
1109
+ return actual != null && v != null && actual < v;
1110
+ case "$lte":
1111
+ return actual != null && v != null && actual <= v;
1112
+ case "$in":
1113
+ return Array.isArray(v) && v.some((x) => looseEq(actual, x));
1114
+ case "$nin":
1115
+ return Array.isArray(v) && !v.some((x) => looseEq(actual, x));
1116
+ case "$between":
1117
+ return Array.isArray(v) && v.length === 2 && actual != null && actual >= v[0] && actual <= v[1];
1118
+ case "$contains":
1119
+ return typeof actual === "string" && typeof v === "string" && actual.includes(v);
1120
+ case "$notContains":
1121
+ return !(typeof actual === "string" && typeof v === "string" && actual.includes(v));
1122
+ case "$startsWith":
1123
+ return typeof actual === "string" && typeof v === "string" && actual.startsWith(v);
1124
+ case "$endsWith":
1125
+ return typeof actual === "string" && typeof v === "string" && actual.endsWith(v);
1126
+ case "$null":
1127
+ return v === true ? actual == null : actual != null;
1128
+ case "$exists":
1129
+ return v === true ? actual !== void 0 : actual === void 0;
1130
+ default:
1131
+ return false;
1132
+ }
1133
+ }
1134
+ function resolveValue2(raw, record) {
1135
+ if (raw && typeof raw === "object" && !Array.isArray(raw) && "$field" in raw) {
1136
+ return getPath(record, String(raw.$field));
1137
+ }
1138
+ return raw;
1139
+ }
1140
+ function getPath(record, path) {
1141
+ if (!path.includes(".")) return record[path];
1142
+ let cur = record;
1143
+ for (const seg of path.split(".")) {
1144
+ if (cur == null || typeof cur !== "object") return void 0;
1145
+ cur = cur[seg];
1146
+ }
1147
+ return cur;
1148
+ }
1149
+ function looseEq(a, b) {
1150
+ if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
1151
+ if (a instanceof Date && (typeof b === "string" || typeof b === "number")) return a.getTime() === new Date(b).getTime();
1152
+ if (b instanceof Date && (typeof a === "string" || typeof a === "number")) return new Date(a).getTime() === b.getTime();
1153
+ return a === b;
1154
+ }
1155
+
794
1156
  // src/validate.ts
795
1157
  var SINGLE_BRACE_RE = /(?:^|[^{])\{\s*([A-Za-z_$][\w.$]*)\s*\}(?!\})/;
796
1158
  var RECORD_REF_RE = /\b(?:record|previous)\.([A-Za-z_$][\w$]*)/g;
797
1159
  function expectedDialect(role) {
798
1160
  return role === "template" ? "template" : "cel";
799
1161
  }
800
- function toSource(input) {
1162
+ function toSource2(input) {
801
1163
  if (input == null) return { source: "" };
802
1164
  if (typeof input === "string") return { source: input };
803
1165
  return { dialect: input.dialect, source: input.source ?? "" };
@@ -852,7 +1214,7 @@ function levenshtein(a, b) {
852
1214
  return dp[m];
853
1215
  }
854
1216
  function validateExpression(role, input, schema) {
855
- const { dialect, source } = toSource(input);
1217
+ const { dialect, source } = toSource2(input);
856
1218
  const errors = [];
857
1219
  const warnings = [];
858
1220
  if (!source.trim()) return { ok: true, errors, warnings };
@@ -966,12 +1328,16 @@ var CEL_STDLIB_FUNCTIONS = [
966
1328
  TEMPLATE_FORMATTERS,
967
1329
  buildScope,
968
1330
  celEngine,
1331
+ compileCelToFilter,
969
1332
  cronEngine,
970
1333
  expectedDialect,
971
1334
  formatValue,
972
1335
  getEngine,
973
1336
  hasDialect,
974
1337
  introspectScope,
1338
+ isPushdownableCel,
1339
+ lowerCelAst,
1340
+ matchesFilterCondition,
975
1341
  normalizeExpression,
976
1342
  normalizeExpressionTree,
977
1343
  register,