From ea86c9728528f65a5b11e878d3d7f36ea42cb92e Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Mon, 18 May 2026 21:28:57 +0930 Subject: [PATCH 1/2] test cases to isolate issue --- AGENTS.md | 12 +- test/fragment_test.exs | 103 ++++++++++++++++++ test/support/provider.ex | 12 ++ test/support/resource/base_type.ex | 39 +++++++ test/support/resource/blueprint.ex | 28 +++++ test/support/resource/cross_domain_base.ex | 39 +++++++ .../support/resource/cross_domain_instance.ex | 13 +++ test/support/resource/noise_instance.ex | 14 +++ test/support/resource/typed_instance.ex | 12 ++ 9 files changed, 269 insertions(+), 3 deletions(-) create mode 100644 test/fragment_test.exs create mode 100644 test/support/provider.ex create mode 100644 test/support/resource/base_type.ex create mode 100644 test/support/resource/blueprint.ex create mode 100644 test/support/resource/cross_domain_base.ex create mode 100644 test/support/resource/cross_domain_instance.ex create mode 100644 test/support/resource/noise_instance.ex create mode 100644 test/support/resource/typed_instance.ex diff --git a/AGENTS.md b/AGENTS.md index a6010e9..406d283 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,12 +22,18 @@ is the Diffo project; upstream bugs found while working in Diffo belong here. a frequent source of bugs and the most important thing to get right. 3. Run `mix test` before and after your change to confirm nothing regressed. +## Fixing bugs + +Before writing any fix, review existing test coverage for the affected behaviour. If the bug +has no test, write the failing test first — this confirms the reproduction and guards the fix +against regression. Only then implement the fix and verify the test passes. + ## Project structure ``` lib/ data_layer.ex — Ash.DataLayer behaviour: CRUD, aggregates, calculations, - transaction, enrichments (OPTIONAL MATCH → FK attributes) + transaction, enrichments (OPTIONAL MATCH → source attributes) cypher.ex — Cypher string helpers: node/2, relationship/3, expression/5, parameterized_node/3, render/1, run/1 cypher/query.ex — Typed clause structs (Match, Where, Return, …) and builder @@ -111,7 +117,7 @@ The `convert_node_to_resource_impl/4` loop iterates translations and reads node Because `belongs_to` source attributes are excluded, the loop does not touch them — their values must survive intact from the enrichments map that seeds the accumulator. -## Enrichments (OPTIONAL MATCH → FK attributes) +## Enrichments (OPTIONAL MATCH → source attributes) After a read query `MATCH (s:Label) OPTIONAL MATCH (s)-[r]-(d) RETURN s, r, d`, `enrichments/3` in `DataLayer` processes each `{edge, dest_node}` pair and populates: @@ -123,7 +129,7 @@ in `DataLayer` processes each `{edge, dest_node}` pair and populates: The lookup uses `mapping.edges` (from `mapping.module`). If an edge returned by the OPTIONAL MATCH has no matching entry in `mapping.edges` (wrong label, wrong direction, or missing relate -entry), `enrichments/3` silently returns `acc` unchanged and the FK attribute remains nil. +entry), `enrichments/3` silently returns `acc` unchanged and the source attribute remains nil. `edge_direction/2` determines direction by comparing `dest_node.id` with `edge.start` / `edge.end`: diff --git a/test/fragment_test.exs b/test/fragment_test.exs new file mode 100644 index 0000000..20a6c56 --- /dev/null +++ b/test/fragment_test.exs @@ -0,0 +1,103 @@ +# SPDX-FileCopyrightText: 2025 ash_neo4j contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshNeo4j.FragmentTest do + @moduledoc false + use ExUnit.Case, async: true + alias AshNeo4j.BoltyHelper + alias AshNeo4j.Sandbox + alias AshNeo4j.Test.Resource.Blueprint + alias AshNeo4j.Test.Resource.CrossDomainInstance + alias AshNeo4j.Test.Resource.NoiseInstance + alias AshNeo4j.Test.Resource.Specification + alias AshNeo4j.Test.Resource.TypedInstance + + setup_all do + BoltyHelper.start() + end + + setup do + Sandbox.checkout() + on_exit(&Sandbox.rollback/0) + end + + describe "belongs_to enrichment from fragment" do + test "specification_id is populated on read when belongs_to is declared on the fragment" do + spec = Specification |> Ash.create!(%{name: "mySpec"}) + + instance = + TypedInstance + |> Ash.create!(%{name: "instance_001", specified_by: spec.id}) + + # Value is correct immediately after create + assert instance.specification_id == spec.id + + # Reload via Ash.get — this is where the bug manifests + reloaded = TypedInstance |> Ash.get!(instance.id) + + assert reloaded.specification_id == spec.id + end + + test "specification_id is nil when no specification edge exists" do + instance = TypedInstance |> Ash.create!(%{name: "instance_no_spec"}) + + reloaded = TypedInstance |> Ash.get!(instance.id) + + assert reloaded.specification_id == nil + end + end + + describe "belongs_to enrichment across domain boundary" do + test "blueprint_id is populated on read when belongs_to target is in a different domain" do + blueprint = Blueprint |> Ash.create!(%{name: "myBlueprint"}) + + instance = + CrossDomainInstance + |> Ash.create!(%{name: "cross_001", blueprinted_by: blueprint.id}) + + assert instance.blueprint_id == blueprint.id + + reloaded = CrossDomainInstance |> Ash.get!(instance.id) + + assert reloaded.blueprint_id == blueprint.id + end + + test "blueprint_id is nil when no blueprint edge exists" do + instance = CrossDomainInstance |> Ash.create!(%{name: "cross_no_blueprint"}) + + reloaded = CrossDomainInstance |> Ash.get!(instance.id) + + assert reloaded.blueprint_id == nil + end + end + + describe "label scoping with fragment noise" do + test "Ash.read! returns only the target resource when a sibling fragment resource exists" do + # Create a NoiseInstance — shares :CrossDomainType label with CrossDomainInstance. + # If reads scope only by fragment label, this noise node will appear in + # CrossDomainInstance reads, revealing the #257 label scoping bug. + _noise = NoiseInstance |> Ash.create!(%{name: "noise_node"}) + + blueprint = Blueprint |> Ash.create!(%{name: "scopingBlueprint"}) + instance = CrossDomainInstance |> Ash.create!(%{name: "scoped_001", blueprinted_by: blueprint.id}) + + results = CrossDomainInstance |> Ash.read!() + + assert length(results) == 1 + assert hd(results).id == instance.id + assert hd(results).blueprint_id == blueprint.id + end + + test "Ash.get! populates blueprint_id even when sibling fragment nodes exist" do + _noise = NoiseInstance |> Ash.create!(%{name: "noise_for_get"}) + + blueprint = Blueprint |> Ash.create!(%{name: "getBlueprintNoise"}) + instance = CrossDomainInstance |> Ash.create!(%{name: "get_scoped", blueprinted_by: blueprint.id}) + + reloaded = CrossDomainInstance |> Ash.get!(instance.id) + + assert reloaded.blueprint_id == blueprint.id + end + end +end diff --git a/test/support/provider.ex b/test/support/provider.ex new file mode 100644 index 0000000..2a5a149 --- /dev/null +++ b/test/support/provider.ex @@ -0,0 +1,12 @@ +# SPDX-FileCopyrightText: 2025 ash_neo4j contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshNeo4j.Test.Provider do + @moduledoc false + use Ash.Domain + + resources do + allow_unregistered? true + end +end diff --git a/test/support/resource/base_type.ex b/test/support/resource/base_type.ex new file mode 100644 index 0000000..d383520 --- /dev/null +++ b/test/support/resource/base_type.ex @@ -0,0 +1,39 @@ +# SPDX-FileCopyrightText: 2025 ash_neo4j contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshNeo4j.Test.Resource.BaseType do + @moduledoc false + + # Fragment that declares belongs_to :specification with an explicit edge label. + # Specification has no reverse relationship back (too many instances to load). + # This mirrors the BaseInstance → Specification setup in diffo. + use Spark.Dsl.Fragment, + of: Ash.Resource, + data_layer: AshNeo4j.DataLayer + + neo4j do + label :Type + relate [{:specification, :SPECIFIED_BY, :outgoing, :Specification}] + end + + actions do + default_accept :* + defaults [:read, :destroy] + + create :create do + primary? true + argument :specified_by, :uuid + change manage_relationship(:specified_by, :specification, type: :append_and_remove) + end + end + + attributes do + uuid_primary_key :id + attribute :name, :string, public?: true + end + + relationships do + belongs_to :specification, AshNeo4j.Test.Resource.Specification, public?: true + end +end diff --git a/test/support/resource/blueprint.ex b/test/support/resource/blueprint.ex new file mode 100644 index 0000000..4bdfa0d --- /dev/null +++ b/test/support/resource/blueprint.ex @@ -0,0 +1,28 @@ +# SPDX-FileCopyrightText: 2025 ash_neo4j contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshNeo4j.Test.Resource.Blueprint do + @moduledoc false + + # Destination resource in the Provider domain — no reverse relationship back. + # Mirrors the Specification → BaseInstance direction in diffo: many instances + # may reference one Blueprint, so Blueprints do not load their instances. + use Ash.Resource, + domain: AshNeo4j.Test.Provider, + data_layer: AshNeo4j.DataLayer + + actions do + defaults [:read, :destroy, update: :*] + + create :create do + primary? true + accept [:name] + end + end + + attributes do + uuid_primary_key :id + attribute :name, :string, public?: true + end +end diff --git a/test/support/resource/cross_domain_base.ex b/test/support/resource/cross_domain_base.ex new file mode 100644 index 0000000..246f4d7 --- /dev/null +++ b/test/support/resource/cross_domain_base.ex @@ -0,0 +1,39 @@ +# SPDX-FileCopyrightText: 2025 ash_neo4j contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshNeo4j.Test.Resource.CrossDomainBase do + @moduledoc false + + # Fragment that declares belongs_to :blueprint where Blueprint is in a different + # Ash domain (Provider vs SRM). Tests that enrichments resolve source attributes + # across domain boundaries. + use Spark.Dsl.Fragment, + of: Ash.Resource, + data_layer: AshNeo4j.DataLayer + + neo4j do + label :CrossDomainType + relate [{:blueprint, :BLUEPRINTED_BY, :outgoing, :Blueprint}] + end + + actions do + default_accept :* + defaults [:read, :destroy] + + create :create do + primary? true + argument :blueprinted_by, :uuid + change manage_relationship(:blueprinted_by, :blueprint, type: :append_and_remove) + end + end + + attributes do + uuid_primary_key :id + attribute :name, :string, public?: true + end + + relationships do + belongs_to :blueprint, AshNeo4j.Test.Resource.Blueprint, public?: true + end +end diff --git a/test/support/resource/cross_domain_instance.ex b/test/support/resource/cross_domain_instance.ex new file mode 100644 index 0000000..70e0989 --- /dev/null +++ b/test/support/resource/cross_domain_instance.ex @@ -0,0 +1,13 @@ +# SPDX-FileCopyrightText: 2025 ash_neo4j contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshNeo4j.Test.Resource.CrossDomainInstance do + @moduledoc false + + # Resource in SRM domain that extends CrossDomainBase, whose belongs_to target + # (Blueprint) lives in the Provider domain. Exercises cross-domain enrichment. + use Ash.Resource, + domain: AshNeo4j.Test.SRM, + fragments: [AshNeo4j.Test.Resource.CrossDomainBase] +end diff --git a/test/support/resource/noise_instance.ex b/test/support/resource/noise_instance.ex new file mode 100644 index 0000000..1cb1769 --- /dev/null +++ b/test/support/resource/noise_instance.ex @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: 2025 ash_neo4j contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshNeo4j.Test.Resource.NoiseInstance do + @moduledoc false + + # Second resource extending CrossDomainBase — used as "noise" to verify label + # scoping: if reads use only the fragment label (:CrossDomainType), this + # resource's nodes will appear in CrossDomainInstance reads and vice versa. + use Ash.Resource, + domain: AshNeo4j.Test.SRM, + fragments: [AshNeo4j.Test.Resource.CrossDomainBase] +end diff --git a/test/support/resource/typed_instance.ex b/test/support/resource/typed_instance.ex new file mode 100644 index 0000000..ecbe4f9 --- /dev/null +++ b/test/support/resource/typed_instance.ex @@ -0,0 +1,12 @@ +# SPDX-FileCopyrightText: 2025 ash_neo4j contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshNeo4j.Test.Resource.TypedInstance do + @moduledoc false + + # Resource that extends BaseType fragment — mirrors a concrete Instance kind in diffo. + use Ash.Resource, + domain: AshNeo4j.Test.SRM, + fragments: [AshNeo4j.Test.Resource.BaseType] +end From 797c8b3c411382e8cca75c4d36a5018c0edac023 Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Mon, 18 May 2026 23:40:36 +0930 Subject: [PATCH 2/2] added extended domain --- lib/cypher/query.ex | 127 ++++++++++++--------- lib/data_layer.ex | 20 ++-- lib/data_layer/domain.ex | 56 +++++++++ lib/data_layer/domain/info.ex | 24 ++++ lib/neo4j_helper.ex | 20 ++-- lib/persisters/persist_labels.ex | 20 +++- lib/persisters/persist_mapping.ex | 8 +- lib/query_helper.ex | 10 +- lib/resource/info.ex | 85 ++++++++++---- lib/resource_mapping.ex | 17 ++- lib/verifiers/verify_labels_pascal_case.ex | 2 +- mix.exs | 4 +- test/data_layer/info_test.exs | 34 ++++++ test/fragment_test.exs | 15 +++ test/resource/info_test.exs | 8 +- test/support/fragment/test_domain.ex | 18 +++ test/support/provider.ex | 2 +- test/type_test.exs | 10 +- 18 files changed, 357 insertions(+), 123 deletions(-) create mode 100644 lib/data_layer/domain.ex create mode 100644 lib/data_layer/domain/info.ex create mode 100644 test/support/fragment/test_domain.ex diff --git a/lib/cypher/query.ex b/lib/cypher/query.ex index 532f78e..3b42fb7 100644 --- a/lib/cypher/query.ex +++ b/lib/cypher/query.ex @@ -153,13 +153,13 @@ defmodule AshNeo4j.Cypher.Query do # --------------------------------------------------------------------------- @doc """ - `MATCH (s:Label) OPTIONAL MATCH (s)-[r]-(d) RETURN s, r, d` + `MATCH (s:L1:L2) OPTIONAL MATCH (s)-[r]-(d) RETURN s, r, d` """ - @spec node_read(atom()) :: t() - def node_read(label) when is_atom(label) do + @spec node_read(atom() | [atom()]) :: t() + def node_read(label) do %__MODULE__{ clauses: [ - %Match{pattern: Cypher.node(:s, [label])}, + %Match{pattern: Cypher.node(:s, List.wrap(label))}, %OptionalMatch{pattern: "(s)-[r]-(d)"}, %Return{items: ["s", "r", "d"]} ] @@ -167,19 +167,19 @@ defmodule AshNeo4j.Cypher.Query do end @doc """ - `MATCH (s:Label) WHERE OPTIONAL MATCH (s)-[r]-(d) RETURN s, r, d` + `MATCH (s:L1:L2) WHERE OPTIONAL MATCH (s)-[r]-(d) RETURN s, r, d` Returns `node_read/1` when `conditions` is empty. """ - @spec node_read_filtered(atom(), [condition()]) :: t() - def node_read_filtered(label, []) when is_atom(label), do: node_read(label) + @spec node_read_filtered(atom() | [atom()], [condition()]) :: t() + def node_read_filtered(label, []), do: node_read(label) - def node_read_filtered(label, conditions) when is_atom(label) and is_list(conditions) do + def node_read_filtered(label, conditions) when is_list(conditions) do {where_string, params} = build_conditions(:s, conditions) %__MODULE__{ clauses: [ - %Match{pattern: Cypher.node(:s, [label])}, + %Match{pattern: Cypher.node(:s, List.wrap(label))}, %Where{conditions: [where_string]}, %OptionalMatch{pattern: "(s)-[r]-(d)"}, %Return{items: ["s", "r", "d"]} @@ -189,15 +189,15 @@ defmodule AshNeo4j.Cypher.Query do end @doc """ - `MATCH (s:SrcLabel)-[r:EdgeLabel]-(d:DestLabel) WHERE d.prop $param WITH s MATCH (s)-[r0]-(d0) RETURN s, r0, d0` + `MATCH (s:SrcLabels)-[r:EdgeLabel]-(d:DestLabel) WHERE d.prop $param WITH s MATCH (s)-[r0]-(d0) RETURN s, r0, d0` """ - @spec relationship_read(atom(), atom(), atom(), atom(), String.t(), atom(), any()) :: t() + @spec relationship_read(atom() | [atom()], atom(), atom(), atom(), String.t(), atom(), any()) :: t() def relationship_read(src_label, edge_label, direction, dest_label, dest_property, operator, value) - when is_atom(src_label) and is_atom(edge_label) and is_atom(direction) and is_atom(dest_label) do + when is_atom(edge_label) and is_atom(direction) and is_atom(dest_label) do param_key = "d_#{dest_property}" match_pattern = - Cypher.node(:s, [src_label]) <> + Cypher.node(:s, List.wrap(src_label)) <> Cypher.relationship(:r, edge_label, direction) <> Cypher.node(:d, [dest_label]) @@ -216,13 +216,13 @@ defmodule AshNeo4j.Cypher.Query do end @doc """ - `MATCH (n:Label {props}) OPTIONAL MATCH (n)-[r]-(d) RETURN n, r, d` + `MATCH (n:L1:L2 {props}) OPTIONAL MATCH (n)-[r]-(d) RETURN n, r, d` Like `node_read/1` but matches by properties in the MATCH pattern (not a WHERE clause). """ - @spec node_read_with_properties(atom(), map()) :: t() - def node_read_with_properties(label, properties) when is_atom(label) and is_map(properties) do - {pattern, params} = Cypher.parameterized_node(:s, [label], properties) + @spec node_read_with_properties(atom() | [atom()], map()) :: t() + def node_read_with_properties(label, properties) when is_map(properties) do + {pattern, params} = Cypher.parameterized_node(:s, List.wrap(label), properties) %__MODULE__{ clauses: [ @@ -270,16 +270,17 @@ defmodule AshNeo4j.Cypher.Query do Related-nodes query — returns one row per (source, destination) pair for expression-based aggregates that need full destination records for Elixir-side evaluation. - `MATCH (s:Label) WHERE s.pk IN $agg_ids OPTIONAL MATCH (s)(d) RETURN s.pk AS source_id, d AS dest_node` + `MATCH (s:L1:L2) WHERE s.pk IN $agg_ids OPTIONAL MATCH (s)(d) RETURN s.pk AS source_id, d AS dest_node` """ - @spec related_nodes(atom(), atom(), [any()], [{atom(), atom(), atom()}]) :: t() + @spec related_nodes(atom() | [atom()], atom(), [any()], [{atom(), atom(), atom()}]) :: t() def related_nodes(source_label, pk_field, ids, path_segments) - when is_atom(source_label) and is_atom(pk_field) and is_list(ids) and is_list(path_segments) do + when is_atom(pk_field) and is_list(ids) and is_list(path_segments) do path = build_agg_path(path_segments) + src = labels_string(source_label) %__MODULE__{ clauses: [ - %Match{pattern: "(s:#{source_label})"}, + %Match{pattern: "(s:#{src})"}, %Where{conditions: ["s.#{pk_field} IN $agg_ids"]}, %OptionalMatch{pattern: "(s)#{path}"}, %Return{items: ["s.#{pk_field} AS source_id", "d AS dest_node"]} @@ -291,13 +292,13 @@ defmodule AshNeo4j.Cypher.Query do @doc """ Per-record aggregate — returns one row per source node with the aggregate value. - `MATCH (s:Label) WHERE s.pk IN $agg_ids OPTIONAL MATCH (s)(d) RETURN s.pk AS source_id, agg_fn AS name` + `MATCH (s:L1:L2) WHERE s.pk IN $agg_ids OPTIONAL MATCH (s)(d) RETURN s.pk AS source_id, agg_fn AS name` `path_segments` is a list of `{edge_label, direction, dest_label}` tuples describing the traversal from source to the node being aggregated. """ @spec aggregate_per_record( - atom(), + atom() | [atom()], atom(), [any()], [{atom(), atom(), atom()}], @@ -307,13 +308,14 @@ defmodule AshNeo4j.Cypher.Query do boolean() ) :: t() def aggregate_per_record(source_label, pk_field, ids, path_segments, kind, field, name, uniq? \\ false) - when is_atom(source_label) and is_atom(pk_field) and is_list(ids) and is_list(path_segments) and is_atom(kind) do + when is_atom(pk_field) and is_list(ids) and is_list(path_segments) and is_atom(kind) do path = build_agg_path(path_segments) expr = aggregate_expr(kind, field, name, uniq?) + src = labels_string(source_label) %__MODULE__{ clauses: [ - %Match{pattern: "(s:#{source_label})"}, + %Match{pattern: "(s:#{src})"}, %Where{conditions: ["s.#{pk_field} IN $agg_ids"]}, %OptionalMatch{pattern: "(s)#{path}"}, %Return{items: ["s.#{pk_field} AS source_id", expr]} @@ -325,18 +327,27 @@ defmodule AshNeo4j.Cypher.Query do @doc """ Total aggregate — returns a single row with the aggregate value across all source nodes. - `MATCH (s:Label) WHERE s.pk IN $agg_ids OPTIONAL MATCH (s)(d) RETURN agg_fn AS name` + `MATCH (s:L1:L2) WHERE s.pk IN $agg_ids OPTIONAL MATCH (s)(d) RETURN agg_fn AS name` """ - @spec aggregate_total(atom(), atom(), [any()], [{atom(), atom(), atom()}], atom(), atom() | nil, atom(), boolean()) :: - t() + @spec aggregate_total( + atom() | [atom()], + atom(), + [any()], + [{atom(), atom(), atom()}], + atom(), + atom() | nil, + atom(), + boolean() + ) :: t() def aggregate_total(source_label, pk_field, ids, path_segments, kind, field, name, uniq? \\ false) - when is_atom(source_label) and is_atom(pk_field) and is_list(ids) and is_list(path_segments) and is_atom(kind) do + when is_atom(pk_field) and is_list(ids) and is_list(path_segments) and is_atom(kind) do path = build_agg_path(path_segments) expr = aggregate_expr(kind, field, name, uniq?) + src = labels_string(source_label) %__MODULE__{ clauses: [ - %Match{pattern: "(s:#{source_label})"}, + %Match{pattern: "(s:#{src})"}, %Where{conditions: ["s.#{pk_field} IN $agg_ids"]}, %OptionalMatch{pattern: "(s)#{path}"}, %Return{items: [expr]} @@ -368,14 +379,14 @@ defmodule AshNeo4j.Cypher.Query do end @doc """ - `MATCH (n:Label {match_props}) SET n += {set_props} REMOVE n.p1, n.p2 RETURN n` + `MATCH (n:L1:L2 {match_props}) SET n += {set_props} REMOVE n.p1, n.p2 RETURN n` Handles all combinations of empty/non-empty set_props and remove_props. """ - @spec update_node(atom(), map(), map(), [atom()]) :: t() + @spec update_node(atom() | [atom()], map(), map(), [atom()]) :: t() def update_node(label, match_props, set_props, remove_props \\ []) - when is_atom(label) and is_map(match_props) and is_map(set_props) and is_list(remove_props) do - {match_pattern, match_params} = Cypher.parameterized_node(:n, [label], match_props) + when is_map(match_props) and is_map(set_props) and is_list(remove_props) do + {match_pattern, match_params} = Cypher.parameterized_node(:n, List.wrap(label), match_props) {props_cypher, set_params} = Cypher.parameterized_properties(:n, set_props) set_clauses = if map_size(set_props) > 0, do: [%Set{expression: "n += #{props_cypher}"}], else: [] @@ -388,26 +399,26 @@ defmodule AshNeo4j.Cypher.Query do end @doc """ - `MATCH (n:Label {props}) DETACH DELETE n` + `MATCH (n:L1:L2 {props}) DETACH DELETE n` """ - @spec delete_nodes(atom(), map()) :: t() - def delete_nodes(label, properties \\ %{}) when is_atom(label) and is_map(properties) do - {pattern, params} = Cypher.parameterized_node(:n, [label], properties) + @spec delete_nodes(atom() | [atom()], map()) :: t() + def delete_nodes(label, properties \\ %{}) when is_map(properties) do + {pattern, params} = Cypher.parameterized_node(:n, List.wrap(label), properties) %__MODULE__{clauses: [%Match{pattern: pattern}, %DetachDelete{items: ["n"]}], params: params} end @doc """ - `MATCH (n:Label {props}) WHERE NOT guard1 AND NOT guard2 DETACH DELETE n` + `MATCH (n:L1:L2 {props}) WHERE NOT guard1 AND NOT guard2 DETACH DELETE n` `guards` is a list of `{edge_label, direction, dest_label}` tuples. Falls back to `delete_nodes/2` when guards is empty. """ - @spec delete_nodes_guarded(atom(), map(), list()) :: t() + @spec delete_nodes_guarded(atom() | [atom()], map(), list()) :: t() def delete_nodes_guarded(label, properties, []), do: delete_nodes(label, properties) def delete_nodes_guarded(label, properties, guards) - when is_atom(label) and is_map(properties) and is_list(guards) do - {pattern, params} = Cypher.parameterized_node(:n, [label], properties) + when is_map(properties) and is_list(guards) do + {pattern, params} = Cypher.parameterized_node(:n, List.wrap(label), properties) conditions = Enum.map(guards, fn {edge_label, direction, dest_label} -> @@ -448,10 +459,10 @@ defmodule AshNeo4j.Cypher.Query do DELETE r0 WITH s MATCH (d:DestLabel {d_props}) MERGE (s)-[r:EDGE]->(d) RETURN s, r, d """ - @spec relate_unrelating_source(atom(), map(), atom(), map(), atom(), atom()) :: t() + @spec relate_unrelating_source(atom() | [atom()], map(), atom(), map(), atom(), atom()) :: t() def relate_unrelating_source(src_label, src_props, dest_label, dest_props, edge_label, direction) - when is_atom(src_label) and is_atom(dest_label) and is_atom(edge_label) and is_atom(direction) do - {src_pattern, src_params} = Cypher.parameterized_node(:s, [src_label], src_props) + when is_atom(dest_label) and is_atom(edge_label) and is_atom(direction) do + {src_pattern, src_params} = Cypher.parameterized_node(:s, List.wrap(src_label), src_props) {dest_pattern, dest_params} = Cypher.parameterized_node(:d, [dest_label], dest_props) %__MODULE__{ @@ -478,10 +489,11 @@ defmodule AshNeo4j.Cypher.Query do WITH s, d OPTIONAL MATCH (s0:SrcLabel)-[r0:EDGE]->(d) WHERE s0 <> s DELETE r0 WITH s, d MERGE (s)-[r:EDGE]->(d) RETURN s, r, d """ - @spec relate_unrelating_destination(atom(), map(), atom(), map(), atom(), atom()) :: t() + @spec relate_unrelating_destination(atom() | [atom()], map(), atom(), map(), atom(), atom()) :: t() def relate_unrelating_destination(src_label, src_props, dest_label, dest_props, edge_label, direction) - when is_atom(src_label) and is_atom(dest_label) and is_atom(edge_label) and is_atom(direction) do - {src_pattern, src_params} = Cypher.parameterized_node(:s, [src_label], src_props) + when is_atom(dest_label) and is_atom(edge_label) and is_atom(direction) do + src_labels = List.wrap(src_label) + {src_pattern, src_params} = Cypher.parameterized_node(:s, src_labels, src_props) {dest_pattern, dest_params} = Cypher.parameterized_node(:d, [dest_label], dest_props) %__MODULE__{ @@ -490,7 +502,7 @@ defmodule AshNeo4j.Cypher.Query do %OptionalMatch{pattern: dest_pattern}, %With{items: ["s", "d"]}, %OptionalMatch{ - pattern: Cypher.node(:s0, [src_label]) <> Cypher.relationship(:r0, edge_label, direction) <> "(d)" + pattern: Cypher.node(:s0, src_labels) <> Cypher.relationship(:r0, edge_label, direction) <> "(d)" }, %Where{conditions: ["s0 <> s"]}, %Delete{items: ["r0"]}, @@ -511,10 +523,11 @@ defmodule AshNeo4j.Cypher.Query do OPTIONAL MATCH (s0:SrcLabel)-[r0:EDGE]->(d) WHERE s0 <> s DELETE r0 WITH s, d MERGE (s)-[r:EDGE]->(d) RETURN s, r, d """ - @spec relate_unrelating_both(atom(), map(), atom(), map(), atom(), atom()) :: t() + @spec relate_unrelating_both(atom() | [atom()], map(), atom(), map(), atom(), atom()) :: t() def relate_unrelating_both(src_label, src_props, dest_label, dest_props, edge_label, direction) - when is_atom(src_label) and is_atom(dest_label) and is_atom(edge_label) and is_atom(direction) do - {src_pattern, src_params} = Cypher.parameterized_node(:s, [src_label], src_props) + when is_atom(dest_label) and is_atom(edge_label) and is_atom(direction) do + src_labels = List.wrap(src_label) + {src_pattern, src_params} = Cypher.parameterized_node(:s, src_labels, src_props) {dest_pattern, dest_params} = Cypher.parameterized_node(:d, [dest_label], dest_props) %__MODULE__{ @@ -527,7 +540,7 @@ defmodule AshNeo4j.Cypher.Query do %OptionalMatch{pattern: dest_pattern}, %With{items: ["s", "d"]}, %OptionalMatch{ - pattern: Cypher.node(:s0, [src_label]) <> Cypher.relationship(:r0, edge_label, direction) <> "(d)" + pattern: Cypher.node(:s0, src_labels) <> Cypher.relationship(:r0, edge_label, direction) <> "(d)" }, %Where{conditions: ["s0 <> s"]}, %Delete{items: ["r0"]}, @@ -542,10 +555,10 @@ defmodule AshNeo4j.Cypher.Query do @doc """ `MATCH (s:SrcLabel {s_props})-[r:EDGE]->(d:DestLabel {d_props}) DELETE r RETURN s, d` """ - @spec unrelate(atom(), map(), atom(), map(), atom(), atom()) :: t() + @spec unrelate(atom() | [atom()], map(), atom(), map(), atom(), atom()) :: t() def unrelate(src_label, src_props, dest_label, dest_props, edge_label, direction) - when is_atom(src_label) and is_atom(dest_label) and is_atom(edge_label) and is_atom(direction) do - {src_pattern, src_params} = Cypher.parameterized_node(:s, [src_label], src_props) + when is_atom(dest_label) and is_atom(edge_label) and is_atom(direction) do + {src_pattern, src_params} = Cypher.parameterized_node(:s, List.wrap(src_label), src_props) {dest_pattern, dest_params} = Cypher.parameterized_node(:d, [dest_label], dest_props) path_pattern = src_pattern <> Cypher.relationship(:r, edge_label, direction) <> dest_pattern @@ -564,6 +577,8 @@ defmodule AshNeo4j.Cypher.Query do # Private helpers # --------------------------------------------------------------------------- + defp labels_string(label) when is_list(label), do: Enum.join(label, ":") + defp guard_condition(variable, edge_label, direction, dest_label) do rel = case direction do diff --git a/lib/data_layer.ex b/lib/data_layer.ex index 04dde5a..783c6da 100644 --- a/lib/data_layer.ex +++ b/lib/data_layer.ex @@ -320,7 +320,7 @@ defmodule AshNeo4j.DataLayer do mapping = ResourceInfo.mapping(resource) subject_id = id_properties(mapping, changeset.data) - subject_label = mapping.label + subject_label = mapping.label_pair update_properties = dump_properties(mapping, changeset.attributes) @@ -532,7 +532,7 @@ defmodule AshNeo4j.DataLayer do """) mapping = ResourceInfo.mapping(resource) - label = mapping.label + label = mapping.label_pair id_properties = id_properties(mapping, changeset.data) result = @@ -895,7 +895,7 @@ defmodule AshNeo4j.DataLayer do end ) - label = mapping.label + label = mapping.label_pair id_properties = id_properties(mapping, attributes) case Neo4jHelper.relate_nodes(label, id_properties, relationships) do @@ -924,7 +924,7 @@ defmodule AshNeo4j.DataLayer do end defp create_node(%ResourceMapping{} = mapping, properties) when is_map(properties) do - case mapping.labels |> Neo4jHelper.create_node(properties) do + case mapping.all_labels |> Neo4jHelper.create_node(properties) do {:ok, %Bolty.Response{results: [node_map | _]}} -> node = Map.get(node_map, "n") convert_node_to_resource(mapping.module, node) @@ -1110,7 +1110,7 @@ defmodule AshNeo4j.DataLayer do case mode do :per_record -> CypherQuery.aggregate_per_record( - mapping.label, + mapping.label_pair, neo4j_pk, ids, path_segments, @@ -1122,7 +1122,7 @@ defmodule AshNeo4j.DataLayer do :total -> CypherQuery.aggregate_total( - mapping.label, + mapping.label_pair, neo4j_pk, ids, path_segments, @@ -1164,7 +1164,7 @@ defmodule AshNeo4j.DataLayer do # This path is also used for expression-based aggregates (Ash.Query.Calculation # field) when a filter is present, because we already load full records there. defp run_filtered_aggregate(mapping, neo4j_pk, ids, aggregate, mode, path_segments, dest_mapping) do - query = CypherQuery.related_nodes(mapping.label, neo4j_pk, ids, path_segments) + query = CypherQuery.related_nodes(mapping.label_pair, neo4j_pk, ids, path_segments) dest_resource = dest_mapping.module domain = Ash.Resource.Info.domain(dest_resource) @@ -1252,7 +1252,7 @@ defmodule AshNeo4j.DataLayer do case mode do :per_record -> CypherQuery.aggregate_per_record( - mapping.label, + mapping.label_pair, neo4j_pk, ids, path_segments, @@ -1264,7 +1264,7 @@ defmodule AshNeo4j.DataLayer do :total -> CypherQuery.aggregate_total( - mapping.label, + mapping.label_pair, neo4j_pk, ids, path_segments, @@ -1301,7 +1301,7 @@ defmodule AshNeo4j.DataLayer do end defp run_expr_agg(mapping, neo4j_pk, ids, aggregate, mode, path_segments, dest_mapping) do - query = CypherQuery.related_nodes(mapping.label, neo4j_pk, ids, path_segments) + query = CypherQuery.related_nodes(mapping.label_pair, neo4j_pk, ids, path_segments) dest_resource = dest_mapping.module domain = Ash.Resource.Info.domain(dest_resource) calc = aggregate.field diff --git a/lib/data_layer/domain.ex b/lib/data_layer/domain.ex new file mode 100644 index 0000000..6869ec2 --- /dev/null +++ b/lib/data_layer/domain.ex @@ -0,0 +1,56 @@ +# SPDX-FileCopyrightText: 2025 ash_neo4j contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshNeo4j.DataLayer.Domain.PersistFragmentLabel do + @moduledoc false + use Spark.Dsl.Transformer + alias Spark.Dsl.Transformer + + @impl true + def transform(dsl) do + fragment_label = Transformer.get_option(dsl, [:neo4j], :label) + {:ok, Transformer.persist(dsl, :neo4j_domain_label, fragment_label)} + end +end + +defmodule AshNeo4j.DataLayer.Domain do + @moduledoc """ + Domain-level DSL extension for AshNeo4j. + + Attach to an Ash domain (directly or via a domain fragment) to write an additional + label on every node in that domain. + + defmodule Telco do + use Spark.Dsl.Fragment, + of: Ash.Domain, + extensions: [AshNeo4j.DataLayer.Domain] + + neo4j do + label :Telco + end + end + + defmodule Provider do + use Ash.Domain, fragments: [Telco] + end + + Nodes for resources in `Provider` will have `:Telco` written as an additional + label on CREATE, giving the graph a semantically navigable axis. + """ + + @neo4j %Spark.Dsl.Section{ + name: :neo4j, + schema: [ + label: [ + type: :atom, + doc: "Label written on CREATE for all nodes whose resource belongs to this domain.", + required: false + ] + ] + } + + use Spark.Dsl.Extension, + sections: [@neo4j], + transformers: [AshNeo4j.DataLayer.Domain.PersistFragmentLabel] +end diff --git a/lib/data_layer/domain/info.ex b/lib/data_layer/domain/info.ex new file mode 100644 index 0000000..233d297 --- /dev/null +++ b/lib/data_layer/domain/info.ex @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: 2025 ash_neo4j contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshNeo4j.DataLayer.Domain.Info do + @moduledoc "Introspection helpers for AshNeo4j.DataLayer.Domain" + + alias Spark.Dsl.Extension + + @doc """ + Returns the label declared in the domain's `neo4j do` block. + + The label is written on CREATE for every node whose resource belongs + to this domain. It provides an additional axis for graph traversal + independent of the specific resource type. + + Returns `nil` if the domain does not use `AshNeo4j.DataLayer.Domain` or + declares no label. + """ + @spec label(Ash.Domain.t()) :: atom() | nil + def label(domain) do + Extension.get_opt(domain, [:neo4j], :label, nil, true) + end +end diff --git a/lib/neo4j_helper.ex b/lib/neo4j_helper.ex index f153e7a..dbfa193 100644 --- a/lib/neo4j_helper.ex +++ b/lib/neo4j_helper.ex @@ -83,7 +83,7 @@ defmodule AshNeo4j.Neo4jHelper do :ok ``` """ - def safe_delete_nodes(label, properties, relationships) when is_atom(label) do + def safe_delete_nodes(label, properties, relationships) when is_atom(label) or is_list(label) do Query.delete_nodes_guarded(label, properties, relationships) |> Cypher.run_expecting_deletions() end @@ -121,7 +121,7 @@ defmodule AshNeo4j.Neo4jHelper do ``` """ def update_node(label, match_properties, set_properties, remove_properties \\ []) - when is_atom(label) and is_map(set_properties) do + when (is_atom(label) or is_list(label)) and is_map(set_properties) do Query.update_node(label, match_properties, set_properties, remove_properties) |> Cypher.run() end @@ -164,7 +164,8 @@ defmodule AshNeo4j.Neo4jHelper do ``` """ def unrelate_nodes(source_label, source_properties, dest_label, dest_properties, edge_label, edge_direction) - when is_atom(source_label) and is_map(source_properties) and is_atom(dest_label) and is_map(dest_properties) and + when (is_atom(source_label) or is_list(source_label)) and is_map(source_properties) and + is_atom(dest_label) and is_map(dest_properties) and is_atom(edge_label) and is_atom(edge_direction) do Query.unrelate(source_label, source_properties, dest_label, dest_properties, edge_label, edge_direction) |> Cypher.run() @@ -194,7 +195,8 @@ defmodule AshNeo4j.Neo4jHelper do edge_label, edge_direction ) - when is_atom(source_label) and is_map(source_properties) and is_atom(dest_label) and is_map(dest_properties) and + when (is_atom(source_label) or is_list(source_label)) and is_map(source_properties) and + is_atom(dest_label) and is_map(dest_properties) and is_atom(edge_label) and is_atom(edge_direction) do Query.relate_unrelating_source( source_label, @@ -231,7 +233,8 @@ defmodule AshNeo4j.Neo4jHelper do edge_label, edge_direction ) - when is_atom(source_label) and is_map(source_properties) and is_atom(dest_label) and is_map(dest_properties) and + when (is_atom(source_label) or is_list(source_label)) and is_map(source_properties) and + is_atom(dest_label) and is_map(dest_properties) and is_atom(edge_label) and is_atom(edge_direction) do Query.relate_unrelating_destination( source_label, @@ -270,7 +273,8 @@ defmodule AshNeo4j.Neo4jHelper do edge_label, edge_direction ) - when is_atom(source_label) and is_map(source_properties) and is_atom(dest_label) and is_map(dest_properties) and + when (is_atom(source_label) or is_list(source_label)) and is_map(source_properties) and + is_atom(dest_label) and is_map(dest_properties) and is_atom(edge_label) and is_atom(edge_direction) do Query.relate_unrelating_both( source_label, @@ -342,7 +346,7 @@ defmodule AshNeo4j.Neo4jHelper do ``` """ def relate_nodes(label, properties, relationships) - when is_atom(label) and is_map(properties) and is_list(relationships) do + when (is_atom(label) or is_list(label)) and is_map(properties) and is_list(relationships) do results = Enum.reduce_while(relationships, [], fn {dest_label, dest_properties, edge_label, edge_direction, exclusive}, acc -> @@ -503,7 +507,7 @@ defmodule AshNeo4j.Neo4jHelper do 1 ``` """ - def read_nodes_related(label, properties \\ %{}) when is_atom(label) and is_map(properties) do + def read_nodes_related(label, properties \\ %{}) when (is_atom(label) or is_list(label)) and is_map(properties) do Query.node_read_with_properties(label, properties) |> Cypher.run() end diff --git a/lib/persisters/persist_labels.ex b/lib/persisters/persist_labels.ex index af866d1..3dc322d 100644 --- a/lib/persisters/persist_labels.ex +++ b/lib/persisters/persist_labels.ex @@ -18,16 +18,30 @@ defmodule AshNeo4j.Persisters.PersistLabels do module_label = short_name(resource_module) resource_label = Transformer.get_option(dsl, [:neo4j], :label, module_label) + domain_fragment_label = + if Code.ensure_loaded?(domain_module) and + function_exported?(domain_module, :spark_dsl_config, 0) do + AshNeo4j.DataLayer.Domain.Info.label(domain_module) + end + # module_label is always the short name of the resource module itself (:Shelf). # resource_label may differ when a fragment contributes a base type label (e.g. :Instance from BaseInstance). - # Both are written on CREATE so polymorphic traversals work; reads match on resource_label only. - labels = [domain_label | Enum.uniq([module_label, resource_label])] + # domain_fragment_label comes from a domain fragment using AshNeo4j.DataLayer.Domain. + # all_labels: written on CREATE (up to 4 labels). + # label_pair: [domain_label, module_label] — used for MATCH on read, update, delete. + all_labels = + [domain_label | Enum.uniq([module_label, resource_label])] + |> then(fn ls -> if domain_fragment_label, do: ls ++ [domain_fragment_label], else: ls end) + + label_pair = [domain_label, module_label] {:ok, dsl |> Transformer.persist(:domain_label, domain_label) |> Transformer.persist(:module_label, module_label) |> Transformer.persist(:label, resource_label) - |> Transformer.persist(:labels, labels)} + |> Transformer.persist(:domain_fragment_label, domain_fragment_label) + |> Transformer.persist(:all_labels, all_labels) + |> Transformer.persist(:label_pair, label_pair)} end end diff --git a/lib/persisters/persist_mapping.ex b/lib/persisters/persist_mapping.ex index 148edf3..262f57a 100644 --- a/lib/persisters/persist_mapping.ex +++ b/lib/persisters/persist_mapping.ex @@ -22,7 +22,9 @@ defmodule AshNeo4j.Persisters.PersistMapping do domain_label = Verifier.get_persisted(dsl, :domain_label) module_label = Verifier.get_persisted(dsl, :module_label) label = Verifier.get_persisted(dsl, :label) - labels = Verifier.get_persisted(dsl, :labels, []) + domain_fragment_label = Verifier.get_persisted(dsl, :domain_fragment_label) + all_labels = Verifier.get_persisted(dsl, :all_labels, []) + label_pair = Verifier.get_persisted(dsl, :label_pair, []) properties = Verifier.get_persisted(dsl, :translations, []) relate = Verifier.get_persisted(dsl, :relate, []) relationship_attributes = Verifier.get_persisted(dsl, :relationship_attributes, []) @@ -34,7 +36,9 @@ defmodule AshNeo4j.Persisters.PersistMapping do domain_label: domain_label, module_label: module_label, label: label, - labels: labels, + domain_fragment_label: domain_fragment_label, + all_labels: all_labels, + label_pair: label_pair, properties: properties, edges: Enum.map(relate, &EdgeDescriptor.from_relate/1), relationship_attributes: relationship_attributes, diff --git a/lib/query_helper.ex b/lib/query_helper.ex index f1e5934..f088cb1 100644 --- a/lib/query_helper.ex +++ b/lib/query_helper.ex @@ -40,7 +40,7 @@ defmodule AshNeo4j.QueryHelper do defp build_query(ash_query, %ResourceMapping{} = mapping) do if ash_query.filter == nil do - Query.node_read(mapping.label) + Query.node_read(mapping.label_pair) else simple_filter = Ash.Filter.to_simple_filter(ash_query.filter, skip_invalid?: true) @@ -53,7 +53,7 @@ defmodule AshNeo4j.QueryHelper do if predicates == [] do Logger.debug("AshNeo4j.QueryHelper: filter #{inspect(ash_query.filter)} is not a simple filter") - Query.node_read(mapping.label) + Query.node_read(mapping.label_pair) else build_filtered_query(mapping, predicates) end @@ -76,7 +76,7 @@ defmodule AshNeo4j.QueryHelper do cond do Enum.empty?(relationship_predicates) -> conditions = to_conditions(mapping, property_predicates) - Query.node_read_filtered(mapping.label, conditions) + Query.node_read_filtered(mapping.label_pair, conditions) length(relationship_predicates) == 1 -> predicate = hd(relationship_predicates) @@ -90,7 +90,7 @@ defmodule AshNeo4j.QueryHelper do ResourceInfo.convert_to_property_name(relationship.destination, relationship.destination_attribute) Query.relationship_read( - mapping.label, + mapping.label_pair, edge.label, edge.direction, dest_label, @@ -101,7 +101,7 @@ defmodule AshNeo4j.QueryHelper do true -> Logger.debug("AshNeo4j.QueryHelper: combination of predicates #{inspect(predicates)} not supported") - Query.node_read(mapping.label) + Query.node_read(mapping.label_pair) end end diff --git a/lib/resource/info.ex b/lib/resource/info.ex index 670664f..635315b 100644 --- a/lib/resource/info.ex +++ b/lib/resource/info.ex @@ -37,15 +37,45 @@ defmodule AshNeo4j.Resource.Info do Extension.get_persisted(resource, :domain_label, nil) end + @doc """ + The label contributed by a domain fragment using `AshNeo4j.DataLayer.Domain`. + Written on CREATE as an additional label for graph traversal. `nil` when the domain + declares no fragment label. + """ + @spec domain_fragment_label(Ash.Resource.t()) :: atom() | nil + def domain_fragment_label(resource) do + case Extension.get_persisted(resource, :domain_fragment_label, nil) do + nil -> + domain = Extension.get_persisted(resource, :domain, nil) + if domain, do: AshNeo4j.DataLayer.Domain.Info.label(domain), else: nil + + val -> + val + end + end + + @doc """ + The two-label pair `[domain_label, module_label]` used in MATCH for all read, update, + delete, and aggregate operations. Always uniquely identifies this specific resource type. + """ + @spec label_pair(Ash.Resource.t()) :: [atom()] + def label_pair(resource) do + Extension.get_persisted(resource, :label_pair, [domain_label(resource), module_label(resource)]) + 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]`. + label, followed by the module label, then any additional base type label from a resource + fragment, then the domain fragment label if the domain uses `AshNeo4j.DataLayer.Domain`. + For example, `DiffoExample.Access.Shelf` (using `BaseInstance` and a `Telco` domain fragment) + returns `[:Access, :Shelf, :Instance, :Telco]`. """ - @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) + @spec all_labels(Ash.Resource.t()) :: list(atom()) | nil + def all_labels(resource) do + Extension.get_persisted(resource, :all_labels, nil) || + [domain_label(resource), module_label(resource), label(resource), domain_fragment_label(resource)] + |> Enum.uniq() + |> Enum.filter(& &1) end @doc """ @@ -54,22 +84,33 @@ defmodule AshNeo4j.Resource.Info do """ @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 + base = + 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), + label_pair: label_pair(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 + + frag_label = domain_fragment_label(resource) + + %{base | domain_fragment_label: frag_label, all_labels: all_labels_for(base, frag_label)} + end + + defp all_labels_for(%ResourceMapping{} = base, frag_label) do + [base.domain_label, base.module_label, base.label, frag_label] + |> Enum.uniq() + |> Enum.filter(& &1) end @doc """ diff --git a/lib/resource_mapping.ex b/lib/resource_mapping.ex index 311d164..7204d85 100644 --- a/lib/resource_mapping.ex +++ b/lib/resource_mapping.ex @@ -21,8 +21,13 @@ defmodule AshNeo4j.ResourceMapping do - `:label` — the label used in MATCH for reads, updates, and deletes; comes from the DSL `label` option and may be a fragment base-type label (e.g. `:Instance` when `Shelf` extends `BaseInstance`). - - `:labels` — full ordered list of labels written on CREATE: `[domain_label, module_label, ...]` - including any additional base-type labels from fragments (e.g. `[:Access, :Shelf, :Instance]`). + - `:domain_fragment_label` — optional label contributed by a domain fragment using + `AshNeo4j.DataLayer.Domain` (e.g. `:Telco`). `nil` when the domain declares none. + - `:all_labels` — full ordered list of labels written on CREATE: `[domain_label, module_label, ...]` + including any base-type label from a resource fragment and the domain fragment label if present + (e.g. `[:Access, :Shelf, :Instance, :Telco]`). + - `:label_pair` — the two-label pair `[domain_label, module_label]` used in MATCH for all + read, update, delete, and aggregate operations. Always uniquely identifies this resource. - `:properties` — keyword list of `{ash_attribute_name, neo4j_property_name}` translations; insertion order is preserved. - `:edges` — list of `AshNeo4j.EdgeDescriptor.t()` structs, one per `relate` entry. @@ -39,7 +44,9 @@ defmodule AshNeo4j.ResourceMapping do domain_label: atom(), module_label: atom(), label: atom(), - labels: [atom()], + domain_fragment_label: atom() | nil, + all_labels: [atom()], + label_pair: [atom()], properties: keyword(String.t()), edges: [EdgeDescriptor.t()], relationship_attributes: keyword(atom()), @@ -52,7 +59,9 @@ defmodule AshNeo4j.ResourceMapping do :domain_label, :module_label, :label, - :labels, + :domain_fragment_label, + :all_labels, + :label_pair, :properties, :edges, :relationship_attributes, diff --git a/lib/verifiers/verify_labels_pascal_case.ex b/lib/verifiers/verify_labels_pascal_case.ex index a39117c..519040d 100644 --- a/lib/verifiers/verify_labels_pascal_case.ex +++ b/lib/verifiers/verify_labels_pascal_case.ex @@ -13,7 +13,7 @@ defmodule AshNeo4j.Verifiers.VerifyLabelsPascalCase do @impl true def verify(dsl) do resource = Verifier.get_persisted(dsl, :module) - labels = Verifier.get_persisted(dsl, :labels) + labels = Verifier.get_persisted(dsl, :all_labels) cond do labels == [] -> diff --git a/mix.exs b/mix.exs index ff137d6..2a0b9fc 100644 --- a/mix.exs +++ b/mix.exs @@ -167,8 +167,8 @@ defmodule AshNeo4j.MixProject do "docs", "spark.replace_doc_links" ], - "spark.formatter": "spark.formatter --extensions AshNeo4j.DataLayer", - "spark.cheat_sheets": "spark.cheat_sheets --extensions AshNeo4j.DataLayer" + "spark.formatter": "spark.formatter --extensions AshNeo4j.DataLayer,AshNeo4j.DataLayer.Domain", + "spark.cheat_sheets": "spark.cheat_sheets --extensions AshNeo4j.DataLayer,AshNeo4j.DataLayer.Domain" ] end end diff --git a/test/data_layer/info_test.exs b/test/data_layer/info_test.exs index ea9d4a8..9a45a3a 100644 --- a/test/data_layer/info_test.exs +++ b/test/data_layer/info_test.exs @@ -6,6 +6,10 @@ defmodule AshNeo4j.DataLayer.InfoTest do @moduledoc false use ExUnit.Case, async: true alias AshNeo4j.DataLayer.Info, as: DataLayerInfo + alias AshNeo4j.DataLayer.Domain.Info, as: DomainInfo + alias AshNeo4j.Resource.Info, as: ResourceInfo + alias AshNeo4j.Test.Provider + alias AshNeo4j.Test.Resource.Blueprint alias AshNeo4j.Test.Resource.Specification alias AshNeo4j.Test.Resource.Event @@ -38,4 +42,34 @@ defmodule AshNeo4j.DataLayer.InfoTest do assert DataLayerInfo.skip(Event) == [:service_id, :resource_id] end end + + describe "domain info" do + test "label returns nil for domains without AshNeo4j.DataLayer.Domain" do + assert DomainInfo.label(AshNeo4j.Test.SRM) == nil + end + + test "label returns the declared label for a domain using a domain fragment" do + assert DomainInfo.label(Provider) == :MyTestDomain + end + end + + describe "resource info — domain fragment label" do + test "domain_fragment_label is nil for resources in a plain domain" do + assert ResourceInfo.domain_fragment_label(Specification) == nil + end + + test "domain_fragment_label is populated for resources in a domain with a domain fragment" do + assert ResourceInfo.domain_fragment_label(Blueprint) == :MyTestDomain + end + + test "all_labels includes domain fragment label when domain fragment is present" do + assert ResourceInfo.all_labels(Blueprint) == [:Provider, :Blueprint, :MyTestDomain] + end + + test "mapping includes domain_fragment_label field" do + mapping = ResourceInfo.mapping(Blueprint) + assert mapping.domain_fragment_label == :MyTestDomain + assert :MyTestDomain in mapping.all_labels + end + end end diff --git a/test/fragment_test.exs b/test/fragment_test.exs index 20a6c56..cd45b0f 100644 --- a/test/fragment_test.exs +++ b/test/fragment_test.exs @@ -72,6 +72,21 @@ defmodule AshNeo4j.FragmentTest do end end + describe "domain fragment label" do + test "domain fragment label appears in resource mapping all_labels" do + all_labels = Blueprint.__ash_neo4j_mapping__().all_labels + assert :MyTestDomain in all_labels + end + + test "domain fragment label is written on CREATE" do + blueprint = Blueprint |> Ash.create!(%{name: "labelTest"}) + reloaded = Blueprint |> Ash.get!(blueprint.id) + assert reloaded != nil + all_labels = Blueprint.__ash_neo4j_mapping__().all_labels + assert all_labels == [:Provider, :Blueprint, :MyTestDomain] + end + end + describe "label scoping with fragment noise" do test "Ash.read! returns only the target resource when a sibling fragment resource exists" do # Create a NoiseInstance — shares :CrossDomainType label with CrossDomainInstance. diff --git a/test/resource/info_test.exs b/test/resource/info_test.exs index a4ed5b6..85767a3 100644 --- a/test/resource/info_test.exs +++ b/test/resource/info_test.exs @@ -32,11 +32,11 @@ defmodule AshNeo4j.Resource.InfoTest do end end - describe "labels" do + describe "all_labels" do test "returns domain label then resource label" do - assert ResourceInfo.labels(Specification) == [:SRM, :Specification] - assert ResourceInfo.labels(Service) == [:SRM, :Service] - assert ResourceInfo.labels(Event) == [:SRM, :Event] + assert ResourceInfo.all_labels(Specification) == [:SRM, :Specification] + assert ResourceInfo.all_labels(Service) == [:SRM, :Service] + assert ResourceInfo.all_labels(Event) == [:SRM, :Event] end end diff --git a/test/support/fragment/test_domain.ex b/test/support/fragment/test_domain.ex new file mode 100644 index 0000000..53b1cc6 --- /dev/null +++ b/test/support/fragment/test_domain.ex @@ -0,0 +1,18 @@ +# SPDX-FileCopyrightText: 2025 ash_neo4j contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshNeo4j.Test.Fragment.TestDomain do + @moduledoc false + + # Domain fragment that contributes a :MyTestDomain label to any domain that + # uses it. Exercises AshNeo4j.DataLayer.Domain and the domain fragment label + # path in PersistLabels. + use Spark.Dsl.Fragment, + of: Ash.Domain, + extensions: [AshNeo4j.DataLayer.Domain] + + neo4j do + label :MyTestDomain + end +end diff --git a/test/support/provider.ex b/test/support/provider.ex index 2a5a149..ea2b089 100644 --- a/test/support/provider.ex +++ b/test/support/provider.ex @@ -4,7 +4,7 @@ defmodule AshNeo4j.Test.Provider do @moduledoc false - use Ash.Domain + use Ash.Domain, fragments: [AshNeo4j.Test.Fragment.TestDomain] resources do allow_unregistered? true diff --git a/test/type_test.exs b/test/type_test.exs index befbacb..7f1c052 100644 --- a/test/type_test.exs +++ b/test/type_test.exs @@ -215,7 +215,7 @@ defmodule AshNeo4j.TypeTest do describe "Ash Read Type tests" do test "type node can be read using ash" do properties = Map.put(@type_node_properties, :uuid, Ash.UUID.generate()) - Neo4jHelper.create_node([:Type], properties) + Neo4jHelper.create_node([:SRM, :Type], properties) type = Ash.read_one!(Type) assert type.uuid == properties.uuid @@ -226,14 +226,14 @@ defmodule AshNeo4j.TypeTest do test "type node has metadata on read" do properties = Map.put(@type_node_properties, :uuid, Ash.UUID.generate()) - Neo4jHelper.create_node([:Domain, :Type], properties) + Neo4jHelper.create_node([:SRM, :Type], properties) type = Ash.read_one!(Type) assert is_struct(type.__meta__, Ecto.Schema.Metadata) assert type.__meta__.state == :loaded assert type.__metadata__ assert type.__metadata__.data_layer == AshNeo4j.DataLayer assert "Type" in type.__metadata__.labels - assert "Domain" in type.__metadata__.labels + assert "SRM" in type.__metadata__.labels assert is_integer(type.__metadata__.node_id) end end @@ -318,7 +318,7 @@ defmodule AshNeo4j.TypeTest do describe "defensive tests" do test "cast function - module not loaded returns error" do - Neo4jHelper.create_node([:Type], %{ + Neo4jHelper.create_node([:SRM, :Type], %{ "uuid" => Ash.UUID.generate(), "function" => "&NonExistent.Module.my_fun/2" }) @@ -327,7 +327,7 @@ defmodule AshNeo4j.TypeTest do end test "cast module - module not loaded returns error" do - Neo4jHelper.create_node([:Type], %{ + Neo4jHelper.create_node([:SRM, :Type], %{ "uuid" => Ash.UUID.generate(), "module" => "Elixir.NonExistent.Module" })