DSQLEX

A SQL-like DSL (Domain Specific Language) for evaluating dynamic calculations in Elixir.

DSQLEX allows users to define calculation expressions using familiar SQL syntax, which can be stored, validated, and evaluated at runtime against structured data.

Features

Installation

Add dsqlex to your list of dependencies in mix.exs:

def deps do
  [
    {:dsqlex, "~> 0.1.0"}
  ]
end

Quick Start

# Define your data context
context = %{
  "price" => Decimal.new("500.00"),
  "quantity" => Decimal.new("4"),
  "category" => "B",
  "rate" => Decimal.new("5.00")
}

# Evaluate a simple expression (SELECT is optional)
{:ok, result} = Dsqlex.eval("price / rate", context)
# => {:ok, Decimal.new("100")}

# Evaluate a conditional expression
{:ok, result} = Dsqlex.eval("""
  CASE 
    WHEN category = 'A' THEN quantity 
    WHEN category != 'A' THEN (price / rate) 
  END
""", context)
# => {:ok, Decimal.new("100")}

API

Dsqlex.eval(expression, context)

Evaluates an expression against a context and returns the result. The SELECT keyword is optional.

{:ok, result} = Dsqlex.eval("x * 2", %{"x" => Decimal.new("50")})
# => {:ok, Decimal.new("100")}

{:error, reason} = Dsqlex.eval("unknown_field", %{})
# => {:error, "Unknown field: unknown_field"}

Dsqlex.parse(expression)

Parses an expression and returns the AST without evaluating. Useful for validating expressions before storing them in a database.

{:ok, ast} = Dsqlex.parse("x / y")
# => {:ok, {:select, {:binary_op, :divide, {:identifier, "x"}, {:identifier, "y"}}}}

{:ok, ast} = Dsqlex.parse("1 + 2 + 3")
# => {:ok, {:select, {:binary_op, :plus, {:binary_op, :plus, {:number, "1"}, {:number, "2"}}, {:number, "3"}}}}


{:error, reason} = Dsqlex.parse("1 + 2 * 3")
# => {:error, "Ambiguous expression: mixing +/- and *// requires parentheses"}

Dsqlex.tokenize(expression)

Tokenizes an expression without parsing. Useful for debugging.

{:ok, tokens} = Dsqlex.tokenize("1 + 2")
# => {:ok, [number: "1", operator: :plus, number: "2"]}

Supported Syntax

Literals

Type Examples
Numbers 42, 3.14, 0.5
Strings 'hello', 'world'
Booleans TRUE, FALSE
Null NULL

Comments

Style Description
-- ... SQL line comment, runs to end of line
# ... MySQL-style line comment, runs to end of line
/* ... */ Block comment, may span multiple lines

Comments are stripped at the lexer stage and never reach the parser, so they may appear anywhere whitespace is allowed and may contain any characters (including non-ASCII text) in their bodies.

status_id NOT IN (
  1,  -- pending review
  2,  -- archived – soft-deleted
  3   -- naïve test
)

Arithmetic Operators

Operator Description Example
+ Addition (NULL if any operand is NULL) SELECT a + b
- Subtraction (NULL if any operand is NULL) SELECT a - b
* Multiplication (NULL if any operand is NULL) SELECT a * b
/ Division (NULL if any operand is NULL) SELECT a / b

Comparison Operators

Operator Description Example
= Equal SELECT a = b
!= Not equal SELECT a != b
< Less than SELECT a < b
> Greater than SELECT a > b
<= Less than or equal SELECT a <= b
>= Greater than or equal SELECT a >= b

Logical Operators

Operator Description Example
AND Logical AND SELECT a = 1 AND b = 2
OR Logical OR SELECT a = 1 OR b = 2

Note: You can chain the same operator (a AND b AND c), but mixing AND/OR requires parentheses.

CASE/WHEN

CASE 
  WHEN condition1 THEN result1
  WHEN condition2 THEN result2
  ELSE default_result
END

Functions

Function Description Example
ROUND(value, precision) Round to decimal places (NULL if value or precision is NULL) ROUND(x, 2)
COALESCE(a, b, ...) Return first non-null COALESCE(a, b, 0)
UPPER(string) Convert to uppercase UPPER(x)
LOWER(string) Convert to lowercase LOWER(x)
ABS(number) Absolute value (NULL if value is NULL) ABS(x)
CONCAT(a, b, ...) Concatenate strings CONCAT(a, ' ', b)
LEAST(a, b, ...) Smallest value (NULL if any arg is NULL) LEAST(a, b, 0)
GREATEST(a, b, ...) Largest value (NULL if any arg is NULL) GREATEST(a, b, 0)
SUM(list, expr) Sum of expr over each element (0 for an empty list) SUM(items, amount)
COUNT(list) / COUNT(list, cond) Number of elements / of elements where cond is truthy COUNT(items, amount > 0)
ANY(list, cond) True if cond holds for at least one element (false for an empty list) ANY(items, status = 'open')
ALL(list, cond) True if cond holds for every element (true for an empty list) ALL(items, amount > 0)

