lib/redis_graph.ex

defmodule RedisGraph do
  @moduledoc """

  Query builder library that provides functions
  to construct Cypher queries to communicate with [RedisGraph](https://redis.io/docs/stack/graph/) database
  and interact with the entities through defined structures.

  The library is developed on top of an existing [library](https://github.com/crflynn/redisgraph-ex),
  written by Christopher Flynn, and provides additional functionality with refactored codebase to support
  [RedisGraph result set](https://redis.io/docs/stack/graph/design/client_spec/).

  To run `RedisGraph` locally with Docker, use

  ```bash
  docker run -p 6379:6379 -it --rm redis/redis-stack-server
  ```

  Here is a simple example of how to use the library:

  ```elixir
  alias RedisGraph.{Query, Graph, QueryResult}

  # Create a connection using Redix
  {:ok, conn} = Redix.start_link("redis://localhost:6379")

  # Create a graph
  graph = Graph.new(%{
    name: "social"
  })

  {:ok, query} =
        Query.new()
        |> Query.create()
        |> Query.node(:n, ["Person"], %{age: 30, name: "John Doe", works: true})
        |> Query.relationship_from_to(:r, "TRAVELS_TO", %{purpose: "pleasure"})
        |> Query.node(:m, ["Place"], %{name: "Japan"})
        |> Query.return(:n)
        |> Query.return_property(:n, "age", :Age)
        |> Query.return(:m)
        |> Query.build_query()

  # query will hold
  # "CREATE (n:Person {age: 30, name: 'John Doe', works: true})-[r:TRAVELS_TO {purpose: 'pleasure'}]->(m:Place {name: 'Japan'}) RETURN n, n.age AS Age, m

  # Execute the query
  {:ok, query_result} = RedisGraph.query(conn, graph.name, query)

  # Get result set
  result_set = Map.get(query_result, :result_set)
  # result_set will hold
  # [
  #   [
  #     %RedisGraph.Node{
  #       id: 2,
  #       alias: :n,
  #       labels: ["Person"],
  #       properties: %{age: 30, name: "John Doe", works: true}
  #     },
  #     30,
  #     %RedisGraph.Node{
  #       id: 3,
  #       alias: :m,
  #       labels: ["Place"],
  #       properties: %{name: "Japan"}
  #     }
  #   ]
  # ]

  ```
  """

  alias RedisGraph.{QueryResult, Util}

  require Logger

  @type connection() :: GenServer.server()

  @doc """
  Execute arbitrary command against the database.

  https://redis.io/docs/stack/graph/commands/

  Query commands will be a list of strings. They
  will begin with either `GRAPH.QUERY`,
  `GRAPH.EXPLAIN`, `GRAPH.DELETE` etc.

  The next element will be the name of the graph.

  The third element will be the query command.

  Optionally pass the last element `--compact`
  for compact results.

  Returns a `RedisGraph.QueryResult` containing the result set
  and metadata associated with the query or error message.

  ## Example:
  ```
  alias RedisGraph.Graph

  # Create a connection using Redix
  {:ok, conn} = Redix.start_link("redis://localhost:6379")

  # Create a graph
  graph = Graph.new(%{name: "imdb"})

  # Create the query
  query = [
      "GRAPH.QUERY",
      graph.name,
      "MATCH (a:actor)-[:act]->(m:movie {title:'straight outta compton'}) RETURN a",
      "--compact"
    ]

  # Call the command
  {:ok, query_result} = RedisGraph.command(conn, query)
  ```
  """
  @spec command(connection(), list(String.t())) ::
          {:ok, QueryResult.t()} | {:error, any()}
  def command(conn, c) do
    # Logger.debug(Enum.join(c, " "))
    IO.puts("c")
    IO.inspect(c)

    case Redix.command(conn, c) do
      {:ok, result} ->
        {:ok, QueryResult.new(%{conn: conn, graph_name: Enum.at(c, 1), raw_result_set: result})}

      {:error, _reason} = error ->
        error
    end
  end

  @doc """
  Run a query on a graph in the database.

  Returns a `RedisGraph.QueryResult` containing the result set
  and metadata associated with the query or error message.

  https://redis.io/commands/graph.query/

  ## Example:
  ```
  alias RedisGraph.Graph

  # Create a connection using Redix
  {:ok, conn} = Redix.start_link("redis://localhost:6379")

  # Create a graph
  graph = Graph.new(%{name: "imdb"})

  # Create the query
  query = "MATCH (a:actor)-[:act]->(m:movie {title:'straight outta compton'}) RETURN a"

  # Call the query
  {:ok, query_result} = RedisGraph.query(conn, graph.name, query)
  ```
  """
  @spec query(connection(), String.t(), String.t()) ::
          {:ok, QueryResult.t()} | {:error, any()}
  def query(conn, graph_name, q) do
    c = ["GRAPH.QUERY", graph_name, q, "--compact"]
    command(conn, c)
  end

  @doc """
  Fetch the execution plan for a query on a graph.

  Returns a raw result containing the query plan.

  https://redis.io/commands/graph.explain/

  ## Example:
  ```
  alias RedisGraph.Graph

  # Create a connection using Redix
  {:ok, conn} = Redix.start_link("redis://localhost:6379")

  # Create a graph
  graph = Graph.new(%{name: "imdb"})

  # Create the query
  query = "MATCH (a:actor)-[:act]->(m:movie {title:'straight outta compton'}) RETURN a"

  # Call the query
  {:ok, query_result} = RedisGraph.execution_plan(conn, graph.name, query)
  ```
  """
  @spec execution_plan(connection(), String.t(), String.t()) :: {:ok, list()} | {:error, any()}
  def execution_plan(conn, graph_name, q) do
    c = ["GRAPH.EXPLAIN", graph_name, q]

    case Redix.command(conn, c) do
      {:error, _reason} = error ->
        error

      {:ok, result} ->
        # Logger.debug(result)
        {:ok, result}
    end
  end

  @doc """
  Delete a graph from the database.

  Returns a `RedisGraph.QueryResult` containing the result set
  and metadata associated with the query or error message.

  https://redis.io/commands/graph.delete/

  ## Example:
  ```
  alias RedisGraph.Graph

  # Create a connection using Redix
  {:ok, conn} = Redix.start_link("redis://localhost:6379")

  # Create a graph
  graph = Graph.new(%{name: "imdb"})

  # Call the query
  {:ok, query_result} = RedisGraph.delete(conn, graph.name)
  ```
  """
  @spec delete(connection(), String.t()) ::
          {:ok, QueryResult.t()} | {:error, any()}
  def delete(conn, graph_name) do
    command = ["GRAPH.DELETE", graph_name]
    RedisGraph.command(conn, command)
  end

  @doc """
  Execute a procedure call against the graph specified and receive the raw result of the procedure call.

  https://redis.io/docs/stack/graph/design/client_spec/#procedure-calls

  ## Example:
  ```
  alias RedisGraph.Graph

  # Create a connection using Redix
  {:ok, conn} = Redix.start_link("redis://localhost:6379")

  # Create a graph
  graph = Graph.new(%{name: "imdb"})

  # Call the query
  {:ok, query_result} = RedisGraph.call_procedure_raw(conn, graph.name, "db.labels")
  ```
  """
  @spec call_procedure_raw(connection(), String.t(), String.t(), list(), map()) ::
          {:ok, list()} | {:error, any()}
  def call_procedure_raw(conn, graph_name, procedure, args \\ [], kwargs \\ %{}) do
    args = Enum.map_join(args, ",", &Util.value_to_string/1)

    yields = Map.get(kwargs, "y", [])

    yields =
      if length(yields) > 0 do
        " YIELD " <> Enum.join(yields, ",")
      else
        ""
      end

    q = "CALL " <> procedure <> "(" <> args <> ")" <> yields
    c = ["GRAPH.QUERY", graph_name, q, "--compact"]

    case Redix.command(conn, c) do
      {:ok, result} ->
        {:ok, result}

      {:error, _reason} = error ->
        error
    end
  end

  @doc """
  Execute a procedure call against the graph specified.

  Returns a `RedisGraph.QueryResult` containing the result set
  and metadata associated with the query or error message.

  https://redis.io/docs/stack/graph/design/client_spec/#procedure-calls

  ## Example:
  ```
  alias RedisGraph.Graph

  # Create a connection using Redix
  {:ok, conn} = Redix.start_link("redis://localhost:6379")

  # Create a graph
  graph = Graph.new(%{name: "imdb"})

  # Call the query
  {:ok, query_result} = RedisGraph.call_procedure(conn, graph.name, "db.labels")
  ```
  """
  @spec call_procedure(connection(), String.t(), String.t(), list(), map()) ::
          {:ok, QueryResult.t()} | {:error, any()}
  def call_procedure(conn, graph_name, procedure, args \\ [], kwargs \\ %{}) do
    args = Enum.map_join(args, ",", &Util.value_to_string/1)

    yields = Map.get(kwargs, "y", [])

    yields =
      if length(yields) > 0 do
        " YIELD " <> Enum.join(yields, ",")
      else
        ""
      end

    q = "CALL " <> procedure <> "(" <> args <> ")" <> yields
    c = ["GRAPH.QUERY", graph_name, q, "--compact"]

    RedisGraph.command(conn, c)
  end

  @doc """
  Fetch response of the `db.labels()` procedure call against the specified graph.
  It returns a `RedisGraph.QueryResult` containing the result set and metadata from the call.
    ## Example:
  ```
  alias RedisGraph.Graph

  # Create a connection using Redix
  {:ok, conn} = Redix.start_link("redis://localhost:6379")

  # Create a graph
  graph = Graph.new(%{name: "imdb"})

  # Call the query
  {:ok, query_result} = RedisGraph.labels(conn, graph.name)
  ```
  """
  @spec labels(connection(), String.t()) :: {:ok, list()} | {:error, any()}
  def labels(conn, graph_name) do
    call_procedure(conn, graph_name, "db.labels")
  end

  @doc """
  Fetch response of the `db.relationshipTypes()` procedure call against the specified graph.
  It returns a `RedisGraph.QueryResult` containing the result set and metadata from the call.
    ## Example:
  ```
  alias RedisGraph.Graph

  # Create a connection using Redix
  {:ok, conn} = Redix.start_link("redis://localhost:6379")

  # Create a graph
  graph = Graph.new(%{name: "imdb"})

  # Call the query
  {:ok, query_result} = RedisGraph.relationship_types(conn, graph.name)
  ```
  """
  @spec relationship_types(connection(), String.t()) :: {:ok, list()} | {:error, any()}
  def relationship_types(conn, graph_name) do
    call_procedure(conn, graph_name, "db.relationshipTypes")
  end

  @doc """
  Fetch response of the `db.propertyKeys()` procedure call against the specified graph.
  It returns a `RedisGraph.QueryResult` containing the result set and metadata from the call.
    ## Example:
  ```
  alias RedisGraph.Graph

  # Create a connection using Redix
  {:ok, conn} = Redix.start_link("redis://localhost:6379")

  # Create a graph
  graph = Graph.new(%{name: "imdb"})

  # Call the query
  {:ok, query_result} = RedisGraph.property_keys(conn, graph.name)
  ```
  """
  @spec property_keys(connection(), String.t()) :: {:ok, list()} | {:error, any()}
  def property_keys(conn, graph_name) do
    call_procedure(conn, graph_name, "db.propertyKeys")
  end
end