defmodule TerminusDB.Document do @moduledoc """ Document CRUD and query API for TerminusDB. Wraps the `/api/document/{path}` endpoints. A "document" is a JSON object conforming to a schema class, stored as linked triples. Documents can be inserted, retrieved, queried by template, replaced, and deleted. All functions require a `TerminusDB.Config` scoped to a database (via `TerminusDB.Config.with_database/2`). The organization defaults to `config.organization` but can be overridden per call via the `:organization` option. ## Graph types Operations target the `:instance` graph (data) by default. Pass `graph_type: :schema` to operate on the schema graph (schema documents are documents too). ## Quick start config = TerminusDB.Config.new(endpoint: "http://localhost:6363") |> TerminusDB.Config.with_database("mydb") # Insert a schema (Class document) {:ok, _} = TerminusDB.Document.insert(config, %{"@type" => "Class", "@id" => "Person", "name" => "xsd:string"}, author: "admin", message: "add schema", graph_type: :schema ) # Insert a document {:ok, _} = TerminusDB.Document.insert(config, %{"@type" => "Person", "name" => "Alice"}, author: "admin", message: "add Alice" ) # Retrieve documents by type {:ok, docs} = TerminusDB.Document.get(config, type: "Person", as_list: true) # Query by template {:ok, matches} = TerminusDB.Document.query(config, %{"@type" => "Person", "name" => "Alice"}) # Stream large result sets TerminusDB.Document.stream(config, type: "Person") |> Stream.each(&IO.inspect/1) |> Stream.run() """ alias TerminusDB.{Client, Config, Error} alias TerminusDB.Client.Params @type graph_type :: :instance | :schema @type insert_opt :: {:author, String.t()} | {:message, String.t()} | {:graph_type, graph_type()} | {:full_replace, boolean()} | {:raw_json, boolean()} | {:organization, String.t()} @type get_opt :: {:graph_type, graph_type()} | {:id, String.t()} | {:type, String.t()} | {:skip, non_neg_integer()} | {:count, pos_integer()} | {:as_list, boolean()} | {:unfold, boolean()} | {:minimized, boolean()} | {:compress_ids, boolean()} | {:organization, String.t()} @type update_opt :: {:author, String.t()} | {:message, String.t()} | {:graph_type, graph_type()} | {:create, boolean()} | {:raw_json, boolean()} | {:organization, String.t()} @type delete_opt :: {:author, String.t()} | {:message, String.t()} | {:graph_type, graph_type()} | {:id, String.t()} | {:nuke, boolean()} | {:organization, String.t()} @type query_opt :: {:graph_type, graph_type()} | {:skip, non_neg_integer()} | {:count, pos_integer()} | {:as_list, boolean()} | {:organization, String.t()} # Path building ------------------------------------------------------------- defp document_path(config, 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 "document/#{org}/#{db}/#{repo}/branch/#{branch}" end defp graph_type_param(opts), do: [graph_type: Atom.to_string(opts[:graph_type] || :instance)] # Public API ---------------------------------------------------------------- @doc """ Inserts one or more documents into the database. `document` can be a single map or a list of maps. The response body from TerminusDB (the inserted document IDs) is returned. ## Options - `:author` — commit author (required by the API). - `:message` — commit message (required by the API). - `:graph_type` — `:instance` (default) or `:schema`. - `:full_replace` — if `true`, delete all existing documents before inserting. - `:raw_json` — if `true`, insert as untyped `sys:JSONDocument` (no schema check). - `:organization` — overrides `config.organization`. ## Examples iex> config = TerminusDB.Config.new( ...> endpoint: "http://localhost:6363", ...> adapter: fn req -> ...> {req, Req.Response.new(status: 200, body: [%{"@id" => "Person/Alice"}])} ...> end ...> ) |> TerminusDB.Config.with_database("mydb") iex> {:ok, ids} = TerminusDB.Document.insert(config, ...> %{"@type" => "Person", "name" => "Alice"}, ...> author: "admin", message: "add Alice" ...> ) iex> ids [%{"@id" => "Person/Alice"}] Inserting a list of documents: iex> config = TerminusDB.Config.new( ...> endpoint: "http://localhost:6363", ...> adapter: fn req -> {req, Req.Response.new(status: 200, body: [%{"@id" => "Person/Alice"}, %{"@id" => "Person/Bob"}])} end ...> ) |> TerminusDB.Config.with_database("mydb") iex> {:ok, ids} = TerminusDB.Document.insert(config, [ ...> %{"@type" => "Person", "name" => "Alice"}, ...> %{"@type" => "Person", "name" => "Bob"} ...> ], author: "admin", message: "add people") iex> length(ids) 2 """ @spec insert(Config.t(), map() | [map()], [insert_opt()]) :: {:ok, term()} | {:error, Error.t()} def insert(config, document, opts \\ []) do path = document_path(config, opts) params = graph_type_param(opts) ++ commit_params(opts) ++ Params.flag_param(:full_replace, opts[:full_replace]) ++ Params.flag_param(:raw_json, opts[:raw_json]) Client.request(config, :post, path, json: document, params: params, area: :document) end @doc """ Inserts documents, returning the response body or raising `TerminusDB.Error`. ## Examples iex> config = TerminusDB.Config.new( ...> endpoint: "http://localhost:6363", ...> adapter: fn req -> {req, Req.Response.new(status: 200, body: [%{"@id" => "Person/Alice"}])} end ...> ) |> TerminusDB.Config.with_database("mydb") iex> TerminusDB.Document.insert!(config, %{"@type" => "Person", "name" => "Alice"}, ...> author: "admin", message: "add" ...> ) [%{"@id" => "Person/Alice"}] """ @spec insert!(Config.t(), map() | [map()], [insert_opt()]) :: term() def insert!(config, document, opts \\ []) do case insert(config, document, opts) do {:ok, body} -> body {:error, error} -> raise error end end @doc """ Retrieves documents from the database. With `:id`, returns a single document. Without `:id`, returns all documents (or those of a `:type`). By default TerminusDB returns concatenated JSON; pass `as_list: true` to get a JSON array decoded into a list of maps. ## Options - `:id` — retrieve a specific document by ID. - `:type` — retrieve documents of a specific type. - `:graph_type` — `:instance` (default) or `:schema`. - `:skip` — number of documents to skip (default `0`). - `:count` — max number of documents to return. - `:as_list` — request a JSON array instead of concatenated JSON. - `:unfold` — join referenced documents (default `true`). - `:minimized` — minify output (default `true`). - `:compress_ids` — compress IDs using prefixes (default `true`). - `:organization` — overrides `config.organization`. ## Examples Get a single document by ID: iex> config = TerminusDB.Config.new( ...> endpoint: "http://localhost:6363", ...> adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"@id" => "Person/Alice", "name" => "Alice"})} end ...> ) |> TerminusDB.Config.with_database("mydb") iex> {:ok, person} = TerminusDB.Document.get(config, id: "Person/Alice", as_list: false) iex> person["name"] "Alice" Get all documents of a type: iex> config = TerminusDB.Config.new( ...> endpoint: "http://localhost:6363", ...> adapter: fn req -> {req, Req.Response.new(status: 200, body: [%{"@id" => "Person/Alice"}, %{"@id" => "Person/Bob"}])} end ...> ) |> TerminusDB.Config.with_database("mydb") iex> {:ok, docs} = TerminusDB.Document.get(config, type: "Person", as_list: true) iex> length(docs) 2 """ @spec get(Config.t(), [get_opt()]) :: {:ok, term()} | {:error, Error.t()} def get(config, opts \\ []) do path = document_path(config, opts) params = graph_type_param(opts) ++ get_params(opts) Client.request(config, :get, path, params: params, area: :document) end @doc """ Retrieves documents, or raises `TerminusDB.Error`. ## Examples iex> config = TerminusDB.Config.new( ...> endpoint: "http://localhost:6363", ...> adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"@id" => "Person/Alice"})} end ...> ) |> TerminusDB.Config.with_database("mydb") iex> TerminusDB.Document.get!(config, id: "Person/Alice") %{"@id" => "Person/Alice"} """ @spec get!(Config.t(), [get_opt()]) :: term() def get!(config, opts \\ []) do case get(config, opts) do {:ok, body} -> body {:error, error} -> raise error end end @doc """ Queries documents by a template. `template` is a map describing the shape of documents to match, e.g. `%{"@type" => "Person", "age" => 30}`. TerminusDB returns all documents that match the template. ## Options - `:graph_type` — `:instance` (default) or `:schema`. - `:skip`, `:count` — pagination. - `:as_list` — return a JSON array. - `:organization` — overrides `config.organization`. ## Examples iex> config = TerminusDB.Config.new( ...> endpoint: "http://localhost:6363", ...> adapter: fn req -> {req, Req.Response.new(status: 200, body: [%{"@type" => "Person", "name" => "Alice", "age" => 30}])} end ...> ) |> TerminusDB.Config.with_database("mydb") iex> {:ok, matches} = TerminusDB.Document.query(config, %{"@type" => "Person", "age" => 30}) iex> hd(matches)["name"] "Alice" """ @spec query(Config.t(), map(), [query_opt()]) :: {:ok, term()} | {:error, Error.t()} def query(config, template, opts \\ []) do path = document_path(config, opts) body = Params.maybe_put( %{ "query" => template, "graph_type" => Atom.to_string(opts[:graph_type] || :instance), "skip" => opts[:skip] || 0 }, "count", opts[:count] ) params = [{:as_list, true}] Client.request(config, :post, path, json: body, params: params, headers: [{"x-http-method-override", "GET"}], area: :document ) end @doc """ Queries documents by template, or raises `TerminusDB.Error`. ## Examples iex> config = TerminusDB.Config.new( ...> endpoint: "http://localhost:6363", ...> adapter: fn req -> {req, Req.Response.new(status: 200, body: [%{"name" => "Alice"}])} end ...> ) |> TerminusDB.Config.with_database("mydb") iex> TerminusDB.Document.query!(config, %{"name" => "Alice"}) [%{"name" => "Alice"}] """ @spec query!(Config.t(), map(), [query_opt()]) :: term() def query!(config, template, opts \\ []) do case query(config, template, opts) do {:ok, body} -> body {:error, error} -> raise error end end @doc """ Replaces one or more existing documents. If a document does not exist, an error is returned unless `create: true` is set, in which case it is inserted. ## Options - `:author`, `:message` — commit metadata (required by the API). - `:graph_type` — `:instance` (default) or `:schema`. - `:create` — insert if the document does not exist (default `false`). - `:raw_json` — treat as untyped JSON (default `false`). - `:organization` — overrides `config.organization`. ## Examples iex> config = TerminusDB.Config.new( ...> endpoint: "http://localhost:6363", ...> adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"@id" => "Person/Alice", "name" => "Alicia"})} end ...> ) |> TerminusDB.Config.with_database("mydb") iex> {:ok, updated} = TerminusDB.Document.replace(config, ...> %{"@id" => "Person/Alice", "name" => "Alicia"}, ...> author: "admin", message: "rename Alice" ...> ) iex> updated["name"] "Alicia" """ @spec replace(Config.t(), map() | [map()], [update_opt()]) :: {:ok, term()} | {:error, Error.t()} def replace(config, document, opts \\ []) do path = document_path(config, opts) params = graph_type_param(opts) ++ commit_params(opts) ++ Params.flag_param(:create, opts[:create]) ++ Params.flag_param(:raw_json, opts[:raw_json]) Client.request(config, :put, path, json: document, params: params, area: :document) end @doc """ Replaces documents, or raises `TerminusDB.Error`. ## Examples iex> config = TerminusDB.Config.new( ...> endpoint: "http://localhost:6363", ...> adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"@id" => "Person/Alice"})} end ...> ) |> TerminusDB.Config.with_database("mydb") iex> TerminusDB.Document.replace!(config, %{"@id" => "Person/Alice", "name" => "Alicia"}, ...> author: "admin", message: "rename" ...> ) %{"@id" => "Person/Alice"} """ @spec replace!(Config.t(), map() | [map()], [update_opt()]) :: term() def replace!(config, document, opts \\ []) do case replace(config, document, opts) do {:ok, body} -> body {:error, error} -> raise error end end @doc """ Deletes documents from the database. With `:id`, deletes a single document. With `:nuke`, deletes all documents at the resource location. Without either, a body containing a list of IDs must be posted (not yet supported by this function; use `:id` or `:nuke`). ## Options - `:author`, `:message` — commit metadata (required by the API). - `:graph_type` — `:instance` (default) or `:schema`. - `:id` — delete a specific document by ID. - `:nuke` — delete all documents (dangerous). - `:organization` — overrides `config.organization`. ## Examples iex> config = TerminusDB.Config.new( ...> endpoint: "http://localhost:6363", ...> adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"api:status" => "api:success"})} end ...> ) |> TerminusDB.Config.with_database("mydb") iex> {:ok, resp} = TerminusDB.Document.delete(config, ...> id: "Person/Alice", author: "admin", message: "remove Alice" ...> ) iex> resp["api:status"] "api:success" """ @spec delete(Config.t(), [delete_opt()]) :: {:ok, term()} | {:error, Error.t()} def delete(config, opts \\ []) do path = document_path(config, opts) params = graph_type_param(opts) ++ commit_params(opts) ++ Params.flag_param(:nuke, opts[:nuke]) ++ Params.flag_param(:id, opts[:id]) Client.request(config, :delete, path, params: params, area: :document) end @doc """ Deletes documents, or raises `TerminusDB.Error`. ## Examples iex> config = TerminusDB.Config.new( ...> endpoint: "http://localhost:6363", ...> adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"api:status" => "api:success"})} end ...> ) |> TerminusDB.Config.with_database("mydb") iex> TerminusDB.Document.delete!(config, id: "Person/Alice", author: "a", message: "m") %{"api:status" => "api:success"} """ @spec delete!(Config.t(), [delete_opt()]) :: term() def delete!(config, opts \\ []) do case delete(config, opts) do {:ok, body} -> body {:error, error} -> raise error end end @doc """ Returns a `Stream` of documents, decoded incrementally from the response. Uses Req's response streaming (`into: :self`) and the concatenated-JSON splitter in `TerminusDB.Streaming` to yield documents one at a time with constant memory. Useful for large result sets. ## Options Accepts the same options as `get/2` (`:type`, `:graph_type`, `:skip`, `:count`, etc.). ## Examples # Stream all Person documents and process one at a time: TerminusDB.Document.stream(config, type: "Person") |> Stream.each(&IO.inspect/1) |> Stream.run() """ @spec stream(Config.t(), [get_opt()]) :: Enumerable.t() def stream(config, opts \\ []) do path = document_path(config, opts) params = graph_type_param(opts) ++ get_params(opts) case Client.request_response(config, :get, path, params: params, into: :self, area: :document ) do {:ok, resp} -> TerminusDB.Streaming.document_stream(resp, timeout: config.receive_timeout) {:error, error} -> raise error end end # Helpers ------------------------------------------------------------------- defp commit_params(opts) do Params.flag_param(:author, opts[:author]) ++ Params.flag_param(:message, opts[:message]) end defp get_params(opts) do # as_list/unfold/minimized/compress_ids are tri-state: the server defaults # them to true, so an explicit false must be sent to override. Params.flag_param(:id, opts[:id]) ++ Params.flag_param(:type, opts[:type]) ++ Params.flag_param(:skip, opts[:skip]) ++ Params.flag_param(:count, opts[:count]) ++ Params.bool_param(:as_list, opts[:as_list]) ++ Params.bool_param(:unfold, opts[:unfold]) ++ Params.bool_param(:minimized, opts[:minimized]) ++ Params.bool_param(:compress_ids, opts[:compress_ids]) end end