@objectstack/formula 7.5.0 → 7.7.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/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +57 -0
- package/dist/index.d.mts +84 -12
- package/dist/index.d.ts +84 -12
- package/dist/index.js +331 -31
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +325 -30
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/cel-engine.test.ts +65 -0
- package/src/cel-engine.ts +73 -2
- package/src/index.ts +5 -1
- package/src/template-engine.ts +194 -42
- package/src/template-formatters.test.ts +55 -0
- package/src/validate.test.ts +73 -0
- package/src/validate.ts +207 -0
package/dist/index.mjs
CHANGED
|
@@ -99,6 +99,28 @@ function coerce(value) {
|
|
|
99
99
|
}
|
|
100
100
|
return value;
|
|
101
101
|
}
|
|
102
|
+
var NUMERIC_STRING_RE = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/;
|
|
103
|
+
function isNumericOverloadError(err) {
|
|
104
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
105
|
+
return /no such overload/i.test(message);
|
|
106
|
+
}
|
|
107
|
+
function hydrateNumericStrings(value) {
|
|
108
|
+
if (typeof value === "string") {
|
|
109
|
+
const trimmed = value.trim();
|
|
110
|
+
if (trimmed.length > 0 && NUMERIC_STRING_RE.test(trimmed)) {
|
|
111
|
+
const n = Number(trimmed);
|
|
112
|
+
if (Number.isFinite(n)) return n;
|
|
113
|
+
}
|
|
114
|
+
return value;
|
|
115
|
+
}
|
|
116
|
+
if (Array.isArray(value)) return value.map(hydrateNumericStrings);
|
|
117
|
+
if (value && typeof value === "object" && !(value instanceof Date)) {
|
|
118
|
+
const out = {};
|
|
119
|
+
for (const [k, v] of Object.entries(value)) out[k] = hydrateNumericStrings(v);
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
return value;
|
|
123
|
+
}
|
|
102
124
|
function classifyError(err) {
|
|
103
125
|
const message = err instanceof Error ? err.message : String(err);
|
|
104
126
|
let kind = "runtime";
|
|
@@ -143,8 +165,19 @@ var celEngine = {
|
|
|
143
165
|
try {
|
|
144
166
|
const env = buildEnv(now);
|
|
145
167
|
const scope = buildScope(ctx);
|
|
146
|
-
|
|
147
|
-
|
|
168
|
+
try {
|
|
169
|
+
const raw = env.evaluate(source, scope);
|
|
170
|
+
return { ok: true, value: coerce(raw) };
|
|
171
|
+
} catch (err) {
|
|
172
|
+
if (!isNumericOverloadError(err)) throw err;
|
|
173
|
+
const hydrated = hydrateNumericStrings(scope);
|
|
174
|
+
try {
|
|
175
|
+
const raw = env.evaluate(source, hydrated);
|
|
176
|
+
return { ok: true, value: coerce(raw) };
|
|
177
|
+
} catch {
|
|
178
|
+
throw err;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
148
181
|
} catch (err) {
|
|
149
182
|
return classifyError(err);
|
|
150
183
|
}
|
|
@@ -221,18 +254,96 @@ var cronEngine = {
|
|
|
221
254
|
};
|
|
222
255
|
|
|
223
256
|
// src/template-engine.ts
|
|
224
|
-
var
|
|
225
|
-
function
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
257
|
+
var HOLE_RE = /\{\{([^}]*)\}\}/g;
|
|
258
|
+
function asNumber(v) {
|
|
259
|
+
if (typeof v === "number") return v;
|
|
260
|
+
if (typeof v === "bigint") return Number(v);
|
|
261
|
+
if (typeof v === "string" && v.trim() !== "" && !Number.isNaN(Number(v))) return Number(v);
|
|
262
|
+
return void 0;
|
|
263
|
+
}
|
|
264
|
+
function asDate(v) {
|
|
265
|
+
if (v instanceof Date) return v;
|
|
266
|
+
if (typeof v === "number") return new Date(v);
|
|
267
|
+
if (typeof v === "string") {
|
|
268
|
+
const d = new Date(v);
|
|
269
|
+
if (!Number.isNaN(d.getTime())) return d;
|
|
232
270
|
}
|
|
233
|
-
return
|
|
271
|
+
return void 0;
|
|
234
272
|
}
|
|
235
|
-
|
|
273
|
+
var FORMATTERS = {
|
|
274
|
+
upper: (v) => baseString(v).toUpperCase(),
|
|
275
|
+
lower: (v) => baseString(v).toLowerCase(),
|
|
276
|
+
trim: (v) => baseString(v).trim(),
|
|
277
|
+
// number | number:2 → grouped, optional fixed decimals
|
|
278
|
+
number: (v, arg, locale) => {
|
|
279
|
+
const n = asNumber(v);
|
|
280
|
+
if (n === void 0) return baseString(v);
|
|
281
|
+
const digits = arg !== void 0 ? Number(arg) : void 0;
|
|
282
|
+
return new Intl.NumberFormat(locale, digits !== void 0 && !Number.isNaN(digits) ? { minimumFractionDigits: digits, maximumFractionDigits: digits } : {}).format(n);
|
|
283
|
+
},
|
|
284
|
+
// currency | currency:EUR → defaults to USD
|
|
285
|
+
currency: (v, arg, locale) => {
|
|
286
|
+
const n = asNumber(v);
|
|
287
|
+
if (n === void 0) return baseString(v);
|
|
288
|
+
const code = arg && arg.trim() || "USD";
|
|
289
|
+
try {
|
|
290
|
+
return new Intl.NumberFormat(locale, { style: "currency", currency: code }).format(n);
|
|
291
|
+
} catch {
|
|
292
|
+
return new Intl.NumberFormat(locale, { style: "currency", currency: "USD" }).format(n);
|
|
293
|
+
}
|
|
294
|
+
},
|
|
295
|
+
// percent | percent:1 → 0.42 → "42%" (value is a 0..1 ratio)
|
|
296
|
+
percent: (v, arg, locale) => {
|
|
297
|
+
const n = asNumber(v);
|
|
298
|
+
if (n === void 0) return baseString(v);
|
|
299
|
+
const digits = arg !== void 0 ? Number(arg) : 0;
|
|
300
|
+
return new Intl.NumberFormat(locale, {
|
|
301
|
+
style: "percent",
|
|
302
|
+
minimumFractionDigits: Number.isNaN(digits) ? 0 : digits,
|
|
303
|
+
maximumFractionDigits: Number.isNaN(digits) ? 0 : digits
|
|
304
|
+
}).format(n);
|
|
305
|
+
},
|
|
306
|
+
// date | date:long | date:iso → date-only
|
|
307
|
+
date: (v, arg, locale) => {
|
|
308
|
+
const d = asDate(v);
|
|
309
|
+
if (!d) return baseString(v);
|
|
310
|
+
if (arg === "iso") return d.toISOString().slice(0, 10);
|
|
311
|
+
const style = arg === "long" ? "long" : arg === "medium" ? "medium" : "short";
|
|
312
|
+
return new Intl.DateTimeFormat(locale, { dateStyle: style }).format(d);
|
|
313
|
+
},
|
|
314
|
+
// datetime | datetime:long | datetime:iso
|
|
315
|
+
datetime: (v, arg, locale) => {
|
|
316
|
+
const d = asDate(v);
|
|
317
|
+
if (!d) return baseString(v);
|
|
318
|
+
if (arg === "iso") return d.toISOString();
|
|
319
|
+
const style = arg === "long" ? "long" : arg === "medium" ? "medium" : "short";
|
|
320
|
+
return new Intl.DateTimeFormat(locale, {
|
|
321
|
+
dateStyle: style,
|
|
322
|
+
timeStyle: style
|
|
323
|
+
}).format(d);
|
|
324
|
+
},
|
|
325
|
+
// truncate:80 → cut with an ellipsis
|
|
326
|
+
truncate: (v, arg) => {
|
|
327
|
+
const s = baseString(v);
|
|
328
|
+
const len = arg !== void 0 ? Number(arg) : 80;
|
|
329
|
+
if (Number.isNaN(len) || s.length <= len) return s;
|
|
330
|
+
return s.slice(0, Math.max(0, len - 1)) + "\u2026";
|
|
331
|
+
},
|
|
332
|
+
// default:'N/A' → fallback when the value is null/undefined/empty
|
|
333
|
+
default: (v, arg) => {
|
|
334
|
+
const s = baseString(v);
|
|
335
|
+
return s === "" ? arg ?? "" : s;
|
|
336
|
+
},
|
|
337
|
+
json: (v) => {
|
|
338
|
+
try {
|
|
339
|
+
return JSON.stringify(v);
|
|
340
|
+
} catch {
|
|
341
|
+
return String(v);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
var TEMPLATE_FORMATTERS = Object.keys(FORMATTERS);
|
|
346
|
+
function baseString(value) {
|
|
236
347
|
if (value === null || value === void 0) return "";
|
|
237
348
|
if (value instanceof Date) return value.toISOString();
|
|
238
349
|
if (typeof value === "string") return value;
|
|
@@ -244,21 +355,59 @@ function stringify(value) {
|
|
|
244
355
|
return String(value);
|
|
245
356
|
}
|
|
246
357
|
}
|
|
358
|
+
function resolvePath(scope, path) {
|
|
359
|
+
const normalized = path.replace(/\[(\w+)\]/g, ".$1");
|
|
360
|
+
const segments = normalized.split(".").filter(Boolean);
|
|
361
|
+
let cursor = scope;
|
|
362
|
+
for (const seg of segments) {
|
|
363
|
+
if (cursor == null || typeof cursor !== "object") return void 0;
|
|
364
|
+
cursor = cursor[seg];
|
|
365
|
+
}
|
|
366
|
+
return cursor;
|
|
367
|
+
}
|
|
368
|
+
var PATH_ONLY_RE = /^[\w.[\]]+$/;
|
|
369
|
+
function parseHole(inner) {
|
|
370
|
+
const pipe = inner.indexOf("|");
|
|
371
|
+
if (pipe === -1) {
|
|
372
|
+
const path2 = inner.trim();
|
|
373
|
+
return PATH_ONLY_RE.test(path2) ? { path: path2 } : null;
|
|
374
|
+
}
|
|
375
|
+
const path = inner.slice(0, pipe).trim();
|
|
376
|
+
if (!PATH_ONLY_RE.test(path)) return null;
|
|
377
|
+
const filterPart = inner.slice(pipe + 1).trim();
|
|
378
|
+
const colon = filterPart.indexOf(":");
|
|
379
|
+
let name = filterPart;
|
|
380
|
+
let arg;
|
|
381
|
+
if (colon !== -1) {
|
|
382
|
+
name = filterPart.slice(0, colon).trim();
|
|
383
|
+
arg = filterPart.slice(colon + 1).trim().replace(/^['"]|['"]$/g, "");
|
|
384
|
+
}
|
|
385
|
+
if (!FORMATTERS[name]) return null;
|
|
386
|
+
return { path, filter: { name, arg } };
|
|
387
|
+
}
|
|
247
388
|
function compileTemplate(source) {
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
error: { kind: "parse", message: "template has unbalanced {{ }} delimiters" }
|
|
253
|
-
};
|
|
389
|
+
const open = (source.match(/\{\{/g) ?? []).length;
|
|
390
|
+
const close = (source.match(/\}\}/g) ?? []).length;
|
|
391
|
+
if (open !== close) {
|
|
392
|
+
return { ok: false, error: { kind: "parse", message: "template has unbalanced {{ }} delimiters" } };
|
|
254
393
|
}
|
|
255
|
-
const
|
|
394
|
+
const holes = [];
|
|
256
395
|
let m;
|
|
257
|
-
|
|
258
|
-
while ((m =
|
|
259
|
-
|
|
396
|
+
HOLE_RE.lastIndex = 0;
|
|
397
|
+
while ((m = HOLE_RE.exec(source)) !== null) {
|
|
398
|
+
const parsed = parseHole(m[1]);
|
|
399
|
+
if (!parsed) {
|
|
400
|
+
return {
|
|
401
|
+
ok: false,
|
|
402
|
+
error: {
|
|
403
|
+
kind: "parse",
|
|
404
|
+
message: `invalid template hole \`{{ ${m[1]} }}\` \u2014 holes are a field path with an optional formatter (\`{{ record.amount | currency }}\`), not arbitrary logic. Move logic into a CEL field. Known formatters: ${TEMPLATE_FORMATTERS.join(", ")}.`
|
|
405
|
+
}
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
holes.push(parsed);
|
|
260
409
|
}
|
|
261
|
-
return { ok: true, value:
|
|
410
|
+
return { ok: true, value: holes };
|
|
262
411
|
}
|
|
263
412
|
var templateEngine = {
|
|
264
413
|
dialect: "template",
|
|
@@ -273,16 +422,20 @@ var templateEngine = {
|
|
|
273
422
|
};
|
|
274
423
|
}
|
|
275
424
|
if (typeof expr.source !== "string") {
|
|
276
|
-
return {
|
|
277
|
-
ok: false,
|
|
278
|
-
error: { kind: "parse", message: "template Expression.source required" }
|
|
279
|
-
};
|
|
425
|
+
return { ok: false, error: { kind: "parse", message: "template Expression.source required" } };
|
|
280
426
|
}
|
|
281
427
|
const check = compileTemplate(expr.source);
|
|
282
428
|
if (!check.ok) return check;
|
|
283
429
|
const scope = buildScope(ctx);
|
|
284
|
-
const
|
|
285
|
-
|
|
430
|
+
const locale = ctx.extra && typeof ctx.extra.locale === "string" && ctx.extra.locale || typeof ctx.locale === "string" && ctx.locale || "en-US";
|
|
431
|
+
const out = expr.source.replace(HOLE_RE, (_match, inner) => {
|
|
432
|
+
const parsed = parseHole(String(inner));
|
|
433
|
+
if (!parsed) return _match;
|
|
434
|
+
const value = resolvePath(scope, parsed.path);
|
|
435
|
+
if (parsed.filter) {
|
|
436
|
+
return FORMATTERS[parsed.filter.name](value, parsed.filter.arg, locale);
|
|
437
|
+
}
|
|
438
|
+
return baseString(value);
|
|
286
439
|
});
|
|
287
440
|
return { ok: true, value: out };
|
|
288
441
|
}
|
|
@@ -451,20 +604,162 @@ function looksLikeExpression(value) {
|
|
|
451
604
|
if (typeof v.dialect !== "string") return false;
|
|
452
605
|
return ExpressionSchema2.safeParse(v).success;
|
|
453
606
|
}
|
|
607
|
+
|
|
608
|
+
// src/validate.ts
|
|
609
|
+
var SINGLE_BRACE_RE = /(?:^|[^{])\{\s*([A-Za-z_$][\w.$]*)\s*\}(?!\})/;
|
|
610
|
+
var RECORD_REF_RE = /\b(?:record|previous)\.([A-Za-z_$][\w$]*)/g;
|
|
611
|
+
function expectedDialect(role) {
|
|
612
|
+
return role === "template" ? "template" : "cel";
|
|
613
|
+
}
|
|
614
|
+
function toSource(input) {
|
|
615
|
+
if (input == null) return { source: "" };
|
|
616
|
+
if (typeof input === "string") return { source: input };
|
|
617
|
+
return { dialect: input.dialect, source: input.source ?? "" };
|
|
618
|
+
}
|
|
619
|
+
function bracesHint(source) {
|
|
620
|
+
const m = SINGLE_BRACE_RE.exec(source);
|
|
621
|
+
if (!m) return null;
|
|
622
|
+
const ref = m[1];
|
|
623
|
+
return `it looks like a \`{${ref}}\` template brace was used inside a CEL expression \u2014 \`{\u2026}\` parses as a CEL map literal and fails. Write the bare reference instead, e.g. \`${ref}\`.`;
|
|
624
|
+
}
|
|
625
|
+
function checkFieldExistence(source, schema, errors) {
|
|
626
|
+
if (!schema?.fields || schema.fields.length === 0) return;
|
|
627
|
+
const known = new Set(schema.fields);
|
|
628
|
+
const seen = /* @__PURE__ */ new Set();
|
|
629
|
+
let m;
|
|
630
|
+
RECORD_REF_RE.lastIndex = 0;
|
|
631
|
+
while ((m = RECORD_REF_RE.exec(source)) !== null) {
|
|
632
|
+
const field = m[1];
|
|
633
|
+
if (seen.has(field) || known.has(field)) continue;
|
|
634
|
+
seen.add(field);
|
|
635
|
+
const suggestion = nearest(field, schema.fields);
|
|
636
|
+
errors.push({
|
|
637
|
+
source,
|
|
638
|
+
message: `unknown field \`${field}\`${schema.objectName ? ` on \`${schema.objectName}\`` : ""}` + (suggestion ? ` \u2014 did you mean \`${suggestion}\`?` : "")
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
function nearest(name, candidates) {
|
|
643
|
+
let best;
|
|
644
|
+
let bestD = Infinity;
|
|
645
|
+
for (const c of candidates) {
|
|
646
|
+
const d = levenshtein(name, c);
|
|
647
|
+
if (d < bestD) {
|
|
648
|
+
bestD = d;
|
|
649
|
+
best = c;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
return bestD <= Math.max(2, Math.floor(name.length / 3)) ? best : void 0;
|
|
653
|
+
}
|
|
654
|
+
function levenshtein(a, b) {
|
|
655
|
+
const m = a.length, n = b.length;
|
|
656
|
+
const dp = Array.from({ length: m + 1 }, (_, i) => i);
|
|
657
|
+
for (let j = 1; j <= n; j++) {
|
|
658
|
+
let prev = dp[0];
|
|
659
|
+
dp[0] = j;
|
|
660
|
+
for (let i = 1; i <= m; i++) {
|
|
661
|
+
const tmp = dp[i];
|
|
662
|
+
dp[i] = Math.min(dp[i] + 1, dp[i - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
663
|
+
prev = tmp;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
return dp[m];
|
|
667
|
+
}
|
|
668
|
+
function validateExpression(role, input, schema) {
|
|
669
|
+
const { dialect, source } = toSource(input);
|
|
670
|
+
const errors = [];
|
|
671
|
+
if (!source.trim()) return { ok: true, errors };
|
|
672
|
+
if (role === "template") {
|
|
673
|
+
if (dialect && dialect !== "template") {
|
|
674
|
+
errors.push({ source, message: `expected a text template but got a \`${dialect}\` expression.` });
|
|
675
|
+
return { ok: false, errors };
|
|
676
|
+
}
|
|
677
|
+
const compiled2 = templateEngine.compile(source);
|
|
678
|
+
if (!compiled2.ok) {
|
|
679
|
+
errors.push({ source, message: `invalid template: ${compiled2.error.message} (holes use \`{{ path }}\`).` });
|
|
680
|
+
}
|
|
681
|
+
const hint = SINGLE_BRACE_RE.test(source) ? bracesHintForTemplate(source) : null;
|
|
682
|
+
if (hint) errors.push({ source, message: hint });
|
|
683
|
+
return { ok: errors.length === 0, errors };
|
|
684
|
+
}
|
|
685
|
+
if (dialect && dialect !== "cel") {
|
|
686
|
+
errors.push({ source, message: `expected a CEL expression but got a \`${dialect}\` dialect.` });
|
|
687
|
+
return { ok: false, errors };
|
|
688
|
+
}
|
|
689
|
+
const compiled = celEngine.compile(source);
|
|
690
|
+
if (!compiled.ok) {
|
|
691
|
+
const hint = bracesHint(source);
|
|
692
|
+
errors.push({
|
|
693
|
+
source,
|
|
694
|
+
message: `invalid CEL ${role}: ${compiled.error.message}` + (hint ? ` \u2014 ${hint}` : ` \u2014 ${role}s are bare CEL (e.g. \`record.rating >= 4\`).`)
|
|
695
|
+
});
|
|
696
|
+
} else {
|
|
697
|
+
checkFieldExistence(source, schema, errors);
|
|
698
|
+
}
|
|
699
|
+
return { ok: errors.length === 0, errors };
|
|
700
|
+
}
|
|
701
|
+
function bracesHintForTemplate(source) {
|
|
702
|
+
const m = SINGLE_BRACE_RE.exec(source);
|
|
703
|
+
const ref = m?.[1] ?? "field";
|
|
704
|
+
return `single-brace \`{${ref}}\` is not a valid template hole \u2014 use double braces: \`{{ ${ref} }}\`.`;
|
|
705
|
+
}
|
|
706
|
+
function introspectScope(role, schema) {
|
|
707
|
+
return {
|
|
708
|
+
dialect: expectedDialect(role),
|
|
709
|
+
fields: [...schema?.fields ?? []],
|
|
710
|
+
roots: ["record", "previous", "input", "os", "vars"],
|
|
711
|
+
functions: CEL_STDLIB_FUNCTIONS
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
var CEL_STDLIB_FUNCTIONS = [
|
|
715
|
+
"now",
|
|
716
|
+
"today",
|
|
717
|
+
"daysFromNow",
|
|
718
|
+
"daysBetween",
|
|
719
|
+
"date",
|
|
720
|
+
"datetime",
|
|
721
|
+
"timestamp",
|
|
722
|
+
"isBlank",
|
|
723
|
+
"isEmpty",
|
|
724
|
+
"coalesce",
|
|
725
|
+
"len",
|
|
726
|
+
"size",
|
|
727
|
+
"int",
|
|
728
|
+
"float",
|
|
729
|
+
"string",
|
|
730
|
+
"bool",
|
|
731
|
+
"upper",
|
|
732
|
+
"lower",
|
|
733
|
+
"trim",
|
|
734
|
+
"contains",
|
|
735
|
+
"startsWith",
|
|
736
|
+
"endsWith",
|
|
737
|
+
"matches",
|
|
738
|
+
"has",
|
|
739
|
+
"min",
|
|
740
|
+
"max",
|
|
741
|
+
"abs",
|
|
742
|
+
"round"
|
|
743
|
+
];
|
|
454
744
|
export {
|
|
745
|
+
CEL_STDLIB_FUNCTIONS,
|
|
455
746
|
DEFAULT_LIMITS,
|
|
456
747
|
ExpressionEngine,
|
|
748
|
+
TEMPLATE_FORMATTERS,
|
|
457
749
|
buildScope,
|
|
458
750
|
celEngine,
|
|
459
751
|
cronEngine,
|
|
752
|
+
expectedDialect,
|
|
460
753
|
getEngine,
|
|
461
754
|
hasDialect,
|
|
755
|
+
introspectScope,
|
|
462
756
|
normalizeExpression,
|
|
463
757
|
normalizeExpressionTree,
|
|
464
758
|
register,
|
|
465
759
|
registerStdLib,
|
|
466
760
|
resolveSeed,
|
|
467
761
|
resolveSeedRecord,
|
|
468
|
-
templateEngine
|
|
762
|
+
templateEngine,
|
|
763
|
+
validateExpression
|
|
469
764
|
};
|
|
470
765
|
//# sourceMappingURL=index.mjs.map
|