Skip to content

Latest commit

 

History

History
323 lines (245 loc) · 10.8 KB

File metadata and controls

323 lines (245 loc) · 10.8 KB

Predicator

CI codecov Hex.pm Version Hex Docs

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

# 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 → Instructions → Evaluator
     ↓              ↓        ↓         ↓           ↓            ↓
"score > 85 AND #2024-01-15# in dates" → Tokens → AST → Instructions → Result

Grammar

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 )?
primaryNUMBER | STRING | BOOLEAN | DATE | DATETIME | IDENTIFIER | function_call | list | "(" expression ")"
function_callFUNCTION_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:

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:

# 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:

# 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)"

Performance

For repeated evaluations with the same expression:

# Compile once
{:ok, instructions} = Predicator.compile("score > threshold")

# Evaluate many times
results = 
  data_list
  |> Enum.map(&Predicator.evaluate(instructions, &1))
  |> Enum.map(fn {:ok, result} -> result end)

Development

Setup

mix deps.get
mix test

Quality Checks

# 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)
mix test.coverage.html  # Generate HTML coverage report

Installation

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

def deps do
  [
    {:predicator, "~> 1.0.0"}
  ]
end

Documentation

Full documentation is available at HexDocs.