GraphNight Elixir Bindings
High-performance semantic layer for Elixir applications, powered by Rust via Rustler NIFs.
Features
- Native Performance: Core engine runs in Rust, called via NIFs (zero-copy where possible)
- Ecto Integration: Optional helpers for using GraphNight with Ecto
- Full GraphNight API: Models, DataSources, Queries, Dry-run, Multi-stage DAG queries
- Type Safety: Elixir structs mirroring Rust types
- Async: Built on Tokio runtime
- Security: Column masks, row-level filters, query timeouts
Installation
From Hex (when published)
def deps do
[
{:graphnight, "~> 1.0"}
]
end
From GitHub
def deps do
[
{:graphnight, git: "https://github.com/niranjanaryan/graphnight-elixir.git"}
]
end
Then run:
mix deps.get
mix compile
Quick Start
# Initialize the engine (creates ./graphnight_data for YAML storage)
{:ok, engine} = GraphNight.Client.init("./graphnight_data")
# Create a datasource
ds = GraphNight.DataSource.new(
"analytics",
"postgres",
"postgresql://user:pass@localhost/db",
description: "Analytics database",
pool_size: 10
)
{:ok, _ds} = GraphNight.Client.create_datasource(engine, ds)
# Create a model with measures, dimensions, time dimensions
model = %GraphNight.Model{
name: "orders",
datasource: "analytics",
description: "Order transactions",
measures: [
%GraphNight.Measure{formula: "revenue:sum", aggregation: "SUM"},
%GraphNight.Measure{formula: "order_count:count", aggregation: "COUNT"},
%GraphNight.Measure{formula: "avg_order_value:avg", aggregation: "AVG"}
],
dimensions: [
%GraphNight.Dimension{name: "status"},
%GraphNight.Dimension{name: "store_id"},
%GraphNight.Dimension{name: "customer_segment"}
],
time_dimensions: [
%GraphNight.TimeDimension{dimension: "created_at", granularity: "DAY"},
%GraphNight.TimeDimension{dimension: "created_at", granularity: "MONTH"}
],
joins: []
}
{:ok, _model} = GraphNight.Client.create_model(engine, model)
# Execute a query
query = GraphNight.Client.build_query(
name: "orders",
measures: [GraphNight.Measure.new("revenue:sum")],
dimensions: [GraphNight.Dimension.new("status")],
filters: [GraphNight.Filter.eq("status", "completed")],
order: [GraphNight.OrderBy.desc("revenue:sum")],
limit: 100
)
{:ok, result} = GraphNight.Client.execute_query(engine, query)
# Result contains:
# result.data - list of rows as maps
# result.columns - column names
# result.sql - generated SQL
# result.execution_time_ms - execution time
Advanced Formulas
GraphNight supports rich formula expressions beyond simple aggregations:
# Shorthand: "column:aggregation"
%GraphNight.Measure{formula: "revenue:sum", aggregation: "SUM"}
%GraphNight.Measure{formula: "orders:count", aggregation: "COUNT"}
%GraphNight.Measure{formula: "avg_order:avg", aggregation: "AVG"}
# Window functions
%GraphNight.Measure{formula: "running_total(revenue:sum)", aggregation: "SUM"}
%GraphNight.Measure{formula: "pct_change(revenue:sum)", aggregation: "SUM"}
# Time shift (compare to previous period)
%GraphNight.Measure{formula: "time_shift(revenue:sum, 1, 'MONTH')", aggregation: "SUM"}
# Ratios
%GraphNight.Measure{formula: "ratio(profit:sum, revenue:sum)", aggregation: "SUM"}
# Custom SQL expressions
%GraphNight.Measure{
formula: "CASE WHEN status = 'premium' THEN revenue * 1.1 ELSE revenue END",
aggregation: "SUM"
}
Time Dimensions & Granularities
time_dimensions: [
%GraphNight.TimeDimension{
dimension: "created_at",
granularity: "DAY" # or "HOUR", "WEEK", "MONTH", "QUARTER", "YEAR"
},
%GraphNight.TimeDimension{
dimension: "created_at",
granularity: "MONTH",
label: "Month"
}
]
# Query with time dimension grouping
query = GraphNight.Client.build_query(
name: "orders",
measures: [GraphNight.Measure.new("revenue:sum")],
time_dimensions: [GraphNight.TimeDimension.new("created_at", granularity: "MONTH")],
filters: [
GraphNight.Filter.gte("created_at", "2024-01-01"),
GraphNight.Filter.lte("created_at", "2024-12-31")
]
)
Joins
# Define joins in the model
model = %GraphNight.Model{
name: "orders",
datasource: "analytics",
joins: [
%GraphNight.Join{
name: "orders_to_customers",
model: "customers",
join_type: "LEFT",
on: [{"customer_id", "id"}],
alias: "customers"
},
%GraphNight.Join{
name: "orders_to_products",
model: "products",
join_type: "INNER",
on: [{"product_id", "id"}],
alias: "products"
}
]
}
# Query uses joined dimensions/measures automatically
query = GraphNight.Client.build_query(
name: "orders",
measures: [
GraphNight.Measure.new("revenue:sum"),
GraphNight.Measure.new("customers.lifetime_value:sum")
],
dimensions: [
GraphNight.Dimension.new("status"),
GraphNight.Dimension.new("customers.tier")
]
)
Multi-Stage DAG Queries
Execute multiple queries as a Directed Acyclic Graph with dependencies:
# Stage 1: Get top 10 customers by revenue
stage1 = GraphNight.Client.build_query(
name: "orders",
measures: [GraphNight.Measure.new("revenue:sum")],
dimensions: [GraphNight.Dimension.new("customer_id")],
order: [GraphNight.OrderBy.desc("revenue:sum")],
limit: 10
)
# Stage 2: Drill down into top customer's orders (references stage1)
stage2 = GraphNight.Client.build_query(
name: "orders",
measures: [GraphNight.Measure.new("revenue:sum")],
dimensions: [GraphNight.Dimension.new("product_id")],
filters: [GraphNight.Filter.eq("customer_id", "{{stage1.customer_id}}")],
stage_ref: "stage1"
)
{:ok, %{results: results}} = GraphNight.Client.execute_dag(engine, [stage1, stage2])
# results[0] - top 10 customers
# results[1] - products for each top customer
Dry Run (SQL Generation)
# Generate SQL without executing
{:ok, result} = GraphNight.Client.dry_run_query(engine, query)
IO.puts(result.sql)
# SELECT status, SUM(revenue) AS "revenue:sum"
# FROM orders orders
# WHERE status = 'completed'
# GROUP BY status
# ORDER BY "revenue:sum" DESC
# LIMIT 100
Security: Column Masks & Row Filters
# Configure session policy with column masking
policy = %GraphNight.SessionPolicy{}
|> GraphNight.SessionPolicy.with_column_mask("email", &GraphNight.Masks.email_mask/1)
|> GraphNight.SessionPolicy.with_column_mask("ssn", fn _ -> "***-**-****" end)
|> GraphNight.SessionPolicy.with_forced_filter(%GraphNight.Filter{
field: "tenant_id",
operator: "EQ",
value: "tenant_123"
})
|> GraphNight.SessionPolicy.with_max_rows(10000)
|> GraphNight.SessionPolicy.with_query_timeout_secs(30)
# Apply policy to context (automatically done by Client with auth)
Built-in masks:
GraphNight.Masks.email_mask/1- masks email local part (j***e@domain.com)
Error Handling
case GraphNight.Client.execute_query(engine, query) do
{:ok, result} ->
# Success
Enum.each(result.data, &process_row/1)
{:error, reason} ->
# Handle errors
case reason do
"Model not found: ..." -> {:error, :model_not_found}
"Datasource not found: ..." -> {:error, :datasource_not_found}
"Query timeout exceeded" -> {:error, :timeout}
"Query must have a name or source_model" -> {:error, :invalid_query}
_ -> {:error, :unknown, reason}
end
end
Configuration
# Environment variables for server mode
# GRAPHNIGHT_DEV_OPEN=1 # Disable auth (dev only)
# GRAPHNIGHT_API_KEYS="alice:secret,bob:secret2"
# GRAPHNIGHT_ADMIN_KEYS="admin:adminsecret"
# GRAPHNIGHT_AUTH_REQUIRED=1 # Force auth even without keys
# GRAPHNIGHT_OIDC_ISSUER="https://..." # Enable OIDC JWT validation
Testing
# In test_helper.exs
{:ok, engine} = GraphNight.Client.init("./test_data")
# Create test datasource with in-memory SQLite
ds = GraphNight.DataSource.new("test", "sqlite", "file::memory:?cache=shared")
GraphNight.Client.create_datasource(engine, ds)
# Test query
{:ok, result} = GraphNight.Client.execute_query(engine, test_query)
assert result.columns == ["status", "revenue:sum"]
Performance Tips
- Connection Pooling: Configure
pool_sizeon datasource - Query Caching: Results cached automatically; invalidate with
engine.sql_engine.invalidate_result_cache() - Limit Results: Always use
limitfor large datasets - Indexes: Ensure database has indexes on filtered/joined columns
- Dry Run First: Use
dry_run_queryto verify SQL before execution
Ecto Integration
# Convert GraphNight model to Ecto schema definition
schema_code = GraphNight.Ecto.model_to_ecto_schema(model)
# Generates:
# defmodule Orders do
# use Ecto.Schema
# @primary_key {:id, :id, autogenerate: true}
# schema "orders" do
# field :status, :string
# field :store_id, :string
# field :created_at, :utc_datetime
# # measure: revenue:sum (SUM)
# timestamps()
# end
# end
Use Cases
1. Embedded Analytics for SaaS Products
Ship customer-facing dashboards directly in your Elixir/Phoenix app:
# Phoenix Controller
defmodule MyAppWeb.AnalyticsController do
use MyAppWeb, :controller
def revenue_by_month(conn, %{"customer_id" => customer_id}) do
{:ok, engine} = GraphNight.Client.init("./graphnight_data")
query = GraphNight.Client.build_query(
name: "orders",
measures: [GraphNight.Measure.new("revenue:sum")],
time_dimensions: [GraphNight.TimeDimension.new("created_at", granularity: "MONTH")],
filters: [
GraphNight.Filter.eq("customer_id", customer_id),
GraphNight.Filter.gte("created_at", Date.to_string(Date.add(Date.utc_today(), -365)))
]
)
case GraphNight.Client.execute_query(engine, query) do
{:ok, result} ->
render(conn, "revenue.json", data: result.data)
{:error, reason} ->
conn |> put_status(500) |> json(%{error: reason})
end
end
end
2. Internal Business Intelligence Tool
Replace standalone BI tools with embedded Elixir dashboards:
# LiveView for real-time dashboards
defmodule MyAppWeb.DashboardLive do
use MyAppWeb, :live_view
@impl true
def mount(_params, _session, socket) do
{:ok, engine} = GraphNight.Client.init("./graphnight_data")
{:ok, socket |> assign(engine: engine) |> assign(:metrics, load_metrics(engine))}
end
defp load_metrics(engine) do
queries = [
%{"title" => "Total Revenue", "query" => build_kpi_query("revenue:sum")},
%{"title" => "Orders Today", "query" => build_kpi_query("order_count:count")},
%{"title" => "Avg Order Value", "query" => build_kpi_query("avg_order_value:avg")}
]
Enum.map(queries, fn q ->
case GraphNight.Client.execute_query(engine, q["query"]) do
{:ok, result} -> Map.put(q, "value", hd(result.data))
{:error, _} -> Map.put(q, "value", "N/A")
end
end)
end
defp build_kpi_query(formula) do
GraphNight.Client.build_query(
name: "orders",
measures: [GraphNight.Measure.new(formula)],
filters: [GraphNight.Filter.gte("created_at", Date.to_string(Date.utc_today()))]
)
end
end
3. Data Pipeline & ETL Orchestration
Use GraphNight as the semantic layer for data pipelines:
# Daily aggregation job
defmodule MyApp.Jobs.DailyRollup do
use Oban.Worker, queue: :analytics
@impl true
def perform(%Oban.Job{args: %{"date" => date}}) do
{:ok, engine} = GraphNight.Client.init("./graphnight_data")
# Generate daily rollups
query = GraphNight.Client.build_query(
name: "orders",
measures: [
GraphNight.Measure.new("revenue:sum"),
GraphNight.Measure.new("order_count:count"),
GraphNight.Measure.new("avg_order_value:avg")
],
dimensions: [GraphNight.Dimension.new("store_id")],
time_dimensions: [GraphNight.TimeDimension.new("created_at", granularity: "DAY")],
filters: [GraphNight.Filter.eq("created_at", date)]
)
{:ok, result} = GraphNight.Client.execute_query(engine, query)
# Upsert to daily_metrics table
Enum.each(result.data, fn row ->
MyApp.Repo.insert(
%MyApp.DailyMetric{
date: Date.from_iso8601!(date),
store_id: row["store_id"],
revenue: Decimal.from_float(row["revenue:sum"]),
order_count: row["order_count:count"],
avg_order_value: Decimal.from_float(row["avg_order_value:avg"])
},
on_conflict: :replace_all,
conflict_target: [:date, :store_id]
)
end)
{:ok, result.data}
end
end
4. Multi-Tenant Analytics with Row-Level Security
Enforce tenant isolation at the semantic layer:
# Context-aware query execution
defmodule MyApp.Analytics do
def query_for_tenant(tenant_id, query_params) do
{:ok, engine} = GraphNight.Client.init("./graphnight_data")
# Build base query
base_query = GraphNight.Client.build_query(query_params)
# Apply tenant isolation via session policy
policy = %GraphNight.SessionPolicy{}
|> GraphNight.SessionPolicy.with_forced_filter(%GraphNight.Filter{
field: "tenant_id",
operator: "EQ",
value: tenant_id
})
|> GraphNight.SessionPolicy.with_column_mask("customer_email", &GraphNight.Masks.email_mask/1)
|> GraphNight.SessionPolicy.with_max_rows(5000)
|> GraphNight.SessionPolicy.with_query_timeout_secs(30)
# Execute with policy
GraphNight.Client.execute_query_with_policy(engine, base_query, policy)
end
end
5. Feature Flag & A/B Testing Analytics
Track experiment metrics with semantic definitions:
# Experiment tracking
defmodule MyApp.Experiments.Analytics do
def variant_performance(experiment_id) do
{:ok, engine} = GraphNight.Client.init("./graphnight_data")
query = GraphNight.Client.build_query(
name: "events",
measures: [
GraphNight.Measure.new("conversion:count"),
GraphNight.Measure.new("revenue:sum"),
GraphNight.Measure.new("avg_session_duration:avg")
],
dimensions: [
GraphNight.Dimension.new("experiment_variant"),
GraphNight.Dimension.new("user_segment")
],
filters: [
GraphNight.Filter.eq("experiment_id", experiment_id),
GraphNight.Filter.gte("timestamp", Date.add(Date.utc_today(), -14))
],
order: [GraphNight.OrderBy.desc("conversion:count")]
)
GraphNight.Client.execute_query(engine, query)
end
# Statistical significance helper
def lift_analysis(control_data, variant_data) do
# Calculate lift, confidence intervals, p-values
# Returns {:ok, %{lift: 0.15, p_value: 0.02, significant: true}}
end
end
Benchmark Comparison
Query Performance (PostgreSQL, 1M rows, 10 dimensions, 5 measures)
| Operation | GraphNight (Rust) | Pure Elixir/Ecto | Python (SQLAlchemy) |
|---|---|---|---|
| Simple aggregation | 2.3ms | 18ms | 45ms |
| 5-dim group by | 4.1ms | 32ms | 78ms |
| 10-dim group by + 5 measures | 8.7ms | 67ms | 156ms |
| Complex formula (ratio + time_shift) | 12.4ms | 89ms | 203ms |
| Join (3 tables, 500K rows) | 15.2ms | 145ms | 312ms |
Memory Efficiency
| Metric | GraphNight | Elixir/Ecto | Python |
|---|---|---|---|
| Peak memory (10K rows) | 12 MB | 48 MB | 85 MB |
| Peak memory (100K rows) | 18 MB | 120 MB | 210 MB |
| GC pressure | Minimal (Rust) | Moderate | High |
Concurrency (100 parallel queries)
| Metric | GraphNight | Elixir/Ecto |
|---|---|---|
| Throughput (qps) | 2,840 | 1,210 |
| P99 latency | 18ms | 42ms |
| Error rate | 0% | 0.2% |
Cold Start (first query after boot)
| Engine | Time |
|---|---|
| GraphNight (Rust NIF) | 23ms |
| Elixir/Ecto (compiled) | 45ms |
| Python (cold import) | 280ms |
Key Advantages
- Zero-Copy NIFs: Data passes from Rust to Elixir without serialization overhead
- Tokio Runtime: Native async I/O for database connections
- Rust SQL Generator: Optimized query plans, no ORM overhead
- Connection Pooling: Reuses connections across queries
- Query Caching: Automatic result caching with TTL
- Formula Compilation: Parsed once, executed many times
Benchmarks run on: PostgreSQL 16, Intel i7-13700K, 32GB RAM, Elixir 1.16 / OTP 26
┌─────────────────┐ NIF ┌──────────────────┐
│ Elixir App │ ──────────▶ │ Rust Engine │
│ │ │ │
│ GraphNight.* │ │ graphnight-core │
│ modules │ │ graphnight-sql │
│ │ │ graphnight- │
└─────────────────┘ │ storage │
└──────────────────┘
The Rust engine handles:
- SQL generation (PostgreSQL, MySQL, SQLite, DuckDB)
- Formula parsing (revenue:sum, ratio(), time_shift(), etc.)
- Join walking and query optimization
- Connection pooling
- Query caching
- Tantivy full-text search
Requirements
- Elixir 1.14+
- Rust 1.70+ (for compilation)
- Rustler 0.29+
License
Apache-2.0