defmodule Subaru do @moduledoc """ A simple property graph for Elixir. Subaru provides a simple, fun-to-use graph database for Elixir. Define a repo, add it to your supervision tree, and start storing graphs. ## Quick Start # 1. Define a graph repo defmodule MyApp.Graph do use Subaru, otp_app: :my_app, adapter: Subaru.Adapters.Mnesia end # 2. Add to supervision tree (application.ex) children = [ MyApp.Graph ] # 3. Use it! alias MyApp.Graph # Store vertices (maps or structs) Graph.put(%{id: "alice", name: "Alice", type: :user}) Graph.put(%{id: "bob", name: "Bob", type: :user}) # Create edges Graph.link("alice", :follows, "bob") # Query the graph import Subaru.Query Graph.run(v("alice") |> out(:follows)) #=> [%{id: "bob", name: "Bob", type: :user}] ## Schemas (Optional) Plain maps work great. For compiler-checked fields, use structs: defmodule MyApp.User do defstruct [:id, :name, :email] end Graph.put(%MyApp.User{id: "alice", name: "Alice", email: "alice@example.com"}) ## Configuration Configuration is optional. Sensible defaults work out of the box: # config/config.exs config :my_app, MyApp.Graph, dir: ".mnesia/my_app", storage_type: :disc_copies """ @doc false defmacro __using__(opts) do quote bind_quoted: [opts: opts] do @otp_app Keyword.fetch!(opts, :otp_app) @adapter Keyword.get(opts, :adapter, Subaru.Adapters.Mnesia) @opts Keyword.drop(opts, [:otp_app, :adapter]) def child_spec(opts) do Subaru.Repo.child_spec({__MODULE__, @otp_app, @adapter, Keyword.merge(@opts, opts)}) end def start_link(opts \\ []) do Subaru.Repo.start_link({__MODULE__, @otp_app, @adapter, Keyword.merge(@opts, opts)}) end # Vertex CRUD @doc """ Insert or replace a vertex. The vertex must have an `:id` field. If a vertex with the same ID exists, it will be replaced. ## Options * `:on_conflict` - `:replace` (default) or `:skip` ## Examples Graph.put(%{id: "alice", name: "Alice"}) Graph.put(%MyApp.User{id: "alice", name: "Alice"}) Graph.put(%{id: "alice", name: "Alice"}, on_conflict: :skip) """ @spec put(map(), keyword()) :: :ok | {:error, term()} def put(vertex, opts \\ []) def put(%{id: _} = vertex, opts) do on_conflict = Keyword.get(opts, :on_conflict, :replace) case on_conflict do :skip -> case get(vertex.id) do {:ok, _} -> :ok :error -> @adapter.put_vertex(__MODULE__, vertex) end :replace -> @adapter.put_vertex(__MODULE__, vertex) end end @doc """ Get a vertex by ID. Returns `{:ok, vertex}` if found, `:error` otherwise. ## Examples {:ok, user} = Graph.get("alice") :error = Graph.get("nonexistent") """ @spec get(String.t()) :: {:ok, map()} | :error def get(id) when is_binary(id) do @adapter.get_vertex(__MODULE__, id) end @doc """ Get a vertex by ID, raising if not found. ## Examples user = Graph.get!("alice") """ @spec get!(String.t()) :: map() def get!(id) when is_binary(id) do case get(id) do {:ok, vertex} -> vertex :error -> raise KeyError, key: id, term: __MODULE__ end end @doc """ Get a vertex by ID, returning nil if not found. ## Examples user = Graph.fetch("alice") #=> %{id: "alice", ...} or nil """ @spec fetch(String.t()) :: map() | nil def fetch(id) when is_binary(id) do case get(id) do {:ok, vertex} -> vertex :error -> nil end end @doc """ Delete a vertex by ID. Also removes all edges connected to this vertex. ## Examples :ok = Graph.delete("alice") """ @spec delete(String.t()) :: :ok | {:error, term()} def delete(id) when is_binary(id) do @adapter.delete_vertex(__MODULE__, id) end # Edge operations @doc """ Create an edge between two vertices. ## Examples Graph.link("alice", :follows, "bob") Graph.link("alice", :follows, "bob", %{since: ~D[2020-01-15]}) """ @spec link(String.t(), atom(), String.t(), map()) :: :ok | {:error, term()} def link(from_id, edge_type, to_id, props \\ %{}) when is_binary(from_id) and is_atom(edge_type) and is_binary(to_id) do @adapter.put_edge(__MODULE__, from_id, edge_type, to_id, props) end @doc """ Remove an edge between two vertices. ## Examples Graph.unlink("alice", :follows, "bob") """ @spec unlink(String.t(), atom(), String.t()) :: :ok | {:error, term()} def unlink(from_id, edge_type, to_id) when is_binary(from_id) and is_atom(edge_type) and is_binary(to_id) do @adapter.delete_edge(__MODULE__, from_id, edge_type, to_id) end # Query execution @doc """ Execute a query and return results as a list. ## Examples import Subaru.Query Graph.run(v("alice") |> out(:follows)) #=> [%{id: "bob", ...}] """ @spec run(Subaru.Query.t()) :: [map()] def run(%Subaru.Query{} = query) do Subaru.Executor.run(query, __MODULE__, @adapter) end @doc """ Count the results of a query. More efficient than `run(query) |> length()` for large result sets when you only need the count. ## Examples import Subaru.Query Graph.count(v("alice") |> out(:follows)) #=> 42 """ @spec count(Subaru.Query.t()) :: non_neg_integer() def count(%Subaru.Query{} = query) do query |> Subaru.Executor.stream(__MODULE__, @adapter) |> Enum.count() end @doc """ Check if a query returns any results. ## Examples import Subaru.Query Graph.exists?(v("alice") |> out(:follows)) #=> true """ @spec exists?(Subaru.Query.t()) :: boolean() def exists?(%Subaru.Query{} = query) do query |> Subaru.Executor.stream(__MODULE__, @adapter) |> Enum.any?() end @doc """ Get unique results from a query. Removes duplicate vertices based on their ID. ## Examples import Subaru.Query # Friends of friends (may include duplicates) Graph.unique(v("alice") |> out(:follows) |> out(:follows)) """ @spec unique(Subaru.Query.t()) :: [map()] def unique(%Subaru.Query{} = query) do query |> Subaru.Executor.stream(__MODULE__, @adapter) |> Enum.uniq_by(fn %{id: id} -> id other -> other end) end # Transactions @doc """ Execute multiple operations in a transaction. All operations inside the function are atomic - they either all succeed or all fail together. ## Examples Graph.transaction(fn -> Graph.put(%{id: "alice", name: "Alice"}) Graph.put(%{id: "bob", name: "Bob"}) Graph.link("alice", :follows, "bob") end) """ @spec transaction((-> result)) :: {:ok, result} | {:error, term()} when result: term() def transaction(fun) when is_function(fun, 0) do @adapter.transaction(__MODULE__, fun) end # ID Generation @doc """ Generate a unique, sortable ID (ULID). ## Examples id = Graph.gen_id() #=> "01HQMXK8M6B1234567890ABCDE" """ @spec gen_id() :: String.t() def gen_id do ExULID.ULID.generate() end end end end