diff --git a/AGENTS.md b/AGENTS.md index 0c48a21..7faedc3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,7 +66,7 @@ lib/diffo/provider/ base_characteristic.ex # Ash Fragment for typed characteristic resources base_relationship.ex # Ash Fragment for shared Relationship structure defined_simple_relationship.ex # DefinedSimpleRelationship — relationship with one optional embedded characteristic, frozen at creation - assignment_relationship.ex # AssignmentRelationship — pool assignment relationship with top-level pool/thing/value scalar attributes + assignment_relationship.ex # AssignmentRelationship — pool assignment relationship with top-level pool/thing/value/alias scalar attributes relationship.ex # Relationship — mutable TMF service/resource relationship with graph Characteristic nodes calculations/ characteristic_value.ex # Calculation: builds .Value TypedStruct from record fields @@ -300,6 +300,7 @@ not. Add any useful hypotheses as a follow-up comment on the issue, then leave i - Calling `Assigner.assign/4` when a `pools do` declaration exists — prefer `Assigner.assign/3` which looks up the thing automatically. - Forgetting to call `Pool.update_pools/3` in `:define` actions when the resource has `pools do` — pool bounds (`first`, `last`, `algorithm`) are set here. - Calling `Assigner.assign/3` on an instance that is not in the correct lifecycle state — the assigner enforces: resource instances must have `resource_state: :operating`; service instances must have `service_state: :active` or `:inactive`. Lifecycle state transitions are an internal domain concern managed by the provider; assignment actions are external-facing. Future: consumer reads may filter out non-`:operating` resources entirely. +- Wondering why `Relationship` and `AssignmentRelationship` both have an `alias` attribute with a `[:source_id, :alias]` / `[:target_id, :alias]` identity — alias is a "baby name" given to a relationship slot before (or when) the target is bound. Its full purpose becomes clear alongside the first-order expectation system (see issue #122): the expectation declares the alias for a slot it expects to be filled, and the actual relationship carries the same alias so the two can be matched. Without expectations in place, aliases look like optional metadata; with them, they are the join key between intent and fulfilment. - Using `characteristic :pool_name, Diffo.Provider.AssignedToRelationship` — `AssignedToRelationship` no longer exists; use `pools do / pool :name, :thing / end` instead. - Querying `Diffo.Provider.Relationship` for assignment records — assignments are stored as `Diffo.Provider.DefinedSimpleRelationship`; access them via `instance.assignments`. - Filtering `instance.forward_relationships` for `type == :assignedTo` — those records no longer exist there; use `instance.assignments` directly. diff --git a/lib/diffo/provider/assigner/assigner.ex b/lib/diffo/provider/assigner/assigner.ex index 63fe270..92ca66c 100644 --- a/lib/diffo/provider/assigner/assigner.ex +++ b/lib/diffo/provider/assigner/assigner.ex @@ -35,6 +35,7 @@ defmodule Diffo.Provider.Assigner do is_atom(thing) do assignment = Map.get(changeset.arguments, :assignment, %{}) assignee_id = Map.get(assignment, :assignee_id) + alias_name = Map.get(assignment, :alias) case assignee_id do nil -> @@ -44,12 +45,12 @@ defmodule Diffo.Provider.Assigner do case Map.get(assignment, :operation, :auto_assign) do :auto_assign -> with {:ok, value} <- next(result, pool, thing) do - create_assignment(result, pool, thing, value, assignee_id) + create_assignment(result, pool, thing, value, assignee_id, alias_name) end :assign -> if assignable?(result, pool, thing, assignment.id) do - create_assignment(result, pool, thing, assignment.id, assignee_id) + create_assignment(result, pool, thing, assignment.id, assignee_id, alias_name) else {:error, "#{thing} #{assignment.id} is not assignable"} end @@ -69,11 +70,12 @@ defmodule Diffo.Provider.Assigner do defp check_lifecycle(_), do: :ok - defp create_assignment(result, pool, thing, value, assignee_id) + defp create_assignment(result, pool, thing, value, assignee_id, alias_name) when is_struct(result) and is_atom(pool) and is_atom(thing) and is_integer(value) and is_bitstring(assignee_id) do with {:ok, _} <- Diffo.Provider.create_assignment_relationship(%{ + alias: alias_name, pool: pool, thing: thing, value: value, diff --git a/lib/diffo/provider/assigner/assignment.ex b/lib/diffo/provider/assigner/assignment.ex index a2cb1d1..bb175c4 100644 --- a/lib/diffo/provider/assigner/assignment.ex +++ b/lib/diffo/provider/assigner/assignment.ex @@ -19,6 +19,8 @@ defmodule Diffo.Provider.Assignment do constraints: [min: 0], description: "the id of the assigned thing" + field :alias, :atom, description: "the consumer's stable name for this assignment slot" + field :assignable_type, :string, description: "the type of the assigned thing" field :assignee_id, :uuid, description: "the id of the assignee Ash resource" diff --git a/lib/diffo/provider/components/assignment_relationship.ex b/lib/diffo/provider/components/assignment_relationship.ex index 8302c80..abbadb2 100644 --- a/lib/diffo/provider/components/assignment_relationship.ex +++ b/lib/diffo/provider/components/assignment_relationship.ex @@ -47,11 +47,18 @@ defmodule Diffo.Provider.AssignmentRelationship do list_name = Diffo.Provider.Relationship.derive_relationship_characteristic_list_name(record.target_type) - characteristic = %{name: record.thing, value: record.value} + characteristics = + [%{name: record.thing, value: record.value}] + |> then(fn chars -> + case record.alias do + nil -> chars + a -> chars ++ [%{name: :alias, value: a}] + end + end) result |> Diffo.Util.set(record.target_type, reference) - |> Diffo.Util.set(list_name, [characteristic]) + |> Diffo.Util.set(list_name, characteristics) end order [:type, :resource, :service, :resourceRelationshipCharacteristic, @@ -61,7 +68,7 @@ defmodule Diffo.Provider.AssignmentRelationship do actions do create :create do description "creates a pool assignment relationship between a source and target instance" - accept [:pool, :thing, :value] + accept [:alias, :pool, :thing, :value] argument :source_id, :uuid argument :target_id, :string @@ -74,6 +81,12 @@ defmodule Diffo.Provider.AssignmentRelationship do end attributes do + attribute :alias, :atom do + description "the alias of this assignment, used by the consuming instance to name the slot" + allow_nil? true + public? true + end + attribute :pool, :atom do description "the pool name this assignment belongs to (e.g. :ports)" allow_nil? false @@ -98,6 +111,10 @@ defmodule Diffo.Provider.AssignmentRelationship do identity :unique_assignment, [:source_id, :pool, :thing, :value] do pre_check? true end + + identity :unique_alias, [:target_id, :alias] do + pre_check? true + end end preparations do diff --git a/lib/diffo/provider/components/calculations/inherited_party.ex b/lib/diffo/provider/components/calculations/inherited_party.ex new file mode 100644 index 0000000..ab58489 --- /dev/null +++ b/lib/diffo/provider/components/calculations/inherited_party.ex @@ -0,0 +1,37 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Calculations.InheritedParty do + @moduledoc false + use Ash.Resource.Calculation + + @impl true + def load(_query, _opts, _context), do: [] + + @impl true + def calculate(records, opts, _context) do + via = opts[:via] + source_role = opts[:source_role] + + Enum.map(records, fn record -> + final_ids = + Enum.reduce(via, [record.id], fn alias_step, ids -> + Enum.flat_map(ids, fn id -> + Diffo.Provider.AssignmentRelationship + |> Ash.Query.filter_input(target_id: id, alias: alias_step) + |> Ash.read!(domain: Diffo.Provider) + |> Enum.map(& &1.source_id) + end) + end) + + Enum.flat_map(final_ids, fn id -> + Diffo.Provider.PartyRef + |> Ash.Query.filter_input(instance_id: id, role: source_role) + |> Ash.Query.load(:party) + |> Ash.read!(domain: Diffo.Provider) + |> Enum.map(& &1.party) + end) + end) + end +end diff --git a/lib/diffo/provider/components/calculations/inherited_place.ex b/lib/diffo/provider/components/calculations/inherited_place.ex new file mode 100644 index 0000000..cc252bd --- /dev/null +++ b/lib/diffo/provider/components/calculations/inherited_place.ex @@ -0,0 +1,37 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Calculations.InheritedPlace do + @moduledoc false + use Ash.Resource.Calculation + + @impl true + def load(_query, _opts, _context), do: [] + + @impl true + def calculate(records, opts, _context) do + via = opts[:via] + source_role = opts[:source_role] + + Enum.map(records, fn record -> + final_ids = + Enum.reduce(via, [record.id], fn alias_step, ids -> + Enum.flat_map(ids, fn id -> + Diffo.Provider.AssignmentRelationship + |> Ash.Query.filter_input(target_id: id, alias: alias_step) + |> Ash.read!(domain: Diffo.Provider) + |> Enum.map(& &1.source_id) + end) + end) + + Enum.flat_map(final_ids, fn id -> + Diffo.Provider.PlaceRef + |> Ash.Query.filter_input(instance_id: id, role: source_role) + |> Ash.Query.load(:place) + |> Ash.read!(domain: Diffo.Provider) + |> Enum.map(& &1.place) + end) + end) + end +end diff --git a/lib/diffo/provider/components/relationship.ex b/lib/diffo/provider/components/relationship.ex index 474c8d5..d0e784b 100644 --- a/lib/diffo/provider/components/relationship.ex +++ b/lib/diffo/provider/components/relationship.ex @@ -130,7 +130,9 @@ defmodule Diffo.Provider.Relationship do end identities do - identity :unique_source_and_target, [:source_id, :target_id] + identity :unique_source_alias, [:source_id, :alias] do + pre_check? true + end end preparations do diff --git a/lib/diffo/provider/extension.ex b/lib/diffo/provider/extension.ex index 0f61d00..aee67ad 100644 --- a/lib/diffo/provider/extension.ex +++ b/lib/diffo/provider/extension.ex @@ -93,6 +93,8 @@ defmodule Diffo.Provider.Extension do Characteristic, Feature, InstanceRole, + InheritedPartyDeclaration, + InheritedPlaceDeclaration, PartyDeclaration, PartyRole, PlaceDeclaration, @@ -292,10 +294,35 @@ defmodule Diffo.Provider.Extension do ] } + @inherited_party_entity %Spark.Dsl.Entity{ + name: :inherited_party, + describe: + "Declares a party derived by traversing the assignment graph — generates a calculation, no PartyRef node created", + target: InheritedPartyDeclaration, + args: [:role], + schema: [ + role: [ + type: :atom, + doc: "The role name — also the default alias to follow on AssignmentRelationship.", + required: true + ], + via: [ + type: {:list, :atom}, + doc: + "Sequence of assignment aliases to traverse. Defaults to [role] for single-hop. Use a list for multi-level." + ], + source_role: [ + type: :atom, + doc: "The PartyRef role to pick up on the arrived-at instance.", + required: true + ] + ] + } + @parties %Spark.Dsl.Section{ name: :parties, describe: - "Party roles on this resource — `party`/`parties`/`party_ref` for Instance kinds; `role` for Party and Place kinds", + "Party roles on this resource — `party`/`parties`/`party_ref`/`inherited_party` for Instance kinds; `role` for Party and Place kinds", examples: [ """ # Instance @@ -303,6 +330,7 @@ defmodule Diffo.Provider.Extension do party :provider, MyApp.Provider party_ref :owner, MyApp.InfrastructureCo parties :technicians, MyApp.Technician, constraints: [min: 1] + inherited_party :customer, source_role: :owner end # Party or Place @@ -311,7 +339,7 @@ defmodule Diffo.Provider.Extension do end """ ], - entities: [@party_entity, @parties_entity, @party_ref_entity, @party_role_entity] + entities: [@party_entity, @parties_entity, @party_ref_entity, @party_role_entity, @inherited_party_entity] } # ── places ───────────────────────────────────────────────────────────────── @@ -368,16 +396,43 @@ defmodule Diffo.Provider.Extension do ] } + @inherited_place_entity %Spark.Dsl.Entity{ + name: :inherited_place, + describe: + "Declares a place derived by traversing the assignment graph — generates a calculation, no PlaceRef node created", + target: InheritedPlaceDeclaration, + args: [:role], + schema: [ + role: [ + type: :atom, + doc: "The role name — also the default alias to follow on AssignmentRelationship.", + required: true + ], + via: [ + type: {:list, :atom}, + doc: + "Sequence of assignment aliases to traverse. Defaults to [role] for single-hop. Use a list for multi-level." + ], + source_role: [ + type: :atom, + doc: "The PlaceRef role to pick up on the arrived-at instance.", + required: true + ] + ] + } + @places %Spark.Dsl.Section{ name: :places, describe: - "Place roles on this resource — `place`/`places`/`place_ref` for Instance kinds; `role` for Party and Place kinds", + "Place roles on this resource — `place`/`places`/`place_ref`/`inherited_place` for Instance kinds; `role` for Party and Place kinds", examples: [ """ # Instance places do place :installation_site, MyApp.GeographicSite place_ref :billing_address, MyApp.GeographicAddress + inherited_place :a_end, source_role: :location + inherited_place :poi, via: [:cvc_link, :nni_link], source_role: :poi end # Party or Place @@ -386,7 +441,7 @@ defmodule Diffo.Provider.Extension do end """ ], - entities: [@place_entity, @places_entity, @place_ref_entity, @place_role_entity] + entities: [@place_entity, @places_entity, @place_ref_entity, @place_role_entity, @inherited_place_entity] } # ── instances ────────────────────────────────────────────────────────────── @@ -586,7 +641,8 @@ defmodule Diffo.Provider.Extension do sections: [@provider], transformers: [ Diffo.Provider.Extension.Transformers.TransformRelationships, - Diffo.Provider.Extension.Transformers.TransformBehaviour + Diffo.Provider.Extension.Transformers.TransformBehaviour, + Diffo.Provider.Extension.Transformers.TransformInheritedRefs ], persisters: [ Diffo.Provider.Extension.Persisters.PersistSpecification, diff --git a/lib/diffo/provider/extension/inherited_party_declaration.ex b/lib/diffo/provider/extension/inherited_party_declaration.ex new file mode 100644 index 0000000..d02437f --- /dev/null +++ b/lib/diffo/provider/extension/inherited_party_declaration.ex @@ -0,0 +1,12 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.InheritedPartyDeclaration do + @moduledoc "DSL entity declaring an inherited party role — derived by traversing the assignment graph" + defstruct [:role, :via, :source_role, __spark_metadata__: nil] + + defimpl String.Chars do + def to_string(struct), do: inspect(struct) + end +end diff --git a/lib/diffo/provider/extension/inherited_place_declaration.ex b/lib/diffo/provider/extension/inherited_place_declaration.ex new file mode 100644 index 0000000..7f55dc9 --- /dev/null +++ b/lib/diffo/provider/extension/inherited_place_declaration.ex @@ -0,0 +1,12 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.InheritedPlaceDeclaration do + @moduledoc "DSL entity declaring an inherited place role — derived by traversing the assignment graph" + defstruct [:role, :via, :source_role, __spark_metadata__: nil] + + defimpl String.Chars do + def to_string(struct), do: inspect(struct) + end +end diff --git a/lib/diffo/provider/extension/transformers/transform_inherited_refs.ex b/lib/diffo/provider/extension/transformers/transform_inherited_refs.ex new file mode 100644 index 0000000..8ef4c32 --- /dev/null +++ b/lib/diffo/provider/extension/transformers/transform_inherited_refs.ex @@ -0,0 +1,63 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.Transformers.TransformInheritedRefs do + @moduledoc "Injects Ash calculations for inherited_place and inherited_party declarations" + use Spark.Dsl.Transformer + alias Spark.Dsl.Transformer + alias Diffo.Provider.Extension.InheritedPlaceDeclaration + alias Diffo.Provider.Extension.InheritedPartyDeclaration + + @impl true + def transform(dsl_state) do + places = Transformer.get_entities(dsl_state, [:provider, :places]) + parties = Transformer.get_entities(dsl_state, [:provider, :parties]) + + dsl_state = + places + |> Enum.filter(&is_struct(&1, InheritedPlaceDeclaration)) + |> Enum.reduce(dsl_state, &inject_place_calculation(&2, &1)) + + dsl_state = + parties + |> Enum.filter(&is_struct(&1, InheritedPartyDeclaration)) + |> Enum.reduce(dsl_state, &inject_party_calculation(&2, &1)) + + {:ok, dsl_state} + end + + defp inject_place_calculation(dsl_state, %InheritedPlaceDeclaration{} = decl) do + via = decl.via || [decl.role] + + calc = %Ash.Resource.Calculation{ + name: decl.role, + type: {:array, :map}, + calculation: {Diffo.Provider.Calculations.InheritedPlace, [via: via, source_role: decl.source_role]}, + description: "Inherited place via assignment alias traversal", + arguments: [], + public?: true, + allow_nil?: true, + constraints: [] + } + + Transformer.add_entity(dsl_state, [:calculations], calc) + end + + defp inject_party_calculation(dsl_state, %InheritedPartyDeclaration{} = decl) do + via = decl.via || [decl.role] + + calc = %Ash.Resource.Calculation{ + name: decl.role, + type: {:array, :map}, + calculation: {Diffo.Provider.Calculations.InheritedParty, [via: via, source_role: decl.source_role]}, + description: "Inherited party via assignment alias traversal", + arguments: [], + public?: true, + allow_nil?: true, + constraints: [] + } + + Transformer.add_entity(dsl_state, [:calculations], calc) + end +end diff --git a/mix.lock b/mix.lock index 44d757b..4d5dc6e 100644 --- a/mix.lock +++ b/mix.lock @@ -9,7 +9,7 @@ "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, "decimal": {:hex, :decimal, "3.1.0", "9ede268cff827e6f0c4fb1b34747c82630dce5d7b877dfb22ec8f0cb25855fce", [:mix], [], "hexpm", "e8b3efb3bb3a13cb5e4268ffe128569067b1972e9dee013537c71a5b073168f9"}, "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, - "ecto": {:hex, :ecto, "3.13.6", "352135b474f91d1ab99a1b502171d207e9db60421c9e3d0ecab4c7ab96b24d14", [:mix], [{:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8afa059bc16cd2c94739ec0a11e3e5df69d828125119109bef35f20a21a76af2"}, + "ecto": {:hex, :ecto, "3.14.0", "2fa64521eebfcb2670d907a86e4ad947290e9933706bb315e6fb5c21b172cb26", [:mix], [{:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "130d69ffb4285f9ce4792b65dfbb994fd13ea4cbc3cbea2524b199aa3de84af3"}, "ets": {:hex, :ets, "0.9.0", "79c6a6c205436780486f72d84230c6cba2f8a9920456750ddd1e47389107d5fd", [:mix], [], "hexpm", "2861fdfb04bcaeff370f1a5904eec864f0a56dcfebe5921ea9aadf2a481c822b"}, "ex_ast": {:hex, :ex_ast, "0.12.0", "052ad63711da41b7efbfb3490dbf3d757bb67caec17d02f6deb0db4a0363e5f6", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.7", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "66b4797f157d32f0a63c6da227515f78816c0ac8f621f6d7a2b22108e7b4dd85"}, "ex_doc": {:hex, :ex_doc, "0.40.2", "f50edec428c4b0a457a167de42414c461122a3585a99515a69d09fff19e5597e", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "4fa426e2beb47854a162e2c488727fdec51cd4692e319b23810c2804cb1a40fe"}, diff --git a/test/provider/extension/inherited_refs_test.exs b/test/provider/extension/inherited_refs_test.exs new file mode 100644 index 0000000..d99ca41 --- /dev/null +++ b/test/provider/extension/inherited_refs_test.exs @@ -0,0 +1,122 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.InheritedRefsTest do + @moduledoc false + use ExUnit.Case, async: true + @moduletag :domain_extended + + alias Diffo.Provider.Assignment + alias Diffo.Test.Servo + + setup do + AshNeo4j.Sandbox.checkout() + on_exit(&AshNeo4j.Sandbox.rollback/0) + end + + describe "inherited_place — single-hop via alias" do + test "service inherits place from assigned card via :primary alias" do + place = + Diffo.Provider.create_place!(%{ + id: "LOC-TEST-INHERITED-001", + name: "Test Exchange", + type: :GeographicSite + }) + + {:ok, card} = Servo.build_card(%{}) + + updates = [ + card: [family: :ISAM, model: "EBLT48", technology: :adsl2Plus], + ports: [first: 1, last: 48, assignable_type: "ADSL2+"] + ] + + {:ok, card} = Servo.define_card(card, %{characteristic_value_updates: updates}) + {:ok, card} = Servo.lifecycle_card(card, %{resource_state: :operating}) + + Diffo.Provider.create_place_ref!(%{ + instance_id: card.id, + role: :location, + place_id: place.id + }) + + {:ok, service} = Servo.build_access_service(%{}) + + {:ok, _card} = + Servo.assign_port(card, %{ + assignment: %Assignment{assignee_id: service.id, operation: :auto_assign, alias: :primary} + }) + + service = Ash.load!(service, [:primary], domain: Servo) + + assert length(service.primary) == 1 + assert hd(service.primary).id == place.id + end + + test "service with no assignment returns empty list for inherited place" do + {:ok, service} = Servo.build_access_service(%{}) + + service = Ash.load!(service, [:primary], domain: Servo) + + assert service.primary == [] + end + + test "service inherits only the place from the aliased assignment, not from unaliased ones" do + place_a = + Diffo.Provider.create_place!(%{ + id: "LOC-TEST-INHERITED-002", + name: "Exchange A", + type: :GeographicSite + }) + + place_b = + Diffo.Provider.create_place!(%{ + id: "LOC-TEST-INHERITED-003", + name: "Exchange B", + type: :GeographicSite + }) + + {:ok, card_a} = Servo.build_card(%{}) + {:ok, card_b} = Servo.build_card(%{}) + + updates = [ + card: [family: :ISAM, model: "EBLT48", technology: :adsl2Plus], + ports: [first: 1, last: 48, assignable_type: "ADSL2+"] + ] + + {:ok, card_a} = Servo.define_card(card_a, %{characteristic_value_updates: updates}) + {:ok, card_a} = Servo.lifecycle_card(card_a, %{resource_state: :operating}) + {:ok, card_b} = Servo.define_card(card_b, %{characteristic_value_updates: updates}) + {:ok, card_b} = Servo.lifecycle_card(card_b, %{resource_state: :operating}) + + Diffo.Provider.create_place_ref!(%{ + instance_id: card_a.id, + role: :location, + place_id: place_a.id + }) + + Diffo.Provider.create_place_ref!(%{ + instance_id: card_b.id, + role: :location, + place_id: place_b.id + }) + + {:ok, service} = Servo.build_access_service(%{}) + + {:ok, _card_a} = + Servo.assign_port(card_a, %{ + assignment: %Assignment{assignee_id: service.id, operation: :auto_assign, alias: :primary} + }) + + {:ok, _card_b} = + Servo.assign_port(card_b, %{ + assignment: %Assignment{assignee_id: service.id, operation: :auto_assign, alias: :secondary} + }) + + service = Ash.load!(service, [:primary], domain: Servo) + + assert length(service.primary) == 1 + assert hd(service.primary).id == place_a.id + end + end +end diff --git a/test/support/resource/instance/access_service.ex b/test/support/resource/instance/access_service.ex new file mode 100644 index 0000000..9f102e5 --- /dev/null +++ b/test/support/resource/instance/access_service.ex @@ -0,0 +1,50 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Test.Instance.AccessService do + @moduledoc """ + Minimal test service instance that declares an inherited_place. + Used by inherited_refs_test.exs to verify assignment alias traversal. + """ + alias Diffo.Provider.BaseInstance + alias Diffo.Test.Servo + + use Ash.Resource, + fragments: [BaseInstance], + domain: Servo + + resource do + description "A test access service with an inherited place via assignment alias" + plural_name :access_services + end + + provider do + specification do + id "c4e7a2b1-3d5f-4a6b-8c9d-0e1f2a3b4c5d" + name "accessService" + type :serviceSpecification + description "A test access service instance" + category "Access" + end + + places do + inherited_place :primary, source_role: :location + end + + behaviour do + actions do + create :build + end + end + end + + actions do + create :build do + accept [:id, :name, :type] + change set_attribute(:type, :service) + change load [:href] + upsert? false + end + end +end diff --git a/test/support/servo.ex b/test/support/servo.ex index d055fff..78b58ac 100644 --- a/test/support/servo.ex +++ b/test/support/servo.ex @@ -17,6 +17,7 @@ defmodule Diffo.Test.Servo do alias Diffo.Test.Instance.CardInstance alias Diffo.Test.Instance.Broadband alias Diffo.Test.Instance.BroadbandV2 + alias Diffo.Test.Instance.AccessService alias Diffo.Test.Characteristic.ShelfCharacteristic alias Diffo.Test.Characteristic.CardCharacteristic alias Diffo.Test.Characteristic.DeploymentClass @@ -54,6 +55,11 @@ defmodule Diffo.Test.Servo do define :get_broadband_v2_by_id, action: :read, get_by: :id end + resource AccessService do + define :build_access_service, action: :build + define :get_access_service_by_id, action: :read, get_by: :id + end + resource ShelfCharacteristic resource CardCharacteristic resource DeploymentClass