Packages

An idiomatic Elixir client for TerminusDB, the version-controlled document graph database. Features DB management, document CRUD with streaming, schema frames, branching, diff/merge, a ~100-operator WOQL DSL, GraphQL, and telemetry. Built on Req with immutable configs and typed errors.

Current section

Files

Jump to
terminusdb_client lib terminus_db woql.ex
Raw

lib/terminus_db/woql.ex

defmodule TerminusDB.WOQL do
@moduledoc """
A functional builder DSL for WOQL (Web Object Query Language).
WOQL is TerminusDB's Datalog-based query language. This module provides a
small but solid set of composable functions that build a `TerminusDB.WOQL.Query`
struct, which can be serialized to the JSON-LD wire format via `to_jsonld/1`
and executed via `TerminusDB.WOQL.execute/3`.
This is WOQL DSL v0.1 - a focused subset covering the most common patterns.
Future releases will extend the vocabulary.
## Design
The DSL is purely functional (no macros). Each function returns a
`%WOQL.Query{}` struct and composes by nesting, mirroring the recommended
functional WOQL style. Variables are plain strings using the `v:Name`
convention.
## Supported vocabulary (v0.1)
| Function | WOQL JSON-LD type |
| --- | --- |
| `triple/3` | `Triple` |
| `and_/1` | `And` |
| `or_/1` | `Or` |
| `eq/2` | `Equals` |
| `select/2` | `Select` |
| `read_document/2` | `ReadDocument` |
| `type_of/2` | `TypeOf` |
## Quick start
import TerminusDB.WOQL
query =
and_([
triple("v:Person", "rdf:type", "@schema:Person"),
triple("v:Person", "name", "v:Name")
])
jsonld = TerminusDB.WOQL.to_jsonld(query)
# Execute against a database
config = TerminusDB.Config.new(endpoint: "http://localhost:6363")
config = TerminusDB.Config.with_database(config, "mydb")
{:ok, result} = TerminusDB.WOQL.execute(config, query)
"""
alias TerminusDB.{Client, Config, Error}
alias TerminusDB.Client.Params
defstruct [:op, :args]
@type t :: %__MODULE__{op: atom(), args: [term()]}
@type woql_var :: String.t()
@type woql_node :: String.t() | woql_var()
@type value :: String.t() | woql_var() | number() | boolean() | map()
# --------------------------------------------------------------------------
# Query builders
# --------------------------------------------------------------------------
@doc """
Builds a `Triple` pattern: subject, predicate, object.
Any argument can be a variable (`"v:Name"`) or a constant.
## Examples
iex> q = TerminusDB.WOQL.triple("v:Person", "name", "v:Name")
iex> q.op
:triple
"""
@spec triple(woql_node(), woql_node(), value()) :: t()
def triple(subject, predicate, object) do
%__MODULE__{op: :triple, args: [subject, predicate, object]}
end
@doc """
Builds an `And` conjunction of sub-queries.
## Examples
iex> q = TerminusDB.WOQL.and_([
...> TerminusDB.WOQL.triple("v:S", "rdf:type", "v:T"),
...> TerminusDB.WOQL.eq("v:T", "Person")
...> ])
iex> q.op
:and
"""
@spec and_([t()]) :: t()
def and_(queries) when is_list(queries) do
%__MODULE__{op: :and, args: queries}
end
@doc """
Builds an `Or` disjunction of sub-queries.
## Examples
iex> q = TerminusDB.WOQL.or_([
...> TerminusDB.WOQL.eq("v:Name", "Alice"),
...> TerminusDB.WOQL.eq("v:Name", "Bob")
...> ])
iex> q.op
:or
"""
@spec or_([t()]) :: t()
def or_(queries) when is_list(queries) do
%__MODULE__{op: :or, args: queries}
end
@doc """
Builds an `Equals` unification: left equals right.
## Examples
iex> q = TerminusDB.WOQL.eq("v:Name", "Alice")
iex> q.op
:eq
"""
@spec eq(value(), value()) :: t()
def eq(left, right) do
%__MODULE__{op: :eq, args: [left, right]}
end
@doc """
Builds a `Select` that projects the given variables from a sub-query.
`vars` is a list of variable names (e.g. `["v:Name", "v:Person"]`).
## Examples
iex> q = TerminusDB.WOQL.select(["v:Name"],
...> TerminusDB.WOQL.and_([
...> TerminusDB.WOQL.triple("v:Person", "name", "v:Name")
...> ])
...> )
iex> q.op
:select
"""
@spec select([woql_var()], t()) :: t()
def select(vars, query) when is_list(vars) and is_struct(query, __MODULE__) do
%__MODULE__{op: :select, args: [vars, query]}
end
@doc """
Builds a `ReadDocument` that reads a document by ID into a variable.
## Examples
iex> q = TerminusDB.WOQL.read_document("Person/Alice", "v:Doc")
iex> q.op
:read_document
"""
@spec read_document(String.t(), woql_var()) :: t()
def read_document(id, var) do
%__MODULE__{op: :read_document, args: [id, var]}
end
@doc """
Builds a `TypeOf` that unifies the type of a node with a variable.
## Examples
iex> q = TerminusDB.WOQL.type_of("v:Person", "v:Type")
iex> q.op
:type_of
"""
@spec type_of(woql_node(), woql_var()) :: t()
def type_of(node, var) do
%__MODULE__{op: :type_of, args: [node, var]}
end
# --------------------------------------------------------------------------
# Serialization
# --------------------------------------------------------------------------
@doc """
Serializes a `WOQL.Query` to the JSON-LD wire format expected by the
`/api/woql` endpoint.
## Examples
iex> q = TerminusDB.WOQL.triple("v:S", "name", "v:N")
iex> jsonld = TerminusDB.WOQL.to_jsonld(q)
iex> jsonld["@type"]
"Triple"
"""
@spec to_jsonld(t()) :: map()
def to_jsonld(%__MODULE__{} = query) do
encode(query)
end
@doc """
Deserializes a JSON-LD WOQL query back into a `WOQL.Query` struct.
## Examples
iex> jsonld = %{"@type" => "Triple", "subject" => %{"@type" => "NodeValue", "variable" => "S"}, "predicate" => %{"@type" => "NodeValue", "node" => "name"}, "object" => %{"@type" => "DataValue", "variable" => "N"}}
iex> q = TerminusDB.WOQL.from_jsonld(jsonld)
iex> q.op
:triple
"""
@spec from_jsonld(map()) :: t()
def from_jsonld(%{} = jsonld) do
decode(jsonld)
end
# --------------------------------------------------------------------------
# Execution
# --------------------------------------------------------------------------
@doc """
Executes a WOQL query against the database scoped in `config`.
Returns `{:ok, result}` where `result` is a map containing `bindings` (a list
of maps, one per solution), or `{:error, TerminusDB.Error.t()}`.
## Options
- `:author` - commit author (for write queries).
- `:message` - commit message (for write queries).
- `:all_witnesses` - check for all errors (default `false`).
- `:organization` - overrides `config.organization`.
- `:repo` - overrides `config.repo`.
- `:branch` - overrides `config.branch`.
## Examples
iex> config = TerminusDB.Config.new(
...> endpoint: "http://localhost:6363",
...> adapter: fn req ->
...> {req, Req.Response.new(status: 200, body: %{"bindings" => [%{"Name" => "Alice"}]})}
...> end
...> ) |> TerminusDB.Config.with_database("mydb")
iex> q = TerminusDB.WOQL.select(["v:Name"],
...> TerminusDB.WOQL.and_([TerminusDB.WOQL.triple("v:P", "name", "v:Name")])
...> )
iex> {:ok, result} = TerminusDB.WOQL.execute(config, q)
iex> result["bindings"]
[%{"Name" => "Alice"}]
"""
@spec execute(Config.t(), t(), keyword()) :: {:ok, map()} | {:error, Error.t()}
def execute(config, %__MODULE__{} = query, opts \\ []) do
org = opts[:organization] || config.organization
db =
config.database ||
raise Error, reason: :http, message: "no database scoped in config"
repo = opts[:repo] || config.repo
branch = opts[:branch] || config.branch
path = "woql/#{org}/#{db}/#{repo}/branch/#{branch}"
body =
%{"query" => to_jsonld(query)}
|> Params.maybe_put("commit_info", build_commit_info(opts))
|> Params.maybe_put("all_witnesses", opts[:all_witnesses])
Client.request(config, :post, path, json: body, area: :woql)
end
@doc """
Executes a WOQL query, or raises `TerminusDB.Error`.
## Examples
iex> config = TerminusDB.Config.new(
...> endpoint: "http://localhost:6363",
...> adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"bindings" => []})} end
...> ) |> TerminusDB.Config.with_database("mydb")
iex> q = TerminusDB.WOQL.triple("v:S", "p", "v:O")
iex> TerminusDB.WOQL.execute!(config, q)
%{"bindings" => []}
"""
@spec execute!(Config.t(), t(), keyword()) :: map()
def execute!(config, query, opts \\ []) do
case execute(config, query, opts) do
{:ok, body} -> body
{:error, error} -> raise error
end
end
# --------------------------------------------------------------------------
# Internal: JSON-LD encoding
#
# The TerminusDB WOQL JSON-LD format uses short type names ("Triple", "And",
# etc.) and wraps values in NodeValue/DataValue objects:
#
# Variables: {"@type": "NodeValue", "variable": "Name"} (for nodes)
# {"@type": "DataValue", "variable": "Name"} (for data values)
# Constants: {"@type": "NodeValue", "node": "rdf:type"} (for node/IRI strings)
# {"@type": "DataValue", "data": {"@type": "xsd:string", "@value": ...}}
# (for literal strings, numbers, booleans)
# --------------------------------------------------------------------------
defp encode(%__MODULE__{op: :triple, args: [s, p, o]}) do
%{
"@type" => "Triple",
"subject" => encode_node(s),
"predicate" => encode_node(p),
"object" => encode_value(o)
}
end
defp encode(%__MODULE__{op: :and, args: queries}) do
%{
"@type" => "And",
"and" => Enum.map(queries, &encode/1)
}
end
defp encode(%__MODULE__{op: :or, args: queries}) do
%{
"@type" => "Or",
"or" => Enum.map(queries, &encode/1)
}
end
defp encode(%__MODULE__{op: :eq, args: [left, right]}) do
%{
"@type" => "Equals",
"left" => encode_data(left),
"right" => encode_data(right)
}
end
defp encode(%__MODULE__{op: :select, args: [vars, query]}) do
%{
"@type" => "Select",
"variables" => Enum.map(vars, &encode_select_var/1),
"query" => encode(query)
}
end
defp encode(%__MODULE__{op: :read_document, args: [id, var]}) do
%{
"@type" => "ReadDocument",
"document" => encode_node(id),
"identifier" => encode_value(var)
}
end
defp encode(%__MODULE__{op: :type_of, args: [node, var]}) do
%{
"@type" => "TypeOf",
"value" => encode_value(node),
"type" => encode_node(var)
}
end
# Encode a node value: variables become NodeValue with "variable",
# constants become NodeValue with "node".
defp encode_node(var) when is_binary(var) and binary_part(var, 0, 2) == "v:" do
%{"@type" => "NodeValue", "variable" => String.slice(var, 2..-1//1)}
end
defp encode_node(value) when is_binary(value) do
%{"@type" => "NodeValue", "node" => value}
end
# Encode a value (triple object, type_of value): variables become DataValue
# with "variable", constant strings become NodeValue with "node" (IRIs),
# numbers/booleans become DataValue with xsd-typed data.
defp encode_value(var) when is_binary(var) and binary_part(var, 0, 2) == "v:" do
%{"@type" => "DataValue", "variable" => String.slice(var, 2..-1//1)}
end
defp encode_value(value) when is_binary(value) do
%{"@type" => "NodeValue", "node" => value}
end
defp encode_value(value) when is_integer(value) do
%{"@type" => "DataValue", "data" => %{"@type" => "xsd:integer", "@value" => value}}
end
defp encode_value(value) when is_float(value) do
%{"@type" => "DataValue", "data" => %{"@type" => "xsd:decimal", "@value" => value}}
end
defp encode_value(value) when is_boolean(value) do
%{"@type" => "DataValue", "data" => %{"@type" => "xsd:boolean", "@value" => value}}
end
defp encode_value(value) when is_map(value), do: value
# Encode a data value for Equals: literals must be wrapped in DataValue with
# the correct xsd type. Variables use DataValue with "variable".
defp encode_data(var) when is_binary(var) and binary_part(var, 0, 2) == "v:" do
%{"@type" => "DataValue", "variable" => String.slice(var, 2..-1//1)}
end
defp encode_data(value) when is_binary(value) do
%{"@type" => "DataValue", "data" => %{"@type" => "xsd:string", "@value" => value}}
end
defp encode_data(value) when is_integer(value) do
%{"@type" => "DataValue", "data" => %{"@type" => "xsd:integer", "@value" => value}}
end
defp encode_data(value) when is_float(value) do
%{"@type" => "DataValue", "data" => %{"@type" => "xsd:decimal", "@value" => value}}
end
defp encode_data(value) when is_boolean(value) do
%{"@type" => "DataValue", "data" => %{"@type" => "xsd:boolean", "@value" => value}}
end
# Select variables are bare variable name strings (without v: prefix).
defp encode_select_var(var) when is_binary(var) and binary_part(var, 0, 2) == "v:" do
String.slice(var, 2..-1//1)
end
defp encode_select_var(var) when is_binary(var), do: var
# --------------------------------------------------------------------------
# Internal: JSON-LD decoding
# --------------------------------------------------------------------------
defp decode(%{"@type" => "Triple"} = m) do
triple(decode_node(m["subject"]), decode_node(m["predicate"]), decode_value(m["object"]))
end
defp decode(%{"@type" => "And", "and" => queries}) do
and_(Enum.map(queries, &decode/1))
end
defp decode(%{"@type" => "Or", "or" => queries}) do
or_(Enum.map(queries, &decode/1))
end
defp decode(%{"@type" => "Equals"} = m) do
eq(decode_data(m["left"]), decode_data(m["right"]))
end
defp decode(%{"@type" => "Select", "variables" => vars, "query" => query}) do
select(Enum.map(vars, &decode_select_var/1), decode(query))
end
defp decode(%{"@type" => "ReadDocument"} = m) do
read_document(decode_node(m["document"]), decode_value(m["identifier"]))
end
defp decode(%{"@type" => "TypeOf"} = m) do
type_of(decode_value(m["value"]), decode_node(m["type"]))
end
defp decode_node(%{"@type" => "NodeValue", "variable" => name}) do
"v:#{name}"
end
defp decode_node(%{"@type" => "NodeValue", "node" => node}) do
node
end
defp decode_node(value), do: value
defp decode_value(%{"@type" => "DataValue", "variable" => name}) do
"v:#{name}"
end
defp decode_value(%{"@type" => "NodeValue", "variable" => name}) do
"v:#{name}"
end
defp decode_value(%{"@type" => "NodeValue", "node" => node}) do
node
end
defp decode_value(%{"@type" => "DataValue", "data" => %{"@value" => value}}) do
value
end
defp decode_value(value), do: value
defp decode_data(%{"@type" => "DataValue", "variable" => name}) do
"v:#{name}"
end
defp decode_data(%{"@type" => "DataValue", "data" => %{"@value" => value}}) do
value
end
defp decode_data(value), do: value
defp decode_select_var(name) when is_binary(name), do: "v:#{name}"
defp build_commit_info(opts) do
author = opts[:author]
message = opts[:message]
if author || message do
%{"author" => author || "", "message" => message || ""}
end
end
end