defmodule RedisGraph.QueryResult do @moduledoc """ A QueryResult is responsible for processing the data that was returned by RedisGraph. https://redis.io/docs/stack/graph/design/result_structure/ The resulting struct contains the result set header and records, statistics about the query executed, and referential lists of entity identifiers, specifically labels, property keys, and relationship types. The labels refer to the `labels` attribute in Node entities in the graph, The property keys are the keys found in any Node or Relationship property maps. The relationship types are the `type` attributes of Relationship entities in the graph. ## Example ```elixir 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 queries and send them to RedisGraph create_query = "CREATE (a:actor {name: 'Hugh Jackman'})-[:act]->(m:movie {title:'Wolverine'}) RETURN a" {:ok, _query_result} = RedisGraph.query(conn, graph.name, create_query) match_query = "MATCH (a:actor {name: 'Hugh Jackman'})-[:act]->(m:movie {title:'Wolverine'}) RETURN a" {:ok, query_result} = RedisGraph.query(conn, graph.name, match_query) # Show the resulting statistics IO.inspect(query_result.statistics) # Query result statistics %{ "Labels added" => nil, "Nodes created" => nil, "Nodes deleted" => nil, "Properties set" => nil, "Query internal execution time" => "0.228669", "Relationships created" => nil, "Relationships deleted" => nil } ``` """ alias RedisGraph.Relationship alias RedisGraph.Node @labels_added "Labels added" @labels_removed "Labels removed" @nodes_created "Nodes created" @nodes_deleted "Nodes deleted" @properties_set "Properties set" @properties_removed "Properties removed" @relationships_created "Relationships created" @relationships_deleted "Relationships deleted" @indices_created "Indices created" @indices_deleted "Indices deleted" @query_internal_execution_time "Query internal execution time" @graph_removed_internal_execution_time "Graph removed, internal execution time" @value_type %{ VALUE_UNKNOWN: 0, VALUE_NULL: 1, VALUE_STRING: 2, VALUE_INTEGER: 3, VALUE_BOOLEAN: 4, VALUE_DOUBLE: 5, VALUE_ARRAY: 6, VALUE_EDGE: 7, VALUE_NODE: 8, VALUE_PATH: 9, VALUE_MAP: 10, VALUE_POINT: 11 } @type t() :: %__MODULE__{ conn: pid(), graph_name: String.t(), raw_result_set: list(any()) | String.t(), header: list(atom()), result_set: list(list(any())), statistics: %{String.t() => String.t()}, labels: %{number() => String.t()}, property_keys: %{number() => String.t()}, relationship_types: %{number() => String.t()} } @enforce_keys [:conn, :graph_name, :raw_result_set] defstruct [ :conn, :graph_name, :raw_result_set, :header, :result_set, :statistics, :labels, :property_keys, :relationship_types ] @doc """ Create a new QueryResult from a map. Pass a map with a connection, graph name, and raw RedisGraph result/response. The raw result is the output of the function `Redix.command/2`. This function is invoked by the `RedisGraph.command/2` function. The functions `RedisGraph.query/3`, `RedisGraph.delete/2` and `RedisGraph.call_procedure/5` will also return a new `RedisGraph.QueryResult`. """ @spec new(map()) :: t() def new(map) do s = struct(__MODULE__, map) process_raw_result(s) end # Process result for GRAPH.QUERY @spec process_raw_result(t()) :: t() defp process_raw_result(%{raw_result_set: result} = query_result) when is_list(result) do if length(result) == 1 do %{query_result | statistics: parse_statistics(Enum.at(result, 0))} else %{ parse_results(query_result) | statistics: parse_statistics(Enum.at(result, -1)) } end end # Process result for GRAPH.DELETE defp process_raw_result(%{raw_result_set: result} = query_result) when is_binary(result) do %{query_result | statistics: parse_statistics(result)} end @doc "Return a boolean indicating emptiness of a QueryResult." @spec is_empty(t()) :: boolean() def is_empty(query_result) do if is_nil(query_result.result_set) or Enum.empty?(query_result.result_set) do true else false end end @spec parse_statistics(list(any()) | String.t()) :: map() defp parse_statistics(raw_statistics) when is_list(raw_statistics) do stats = [ @labels_added, @labels_removed, @nodes_created, @nodes_deleted, @properties_set, @properties_removed, @relationships_created, @relationships_deleted, @indices_created, @indices_deleted, @query_internal_execution_time ] stats |> Enum.map(fn s -> {s, get_value(s, raw_statistics)} end) |> Enum.into(%{}) end defp parse_statistics(raw_statistics) when is_binary(raw_statistics) do %{ @graph_removed_internal_execution_time => extract_value(@graph_removed_internal_execution_time, raw_statistics) } end defp get_value(stat, [raw_statistic | raw_statistics]) do case extract_value(stat, raw_statistic) do nil -> get_value(stat, raw_statistics) value -> value end end defp get_value(_stat, []) do nil end defp extract_value(stat, raw_statistic) do if String.contains?(raw_statistic, stat) do raw_statistic |> String.split(": ") |> Enum.at(1) |> String.split(" ") |> Enum.at(0) else nil end end @spec parse_header(t()) :: list(atom()) defp parse_header(%{raw_result_set: [header | _tail]} = _query_result) do header |> Enum.map(fn h -> Enum.at(h, 1) |> String.to_atom() end) end @spec fetch_metadata(t()) :: t() defp fetch_metadata(%{conn: conn, graph_name: name} = query_result) do labels = parse_procedure_call(RedisGraph.call_procedure_raw(conn, name, "db.labels")) property_keys = parse_procedure_call(RedisGraph.call_procedure_raw(conn, name, "db.propertyKeys")) relationship_types = parse_procedure_call(RedisGraph.call_procedure_raw(conn, name, "db.relationshipTypes")) %{ query_result | labels: labels, property_keys: property_keys, relationship_types: relationship_types } end defp parse_procedure_call(response) do case response do {:ok, result} -> [_columns_array, records_array, _metadata_array] = result # e.g. of records_array -- [ [[_value_type, element]], [[_value_type, element]] ] Enum.with_index(records_array, fn [[_value_type, element] | _], index -> {index, element} end) |> Enum.into(%{}) {:error, reason} -> raise reason end end @spec parse_results(t()) :: t() defp parse_results(%{raw_result_set: [header | _tail]} = query_result) do query_result = fetch_metadata(query_result) if length(header) > 0 do header = parse_header(query_result) %{ query_result | header: header, result_set: parse_records(query_result) } else query_result end end @spec parse_records(t()) :: list(any) defp parse_records(%{raw_result_set: [_header | [records_array | _statistics]]} = query_result) do Enum.map(records_array, &parse_row(query_result, &1)) end @spec parse_row(t(), list(any())) :: list(any()) defp parse_row(%{raw_result_set: [header | _tail]} = query_result, row) do Stream.with_index(row) |> Enum.map(fn {cell, index} -> [column_type, alias] = header |> Enum.at(index) parse_cell(query_result, cell, column_type, String.to_atom(alias)) end) end # https://redis.io/docs/stack/graph/design/client_spec/ @spec parse_cell(t(), list(any()), number(), atom() | nil) :: Node.t() defp parse_cell(query_result, cell, column_type \\ 1, alias \\ nil) defp parse_cell(query_result, cell, 1, alias) do [value_type | [value]] = cell cond do value_type == @value_type[:VALUE_NODE] -> parse_node(query_result, value, alias) value_type == @value_type[:VALUE_EDGE] -> parse_relationship(query_result, value, alias) value_type == @value_type[:VALUE_NULL] || value_type == @value_type[:VALUE_INTEGER] || value_type == @value_type[:VALUE_STRING] -> value value_type == @value_type[:VALUE_BOOLEAN] -> if(value == "true", do: true, else: false) value_type == @value_type[:VALUE_DOUBLE] -> String.to_float(value) value_type == @value_type[:VALUE_ARRAY] -> Enum.map(value, fn inner_value -> parse_cell(query_result, inner_value) end) value_type == @value_type[:VALUE_PATH] || value_type == @value_type[:VALUE_MAP] || value_type == @value_type[:VALUE_POINT] -> "will be implemented in future" true -> "unknown value type" end end # Unused, retained for client compatibility. defp parse_cell(query_result, cell, 2, alias) do parse_node(query_result, cell, alias) end # Unused, retained for client compatibility. defp parse_cell(query_result, cell, 3, alias) do parse_relationship(query_result, cell, alias) end @spec parse_node(t(), list(any()), atom()) :: Node.t() defp parse_node(query_result, cell, alias) do [node_id | [label_indexes | [properties]]] = cell Node.new(%{ id: node_id, alias: alias, labels: parse_labels(query_result, label_indexes), properties: parse_entity_properties(query_result, properties) }) end @spec parse_relationship(t(), list(any()), atom()) :: Relationship.t() defp parse_relationship(query_result, cell, alias) do [relationship_id | [relation_index | [src_node_id | [dest_node_id | [properties]]]]] = cell Relationship.new(%{ id: relationship_id, alias: alias, type: parse_relationship_type(query_result, relation_index), src_node: src_node_id, dest_node: dest_node_id, properties: parse_entity_properties(query_result, properties) }) end @spec parse_labels(t(), list(number())) :: list() defp parse_labels(query_result, label_indexes) do Enum.map(label_indexes, fn label_id -> Map.get(query_result.labels, label_id) end) end @spec parse_relationship_type(t(), list(number())) :: String.t() defp parse_relationship_type(query_result, relationship_type_index) do Map.get(query_result.relationship_types, relationship_type_index) end @spec parse_entity_properties(t(), list(number())) :: list() defp parse_entity_properties(query_result, properties) do Enum.map(properties, fn [property_id | cell] -> {:"#{Map.get(query_result.property_keys, property_id)}", parse_cell(query_result, cell, 1)} end) |> Enum.into(%{}) end @doc "Transform the results_set from QueryResult into a list of maps as records." @spec results_to_maps(t()) :: list(map()) def results_to_maps(%{header: header, result_set: records} = _query_result) do records |> Enum.map(fn record -> record |> Enum.with_index() |> Enum.map(fn {v, idx} -> {Enum.at(header, idx), v} end) |> Enum.into(%{}) end) end defp get_stat(query_result, stat) do Map.get(query_result.statistics, stat) end @doc "Get the `labels added` quantity from a QueryResult." @spec labels_added(t()) :: String.t() def labels_added(query_result) do get_stat(query_result, @labels_added) end @doc "Get the `labels removed` quantity from a QueryResult." @spec labels_removed(t()) :: String.t() def labels_removed(query_result) do get_stat(query_result, @labels_removed) end @doc "Get the `nodes created` quantity from a QueryResult." @spec nodes_created(t()) :: String.t() def nodes_created(query_result) do get_stat(query_result, @nodes_created) end @doc "Get the `nodes deleted` quantity from a QueryResult." @spec nodes_deleted(t()) :: String.t() def nodes_deleted(query_result) do get_stat(query_result, @nodes_deleted) end @doc "Get the `properties set` quantity from a QueryResult." @spec properties_set(t()) :: String.t() def properties_set(query_result) do get_stat(query_result, @properties_set) end @doc "Get the `properties removed` quantity from a QueryResult." @spec properties_removed(t()) :: String.t() def properties_removed(query_result) do get_stat(query_result, @properties_removed) end @doc "Get the `relationships created` quantity from a QueryResult." @spec relationships_created(t()) :: String.t() def relationships_created(query_result) do get_stat(query_result, @relationships_created) end @doc "Get the `relationships deleted` quantity from a QueryResult." @spec relationships_deleted(t()) :: String.t() def relationships_deleted(query_result) do get_stat(query_result, @relationships_deleted) end @doc "Get the `indices created` quantity from a QueryResult." @spec indices_created(t()) :: String.t() def indices_created(query_result) do get_stat(query_result, @indices_created) end @doc "Get the `indices deleted` quantity from a QueryResult." @spec indices_deleted(t()) :: String.t() def indices_deleted(query_result) do get_stat(query_result, @indices_deleted) end @doc "Get the `query internal execution time` (ms) from a QueryResult." @spec query_internal_execution_time(t()) :: String.t() def query_internal_execution_time(query_result) do get_stat(query_result, @query_internal_execution_time) end end