Getting started with Arcadic

Copy Markdown View Source

Setup

Arcadic is a lean, framework-agnostic Elixir client for ArcadeDB over its HTTP Cypher command API, with an optional Bolt transport for the query hot path. It ships Cypher/SQL to ArcadeDB and manages connections, sessions, and transactions — the "postgrex of ArcadeDB".

This notebook needs a running ArcadeDB. The quickest way is Docker:

docker run -d --name arcadic-quickstart \
  -p 2480:2480 -p 7687:7687 \
  -e JAVA_OPTS="-Darcadedb.server.rootPassword=playwithdata \
                -Darcadedb.server.plugins=Bolt:com.arcadedb.bolt.BoltProtocolPlugin" \
  arcadedata/arcadedb:latest

Two ports are published: 2480 (the HTTP command API — the default transport) and 7687 (Bolt — used by the streaming section below). The -Darcadedb.server.plugins=Bolt:... option enables ArcadeDB's Bolt protocol, which is off by default; without it the streaming section can't connect.

Heads-up on port 7687. That is also Neo4j's default Bolt port. If you run a local Neo4j, it likely already owns 7687 and the streaming section would end up talking to it instead of ArcadeDB, giving a confusing handshake or auth error. Map ArcadeDB's Bolt to a free host port instead (e.g. -p 7690:7687) and set ARCADIC_BOLT_PORT=7690 before launching Livebook.

Re-running cells. The setup cells (connect, create-database) are safe to re-run — the database is created only if it does not already exist, and the data cells use MERGE, so they are idempotent. To start completely fresh, run the Cleanup cell at the bottom, then run all cells top to bottom.

Every connection value can be overridden with an environment variable set before you launch Livebook (ARCADIC_HTTP_URL, ARCADIC_BOLT_HOST, ARCADIC_BOLT_PORT, ARCADIC_PASSWORD); otherwise the defaults match the docker run above.

Install

Mix.install([
  {:arcadic, "~> 0.7"},
  {:boltx, "~> 0.0.6"},
  {:kino, "~> 0.14"}
])

Connect

Arcadic.connect/3 builds a pure-data connection handle — no process to supervise. We create a throwaway arcadic_quickstart database to play in (only if it does not already exist, so this cell is safe to re-run).

http_url = System.get_env("ARCADIC_HTTP_URL", "http://localhost:2480")
password = System.get_env("ARCADIC_PASSWORD", "playwithdata")
database = "arcadic_quickstart"

admin = Arcadic.connect(http_url, database, auth: {"root", password})
{:ok, true} = Arcadic.Server.ready?(admin)

unless match?({:ok, true}, Arcadic.Server.database_exists?(admin, database)) do
  :ok = Arcadic.Server.create_database(admin, database)
end

conn = Arcadic.connect(http_url, database, auth: {"root", password})

Query and command (parameters only)

Every dynamic value reaches ArcadeDB only as a bound parameter ($name) — never string interpolation. command/4 writes; query/4 reads. MERGE keeps the write idempotent, so re-running this cell does not create duplicate people.

{:ok, _} =
  Arcadic.command(
    conn,
    "MERGE (p:Person {name: $name}) ON CREATE SET p.role = $role RETURN p.name AS name",
    %{"name" => "Alice", "role" => "engineer"}
  )

{:ok, rows} = Arcadic.query(conn, "MATCH (p:Person) RETURN p.name AS name, p.role AS role", %{})
rows

Transactions

Arcadic.transaction/3 opens an ArcadeDB session, runs the function with a session-scoped connection, and commits on normal return. Returning normally commits; raising rolls back and reraises; Arcadic.rollback/2 aborts on purpose and yields {:error, reason}.

{:ok, total} =
  Arcadic.transaction(conn, fn tx ->
    Arcadic.command!(tx, "MERGE (p:Person {name: $name})", %{"name" => "Bob"})
    [%{"c" => c}] = Arcadic.query!(tx, "MATCH (p:Person) RETURN count(p) AS c", %{})
    c
  end)

total
# An intentional rollback discards everything the transaction did.
result =
  Arcadic.transaction(conn, fn tx ->
    Arcadic.command!(tx, "CREATE (p:Person {name: $name})", %{"name" => "Ghost"})
    Arcadic.rollback(tx, :changed_my_mind)
  end)

{:ok, [%{"c" => ghosts}]} =
  Arcadic.query(conn, "MATCH (p:Person {name: $name}) RETURN count(p) AS c", %{"name" => "Ghost"})

{result, ghosts}

Other query languages

Cypher is the default, but ArcadeDB is multi-model. Opt into another engine per call with language: — here, SQL.

Arcadic.query!(conn, "SELECT name, role FROM Person ORDER BY name", %{}, language: "sql")

Inspecting the plan (EXPLAIN)

Requires arcadic ≥ 0.5 (satisfied by the ~> 0.5 pin above).

