# SPDX-FileCopyrightText: 2025 ash_neo4j contributors # # SPDX-License-Identifier: MIT defmodule AshNeo4j.QueryHelper do require Logger require Ash.Query alias AshNeo4j.Cypher alias AshNeo4j.Cypher.Query alias AshNeo4j.Resource.Info, as: ResourceInfo alias AshNeo4j.ResourceMapping @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 mapping = ResourceInfo.mapping(ash_query.resource) query = ash_query |> build_query(mapping) |> Query.add_order_by(sort_terms(ash_query, mapping)) |> Query.add_skip(ash_query.offset) |> Query.add_limit(ash_query.limit) case Cypher.run(query) do {:ok, %Bolty.Response{results: results}} -> {:ok, results} {:error, _} -> {:error, "Error running cypher query"} end end defp build_query(ash_query, %ResourceMapping{} = mapping) do if ash_query.filter == nil do Query.node_read(mapping.label) else simple_filter = Ash.Filter.to_simple_filter(ash_query.filter, skip_invalid?: true) predicates = simple_filter |> Map.get(:predicates, []) |> Enum.reject(fn pred -> match?(%Ash.Query.Ref{attribute: %Ash.Query.Calculation{}}, Map.get(pred, :left)) end) if predicates == [] do Logger.debug("AshNeo4j.QueryHelper: filter #{inspect(ash_query.filter)} is not a simple filter") Query.node_read(mapping.label) else build_filtered_query(mapping, predicates) end end end defp build_filtered_query(%ResourceMapping{} = mapping, predicates) do relationship_predicates = Enum.filter(predicates, fn predicate -> if Map.has_key?(predicate, :operator) do prop = property_name(mapping, predicate.left) predicate.operator in [:==, :in] and ResourceInfo.relationship(mapping.module, prop) != nil else false end end) property_predicates = predicates -- relationship_predicates cond do Enum.empty?(relationship_predicates) -> conditions = to_conditions(mapping, property_predicates) Query.node_read_filtered(mapping.label, conditions) length(relationship_predicates) == 1 -> predicate = hd(relationship_predicates) prop = property_name(mapping, predicate.left) relationship_name = elem(ResourceInfo.relationship(mapping.module, prop), 1) relationship = Ash.Resource.Info.relationship(mapping.module, relationship_name) edge = Enum.find(mapping.edges, &(&1.relationship == relationship_name)) dest_label = ResourceInfo.label(relationship.destination) dest_property = ResourceInfo.convert_to_property_name(relationship.destination, relationship.destination_attribute) Query.relationship_read( mapping.label, edge.label, edge.direction, dest_label, dest_property, predicate.operator, to_param_value(predicate.right) ) true -> Logger.debug("AshNeo4j.QueryHelper: combination of predicates #{inspect(predicates)} not supported") Query.node_read(mapping.label) end end defp to_conditions(%ResourceMapping{} = mapping, predicates) do predicates |> Enum.map(fn %{operator: op} = predicate -> prop = property_name(mapping, predicate.left) val = if op == :is_nil, do: predicate.right, else: to_param_value(predicate.right) ci? = case_insensitive?(mapping, predicate.left, predicate.right) {prop, op, val, ci?} %{name: :contains} = predicate -> argument = hd(predicate.arguments) value = hd(tl(predicate.arguments)) prop = property_name(mapping, argument) ci? = case_insensitive?(mapping, argument, value) {prop, :contains, to_param_value(value), ci?} predicate -> Logger.debug("AshNeo4j.QueryHelper: predicate #{inspect(predicate)} not handled") nil end) |> Enum.reject(&is_nil/1) end defp sort_terms(ash_query, %ResourceMapping{} = mapping) do case ash_query.sort do sort when sort in [nil, []] -> [] sort -> sort |> Enum.reject(fn {name, _order} -> is_struct(name, Ash.Query.Calculation) end) |> Enum.map(fn {name, order} -> {Keyword.get(mapping.properties, name, name), order} end) end end defp property_name(%ResourceMapping{} = mapping, ref_or_atom) do attr_name = case ref_or_atom do %Ash.Query.Ref{} -> Ash.Query.Ref.name(ref_or_atom) atom when is_atom(atom) -> atom end Keyword.get(mapping.properties, attr_name, attr_name) |> to_string() end defp to_param_value(%Ash.CiString{} = v), do: Ash.CiString.value(v) defp to_param_value(%MapSet{} = ms), do: MapSet.to_list(ms) defp to_param_value(value), do: value defp case_insensitive?(%ResourceMapping{} = mapping, predicate_left, predicate_right) do ResourceInfo.attribute_type(mapping.module, predicate_left) in [Ash.Type.CiString, :ci_string] or match?(%Ash.CiString{}, predicate_right) end end