nebula_graph_ex

An Elixir client for NebulaGraph, 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:


Prerequisites


Installation

Add nebula_graph_ex to your dependencies:

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

mix nebula_graph_ex.gen.graph MyApp.Graph

The generator does three 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

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 three 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.

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

# config/config.exs
config :my_app, MyApp.Graph,
  hostname: "localhost",
  port: 9669,
  username: "root",
  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:

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

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


Status checks

To verify that the pool process is running and can execute a simple query:

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

{:ok, status} = NebulaGraphEx.Graph.status(MyApp.Graph)

If the pool is not running, you get:

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

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

{:ok, status} = MyApp.Graph.status(probe_query: false)

Querying

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:

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.

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:

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

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:

mix nebula_graph_ex.gen.graph MyApp.PrimaryGraph
mix nebula_graph_ex.gen.graph MyApp.AnalyticsGraph

Each module reads its own config key independently:

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

children = [MyApp.PrimaryGraph, MyApp.AnalyticsGraph]

Configuration reference

Option Default Description
:hostname"localhost" NebulaGraph graphd host
:port9669 NebulaGraph graphd port
:hostsnil 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
:spacenil Default graph space; nil means no space is selected on connect
:pool_size10 Number of connections to maintain
:max_overflow0 Extra connections spawned under burst load
:connect_timeout5_000 TCP connect timeout (ms)
:recv_timeout15_000 Socket read timeout per frame (ms)
:send_timeout15_000 Socket write timeout (ms)
:timeout15_000 Per-query execution timeout (ms)
:idle_interval1_000 How often idle connections are pinged (ms)
:sslfalse 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_min1_000 Minimum reconnect back-off (ms)
:backoff_max30_000 Maximum reconnect back-off (ms)
:decode_mappernil Function applied to each %Record{} row; nil returns records as-is
:prefer_jsonfalse Use executeJson instead of execute; returns raw JSON from the server
:telemetry_prefix[:nebula_graph_ex, :query] Prefix for :telemetry events
:logfalse Emit a Logger message for each query
:log_level:debug Logger level used when :log is true
:slow_query_thresholdnil Log queries that exceed this duration in ms, regardless of :log
:show_sensitive_data_on_connection_errorfalse Include the password in connection error messages

Full option documentation: NebulaGraphEx.Options.


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:

NebulaGraphEx.Telemetry.attach_default_handler()

Attach a custom handler for production metrics:

: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
nullnil
booltrue / false
intinteger()
floatfloat()
stringbinary()
date%NebulaGraphEx.Types.NebDate{}
time%NebulaGraphEx.Types.NebTime{}
datetime%NebulaGraphEx.Types.NebDateTime{}
vertex%NebulaGraphEx.Types.Vertex{}
edge%NebulaGraphEx.Types.Edge{}
path%NebulaGraphEx.Types.Path{}
listlist()
mapmap()
setMapSet.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):

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:

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:

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:

For a library published to Hex, the safest default is:

To bump the version in mix.exs:

mix nebula_graph_ex.version

Running the task without an argument prompts for the bump type interactively. You can also specify it directly:

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:

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:

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

Apache 2.0 — see LICENSE.