# Predicator
[](https://github.com/riddler/predicator-ex/actions/workflows/ci.yml)
[](https://codecov.io/gh/riddler/predicator-ex)
[](https://hex.pm/packages/predicator)
[](https://hexdocs.pm/predicator/)
A secure, non-evaluative condition engine for processing end-user boolean predicates in Elixir.
Predicator allows you to safely evaluate user-defined expressions without the security risks of dynamic code execution.
## Features
- 🔒 **Secure**: No `eval()` or dynamic code execution - safe for end-user input
- 🎯 **Simple**: Clean, intuitive expression syntax (`score > 85`, `name = "John"`)
- 🚀 **Fast**: Compiled expressions execute efficiently with minimal overhead
- 🛡️ **Type Safe**: Built with comprehensive specs and rigorous testing
- 🎨 **Flexible**: Support for literals, identifiers, comparisons, and parentheses
- 📊 **Observable**: Detailed error reporting with line/column information
- 🔄 **Reversible**: Convert AST back to string expressions with formatting options
- 📅 **Date Support**: Native date and datetime literals with ISO 8601 format
- 📋 **Lists**: List literals with membership operations (`in`, `contains`)
- 🧠 **Smart Logic**: Logical operators with proper precedence (`AND`, `OR`, `NOT`)
- 🔧 **Functions**: Built-in functions for string, numeric, and date operations
## Quick Start
```elixir
# Basic evaluation
iex> Predicator.evaluate!("score > 85", %{"score" => 92})
true
# String comparisons
iex> Predicator.evaluate!("name = \"Alice\"", %{"name" => "Alice"})
true
# Date and datetime literals
iex> Predicator.evaluate!("#2024-01-15# > #2024-01-10#", %{})
true
iex> Predicator.evaluate!("created_at < #2024-01-15T10:30:00Z#", %{"created_at" => ~U[2024-01-10 09:00:00Z]})
true
# List literals and membership
iex> Predicator.evaluate!("role in [\"admin\", \"manager\"]", %{"role" => "admin"})
true
iex> Predicator.evaluate!("[1, 2, 3] contains 2", %{})
true
# Logical operators with proper precedence
iex> Predicator.evaluate!("score > 85 AND age >= 18", %{"score" => 92, "age" => 25})
true
iex> Predicator.evaluate!("role = \"admin\" OR role = \"manager\"", %{"role" => "admin"})
true
iex> Predicator.evaluate!("NOT expired AND active", %{"expired" => false, "active" => true})
true
# Complex expressions with parentheses
iex> Predicator.evaluate!("(score > 85 OR admin) AND active", %{"score" => 80, "admin" => true, "active" => true})
true
# Built-in functions
iex> Predicator.evaluate!("len(name) > 3", %{"name" => "Alice"})
true
iex> Predicator.evaluate!("upper(role) = \"ADMIN\"", %{"role" => "admin"})
true
iex> Predicator.evaluate!("year(created_at) = 2024", %{"created_at" => ~D[2024-03-15]})
true
# Compile once, evaluate many times for performance
iex> {:ok, instructions} = Predicator.compile("score > threshold AND active")
iex> Predicator.evaluate!(instructions, %{"score" => 95, "threshold" => 80, "active" => true})
true
# Using evaluate/2 (returns {:ok, result} or {:error, message})
iex> Predicator.evaluate("score > 85", %{"score" => 92})
{:ok, true}
iex> Predicator.evaluate("invalid >> syntax", %{})
{:error, "Expected number, string, boolean, date, datetime, identifier, function call, list, or '(' but found '>' at line 1, column 10"}
# Using evaluate/1 for expressions without context (strings or instruction lists)
iex> Predicator.evaluate("#2024-01-15# > #2024-01-10#")
{:ok, true}
iex> Predicator.evaluate([["lit", 42]])
{:ok, 42}
# Round-trip: parse and decompile expressions
iex> {:ok, ast} = Predicator.parse("score > 85 AND #2024-01-15# in dates")
iex> Predicator.decompile(ast)
"score > 85 AND #2024-01-15# IN dates"
```
## Supported Operations
### Comparison Operators
| Operator | Description | Example |
|----------|-------------|---------|
| `>` | Greater than | `score > 85`, `#2024-01-15# > #2024-01-10#` |
| `<` | Less than | `age < 30`, `created_at < #2024-01-15T10:00:00Z#` |
| `>=` | Greater than or equal | `points >= 100` |
| `<=` | Less than or equal | `count <= 5` |
| `=` | Equal | `status = "active"`, `date = #2024-01-15#` |
| `!=` | Not equal | `role != "guest"` |
### Logical Operators
| Operator | Description | Example |
|----------|-------------|---------|
| `AND` | Logical AND (case-insensitive) | `score > 85 AND age >= 18` |
| `OR` | Logical OR (case-insensitive) | `role = "admin" OR role = "manager"` |
| `NOT` | Logical NOT (case-insensitive) | `NOT expired` |
### Membership Operators
| Operator | Description | Example |
|----------|-------------|---------|
| `in` | Element in collection | `role in ["admin", "manager"]` |
| `contains` | Collection contains element | `[1, 2, 3] contains 2` |
### Built-in Functions
#### String Functions
| Function | Description | Example |
|----------|-------------|---------|
| `len(string)` | String length | `len(name) > 3` |
| `upper(string)` | Convert to uppercase | `upper(role) = "ADMIN"` |
| `lower(string)` | Convert to lowercase | `lower(name) = "alice"` |
| `trim(string)` | Remove whitespace | `len(trim(input)) > 0` |
#### Numeric Functions
| Function | Description | Example |
|----------|-------------|---------|
| `abs(number)` | Absolute value | `abs(balance) < 100` |
| `max(a, b)` | Maximum of two numbers | `max(score1, score2) > 85` |
| `min(a, b)` | Minimum of two numbers | `min(age, 65) >= 18` |
#### Date Functions
| Function | Description | Example |
|----------|-------------|---------|
| `year(date)` | Extract year | `year(created_at) = 2024` |
| `month(date)` | Extract month | `month(birthday) = 12` |
| `day(date)` | Extract day | `day(deadline) <= 15` |
## Data Types
- **Numbers**: `42`, `-17` (integers)
- **Strings**: `"hello"`, `"world"` (double-quoted with escape sequences)
- **Booleans**: `true`, `false` (or plain identifiers like `active`, `expired`)
- **Dates**: `#2024-01-15#` (ISO 8601 date format)
- **DateTimes**: `#2024-01-15T10:30:00Z#` (ISO 8601 datetime format with timezone)
- **Lists**: `[1, 2, 3]`, `["admin", "manager"]` (homogeneous collections)
- **Identifiers**: `score`, `user_name`, `is_active` (variable references)
## Architecture
Predicator uses a multi-stage compilation pipeline:
```
Expression String → Lexer → Parser → Compiler → Evaluator
↓ ↓ ↓ ↓ ↓
"score > 85 OR admin" → Tokens → AST → Instructions → Result
```
### Grammar
```ebnf
expression → logical_or
logical_or → logical_and ( ("OR" | "or") logical_and )*
logical_and → logical_not ( ("AND" | "and") logical_not )*
logical_not → ("NOT" | "not") logical_not | comparison
comparison → primary ( ( ">" | "<" | ">=" | "<=" | "=" | "!=" | "in" | "contains" ) primary )?
primary → NUMBER | STRING | BOOLEAN | DATE | DATETIME | IDENTIFIER | function_call | list | "(" expression ")"
function_call → FUNCTION_NAME "(" ( expression ( "," expression )* )? ")"
list → "[" ( expression ( "," expression )* )? "]"
```
### Core Components
- **Lexer** (`Predicator.Lexer`): Tokenizes input with position tracking
- **Parser** (`Predicator.Parser`): Builds Abstract Syntax Tree with error reporting
- **Compiler** (`Predicator.Compiler`): Converts AST to executable instructions
- **Evaluator** (`Predicator.Evaluator`): Executes instructions against data
- **Visitors**: AST transformation modules
- **StringVisitor** (`Predicator.Visitors.StringVisitor`): Converts AST back to expressions
- **InstructionsVisitor** (`Predicator.Visitors.InstructionsVisitor`): Converts AST to instructions
- **Functions**: Function system components
- **SystemFunctions** (`Predicator.Functions.SystemFunctions`): Built-in system functions
- **Registry** (`Predicator.Functions.Registry`): Function registration and dispatch
## Error Handling
Predicator provides detailed error information with exact positioning:
```elixir
iex> Predicator.evaluate("score >> 85", %{})
{:error, "Unexpected character '>' at line 1, column 8"}
iex> Predicator.evaluate("score AND", %{})
{:error, "Expected number, string, boolean, date, datetime, identifier, function call, list, or '(' but found end of input at line 1, column 1"}
```
## Advanced Usage
### Custom Function Registration
You can register your own custom functions for use in expressions:
```elixir
# Register a simple function
Predicator.register_function("double", 1, fn [n], _context ->
{:ok, n * 2}
end)
# Use in expressions
iex> Predicator.evaluate("double(score) > 100", %{"score" => 60})
{:ok, true}
# Context-aware function
Predicator.register_function("user_role", 0, fn [], context ->
{:ok, Map.get(context, "current_user_role", "guest")}
end)
iex> Predicator.evaluate("user_role() = \"admin\"", %{"current_user_role" => "admin"})
{:ok, true}
# Function with error handling
Predicator.register_function("divide", 2, fn [a, b], _context ->
if b == 0 do
{:error, "Division by zero"}
else
{:ok, a / b}
end
end)
```
### String Formatting Options
The StringVisitor supports multiple formatting modes:
```elixir
# Compact formatting (no spaces)
iex> Predicator.decompile(ast, spacing: :compact)
"score>85"
# Verbose formatting (extra spaces)
iex> Predicator.decompile(ast, spacing: :verbose)
"score > 85"
# Explicit parentheses
iex> Predicator.decompile(ast, parentheses: :explicit)
"(score > 85)"
```
## Development
### Setup
```bash
mix deps.get
mix test
```
### Quality Checks
```bash
# Run all quality checks
mix quality
# Individual checks
mix format # Format code
mix credo --strict # Linting
mix coveralls # Test coverage
mix dialyzer # Type checking
```
### Test Coverage
Current coverage: **92.6%** overall with comprehensive testing across all components:
- **Lexer**: 100% (including date/datetime tokenization)
- **Types**: 100% (including date type checking)
- **Evaluator**: 90.1% (all operations and error conditions)
- **Parser**: 86.8% (complex expressions and edge cases)
- **StringVisitor**: 94.8% (formatting and decompilation)
```bash
mix test.coverage.html # Generate HTML coverage report
```
## Installation
Add `predicator` to your list of dependencies in `mix.exs`:
```elixir
def deps do
[
{:predicator, "~> 1.0.0"}
]
end
```
## Documentation
Full documentation is available at [HexDocs](https://hexdocs.pm/predicator).