Skip to content

Latest commit

 

History

History
838 lines (662 loc) · 23.5 KB

File metadata and controls

838 lines (662 loc) · 23.5 KB

Formula Specification v1.2

This document describes the formula syntax and features supported by @revisium/formula.

Syntax Overview

Formulas are expressions that reference data fields and perform calculations. The parser analyzes formulas to extract dependencies and detect which features are used.

Operators

Arithmetic Operators

Operator Description
+ Addition or string concatenation
- Subtraction
* Multiplication
/ Division
% Modulo (remainder)

Comparison Operators

Operator Description
== Equal
!= Not equal
> Greater than
< Less than
>= Greater or equal
<= Less or equal

Logical Operators

Operator Description
&& Logical AND
|| Logical OR
! Logical NOT

Other Syntax

  • Parentheses: (a + b) * c
  • Unary minus: -value, a + -b

Built-in Functions

String Functions

Function Description Signature Returns
concat Concatenate multiple values into a single string concat(value1, value2, ...) string
upper Convert string to uppercase upper(text) string
lower Convert string to lowercase lower(text) string
trim Remove whitespace from both ends of a string trim(text) string
left Extract characters from the beginning of a string left(text, count) string
right Extract characters from the end of a string right(text, count) string
replace Replace first occurrence of a substring replace(text, search, replacement) string
join Join array elements into a string join(array, separator?) string

Numeric Functions

Function Description Signature Returns
round Round a number to specified decimal places round(number, decimals?) number
floor Round down to the nearest integer floor(number) number
ceil Round up to the nearest integer ceil(number) number
abs Get the absolute value abs(number) number
sqrt Calculate the square root sqrt(number) number
pow Raise a number to a power pow(base, exponent) number
min Get the minimum of multiple values min(value1, value2, ...) number
max Get the maximum of multiple values max(value1, value2, ...) number
log Calculate the natural logarithm log(number) number
log10 Calculate the base-10 logarithm log10(number) number
exp Calculate e raised to a power exp(number) number
sign Get the sign of a number (-1, 0, or 1) sign(number) number
length Get the length of a string or array length(value) number

Boolean Functions

Function Description Signature Returns
and Logical AND of two values and(a, b) boolean
or Logical OR of two values or(a, b) boolean
not Logical NOT of a value not(value) boolean
contains Check if a string contains a substring contains(text, search) boolean
startswith Check if a string starts with a prefix startswith(text, prefix) boolean
endswith Check if a string ends with a suffix endswith(text, suffix) boolean
isnull Check if a value is null or undefined isnull(value) boolean
includes Check if an array contains a value includes(array, value) boolean

Array Functions

Function Description Signature Returns
sum Calculate the sum of array elements. Supports wildcard property access to sum nested values. sum(array) number
avg Calculate the average of array elements. Supports wildcard property access. avg(array) number
count Get the number of elements in an array count(array) number
first Get the first element of an array first(array) any
last Get the last element of an array last(array) any

Conversion Functions

Function Description Signature Returns
tostring Convert a value to string tostring(value) string
tonumber Convert a value to number tonumber(value) number
toboolean Convert a value to boolean toboolean(value) boolean

Conditional Functions

Function Description Signature Returns
if Return one of two values based on a condition if(condition, valueIfTrue, valueIfFalse) any
coalesce Return the first non-null value coalesce(value1, value2, ...) any

Field References

  • Simple field: fieldName (e.g., price, quantity)
  • Nested path: object.property (e.g., stats.damage)
  • Array index: array[0] or array[-1] for last element
  • Bracket notation: ["field-name"] for field names containing hyphens
    • Required when field name contains hyphen (-)
    • Without brackets: field-name is parsed as "field minus name"
    • With brackets: ["field-name"] references the field correctly
  • Combined: items[0].price, user.addresses[-1].city, obj["field-name"].value

Supported Features

v1.0 Features

Simple Refs

Reference top-level fields by name.

price
quantity
baseDamage

Feature: simple_refs

Dependencies extracted: ["price"], ["quantity"], ["baseDamage"]

Arithmetic

Basic math operations (+, -, *, /).

price * 1.1
a + b - c
quantity * price

Feature: arithmetic

Comparison

Compare values (>, <, >=, <=, ==, !=).

price > 100
x == 10
quantity >= 5

Feature: comparison

Function Named Fields

Fields can have the same name as built-in functions (max, min, sum, etc.). Built-in functions are always checked first when a function call is made.

max(max, 0)
min(min, 100)
max(max - field.min, 0)
round(round * 2)

