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:latestTwo 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 setARCADIC_BOLT_PORT=7690before 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", %{})
rowsTransactions
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.5pin 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!/4looks 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").planStreaming 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 abolt+sconnect 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.Vectorbuilds and queries ArcadeDB'sLSM_VECTOR-backed indexes for similarity search — e.g.Arcadic.Vector.create_dense_index!(conn, "Person", "embedding", 384), thenArcadic.Vector.neighbors!/5for a k-NN lookup.Arcadic.Import/Arcadic.Exportwrap ArcadeDB's bulkIMPORT DATABASE/EXPORT DATABASEcommands — 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)