# SPDX-FileCopyrightText: 2025 ash_neo4j contributors # # SPDX-License-Identifier: MIT defmodule AshNeo4j.Resource.Info do @moduledoc "Resource information for AshNeo4j.DataLayer" alias Spark.Dsl.Extension alias AshNeo4j.Util alias AshNeo4j.{EdgeDescriptor, ResourceMapping} @doc """ The match label used for read, update, and destroy operations. This is the value of `label` in the `neo4j do` block — which may come from a fragment (e.g. `:Instance` from `BaseInstance`). Defaults to the PascalCase short name of the resource module. """ @spec label(Ash.Resource.t()) :: atom() | nil def label(resource) do Extension.get_persisted(resource, :label, nil) end @doc """ The label derived from the resource module's own short name (e.g. `:Shelf` for `MyApp.Access.Shelf`). Always set regardless of any fragment label override. Use this when you need to identify the specific resource type rather than its base type. """ @spec module_label(Ash.Resource.t()) :: atom() | nil def module_label(resource) do Extension.get_persisted(resource, :module_label, nil) end @doc """ The domain label is the PascalCase short name of the domain's Elixir Module name. It is used only on create. """ @spec domain_label(Ash.Resource.t()) :: atom() | nil def domain_label(resource) do Extension.get_persisted(resource, :domain_label, nil) end @doc """ Returns the full list of labels written to the node on CREATE. Always starts with the domain label, followed by the module label, then any additional base type labels from fragments. For example, `DiffoExample.Access.Shelf` (using `BaseInstance`) returns `[:Access, :Shelf, :Instance]`. """ @spec labels(Ash.Resource.t()) :: list(atom()) | nil def labels(resource) do Extension.get_persisted(resource, :labels, nil) || ([domain_label(resource), label(resource)] |> Enum.uniq() |> Enum.filter(& &1)) end @doc """ Returns the complete graph mapping for a resource as a `%AshNeo4j.ResourceMapping{}` struct. This is the single source of truth for how an Ash resource maps to the Neo4j graph. """ @spec mapping(Ash.Resource.t()) :: ResourceMapping.t() def mapping(resource) do if function_exported?(resource, :__ash_neo4j_mapping__, 0) do resource.__ash_neo4j_mapping__() else %ResourceMapping{ module: resource, domain_label: domain_label(resource), module_label: module_label(resource), label: label(resource), labels: labels(resource), properties: translations(resource), edges: Enum.map(relate(resource), &EdgeDescriptor.from_relate/1), relationship_attributes: relationship_attributes(resource), guards: AshNeo4j.DataLayer.Info.guard(resource), skip: AshNeo4j.DataLayer.Info.skip(resource) } end end @doc """ Returns the effective relate of the resource, merging DSL and defaults """ @spec relate(Ash.Resource.t()) :: list(tuple()) | nil def relate(resource) do Extension.get_persisted(resource, :relate, []) end @doc """ Returns the list of attribute translations for the resource. """ @spec translations(Ash.Resource.t()) :: keyword() | nil def translations(resource) do Extension.get_persisted(resource, :translations, []) end @doc """ Returns the relationship attributes for the resource. """ @spec relationship_attributes(Ash.Resource.t()) :: keyword() | nil def relationship_attributes(resource) do Extension.get_persisted(resource, :relationship_attributes, []) end @doc """ Returns a node_relationship that matches the relationship name """ @spec node_relationship(Ash.Resource.t(), atom() | String.t()) :: tuple() | nil def node_relationship(resource, name) when is_atom(resource) and is_atom(name) do List.keyfind(relate(resource), name, 0) end def node_relationship(resource, name) when is_atom(resource) and is_bitstring(name) do List.keyfind(relate(resource), String.to_atom(name), 0) end @doc """ Returns a node_relationship that matches the edge label, edge direction and destination label """ @spec node_relationship(Ash.Resource.t(), atom(), atom(), atom()) :: tuple() | nil def node_relationship(resource, edge_label, edge_direction, destination_label) when is_atom(resource) and is_atom(edge_label) and is_atom(edge_direction) and is_atom(destination_label) do Enum.find( relate(resource), fn related -> case related do {_, ^edge_label, ^edge_direction, ^destination_label} -> true _ -> false end end ) end @spec node_relationship(Ash.Resource.t(), atom(), atom(), list(atom())) :: tuple() | nil def node_relationship(resource, edge_label, edge_direction, destination_labels) when is_atom(resource) and is_atom(edge_label) and is_atom(edge_direction) and is_list(destination_labels) do destination_labels = List.delete(destination_labels, domain_label(resource)) Enum.reduce_while(destination_labels, nil, fn destination_label, acc -> node_relationship = Enum.find( relate(resource), fn related -> case related do {_, ^edge_label, ^edge_direction, ^destination_label} -> true _ -> false end end ) if node_relationship do {:halt, node_relationship} else {:cont, acc} end end) end @doc """ Returns the relationship from the source attribute, if any """ @spec relationship(Ash.Resource.t(), atom() | String.t()) :: tuple() | nil def relationship(resource, source_attribute) when is_atom(resource) and is_atom(source_attribute) do List.keyfind(relationship_attributes(resource), source_attribute, 0) end def relationship(resource, source_attribute) when is_atom(resource) and is_bitstring(source_attribute) do List.keyfind(relationship_attributes(resource), String.to_atom(source_attribute), 0) end @doc """ Returns a matching Ash.Resource.Info relationship given edge label, edge direction and destination node label """ @spec relationship(Ash.Resource.t(), atom(), atom(), atom()) :: struct() | nil def relationship(resource, edge_label, edge_direction, destination_label) when is_atom(resource) and is_atom(edge_label) and is_atom(edge_direction) and is_atom(destination_label) do node_relationship = node_relationship(resource, edge_label, edge_direction, destination_label) if node_relationship != nil do Ash.Resource.Info.relationship(resource, elem(node_relationship, 0)) end end @spec relationship(Ash.Resource.t(), atom(), atom(), list(atom())) :: struct() | nil def relationship(resource, edge_label, edge_direction, destination_labels) when is_atom(resource) and is_atom(edge_label) and is_atom(edge_direction) and is_list(destination_labels) do node_relationship = node_relationship(resource, edge_label, edge_direction, destination_labels) if node_relationship != nil do Ash.Resource.Info.relationship(resource, elem(node_relationship, 0)) end end @doc """ Returns the reverse node relationship given resource and relationship name """ @spec reverse_node_relationship(Ash.Resource.t(), atom()) :: tuple() | nil def reverse_node_relationship(resource, name) when is_atom(resource) and is_atom(name) do destination_resource = Ash.Resource.Info.related(resource, name) reverse_relationship_path = Ash.Resource.Info.reverse_relationship(resource, [name]) if reverse_relationship_path != nil do node_relationship(destination_resource, hd(reverse_relationship_path)) end end @doc """ Returns the reverse relationship given resource and relationship name """ @spec reverse_relationship(Ash.Resource.t(), atom()) :: tuple() | nil def reverse_relationship(resource, name) when is_atom(resource) and is_atom(name) do destination_resource = Ash.Resource.Info.related(resource, name) reverse_relationship_path = Ash.Resource.Info.reverse_relationship(resource, [name]) if reverse_relationship_path != nil do Ash.Resource.Info.relationship(destination_resource, hd(reverse_relationship_path)) end end @doc """ Returns whether the relationship is exclusive on the source resource """ @spec source_exclusive?(Ash.Resource.t(), atom()) :: boolean() def source_exclusive?(resource, name) when is_atom(resource) and is_atom(name) do relationship = Ash.Resource.Info.relationship(resource, name) relationship.cardinality == :one end @doc """ Returns whether the relationship is exclusive on the destination resource, given a source resource and source relationship name """ @spec destination_exclusive?(Ash.Resource.t(), atom()) :: boolean() def destination_exclusive?(resource, name) when is_atom(resource) and is_atom(name) do destination_resource = Ash.Resource.Info.related(resource, name) if resource == destination_resource do # same resource {^name, edge_label, edge_direction, destination_label} = node_relationship(resource, name) reverse_relationship = relationship(destination_resource, edge_label, Util.reverse(edge_direction), destination_label) if reverse_relationship != nil do reverse_relationship.cardinality == :one else false end else # different resource reverse_relationship_path = Ash.Resource.Info.reverse_relationship(resource, [name]) if reverse_relationship_path != nil do reverse_relationship = Ash.Resource.Info.relationship(destination_resource, hd(reverse_relationship_path)) reverse_relationship.cardinality == :one else false end end end @doc """ Converts an attribute name to a node property name string, translating if necessary """ @spec convert_to_property_name(Ash.Resource.t(), Ash.Query.Ref.t()) :: String.t() | nil def convert_to_property_name(resource, ash_query_ref) when is_atom(resource) and is_struct(ash_query_ref, Ash.Query.Ref) do attribute_name = Ash.Query.Ref.name(ash_query_ref) convert_to_property_name(resource, attribute_name) end @spec convert_to_property_name(Ash.Resource.t(), atom()) :: String.t() | nil def convert_to_property_name(resource, attribute_name) when is_atom(resource) and is_atom(attribute_name) do translations(resource) |> Keyword.get(attribute_name, attribute_name) |> to_string() end @doc """ Returns the Ash.Type of the attribute from the name """ @spec attribute_type(Ash.Resource.t(), atom()) :: Ash.Type.t() | nil def attribute_type(resource, ash_query_ref) when is_atom(resource) and is_struct(ash_query_ref, Ash.Query.Ref) do attribute_name = Ash.Query.Ref.name(ash_query_ref) attribute_type(resource, attribute_name) end @spec attribute_type(Ash.Resource.t(), atom()) :: Ash.Type.t() | nil def attribute_type(resource, attribute_name) when is_atom(resource) and is_atom(attribute_name) do case Ash.Resource.Info.attribute(resource, attribute_name) do nil -> nil attribute -> attribute.type end end @doc """ Converts attributes to node properties """ @spec convert_to_properties(Ash.Resource.t(), map()) :: map() def convert_to_properties(resource, attributes) when is_atom(resource) and is_map(attributes) do translations = translations(resource) Enum.reduce(attributes, %{}, fn {attribute_name, value}, acc -> property_name = Keyword.get(translations, attribute_name, attribute_name) Map.put(acc, property_name, value) end) end @doc """ Returns the list of node relationships which block resource deletion, given the source resource The node relationships are tuples of {edge_label, edge_direction, destination_label} These include explicit guard relationships. """ @spec preserve_node_relationships(Ash.Resource.t()) :: list(tuple()) def preserve_node_relationships(resource) when is_atom(resource) do Enum.reduce(relate(resource), AshNeo4j.DataLayer.Info.guard(resource), fn {name, edge_label, edge_direction, destination_label}, acc -> relationship = Ash.Resource.Info.relationship(resource, name) reverse_node_relationship = reverse_node_relationship(resource, relationship.name) if reverse_node_relationship do reverse_relationship = Ash.Resource.Info.relationship(relationship.destination, elem(reverse_node_relationship, 0)) cond do reverse_relationship && reverse_relationship.cardinality == :one -> if reverse_relationship.allow_nil? do acc else [{edge_label, edge_direction, destination_label} | acc] end true -> acc end else acc end end) end end