@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.mjs CHANGED
@@ -174,7 +174,14 @@ var SCOPE_ROOTS = [
174
174
  "data",
175
175
  "params",
176
176
  "config",
177
- "settings"
177
+ "settings",
178
+ // UI action / predicate context (ActionEngine, renderers): the current
179
+ // record plus ambient globals exposed to `visible`/`disabled` predicates.
180
+ "ctx",
181
+ "features",
182
+ // Master-detail inline grids inject the header record as `parent` for a
183
+ // child field's `readonlyWhen`/`requiredWhen` predicate (ADR-0036, #1581).
184
+ "parent"
178
185
  ];
179
186
  function buildScopedEnv(knownFields) {
180
187
  const env = new Environment({
@@ -749,13 +756,364 @@ function looksLikeExpression(value) {
749
756
  return ExpressionSchema2.safeParse(v).success;
750
757
  }
751
758
 
759
+ // src/cel-to-filter.ts
760
+ import { Environment as Environment2 } from "@marcbachmann/cel-js";
761
+ var SHAPE_VALUE = /* @__PURE__ */ Symbol("cel-filter-shape-placeholder");
762
+ var CompileError = class extends Error {
763
+ constructor(reason, message) {
764
+ super(message);
765
+ this.reason = reason;
766
+ this.name = "CelFilterCompileError";
767
+ }
768
+ };
769
+ var parseEnv;
770
+ function getParseEnv() {
771
+ if (!parseEnv) {
772
+ parseEnv = new Environment2({ unlistedVariablesAreDyn: true, enableOptionalTypes: true });
773
+ }
774
+ return parseEnv;
775
+ }
776
+ function toSource(input) {
777
+ if (typeof input === "string") return input.trim() || null;
778
+ if (input && typeof input === "object" && typeof input.source === "string") {
779
+ return input.source.trim() || null;
780
+ }
781
+ return null;
782
+ }
783
+ function compileCelToFilter(input, opts = {}) {
784
+ const source = toSource(input);
785
+ if (!source) return { ok: false, reason: "parse-error", detail: "empty expression" };
786
+ let ast;
787
+ try {
788
+ ast = getParseEnv().parse(source).ast;
789
+ } catch (err) {
790
+ return { ok: false, reason: "parse-error", detail: err.message?.split("\n")[0] ?? "parse error" };
791
+ }
792
+ return lowerCelAst(ast, opts, "value");
793
+ }
794
+ function isPushdownableCel(input, opts = {}) {
795
+ const source = toSource(input);
796
+ if (!source) return { ok: false, reason: "parse-error", detail: "empty expression" };
797
+ let ast;
798
+ try {
799
+ ast = getParseEnv().parse(source).ast;
800
+ } catch (err) {
801
+ return { ok: false, reason: "parse-error", detail: err.message?.split("\n")[0] ?? "parse error" };
802
+ }
803
+ const res = lowerCelAst(ast, opts, "shape");
804
+ return res.ok ? { ok: true } : { ok: false, reason: res.reason, detail: res.detail };
805
+ }
806
+ function lowerCelAst(ast, opts = {}, mode = "value") {
807
+ const ctx = {
808
+ fieldRoots: new Set(opts.fieldRoots ?? ["record"]),
809
+ variableRoots: new Set(opts.variableRoots ?? ["current_user"]),
810
+ variables: opts.variables ?? {},
811
+ mode
812
+ };
813
+ try {
814
+ return { ok: true, filter: lowerCondition(ast, ctx) };
815
+ } catch (err) {
816
+ if (err instanceof CompileError) return { ok: false, reason: err.reason, detail: err.message };
817
+ return { ok: false, reason: "unsupported", detail: err.message ?? "compile error" };
818
+ }
819
+ }
820
+ var FLIP = { ">": "<", "<": ">", ">=": "<=", "<=": ">=", "==": "==", "!=": "!=" };
821
+ var CMP_OP = { ">": "$gt", ">=": "$gte", "<": "$lt", "<=": "$lte" };
822
+ var STRING_METHOD = { startsWith: "$startsWith", endsWith: "$endsWith", contains: "$contains" };
823
+ function lowerCondition(node, ctx) {
824
+ const op = node.op;
825
+ const args = node.args;
826
+ switch (op) {
827
+ case "&&":
828
+ return combine("$and", node, ctx);
829
+ case "||":
830
+ return combine("$or", node, ctx);
831
+ case "!_":
832
+ return { $not: lowerCondition(args, ctx) };
833
+ case "==":
834
+ case "!=":
835
+ case ">":
836
+ case ">=":
837
+ case "<":
838
+ case "<=":
839
+ return lowerComparison(op, args[0], args[1], ctx);
840
+ case "in":
841
+ return lowerMembership(args[0], args[1], ctx);
842
+ case "rcall":
843
+ return lowerStringMethod(args, ctx);
844
+ case "value": {
845
+ const v = coerceLiteral(args);
846
+ if (v === true) return {};
847
+ throw new CompileError("unsupported", `constant non-true predicate (${String(v)})`);
848
+ }
849
+ default:
850
+ throw new CompileError("unsupported", `unsupported operator "${String(op)}"`);
851
+ }
852
+ }
853
+ function combine(key, node, ctx) {
854
+ const [l, r] = node.args;
855
+ const parts = [];
856
+ for (const child of [lowerCondition(l, ctx), lowerCondition(r, ctx)]) {
857
+ const nested = child[key];
858
+ if (Array.isArray(nested) && Object.keys(child).length === 1) parts.push(...nested);
859
+ else parts.push(child);
860
+ }
861
+ return { [key]: parts };
862
+ }
863
+ function lowerComparison(op, lNode, rNode, ctx) {
864
+ const L = classify(lNode, ctx);
865
+ const R = classify(rNode, ctx);
866
+ const lField = L.kind === "field";
867
+ const rField = R.kind === "field";
868
+ if (lField && rField) {
869
+ return emit(L.path, op, { $field: R.path }, true);
870
+ }
871
+ if (lField) return emit(L.path, op, resolveValue(R, ctx), false);
872
+ if (rField) return emit(R.path, FLIP[op] ?? op, resolveValue(L, ctx), false);
873
+ const lv = resolveValue(L, ctx);
874
+ const rv = resolveValue(R, ctx);
875
+ if (ctx.mode === "shape") return {};
876
+ const truth = constFold(op, lv, rv);
877
+ if (truth === true) return {};
878
+ throw new CompileError("unsupported", `constant ${op} predicate that is not always-true`);
879
+ }
880
+ function lowerMembership(elemNode, containerNode, ctx) {
881
+ const elem = classify(elemNode, ctx);
882
+ if (elem.kind !== "field") {
883
+ throw new CompileError("unsupported", `\`in\` requires a field on the left (got ${elem.kind})`);
884
+ }
885
+ const container = classify(containerNode, ctx);
886
+ const value = resolveValue(container, ctx);
887
+ if (value !== SHAPE_VALUE && !Array.isArray(value)) {
888
+ throw new CompileError("unsupported", `\`in\` requires an array/list on the right`);
889
+ }
890
+ return { [elem.path]: { $in: value } };
891
+ }
892
+ function lowerStringMethod(args, ctx) {
893
+ const [method, receiver, callArgs] = args;
894
+ const mapped = STRING_METHOD[method];
895
+ if (!mapped) throw new CompileError("unsupported", `unsupported method "${method}()"`);
896
+ const recv = classify(receiver, ctx);
897
+ if (recv.kind !== "field") throw new CompileError("unsupported", `"${method}()" must be called on a field`);
898
+ if (!Array.isArray(callArgs) || callArgs.length !== 1) {
899
+ throw new CompileError("unsupported", `"${method}()" takes exactly one argument`);
900
+ }
901
+ const arg = resolveValue(classify(callArgs[0], ctx), ctx);
902
+ if (arg !== SHAPE_VALUE && typeof arg !== "string") {
903
+ throw new CompileError("unsupported", `"${method}()" argument must be a string literal`);
904
+ }
905
+ return { [recv.path]: { [mapped]: arg } };
906
+ }
907
+ function emit(field, op, value, isRef) {
908
+ if (op === "==") {
909
+ if (!isRef && value === null) return { [field]: { $null: true } };
910
+ if (isRef) return { [field]: { $eq: value } };
911
+ return { [field]: value };
912
+ }
913
+ if (op === "!=") {
914
+ if (!isRef && value === null) return { [field]: { $null: false } };
915
+ return { [field]: { $ne: value } };
916
+ }
917
+ const cmp = CMP_OP[op];
918
+ if (cmp) return { [field]: { [cmp]: value } };
919
+ throw new CompileError("unsupported", `unsupported comparison "${op}"`);
920
+ }
921
+ function classify(node, ctx) {
922
+ switch (node.op) {
923
+ case "value":
924
+ return { kind: "literal", value: coerceLiteral(node.args) };
925
+ case "list": {
926
+ const items = node.args.map((n) => {
927
+ const leaf = classify(n, ctx);
928
+ if (leaf.kind !== "literal") {
929
+ throw new CompileError("unsupported", "list elements must be literals");
930
+ }
931
+ return leaf.value;
932
+ });
933
+ return { kind: "literal", value: items };
934
+ }
935
+ case "id": {
936
+ const name = node.args;
937
+ if (ctx.variableRoots.has(name)) return { kind: "var", path: [name] };
938
+ return { kind: "field", path: name };
939
+ }
940
+ case ".":
941
+ case ".?": {
942
+ const [recv, field] = node.args;
943
+ const chain = memberChain(recv, field);
944
+ if (!chain) throw new CompileError("unsupported", "unsupported member-access expression");
945
+ const [root, ...rest] = chain;
946
+ if (ctx.variableRoots.has(root)) return { kind: "var", path: chain };
947
+ if (ctx.fieldRoots.has(root)) {
948
+ if (rest.length !== 1) {
949
+ throw new CompileError("unsupported", `cross-object/nested field path "${chain.join(".")}" is not pushdown-able`);
950
+ }
951
+ return { kind: "field", path: rest[0] };
952
+ }
953
+ throw new CompileError("unsupported", `cross-object field path "${chain.join(".")}" is not pushdown-able`);
954
+ }
955
+ default:
956
+ throw new CompileError("unsupported", `unsupported operand "${String(node.op)}"`);
957
+ }
958
+ }
959
+ function memberChain(recv, field) {
960
+ if (recv.op === "id") return [recv.args, field];
961
+ if (recv.op === "." || recv.op === ".?") {
962
+ const [innerRecv, innerField] = recv.args;
963
+ const inner = memberChain(innerRecv, innerField);
964
+ return inner ? [...inner, field] : null;
965
+ }
966
+ return null;
967
+ }
968
+ function resolveValue(leaf, ctx) {
969
+ if (leaf.kind === "literal") return leaf.value;
970
+ if (leaf.kind === "field") {
971
+ throw new CompileError("unsupported", `expected a value but got field "${leaf.path}"`);
972
+ }
973
+ if (ctx.mode === "shape") return SHAPE_VALUE;
974
+ let cur = ctx.variables;
975
+ for (const seg of leaf.path) {
976
+ if (cur == null || typeof cur !== "object") {
977
+ throw new CompileError("unresolved-variable", `variable "${leaf.path.join(".")}" is not resolvable`);
978
+ }
979
+ cur = cur[seg];
980
+ }
981
+ if (cur === void 0 || cur === null) {
982
+ throw new CompileError("unresolved-variable", `variable "${leaf.path.join(".")}" is ${String(cur)}`);
983
+ }
984
+ return cur;
985
+ }
986
+ function coerceLiteral(v) {
987
+ if (typeof v === "bigint") return Number(v);
988
+ if (v === null || typeof v === "string" || typeof v === "number" || typeof v === "boolean") return v;
989
+ if (typeof v === "object" && typeof v.valueOf === "function") {
990
+ const prim = v.valueOf();
991
+ if (typeof prim === "bigint") return Number(prim);
992
+ if (typeof prim === "number" || typeof prim === "string" || typeof prim === "boolean") return prim;
993
+ }
994
+ throw new CompileError("unsupported", `unsupported literal type "${typeof v}"`);
995
+ }
996
+ function constFold(op, l, r) {
997
+ switch (op) {
998
+ case "==":
999
+ return l === r;
1000
+ case "!=":
1001
+ return l !== r;
1002
+ case ">":
1003
+ return l > r;
1004
+ case ">=":
1005
+ return l >= r;
1006
+ case "<":
1007
+ return l < r;
1008
+ case "<=":
1009
+ return l <= r;
1010
+ default:
1011
+ return void 0;
1012
+ }
1013
+ }
1014
+
1015
+ // src/matches-filter.ts
1016
+ function matchesFilterCondition(record, filter) {
1017
+ if (filter == null) return true;
1018
+ if (typeof filter !== "object" || Array.isArray(filter)) return false;
1019
+ return evalNode(record, filter);
1020
+ }
1021
+ function evalNode(record, node) {
1022
+ for (const [key, val] of Object.entries(node)) {
1023
+ if (key === "$and") {
1024
+ if (!Array.isArray(val) || !val.every((c) => evalNode(record, c))) return false;
1025
+ } else if (key === "$or") {
1026
+ if (!Array.isArray(val) || val.length === 0 || !val.some((c) => evalNode(record, c))) return false;
1027
+ } else if (key === "$not") {
1028
+ if (val == null || typeof val !== "object") return false;
1029
+ if (evalNode(record, val)) return false;
1030
+ } else if (key.startsWith("$")) {
1031
+ return false;
1032
+ } else {
1033
+ if (!evalField(record, key, val)) return false;
1034
+ }
1035
+ }
1036
+ return true;
1037
+ }
1038
+ function evalField(record, field, spec) {
1039
+ const actual = getPath(record, field);
1040
+ if (spec === null) return actual == null;
1041
+ if (typeof spec !== "object" || spec instanceof Date) return looseEq(actual, spec);
1042
+ if (Array.isArray(spec)) return false;
1043
+ const ops = spec;
1044
+ const keys = Object.keys(ops);
1045
+ if (keys.length === 0 || keys.some((k) => !k.startsWith("$"))) return false;
1046
+ for (const op of keys) {
1047
+ if (!evalOp(actual, op, ops[op], record)) return false;
1048
+ }
1049
+ return true;
1050
+ }
1051
+ function evalOp(actual, op, raw, record) {
1052
+ const v = resolveValue2(raw, record);
1053
+ switch (op) {
1054
+ case "$eq":
1055
+ return v === null ? actual == null : looseEq(actual, v);
1056
+ case "$ne":
1057
+ return v === null ? actual != null : !looseEq(actual, v);
1058
+ case "$gt":
1059
+ return actual != null && v != null && actual > v;
1060
+ case "$gte":
1061
+ return actual != null && v != null && actual >= v;
1062
+ case "$lt":
1063
+ return actual != null && v != null && actual < v;
1064
+ case "$lte":
1065
+ return actual != null && v != null && actual <= v;
1066
+ case "$in":
1067
+ return Array.isArray(v) && v.some((x) => looseEq(actual, x));
1068
+ case "$nin":
1069
+ return Array.isArray(v) && !v.some((x) => looseEq(actual, x));
1070
+ case "$between":
1071
+ return Array.isArray(v) && v.length === 2 && actual != null && actual >= v[0] && actual <= v[1];
1072
+ case "$contains":
1073
+ return typeof actual === "string" && typeof v === "string" && actual.includes(v);
1074
+ case "$notContains":
1075
+ return !(typeof actual === "string" && typeof v === "string" && actual.includes(v));
1076
+ case "$startsWith":
1077
+ return typeof actual === "string" && typeof v === "string" && actual.startsWith(v);
1078
+ case "$endsWith":
1079
+ return typeof actual === "string" && typeof v === "string" && actual.endsWith(v);
1080
+ case "$null":
1081
+ return v === true ? actual == null : actual != null;
1082
+ case "$exists":
1083
+ return v === true ? actual !== void 0 : actual === void 0;
1084
+ default:
1085
+ return false;
1086
+ }
1087
+ }
1088
+ function resolveValue2(raw, record) {
1089
+ if (raw && typeof raw === "object" && !Array.isArray(raw) && "$field" in raw) {
1090
+ return getPath(record, String(raw.$field));
1091
+ }
1092
+ return raw;
1093
+ }
1094
+ function getPath(record, path) {
1095
+ if (!path.includes(".")) return record[path];
1096
+ let cur = record;
1097
+ for (const seg of path.split(".")) {
1098
+ if (cur == null || typeof cur !== "object") return void 0;
1099
+ cur = cur[seg];
1100
+ }
1101
+ return cur;
1102
+ }
1103
+ function looseEq(a, b) {
1104
+ if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
1105
+ if (a instanceof Date && (typeof b === "string" || typeof b === "number")) return a.getTime() === new Date(b).getTime();
1106
+ if (b instanceof Date && (typeof a === "string" || typeof a === "number")) return new Date(a).getTime() === b.getTime();
1107
+ return a === b;
1108
+ }
1109
+
752
1110
  // src/validate.ts
753
1111
  var SINGLE_BRACE_RE = /(?:^|[^{])\{\s*([A-Za-z_$][\w.$]*)\s*\}(?!\})/;
754
1112
  var RECORD_REF_RE = /\b(?:record|previous)\.([A-Za-z_$][\w$]*)/g;
755
1113
  function expectedDialect(role) {
756
1114
  return role === "template" ? "template" : "cel";
757
1115
  }
758
- function toSource(input) {
1116
+ function toSource2(input) {
759
1117
  if (input == null) return { source: "" };
760
1118
  if (typeof input === "string") return { source: input };
761
1119
  return { dialect: input.dialect, source: input.source ?? "" };
@@ -810,7 +1168,7 @@ function levenshtein(a, b) {
810
1168
  return dp[m];
811
1169
  }
812
1170
  function validateExpression(role, input, schema) {
813
- const { dialect, source } = toSource(input);
1171
+ const { dialect, source } = toSource2(input);
814
1172
  const errors = [];
815
1173
  const warnings = [];
816
1174
  if (!source.trim()) return { ok: true, errors, warnings };
@@ -923,12 +1281,16 @@ export {
923
1281
  TEMPLATE_FORMATTERS,
924
1282
  buildScope,
925
1283
  celEngine,
1284
+ compileCelToFilter,
926
1285
  cronEngine,
927
1286
  expectedDialect,
928
1287
  formatValue,
929
1288
  getEngine,
930
1289
  hasDialect,
931
1290
  introspectScope,
1291
+ isPushdownableCel,
1292
+ lowerCelAst,
1293
+ matchesFilterCondition,
932
1294
  normalizeExpression,
933
1295
  normalizeExpressionTree,
934
1296
  register,