Feature: function_named_fields

v1.1 Features

Features below require formula version 1.1 and set minVersion: "1.1".

Nested Path

Access nested object properties using dot notation.

stats.damage
user.profile.name
item.metadata.category

Feature: nested_path

Dependencies extracted: ["stats.damage"]

Array Index

Access array elements by numeric index. Negative indices access from the end.

items[0].price
inventory[1].quantity
items[-1].name  // last element
items[-2].price // second to last

Feature: array_index

Dependencies extracted: ["items[0].price"], ["items[-1].name"]

Array Wildcard Property

Access properties across all array elements using [*] wildcard. Property access after wildcard maps over all elements. Multiple wildcards flatten nested arrays..

items[*].price                    // [10, 20, 30] - map property
sum(items[*].price)               // 60 - sum mapped values
avg(items[*].rating)              // average of all ratings
values[*].nested.value            // deeply nested property access
orders[*].items                   // [[1,2], [3,4]] - array of arrays
orders[*].items[*]                // [1,2,3,4] - flattened
sum(orders[*].items[*].amount)    // sum all nested amounts

Feature: array_wildcard_property

Dependencies extracted: ["items[].price"], ["orders[].items[*].amount"]

Root Path

Absolute path reference starting with /. Always resolves from root data, even inside array item formulas.

/taxRate
/config.tax
price * (1 + /taxRate)
price * /config.multiplier

Feature: root_path

Dependencies extracted: ["/taxRate"], ["/config.tax"]

Relative Path

Relative path reference starting with ../. Each ../ goes up one level in the path hierarchy. Works with nested objects, arrays, and combinations. Supports accessing nested properties after the relative prefix (e.g., ../config.value).

../discount
../../rootRate
../config.multiplier
price * (1 - ../discount)
price * ../../globalRate
price * ../settings.tax.rate

Feature: relative_path

Dependencies extracted: ["../discount"], ["../../rootRate"], ["../config.multiplier"]

Bracket Notation

Access fields containing hyphens using bracket notation with quotes. Required because "field-name" would be parsed as "field minus name" (subtraction). Works like JavaScript object["key"] syntax..

["field-name"]          // Without brackets: field - name (subtraction!)
['field-name']          // Single quotes also work
["field-one"]["field-two"]
obj["field-name"].value
["items-list"][0]["val"]
["price-new"] * 2

Feature: bracket_notation

Dependencies extracted: ["field-name"], ['field-name']

v1.2 Features

Features below require formula version 1.2 and set minVersion: "1.2".

Context Token

Array context tokens provide information about current position and neighboring elements when evaluating formulas inside arrays. # prefix for scalar metadata (number/boolean), @ prefix for object references..

