# nebula_graph_ex
An Elixir client for [NebulaGraph](https://www.nebula-graph.io/), a distributed open-source graph database built for massive-scale graph data.
`nebula_graph_ex` handles the full lifecycle of communicating with NebulaGraph from Elixir: opening and pooling TCP connections, authenticating sessions, encoding and executing nGQL statements, and decoding the response into idiomatic Elixir values. Your application code works entirely with native Elixir types — integers, binaries, structs — and never touches the wire protocol directly.
**Key capabilities:**
- Connection pooling with automatic reconnection and back-off
- Complete value decoding — vertices, edges, paths, temporal types, geography, lists, maps, sets, and all primitive types
- Parameterised queries that encode values as typed Thrift structs, keeping nGQL strings free of interpolation
- TLS/SSL support for encrypted connections
- `:telemetry` events on every query for metrics and tracing
- Per-query option overrides for space switching, timeouts, and row transformation
---
## Prerequisites
- Elixir 1.15 or later
- A running NebulaGraph 3.x instance (see [Running locally](#running-locally) for a Docker setup)
---
## Installation
Add `nebula_graph_ex` to your dependencies:
```elixir
# mix.exs
defp deps do
[
{:nebula_graph_ex, "~> 0.1"}
]
end
```
---
## Setup
There are two ways to set up the library: using the generator (recommended) or manually.
---
### Option 1: Generator (recommended)
Run the Mix task to wire up the library in one step:
```bash
mix nebula_graph_ex.gen.graph MyApp.Graph
```
The generator does five things:
1. Creates `lib/my_app/graph.ex` with the graph module
2. Inserts a default config block into `config/config.exs`
3. Injects the module as the first child in `lib/my_app/application.ex`
4. Adds NebulaGraphEx query metrics to `lib/my_app_web/telemetry.ex` when that file exists
5. Adds `metrics: MyAppWeb.Telemetry` to the LiveDashboard route when a dashboard is present and metrics are not already configured
It then prints a `config/runtime.exs` snippet for secrets, which you add manually.
The generator is idempotent — re-running it on an already-configured app skips each step that is already in place without overwriting anything.
---
### Option 2: Manual setup
The generator performs these steps. Do them by hand if you prefer not to use it.
**1. Create the graph module**
Create a dedicated module for your graph connection. The module name doubles as the registered pool name — no PIDs to pass around.
```elixir
# lib/my_app/graph.ex
defmodule MyApp.Graph do
use NebulaGraphEx.Graph, otp_app: :my_app
end
```
**2. Configure the connection**
Static options (hostname, pool size, space) belong in `config/config.exs`:
```elixir
# config/config.exs
config :my_app, MyApp.Graph,
hostname: "localhost",
port: 9669,
username: "root",
password: "my_password",
space: "my_graph",
pool_size: 10
```
Secrets and environment-specific values belong in `config/runtime.exs`, where they are resolved at boot rather than embedded at compile time:
```elixir
# config/runtime.exs
import Config
config :my_app, MyApp.Graph,
hostname: System.get_env("NEBULA_HOST", "localhost"),
password: fn -> System.fetch_env!("NEBULA_PASS") end
```
Passing `:password` as a zero-arity function ensures the value is never captured in a compiled module or logged in a crash report.
**3. Start the pool**
Add the module to your application's supervision tree:
```elixir
# lib/my_app/application.ex
def start(_type, _args) do
children = [MyApp.Graph]
Supervisor.start_link(children, strategy: :one_for_one)
end
```
The pool starts, authenticates, and is ready to accept queries before `start/2` returns.
**4. Add telemetry metrics**
If your Phoenix app has `lib/my_app_web/telemetry.ex`, add NebulaGraphEx metrics
to the `metrics/0` list:
```elixir
summary("nebula_graph_ex.query.duration",
event_name: [:nebula_graph_ex, :query, :stop],
measurement: :duration,
unit: {:native, :millisecond}
),
counter("nebula_graph_ex.query.count",
event_name: [:nebula_graph_ex, :query, :stop]
),
counter("nebula_graph_ex.query.error.count",
event_name: [:nebula_graph_ex, :query, :exception]
)
```
If your app exposes Phoenix LiveDashboard, make sure the route uses the
telemetry module for metrics:
```elixir
live_dashboard "/dashboard", metrics: MyAppWeb.Telemetry
```
---
## Status checks
To verify that the pool process is running and can execute a simple query:
```elixir
{:ok, status} = MyApp.Graph.status()
status.connected?
#=> true
status.queryable?
#=> true
```
Under the hood, `status/0` performs an active health check by running
`RETURN 1 AS status`.
If you want to call it through the generic API:
```elixir
{:ok, status} = NebulaGraphEx.Graph.status(MyApp.Graph)
```
If the pool is not running, you get:
```elixir
{:error,
%{
error: :not_running,
error_details: %{reason: :not_running, message: "pool process is not running"},
connected?: false,
queryable?: false
}}
```
If the pool is running but the probe query fails, `status/0` also returns the
underlying error and normalized details so callers can surface a useful message:
```elixir
{:error, status} = MyApp.Graph.status(probe_statement: "THIS IS NOT VALID NGQL")
status.error_details
#=> %{code: :e_syntax_error, message: "...", category: :query, statement: "THIS IS NOT VALID NGQL"}
```
If you only want to know whether the pool process exists without probing the
database, disable the query probe:
```elixir
{:ok, status} = MyApp.Graph.status(probe_query: false)
```
---
## Querying
```elixir
alias NebulaGraphEx.{Record, ResultSet}
{:ok, rs} = MyApp.Graph.query("MATCH (v:Player) RETURN v.name, v.age LIMIT 5")
rs |> ResultSet.count() #=> 5
rs |> ResultSet.columns() #=> ["v.name", "v.age"]
rs |> ResultSet.to_maps() #=> [%{"v.name" => "Tim Duncan", "v.age" => 42}, ...]
```
Use `query!/3` when you want the result set directly and prefer an exception over a pattern-match on error:
```elixir
MyApp.Graph.query!("RETURN 1+1 AS n")
|> ResultSet.first!()
|> Record.get!("n")
#=> 2
```
### Parameterised queries
Pass user-supplied values as parameters, never by interpolating them into the statement string. Parameters are encoded as typed Thrift values — there is no string substitution, so injection is not possible.
```elixir
MyApp.Graph.query(
"MATCH (v:Player{name: $name}) RETURN v.age AS age",
%{"name" => "Tim Duncan"}
)
```
### Working with graph types
Vertices, edges, and paths are decoded into typed Elixir structs automatically:
```elixir
{:ok, rs} = MyApp.Graph.query("MATCH (v:Player) RETURN v LIMIT 1")
rs
|> ResultSet.first!()
|> Record.get!("v")
#=> %NebulaGraphEx.Types.Vertex{
# vid: "player100",
# tags: [%NebulaGraphEx.Types.Tag{name: "Player", props: %{"name" => "Tim Duncan", "age" => 42}}]
# }
rs
|> ResultSet.first!()
|> Record.get!("v")
|> NebulaGraphEx.Types.Vertex.prop("Player", "name")
#=> "Tim Duncan"
```
See the [Type mapping](#type-mapping) table for a full list of how NebulaGraph types map to Elixir terms.
### Per-query options
Any pool-level option can be overridden for a single call:
```elixir
MyApp.Graph.query(stmt, %{},
space: "other_space", # switch graph space for this query only
timeout: 60_000, # override execution timeout (ms)
decode_mapper: &Record.to_map/1 # transform each row at decode time
)
```
---
## Multiple pools
When your application needs connections to more than one NebulaGraph cluster or space, create a module per connection — either with the generator or manually, following the same steps as the single-pool setup.
Using the generator:
```bash
mix nebula_graph_ex.gen.graph MyApp.PrimaryGraph
mix nebula_graph_ex.gen.graph MyApp.AnalyticsGraph
```
Each module reads its own config key independently:
```elixir
# config/runtime.exs
config :my_app, MyApp.PrimaryGraph,
hostname: System.fetch_env!("NEBULA_PRIMARY_HOST"),
space: "primary"
config :my_app, MyApp.AnalyticsGraph,
hostname: System.fetch_env!("NEBULA_ANALYTICS_HOST"),
space: "analytics"
```
Add all modules to the supervision tree:
```elixir
children = [MyApp.PrimaryGraph, MyApp.AnalyticsGraph]
```
---
## Configuration reference
| Option | Default | Description |
|--------|---------|-------------|
| `:hostname` | `"localhost"` | NebulaGraph graphd host |
| `:port` | `9669` | NebulaGraph graphd port |
| `:hosts` | `nil` | Multi-host list `[{"h1", 9669}, {"h2", 9669}]` — overrides `:hostname`/`:port` |
| `:load_balancing` | `:round_robin` | Host selection when `:hosts` is set: `:round_robin` or `:random` |
| `:username` | `"root"` | Authentication username |
| `:password` | `"nebula"` | Authentication password — accepts a string or a zero-arity function |
| `:space` | `nil` | Default graph space; `nil` means no space is selected on connect |
| `:pool_size` | `10` | Number of connections to maintain |
| `:max_overflow` | `0` | Extra connections spawned under burst load |
| `:connect_timeout` | `5_000` | TCP connect timeout (ms) |
| `:recv_timeout` | `15_000` | Socket read timeout per frame (ms) |
| `:send_timeout` | `15_000` | Socket write timeout (ms) |
| `:timeout` | `15_000` | Per-query execution timeout (ms) |
| `:idle_interval` | `1_000` | How often idle connections are pinged (ms) |
| `:ssl` | `false` | Enable TLS |
| `:ssl_opts` | `[]` | Options forwarded to `:ssl.connect/3` (`:verify`, `:cacertfile`, etc.) |
| `:backoff_type` | `:rand_exp` | Reconnect strategy: `:stop`, `:exp`, `:rand`, or `:rand_exp` |
| `:backoff_min` | `1_000` | Minimum reconnect back-off (ms) |
| `:backoff_max` | `30_000` | Maximum reconnect back-off (ms) |
| `:decode_mapper` | `nil` | Function applied to each `%Record{}` row; `nil` returns records as-is |
| `:prefer_json` | `false` | Use `executeJson` instead of `execute`; returns raw JSON from the server |
| `:telemetry_prefix` | `[:nebula_graph_ex, :query]` | Prefix for `:telemetry` events |
| `:log` | `false` | Emit a Logger message for each query |
| `:log_level` | `:debug` | Logger level used when `:log` is `true` |
| `:slow_query_threshold` | `nil` | Log queries that exceed this duration in ms, regardless of `:log` |
| `:show_sensitive_data_on_connection_error` | `false` | Include the password in connection error messages |
Full option documentation: [`NebulaGraphEx.Options`](https://hexdocs.pm/nebula_graph_ex/NebulaGraphEx.Options.html).
---
## Telemetry
Every query emits three `:telemetry` events:
| Event | When |
|-------|------|
| `[:nebula_graph_ex, :query, :start]` | Before the query is sent |
| `[:nebula_graph_ex, :query, :stop]` | After a successful response |
| `[:nebula_graph_ex, :query, :exception]` | If the query raises |
The `:stop` and `:exception` events include a `:duration` measurement in native time units, and a metadata map containing `:statement`, `:params`, and `:opts`.
Attach the built-in logger handler during development:
```elixir
NebulaGraphEx.Telemetry.attach_default_handler()
```
Attach a custom handler for production metrics:
```elixir
:telemetry.attach(
"my-app-nebula-metrics",
[:nebula_graph_ex, :query, :stop],
fn _event, %{duration: duration}, %{statement: statement}, _config ->
ms = System.convert_time_unit(duration, :native, :millisecond)
MyMetrics.histogram("nebula.query_ms", ms, tags: [statement: statement])
end,
nil
)
```
The prefix can be changed per-pool or per-query with the `:telemetry_prefix` option.
---
## Type mapping
All NebulaGraph value types are decoded to native Elixir terms. The mapping is:
| NebulaGraph type | Elixir term |
|------------------|-------------|
| `null` | `nil` |
| `bool` | `true` / `false` |
| `int` | `integer()` |
| `float` | `float()` |
| `string` | `binary()` |
| `date` | `%NebulaGraphEx.Types.NebDate{}` |
| `time` | `%NebulaGraphEx.Types.NebTime{}` |
| `datetime` | `%NebulaGraphEx.Types.NebDateTime{}` |
| `vertex` | `%NebulaGraphEx.Types.Vertex{}` |
| `edge` | `%NebulaGraphEx.Types.Edge{}` |
| `path` | `%NebulaGraphEx.Types.Path{}` |
| `list` | `list()` |
| `map` | `map()` |
| `set` | `MapSet.t()` |
| `geography` | `%NebulaGraphEx.Types.Geography{}` |
| `duration` | `%NebulaGraphEx.Types.Duration{}` |
Temporal structs provide conversion helpers to standard Elixir types where a direct mapping exists — see `NebulaGraphEx.Types.NebDate`, `NebulaGraphEx.Types.NebTime`, and `NebulaGraphEx.Types.NebDateTime`.
---
## Running locally
A `docker-compose.yml` is included at the project root. It starts a single-node NebulaGraph 3.x instance on the default port `9669` with the default credentials (`root` / `nebula`):
```bash
docker compose up -d
```
---
## Running integration tests
Integration tests require a live NebulaGraph instance and are excluded from the default test run. Start the Docker cluster, then:
```bash
mix test --include integration
```
---
## Contributing
Contributions should keep the public API, docs, and release metadata aligned.
Before opening a release PR or tagging a version:
```bash
mix precommit
mix test --include integration
```
Integration tests require a running NebulaGraph instance, so start the local
Docker setup first if you want full coverage.
### Versioning
This project should follow Semantic Versioning. The version in `mix.exs` is the
source of truth, and git tags should always match it as `v<version>`.
Industry-standard SemVer guidance:
- `patch`: backwards-compatible bug fixes, documentation-only fixes that affect the shipped version, and internal changes that do not change public behaviour
- `minor`: backwards-compatible new features, new public APIs, and additive functionality
- `major`: breaking changes to the public API, behaviour, configuration, or supported upgrade path
For a library published to Hex, the safest default is:
- use `patch` for fixes
- use `minor` for additive improvements
- use `major` only when users must change their code or upgrade process
To bump the version in `mix.exs`:
```bash
mix nebula_graph_ex.version
```
Running the task without an argument prompts for the bump type interactively.
You can also specify it directly:
```bash
mix nebula_graph_ex.version patch
mix nebula_graph_ex.version minor
mix nebula_graph_ex.version major
```
### Releasing
To create the matching annotated git tag after updating `mix.exs`:
```bash
mix nebula_graph_ex.tag
```
The tag task reads `@version` from `mix.exs`, creates `v<version>`, and refuses
to run if the git worktree is dirty or the tag already exists.
Typical release flow:
```bash
mix precommit
mix docs
mix nebula_graph_ex.version minor
git add mix.exs README.md lib test
git commit -m "Prepare 0.2.0 release"
mix nebula_graph_ex.tag
git push origin main
git push origin v0.2.0
mix hex.publish
```
---
## License
MIT — see [LICENSE](https://github.com/VChain/nebula_graph_ex/blob/main/LICENSE).