Current section

Files

Jump to
ash_neo4j lib query_helper.ex
Raw

lib/query_helper.ex

# SPDX-FileCopyrightText: 2025 ash_neo4j contributors <https://github.com/diffo-dev/ash_neo4j/graphs.contributors>
#
# SPDX-License-Identifier: MIT
defmodule AshNeo4j.QueryHelper do
require Logger
require Ash.Query
alias AshNeo4j.Cypher
alias AshNeo4j.DataLayer.Info
@moduledoc """
AshNeo4j DataLayer QueryHelper
"""
@doc """
Queries nodes, using Ash.Query
"""
@spec query_nodes(struct()) :: {:error, any()} | {:ok, any()}
def query_nodes(ash_query) when is_struct(ash_query) do
cypher =
cypher(ash_query)
|> order_by(ash_query)
|> skip(ash_query)
|> limit(ash_query)
case Cypher.run(cypher) do
{:ok, %Bolty.Response{results: results}} ->
{:ok, results}
{:error, _} ->
{:error, "Error running cypher #{cypher}"}
end
end
defp cypher(ash_query) when is_struct(ash_query) do
label = Info.label(ash_query.resource)
if ash_query.filter == nil do
# there is no filter, but we want related nodes to simulate foreign keys
"MATCH " <> Cypher.node(:s, [label]) <> " OPTIONAL MATCH (s)-[r]-(d) RETURN s, r, d"
else
# will a simple filter work?
simple_filter = Ash.Filter.to_simple_filter(ash_query.filter, skip_invalid?: true)
predicates = Map.get(simple_filter, :predicates, [])
if predicates == [] do
# simple filter didn't work
Logger.warning("AshNeo4j.QueryHelper: filter #{inspect(ash_query.filter)} is not a simple filter")
"MATCH " <> Cypher.node(:s, [label]) <> " OPTIONAL MATCH (s)-[r]-(d) RETURN s, r, d"
else
# need to sort out which predicates are source property related, and which are relationship related
relationship_predicates =
Enum.reduce(predicates, [], fn predicate, acc ->
if Map.has_key?(predicate, :operator) do
operator = convert_operator(predicate.operator)
property_name = Info.convert_to_property_name(ash_query.resource, predicate.left)
relationship = Info.relationship(ash_query.resource, property_name)
if (operator == "in" or operator == "=") and relationship != nil do
[predicate | acc]
else
acc
end
else
acc
end
end)
property_predicates = predicates -- relationship_predicates
cond do
Enum.empty?(relationship_predicates) ->
"MATCH (s:#{label}) WHERE " <>
predicates(ash_query.resource, property_predicates) <>
" OPTIONAL MATCH (s)-[r]-(d) RETURN s, r, d"
length(relationship_predicates) == 1 ->
predicate = hd(relationship_predicates)
operator = convert_operator(predicate.operator)
property_name = Info.convert_to_property_name(ash_query.resource, predicate.left)
property_value = convert_value(predicate.right)
relationship_name = elem(Info.relationship(ash_query.resource, property_name), 1)
relationship = Ash.Resource.Info.relationship(ash_query.resource, relationship_name)
node_relationship = Info.node_relationship(ash_query.resource, relationship_name)
dest_label = Info.label(relationship.destination)
dest_property_name =
Info.convert_to_property_name(relationship.destination, relationship.destination_attribute)
"MATCH " <>
Cypher.node(:s, [label]) <>
Cypher.relationship(node_relationship) <>
Cypher.node(:d, [dest_label]) <>
" WHERE " <>
Cypher.expression(:d, dest_property_name, operator, property_value) <>
" WITH s MATCH (s)-[r0]-(d0) RETURN s, r0, d0"
true ->
Logger.warning("AshNeo4j.QueryHelper: combination of predicates #{inspect(predicates)} not supported")
"MATCH " <> Cypher.node(:s, [label]) <> " OPTIONAL MATCH (s)-[r]-(d) RETURN s, r, d"
end
end
end
end
defp predicates(resource, predicates) when is_atom(resource) and is_list(predicates) do
Enum.map_join(predicates, " AND ", fn predicate ->
case predicate do
%{operator: predicate_operator} ->
operator = convert_operator(predicate_operator)
property_name = Info.convert_to_property_name(resource, predicate.left)
property_value = convert_value(predicate.right)
Cypher.expression(:s, property_name, operator, property_value)
%{name: :contains} ->
argument = hd(predicate.arguments)
property_name = AshNeo4j.DataLayer.Info.convert_to_property_name(resource, argument)
attribute = hd(tl(predicate.arguments))
Cypher.expression(:s, property_name, "contains", attribute)
_ ->
Logger.warning("AshNeo4j.QueryHelper: predicate #{inspect(predicate)} not handled")
"TRUE"
end
end)
end
defp order_by(cypher, ash_query) when is_bitstring(cypher) and is_struct(ash_query) do
case ash_query.sort do
nil ->
cypher
[] ->
cypher
_ ->
translations = AshNeo4j.DataLayer.Info.translations(ash_query.resource)
terms =
Enum.map_join(ash_query.sort, ", ", fn {name, order} ->
case order do
:desc -> "s.#{Keyword.get(translations, name, name)} DESC"
_ -> "s.#{Keyword.get(translations, name, name)} ASC"
end
end)
cypher <> " " <> "ORDER BY " <> terms
end
end
defp limit(cypher, ash_query) when is_bitstring(cypher) and is_struct(ash_query) do
case ash_query.limit do
nil -> cypher
_ -> cypher <> " LIMIT #{ash_query.limit}"
end
end
defp skip(cypher, ash_query) when is_bitstring(cypher) and is_struct(ash_query) do
case ash_query.offset do
nil -> cypher
0 -> cypher
_ -> cypher <> " SKIP #{ash_query.offset}"
end
end
defp convert_operator(:==), do: "="
defp convert_operator(:!=), do: "<>"
defp convert_operator(:in), do: "in"
defp convert_operator(:<=), do: "<="
defp convert_operator(:<), do: "<"
defp convert_operator(:>), do: ">"
defp convert_operator(:>=), do: ">="
defp convert_operator(:is_nil), do: "is_nil"
defp convert_operator(operator) do
Logger.warning("AshNeo4j.QueryHelper: operator #{operator} not handled")
nil
end
defp convert_value(value) when is_binary(value), do: "'#{value}'"
defp convert_value(value) when is_atom(value), do: "'#{value}'"
defp convert_value(values) when is_struct(values, MapSet),
do: "[#{Enum.map(values, fn value -> convert_value(value) end) |> Enum.join(",")}]"
defp convert_value(value), do: value
end