Arcadic.explain!/4 prepends EXPLAIN and returns the query plan without running the statement — handy for checking how ArcadeDB will resolve a query (by type scan, bucket, or index) before you run it for real. It returns a %{plan, plan_tree, rows} map; .plan is the human-readable summary (plan_tree holds the raw, transport-defined tree and rows is empty for EXPLAIN).

Careful with its sibling. Arcadic.profile!/4 looks similar but executes the statement to collect real runtime metrics — on a write it mutates. Don't profile a write unless you want the effect.

Arcadic.explain!(conn, "SELECT FROM Person", %{}, language: "sql").plan

Streaming large results over HTTP

Arcadic.query_stream/4 streams a large read as a lazy Stream over the default HTTP transport — no Bolt setup required (it runs over Bolt too, shown next). It pages a WHERE-less SELECT/MATCH behind the scenes (an @rid keyset for SQL) so the whole result never has to sit in memory at once, and returns {:ok, stream}. chunk_size: 5 is the page size — the rows fetched per round-trip. With only the two people seeded above the whole result fits one page, but the cursor pages automatically as the type grows.

{:ok, page_stream} =
  Arcadic.query_stream(conn, "SELECT FROM Person", %{}, language: "sql", chunk_size: 5)

page_stream |> Enum.to_list() |> Kino.DataTable.new()

Streaming large results over Bolt

For large result sets, Arcadic.query_stream/4 returns a lazy Stream of rows over Bolt, paged with PULL so the whole result never has to sit in memory at once. Arcadic.Transport.Bolt.setup/1 builds the transport options in one call.

bolt_host = System.get_env("ARCADIC_BOLT_HOST", "localhost")
bolt_port = String.to_integer(System.get_env("ARCADIC_BOLT_PORT", "7687"))

{:ok, transport_options} =
  Arcadic.Transport.Bolt.setup(
    hostname: bolt_host,
    port: bolt_port,
    username: "root",
    password: password
  )

bolt_conn =
  Arcadic.connect(http_url, database,
    auth: {"root", password},
    transport: Arcadic.Transport.Bolt,
    transport_options: transport_options
  )

{:ok, stream} =
  Arcadic.query_stream(bolt_conn, "UNWIND range(1, 25) AS i RETURN i AS n, i * i AS square", %{},
    chunk_size: 5
  )

stream |> Enum.to_list() |> Kino.DataTable.new()

Bolt over TLS. Arcadic.Transport.Bolt.setup(scheme: "bolt+s", ...) is secure by default (verifies the server certificate). This notebook does not demo it: a verify_peer handshake against an untrusted cert can currently wedge ArcadeDB's shared Bolt listener server-side (an upstream ArcadeDB defect, not arcadic's — see the README's TLS section and ArcadeData/arcadedb#5106). Don't paste a bolt+s connect into this notebook against a server you don't control.

Migrations

Arcadic.Migrator runs Arcadic.Migrations in order and records applied versions in the _arcadic_migrations type, so re-running is a no-op. In a real app each migration is its own module; here we define and run one inline.

defmodule Quickstart.Migrations.V1 do
  @behaviour Arcadic.Migration
  @impl true
  def version, do: 1
  @impl true
  def up(c), do: Arcadic.command!(c, "CREATE VERTEX TYPE Widget IF NOT EXISTS", %{}, language: "sql") && :ok
  @impl true
  def down(c), do: Arcadic.command!(c, "DROP TYPE Widget IF EXISTS", %{}, language: "sql") && :ok
end

defmodule Quickstart.Migrations do
  use Arcadic.MigrationRegistry
  migrations([Quickstart.Migrations.V1])
end

{:ok, applied} = Arcadic.Migrator.migrate(conn, Quickstart.Migrations)
{:ok, status} = Arcadic.Migrator.status(conn, Quickstart.Migrations)
{applied, status}

Schema introspection

Arcadic.Schema reads the database's own catalog — the Widget type the migration above just created shows up here too.

Arcadic.Schema.types!(conn)

Server admin

Arcadic.Server covers server-level operations. Database identifiers are allowlist-validated before they reach the wire.

{:ok, databases} = Arcadic.Server.list_databases(admin)
{:ok, exists?} = Arcadic.Server.database_exists?(admin, database)
{databases, exists?}

Beyond this notebook

Two more areas Arcadic covers that don't fit a quickstart cell:

  • Arcadic.Vector builds and queries ArcadeDB's LSM_VECTOR-backed indexes for similarity search — e.g. Arcadic.Vector.create_dense_index!(conn, "Person", "embedding", 384), then Arcadic.Vector.neighbors!/5 for a k-NN lookup.
  • Arcadic.Import/Arcadic.Export wrap ArcadeDB's bulk IMPORT DATABASE/EXPORT DATABASE commands — e.g. Arcadic.Export.database!(conn, "backup", with: [format: "jsonl"]) writes a bare, allowlisted export name server-side (never a path).

Cleanup

Drop the throwaway database when you are done (or before a fresh top-to-bottom run).

Arcadic.Server.drop_database(admin, database)