README.md

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

```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",
  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.

---

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

Apache 2.0 — see [LICENSE](LICENSE).