List Aggregation

SUM, COUNT, ANY and ALL evaluate their second argument once per element of a list of maps. Bare identifiers read the element; ROOT.<path> reads the context the expression was evaluated against (still the outermost context inside nested aggregations). A nil element result in SUM is an error that names the element index, so return 0 for elements that contribute nothing.

context = %{
  "cutoff_date" => "2024-03-10",
  "items" => [
    %{"amount" => Decimal.new("100"), "shipped_on" => "2024-03-01"},
    %{"amount" => Decimal.new("40"), "shipped_on" => "2024-03-15"}
  ]
}

{:ok, result} = Dsqlex.eval("SUM(items, CASE WHEN shipped_on <= ROOT.cutoff_date THEN amount ELSE 0 END)", context)
# => {:ok, Decimal.new("100")}

By default a dot path through a list (items.amount) sums the element values when they are all numeric and returns the list otherwise. Pass strict_lists: true to Dsqlex.eval/3 to turn that, and any list value used where a scalar is expected, into an error that points at the list functions.

The Parentheses Rule

To eliminate ambiguity and ensure calculation correctness, DSQLEX requires parentheses when mixing operator groups:

# ✅ Valid - single operation
"SELECT a + b"
"SELECT a = 1"

# ✅ Valid - chaining the same operator group
"SELECT a + b + c"        # additive chain, left-associative: (a+b)+c
"SELECT a - b + c"        # additive chain
"SELECT a * b / c"        # multiplicative chain, left-associative: (a*b)/c

# ✅ Valid - parentheses make intent clear
"SELECT (a + b) * c"
"SELECT (a = 1 AND b = 2) OR c = 3"

# ✅ Valid - same logical operator can chain
"SELECT a = 1 AND b = 2 AND c = 3"
"SELECT a = 1 OR b = 2 OR c = 3"

# ❌ Invalid - mixing operator groups requires parentheses
"SELECT a + b * c"        # mixing additive and multiplicative
"SELECT a = 1 AND b = 2 OR c = 3"  # mixing AND/OR

This design choice prioritizes correctness over convenience — mixed-precedence expressions must use parentheses to make the intended order of operations explicit.

Examples

Conditional Selection

expression = """
  CASE 
    WHEN category = 'A' THEN x 
    WHEN category != 'A' THEN (y / z) 
  END
"""

context = %{
  "x" => Decimal.new("100.00"),
  "y" => Decimal.new("500.00"),
  "category" => "B",
  "z" => Decimal.new("5.00")
}

{:ok, result} = Dsqlex.eval(expression, context)
# => {:ok, Decimal.new("100")}

Tiered Calculation

expression = """
  CASE 
    WHEN x > 1000 THEN ROUND(x * 0.02, 2)
    WHEN x > 100 THEN ROUND(x * 0.03, 2)
    ELSE ROUND(x * 0.05, 2)
  END
"""

{:ok, result} = Dsqlex.eval(expression, %{"x" => Decimal.new("500")})
# => {:ok, Decimal.new("15.00")}

Null Handling

expression = "ROUND(COALESCE(x, 0) + y, 2)"

context = %{
  "x" => nil,
  "y" => Decimal.new("99.99")
}

{:ok, result} = Dsqlex.eval(expression, context)
# => {:ok, Decimal.new("99.99")}

Conditional Text

expression = """
  CASE 
    WHEN status = 'active' THEN UPPER(label)
    ELSE 'INACTIVE'
  END
"""

{:ok, result} = Dsqlex.eval(expression, %{"status" => "active", "label" => "hello world"})
# => {:ok, "HELLO WORLD"}

Architecture

DSQLEX uses a classic three-stage pipeline:

┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│   String    │ ──► │   Tokens    │ ──► │     AST     │ ──► Result
│             │     │             │     │             │
│  "SELECT    │     │ [{:keyword, │     │ {:select,   │
│   a + b"    │     │   :select}, │     │  {:binary_  │
│             │     │  ...]       │     │   op, ...}} │
└─────────────┘     └─────────────┘     └─────────────┘
     Lexer              Parser            Evaluator

Testing

mix test

The test suite includes 162 tests covering:

License

MIT