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
- 🧮 Arithmetic operations:
+,-,*,/with Decimal precision - 🔍 Comparisons:
=,!=,<,>,<=,>= - 🔗 Logical operators:
AND,ORwith same-operator chaining - 🔀 Conditional logic:
CASE WHEN ... THEN ... ELSE ... END - 📦 Built-in functions:
ROUND(),COALESCE(),UPPER(),LOWER(),ABS(),CONCAT() - ✅ Validation: Parse expressions before storing to catch syntax errors early
- 🎯 Unambiguous syntax: Parentheses required for complex expressions
- 💬 Comments: SQL-style line comments (
-- ...,# ...) and block comments (/* ... */)
Installation
Add dsqlex to your list of dependencies in mix.exs:
def deps do
[
{:dsqlex, "~> 0.1.0"}
]
endQuick 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 | SELECT a + b |
- | Subtraction | SELECT a - b |
* | Multiplication | SELECT a * b |
/ | Division | 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
ENDFunctions
| Function | Description | Example |
|---|---|---|
ROUND(value, precision) | Round to decimal places | 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 | ABS(x) |
CONCAT(a, b, ...) | Concatenate strings | CONCAT(a, ' ', b) |
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/ORThis 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- Lexer (
Dsqlex.Lexer): Converts string to tokens - Parser (
Dsqlex.Parser): Converts tokens to AST - Evaluator (
Dsqlex.Evaluator): Walks AST with context to produce result
Testing
mix test
The test suite includes 162 tests covering:
- Lexer token generation
- Parser AST construction
- Evaluator computations
- End-to-end integration tests
- Error handling at every stage
License
MIT