#index                  // Current array index (0-based)
#length                 // Array length
#first                  // true if first element
#last                   // true if last element
@prev                   // Previous element (null if first)
@next                   // Next element (null if last)
#parent.index           // Index in parent array (nested arrays)
#parent.length          // Length of parent array
#root.index             // Index in topmost array
@root.prev              // Previous element in topmost array
if(#first, value, @prev.total + value)  // Running total
concat(#parent.index + 1, ".", #index + 1)  // "1.1", "1.2" numbering

Feature: context_token

Version Detection

The parser automatically detects the minimum required version:

Feature Min Version
Simple refs, arithmetic, comparisons 1.0
Function-named fields (max(max, 0)) 1.0
Nested paths (a.b) 1.1
Array index ([0], [-1]) 1.1
Array wildcard property (items[*].price) 1.1
Absolute paths (/field) 1.1
Relative paths (../field) 1.1
Bracket notation (["field-name"]) 1.1
Context tokens (#index, @prev) 1.2

Parse Result

interface ParseResult {
  ast: ASTNode;           // Abstract syntax tree
  dependencies: string[]; // List of field dependencies
  features: string[];     // List of detected features
  minVersion: string;     // Minimum required version ("1.0" or "1.1")
}

API Examples

Simple Expression (v1.0)

parseExpression('price * 1.1')
// {
//   minVersion: "1.0",
//   features: [],
//   dependencies: ["price"]
// }

Nested Path (v1.1)

parseExpression('stats.damage * multiplier')
// {
//   minVersion: "1.1",
//   features: ["nested_path"],
//   dependencies: ["stats.damage", "multiplier"]
// }

Array Access (v1.1)

parseExpression('items[0].price + items[1].price')
// {
//   minVersion: "1.1",
//   features: ["array_index", "nested_path"],
//   dependencies: ["items[0].price", "items[1].price"]
// }

Function-named fields

// Built-in functions take precedence in function calls
evaluate('max(max, 0)', { max: 10 })
// 10 (max() function, then max field)

evaluate('max(max - field.min, 0)', { max: 100, field: { min: 20 } })
// 80

evaluate('round(round * 2)', { round: 3.7 })
// 7

// Field named "sum" doesn't conflict with sum() function
evaluate('sum(values) + sum', { values: [1, 2, 3], sum: 10 })
// 16

Evaluate with context (array items)

// Absolute path: /field always resolves from root
evaluateWithContext('price * (1 + /taxRate)', {
  rootData: { taxRate: 0.1, items: [{ price: 100 }] },
  itemData: { price: 100 },
  currentPath: 'items[0]'
})
// 110

// Nested absolute path
evaluateWithContext('price * /config.multiplier', {
  rootData: { config: { multiplier: 1.5 }, items: [] },
  itemData: { price: 100 },
  currentPath: 'items[0]'
})
// 150

// Relative path: ../field resolves from parent (root)
evaluateWithContext('price * (1 - ../discount)', {
  rootData: { discount: 0.2, items: [] },
  itemData: { price: 100 },
  currentPath: 'items[0]'
})
// 80

// itemData takes precedence over rootData for same field
evaluateWithContext('value + 10', {
  rootData: { value: 100 },
  itemData: { value: 50 },
  currentPath: 'items[0]'
})
// 60

Relative paths - path resolution

// Path structure explanation:
// currentPath splits by "." (dots), keeping array indices attached to field names
// "items[0]" = 1 segment
// "items[0].inner" = 2 segments: ["items[0]", "inner"]
// "container.items[0]" = 2 segments: ["container", "items[0]"]

// Single ../ from array item -> goes to root
// currentPath: "items[0]" (1 segment)
// ../ goes up 1 level -> root
evaluateWithContext('price * ../discount', {
  rootData: { discount: 0.2, items: [{ price: 100 }] },
  itemData: { price: 100 },
  currentPath: 'items[0]'
})
// Resolves ../discount to root.discount = 0.2
// Result: 100 * 0.2 = 20

// Single ../ from nested object in array -> goes to array item
// currentPath: "items[0].inner" (2 segments)
// ../ goes up 1 level -> "items[0]"
evaluateWithContext('price * ../itemMultiplier', {
  rootData: { items: [{ itemMultiplier: 3, inner: { price: 10 } }] },
  itemData: { price: 10 },
  currentPath: 'items[0].inner'
})
// Resolves ../itemMultiplier to items[0].itemMultiplier = 3
// Result: 10 * 3 = 30

// Double ../../ from nested object in array -> goes to root
// currentPath: "items[0].inner" (2 segments)
// ../../ goes up 2 levels -> root
evaluateWithContext('price * ../../rootRate', {
  rootData: { rootRate: 2, items: [{ inner: { price: 5 } }] },
  itemData: { price: 5 },
  currentPath: 'items[0].inner'
})
// Resolves ../../rootRate to root.rootRate = 2
// Result: 5 * 2 = 10

Relative paths - nested arrays

// Array inside nested object
// currentPath: "container.items[0]" (2 segments: ["container", "items[0]"])
// ../ goes up 1 level -> "container"
evaluateWithContext('price * ../containerRate', {
  rootData: {
    container: {
      containerRate: 4,
      items: [{ price: 5 }]
    }
  },
  itemData: { price: 5 },
  currentPath: 'container.items[0]'
})
// Resolves ../containerRate to container.containerRate = 4
// Result: 5 * 4 = 20

// ../../ from array inside object -> goes to root
// currentPath: "container.items[0]" (2 segments)
// ../../ goes up 2 levels -> root
evaluateWithContext('price * ../../rootVal', {
  rootData: {
    rootVal: 6,
    container: { items: [{ price: 5 }] }
  },
  itemData: { price: 5 },
  currentPath: 'container.items[0]'
})
// Resolves ../../rootVal to root.rootVal = 6
// Result: 5 * 6 = 30

// Nested arrays: items[].subItems[]
// currentPath: "items[0].subItems[0]" (2 segments: ["items[0]", "subItems[0]"])
// ../ goes up 1 level -> "items[0]"
evaluateWithContext('qty * ../itemPrice', {
  rootData: {
    items: [{ itemPrice: 10, subItems: [{ qty: 3 }] }]
  },
  itemData: { qty: 3 },
  currentPath: 'items[0].subItems[0]'
})
// Resolves ../itemPrice to items[0].itemPrice = 10
// Result: 3 * 10 = 30

Relative paths - accessing nested properties

// ../sibling.nested accesses a sibling with nested property
// currentPath: "items[0].products[0]" (2 segments)
// ../ goes to "items[0]", then accesses .config.discount
evaluateWithContext('price * ../config.discount', {
  rootData: {
    items: [{
      config: { discount: 0.9 },
      products: [{ price: 100 }]
    }]
  },
  itemData: { price: 100 },
  currentPath: 'items[0].products[0]'
})
// Resolves ../config.discount to items[0].config.discount = 0.9
// Result: 100 * 0.9 = 90

// Deep nested: ../../settings.tax.rate
evaluateWithContext('amount * ../../settings.tax.rate', {
  rootData: {
    settings: { tax: { rate: 0.1 } },
    orders: [{ items: [{ amount: 200 }] }]
  },
  itemData: { amount: 200 },
  currentPath: 'orders[0].items[0]'
})
// Resolves ../../settings.tax.rate to root.settings.tax.rate = 0.1
// Result: 200 * 0.1 = 20

Relative paths - complex nesting

// Array inside object inside array
// Structure: items[].container.subItems[]
// currentPath: "items[0].container.subItems[0]" (3 segments)
evaluateWithContext('val * ../containerMultiplier', {
  rootData: {
    items: [{
      container: {
        containerMultiplier: 4,
        subItems: [{ val: 3 }]
      }
    }]
  },
  itemData: { val: 3 },
  currentPath: 'items[0].container.subItems[0]'
})
// ../ goes to "items[0].container"
// Resolves ../containerMultiplier to items[0].container.containerMultiplier = 4
// Result: 3 * 4 = 12

// ../../ from same structure -> goes to array item
evaluateWithContext('val * ../../itemRate', {
  rootData: {
    items: [{
      itemRate: 5,
      container: { subItems: [{ val: 2 }] }
    }]
  },
  itemData: { val: 2 },
  currentPath: 'items[0].container.subItems[0]'
})
// ../../ goes to "items[0]"
// Resolves ../../itemRate to items[0].itemRate = 5
// Result: 2 * 5 = 10

// ../../../ from same structure -> goes to root
evaluateWithContext('val * ../../../rootFactor', {
  rootData: {
    rootFactor: 3,
    items: [{
      container: { subItems: [{ val: 7 }] }
    }]
  },
  itemData: { val: 7 },
  currentPath: 'items[0].container.subItems[0]'
})
// ../../../ goes to root
// Resolves ../../../rootFactor to root.rootFactor = 3
// Result: 7 * 3 = 21

Array context tokens - basic

// arrayContext provides position info for array item formulas
const arrayContext = {
  levels: [{
    index: 2,      // current position
    length: 5,     // array length
    prev: { value: 20 },  // previous element
    next: { value: 40 },  // next element
  }]
};

evaluateWithContext('#index', { rootData: {}, arrayContext })
// 2

evaluateWithContext('#length', { rootData: {}, arrayContext })
// 5

evaluateWithContext('#first', { rootData: {}, arrayContext })
// false (index !== 0)

evaluateWithContext('#last', { rootData: {}, arrayContext })
// false (index !== length - 1)

evaluateWithContext('@prev.value', { rootData: {}, arrayContext })
// 20

evaluateWithContext('@next.value', { rootData: {}, arrayContext })
// 40

// At first element, @prev is null
evaluateWithContext('@prev', {
  rootData: {},
  arrayContext: { levels: [{ index: 0, length: 3, prev: null, next: {} }] }
})
// null

Array context tokens - nested arrays

// For nested arrays like orders[].items[]:
// levels[0] = innermost (items), levels[1] = parent (orders)
const arrayContext = {
  levels: [
    { index: 1, length: 3, prev: {}, next: {} },  // items[1]
    { index: 2, length: 5, prev: {}, next: {} },  // orders[2]
  ]
};

evaluateWithContext('#index', { rootData: {}, arrayContext })
// 1 (current item index)

evaluateWithContext('#parent.index', { rootData: {}, arrayContext })
// 2 (parent order index)

evaluateWithContext('#parent.length', { rootData: {}, arrayContext })
// 5 (number of orders)

// #root.* is shortcut for topmost array (same as #parent.* for 2 levels)
evaluateWithContext('#root.index', { rootData: {}, arrayContext })
// 2

// For 3+ levels, #root always points to outermost array
const threeLevel = {
  levels: [
    { index: 0, length: 2, prev: null, next: {} },  // innermost
    { index: 1, length: 3, prev: {}, next: {} },    // middle
    { index: 2, length: 4, prev: {}, next: null },  // outermost (root)
  ]
};

evaluateWithContext('#parent.parent.index', { rootData: {}, arrayContext: threeLevel })
// 2 (outermost)

evaluateWithContext('#root.index', { rootData: {}, arrayContext: threeLevel })
// 2 (same as #parent.parent.index)

Array context tokens - practical examples

// Running total pattern (like Excel)
// rows[].runningTotal = if(#first, value, @prev.runningTotal + value)
const rows = [
  { value: 10 },
  { value: 20 },
  { value: 15 },
];

// For rows[2]:
evaluateWithContext('if(#first, value, @prev.value + value)', {
  rootData: {},
  itemData: { value: 15 },
  arrayContext: {
    levels: [{
      index: 2,
      length: 3,
      prev: { value: 20 },  // Note: use non-computed field from prev
      next: null,
    }]
  }
})
// 35 (20 + 15)

// Nested numbering like "1.1", "1.2", "2.1"
// sections[].questions[].number
evaluateWithContext('concat(#parent.index + 1, ".", #index + 1)', {
  rootData: {},
  arrayContext: {
    levels: [
      { index: 1, length: 3, prev: {}, next: {} },  // question index
      { index: 0, length: 2, prev: null, next: {} }, // section index
    ]
  }
})
// "1.2"

// Delta from previous
// measurements[].delta = if(#first, 0, value - @prev.value)
evaluateWithContext('if(#first, 0, value - @prev.value)', {
  rootData: {},
  itemData: { value: 105 },
  arrayContext: {
    levels: [{
      index: 1,
      length: 3,
      prev: { value: 100 },
      next: { value: 102 },
    }]
  }
})
// 5

Evaluation

The evaluate function executes a formula with a given context:

evaluate('price * 1.1', { price: 100 })
// 110

evaluate('stats.damage', { stats: { damage: 50 } })
// 50

evaluate('items[0].price', { items: [{ price: 10 }] })
// 10

evaluate('price > 100', { price: 150 })
// true

evaluate('a + b * c', { a: 1, b: 2, c: 3 })
// 7

AST Utilities

serializeAst

Convert an AST back to a formula string. Useful for debugging or displaying parsed formulas.

serializeAst(ast: ASTNode): string
import { parseFormula, serializeAst } from '@revisium/formula';

const { ast } = parseFormula('price * (1 + taxRate)');
serializeAst(ast)
// "price * (1 + taxRate)"

// After modifying AST nodes, serialize back to string
const { ast: ast2 } = parseFormula('a + b');
serializeAst(ast2)
// "a + b"

replaceDependencies

Replace field references in an AST with new names. Useful for renaming fields or migrating formulas.

replaceDependencies(ast: ASTNode, replacements: Record<string, string>): ASTNode
import { parseFormula, replaceDependencies, serializeAst } from '@revisium/formula';

// Rename a field in a formula
const { ast } = parseFormula('oldPrice * quantity');
const newAst = replaceDependencies(ast, { oldPrice: 'price' });
serializeAst(newAst)
// "price * quantity"

// Rename multiple fields
const { ast: ast2 } = parseFormula('a + b * c');
const newAst2 = replaceDependencies(ast2, { a: 'x', b: 'y', c: 'z' });
serializeAst(newAst2)
// "x + y * z"

// Works with nested paths
const { ast: ast3 } = parseFormula('stats.damage * multiplier');
const newAst3 = replaceDependencies(ast3, { 'stats.damage': 'stats.power' });
serializeAst(newAst3)
// "stats.power * multiplier"

Schema Usage

Formula fields use the following structure:

{ "x-formula": { "version": 1, "expression": "..." }, "readOnly": true }

Supported field types: string, number, boolean

Rules:

  • Add x-formula to string, number, or boolean field schema
  • readOnly: true is REQUIRED for fields with x-formula
  • Expression must reference existing fields in the same table
  • Circular dependencies are not allowed (a references b, b references a)

Expression Examples

Expression Description Result Type
price * quantity Calculate total from price and quantity number
firstName + " " + lastName Concatenate strings with space string
quantity > 0 Check if in stock boolean
if(stock > 0, "Available", "Out of Stock") Conditional text based on stock string
price * (1 + taxRate) Price with tax number
items[0].price + items[1].price Sum first two item prices (v1.1) number