Current section
Files
Jump to
Current section
Files
lib/subaru/query.ex
defmodule Subaru.Query do
@moduledoc """
Query DSL for building graph traversal queries.
Queries are data structures that describe what to fetch. They are inert until
executed with `Graph.run/1`. This allows queries to be inspected, composed,
and optimized before execution.
## Usage
import Subaru.Query
# Start from a vertex by ID
query = v("alice")
# Traverse outgoing edges
query = v("alice") |> out(:follows)
# Chain traversals
query = v("alice") |> out(:follows) |> out(:purchased)
# Filter results
query = v("alice") |> out(:purchased) |> filter(& &1.year >= 2020)
# Limit results
query = v("alice") |> out(:follows) |> take(10)
# Execute
results = Graph.run(query)
## Query Inspection
Queries are just data:
query = v("alice") |> out(:follows)
IO.inspect(query)
#=> %Subaru.Query{start: {:id, "alice"}, steps: [{:out, :follows}]}
"""
defstruct start: nil,
steps: []
@typedoc "Unique vertex identifier (typically a ULID string)"
@type vertex_id :: String.t()
@typedoc "Edge relationship type (e.g., :follows, :purchased)"
@type edge_type :: atom()
@typedoc "Predicate function for filtering vertices or edges"
@type filter_fn :: (map() -> boolean())
@typedoc """
Specifies where a query starts:
- `{:id, id}` - Start from a specific vertex by ID
- `{:type, type}` - Start from all vertices of a type
- `{:type, type, props}` - Start from vertices matching type and properties
- `{:match, props}` - Start from vertices matching properties (any type)
"""
@type start_spec ::
{:id, vertex_id()}
| {:type, atom()}
| {:type, atom(), map()}
| {:match, map()}
@typedoc """
A single traversal step in the query plan:
- `{:out, type}` - Traverse outgoing edges (nil = all types)
- `{:in, type}` - Traverse incoming edges (nil = all types)
- `{:filter, fn}` - Filter results with predicate
- `{:take, n}` - Limit to n results
- `{:edges, type}` - Get edge data instead of destination vertices
- `:to` - Get destination vertices from edges
"""
@type step ::
{:out, edge_type() | nil}
| {:in, edge_type() | nil}
| {:filter, filter_fn()}
| {:take, pos_integer()}
| {:edges, edge_type() | nil}
| :to
@typedoc "A query plan that describes a graph traversal"
@type t :: %__MODULE__{
start: start_spec() | nil,
steps: [step()]
}
@doc """
Start a query from a vertex or set of vertices.
## Examples
iex> import Subaru.Query
iex> v("alice")
%Subaru.Query{start: {:id, "alice"}, steps: []}
iex> import Subaru.Query
iex> v(:user)
%Subaru.Query{start: {:type, :user}, steps: []}
iex> import Subaru.Query
iex> v(:user, name: "Alice")
%Subaru.Query{start: {:type, :user, %{name: "Alice"}}, steps: []}
"""
@spec v(vertex_id() | atom() | struct() | map()) :: t()
def v(id) when is_binary(id) do
%__MODULE__{start: {:id, id}}
end
def v(type) when is_atom(type) do
%__MODULE__{start: {:type, type}}
end
def v(%{__struct__: type} = struct) do
props =
struct
|> Map.from_struct()
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
|> Map.new()
if map_size(props) == 0 do
%__MODULE__{start: {:type, type}}
else
%__MODULE__{start: {:type, type, props}}
end
end
@doc """
Start a query from vertices matching properties.
## Examples
iex> import Subaru.Query
iex> v(:user, name: "Alice", active: true)
%Subaru.Query{start: {:type, :user, %{name: "Alice", active: true}}, steps: []}
iex> import Subaru.Query
iex> v(:user, %{role: :admin})
%Subaru.Query{start: {:type, :user, %{role: :admin}}, steps: []}
"""
@spec v(atom(), keyword() | map()) :: t()
def v(type, props) when is_atom(type) and is_list(props) do
v(type, Map.new(props))
end
def v(type, props) when is_atom(type) and is_map(props) do
if map_size(props) == 0 do
%__MODULE__{start: {:type, type}}
else
%__MODULE__{start: {:type, type, props}}
end
end
@doc """
Traverse outgoing edges of the given type.
If no type is specified, traverses all outgoing edges.
## Examples
iex> import Subaru.Query
iex> v("alice") |> out(:follows)
%Subaru.Query{start: {:id, "alice"}, steps: [{:out, :follows}]}
iex> import Subaru.Query
iex> v("alice") |> out()
%Subaru.Query{start: {:id, "alice"}, steps: [{:out, nil}]}
"""
@spec out(t()) :: t()
@spec out(t(), edge_type()) :: t()
def out(%__MODULE__{} = query, edge_type \\ nil) do
add_step(query, {:out, edge_type})
end
@doc """
Traverse incoming edges of the given type.
If no type is specified, traverses all incoming edges.
Named `in_` to avoid conflict with Elixir's `in` operator.
## Examples
iex> import Subaru.Query
iex> v("bob") |> in_(:follows)
%Subaru.Query{start: {:id, "bob"}, steps: [{:in, :follows}]}
iex> import Subaru.Query
iex> v("bob") |> in_()
%Subaru.Query{start: {:id, "bob"}, steps: [{:in, nil}]}
"""
@spec in_(t()) :: t()
@spec in_(t(), edge_type()) :: t()
def in_(%__MODULE__{} = query, edge_type \\ nil) do
add_step(query, {:in, edge_type})
end
@doc """
Filter results using a predicate function.
The function receives each vertex or edge and should return true to keep it.
## Examples
v("alice") |> out(:purchased) |> filter(& &1.year >= 2020)
v("alice") |> out(:follows) |> filter(fn user -> user.active end)
"""
@spec filter(t(), filter_fn()) :: t()
def filter(%__MODULE__{} = query, fun) when is_function(fun, 1) do
add_step(query, {:filter, fun})
end
@doc """
Limit the number of results.
## Examples
iex> import Subaru.Query
iex> v("alice") |> out(:follows) |> take(10)
%Subaru.Query{start: {:id, "alice"}, steps: [{:take, 10}, {:out, :follows}]}
"""
@spec take(t(), pos_integer()) :: t()
def take(%__MODULE__{} = query, count) when is_integer(count) and count > 0 do
add_step(query, {:take, count})
end
@doc """
Get edges instead of destination vertices.
Returns edge maps with `:from`, `:to`, `:type` and any edge properties.
## Examples
iex> import Subaru.Query
iex> v("alice") |> edges(:follows)
%Subaru.Query{start: {:id, "alice"}, steps: [{:edges, :follows}]}
iex> import Subaru.Query
iex> v("alice") |> edges()
%Subaru.Query{start: {:id, "alice"}, steps: [{:edges, nil}]}
"""
@spec edges(t()) :: t()
@spec edges(t(), edge_type()) :: t()
def edges(%__MODULE__{} = query, edge_type \\ nil) do
add_step(query, {:edges, edge_type})
end
@doc """
After `edges/1`, get the destination vertices.
## Examples
iex> import Subaru.Query
iex> v("alice") |> edges(:follows) |> to()
%Subaru.Query{start: {:id, "alice"}, steps: [:to, {:edges, :follows}]}
"""
@spec to(t()) :: t()
def to(%__MODULE__{} = query) do
add_step(query, :to)
end
# Private helpers
defp add_step(%__MODULE__{steps: steps} = query, step) do
# Prepend for O(1) instead of O(n) append. Steps are reversed at execution time.
%{query | steps: [step | steps]}
end
end