diff --git a/.changeset/metric-formula-expression-model.md b/.changeset/metric-formula-expression-model.md new file mode 100644 index 0000000000..23f2bcd0f9 --- /dev/null +++ b/.changeset/metric-formula-expression-model.md @@ -0,0 +1,5 @@ +--- +'@hyperdx/common-utils': minor +--- + +Add the metric formula expression model: a `formulas` entry on chart configs (letter-based series refs — `A`, `B`, `C` map to `select` positions) plus an arithmetic-only parser/validator (`core/formula.ts`) that produces a validated AST and structured validation errors (unknown series ref, empty expression, malformed syntax, invalid tokens). Groundwork for metric formulas like `A / (A + B + C) * 100`; no query rendering or UI changes yet. diff --git a/packages/common-utils/src/__tests__/formula.test.ts b/packages/common-utils/src/__tests__/formula.test.ts new file mode 100644 index 0000000000..6282d3ed99 --- /dev/null +++ b/packages/common-utils/src/__tests__/formula.test.ts @@ -0,0 +1,465 @@ +import { + FormulaAst, + indexToSeriesRef, + MAX_FORMULA_DEPTH, + MAX_FORMULA_EXPRESSION_LENGTH, + parseFormula, + seriesRefToIndex, + validateFormula, +} from '@/core/formula'; + +const expectOk = ( + result: ReturnType, +): Extract, { ok: true }> => { + expect(result.ok).toBe(true); + if (!result.ok) throw new Error('expected ok result'); + return result; +}; + +const expectErrors = ( + result: ReturnType, +): Extract, { ok: false }> => { + expect(result.ok).toBe(false); + if (result.ok) throw new Error('expected error result'); + return result; +}; + +describe('seriesRefToIndex / indexToSeriesRef', () => { + it('maps letters to zero-based select indices and back', () => { + expect(seriesRefToIndex('A')).toBe(0); + expect(seriesRefToIndex('B')).toBe(1); + expect(seriesRefToIndex('Z')).toBe(25); + expect(indexToSeriesRef(0)).toBe('A'); + expect(indexToSeriesRef(25)).toBe('Z'); + }); + + it('returns undefined for indices without a single-letter ref', () => { + expect(indexToSeriesRef(26)).toBeUndefined(); + expect(indexToSeriesRef(-1)).toBeUndefined(); + }); +}); + +describe('parseFormula', () => { + // ─── Success cases ───────────────────────────────────────────────────────── + + it('parses a single series ref', () => { + const { ast, referencedIndices } = expectOk(parseFormula('A')); + expect(ast).toEqual({ type: 'seriesRef', ref: 'A', index: 0 }); + expect(referencedIndices).toEqual([0]); + }); + + it('parses the motivating HDX-4938 example: A / (A + B + C) * 100', () => { + const { ast, referencedIndices } = expectOk( + parseFormula('A / (A + B + C) * 100'), + ); + const a: FormulaAst = { type: 'seriesRef', ref: 'A', index: 0 }; + const b: FormulaAst = { type: 'seriesRef', ref: 'B', index: 1 }; + const c: FormulaAst = { type: 'seriesRef', ref: 'C', index: 2 }; + expect(ast).toEqual({ + type: 'binary', + op: '*', + left: { + type: 'binary', + op: '/', + left: a, + right: { + type: 'binary', + op: '+', + left: { type: 'binary', op: '+', left: a, right: b }, + right: c, + }, + }, + right: { type: 'number', value: 100 }, + }); + expect(referencedIndices).toEqual([0, 1, 2]); + }); + + it('applies multiplication/division precedence over addition/subtraction', () => { + const { ast } = expectOk(parseFormula('A + B * C')); + expect(ast).toEqual({ + type: 'binary', + op: '+', + left: { type: 'seriesRef', ref: 'A', index: 0 }, + right: { + type: 'binary', + op: '*', + left: { type: 'seriesRef', ref: 'B', index: 1 }, + right: { type: 'seriesRef', ref: 'C', index: 2 }, + }, + }); + }); + + it('is left-associative for same-precedence operators', () => { + const { ast } = expectOk(parseFormula('A - B - C')); + expect(ast).toEqual({ + type: 'binary', + op: '-', + left: { + type: 'binary', + op: '-', + left: { type: 'seriesRef', ref: 'A', index: 0 }, + right: { type: 'seriesRef', ref: 'B', index: 1 }, + }, + right: { type: 'seriesRef', ref: 'C', index: 2 }, + }); + }); + + it('parentheses override precedence', () => { + const { ast } = expectOk(parseFormula('(A + B) * C')); + expect(ast).toEqual({ + type: 'binary', + op: '*', + left: { + type: 'binary', + op: '+', + left: { type: 'seriesRef', ref: 'A', index: 0 }, + right: { type: 'seriesRef', ref: 'B', index: 1 }, + }, + right: { type: 'seriesRef', ref: 'C', index: 2 }, + }); + }); + + it('parses numeric constants (integer, decimal, leading-dot)', () => { + expect(expectOk(parseFormula('A * 2')).ast).toEqual({ + type: 'binary', + op: '*', + left: { type: 'seriesRef', ref: 'A', index: 0 }, + right: { type: 'number', value: 2 }, + }); + expect(expectOk(parseFormula('A * 0.5')).ast).toMatchObject({ + right: { type: 'number', value: 0.5 }, + }); + expect(expectOk(parseFormula('A * .25')).ast).toMatchObject({ + right: { type: 'number', value: 0.25 }, + }); + }); + + it('parses unary minus', () => { + expect(expectOk(parseFormula('-A + B')).ast).toEqual({ + type: 'binary', + op: '+', + left: { + type: 'unary', + op: '-', + operand: { type: 'seriesRef', ref: 'A', index: 0 }, + }, + right: { type: 'seriesRef', ref: 'B', index: 1 }, + }); + // Double negation and negation of a parenthesized expression. + expect(expectOk(parseFormula('--A')).ast).toEqual({ + type: 'unary', + op: '-', + operand: { + type: 'unary', + op: '-', + operand: { type: 'seriesRef', ref: 'A', index: 0 }, + }, + }); + expect(expectOk(parseFormula('-(A + B)')).ast).toMatchObject({ + type: 'unary', + op: '-', + }); + // Unary minus binds tighter than binary operators to its right. + expect(expectOk(parseFormula('A * -B')).ast).toEqual({ + type: 'binary', + op: '*', + left: { type: 'seriesRef', ref: 'A', index: 0 }, + right: { + type: 'unary', + op: '-', + operand: { type: 'seriesRef', ref: 'B', index: 1 }, + }, + }); + }); + + it('tolerates arbitrary whitespace', () => { + const compact = expectOk(parseFormula('A/(A+B)*100')); + const spaced = expectOk(parseFormula(' A /\t( A + B ) * 100 ')); + expect(spaced.ast).toEqual(compact.ast); + }); + + it('deduplicates and sorts referencedIndices', () => { + const { referencedIndices } = expectOk(parseFormula('C + A + C + A')); + expect(referencedIndices).toEqual([0, 2]); + }); + + it('parses a constant-only expression (range checks are validateFormula concerns)', () => { + const { ast, referencedIndices } = expectOk(parseFormula('1 + 2')); + expect(ast).toMatchObject({ type: 'binary', op: '+' }); + expect(referencedIndices).toEqual([]); + }); + + it('parses division by a zero constant (divide-by-zero is a render-time concern)', () => { + expectOk(parseFormula('A / 0')); + }); + + // ─── Error cases ─────────────────────────────────────────────────────────── + + it('rejects an empty expression', () => { + for (const expression of ['', ' ', '\t\n']) { + const { errors } = expectErrors(parseFormula(expression)); + expect(errors).toEqual([ + { code: 'empty-expression', message: expect.any(String) }, + ]); + } + }); + + it('rejects a trailing operator', () => { + const { errors } = expectErrors(parseFormula('A +')); + expect(errors).toEqual([ + { + code: 'syntax-error', + message: expect.stringContaining('end of expression'), + position: 3, + }, + ]); + }); + + it('rejects a leading binary operator', () => { + const { errors } = expectErrors(parseFormula('* A')); + expect(errors).toMatchObject([{ code: 'syntax-error', position: 0 }]); + }); + + it('rejects adjacent operands with no operator', () => { + const { errors } = expectErrors(parseFormula('A B')); + expect(errors).toEqual([ + { + code: 'syntax-error', + message: expect.stringContaining('series reference "B"'), + position: 2, + }, + ]); + }); + + it('rejects adjacent ref and number (e.g. A1)', () => { + const { errors } = expectErrors(parseFormula('A1')); + expect(errors).toMatchObject([{ code: 'syntax-error', position: 1 }]); + }); + + it('rejects doubled binary operators', () => { + const { errors } = expectErrors(parseFormula('A + * B')); + expect(errors).toMatchObject([{ code: 'syntax-error', position: 4 }]); + }); + + it('rejects unbalanced parentheses', () => { + expect(expectErrors(parseFormula('(A + B')).errors).toEqual([ + { + code: 'syntax-error', + message: expect.stringContaining('closing parenthesis'), + position: 6, + }, + ]); + expect(expectErrors(parseFormula('A + B)')).errors).toEqual([ + { + code: 'syntax-error', + message: expect.stringContaining('closing parenthesis'), + position: 5, + }, + ]); + expect(expectErrors(parseFormula('()')).errors).toMatchObject([ + { code: 'syntax-error', position: 1 }, + ]); + }); + + it('rejects lowercase series refs with a helpful message', () => { + const { errors } = expectErrors(parseFormula('a + b')); + expect(errors).toEqual([ + { + code: 'invalid-token', + message: expect.stringContaining('uppercase'), + position: 0, + token: 'a', + }, + ]); + }); + + it('rejects multi-letter identifiers (no functions in v1)', () => { + const { errors } = expectErrors(parseFormula('ABS(A)')); + expect(errors).toEqual([ + { + code: 'invalid-token', + message: expect.stringContaining('functions are not supported'), + position: 0, + token: 'ABS', + }, + ]); + expect(expectErrors(parseFormula('A + Foo')).errors).toMatchObject([ + { code: 'invalid-token', position: 4, token: 'Foo' }, + ]); + }); + + it('rejects unexpected characters with their position', () => { + expect(expectErrors(parseFormula('$A + B')).errors).toEqual([ + { + code: 'invalid-token', + message: expect.any(String), + position: 0, + token: '$', + }, + ]); + expect(expectErrors(parseFormula('A % B')).errors).toMatchObject([ + { code: 'invalid-token', position: 2, token: '%' }, + ]); + expect(expectErrors(parseFormula('A + "B"')).errors).toMatchObject([ + { code: 'invalid-token', position: 4, token: '"' }, + ]); + }); + + it('rejects a bare dot', () => { + const { errors } = expectErrors(parseFormula('A + .')); + expect(errors).toMatchObject([ + { code: 'invalid-token', position: 4, token: '.' }, + ]); + }); + + // ─── Length / depth bounds ───────────────────────────────────────────────── + // Oversized or deeply nested expressions must come back as structured + // errors, never escape the result-based API as a stack-overflow RangeError. + + it('rejects an oversized expression with a structured error', () => { + const terms = Array.from( + { length: MAX_FORMULA_EXPRESSION_LENGTH }, + () => 'A', + ).join(' + '); + const { errors } = expectErrors(parseFormula(terms)); + expect(errors).toEqual([ + { + code: 'expression-too-long', + message: expect.stringContaining(`${MAX_FORMULA_EXPRESSION_LENGTH}`), + maxLength: MAX_FORMULA_EXPRESSION_LENGTH, + }, + ]); + }); + + it('accepts an expression at exactly the maximum length', () => { + // "A+A+A..." — every char meaningful, length exactly at the cap (odd + // cap lands on a ref; pad with a leading unary minus when even). + const base = 'A' + '+A'.repeat((MAX_FORMULA_EXPRESSION_LENGTH - 1) / 2); + const expression = + base.length === MAX_FORMULA_EXPRESSION_LENGTH ? base : `-${base}`; + expect(expression).toHaveLength(MAX_FORMULA_EXPRESSION_LENGTH); + expectOk(parseFormula(expression)); + }); + + it('rejects nesting beyond MAX_FORMULA_DEPTH parentheses with a structured error', () => { + const depth = MAX_FORMULA_DEPTH + 1; + const expression = '('.repeat(depth) + 'A' + ')'.repeat(depth); + const { errors } = expectErrors(parseFormula(expression)); + expect(errors).toEqual([ + { + code: 'syntax-error', + message: expect.stringContaining('nested too deeply'), + position: MAX_FORMULA_DEPTH, + }, + ]); + }); + + it('accepts nesting at exactly MAX_FORMULA_DEPTH', () => { + const expression = + '('.repeat(MAX_FORMULA_DEPTH) + 'A' + ')'.repeat(MAX_FORMULA_DEPTH); + expectOk(parseFormula(expression)); + }); + + it('bounds unary minus chains with the same depth limit', () => { + expectOk(parseFormula('-'.repeat(MAX_FORMULA_DEPTH) + 'A')); + const { errors } = expectErrors( + parseFormula('-'.repeat(MAX_FORMULA_DEPTH + 1) + 'A'), + ); + expect(errors).toMatchObject([ + { + code: 'syntax-error', + message: expect.stringContaining('nested too deeply'), + }, + ]); + }); + + it('depth is per-branch, not cumulative across sibling groups', () => { + const group = '(-(A))'; + const expression = Array.from({ length: 30 }, () => group).join(' + '); + expect(expression.length).toBeLessThanOrEqual( + MAX_FORMULA_EXPRESSION_LENGTH, + ); + expectOk(parseFormula(expression)); + }); + + it('never throws on adversarial input within the length cap', () => { + // Worst-case recursive inputs at the maximum length: all open parens, + // all minuses. Must return structured errors, not RangeError. + for (const expression of [ + '('.repeat(MAX_FORMULA_EXPRESSION_LENGTH), + '-'.repeat(MAX_FORMULA_EXPRESSION_LENGTH), + ]) { + const { errors } = expectErrors(parseFormula(expression)); + expect(errors).toMatchObject([{ code: 'syntax-error' }]); + } + }); +}); + +describe('validateFormula', () => { + it('accepts refs within the series count', () => { + const result = expectOk( + validateFormula('A / (A + B + C) * 100', { seriesCount: 3 }), + ); + expect(result.referencedIndices).toEqual([0, 1, 2]); + }); + + it('rejects refs beyond the series count', () => { + const { errors } = expectErrors( + validateFormula('A + C', { seriesCount: 2 }), + ); + expect(errors).toEqual([ + { + code: 'unknown-series-ref', + message: expect.stringContaining('A through B'), + position: 4, + ref: 'C', + }, + ]); + }); + + it('reports each unknown ref once, even when repeated', () => { + const { errors } = expectErrors( + validateFormula('C + D + C', { seriesCount: 2 }), + ); + expect(errors).toMatchObject([ + { code: 'unknown-series-ref', ref: 'C' }, + { code: 'unknown-series-ref', ref: 'D' }, + ]); + }); + + it('handles a chart with no series', () => { + const { errors } = expectErrors(validateFormula('A', { seriesCount: 0 })); + expect(errors).toEqual([ + { + code: 'unknown-series-ref', + message: expect.stringContaining('no series'), + position: 0, + ref: 'A', + }, + ]); + }); + + it('accepts the boundary ref exactly at seriesCount - 1', () => { + expectOk(validateFormula('Z', { seriesCount: 26 })); + expectErrors(validateFormula('Z', { seriesCount: 25 })); + }); + + it('rejects constant-only expressions', () => { + const { errors } = expectErrors( + validateFormula('1 + 2', { seriesCount: 3 }), + ); + expect(errors).toEqual([ + { code: 'constant-only-expression', message: expect.any(String) }, + ]); + }); + + it('propagates parse errors unchanged', () => { + const { errors } = expectErrors(validateFormula('A +', { seriesCount: 3 })); + expect(errors).toMatchObject([{ code: 'syntax-error' }]); + }); + + it('propagates empty-expression errors unchanged', () => { + const { errors } = expectErrors(validateFormula('', { seriesCount: 3 })); + expect(errors).toMatchObject([{ code: 'empty-expression' }]); + }); +}); diff --git a/packages/common-utils/src/__tests__/types.test.ts b/packages/common-utils/src/__tests__/types.test.ts index 3a541e91f1..bcb92f3086 100644 --- a/packages/common-utils/src/__tests__/types.test.ts +++ b/packages/common-utils/src/__tests__/types.test.ts @@ -1,11 +1,13 @@ import { z } from 'zod'; +import { MAX_FORMULA_EXPRESSION_LENGTH } from '@/core/formula'; import { BackgroundChartSchema, ColorConditionSchema, DASHBOARD_VARIABLE_NAME_MAX_LENGTH, DashboardFilterSchema, DerivedColumnSchema, + MetricFormulaSchema, SavedChartConfigSchema, } from '@/types'; @@ -535,3 +537,80 @@ describe('DashboardFilterSchema variable fields', () => { expect(result.success).toBe(true); }); }); + +describe('MetricFormulaSchema', () => { + it('parses an expression-only formula', () => { + const result = MetricFormulaSchema.safeParse({ + expression: 'A / (A + B + C) * 100', + }); + expect(result.success).toBe(true); + }); + + it('parses a formula with alias and numberFormat', () => { + const result = MetricFormulaSchema.safeParse({ + expression: 'A / B', + alias: 'Success rate', + numberFormat: { output: 'percent', mantissa: 1 }, + }); + expect(result.success).toBe(true); + }); + + it('requires an expression', () => { + expect(MetricFormulaSchema.safeParse({ alias: 'x' }).success).toBe(false); + expect(MetricFormulaSchema.safeParse({ expression: 1 }).success).toBe( + false, + ); + }); + + // The schema length cap and the parser's MAX_FORMULA_EXPRESSION_LENGTH + // must agree — types.ts is a leaf module (imports only zod), so the bound + // is duplicated as a literal there and pinned here. + it('caps expression length at MAX_FORMULA_EXPRESSION_LENGTH', () => { + expect( + MetricFormulaSchema.safeParse({ + expression: 'A'.repeat(MAX_FORMULA_EXPRESSION_LENGTH), + }).success, + ).toBe(true); + expect( + MetricFormulaSchema.safeParse({ + expression: 'A'.repeat(MAX_FORMULA_EXPRESSION_LENGTH + 1), + }).success, + ).toBe(false); + }); +}); + +describe('formulas on saved chart configs', () => { + // `formulas` / `showOperandSeries` live on _ChartConfigSchema (builder + // configs only — formulas reference `select` entries by position, which + // raw SQL / PromQL configs do not have). + + it('retains formulas and showOperandSeries on a builder saved config', () => { + const parsed = SavedChartConfigSchema.parse({ + source: 'test-source', + timestampValueExpression: 'Timestamp', + select: [ + { aggFn: 'sum', valueExpression: 'Value', metricName: 'success' }, + { aggFn: 'sum', valueExpression: 'Value', metricName: 'error' }, + ], + where: '', + formulas: [{ expression: 'A / (A + B) * 100', alias: 'Success rate' }], + showOperandSeries: false, + }); + + expect(parsed).toMatchObject({ + formulas: [{ expression: 'A / (A + B) * 100', alias: 'Success rate' }], + showOperandSeries: false, + }); + }); + + it('parses a builder saved config without formulas (back-compat)', () => { + const parsed = SavedChartConfigSchema.parse({ + source: 'test-source', + timestampValueExpression: 'Timestamp', + select: [{ aggFn: 'count', valueExpression: '' }], + where: '', + }); + + expect(parsed).not.toHaveProperty('formulas'); + }); +}); diff --git a/packages/common-utils/src/core/formula.ts b/packages/common-utils/src/core/formula.ts new file mode 100644 index 0000000000..2fb6273be8 --- /dev/null +++ b/packages/common-utils/src/core/formula.ts @@ -0,0 +1,512 @@ +/** + * Parser / validator for metric formula expressions (HDX-5078). + * + * A formula is a letter-ref arithmetic expression over the chart's `select` + * series: `A` references `select[0]`, `B` references `select[1]`, and so on + * (Grafana/Datadog convention; stable against alias edits). Example: + * + * A / (A + B + C) * 100 + * + * Grammar (v1 — arithmetic only, no scalar functions, never raw SQL): + * + * expression := term (('+' | '-') term)* + * term := factor (('*' | '/') factor)* + * factor := '-' factor | primary + * primary := number | seriesRef | '(' expression ')' + * seriesRef := 'A'..'Z' (single uppercase letter) + * number := digits ('.' digits)? | '.' digits + * + * The expression parses to a validated AST which the query renderer + * (HDX-5079) compiles into a SQL projection over pivoted per-series columns. + * Validation failures are returned as structured results so the chart editor + * (HDX-5080) can surface inline errors. + */ + +// ─── AST ───────────────────────────────────────────────────────────────────── + +export type FormulaBinaryOp = '+' | '-' | '*' | '/'; + +export type FormulaAst = + | { + type: 'binary'; + op: FormulaBinaryOp; + left: FormulaAst; + right: FormulaAst; + } + | { type: 'unary'; op: '-'; operand: FormulaAst } + | { type: 'number'; value: number } + // `ref` is the letter as written (e.g. 'B'); `index` is its zero-based + // position in the chart's `select` list (e.g. 1). + | { type: 'seriesRef'; ref: string; index: number }; + +// ─── Bounds ────────────────────────────────────────────────────────────────── + +/** + * Maximum accepted expression length, in characters. Generous for a + * human-typed formula while keeping parser recursion (one level per nesting + * depth, and depth <= length) far below any engine's stack limit, so + * validation always returns structured results instead of overflowing. + * Mirrored by `MetricFormulaSchema.expression`'s max length in `types.ts`. + */ +export const MAX_FORMULA_EXPRESSION_LENGTH = 1024; + +/** + * Maximum nesting depth (parentheses + unary minus chains). Deterministic + * recursion bound regardless of engine stack size; far beyond any real + * formula. + */ +export const MAX_FORMULA_DEPTH = 64; + +// ─── Structured validation errors ──────────────────────────────────────────── + +/** + * One structured validation failure. `position` is the zero-based character + * offset into the original expression (for inline error markers in the + * editor); errors that apply to the expression as a whole omit it. + */ +export type FormulaValidationError = + | { code: 'empty-expression'; message: string } + | { + code: 'expression-too-long'; + message: string; + maxLength: number; + } + | { code: 'invalid-token'; message: string; position: number; token: string } + | { code: 'syntax-error'; message: string; position: number } + | { + code: 'unknown-series-ref'; + message: string; + position: number; + ref: string; + } + | { code: 'constant-only-expression'; message: string }; + +export type FormulaParseResult = + | { + ok: true; + ast: FormulaAst; + /** Distinct zero-based `select` indices referenced, ascending. */ + referencedIndices: number[]; + } + | { ok: false; errors: FormulaValidationError[] }; + +// ─── Tokenizer ─────────────────────────────────────────────────────────────── + +type Token = + | { type: 'number'; value: number; position: number; text: string } + | { type: 'seriesRef'; ref: string; position: number } + | { type: 'operator'; op: FormulaBinaryOp; position: number } + | { type: 'lparen'; position: number } + | { type: 'rparen'; position: number }; + +const isBinaryOp = (ch: string): ch is FormulaBinaryOp => + ch === '+' || ch === '-' || ch === '*' || ch === '/'; + +const isDigit = (ch: string) => ch >= '0' && ch <= '9'; +const isUppercaseLetter = (ch: string) => ch >= 'A' && ch <= 'Z'; +const isLowercaseLetter = (ch: string) => ch >= 'a' && ch <= 'z'; +const isAnyLetter = (ch: string) => + isUppercaseLetter(ch) || isLowercaseLetter(ch); +const isWhitespace = (ch: string) => /\s/.test(ch); + +/** Zero-based `select` index a series ref letter maps to ('A' -> 0). */ +export const seriesRefToIndex = (ref: string): number => + ref.charCodeAt(0) - 'A'.charCodeAt(0); + +/** Series ref letter for a zero-based `select` index (0 -> 'A'), or undefined past 'Z'. */ +export const indexToSeriesRef = (index: number): string | undefined => + index >= 0 && index < 26 + ? String.fromCharCode('A'.charCodeAt(0) + index) + : undefined; + +const tokenize = ( + expression: string, +): { tokens: Token[] } | { error: FormulaValidationError } => { + const tokens: Token[] = []; + let i = 0; + while (i < expression.length) { + const ch = expression.charAt(i); + if (isWhitespace(ch)) { + i++; + } else if (isBinaryOp(ch)) { + tokens.push({ type: 'operator', op: ch, position: i }); + i++; + } else if (ch === '(') { + tokens.push({ type: 'lparen', position: i }); + i++; + } else if (ch === ')') { + tokens.push({ type: 'rparen', position: i }); + i++; + } else if ( + isDigit(ch) || + (ch === '.' && isDigit(expression.charAt(i + 1))) + ) { + const start = i; + while (i < expression.length && isDigit(expression.charAt(i))) i++; + if (expression.charAt(i) === '.') { + i++; + while (i < expression.length && isDigit(expression.charAt(i))) i++; + } + const text = expression.slice(start, i); + const value = Number(text); + if (!Number.isFinite(value)) { + return { + error: { + code: 'invalid-token', + message: `Invalid number "${text}"`, + position: start, + token: text, + }, + }; + } + tokens.push({ type: 'number', value, position: start, text }); + } else if (isUppercaseLetter(ch)) { + // Series refs are single letters; a run of letters (e.g. `AB`, `ABS`) + // is not a valid ref and functions are not supported in v1. + const start = i; + while (i < expression.length && isAnyLetter(expression.charAt(i))) { + i++; + } + const text = expression.slice(start, i); + if (text.length > 1) { + return { + error: { + code: 'invalid-token', + message: `Unexpected "${text}" — series references are single letters (A, B, C, ...) and functions are not supported`, + position: start, + token: text, + }, + }; + } + tokens.push({ type: 'seriesRef', ref: text, position: start }); + } else if (isLowercaseLetter(ch)) { + const start = i; + while (i < expression.length && isAnyLetter(expression.charAt(i))) { + i++; + } + const text = expression.slice(start, i); + return { + error: { + code: 'invalid-token', + message: `Unexpected "${text}" — series references are uppercase letters (A, B, C, ...)`, + position: start, + token: text, + }, + }; + } else { + return { + error: { + code: 'invalid-token', + message: `Unexpected character "${ch}"`, + position: i, + token: ch, + }, + }; + } + } + return { tokens }; +}; + +// ─── Parser (recursive descent) ────────────────────────────────────────────── + +class SyntaxError_ extends Error { + constructor( + message: string, + readonly position: number, + ) { + super(message); + } +} + +class Parser { + private pos = 0; + // Current nesting depth (parentheses + unary minus chains). Binary + // operator chains are parsed iteratively so they don't contribute. Keeps + // recursion bounded deterministically (see MAX_FORMULA_DEPTH); the length + // pre-check in parseFormula already makes overflow unreachable, this makes + // the limit engine-independent and the error message precise. + private depth = 0; + + constructor( + private readonly tokens: Token[], + private readonly expressionLength: number, + ) {} + + private enterNested(position: number): void { + this.depth++; + if (this.depth > MAX_FORMULA_DEPTH) { + throw new SyntaxError_( + `Expression is nested too deeply (max ${MAX_FORMULA_DEPTH} levels)`, + position, + ); + } + } + + private exitNested(): void { + this.depth--; + } + + parse(): FormulaAst { + const ast = this.parseExpression(); + const trailing = this.peek(); + if (trailing !== undefined) { + throw new SyntaxError_( + trailing.type === 'rparen' + ? 'Unmatched closing parenthesis' + : `Unexpected ${describeToken(trailing)}`, + trailing.position, + ); + } + return ast; + } + + private peek(): Token | undefined { + return this.tokens[this.pos]; + } + + private next(): Token | undefined { + return this.tokens[this.pos++]; + } + + // expression := term (('+' | '-') term)* + private parseExpression(): FormulaAst { + let left = this.parseTerm(); + for (;;) { + const tok = this.peek(); + if (tok?.type !== 'operator' || (tok.op !== '+' && tok.op !== '-')) break; + this.pos++; + left = { type: 'binary', op: tok.op, left, right: this.parseTerm() }; + } + return left; + } + + // term := factor (('*' | '/') factor)* + private parseTerm(): FormulaAst { + let left = this.parseFactor(); + for (;;) { + const tok = this.peek(); + if (tok?.type !== 'operator' || (tok.op !== '*' && tok.op !== '/')) break; + this.pos++; + left = { type: 'binary', op: tok.op, left, right: this.parseFactor() }; + } + return left; + } + + // factor := '-' factor | primary + private parseFactor(): FormulaAst { + const tok = this.peek(); + if (tok?.type === 'operator' && tok.op === '-') { + this.pos++; + this.enterNested(tok.position); + const operand = this.parseFactor(); + this.exitNested(); + return { type: 'unary', op: '-', operand }; + } + return this.parsePrimary(); + } + + // primary := number | seriesRef | '(' expression ')' + private parsePrimary(): FormulaAst { + const tok = this.next(); + if (tok === undefined) { + throw new SyntaxError_( + 'Unexpected end of expression', + this.expressionLength, + ); + } + switch (tok.type) { + case 'number': + return { type: 'number', value: tok.value }; + case 'seriesRef': + return { + type: 'seriesRef', + ref: tok.ref, + index: seriesRefToIndex(tok.ref), + }; + case 'lparen': { + this.enterNested(tok.position); + const inner = this.parseExpression(); + this.exitNested(); + const closing = this.next(); + if (closing?.type !== 'rparen') { + throw new SyntaxError_( + 'Missing closing parenthesis', + closing?.position ?? this.expressionLength, + ); + } + return inner; + } + default: + throw new SyntaxError_( + `Unexpected ${describeToken(tok)}`, + tok.position, + ); + } + } +} + +const describeToken = (tok: Token): string => { + switch (tok.type) { + case 'number': + return `number "${tok.text}"`; + case 'seriesRef': + return `series reference "${tok.ref}"`; + case 'operator': + return `operator "${tok.op}"`; + case 'lparen': + return 'opening parenthesis'; + case 'rparen': + return 'closing parenthesis'; + } +}; + +const collectReferencedIndices = (ast: FormulaAst, out: Set): void => { + switch (ast.type) { + case 'binary': + collectReferencedIndices(ast.left, out); + collectReferencedIndices(ast.right, out); + break; + case 'unary': + collectReferencedIndices(ast.operand, out); + break; + case 'seriesRef': + out.add(ast.index); + break; + case 'number': + break; + } +}; + +// ─── Public API ────────────────────────────────────────────────────────────── + +/** + * Parse a formula expression into a validated AST. + * + * Purely syntactic — does not know how many series the chart has; use + * `validateFormula` for the full check (range validation of series refs). + */ +export const parseFormula = (expression: string): FormulaParseResult => { + if (expression.trim() === '') { + return { + ok: false, + errors: [{ code: 'empty-expression', message: 'Expression is empty' }], + }; + } + // Checked before tokenizing so an oversized (possibly hostile) expression + // can never drive the recursive parser toward a stack overflow — the + // validation API always returns structured results rather than throwing. + if (expression.length > MAX_FORMULA_EXPRESSION_LENGTH) { + return { + ok: false, + errors: [ + { + code: 'expression-too-long', + message: `Expression is too long (${expression.length} characters, max ${MAX_FORMULA_EXPRESSION_LENGTH})`, + maxLength: MAX_FORMULA_EXPRESSION_LENGTH, + }, + ], + }; + } + const tokenized = tokenize(expression); + if ('error' in tokenized) { + return { ok: false, errors: [tokenized.error] }; + } + try { + const ast = new Parser(tokenized.tokens, expression.length).parse(); + const indices = new Set(); + collectReferencedIndices(ast, indices); + return { + ok: true, + ast, + referencedIndices: [...indices].sort((a, b) => a - b), + }; + } catch (e) { + if (e instanceof SyntaxError_) { + return { + ok: false, + errors: [ + { code: 'syntax-error', message: e.message, position: e.position }, + ], + }; + } + throw e; + } +}; + +/** + * Parse and fully validate a formula expression against a chart's series. + * + * On top of `parseFormula`, checks that: + * - every series ref resolves to an existing `select` entry + * (`seriesCount` is the length of the chart's `select` list), and + * - the expression references at least one series (a constant-only formula + * like `1 + 2` is almost certainly a mistake). + * + * All failures for a parseable expression are reported together so the + * editor can surface every invalid ref at once. + */ +export const validateFormula = ( + expression: string, + { seriesCount }: { seriesCount: number }, +): FormulaParseResult => { + const parsed = parseFormula(expression); + if (!parsed.ok) { + return parsed; + } + + const errors: FormulaValidationError[] = []; + + const checkRefs = (ast: FormulaAst): void => { + switch (ast.type) { + case 'binary': + checkRefs(ast.left); + checkRefs(ast.right); + break; + case 'unary': + checkRefs(ast.operand); + break; + case 'seriesRef': + if (ast.index >= seriesCount) { + errors.push({ + code: 'unknown-series-ref', + message: + seriesCount > 0 + ? `Unknown series "${ast.ref}" — this chart only has series A through ${indexToSeriesRef(seriesCount - 1)}` + : `Unknown series "${ast.ref}" — this chart has no series`, + position: findRefPosition(expression, ast.ref), + ref: ast.ref, + }); + } + break; + case 'number': + break; + } + }; + checkRefs(parsed.ast); + + // Dedupe repeated refs to the same unknown letter (e.g. `Z + Z`). + const seenRefs = new Set(); + const dedupedErrors = errors.filter(err => { + if (err.code !== 'unknown-series-ref') return true; + if (seenRefs.has(err.ref)) return false; + seenRefs.add(err.ref); + return true; + }); + + if (parsed.referencedIndices.length === 0) { + dedupedErrors.push({ + code: 'constant-only-expression', + message: 'Expression must reference at least one series (A, B, C, ...)', + }); + } + + return dedupedErrors.length > 0 + ? { ok: false, errors: dedupedErrors } + : parsed; +}; + +/** First character offset of a series-ref letter in the expression. */ +const findRefPosition = (expression: string, ref: string): number => { + const position = expression.indexOf(ref); + return position >= 0 ? position : 0; +}; diff --git a/packages/common-utils/src/types.ts b/packages/common-utils/src/types.ts index a937283300..bb211a7d7f 100644 --- a/packages/common-utils/src/types.ts +++ b/packages/common-utils/src/types.ts @@ -415,6 +415,32 @@ export const DerivedColumnSchema = z.intersection( }), ); export const SelectListSchema = z.array(DerivedColumnSchema).or(z.string()); + +/** + * A derived series computed from other series in the chart's `select` list + * via a letter-ref arithmetic expression (metric charts only for v1). + * + * `expression` references select entries by position using single uppercase + * letters — `A` is `select[0]`, `B` is `select[1]`, ... (Grafana/Datadog + * convention; stable against alias edits). Example: `A / (A + B + C) * 100`. + * + * The grammar is arithmetic only (`+ - * /`, parentheses, numeric constants) + * and is parsed to a validated AST by `core/formula.ts` before any query + * rendering — expressions are never passed through as raw SQL. + */ +export const MetricFormulaSchema = z.object({ + // Length-capped at the validation boundary so persisted configs can never + // carry an expression large enough to matter for the recursive parser + // (mirrors MAX_FORMULA_EXPRESSION_LENGTH in `core/formula.ts`, which + // guards the parser itself for arbitrary caller input). + expression: z.string().max(1024), + // Legend / column name for the formula series. Falls back to the raw + // expression when unset (mirrors DerivedColumnSchema.alias semantics). + alias: z.string().optional(), + numberFormat: NumberFormatSchema.optional(), +}); +export type MetricFormula = z.infer; + export const SortSpecificationSchema = z.intersection( RootValueExpressionSchema, z.object({ @@ -1319,6 +1345,19 @@ export const _ChartConfigSchema = SharedChartSettingsSchema.extend({ // Only meaningful for grouped ratios (seriesReturnType === 'ratio' + a Group // By). Defaults to per-group when unset. See RatioModeSchema. ratioMode: RatioModeSchema.optional(), + // Derived series computed from the `select` entries via letter-ref + // arithmetic expressions (`A` = select[0], ...). Metric sources only for + // v1; enforced by the editor and query renderer rather than this + // (deliberately permissive) schema, mirroring how `seriesReturnType: + // 'ratio'` is gated. Formulas are additive — the ratio toggle is untouched + // and the two are mutually exclusive in the editor. See + // MetricFormulaSchema and `core/formula.ts` for the grammar/validation. + formulas: z.array(MetricFormulaSchema).optional(), + // Whether the raw operand series referenced by `formulas` are emitted + // alongside the formula column(s) (true / unset) or only the formula + // column(s) are returned (false). Only meaningful when `formulas` is + // non-empty; ignored otherwise. + showOperandSeries: z.boolean().optional(), // Used to preserve original table select string when chart overrides it (e.g., histograms) eventTableSelect: z.string().optional(), source: z.string().optional(),