From f5c9448b8689c4c40a76f076ba33128049220bef Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Sun, 17 May 2026 08:02:23 +0930 Subject: [PATCH 1/6] phases 1,2,3 --- lib/diffo/provider.ex | 8 + .../assigner/assignable_characteristic.ex | 81 ++++++ .../assignable_characteristic/value.ex | 25 ++ lib/diffo/provider/assigner/assigner.ex | 271 ++++++------------ lib/diffo/provider/components/relationship.ex | 51 +++- test/provider/extension/assigner_test.exs | 50 ++-- test/support/resource/instance/card.ex | 4 +- test/support/servo.ex | 2 + 8 files changed, 274 insertions(+), 218 deletions(-) create mode 100644 lib/diffo/provider/assigner/assignable_characteristic.ex create mode 100644 lib/diffo/provider/assigner/assignable_characteristic/value.ex diff --git a/lib/diffo/provider.ex b/lib/diffo/provider.ex index 74987a4..abe503d 100644 --- a/lib/diffo/provider.ex +++ b/lib/diffo/provider.ex @@ -59,6 +59,7 @@ defmodule Diffo.Provider do resource Diffo.Provider.Relationship do define :create_relationship, action: :create + define :create_assignment_relationship, action: :create_assignment define :get_relationship_by_id, action: :read, get_by: :id define :list_relationships, action: :list @@ -77,6 +78,13 @@ defmodule Diffo.Provider do define :delete_relationship, action: :destroy end + resource Diffo.Provider.AssignableCharacteristic do + define :create_assignable_characteristic, action: :create + define :get_assignable_characteristic_by_id, action: :read, get_by: :id + define :update_assignable_characteristic, action: :update + define :delete_assignable_characteristic, action: :destroy + end + resource Diffo.Provider.Characteristic do define :create_characteristic, action: :create define :get_characteristic_by_id, action: :read, get_by: :id diff --git a/lib/diffo/provider/assigner/assignable_characteristic.ex b/lib/diffo/provider/assigner/assignable_characteristic.ex new file mode 100644 index 0000000..38cfefe --- /dev/null +++ b/lib/diffo/provider/assigner/assignable_characteristic.ex @@ -0,0 +1,81 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.AssignableCharacteristic do + @moduledoc """ + Typed characteristic carrying pool bounds and assignment algorithm. + + Replaces the `AssignableValue` TypedStruct. Stored as a proper Neo4j node + via `BaseCharacteristic`, with direct attributes rather than a wrapped + `Diffo.Type.Value` dynamic. The `free` count is not stored here — it is + derived from the count of `assignedTo` Relationship records (Phase 4). + """ + use Ash.Resource, + fragments: [Diffo.Provider.BaseCharacteristic], + domain: Diffo.Provider + + resource do + description "Typed characteristic carrying pool assignment bounds and algorithm" + plural_name :assignable_characteristics + end + + attributes do + attribute :first, :integer do + description "the first assignable value in the pool" + public? true + default 1 + constraints min: 0 + end + + attribute :last, :integer do + description "the last assignable value in the pool" + public? true + default 1 + constraints min: 0 + end + + attribute :assignable_type, :string do + description "the type label of the assignable thing (e.g. \"ADSL2+\")" + public? true + allow_nil? true + end + + attribute :algorithm, :atom do + description "the selection algorithm for auto-assign" + public? true + default :lowest + constraints one_of: [:lowest, :highest, :random] + end + end + + calculations do + calculate :value, Diffo.Type.CharacteristicValue, + Diffo.Provider.Calculations.CharacteristicValue do + public? true + end + end + + actions do + create :create do + accept [:name, :first, :last, :assignable_type, :algorithm] + argument :instance_id, :uuid + argument :feature_id, :uuid + change manage_relationship(:instance_id, :instance, type: :append) + change manage_relationship(:feature_id, :feature, type: :append) + end + + update :update do + accept [:first, :last, :assignable_type, :algorithm] + end + end + + preparations do + prepare build(load: [:value]) + end + + jason do + pick [:name, :value] + compact true + end +end diff --git a/lib/diffo/provider/assigner/assignable_characteristic/value.ex b/lib/diffo/provider/assigner/assignable_characteristic/value.ex new file mode 100644 index 0000000..10f7d63 --- /dev/null +++ b/lib/diffo/provider/assigner/assignable_characteristic/value.ex @@ -0,0 +1,25 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.AssignableCharacteristic.Value do + @moduledoc "JSON value struct for AssignableCharacteristic." + use Ash.TypedStruct, extensions: [AshJason.TypedStruct] + + typed_struct do + field :first, :integer, description: "the first assignable value in the pool" + field :last, :integer, description: "the last assignable value in the pool" + field :assignable_type, :string, description: "the type label of the assignable thing" + field :algorithm, :atom, description: "the selection algorithm for auto-assign" + end + + jason do + pick [:first, :last, :assignable_type, :algorithm] + compact true + rename assignable_type: :type + end + + defimpl String.Chars do + def to_string(struct), do: inspect(struct) + end +end diff --git a/lib/diffo/provider/assigner/assigner.ex b/lib/diffo/provider/assigner/assigner.ex index 32b47e2..8d93608 100644 --- a/lib/diffo/provider/assigner/assigner.ex +++ b/lib/diffo/provider/assigner/assigner.ex @@ -4,16 +4,19 @@ defmodule Diffo.Provider.Assigner do @moduledoc """ - Helper to perform Assignment maintaining AssignableValue + Helper to perform Assignment using Relationship attributes. + + Assignment state is stored directly on `Diffo.Provider.Relationship` nodes + (pool, thing, assigned) rather than creating a separate Characteristic node. """ - alias Diffo.Provider.AssignableValue - alias Diffo.Type.Value + alias Diffo.Provider.AssignableCharacteristic + alias Diffo.Provider.Relationship @doc """ - Assign a thing using the instance changeset assignment + Assign a thing using the instance changeset assignment. """ - def assign(result, changeset, things, thing) - when is_struct(result) and is_struct(changeset, Ash.Changeset) and is_atom(things) and + def assign(result, changeset, pool, thing) + when is_struct(result) and is_struct(changeset, Ash.Changeset) and is_atom(pool) and is_atom(thing) do assignment = Map.get(changeset.arguments, :assignment, %{}) assignee_id = Map.get(assignment, :assignee_id) @@ -25,240 +28,136 @@ defmodule Diffo.Provider.Assigner do _ -> case Map.get(assignment, :operation, :auto_assign) do :auto_assign -> - case next(result, things, thing) do + case next(result, pool, thing) do {:ok, assigned} -> - relate_is_assigned(result, things, thing, assigned, assignee_id) + relate_is_assigned(result, pool, thing, assigned, assignee_id) {:error, error} -> {:error, error} end :assign -> - case assignable?(result, things, thing, assignment.id) do + case assignable?(result, pool, thing, assignment.id) do true -> - relate_is_assigned(result, things, thing, assignment.id, assignee_id) + relate_is_assigned(result, pool, thing, assignment.id, assignee_id) false -> {:error, "#{thing} #{assignment.id} is not assignable"} end :unassign -> - unrelate_is_assigned(result, things, thing, assignment.id, assignee_id) + unrelate_is_assigned(result, pool, thing, assignment.id, assignee_id) end end end - defp relate_is_assigned(result, things, thing, value, assignee_id) - when is_struct(result) and is_atom(things) and is_atom(thing) and is_integer(value) and + defp relate_is_assigned(result, pool, thing, value, assignee_id) + when is_struct(result) and is_atom(pool) and is_atom(thing) and is_integer(value) and is_bitstring(assignee_id) do - case Diffo.Provider.create_characteristic(%{ - name: thing, - value: Value.primitive("integer", value), - type: :relationship + case Diffo.Provider.create_assignment_relationship(%{ + pool: pool, + thing: thing, + assigned: value, + source_id: result.id, + target_id: assignee_id }) do - {:ok, characteristic} -> - case Diffo.Provider.create_relationship(%{ - type: :assignedTo, - source_id: result.id, - target_id: assignee_id, - characteristics: [characteristic.id] - }) do - {:ok, _relationship} -> - case decrement_free(result, things) do - :ok -> - {:ok, result} - - {:error, error} -> - {:error, error} - end - - {:error, error} -> - {:error, error} - end + {:ok, _relationship} -> + {:ok, result} {:error, error} -> {:error, error} end end - defp unrelate_is_assigned(result, things, thing, value, assignee_id) - when is_struct(result) and is_atom(things) and is_atom(thing) and is_integer(value) and + defp unrelate_is_assigned(result, pool, thing, value, assignee_id) + when is_struct(result) and is_atom(pool) and is_atom(thing) and is_integer(value) and is_bitstring(assignee_id) do - relationships = - Enum.filter(result.forward_relationships, fn %{ - type: type, - target_id: target_id, - characteristics: characteristics - } -> - type == :assignedTo and target_id == assignee_id and - Enum.any?(characteristics, fn %{name: name, value: v} -> - name == thing and Diffo.Unwrap.unwrap(v) == value - end) - end) - - case length(relationships) do - 0 -> + case find_assignment(result.id, assignee_id, pool, thing, value) do + {:ok, nil} -> {:error, "#{thing} #{value} is not assigned to assignee #{assignee_id}"} - 1 -> - relationship = hd(relationships) - - characteristic = - Enum.find(relationship.characteristics, fn %{name: n} -> n == thing end) - - relationship = - Diffo.Provider.unrelate_relationship_characteristics!(relationship, %{ - characteristics: [characteristic.id] - }) - - Diffo.Provider.delete_characteristic(characteristic.id) - - case Diffo.Provider.delete_relationship(relationship.id) do + {:ok, relationship} -> + case Ash.destroy(relationship, domain: Diffo.Provider) do :ok -> - case increment_free(result, things) do - :ok -> - {:ok, result} - - {:error, error} -> - {:error, error} - end + {:ok, result} {:error, error} -> {:error, error} end - _ -> - {:error, "multiple relationships found for #{thing} #{value} and assignee #{assignee_id}"} + {:error, error} -> + {:error, error} end end - defp assignments(instance, thing) when is_struct(instance) and is_atom(thing) do - Enum.reduce(instance.forward_relationships, [], fn %{ - type: type, - characteristics: characteristics, - target_id: target_id - }, - acc -> - case type do - :assignedTo -> - characteristic = Enum.find(characteristics, fn %{name: n} -> n == thing end) - - if characteristic do - assignment = - struct(Diffo.Provider.Assignment, %{ - id: Diffo.Unwrap.unwrap(characteristic.value), - assignable_type: thing, - assignee_id: target_id - }) - - [assignment | acc] - else - acc - end - - _ -> - acc - end - end) - |> Enum.sort(Diffo.Provider.Assignment) + defp find_assignment(source_id, target_id, pool, thing, value) do + Relationship + |> Ash.Query.new() + |> Ash.Query.filter_input( + source_id: source_id, + target_id: target_id, + pool: pool, + thing: thing, + assigned: value, + type: :assignedTo + ) + |> Ash.read_one(domain: Diffo.Provider) end - defp next(instance, things, thing) - when is_struct(instance) and is_atom(things) and is_atom(thing) do - characteristic = Enum.find(instance.characteristics, fn %{name: name} -> name == things end) - assignable_value = Diffo.Unwrap.unwrap(characteristic.value) - algorithm = Map.get(assignable_value, :algorithm) - - case free = free(instance, thing, assignable_value) do - [] -> - {:error, "all things are assigned"} + defp next(instance, pool, thing) + when is_struct(instance) and is_atom(pool) and is_atom(thing) do + case pool_characteristic(instance.id, pool) do + {:ok, nil} -> + {:error, "pool #{pool} not found on instance #{instance.id}"} - _ -> - case algorithm do - :lowest -> - {:ok, hd(free)} + {:ok, char} -> + free = free_values(instance.id, pool, thing, char.first, char.last) - :random -> - {:ok, Enum.random(free)} + case free do + [] -> + {:error, "all things are assigned"} - :highest -> - {:ok, List.last(free)} + _ -> + case char.algorithm do + :lowest -> {:ok, hd(free)} + :random -> {:ok, Enum.random(free)} + :highest -> {:ok, List.last(free)} + end end - end - end - - defp assignable?(instance, things, thing, value) - when is_struct(instance) and is_atom(things) and is_atom(thing) and is_integer(value) do - characteristic = Enum.find(instance.characteristics, fn %{name: name} -> name == things end) - assignable_value = Diffo.Unwrap.unwrap(characteristic.value) - free = free(instance, thing, assignable_value) - - value in free - end - - defp decrement_free(instance, things) when is_struct(instance) and is_atom(things) do - characteristic = - Enum.find(instance.characteristics, fn %{name: name} -> name == things end) - - assignable_value = Diffo.Unwrap.unwrap(characteristic.value) - - {_free, updated} = - Map.get_and_update(assignable_value, :free, fn free -> {free - 1, free - 1} end) - - {:ok, new_struct} = - Ash.Type.cast_input( - AssignableValue, - Map.from_struct(updated), - AssignableValue.subtype_constraints() - ) - - new_value = Value.dynamic(new_struct) - - case Diffo.Provider.update_characteristic(characteristic, %{value: new_value}) do - {:ok, _characteristic} -> - :ok {:error, error} -> {:error, error} end end - defp increment_free(instance, things) when is_struct(instance) and is_atom(things) do - characteristic = - Enum.find(instance.characteristics, fn %{name: name} -> name == things end) - - assignable_value = Diffo.Unwrap.unwrap(characteristic.value) - - {_free, updated} = - Map.get_and_update(assignable_value, :free, fn free -> {free + 1, free + 1} end) - - {:ok, new_struct} = - Ash.Type.cast_input( - AssignableValue, - Map.from_struct(updated), - AssignableValue.subtype_constraints() - ) - - new_value = Value.dynamic(new_struct) - - case Diffo.Provider.update_characteristic(characteristic, %{value: new_value}) do - {:ok, _characteristic} -> - :ok - - {:error, error} -> - {:error, error} + defp assignable?(instance, pool, thing, value) + when is_struct(instance) and is_atom(pool) and is_atom(thing) and is_integer(value) do + case pool_characteristic(instance.id, pool) do + {:ok, nil} -> false + {:ok, char} -> value in free_values(instance.id, pool, thing, char.first, char.last) + {:error, _} -> false end end - defp free(instance, thing, assignable_value) - when is_struct(instance) and is_atom(thing) and - is_struct(assignable_value, AssignableValue) do - assigned = - assignments(instance, thing) - |> Enum.into([], &Map.get(&1, :id)) + defp pool_characteristic(instance_id, pool) do + AssignableCharacteristic + |> Ash.Query.new() + |> Ash.Query.filter_input(instance_id: instance_id, name: pool) + |> Ash.read_one(domain: Diffo.Provider) + end - first = Map.get(assignable_value, :first) - last = Map.get(assignable_value, :last) + defp free_values(source_id, pool, thing, first, last) do + assigned = + Relationship + |> Ash.Query.new() + |> Ash.Query.filter_input( + source_id: source_id, + pool: pool, + thing: thing, + type: :assignedTo + ) + |> Ash.read!(domain: Diffo.Provider) + |> Enum.map(& &1.assigned) Enum.to_list(first..last) -- assigned end diff --git a/lib/diffo/provider/components/relationship.ex b/lib/diffo/provider/components/relationship.ex index 28ceb03..fdcc560 100644 --- a/lib/diffo/provider/components/relationship.ex +++ b/lib/diffo/provider/components/relationship.ex @@ -44,10 +44,18 @@ defmodule Diffo.Provider.Relationship do list_name = Diffo.Provider.Relationship.derive_relationship_characteristic_list_name(target_type) - result - |> Diffo.Util.set(target_type, reference) - |> Diffo.Util.suppress_rename(:characteristics, list_name) - |> Diffo.Util.suppress(:alias) + if record.type == :assignedTo and not is_nil(record.assigned) do + result + |> Diffo.Util.set(target_type, reference) + |> Diffo.Util.remove(:alias) + |> Diffo.Util.remove(:characteristics) + |> Diffo.Util.set(list_name, [%{name: record.thing, value: record.assigned}]) + else + result + |> Diffo.Util.set(target_type, reference) + |> Diffo.Util.suppress_rename(:characteristics, list_name) + |> Diffo.Util.suppress(:alias) + end end order [ @@ -82,6 +90,19 @@ defmodule Diffo.Provider.Relationship do change load [:characteristics] end + create :create_assignment do + description "creates an assignment relationship with pool/thing/assigned attributes" + accept [:pool, :thing, :assigned] + + argument :source_id, :uuid + argument :target_id, :string + + change set_attribute(:type, :assignedTo) + change manage_relationship(:source_id, :source, type: :append) + change manage_relationship(:target_id, :target, type: :append) + change Diffo.Changes.DetailRelationship + end + read :list do description "lists all relationships" end @@ -148,6 +169,24 @@ defmodule Diffo.Provider.Relationship do public? true end + attribute :pool, :atom do + description "the pool name on the source instance (assignedTo relationships only)" + allow_nil? true + public? true + end + + attribute :thing, :atom do + description "the kind of thing being assigned within the pool (assignedTo relationships only)" + allow_nil? true + public? true + end + + attribute :assigned, :integer do + description "the assigned value from the pool (assignedTo relationships only)" + allow_nil? true + public? true + end + create_timestamp :created_at update_timestamp :updated_at @@ -174,6 +213,10 @@ defmodule Diffo.Provider.Relationship do identities do identity :unique_source_and_target, [:source_id, :target_id] + + identity :unique_assignment, [:source_id, :target_id, :pool, :thing, :assigned] do + where expr(type == :assignedTo) + end end validations do diff --git a/test/provider/extension/assigner_test.exs b/test/provider/extension/assigner_test.exs index 3e8d8f2..a3d2574 100644 --- a/test/provider/extension/assigner_test.exs +++ b/test/provider/extension/assigner_test.exs @@ -9,7 +9,6 @@ defmodule Diffo.Provider.Extension.AssignerTest do alias Diffo.Provider.Characteristic alias Diffo.Provider.Assignment - alias Diffo.Test.Characteristics alias Diffo.Test.Parties alias Diffo.Test.Servo alias Diffo.Test.Instance.Card @@ -24,10 +23,8 @@ defmodule Diffo.Provider.Extension.AssignerTest do test "create a card" do {:ok, card} = Servo.build_card(%{}) - # check the instance is a Card assert is_struct(card, Card) - # check specification resource enrichment and node relationship refute is_nil(card.specification_id) assert is_struct(card.specification, Specification) @@ -40,10 +37,9 @@ defmodule Diffo.Provider.Extension.AssignerTest do :outgoing ) - # check dynamic characteristic resource enrichment and node relationships - # :card is now a typed CardValue node (not in characteristics); only :ports remains dynamic + # both :card and :ports are now typed (BaseCharacteristic), not in dynamic characteristics assert is_list(card.characteristics) - assert length(card.characteristics) == 1 + assert length(card.characteristics) == 0 Enum.each(card.characteristics, fn characteristic -> assert is_struct(characteristic, Characteristic) @@ -61,7 +57,7 @@ defmodule Diffo.Provider.Extension.AssignerTest do encoding = Jason.encode!(card) |> Diffo.Util.summarise_dates() assert encoding == - ~s({\"id\":\"#{card.id}",\"href\":\"resourceInventoryManagement/v4/resource/#{card.id}",\"category\":\"Network Resource\",\"description\":\"A Card Resource Instance\",\"resourceSpecification\":{\"id\":\"cd29956f-6c68-44cc-bf54-705eb8d2f754\",\"href\":\"resourceCatalogManagement/v4/resourceSpecification/cd29956f-6c68-44cc-bf54-705eb8d2f754\",\"name\":\"card\",\"version\":\"v1.0.0\"},\"resourceCharacteristic\":[{\"name\":\"ports\",\"value\":{\"first\":1,\"last\":1,\"free\":1,\"algorithm\":\"lowest\"}}]}) + ~s({\"id\":\"#{card.id}",\"href\":\"resourceInventoryManagement/v4/resource/#{card.id}",\"category\":\"Network Resource\",\"description\":\"A Card Resource Instance\",\"resourceSpecification\":{\"id\":\"cd29956f-6c68-44cc-bf54-705eb8d2f754\",\"href\":\"resourceCatalogManagement/v4/resourceSpecification/cd29956f-6c68-44cc-bf54-705eb8d2f754\",\"name\":\"card\",\"version\":\"v1.0.0\"}}) end test "define card" do @@ -69,7 +65,7 @@ defmodule Diffo.Provider.Extension.AssignerTest do updates = [ card: [family: :ISAM, model: "EBLT48", technology: :adsl2Plus], - ports: [first: 1, last: 48, free: 48, assignable_type: "ADSL2+"] + ports: [first: 1, last: 48, assignable_type: "ADSL2+"] ] {:ok, card} = Servo.define_card(card, %{characteristic_value_updates: updates}) @@ -87,7 +83,7 @@ defmodule Diffo.Provider.Extension.AssignerTest do encoding = Jason.encode!(card) |> Diffo.Util.summarise_dates() assert encoding == - ~s({\"id\":\"#{card.id}",\"href\":\"resourceInventoryManagement/v4/resource/#{card.id}",\"category\":\"Network Resource\",\"description\":\"A Card Resource Instance\",\"resourceSpecification\":{\"id\":\"cd29956f-6c68-44cc-bf54-705eb8d2f754\",\"href\":\"resourceCatalogManagement/v4/resourceSpecification/cd29956f-6c68-44cc-bf54-705eb8d2f754\",\"name\":\"card\",\"version\":\"v1.0.0\"},\"resourceCharacteristic\":[{\"name\":\"ports\",\"value\":{\"first\":1,\"last\":48,\"free\":48,\"type\":\"ADSL2+\",\"algorithm\":\"lowest\"}}]}) + ~s({\"id\":\"#{card.id}",\"href\":\"resourceInventoryManagement/v4/resource/#{card.id}",\"category\":\"Network Resource\",\"description\":\"A Card Resource Instance\",\"resourceSpecification\":{\"id\":\"cd29956f-6c68-44cc-bf54-705eb8d2f754\",\"href\":\"resourceCatalogManagement/v4/resourceSpecification/cd29956f-6c68-44cc-bf54-705eb8d2f754\",\"name\":\"card\",\"version\":\"v1.0.0\"}}) end test "auto assign port to resource" do @@ -97,7 +93,7 @@ defmodule Diffo.Provider.Extension.AssignerTest do updates = [ card: [family: :ISAM, model: "EBLT48", technology: :adsl2Plus], - ports: [first: 1, last: 48, free: 48, assignable_type: "ADSL2+"] + ports: [first: 1, last: 48, assignable_type: "ADSL2+"] ] {:ok, card} = Servo.define_card(card, %{characteristic_value_updates: updates}) @@ -107,12 +103,13 @@ defmodule Diffo.Provider.Extension.AssignerTest do assignment: %Assignment{assignee_id: assignee.id, operation: :auto_assign} }) - Characteristics.check_values([ports: [free: 47]], card) + assigned_rels = Enum.filter(card.forward_relationships, fn rel -> rel.type == :assignedTo end) + assert length(assigned_rels) == 1 encoding = Jason.encode!(card) |> Diffo.Util.summarise_dates() assert encoding == - ~s({\"id\":\"#{card.id}",\"href\":\"resourceInventoryManagement/v4/resource/#{card.id}",\"category\":\"Network Resource\",\"description\":\"A Card Resource Instance\",\"resourceSpecification\":{\"id\":\"cd29956f-6c68-44cc-bf54-705eb8d2f754\",\"href\":\"resourceCatalogManagement/v4/resourceSpecification/cd29956f-6c68-44cc-bf54-705eb8d2f754\",\"name\":\"card\",\"version\":\"v1.0.0\"},\"resourceRelationship\":[{\"type\":\"assignedTo\",\"resource\":{\"id\":\"#{assignee.id}\",\"href\":\"resourceInventoryManagement/v4/resource/#{assignee.id}\"},\"resourceRelationshipCharacteristic\":[{\"name\":\"port\",\"value\":1}]}],\"resourceCharacteristic\":[{\"name\":\"ports\",\"value\":{\"first\":1,\"last\":48,\"free\":47,\"type\":\"ADSL2+\",\"algorithm\":\"lowest\"}}]}) + ~s({\"id\":\"#{card.id}",\"href\":\"resourceInventoryManagement/v4/resource/#{card.id}",\"category\":\"Network Resource\",\"description\":\"A Card Resource Instance\",\"resourceSpecification\":{\"id\":\"cd29956f-6c68-44cc-bf54-705eb8d2f754\",\"href\":\"resourceCatalogManagement/v4/resourceSpecification/cd29956f-6c68-44cc-bf54-705eb8d2f754\",\"name\":\"card\",\"version\":\"v1.0.0\"},\"resourceRelationship\":[{\"type\":\"assignedTo\",\"resource\":{\"id\":\"#{assignee.id}\",\"href\":\"resourceInventoryManagement/v4/resource/#{assignee.id}\"},\"resourceRelationshipCharacteristic\":[{\"name\":\"port\",\"value\":1}]}]}) end test "auto assign two ports to same resource" do @@ -122,7 +119,7 @@ defmodule Diffo.Provider.Extension.AssignerTest do updates = [ card: [family: :ISAM, model: "EBLT48", technology: :adsl2Plus], - ports: [first: 1, last: 48, free: 48, assignable_type: "ADSL2+"] + ports: [first: 1, last: 48, assignable_type: "ADSL2+"] ] {:ok, card} = Servo.define_card(card, %{characteristic_value_updates: updates}) @@ -137,12 +134,13 @@ defmodule Diffo.Provider.Extension.AssignerTest do assignment: %Assignment{assignee_id: assignee.id, operation: :auto_assign} }) - Characteristics.check_values([ports: [free: 46]], card) + assigned_rels = Enum.filter(card.forward_relationships, fn rel -> rel.type == :assignedTo end) + assert length(assigned_rels) == 2 encoding = Jason.encode!(card) |> Diffo.Util.summarise_dates() assert encoding == - ~s({\"id\":\"#{card.id}",\"href\":\"resourceInventoryManagement/v4/resource/#{card.id}",\"category\":\"Network Resource\",\"description\":\"A Card Resource Instance\",\"resourceSpecification\":{\"id\":\"cd29956f-6c68-44cc-bf54-705eb8d2f754\",\"href\":\"resourceCatalogManagement/v4/resourceSpecification/cd29956f-6c68-44cc-bf54-705eb8d2f754\",\"name\":\"card\",\"version\":\"v1.0.0\"},\"resourceRelationship\":[{\"type\":\"assignedTo\",\"resource\":{\"id\":\"#{assignee.id}\",\"href\":\"resourceInventoryManagement/v4/resource/#{assignee.id}\"},\"resourceRelationshipCharacteristic\":[{\"name\":\"port\",\"value\":1}]},{\"type\":\"assignedTo\",\"resource\":{\"id\":\"#{assignee.id}\",\"href\":\"resourceInventoryManagement/v4/resource/#{assignee.id}\"},\"resourceRelationshipCharacteristic\":[{\"name\":\"port\",\"value\":2}]}],\"resourceCharacteristic\":[{\"name\":\"ports\",\"value\":{\"first\":1,\"last\":48,\"free\":46,\"type\":\"ADSL2+\",\"algorithm\":\"lowest\"}}]}) + ~s({\"id\":\"#{card.id}",\"href\":\"resourceInventoryManagement/v4/resource/#{card.id}",\"category\":\"Network Resource\",\"description\":\"A Card Resource Instance\",\"resourceSpecification\":{\"id\":\"cd29956f-6c68-44cc-bf54-705eb8d2f754\",\"href\":\"resourceCatalogManagement/v4/resourceSpecification/cd29956f-6c68-44cc-bf54-705eb8d2f754\",\"name\":\"card\",\"version\":\"v1.0.0\"},\"resourceRelationship\":[{\"type\":\"assignedTo\",\"resource\":{\"id\":\"#{assignee.id}\",\"href\":\"resourceInventoryManagement/v4/resource/#{assignee.id}\"},\"resourceRelationshipCharacteristic\":[{\"name\":\"port\",\"value\":1}]},{\"type\":\"assignedTo\",\"resource\":{\"id\":\"#{assignee.id}\",\"href\":\"resourceInventoryManagement/v4/resource/#{assignee.id}\"},\"resourceRelationshipCharacteristic\":[{\"name\":\"port\",\"value\":2}]}]}) end test "specific assignment rejects duplicate request" do @@ -152,7 +150,7 @@ defmodule Diffo.Provider.Extension.AssignerTest do updates = [ card: [family: :ISAM, model: "EBLT48", technology: :adsl2Plus], - ports: [first: 1, last: 48, free: 48, assignable_type: "ADSL2+"] + ports: [first: 1, last: 48, assignable_type: "ADSL2+"] ] {:ok, card} = Servo.define_card(card, %{characteristic_value_updates: updates}) @@ -167,12 +165,13 @@ defmodule Diffo.Provider.Extension.AssignerTest do assignment: %Assignment{id: 5, assignee_id: assignee.id, operation: :assign} }) - Characteristics.check_values([ports: [free: 47]], card) + assigned_rels = Enum.filter(card.forward_relationships, fn rel -> rel.type == :assignedTo end) + assert length(assigned_rels) == 1 encoding = Jason.encode!(card) |> Diffo.Util.summarise_dates() assert encoding == - ~s({\"id\":\"#{card.id}",\"href\":\"resourceInventoryManagement/v4/resource/#{card.id}",\"category\":\"Network Resource\",\"description\":\"A Card Resource Instance\",\"resourceSpecification\":{\"id\":\"cd29956f-6c68-44cc-bf54-705eb8d2f754\",\"href\":\"resourceCatalogManagement/v4/resourceSpecification/cd29956f-6c68-44cc-bf54-705eb8d2f754\",\"name\":\"card\",\"version\":\"v1.0.0\"},\"resourceRelationship\":[{\"type\":\"assignedTo\",\"resource\":{\"id\":\"#{assignee.id}\",\"href\":\"resourceInventoryManagement/v4/resource/#{assignee.id}\"},\"resourceRelationshipCharacteristic\":[{\"name\":\"port\",\"value\":5}]}],\"resourceCharacteristic\":[{\"name\":\"ports\",\"value\":{\"first\":1,\"last\":48,\"free\":47,\"type\":\"ADSL2+\",\"algorithm\":\"lowest\"}}]}) + ~s({\"id\":\"#{card.id}",\"href\":\"resourceInventoryManagement/v4/resource/#{card.id}",\"category\":\"Network Resource\",\"description\":\"A Card Resource Instance\",\"resourceSpecification\":{\"id\":\"cd29956f-6c68-44cc-bf54-705eb8d2f754\",\"href\":\"resourceCatalogManagement/v4/resourceSpecification/cd29956f-6c68-44cc-bf54-705eb8d2f754\",\"name\":\"card\",\"version\":\"v1.0.0\"},\"resourceRelationship\":[{\"type\":\"assignedTo\",\"resource\":{\"id\":\"#{assignee.id}\",\"href\":\"resourceInventoryManagement/v4/resource/#{assignee.id}\"},\"resourceRelationshipCharacteristic\":[{\"name\":\"port\",\"value\":5}]}]}) end test "unassign an auto-assigned port from a resource" do @@ -182,7 +181,7 @@ defmodule Diffo.Provider.Extension.AssignerTest do updates = [ card: [family: :ISAM, model: "EBLT48", technology: :adsl2Plus], - ports: [first: 1, last: 48, free: 48, assignable_type: "ADSL2+"] + ports: [first: 1, last: 48, assignable_type: "ADSL2+"] ] {:ok, card} = Servo.define_card(card, %{characteristic_value_updates: updates}) @@ -192,14 +191,12 @@ defmodule Diffo.Provider.Extension.AssignerTest do assignment: %Assignment{assignee_id: assignee.id, operation: :auto_assign} }) - Characteristics.check_values([ports: [free: 47]], card) + assigned_rels = Enum.filter(card.forward_relationships, fn rel -> rel.type == :assignedTo end) + assert length(assigned_rels) == 1 assigned_port = Enum.find(card.forward_relationships, fn rel -> rel.type == :assignedTo end) - |> Map.get(:characteristics) - |> Enum.find(fn char -> char.name == :port end) - |> Map.get(:value) - |> Diffo.Unwrap.unwrap() + |> Map.get(:assigned) {:ok, card} = Servo.assign_port(card, %{ @@ -210,12 +207,13 @@ defmodule Diffo.Provider.Extension.AssignerTest do } }) - Characteristics.check_values([ports: [free: 48]], card) + assigned_rels = Enum.filter(card.forward_relationships, fn rel -> rel.type == :assignedTo end) + assert length(assigned_rels) == 0 encoding = Jason.encode!(card) |> Diffo.Util.summarise_dates() assert encoding == - ~s({\"id\":\"#{card.id}",\"href\":\"resourceInventoryManagement/v4/resource/#{card.id}",\"category\":\"Network Resource\",\"description\":\"A Card Resource Instance\",\"resourceSpecification\":{\"id\":\"cd29956f-6c68-44cc-bf54-705eb8d2f754\",\"href\":\"resourceCatalogManagement/v4/resourceSpecification/cd29956f-6c68-44cc-bf54-705eb8d2f754\",\"name\":\"card\",\"version\":\"v1.0.0\"},\"resourceCharacteristic\":[{\"name\":\"ports\",\"value\":{\"first\":1,\"last\":48,\"free\":48,\"type\":\"ADSL2+\",\"algorithm\":\"lowest\"}}]}) + ~s({\"id\":\"#{card.id}",\"href\":\"resourceInventoryManagement/v4/resource/#{card.id}",\"category\":\"Network Resource\",\"description\":\"A Card Resource Instance\",\"resourceSpecification\":{\"id\":\"cd29956f-6c68-44cc-bf54-705eb8d2f754\",\"href\":\"resourceCatalogManagement/v4/resourceSpecification/cd29956f-6c68-44cc-bf54-705eb8d2f754\",\"name\":\"card\",\"version\":\"v1.0.0\"}}) end end end diff --git a/test/support/resource/instance/card.ex b/test/support/resource/instance/card.ex index bad90e8..ce6269e 100644 --- a/test/support/resource/instance/card.ex +++ b/test/support/resource/instance/card.ex @@ -13,7 +13,7 @@ defmodule Diffo.Test.Instance.Card do alias Diffo.Provider.Extension.Characteristic alias Diffo.Provider.Assigner alias Diffo.Provider.Assignment - alias Diffo.Provider.AssignableValue + alias Diffo.Provider.AssignableCharacteristic alias Diffo.Test.Servo alias Diffo.Test.Characteristic.Card, as: CardCharacteristic @@ -37,7 +37,7 @@ defmodule Diffo.Test.Instance.Card do characteristics do characteristic :card, CardCharacteristic - characteristic :ports, AssignableValue + characteristic :ports, AssignableCharacteristic end behaviour do diff --git a/test/support/servo.ex b/test/support/servo.ex index 20acdf6..54bafeb 100644 --- a/test/support/servo.ex +++ b/test/support/servo.ex @@ -19,6 +19,7 @@ defmodule Diffo.Test.Servo do alias Diffo.Test.Characteristic.Shelf, as: ShelfCharacteristic alias Diffo.Test.Characteristic.Card, as: CardCharacteristic alias Diffo.Test.Characteristic.DeploymentClass + alias Diffo.Provider.AssignableCharacteristic domain do description "service and resource management" @@ -54,5 +55,6 @@ defmodule Diffo.Test.Servo do resource ShelfCharacteristic resource CardCharacteristic resource DeploymentClass + resource AssignableCharacteristic end end From 7eb1faf59c948210b64f1fcf232c259ab0042793 Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Sun, 17 May 2026 14:48:22 +0930 Subject: [PATCH 2/6] phase 4 --- .../assigner/assignable_characteristic.ex | 6 ++++ lib/diffo/provider/assigner/assigner.ex | 27 ++++------------- .../calculations/assigned_values.ex | 29 +++++++++++++++++++ 3 files changed, 41 insertions(+), 21 deletions(-) create mode 100644 lib/diffo/provider/components/calculations/assigned_values.ex diff --git a/lib/diffo/provider/assigner/assignable_characteristic.ex b/lib/diffo/provider/assigner/assignable_characteristic.ex index 38cfefe..3e4b568 100644 --- a/lib/diffo/provider/assigner/assignable_characteristic.ex +++ b/lib/diffo/provider/assigner/assignable_characteristic.ex @@ -54,6 +54,12 @@ defmodule Diffo.Provider.AssignableCharacteristic do Diffo.Provider.Calculations.CharacteristicValue do public? true end + + calculate :assigned_values, {:array, :integer}, + Diffo.Provider.Calculations.AssignedValues do + public? true + argument :thing, :atom, allow_nil?: false + end end actions do diff --git a/lib/diffo/provider/assigner/assigner.ex b/lib/diffo/provider/assigner/assigner.ex index 8d93608..33b7c52 100644 --- a/lib/diffo/provider/assigner/assigner.ex +++ b/lib/diffo/provider/assigner/assigner.ex @@ -106,12 +106,12 @@ defmodule Diffo.Provider.Assigner do defp next(instance, pool, thing) when is_struct(instance) and is_atom(pool) and is_atom(thing) do - case pool_characteristic(instance.id, pool) do + case pool_characteristic(instance.id, pool, thing) do {:ok, nil} -> {:error, "pool #{pool} not found on instance #{instance.id}"} {:ok, char} -> - free = free_values(instance.id, pool, thing, char.first, char.last) + free = Enum.to_list(char.first..char.last) -- char.assigned_values case free do [] -> @@ -132,33 +132,18 @@ defmodule Diffo.Provider.Assigner do defp assignable?(instance, pool, thing, value) when is_struct(instance) and is_atom(pool) and is_atom(thing) and is_integer(value) do - case pool_characteristic(instance.id, pool) do + case pool_characteristic(instance.id, pool, thing) do {:ok, nil} -> false - {:ok, char} -> value in free_values(instance.id, pool, thing, char.first, char.last) + {:ok, char} -> value in Enum.to_list(char.first..char.last) -- char.assigned_values {:error, _} -> false end end - defp pool_characteristic(instance_id, pool) do + defp pool_characteristic(instance_id, pool, thing) do AssignableCharacteristic |> Ash.Query.new() |> Ash.Query.filter_input(instance_id: instance_id, name: pool) + |> Ash.Query.load(assigned_values: [thing: thing]) |> Ash.read_one(domain: Diffo.Provider) end - - defp free_values(source_id, pool, thing, first, last) do - assigned = - Relationship - |> Ash.Query.new() - |> Ash.Query.filter_input( - source_id: source_id, - pool: pool, - thing: thing, - type: :assignedTo - ) - |> Ash.read!(domain: Diffo.Provider) - |> Enum.map(& &1.assigned) - - Enum.to_list(first..last) -- assigned - end end diff --git a/lib/diffo/provider/components/calculations/assigned_values.ex b/lib/diffo/provider/components/calculations/assigned_values.ex new file mode 100644 index 0000000..fdd712a --- /dev/null +++ b/lib/diffo/provider/components/calculations/assigned_values.ex @@ -0,0 +1,29 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Calculations.AssignedValues do + @moduledoc false + use Ash.Resource.Calculation + + @impl true + def load(_query, _opts, _context), do: [] + + @impl true + def calculate(records, _opts, context) do + thing = context.arguments[:thing] + + Enum.map(records, fn record -> + Diffo.Provider.Relationship + |> Ash.Query.new() + |> Ash.Query.filter_input( + source_id: record.instance_id, + pool: record.name, + thing: thing, + type: :assignedTo + ) + |> Ash.read!(domain: Diffo.Provider) + |> Enum.map(& &1.assigned) + end) + end +end From 6757a8f9d994b534c2db81343235f540dbc0b8c7 Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Sun, 17 May 2026 16:47:31 +0930 Subject: [PATCH 3/6] stage 5 - pools DSL --- lib/diffo/provider/assigner/assigner.ex | 12 ++++ lib/diffo/provider/extension.ex | 40 ++++++++++- .../provider/extension/characteristic.ex | 9 +-- .../extension/persisters/persist_pools.ex | 26 +++++++ lib/diffo/provider/extension/pool.ex | 69 +++++++++++++++++++ .../transformers/transform_behaviour.ex | 6 ++ .../extension/verifiers/verify_pools.ex | 34 +++++++++ .../extension/instance_transformer_test.exs | 3 +- test/support/resource/instance/card.ex | 10 ++- 9 files changed, 200 insertions(+), 9 deletions(-) create mode 100644 lib/diffo/provider/extension/persisters/persist_pools.ex create mode 100644 lib/diffo/provider/extension/pool.ex create mode 100644 lib/diffo/provider/extension/verifiers/verify_pools.ex diff --git a/lib/diffo/provider/assigner/assigner.ex b/lib/diffo/provider/assigner/assigner.ex index 33b7c52..14930e7 100644 --- a/lib/diffo/provider/assigner/assigner.ex +++ b/lib/diffo/provider/assigner/assigner.ex @@ -12,6 +12,18 @@ defmodule Diffo.Provider.Assigner do alias Diffo.Provider.AssignableCharacteristic alias Diffo.Provider.Relationship + @doc """ + Assign a thing using the pool declared via `pools do` on the instance module. + The thing name is looked up from the pool declaration. + """ + def assign(result, changeset, pool_name) + when is_struct(result) and is_struct(changeset, Ash.Changeset) and is_atom(pool_name) do + case result.__struct__.pool(pool_name) do + nil -> {:error, "pool #{pool_name} not declared on #{result.__struct__}"} + pool -> assign(result, changeset, pool_name, pool.thing) + end + end + @doc """ Assign a thing using the instance changeset assignment. """ diff --git a/lib/diffo/provider/extension.ex b/lib/diffo/provider/extension.ex index bbc7042..299a9c2 100644 --- a/lib/diffo/provider/extension.ex +++ b/lib/diffo/provider/extension.ex @@ -92,7 +92,8 @@ defmodule Diffo.Provider.Extension do PartyDeclaration, PartyRole, PlaceDeclaration, - PlaceRole + PlaceRole, + Pool } # ── specification ────────────────────────────────────────────────────────── @@ -425,6 +426,40 @@ defmodule Diffo.Provider.Extension do entities: [@instance_role_entity, @instance_ref_entity] } + # ── pools ────────────────────────────────────────────────────────────────── + + @pool_entity %Spark.Dsl.Entity{ + name: :pool, + describe: "Declares an assignable pool — a named range of values for auto-assignment", + target: Pool, + args: [:name, :thing], + schema: [ + name: [ + type: :atom, + doc: "The pool name (matches the AssignableCharacteristic name).", + required: true + ], + thing: [ + type: :atom, + doc: "The name of the thing being assigned within the pool (e.g. :port).", + required: true + ] + ] + } + + @pools %Spark.Dsl.Section{ + name: :pools, + describe: "Assignable pools on this Instance — each pool maps to an AssignableCharacteristic", + examples: [ + """ + pools do + pool :ports, :port + end + """ + ], + entities: [@pool_entity] + } + # ── behaviour ────────────────────────────────────────────────────────────── @action_create_entity %Spark.Dsl.Entity{ @@ -485,6 +520,7 @@ defmodule Diffo.Provider.Extension do @specification, @characteristics, @features, + @pools, @parties, @places, @instances, @@ -498,6 +534,7 @@ defmodule Diffo.Provider.Extension do Diffo.Provider.Extension.Persisters.PersistSpecification, Diffo.Provider.Extension.Persisters.PersistCharacteristics, Diffo.Provider.Extension.Persisters.PersistFeatures, + Diffo.Provider.Extension.Persisters.PersistPools, Diffo.Provider.Extension.Persisters.PersistParties, Diffo.Provider.Extension.Persisters.PersistPlaces, Diffo.Provider.Extension.Persisters.PersistInstances, @@ -507,6 +544,7 @@ defmodule Diffo.Provider.Extension do Diffo.Provider.Extension.Verifiers.VerifySpecification, Diffo.Provider.Extension.Verifiers.VerifyCharacteristics, Diffo.Provider.Extension.Verifiers.VerifyFeatures, + Diffo.Provider.Extension.Verifiers.VerifyPools, Diffo.Provider.Extension.Verifiers.VerifyParties, Diffo.Provider.Extension.Verifiers.VerifyPlaces, Diffo.Provider.Extension.Verifiers.VerifyInstances, diff --git a/lib/diffo/provider/extension/characteristic.ex b/lib/diffo/provider/extension/characteristic.ex index 865fe68..f548dfb 100644 --- a/lib/diffo/provider/extension/characteristic.ex +++ b/lib/diffo/provider/extension/characteristic.ex @@ -104,11 +104,12 @@ defmodule Diffo.Provider.Extension.Characteristic do defp apply_updates(result, updates, declarations) do Enum.reduce_while(updates, {:ok, result}, fn {name, update}, {:ok, acc} -> decl = Enum.find(declarations, &(&1.name == name)) + dynamic = Enum.find(acc.characteristics, fn %{name: n} -> n == name end) - if decl && typed?(decl.value_type) do - apply_typed_update(acc, name, decl.value_type, update) - else - apply_dynamic_update(acc, name, update) + cond do + decl && typed?(decl.value_type) -> apply_typed_update(acc, name, decl.value_type, update) + decl || dynamic -> apply_dynamic_update(acc, name, update) + true -> {:cont, {:ok, acc}} end end) end diff --git a/lib/diffo/provider/extension/persisters/persist_pools.ex b/lib/diffo/provider/extension/persisters/persist_pools.ex new file mode 100644 index 0000000..8a365eb --- /dev/null +++ b/lib/diffo/provider/extension/persisters/persist_pools.ex @@ -0,0 +1,26 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.Persisters.PersistPools do + @moduledoc "Persists pool declarations and bakes pools/0" + use Spark.Dsl.Transformer + alias Spark.Dsl.Transformer + + @impl true + def transform(dsl_state) do + declarations = Transformer.get_entities(dsl_state, [:provider, :pools]) + escaped = Macro.escape(declarations) + dsl_state = Transformer.persist(dsl_state, :pools, declarations) + + {:ok, + Transformer.eval( + dsl_state, + [], + quote do + @doc false + def pools, do: unquote(escaped) + end + )} + end +end diff --git a/lib/diffo/provider/extension/pool.ex b/lib/diffo/provider/extension/pool.ex new file mode 100644 index 0000000..53a2ebc --- /dev/null +++ b/lib/diffo/provider/extension/pool.ex @@ -0,0 +1,69 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.Pool do + @moduledoc false + require Logger + + defstruct [:name, :thing, __spark_metadata__: nil] + + @doc "Creates AssignableCharacteristic nodes for each declared pool during the build action" + def create_pools(result, pools) when is_struct(result) and is_list(pools) do + Enum.reduce_while(pools, {:ok, result}, fn %__MODULE__{name: name}, {:ok, acc} -> + case Diffo.Provider.AssignableCharacteristic + |> Ash.Changeset.for_create(:create, %{name: name, instance_id: acc.id}) + |> Ash.create() do + {:ok, _} -> {:cont, {:ok, acc}} + {:error, error} -> {:halt, {:error, error}} + end + end) + end + + @doc "Applies characteristic_value_updates to pool AssignableCharacteristic records" + def update_pools(result, changeset, pools) + when is_struct(result) and is_struct(changeset, Ash.Changeset) and is_list(pools) do + characteristic_value_updates = + Ash.Changeset.get_argument(changeset, :characteristic_value_updates) + + case characteristic_value_updates do + nil -> {:ok, result} + [] -> {:ok, result} + _ -> apply_pool_updates(result, pools, characteristic_value_updates) + end + end + + defp apply_pool_updates(result, pools, updates) do + Enum.reduce_while(pools, {:ok, result}, fn %__MODULE__{name: name}, {:ok, acc} -> + case Keyword.get(updates, name) do + nil -> + {:cont, {:ok, acc}} + + update -> + case Diffo.Provider.AssignableCharacteristic + |> Ash.Query.new() + |> Ash.Query.filter_input(instance_id: acc.id, name: name) + |> Ash.read_one() do + {:ok, nil} -> + Logger.warning("pool #{name} not found on instance #{acc.id}") + {:cont, {:ok, acc}} + + {:ok, char} -> + attrs = if is_list(update), do: Map.new(update), else: update + + case char |> Ash.Changeset.for_update(:update, attrs) |> Ash.update() do + {:ok, _} -> {:cont, {:ok, acc}} + {:error, error} -> {:halt, {:error, error}} + end + + {:error, error} -> + {:halt, {:error, error}} + end + end + end) + end + + defimpl String.Chars do + def to_string(struct), do: inspect(struct) + end +end diff --git a/lib/diffo/provider/extension/transformers/transform_behaviour.ex b/lib/diffo/provider/extension/transformers/transform_behaviour.ex index e456331..7bc70ba 100644 --- a/lib/diffo/provider/extension/transformers/transform_behaviour.ex +++ b/lib/diffo/provider/extension/transformers/transform_behaviour.ex @@ -44,6 +44,8 @@ defmodule Diffo.Provider.Extension.Transformers.TransformBehaviour do result, features() ), + {:ok, result} <- + Diffo.Provider.Extension.Pool.create_pools(result, pools()), do: {:ok, result} end @@ -77,6 +79,9 @@ defmodule Diffo.Provider.Extension.Transformers.TransformBehaviour do end end + @doc false + def pool(name), do: Enum.find(pools(), &(&1.name == name)) + @doc false def party(role), do: Enum.find(parties(), &(&1.role == role)) @@ -127,6 +132,7 @@ defmodule Diffo.Provider.Extension.Transformers.TransformBehaviour do def after?(Diffo.Provider.Extension.Persisters.PersistSpecification), do: true def after?(Diffo.Provider.Extension.Persisters.PersistCharacteristics), do: true def after?(Diffo.Provider.Extension.Persisters.PersistFeatures), do: true + def after?(Diffo.Provider.Extension.Persisters.PersistPools), do: true def after?(Diffo.Provider.Extension.Persisters.PersistParties), do: true def after?(Diffo.Provider.Extension.Persisters.PersistPlaces), do: true def after?(_), do: false diff --git a/lib/diffo/provider/extension/verifiers/verify_pools.ex b/lib/diffo/provider/extension/verifiers/verify_pools.ex new file mode 100644 index 0000000..b6a4a90 --- /dev/null +++ b/lib/diffo/provider/extension/verifiers/verify_pools.ex @@ -0,0 +1,34 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.Verifiers.VerifyPools do + @moduledoc "Verifies pool names are unique" + use Spark.Dsl.Verifier + + alias Spark.Dsl.Verifier + alias Spark.Error.DslError + + @impl true + def verify(dsl_state) do + resource = Verifier.get_persisted(dsl_state, :module) + pools = Verifier.get_entities(dsl_state, [:provider, :pools]) + + errors = + pools + |> Enum.group_by(& &1.name) + |> Enum.filter(fn {_name, ps} -> length(ps) > 1 end) + |> Enum.map(fn {name, _} -> + DslError.exception( + module: resource, + path: [:provider, :pools], + message: "pools: name #{inspect(name)} is declared more than once" + ) + end) + + case errors do + [] -> :ok + errors -> {:error, errors} + end + end +end diff --git a/test/provider/extension/instance_transformer_test.exs b/test/provider/extension/instance_transformer_test.exs index b4d5b8a..c7964dd 100644 --- a/test/provider/extension/instance_transformer_test.exs +++ b/test/provider/extension/instance_transformer_test.exs @@ -55,7 +55,8 @@ defmodule Diffo.Provider.Extension.InstanceTransformerTest do test "characteristics are also accessible via Info" do assert length(Info.characteristics(Shelf)) == 3 - assert length(Info.characteristics(Card)) == 2 + # Card has :card characteristic; :ports moved to pools do + assert length(Info.characteristics(Card)) == 1 end test "Info.characteristic/2 returns the named characteristic" do diff --git a/test/support/resource/instance/card.ex b/test/support/resource/instance/card.ex index ce6269e..11bf7f4 100644 --- a/test/support/resource/instance/card.ex +++ b/test/support/resource/instance/card.ex @@ -11,9 +11,9 @@ defmodule Diffo.Test.Instance.Card do alias Diffo.Provider.BaseInstance alias Diffo.Provider.Instance.Relationship alias Diffo.Provider.Extension.Characteristic + alias Diffo.Provider.Extension.Pool alias Diffo.Provider.Assigner alias Diffo.Provider.Assignment - alias Diffo.Provider.AssignableCharacteristic alias Diffo.Test.Servo alias Diffo.Test.Characteristic.Card, as: CardCharacteristic @@ -37,7 +37,10 @@ defmodule Diffo.Test.Instance.Card do characteristics do characteristic :card, CardCharacteristic - characteristic :ports, AssignableCharacteristic + end + + pools do + pool :ports, :port end behaviour do @@ -67,6 +70,7 @@ defmodule Diffo.Test.Instance.Card do change after_action(fn changeset, result, _context -> with {:ok, result} <- Characteristic.update_all(result, changeset, characteristics()), + {:ok, result} <- Pool.update_pools(result, changeset, pools()), {:ok, result} <- Servo.get_card_by_id(result.id), do: {:ok, result} end) @@ -88,7 +92,7 @@ defmodule Diffo.Test.Instance.Card do argument :assignment, :struct, constraints: [instance_of: Assignment] change after_action(fn changeset, result, _context -> - with {:ok, result} <- Assigner.assign(result, changeset, :ports, :port), + with {:ok, result} <- Assigner.assign(result, changeset, :ports), {:ok, result} <- Servo.get_card_by_id(result.id), do: {:ok, result} end) From 51e43e6029c8baab75b777617b39e9dd16e475ac Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Sun, 17 May 2026 16:55:36 +0930 Subject: [PATCH 4/6] docs and guidance --- AGENTS.md | 14 ++++ .../dsls/DSL-Diffo.Provider.Extension.md | 50 ++++++++++++++ .../use_diffo_provider_extension.livemd | 58 ++++++++--------- lib/diffo/provider/extension.ex | 4 ++ usage-rules.md | 65 ++++++++++++++++++- 5 files changed, 159 insertions(+), 32 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 37da489..63df0a6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,6 +28,7 @@ lib/diffo/provider/ info.ex # Runtime introspection via Spark.InfoGenerator characteristic.ex # Characteristic build helpers feature.ex # Feature build helpers + pool.ex # Pool struct + create_pools/2 + update_pools/3 instance_role.ex # InstanceRole struct party_declaration.ex # PartyDeclaration struct place_declaration.ex # PlaceDeclaration struct @@ -36,6 +37,9 @@ lib/diffo/provider/ persisters/ # Spark transformers — bake DSL state into module transformers/ # TransformBehaviour — action argument injection verifiers/ # Compile-time DSL correctness checks + assigner/ + assigner.ex # Diffo.Provider.Assigner — assign/3 (pools do) and assign/4 + assignable_characteristic.ex # AssignableCharacteristic — pool bounds + algorithm base_instance.ex # Ash Fragment for Instance resources base_party.ex # Ash Fragment for Party resources base_place.ex # Ash Fragment for Place resources @@ -43,6 +47,7 @@ lib/diffo/provider/ base_characteristic.ex # Ash Fragment for typed characteristic resources calculations/ characteristic_value.ex # Calculation: builds .Value TypedStruct from record fields + assigned_values.ex # Calculation: returns list of assigned integers for a pool+thing instance/extension.ex # Thin marker (sections: []) — kind identification party/extension.ex # Thin marker place/extension.ex # Thin marker @@ -89,6 +94,11 @@ provider do characteristic :ports, {:array, MyApp.PortCharacteristic} end + pools do + pool :cores, :core # assignable pool; thing name is :core + pool :vlans, :vlan + end + features do feature :advanced_routing, is_enabled?: false do characteristic :policy, MyApp.RoutingPolicy @@ -148,6 +158,10 @@ mix test --max-failures 5 # stop early - Using old `structure do` / top-level `instances do` — use `provider do` only. - Using `party :role, Type, reference: true` — use `party_ref :role, Type`. - Using a plain `Ash.TypedStruct` as a `characteristic` DSL target — use a `BaseCharacteristic`-derived resource instead; the TypedStruct belongs in `.Value`. +- Using `characteristic :name, Diffo.Provider.AssignableCharacteristic` for pools — use `pools do / pool :name, :thing / end` instead. +- Using the removed `AssignableValue` TypedStruct — it no longer exists; use `pools do`. +- 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 `build_before/1` or `build_after/2` in actions — these run automatically. - Declaring `:specified_by`, `:features`, `:characteristics` as action arguments. - Editing `documentation/dsls/DSL-Diffo.Provider.Extension.md` — it is Spark-generated; diff --git a/documentation/dsls/DSL-Diffo.Provider.Extension.md b/documentation/dsls/DSL-Diffo.Provider.Extension.md index ba5e90f..091aea2 100644 --- a/documentation/dsls/DSL-Diffo.Provider.Extension.md +++ b/documentation/dsls/DSL-Diffo.Provider.Extension.md @@ -30,6 +30,10 @@ the sections relevant to it, and verifiers enforce correct usage. end end + pools do + pool :ports, :port + end + parties do party :provider, MyApp.Provider party_ref :owner, MyApp.InfrastructureCo @@ -92,6 +96,8 @@ Provider DSL — structure, roles, and behaviour for this resource kind * [features](#provider-features) * feature * characteristic + * [pools](#provider-pools) + * pool * [parties](#provider-parties) * party * parties @@ -276,6 +282,50 @@ Adds a Characteristic +### provider.pools +Assignable pools on this Instance — each pool maps to an AssignableCharacteristic + +### Nested DSLs + * [pool](#provider-pools-pool) + + +### Examples +``` +pools do + pool :ports, :port +end + +``` + + + + +### provider.pools.pool +```elixir +pool name, thing +``` + + +Declares an assignable pool — a named range of values for auto-assignment + + + + + +### Arguments + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`name`](#provider-pools-pool-name){: #provider-pools-pool-name .spark-required} | `atom` | | The pool name (matches the AssignableCharacteristic name). | +| [`thing`](#provider-pools-pool-thing){: #provider-pools-pool-thing .spark-required} | `atom` | | The name of the thing being assigned within the pool (e.g. :port). | + + + + + + + + ### provider.parties Party roles on this resource — `party`/`parties`/`party_ref` for Instance kinds; `role` for Party and Place kinds diff --git a/documentation/how_to/use_diffo_provider_extension.livemd b/documentation/how_to/use_diffo_provider_extension.livemd index 954e651..df8ad05 100644 --- a/documentation/how_to/use_diffo_provider_extension.livemd +++ b/documentation/how_to/use_diffo_provider_extension.livemd @@ -141,6 +141,8 @@ The id is a stable UUID4, the same in every environment for this Instance kind. **`features do`** — optional capabilities with their own typed characteristic payload. +**`pools do`** — assignable pools for partial resource allocation. Each `pool :name, :thing` declaration creates an `AssignableCharacteristic` node during `build` and generates `pools/0` / `pool/1` on the module. Pool bounds (`first`, `last`, `algorithm`, `assignable_type`) are set in a `:define` action via `Pool.update_pools/3`. Assignment actions use `Assigner.assign/3` — the thing name is looked up from the pool declaration. + **`parties do`** — party roles: `party` (singular), `parties` (plural), `party_ref` (reference, no direct edge). **`places do`** — place roles: `place` (singular), `places` (plural), `place_ref` (reference). @@ -151,7 +153,7 @@ arguments automatically onto that action. Each characteristic is a dedicated Ash resource using the `Diffo.Provider.BaseCharacteristic` fragment. It carries direct typed attributes and a `:value` calculation that builds a companion `.Value` TypedStruct for ordered JSON encoding. The TypedStruct uses [AshJason.TypedStruct](https://hexdocs.pm/ash_jason/) to control field order in the JSON output. -For partial resource allocation and assignment we've created Diffo.Provider.Assigner. It is used by the host resource, which declares a characteristic with a `Diffo.Provider.AssignableValue` TypedStruct. Allocation is managed within the Provider domain using this characteristic. Assignment to Services or Resources is via 'reverse' type: "assignedTo" relationships enriched by relationship characteristics. +For partial resource allocation and assignment we've created `Diffo.Provider.Assigner`. The host resource declares a `pools do` section listing each assignable pool by name and the kind of thing being assigned. Pool bounds (first/last value, algorithm) are set via a `:define` action. Assignment to Services or Resources is via `type: :assignedTo` Relationships that carry the assigned value directly on the Relationship node. Let's imagine a Compute domain which operates GPU and NPU resources. We want to expose a Cluster composite resource which can be dynamically composed of a number of GPU and NPU cores. @@ -381,7 +383,7 @@ defmodule Diffo.Compute.GpuCharacteristic.Value do end ``` -The GPU resource declares `GpuCharacteristic` for the typed `:gpu` slot and keeps `AssignableValue` for the `:cores` allocation pool (the assigner still uses the dynamic characteristic pattern). The `update :define` action now only needs to handle the dynamic `:cores` update — the typed `:gpu` characteristic is updated directly on the characteristic resource: +The GPU resource declares `GpuCharacteristic` for the typed `:gpu` slot and uses `pools do` to declare the `:cores` assignable pool. The `update :define` action updates both the typed characteristic and the pool bounds. The `update :assign_core` action uses `Assigner.assign/3` — the thing name (`:core`) is looked up from the pool declaration automatically: ```elixir defmodule Diffo.Compute.GPU do @@ -391,10 +393,10 @@ defmodule Diffo.Compute.GPU do alias Diffo.Provider.BaseInstance alias Diffo.Provider.Instance.Relationship - alias Diffo.Provider.Instance.Characteristic + alias Diffo.Provider.Extension.Characteristic + alias Diffo.Provider.Extension.Pool alias Diffo.Provider.Assigner alias Diffo.Provider.Assignment - alias Diffo.Provider.AssignableValue alias Diffo.Compute alias Diffo.Compute.GpuCharacteristic @@ -418,7 +420,10 @@ defmodule Diffo.Compute.GPU do characteristics do characteristic :gpu, GpuCharacteristic - characteristic :cores, AssignableValue + end + + pools do + pool :cores, :core end behaviour do @@ -442,11 +447,13 @@ defmodule Diffo.Compute.GPU do end update :define do - description "allocates the GPU cores (AssignableValue)" + description "sets GPU identity and allocates the cores pool" argument :characteristic_value_updates, {:array, :term} change after_action(fn changeset, result, _context -> - with {:ok, result} <- Characteristic.update_values(result, changeset), + with {:ok, result} <- + Characteristic.update_all(result, changeset, characteristics()), + {:ok, result} <- Pool.update_pools(result, changeset, pools()), {:ok, result} <- Compute.get_gpu_by_id(result.id), do: {:ok, result} end) @@ -464,11 +471,11 @@ defmodule Diffo.Compute.GPU do end update :assign_core do - description "relates the GPU with an instance by assigning a core" + description "assigns a core from this GPU to another instance" argument :assignment, :struct, constraints: [instance_of: Assignment] change after_action(fn changeset, result, _context -> - with {:ok, result} <- Assigner.assign(result, changeset, :cores, :core), + with {:ok, result} <- Assigner.assign(result, changeset, :cores), {:ok, result} <- Compute.get_gpu_by_id(result.id), do: {:ok, result} end) @@ -476,7 +483,6 @@ defmodule Diffo.Compute.GPU do end end ``` -``` ## Party Extension @@ -745,27 +751,19 @@ gpu_1 = Compute.build_gpu!(%{name: "GPU 1"}) gpu_2 = Compute.build_gpu!(%{name: "GPU 2"}) ``` -We set the typed `:gpu` characteristic directly on the characteristic resource, then allocate the `:cores` AssignableValue via `update :define`: +We define each GPU: setting its typed `:gpu` characteristic fields and allocating the `:cores` pool bounds. Both are passed via `characteristic_value_updates` to the `:define` action — `Characteristic.update_all` handles the typed `:gpu` update and `Pool.update_pools` handles the `:cores` pool bounds: ```elixir -# Update the typed GpuCharacteristic on each GPU -[gpu_char_1] = Enum.filter(gpu_1.characteristics, fn c -> c.name == :gpu end) -[gpu_char_2] = Enum.filter(gpu_2.characteristics, fn c -> c.name == :gpu end) - -gpu_attrs = %{family: :nvidia, model: "GeForce RTX5090", technology: :blackwell} -Compute.update_gpu_characteristic!(gpu_char_1, gpu_attrs) -Compute.update_gpu_characteristic!(gpu_char_2, gpu_attrs) -``` - -```elixir -# Allocate the cores pool (AssignableValue — dynamic characteristic) -core_updates = [cores: [first: 1, last: 680, free: 680, assignable_type: "tensor"]] +gpu_attrs = [ + gpu: [family: :nvidia, model: "GeForce RTX5090", technology: :blackwell], + cores: [first: 1, last: 680, assignable_type: "tensor"] +] -gpu_1 = Compute.define_gpu!(gpu_1, %{characteristic_value_updates: core_updates}) -gpu_2 = Compute.define_gpu!(gpu_2, %{characteristic_value_updates: core_updates}) +gpu_1 = Compute.define_gpu!(gpu_1, %{characteristic_value_updates: gpu_attrs}) +gpu_2 = Compute.define_gpu!(gpu_2, %{characteristic_value_updates: gpu_attrs}) ``` -The GPU's `:cores` characteristic is an AssignableValue that tracks how many cores are free (unassigned). We can render one as json: +The `:cores` pool is backed by an `AssignableCharacteristic` node that records the range bounds and algorithm. Free cores are computed at assignment time from the count of existing `:assignedTo` relationships — there is no stored `free` counter. We can render one as json: ```elixir Jason.encode!(gpu_1, pretty: true) |> IO.puts @@ -785,9 +783,9 @@ gpu_1 = Compute.assign_gpu_core!(gpu_1, assignment) gpu_2 = Compute.assign_gpu_core!(gpu_2, assignment) ``` -Now our cluster should have a core from each gpu. Check in the neo4j browser for the type: :assignedTo Relationship from the gpu_1 and gpu_2 to the clusters. There should be four, each with a Relationship Characteristic of core, with a value of the assigned core, e.g. 1, 2. +Now our cluster should have a core from each GPU. Check in the Neo4j browser for `:assignedTo` Relationship nodes from `gpu_1` and `gpu_2` to `cluster_1`. There should be four — each Relationship carries `pool: :cores`, `thing: :core`, and the `assigned` integer value (e.g. 1, 2, 3 from gpu_1 and 1 from gpu_2). -Also the gpu will show each assignedTo relationship, since these are forward relationships. These should also show the relationship characteristic: +The GPU's `forward_relationships` include each `:assignedTo` relationship, showing the assigned core number in the JSON encoding as a `resourceRelationshipCharacteristic`: ```elixir Jason.encode!(gpu_1, pretty: true) |> IO.puts @@ -805,7 +803,9 @@ What happens when I request a specific assignment from an instance to which the In this tutorial you've used Diffo's unified `provider do` extension to define a Compute domain with: -- A composite Cluster resource with GPU core assignment via `Diffo.Provider.Assigner` +- A composite Cluster resource that receives GPU cores via `Diffo.Provider.Assigner` +- A GPU resource using `pools do` to declare the `:cores` assignable pool — `pool :cores, :core` replaces the old `characteristic :cores, AssignableValue` pattern +- Assignment stored directly on `:assignedTo` Relationship nodes (no separate Characteristic nodes for assignments) - `Tenant` and `Engineer` Party kinds declared with `provider do` that express which instances they operate and manage - A `DataCentre` Place kind that declares the instances located at it diff --git a/lib/diffo/provider/extension.ex b/lib/diffo/provider/extension.ex index 299a9c2..36a8e58 100644 --- a/lib/diffo/provider/extension.ex +++ b/lib/diffo/provider/extension.ex @@ -31,6 +31,10 @@ defmodule Diffo.Provider.Extension do end end + pools do + pool :ports, :port + end + parties do party :provider, MyApp.Provider party_ref :owner, MyApp.InfrastructureCo diff --git a/usage-rules.md b/usage-rules.md index beac8e4..e88c922 100644 --- a/usage-rules.md +++ b/usage-rules.md @@ -40,7 +40,7 @@ end All DSL declarations live inside a single `provider do` block. The sections available depend on the resource kind: -- **Instance** — `specification`, `characteristics`, `features`, `parties`, `places`, `behaviour` +- **Instance** — `specification`, `characteristics`, `features`, `pools`, `parties`, `places`, `behaviour` - **Party** — `instances`, `parties`, `places` - **Place** — `instances`, `parties`, `places` @@ -232,6 +232,58 @@ Role names are domain nouns from the party's/place's perspective — timeless, `snake_case` atoms. Use `camelCase` atoms for multi-word names that follow TMF conventions (e.g. `:dataCentre`, not `:data_centre`). +### `pools do` — Instance only + +Declares named assignable pools. Each pool maps to a `Diffo.Provider.AssignableCharacteristic` +node that is created automatically during the `build` action. Use this instead of declaring +`characteristic :name, AssignableCharacteristic` in `characteristics do`. + +```elixir +provider do + pools do + pool :cores, :core # pool name :cores, thing name :core + pool :ports, :port + end +end +``` + +- **`pool name, thing`** — `name` is the pool atom (also the AssignableCharacteristic name); + `thing` is the atom identifying what is being assigned within the pool (stored on assignment + Relationships as the `thing` attribute). +- Pool bounds (`first`, `last`, `algorithm`, `assignable_type`) are set via `Pool.update_pools/3` + in a `:define` action; they are not declared in the DSL. +- Each Instance module gets `pools/0` (list of declarations) and `pool/1` (lookup by name) + generated at compile time. + +In the `:define` action, apply updates for both characteristics and pools: + +```elixir +update :define do + argument :characteristic_value_updates, {:array, :term} + + change after_action(fn changeset, result, _context -> + with {:ok, result} <- Characteristic.update_all(result, changeset, characteristics()), + {:ok, result} <- Pool.update_pools(result, changeset, pools()), + {:ok, result} <- MyDomain.get_by_id(result.id), + do: {:ok, result} + end) +end +``` + +In assignment actions, use `Assigner.assign/3` (thing is looked up from the pool declaration): + +```elixir +update :assign_core do + argument :assignment, :struct, constraints: [instance_of: Assignment] + + change after_action(fn changeset, result, _context -> + with {:ok, result} <- Assigner.assign(result, changeset, :cores), + {:ok, result} <- MyDomain.get_by_id(result.id), + do: {:ok, result} + end) +end +``` + ### `behaviour do` — Instance only Marks a named create action for build wiring. Declaring `create :name` injects the @@ -253,8 +305,8 @@ end Every resource with a complete `specification do` block gets these compile-time generated functions: -- `specification/0`, `characteristics/0`, `features/0`, `parties/0`, `places/0` -- `characteristic/1`, `feature/1`, `feature_characteristic/2`, `party/1`, `place/1` +- `specification/0`, `characteristics/0`, `features/0`, `pools/0`, `parties/0`, `places/0` +- `characteristic/1`, `feature/1`, `feature_characteristic/2`, `pool/1`, `party/1`, `place/1` - `build_before/1` — upserts the Specification node; creates Feature, Characteristic, and Party nodes; sets action argument ids. Called automatically before every create action. - `build_after/2` — relates the created TMF entities to the new instance node. Called @@ -406,3 +458,10 @@ end managed entirely by the `build_before/1` generated function. - **Do not use `party/1` in place of `parties/3`** (and vice versa) — `party` declares a singular role; `parties` declares a plural role. Mismatching causes compile-time errors. +- **Do not use `characteristic :name, Diffo.Provider.AssignableCharacteristic`** for assignable + pools — use `pools do / pool :name, :thing / end` instead. The `pools do` section creates the + `AssignableCharacteristic` node automatically during `build` and generates `pools/0` / `pool/1`. +- **Do not use the old `AssignableValue` TypedStruct** — it is removed. Use `pools do`. +- **Do not call `Assigner.assign/4` when a pool declaration exists** — prefer `Assigner.assign/3` + which looks up the thing name from the pool automatically. `assign/4` is still available for + cases without a `pools do` declaration. From ff05068c66e77bb696beeec7af89c5bafeea4778 Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Sun, 17 May 2026 18:43:59 +0930 Subject: [PATCH 5/6] phase 6 - AssignerRelationship extends BaseRelationship --- AGENTS.md | 5 + .../use_diffo_provider_extension.livemd | 14 +-- lib/diffo/provider.ex | 7 +- .../assigner/assigned_to_relationship.ex | 103 ++++++++++++++++ lib/diffo/provider/assigner/assigner.ex | 15 ++- .../provider/components/base_instance.ex | 9 ++ .../provider/components/base_relationship.ex | 85 +++++++++++++ .../calculations/assigned_values.ex | 5 +- .../provider/components/instance/util.ex | 31 ++--- lib/diffo/provider/components/relationship.ex | 115 ++---------------- test/provider/extension/assigner_test.exs | 19 +-- usage-rules.md | 5 + 12 files changed, 258 insertions(+), 155 deletions(-) create mode 100644 lib/diffo/provider/assigner/assigned_to_relationship.ex create mode 100644 lib/diffo/provider/components/base_relationship.ex diff --git a/AGENTS.md b/AGENTS.md index 63df0a6..02f8af3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,11 +40,13 @@ lib/diffo/provider/ assigner/ assigner.ex # Diffo.Provider.Assigner — assign/3 (pools do) and assign/4 assignable_characteristic.ex # AssignableCharacteristic — pool bounds + algorithm + assigned_to_relationship.ex # AssignedToRelationship — assignedTo edges (pool/thing/assigned) base_instance.ex # Ash Fragment for Instance resources base_party.ex # Ash Fragment for Party resources base_place.ex # Ash Fragment for Place resources components/ base_characteristic.ex # Ash Fragment for typed characteristic resources + base_relationship.ex # Ash Fragment for shared Relationship structure calculations/ characteristic_value.ex # Calculation: builds .Value TypedStruct from record fields assigned_values.ex # Calculation: returns list of assigned integers for a pool+thing @@ -162,6 +164,9 @@ mix test --max-failures 5 # stop early - Using the removed `AssignableValue` TypedStruct — it no longer exists; use `pools do`. - 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. +- Using `characteristic :pool_name, Diffo.Provider.AssignedToRelationship` — `AssignedToRelationship` is not a characteristic; use `pools do / pool :name, :thing / end` instead. +- Querying `Diffo.Provider.Relationship` for assignment records — assignment relationships are on `Diffo.Provider.AssignedToRelationship`; access them via `instance.assignments`. +- Filtering `instance.forward_relationships` for `type == :assignedTo` — those records no longer exist there; use `instance.assignments` directly. - Calling `build_before/1` or `build_after/2` in actions — these run automatically. - Declaring `:specified_by`, `:features`, `:characteristics` as action arguments. - Editing `documentation/dsls/DSL-Diffo.Provider.Extension.md` — it is Spark-generated; diff --git a/documentation/how_to/use_diffo_provider_extension.livemd b/documentation/how_to/use_diffo_provider_extension.livemd index df8ad05..5c9514f 100644 --- a/documentation/how_to/use_diffo_provider_extension.livemd +++ b/documentation/how_to/use_diffo_provider_extension.livemd @@ -153,7 +153,7 @@ arguments automatically onto that action. Each characteristic is a dedicated Ash resource using the `Diffo.Provider.BaseCharacteristic` fragment. It carries direct typed attributes and a `:value` calculation that builds a companion `.Value` TypedStruct for ordered JSON encoding. The TypedStruct uses [AshJason.TypedStruct](https://hexdocs.pm/ash_jason/) to control field order in the JSON output. -For partial resource allocation and assignment we've created `Diffo.Provider.Assigner`. The host resource declares a `pools do` section listing each assignable pool by name and the kind of thing being assigned. Pool bounds (first/last value, algorithm) are set via a `:define` action. Assignment to Services or Resources is via `type: :assignedTo` Relationships that carry the assigned value directly on the Relationship node. +For partial resource allocation and assignment we've created `Diffo.Provider.Assigner`. The host resource declares a `pools do` section listing each assignable pool by name and the kind of thing being assigned. Pool bounds (first/last value, algorithm) are set via a `:define` action. Each assignment is stored as a `Diffo.Provider.AssignedToRelationship` node (Neo4j label `:AssignmentRelationship`) carrying `pool`, `thing`, and the `assigned` value. These are distinct from regular TMF `Diffo.Provider.Relationship` nodes and are accessible on an instance via `instance.assignments`. Let's imagine a Compute domain which operates GPU and NPU resources. We want to expose a Cluster composite resource which can be dynamically composed of a number of GPU and NPU cores. @@ -763,7 +763,7 @@ gpu_1 = Compute.define_gpu!(gpu_1, %{characteristic_value_updates: gpu_attrs}) gpu_2 = Compute.define_gpu!(gpu_2, %{characteristic_value_updates: gpu_attrs}) ``` -The `:cores` pool is backed by an `AssignableCharacteristic` node that records the range bounds and algorithm. Free cores are computed at assignment time from the count of existing `:assignedTo` relationships — there is no stored `free` counter. We can render one as json: +The `:cores` pool is backed by an `AssignableCharacteristic` node that records the range bounds and algorithm. Free cores are computed at assignment time from the count of existing `AssignmentRelationship` records — there is no stored `free` counter. We can render one as json: ```elixir Jason.encode!(gpu_1, pretty: true) |> IO.puts @@ -783,16 +783,16 @@ gpu_1 = Compute.assign_gpu_core!(gpu_1, assignment) gpu_2 = Compute.assign_gpu_core!(gpu_2, assignment) ``` -Now our cluster should have a core from each GPU. Check in the Neo4j browser for `:assignedTo` Relationship nodes from `gpu_1` and `gpu_2` to `cluster_1`. There should be four — each Relationship carries `pool: :cores`, `thing: :core`, and the `assigned` integer value (e.g. 1, 2, 3 from gpu_1 and 1 from gpu_2). +Now our cluster should have a core from each GPU. Check in the Neo4j browser for `:AssignmentRelationship` nodes from `gpu_1` and `gpu_2` to `cluster_1`. There should be four — each carries `pool: :cores`, `thing: :core`, and the `assigned` integer value (e.g. 1, 2, 3 from gpu_1 and 1 from gpu_2). -The GPU's `forward_relationships` include each `:assignedTo` relationship, showing the assigned core number in the JSON encoding as a `resourceRelationshipCharacteristic`: +The GPU's `assignments` hold each assignment, showing the assigned core number in the JSON encoding as a `resourceRelationshipCharacteristic`: ```elixir Jason.encode!(gpu_1, pretty: true) |> IO.puts ``` -Make sure you have a look at it in the neo4j browser. There should be Relationship nodes with a role of :assignedTo from each GPU resource instance to the cluster_1 resource instance. Each Relationship should be defined by a Characteristic with the assigned core number. -There is no central assignment table, rather the relationships ARE the assignments. +Make sure you have a look at it in the neo4j browser. There should be `:AssignmentRelationship` nodes from each GPU resource instance to the `cluster_1` resource instance, each carrying the assigned core number. +There is no central assignment table — the `AssignedToRelationship` nodes ARE the assignments. They are separate from the regular `:Relationship` nodes used for TMF service/resource relationships, and are accessible in Elixir via `instance.assignments`. As an exercise, clone the GPU resource to create an NPU resource and assign some NPU cores from it to your cluster. Check that the assigned NPU cores are unique. @@ -805,7 +805,7 @@ In this tutorial you've used Diffo's unified `provider do` extension to define a - A composite Cluster resource that receives GPU cores via `Diffo.Provider.Assigner` - A GPU resource using `pools do` to declare the `:cores` assignable pool — `pool :cores, :core` replaces the old `characteristic :cores, AssignableValue` pattern -- Assignment stored directly on `:assignedTo` Relationship nodes (no separate Characteristic nodes for assignments) +- Assignments stored on `Diffo.Provider.AssignedToRelationship` nodes (Neo4j label `:AssignmentRelationship`, distinct from TMF `:Relationship` nodes); accessible via `instance.assignments` - `Tenant` and `Engineer` Party kinds declared with `provider do` that express which instances they operate and manage - A `DataCentre` Place kind that declares the instances located at it diff --git a/lib/diffo/provider.ex b/lib/diffo/provider.ex index abe503d..65d16a6 100644 --- a/lib/diffo/provider.ex +++ b/lib/diffo/provider.ex @@ -59,7 +59,6 @@ defmodule Diffo.Provider do resource Diffo.Provider.Relationship do define :create_relationship, action: :create - define :create_assignment_relationship, action: :create_assignment define :get_relationship_by_id, action: :read, get_by: :id define :list_relationships, action: :list @@ -78,6 +77,12 @@ defmodule Diffo.Provider do define :delete_relationship, action: :destroy end + resource Diffo.Provider.AssignedToRelationship do + define :create_assigned_to_relationship, action: :create_assignment + define :get_assigned_to_relationship_by_id, action: :read, get_by: :id + define :delete_assigned_to_relationship, action: :destroy + end + resource Diffo.Provider.AssignableCharacteristic do define :create_assignable_characteristic, action: :create define :get_assignable_characteristic_by_id, action: :read, get_by: :id diff --git a/lib/diffo/provider/assigner/assigned_to_relationship.ex b/lib/diffo/provider/assigner/assigned_to_relationship.ex new file mode 100644 index 0000000..1926d88 --- /dev/null +++ b/lib/diffo/provider/assigner/assigned_to_relationship.ex @@ -0,0 +1,103 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.AssignedToRelationship do + @moduledoc """ + Ash Resource for a pool assignment relationship. + + Carries the assignment attributes (`pool`, `thing`, `assigned`) that link a + source instance to an assignee instance. Stored as an `:AssignedToRelationship` + Neo4j node, distinct from the `:Relationship` nodes used for TMF service/resource + relationships. Accessible on an instance via `instance.assignments`. + + Created by `Diffo.Provider.Assigner` via `Diffo.Provider.create_assigned_to_relationship/1`. + """ + use Ash.Resource, + fragments: [Diffo.Provider.BaseRelationship], + otp_app: :diffo, + domain: Diffo.Provider + + resource do + description "An Ash Resource for a pool assignment relationship" + plural_name :assigned_to_relationships + end + + neo4j do + relate [ + {:source, :RELATES, :incoming, :Instance}, + {:target, :RELATES, :outgoing, :Instance} + ] + end + + jason do + pick [:type] + + customize fn result, record -> + target_type = Map.get(record, :target_type) + + reference = %Diffo.Provider.Reference{ + id: record.target_id, + href: Map.get(record, :target_href) + } + + list_name = + Diffo.Provider.Relationship.derive_relationship_characteristic_list_name(target_type) + + result + |> Diffo.Util.set(target_type, reference) + |> Diffo.Util.set(list_name, [%{name: record.thing, value: record.assigned}]) + end + + order [ + :type, + :service, + :resource, + :serviceRelationshipCharacteristic, + :resourceRelationshipCharacteristic + ] + end + + actions do + create :create_assignment do + description "creates an assignedTo relationship with pool/thing/assigned attributes" + accept [:pool, :thing, :assigned] + + argument :source_id, :uuid + argument :target_id, :string + + change set_attribute(:type, :assignedTo) + change manage_relationship(:source_id, :source, type: :append) + change manage_relationship(:target_id, :target, type: :append) + change Diffo.Changes.DetailRelationship + end + end + + attributes do + attribute :pool, :atom do + description "the pool name on the source instance" + allow_nil? true + public? true + end + + attribute :thing, :atom do + description "the kind of thing being assigned within the pool" + allow_nil? true + public? true + end + + attribute :assigned, :integer do + description "the assigned value from the pool" + allow_nil? true + public? true + end + end + + identities do + identity :unique_assignment, [:source_id, :target_id, :pool, :thing, :assigned] + end + + preparations do + prepare build(sort: [created_at: :asc]) + end +end diff --git a/lib/diffo/provider/assigner/assigner.ex b/lib/diffo/provider/assigner/assigner.ex index 14930e7..daf5d39 100644 --- a/lib/diffo/provider/assigner/assigner.ex +++ b/lib/diffo/provider/assigner/assigner.ex @@ -4,13 +4,13 @@ defmodule Diffo.Provider.Assigner do @moduledoc """ - Helper to perform Assignment using Relationship attributes. + Helper to perform Assignment using `Diffo.Provider.AssignedToRelationship`. - Assignment state is stored directly on `Diffo.Provider.Relationship` nodes - (pool, thing, assigned) rather than creating a separate Characteristic node. + Assignment state is stored on `AssignedToRelationship` nodes (pool, thing, assigned), + distinct from regular TMF `Diffo.Provider.Relationship` nodes. """ alias Diffo.Provider.AssignableCharacteristic - alias Diffo.Provider.Relationship + alias Diffo.Provider.AssignedToRelationship @doc """ Assign a thing using the pool declared via `pools do` on the instance module. @@ -66,7 +66,7 @@ defmodule Diffo.Provider.Assigner do defp relate_is_assigned(result, pool, thing, value, assignee_id) when is_struct(result) and is_atom(pool) and is_atom(thing) and is_integer(value) and is_bitstring(assignee_id) do - case Diffo.Provider.create_assignment_relationship(%{ + case Diffo.Provider.create_assigned_to_relationship(%{ pool: pool, thing: thing, assigned: value, @@ -103,15 +103,14 @@ defmodule Diffo.Provider.Assigner do end defp find_assignment(source_id, target_id, pool, thing, value) do - Relationship + AssignedToRelationship |> Ash.Query.new() |> Ash.Query.filter_input( source_id: source_id, target_id: target_id, pool: pool, thing: thing, - assigned: value, - type: :assignedTo + assigned: value ) |> Ash.read_one(domain: Diffo.Provider) end diff --git a/lib/diffo/provider/components/base_instance.ex b/lib/diffo/provider/components/base_instance.ex index 94fa443..13e6b90 100644 --- a/lib/diffo/provider/components/base_instance.ex +++ b/lib/diffo/provider/components/base_instance.ex @@ -188,6 +188,7 @@ defmodule Diffo.Provider.BaseInstance do {:process_statuses, :STATUSES, :incoming, :ProcessStatus}, {:forward_relationships, :RELATES, :outgoing, :Relationship}, {:reverse_relationships, :RELATES, :incoming, :Relationship}, + {:assignments, :RELATES, :outgoing, :AssignedToRelationship}, {:features, :HAS, :outgoing, :Feature}, {:characteristics, :HAS, :outgoing, :Characteristic}, {:entities, :RELATES, :outgoing, :EntityRef}, @@ -209,6 +210,7 @@ defmodule Diffo.Provider.BaseInstance do :specification, :process_statuses, :forward_relationships, + :assignments, :features, :characteristics, :entities, @@ -407,6 +409,12 @@ defmodule Diffo.Provider.BaseInstance do public? true end + has_many :assignments, Diffo.Provider.AssignedToRelationship do + description "the instance's outgoing pool assignment relationships" + destination_attribute :source_id + public? true + end + has_many :features, Diffo.Provider.Feature do description "the instance's collection of defining features" public? true @@ -655,6 +663,7 @@ defmodule Diffo.Provider.BaseInstance do :specification, :process_statuses, :forward_relationships, + :assignments, :entities, :notes, :features, diff --git a/lib/diffo/provider/components/base_relationship.ex b/lib/diffo/provider/components/base_relationship.ex new file mode 100644 index 0000000..9de441b --- /dev/null +++ b/lib/diffo/provider/components/base_relationship.ex @@ -0,0 +1,85 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.BaseRelationship do + @moduledoc """ + Ash Resource Fragment which is the shared foundation for TMF Relationship resources. + + Provides the common attributes, relationships, validations, and actions shared + between `Diffo.Provider.Relationship` (TMF service/resource relationships) and + `Diffo.Provider.AssignedToRelationship` (pool assignment relationships). + + ## Common attributes + + - `id` — uuid4 primary key + - `type` — relationship type atom + - `target_href` — denormalised target href (set by the `DetailRelationship` change) + - `target_type` — denormalised target type (`:service` or `:resource`) + - `created_at`, `updated_at` — timestamps + + ## Common Ash relationships + + - `belongs_to :source, Diffo.Provider.Instance` + - `belongs_to :target, Diffo.Provider.Instance` + """ + use Spark.Dsl.Fragment, + of: Ash.Resource, + otp_app: :diffo, + domain: Diffo.Provider, + data_layer: AshNeo4j.DataLayer, + extensions: [AshJason.Resource] + + attributes do + uuid_primary_key :id do + description "a uuid4, unique to this relationship, generated by default" + public? true + end + + attribute :type, :atom do + description "the type of the relationship from the source to the target" + allow_nil? false + public? true + end + + attribute :target_href, :string do + description "the target href, denormalised from the target instance" + allow_nil? true + writable? false + public? true + end + + attribute :target_type, :atom do + description "the target type, denormalised from the target instance" + allow_nil? true + writable? false + public? true + end + + create_timestamp :created_at + update_timestamp :updated_at + end + + relationships do + belongs_to :source, Diffo.Provider.Instance do + description "the source instance which originates this relationship" + allow_nil? false + public? true + end + + belongs_to :target, Diffo.Provider.Instance do + description "the target instance which is the destination of this relationship" + allow_nil? false + public? true + end + end + + validations do + validate {Diffo.Validations.IsUuid4OrNil, attribute: :source_id}, on: :create + validate {Diffo.Validations.IsUuid4OrNil, attribute: :target_id}, on: :create + end + + actions do + defaults [:read, :destroy] + end +end diff --git a/lib/diffo/provider/components/calculations/assigned_values.ex b/lib/diffo/provider/components/calculations/assigned_values.ex index fdd712a..6ac879f 100644 --- a/lib/diffo/provider/components/calculations/assigned_values.ex +++ b/lib/diffo/provider/components/calculations/assigned_values.ex @@ -14,13 +14,12 @@ defmodule Diffo.Provider.Calculations.AssignedValues do thing = context.arguments[:thing] Enum.map(records, fn record -> - Diffo.Provider.Relationship + Diffo.Provider.AssignedToRelationship |> Ash.Query.new() |> Ash.Query.filter_input( source_id: record.instance_id, pool: record.name, - thing: thing, - type: :assignedTo + thing: thing ) |> Ash.read!(domain: Diffo.Provider) |> Enum.map(& &1.assigned) diff --git a/lib/diffo/provider/components/instance/util.ex b/lib/diffo/provider/components/instance/util.ex index 3092cf2..9460c6a 100644 --- a/lib/diffo/provider/components/instance/util.ex +++ b/lib/diffo/provider/components/instance/util.ex @@ -74,40 +74,40 @@ defmodule Diffo.Provider.Instance.Util do @doc false def relationships(result) do - if relationships = Diffo.Util.get(result, :forward_relationships) do + fwd = Diffo.Util.get(result, :forward_relationships) + asgn = Diffo.Util.get(result, :assignments) + + if fwd != nil or asgn != nil do + all_relationships = List.wrap(fwd) ++ List.wrap(asgn) + service_relationships = - relationships - |> Enum.filter(fn relationship -> - relationship.target != nil && relationship.target_type == :service + Enum.filter(all_relationships, fn rel -> + rel.target != nil && rel.target_type == :service end) resource_relationships = - relationships - |> Enum.filter(fn relationship -> - relationship.target != nil && relationship.target_type == :resource + Enum.filter(all_relationships, fn rel -> + rel.target != nil && rel.target_type == :resource end) supporting_services = service_relationships - |> Enum.filter(fn relationship -> - relationship.alias != nil - end) - |> Enum.into([], fn aliased -> + |> Enum.filter(fn rel -> Map.get(rel, :alias) != nil end) + |> Enum.map(fn aliased -> %Diffo.Provider.Reference{id: aliased.alias, href: Map.get(aliased, :target_href)} end) supporting_resources = resource_relationships - |> Enum.filter(fn relationship -> - relationship.alias != nil - end) - |> Enum.into([], fn aliased -> + |> Enum.filter(fn rel -> Map.get(rel, :alias) != nil end) + |> Enum.map(fn aliased -> %Diffo.Provider.Reference{id: aliased.alias, href: Map.get(aliased, :target_href)} end) result |> Diffo.Util.remove(:forward_relationships) |> Diffo.Util.remove(:reverse_relationships) + |> Diffo.Util.remove(:assignments) |> Diffo.Util.set(:serviceRelationship, service_relationships) |> Diffo.Util.set(:resourceRelationship, resource_relationships) |> Diffo.Util.set(:supportingService, supporting_services) @@ -116,6 +116,7 @@ defmodule Diffo.Provider.Instance.Util do result |> Diffo.Util.remove(:forward_relationships) |> Diffo.Util.remove(:reverse_relationships) + |> Diffo.Util.remove(:assignments) end end diff --git a/lib/diffo/provider/components/relationship.ex b/lib/diffo/provider/components/relationship.ex index fdcc560..474c8d5 100644 --- a/lib/diffo/provider/components/relationship.ex +++ b/lib/diffo/provider/components/relationship.ex @@ -8,10 +8,10 @@ defmodule Diffo.Provider.Relationship do Ash Resource for a TMF Service or Resource Relationship """ use Ash.Resource, + fragments: [Diffo.Provider.BaseRelationship], + extensions: [AshOutstanding.Resource], otp_app: :diffo, - domain: Diffo.Provider, - data_layer: AshNeo4j.DataLayer, - extensions: [AshOutstanding.Resource, AshJason.Resource] + domain: Diffo.Provider resource do description "An Ash Resource for a TMF Service or Resource Relationship" @@ -44,18 +44,10 @@ defmodule Diffo.Provider.Relationship do list_name = Diffo.Provider.Relationship.derive_relationship_characteristic_list_name(target_type) - if record.type == :assignedTo and not is_nil(record.assigned) do - result - |> Diffo.Util.set(target_type, reference) - |> Diffo.Util.remove(:alias) - |> Diffo.Util.remove(:characteristics) - |> Diffo.Util.set(list_name, [%{name: record.thing, value: record.assigned}]) - else - result - |> Diffo.Util.set(target_type, reference) - |> Diffo.Util.suppress_rename(:characteristics, list_name) - |> Diffo.Util.suppress(:alias) - end + result + |> Diffo.Util.set(target_type, reference) + |> Diffo.Util.suppress_rename(:characteristics, list_name) + |> Diffo.Util.suppress(:alias) end order [ @@ -73,8 +65,6 @@ defmodule Diffo.Provider.Relationship do end actions do - defaults [:read, :destroy] - create :create do description "creates a relationship between a source and target instance" accept [:source_id, :target_id, :type, :alias] @@ -90,19 +80,6 @@ defmodule Diffo.Provider.Relationship do change load [:characteristics] end - create :create_assignment do - description "creates an assignment relationship with pool/thing/assigned attributes" - accept [:pool, :thing, :assigned] - - argument :source_id, :uuid - argument :target_id, :string - - change set_attribute(:type, :assignedTo) - change manage_relationship(:source_id, :source, type: :append) - change manage_relationship(:target_id, :target, type: :append) - change Diffo.Changes.DetailRelationship - end - read :list do description "lists all relationships" end @@ -138,73 +115,14 @@ defmodule Diffo.Provider.Relationship do end attributes do - uuid_primary_key :id do - description "a uuid4, unique to this instance, generated by default" - public? true - end - attribute :alias, :atom do description "the alias of this relationship, used for supporting service or resource" allow_nil? true public? true end - - attribute :type, :atom do - description "the type of the relationship from the source to the target" - allow_nil? false - public? true - end - - attribute :target_href, :string do - description "the target href" - allow_nil? true - writable? false - public? true - end - - attribute :target_type, :atom do - description "the target type" - allow_nil? true - writable? false - public? true - end - - attribute :pool, :atom do - description "the pool name on the source instance (assignedTo relationships only)" - allow_nil? true - public? true - end - - attribute :thing, :atom do - description "the kind of thing being assigned within the pool (assignedTo relationships only)" - allow_nil? true - public? true - end - - attribute :assigned, :integer do - description "the assigned value from the pool (assignedTo relationships only)" - allow_nil? true - public? true - end - - create_timestamp :created_at - - update_timestamp :updated_at end relationships do - belongs_to :source, Diffo.Provider.Instance do - description "the source instance which relates to the target instance via this relationship" - allow_nil? false - public? true - end - - belongs_to :target, Diffo.Provider.Instance do - description "the target instance which is related from the source instance via this relationship" - allow_nil? false - public? true - end - has_many :characteristics, Diffo.Provider.Characteristic do description "the relationship's collection of defining characteristics" public? true @@ -213,25 +131,6 @@ defmodule Diffo.Provider.Relationship do identities do identity :unique_source_and_target, [:source_id, :target_id] - - identity :unique_assignment, [:source_id, :target_id, :pool, :thing, :assigned] do - where expr(type == :assignedTo) - end - end - - validations do - validate {Diffo.Validations.IsUuid4OrNil, attribute: :source_id}, on: :create - validate {Diffo.Validations.IsUuid4OrNil, attribute: :target_id}, on: :create - - # validate present(:alias) do - # on [:create, :update] - # where [one_of(:source_type, [:resource]), one_of(:target_type, [:service])] - # message "a resource cannot have a supporting service" - # end - - # validate {Diffo.Validations.RelatedResourcesDifferent, - # relationship: :characteristic, attribute: :name}, - # on: :update end preparations do diff --git a/test/provider/extension/assigner_test.exs b/test/provider/extension/assigner_test.exs index a3d2574..17d0f10 100644 --- a/test/provider/extension/assigner_test.exs +++ b/test/provider/extension/assigner_test.exs @@ -103,8 +103,7 @@ defmodule Diffo.Provider.Extension.AssignerTest do assignment: %Assignment{assignee_id: assignee.id, operation: :auto_assign} }) - assigned_rels = Enum.filter(card.forward_relationships, fn rel -> rel.type == :assignedTo end) - assert length(assigned_rels) == 1 + assert length(card.assignments) == 1 encoding = Jason.encode!(card) |> Diffo.Util.summarise_dates() @@ -134,8 +133,7 @@ defmodule Diffo.Provider.Extension.AssignerTest do assignment: %Assignment{assignee_id: assignee.id, operation: :auto_assign} }) - assigned_rels = Enum.filter(card.forward_relationships, fn rel -> rel.type == :assignedTo end) - assert length(assigned_rels) == 2 + assert length(card.assignments) == 2 encoding = Jason.encode!(card) |> Diffo.Util.summarise_dates() @@ -165,8 +163,7 @@ defmodule Diffo.Provider.Extension.AssignerTest do assignment: %Assignment{id: 5, assignee_id: assignee.id, operation: :assign} }) - assigned_rels = Enum.filter(card.forward_relationships, fn rel -> rel.type == :assignedTo end) - assert length(assigned_rels) == 1 + assert length(card.assignments) == 1 encoding = Jason.encode!(card) |> Diffo.Util.summarise_dates() @@ -191,12 +188,9 @@ defmodule Diffo.Provider.Extension.AssignerTest do assignment: %Assignment{assignee_id: assignee.id, operation: :auto_assign} }) - assigned_rels = Enum.filter(card.forward_relationships, fn rel -> rel.type == :assignedTo end) - assert length(assigned_rels) == 1 + assert length(card.assignments) == 1 - assigned_port = - Enum.find(card.forward_relationships, fn rel -> rel.type == :assignedTo end) - |> Map.get(:assigned) + assigned_port = hd(card.assignments).assigned {:ok, card} = Servo.assign_port(card, %{ @@ -207,8 +201,7 @@ defmodule Diffo.Provider.Extension.AssignerTest do } }) - assigned_rels = Enum.filter(card.forward_relationships, fn rel -> rel.type == :assignedTo end) - assert length(assigned_rels) == 0 + assert length(card.assignments) == 0 encoding = Jason.encode!(card) |> Diffo.Util.summarise_dates() diff --git a/usage-rules.md b/usage-rules.md index e88c922..5b71fc3 100644 --- a/usage-rules.md +++ b/usage-rules.md @@ -465,3 +465,8 @@ end - **Do not call `Assigner.assign/4` when a pool declaration exists** — prefer `Assigner.assign/3` which looks up the thing name from the pool automatically. `assign/4` is still available for cases without a `pools do` declaration. +- **Do not query `Diffo.Provider.Relationship` for `type: :assignedTo` records** — assignment + relationships live on `Diffo.Provider.AssignedToRelationship`. Access them via `instance.assignments`. +- **Do not filter `instance.forward_relationships` for `type == :assignedTo`** — those records no + longer exist there. `forward_relationships` contains only regular TMF relationships; + `assignments` contains pool assignment relationships. From 1d9b43bbff851187d9ec24f47f70e909513c0710 Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Sun, 17 May 2026 19:20:33 +0930 Subject: [PATCH 6/6] phase 7 - resource naming --- AGENTS.md | 13 ++ test/provider/extension/assigner_test.exs | 6 +- test/provider/extension/info_test.exs | 6 +- .../extension/instance_transformer_test.exs | 114 +++++++++--------- .../extension/instance_verifier_test.exs | 26 ++-- test/provider/extension/party_test.exs | 18 +-- test/provider/extension/place_test.exs | 12 +- .../provider/extension/specification_test.exs | 18 +-- .../{card.ex => card_characteristic.ex} | 2 +- .../{card => card_characteristic}/value.ex | 2 +- .../{shelf.ex => shelf_characteristic.ex} | 2 +- .../{shelf => shelf_characteristic}/value.ex | 2 +- .../instance/{card.ex => card_instance.ex} | 4 +- .../instance/{shelf.ex => shelf_instance.ex} | 5 +- test/support/servo.ex | 12 +- test/type/dynamic_test.exs | 2 +- usage-rules.md | 18 +++ 17 files changed, 146 insertions(+), 116 deletions(-) rename test/support/resource/characteristic/{card.ex => card_characteristic.ex} (95%) rename test/support/resource/characteristic/{card => card_characteristic}/value.ex (89%) rename test/support/resource/characteristic/{shelf.ex => shelf_characteristic.ex} (95%) rename test/support/resource/characteristic/{shelf => shelf_characteristic}/value.ex (89%) rename test/support/resource/instance/{card.ex => card_instance.ex} (96%) rename test/support/resource/instance/{shelf.ex => shelf_instance.ex} (97%) diff --git a/AGENTS.md b/AGENTS.md index 02f8af3..25ffd94 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -155,6 +155,19 @@ mix test path/to/test.exs:LINE # single test mix test --max-failures 5 # stop early ``` +## Module naming and Neo4j labels + +AshNeo4j derives a node label from the **last segment** of the module name. Two resources +whose names end in the same word get the same label, which causes read collisions. + +**Rule:** suffix every resource module with its kind so the last segment is unique: +- Instance resources: `MyApp.Instance.WidgetInstance` (not `MyApp.Instance.Widget`) +- Characteristic resources: `MyApp.Characteristic.WidgetCharacteristic` (not `MyApp.Characteristic.Widget`) +- Party/Place resources: follow the same convention if ambiguity is possible. + +E.g. `Diffo.Test.Instance.CardInstance` → label `:CardInstance`, +and `Diffo.Test.Characteristic.CardCharacteristic` → label `:CardCharacteristic` — no collision. + ## Common agent mistakes - Using old `structure do` / top-level `instances do` — use `provider do` only. diff --git a/test/provider/extension/assigner_test.exs b/test/provider/extension/assigner_test.exs index 17d0f10..a24c229 100644 --- a/test/provider/extension/assigner_test.exs +++ b/test/provider/extension/assigner_test.exs @@ -11,7 +11,7 @@ defmodule Diffo.Provider.Extension.AssignerTest do alias Diffo.Test.Parties alias Diffo.Test.Servo - alias Diffo.Test.Instance.Card + alias Diffo.Test.Instance.CardInstance setup do AshNeo4j.Sandbox.checkout() @@ -23,7 +23,7 @@ defmodule Diffo.Provider.Extension.AssignerTest do test "create a card" do {:ok, card} = Servo.build_card(%{}) - assert is_struct(card, Card) + assert is_struct(card, CardInstance) refute is_nil(card.specification_id) assert is_struct(card.specification, Specification) @@ -71,7 +71,7 @@ defmodule Diffo.Provider.Extension.AssignerTest do {:ok, card} = Servo.define_card(card, %{characteristic_value_updates: updates}) {:ok, card_value} = - Diffo.Test.Characteristic.Card + Diffo.Test.Characteristic.CardCharacteristic |> Ash.Query.new() |> Ash.Query.filter_input(instance_id: card.id) |> Ash.read_one() diff --git a/test/provider/extension/info_test.exs b/test/provider/extension/info_test.exs index a26441f..3a508f4 100644 --- a/test/provider/extension/info_test.exs +++ b/test/provider/extension/info_test.exs @@ -10,7 +10,7 @@ defmodule Diffo.Provider.Extension.InfoTest do describe "instance?/1" do test "returns true for a BaseInstance-derived resource" do - assert Info.instance?(Diffo.Test.Instance.Shelf) == true + assert Info.instance?(Diffo.Test.Instance.ShelfInstance) == true end test "returns true for the base Instance resource" do @@ -40,7 +40,7 @@ defmodule Diffo.Provider.Extension.InfoTest do end test "returns false for a BaseInstance-derived resource" do - assert Info.party?(Diffo.Test.Instance.Shelf) == false + assert Info.party?(Diffo.Test.Instance.ShelfInstance) == false end test "returns false for a BasePlace-derived resource" do @@ -62,7 +62,7 @@ defmodule Diffo.Provider.Extension.InfoTest do end test "returns false for a BaseInstance-derived resource" do - assert Info.place?(Diffo.Test.Instance.Shelf) == false + assert Info.place?(Diffo.Test.Instance.ShelfInstance) == false end test "returns false for a BaseParty-derived resource" do diff --git a/test/provider/extension/instance_transformer_test.exs b/test/provider/extension/instance_transformer_test.exs index c7964dd..e08b364 100644 --- a/test/provider/extension/instance_transformer_test.exs +++ b/test/provider/extension/instance_transformer_test.exs @@ -6,8 +6,8 @@ defmodule Diffo.Provider.Extension.InstanceTransformerTest do @moduledoc false use ExUnit.Case, async: true, async: true - alias Diffo.Test.Instance.Shelf - alias Diffo.Test.Instance.Card + alias Diffo.Test.Instance.ShelfInstance + alias Diffo.Test.Instance.CardInstance alias Diffo.Provider.Extension.Characteristic alias Diffo.Provider.Extension.Feature alias Diffo.Provider.Extension.PlaceDeclaration @@ -15,7 +15,7 @@ defmodule Diffo.Provider.Extension.InstanceTransformerTest do describe "PersistSpecification" do test "bakes specification/0 onto the resource" do - spec = Shelf.specification() + spec = ShelfInstance.specification() assert spec[:id] == "ef016d85-9dbd-429c-84da-1df56cc7dda5" assert spec[:name] == "shelf" assert spec[:type] == :resourceSpecification @@ -25,21 +25,21 @@ defmodule Diffo.Provider.Extension.InstanceTransformerTest do end test "card specification is baked correctly" do - spec = Card.specification() + spec = CardInstance.specification() assert spec[:id] == "cd29956f-6c68-44cc-bf54-705eb8d2f754" assert spec[:name] == "card" assert spec[:type] == :resourceSpecification end test "specification is also accessible via Info" do - assert Info.specification(Shelf)[:name] == "shelf" - assert Info.specification(Card)[:name] == "card" + assert Info.specification(ShelfInstance)[:name] == "shelf" + assert Info.specification(CardInstance)[:name] == "card" end end describe "PersistCharacteristics" do test "bakes characteristics/0 onto the resource" do - chars = Shelf.characteristics() + chars = ShelfInstance.characteristics() assert is_list(chars) assert length(chars) == 3 names = Enum.map(chars, & &1.name) @@ -49,29 +49,29 @@ defmodule Diffo.Provider.Extension.InstanceTransformerTest do end test "each characteristic is a Characteristic struct" do - [first | _] = Shelf.characteristics() + [first | _] = ShelfInstance.characteristics() assert is_struct(first, Characteristic) end test "characteristics are also accessible via Info" do - assert length(Info.characteristics(Shelf)) == 3 + assert length(Info.characteristics(ShelfInstance)) == 3 # Card has :card characteristic; :ports moved to pools do - assert length(Info.characteristics(Card)) == 1 + assert length(Info.characteristics(CardInstance)) == 1 end test "Info.characteristic/2 returns the named characteristic" do - char = Info.characteristic(Shelf, :shelves) + char = Info.characteristic(ShelfInstance, :shelves) assert char.name == :shelves end test "Info.characteristic/2 returns nil for unknown name" do - assert Info.characteristic(Shelf, :nonexistent) == nil + assert Info.characteristic(ShelfInstance, :nonexistent) == nil end end describe "PersistFeatures" do test "bakes features/0 onto the resource" do - features = Shelf.features() + features = ShelfInstance.features() assert is_list(features) assert length(features) == 1 [feature] = features @@ -80,12 +80,12 @@ defmodule Diffo.Provider.Extension.InstanceTransformerTest do end test "each feature is a Feature struct" do - [feature] = Shelf.features() + [feature] = ShelfInstance.features() assert is_struct(feature, Feature) end test "feature characteristics are nested in the declaration" do - [feature] = Shelf.features() + [feature] = ShelfInstance.features() assert length(feature.characteristics) == 2 char_names = Enum.map(feature.characteristics, & &1.name) assert :deploymentClass in char_names @@ -93,36 +93,36 @@ defmodule Diffo.Provider.Extension.InstanceTransformerTest do end test "features are also accessible via Info" do - assert length(Info.features(Shelf)) == 1 - assert Info.features(Card) == [] + assert length(Info.features(ShelfInstance)) == 1 + assert Info.features(CardInstance) == [] end test "Info.feature/2 returns the named feature" do - feature = Info.feature(Shelf, :spectralManagement) + feature = Info.feature(ShelfInstance, :spectralManagement) assert feature.name == :spectralManagement end test "Info.feature/2 returns nil for unknown name" do - assert Info.feature(Shelf, :nonexistent) == nil + assert Info.feature(ShelfInstance, :nonexistent) == nil end test "Info.feature_characteristic/3 returns the named characteristic within a feature" do - char = Info.feature_characteristic(Shelf, :spectralManagement, :deploymentClass) + char = Info.feature_characteristic(ShelfInstance, :spectralManagement, :deploymentClass) assert char.name == :deploymentClass end test "Info.feature_characteristic/3 returns nil for unknown feature" do - assert Info.feature_characteristic(Shelf, :nonexistent, :deploymentClass) == nil + assert Info.feature_characteristic(ShelfInstance, :nonexistent, :deploymentClass) == nil end test "Info.feature_characteristic/3 returns nil for unknown characteristic" do - assert Info.feature_characteristic(Shelf, :spectralManagement, :nonexistent) == nil + assert Info.feature_characteristic(ShelfInstance, :spectralManagement, :nonexistent) == nil end end describe "PersistParties" do test "bakes parties/0 onto the resource" do - parties = Shelf.parties() + parties = ShelfInstance.parties() assert is_list(parties) assert length(parties) == 5 roles = Enum.map(parties, & &1.role) @@ -134,39 +134,39 @@ defmodule Diffo.Provider.Extension.InstanceTransformerTest do end test "reference party has reference flag set" do - provider = Enum.find(Shelf.parties(), &(&1.role == :provider)) + provider = Enum.find(ShelfInstance.parties(), &(&1.role == :provider)) assert provider.reference == true end test "calculate party has calculate set" do - manager = Enum.find(Shelf.parties(), &(&1.role == :manager)) + manager = Enum.find(ShelfInstance.parties(), &(&1.role == :manager)) assert manager.calculate == :manager_calc end test "plural party has constraints" do - installer = Enum.find(Shelf.parties(), &(&1.role == :installer)) + installer = Enum.find(ShelfInstance.parties(), &(&1.role == :installer)) assert installer.multiple == true assert installer.constraints == [min: 1, max: 3] end test "parties are also accessible via Info" do - assert length(Info.parties(Shelf)) == 5 - assert Info.parties(Card) == [] + assert length(Info.parties(ShelfInstance)) == 5 + assert Info.parties(CardInstance) == [] end test "Info.party/2 returns the named party declaration by role" do - p = Info.party(Shelf, :facilitator) + p = Info.party(ShelfInstance, :facilitator) assert p.role == :facilitator end test "Info.party/2 returns nil for unknown role" do - assert Info.party(Shelf, :nonexistent) == nil + assert Info.party(ShelfInstance, :nonexistent) == nil end end describe "PersistPlaces" do test "bakes places/0 onto the resource" do - places = Shelf.places() + places = ShelfInstance.places() assert is_list(places) assert length(places) == 2 roles = Enum.map(places, & &1.role) @@ -175,55 +175,55 @@ defmodule Diffo.Provider.Extension.InstanceTransformerTest do end test "each place is a PlaceDeclaration struct" do - [first | _] = Shelf.places() + [first | _] = ShelfInstance.places() assert is_struct(first, PlaceDeclaration) end test "reference place has reference flag set" do - billing = Enum.find(Shelf.places(), &(&1.role == :billing_address)) + billing = Enum.find(ShelfInstance.places(), &(&1.role == :billing_address)) assert billing.reference == true end test "places are also accessible via Info" do - assert length(Info.places(Shelf)) == 2 - assert Info.places(Card) == [] + assert length(Info.places(ShelfInstance)) == 2 + assert Info.places(CardInstance) == [] end test "Info.place/2 returns the named place declaration by role" do - p = Info.place(Shelf, :installation_site) + p = Info.place(ShelfInstance, :installation_site) assert p.role == :installation_site end test "Info.place/2 returns nil for unknown role" do - assert Info.place(Shelf, :nonexistent) == nil + assert Info.place(ShelfInstance, :nonexistent) == nil end end describe "TransformBehaviour" do setup do - Code.ensure_loaded!(Shelf) - Code.ensure_loaded!(Card) + Code.ensure_loaded!(ShelfInstance) + Code.ensure_loaded!(CardInstance) :ok end test "build_before/1 is defined on shelf" do - assert function_exported?(Shelf, :build_before, 1) + assert function_exported?(ShelfInstance, :build_before, 1) end test "build_after/2 is defined on shelf" do - assert function_exported?(Shelf, :build_after, 2) + assert function_exported?(ShelfInstance, :build_after, 2) end test "build_before/1 is defined on card" do - assert function_exported?(Card, :build_before, 1) + assert function_exported?(CardInstance, :build_before, 1) end test "build_after/2 is defined on card" do - assert function_exported?(Card, :build_after, 2) + assert function_exported?(CardInstance, :build_after, 2) end test "action_create injects :specified_by argument into :build" do - action = Ash.Resource.Info.action(Shelf, :build) + action = Ash.Resource.Info.action(ShelfInstance, :build) arg_names = Enum.map(action.arguments, & &1.name) assert :specified_by in arg_names assert :features in arg_names @@ -231,7 +231,7 @@ defmodule Diffo.Provider.Extension.InstanceTransformerTest do end test "injected arguments are not public" do - action = Ash.Resource.Info.action(Shelf, :build) + action = Ash.Resource.Info.action(ShelfInstance, :build) injected = Enum.filter(action.arguments, &(&1.name in [:specified_by, :features, :characteristics])) @@ -240,56 +240,56 @@ defmodule Diffo.Provider.Extension.InstanceTransformerTest do end test "characteristic/1 returns the named characteristic" do - char = Shelf.characteristic(:shelves) + char = ShelfInstance.characteristic(:shelves) assert char.name == :shelves - assert char.value_type == {:array, Diffo.Test.Characteristic.Shelf} + assert char.value_type == {:array, Diffo.Test.Characteristic.ShelfCharacteristic} end test "characteristic/1 returns nil for unknown name" do - assert Shelf.characteristic(:nonexistent) == nil + assert ShelfInstance.characteristic(:nonexistent) == nil end test "feature/1 returns the named feature" do - feature = Shelf.feature(:spectralManagement) + feature = ShelfInstance.feature(:spectralManagement) assert feature.name == :spectralManagement assert feature.is_enabled? == true end test "feature/1 returns nil for unknown name" do - assert Shelf.feature(:nonexistent) == nil + assert ShelfInstance.feature(:nonexistent) == nil end test "feature_characteristic/2 returns the named characteristic within a feature" do - char = Shelf.feature_characteristic(:spectralManagement, :deploymentClass) + char = ShelfInstance.feature_characteristic(:spectralManagement, :deploymentClass) assert char.name == :deploymentClass end test "feature_characteristic/2 returns nil for unknown feature" do - assert Shelf.feature_characteristic(:nonexistent, :deploymentClass) == nil + assert ShelfInstance.feature_characteristic(:nonexistent, :deploymentClass) == nil end test "feature_characteristic/2 returns nil for unknown characteristic" do - assert Shelf.feature_characteristic(:spectralManagement, :nonexistent) == nil + assert ShelfInstance.feature_characteristic(:spectralManagement, :nonexistent) == nil end test "party/1 returns the named party declaration by role" do - p = Shelf.party(:facilitator) + p = ShelfInstance.party(:facilitator) assert p.role == :facilitator assert p.multiple == false end test "party/1 returns nil for unknown role" do - assert Shelf.party(:nonexistent) == nil + assert ShelfInstance.party(:nonexistent) == nil end test "place/1 returns the named place declaration by role" do - p = Shelf.place(:installation_site) + p = ShelfInstance.place(:installation_site) assert p.role == :installation_site assert p.multiple == false end test "place/1 returns nil for unknown role" do - assert Shelf.place(:nonexistent) == nil + assert ShelfInstance.place(:nonexistent) == nil end end end diff --git a/test/provider/extension/instance_verifier_test.exs b/test/provider/extension/instance_verifier_test.exs index cab2649..8fce63a 100644 --- a/test/provider/extension/instance_verifier_test.exs +++ b/test/provider/extension/instance_verifier_test.exs @@ -153,8 +153,8 @@ defmodule Diffo.Provider.Extension.InstanceVerifierTest do end characteristics do - characteristic :foo, Diffo.Test.Characteristic.Shelf - characteristic :foo, Diffo.Test.Characteristic.Shelf + characteristic :foo, Diffo.Test.Characteristic.ShelfCharacteristic + characteristic :foo, Diffo.Test.Characteristic.ShelfCharacteristic end end end @@ -273,8 +273,8 @@ defmodule Diffo.Provider.Extension.InstanceVerifierTest do features do feature :my_feature do - characteristic :baz, Diffo.Test.Characteristic.Shelf - characteristic :baz, Diffo.Test.Characteristic.Shelf + characteristic :baz, Diffo.Test.Characteristic.ShelfCharacteristic + characteristic :baz, Diffo.Test.Characteristic.ShelfCharacteristic end end end @@ -322,7 +322,7 @@ defmodule Diffo.Provider.Extension.InstanceVerifierTest do fn -> defmodule DuplicatePartyRole do alias Diffo.Provider.BaseInstance - alias Diffo.Test.Instance.Shelf + alias Diffo.Test.Instance.ShelfInstance use Ash.Resource, fragments: [BaseInstance], domain: Diffo.Test.Servo resource do @@ -336,8 +336,8 @@ defmodule Diffo.Provider.Extension.InstanceVerifierTest do end parties do - party :operator, Shelf - party :operator, Shelf + party :operator, ShelfInstance + party :operator, ShelfInstance end end end @@ -376,7 +376,7 @@ defmodule Diffo.Provider.Extension.InstanceVerifierTest do test "party_type not extending BaseParty warns DslError on compilation" do Util.assert_compile_time_warning( Spark.Error.DslError, - "parties: party_type Diffo.Test.Instance.Shelf does not extend BaseParty", + "parties: party_type Diffo.Test.Instance.ShelfInstance does not extend BaseParty", fn -> defmodule InvalidPartyBaseType do alias Diffo.Provider.BaseInstance @@ -393,7 +393,7 @@ defmodule Diffo.Provider.Extension.InstanceVerifierTest do end parties do - party :operator, Diffo.Test.Instance.Shelf + party :operator, Diffo.Test.Instance.ShelfInstance end end end @@ -532,7 +532,7 @@ defmodule Diffo.Provider.Extension.InstanceVerifierTest do test "party_ref with non-BaseParty type warns DslError on compilation" do Util.assert_compile_time_warning( Spark.Error.DslError, - "parties: party_type Diffo.Test.Instance.Shelf does not extend BaseParty", + "parties: party_type Diffo.Test.Instance.ShelfInstance does not extend BaseParty", fn -> defmodule InvalidPartyRefBaseType do alias Diffo.Provider.BaseInstance @@ -549,7 +549,7 @@ defmodule Diffo.Provider.Extension.InstanceVerifierTest do end parties do - party_ref :owner, Diffo.Test.Instance.Shelf + party_ref :owner, Diffo.Test.Instance.ShelfInstance end end end @@ -590,7 +590,7 @@ defmodule Diffo.Provider.Extension.InstanceVerifierTest do test "place_ref with non-BasePlace type warns DslError on compilation" do Util.assert_compile_time_warning( Spark.Error.DslError, - "places: place_type Diffo.Test.Instance.Shelf does not extend BasePlace", + "places: place_type Diffo.Test.Instance.ShelfInstance does not extend BasePlace", fn -> defmodule InvalidPlaceRefBaseType do alias Diffo.Provider.BaseInstance @@ -607,7 +607,7 @@ defmodule Diffo.Provider.Extension.InstanceVerifierTest do end places do - place_ref :billing, Diffo.Test.Instance.Shelf + place_ref :billing, Diffo.Test.Instance.ShelfInstance end end end diff --git a/test/provider/extension/party_test.exs b/test/provider/extension/party_test.exs index ef8fd2e..34178bf 100644 --- a/test/provider/extension/party_test.exs +++ b/test/provider/extension/party_test.exs @@ -11,7 +11,7 @@ defmodule Diffo.Provider.Extension.PartyTest do alias Diffo.Test.Party.Organization alias Diffo.Test.Party.Person - alias Diffo.Test.Instance.Shelf + alias Diffo.Test.Instance.ShelfInstance alias Diffo.Test.Nbn alias Diffo.Test.Servo alias Diffo.Provider.Instance.Party @@ -53,14 +53,14 @@ defmodule Diffo.Provider.Extension.PartyTest do describe "Instance DSL — Shelf parties" do test "party declarations are accessible via info" do - parties = InstanceInfo.structure_parties(Shelf) + parties = InstanceInfo.structure_parties(ShelfInstance) roles = Enum.map(parties, & &1.role) assert :facilitator in roles assert :overseer in roles end test "party types are correct" do - parties = InstanceInfo.structure_parties(Shelf) + parties = InstanceInfo.structure_parties(ShelfInstance) facilitator = Enum.find(parties, &(&1.role == :facilitator)) overseer = Enum.find(parties, &(&1.role == :overseer)) assert facilitator.party_type == Organization @@ -68,38 +68,38 @@ defmodule Diffo.Provider.Extension.PartyTest do end test "singular party defaults to multiple: false" do - parties = InstanceInfo.structure_parties(Shelf) + parties = InstanceInfo.structure_parties(ShelfInstance) facilitator = Enum.find(parties, &(&1.role == :facilitator)) assert facilitator.multiple == false end test "reference: true is declared" do - parties = InstanceInfo.structure_parties(Shelf) + parties = InstanceInfo.structure_parties(ShelfInstance) provider = Enum.find(parties, &(&1.role == :provider)) assert provider.reference == true assert provider.multiple == false end test "reference defaults to false" do - parties = InstanceInfo.structure_parties(Shelf) + parties = InstanceInfo.structure_parties(ShelfInstance) facilitator = Enum.find(parties, &(&1.role == :facilitator)) assert facilitator.reference == false end test "calculate: is declared" do - parties = InstanceInfo.structure_parties(Shelf) + parties = InstanceInfo.structure_parties(ShelfInstance) manager = Enum.find(parties, &(&1.role == :manager)) assert manager.calculate == :manager_calc end test "parties (plural) sets multiple: true" do - parties = InstanceInfo.structure_parties(Shelf) + parties = InstanceInfo.structure_parties(ShelfInstance) installer = Enum.find(parties, &(&1.role == :installer)) assert installer.multiple == true end test "parties (plural) constraints are declared" do - parties = InstanceInfo.structure_parties(Shelf) + parties = InstanceInfo.structure_parties(ShelfInstance) installer = Enum.find(parties, &(&1.role == :installer)) assert installer.constraints == [min: 1, max: 3] end diff --git a/test/provider/extension/place_test.exs b/test/provider/extension/place_test.exs index b159366..9318745 100644 --- a/test/provider/extension/place_test.exs +++ b/test/provider/extension/place_test.exs @@ -11,7 +11,7 @@ defmodule Diffo.Provider.Extension.PlaceTest do alias Diffo.Test.Party.Organization alias Diffo.Test.Place.GeographicSite - alias Diffo.Test.Instance.Shelf + alias Diffo.Test.Instance.ShelfInstance alias Diffo.Test.Nbn setup do @@ -44,33 +44,33 @@ defmodule Diffo.Provider.Extension.PlaceTest do describe "Instance DSL — Shelf places" do test "place declarations are accessible via info" do - places = InstanceInfo.structure_places(Shelf) + places = InstanceInfo.structure_places(ShelfInstance) roles = Enum.map(places, & &1.role) assert :installation_site in roles assert :billing_address in roles end test "place types are correct" do - places = InstanceInfo.structure_places(Shelf) + places = InstanceInfo.structure_places(ShelfInstance) installation_site = Enum.find(places, &(&1.role == :installation_site)) assert installation_site.place_type == Diffo.Provider.Place end test "singular place defaults to multiple: false" do - places = InstanceInfo.structure_places(Shelf) + places = InstanceInfo.structure_places(ShelfInstance) installation_site = Enum.find(places, &(&1.role == :installation_site)) assert installation_site.multiple == false end test "reference: true is declared" do - places = InstanceInfo.structure_places(Shelf) + places = InstanceInfo.structure_places(ShelfInstance) billing = Enum.find(places, &(&1.role == :billing_address)) assert billing.reference == true assert billing.multiple == false end test "reference defaults to false" do - places = InstanceInfo.structure_places(Shelf) + places = InstanceInfo.structure_places(ShelfInstance) installation_site = Enum.find(places, &(&1.role == :installation_site)) assert installation_site.reference == false end diff --git a/test/provider/extension/specification_test.exs b/test/provider/extension/specification_test.exs index c30016d..c14f7e9 100644 --- a/test/provider/extension/specification_test.exs +++ b/test/provider/extension/specification_test.exs @@ -6,7 +6,7 @@ defmodule Diffo.Provider.Extension.SpecificationTest do @moduledoc false use ExUnit.Case, async: true alias Diffo.Test.Servo - alias Diffo.Test.Instance.Shelf + alias Diffo.Test.Instance.ShelfInstance setup do AshNeo4j.Sandbox.checkout() @@ -15,8 +15,8 @@ defmodule Diffo.Provider.Extension.SpecificationTest do describe "specification" do test "description declared in specification DSL roundtrips to the persisted specification" do - spec_id = Shelf.specification()[:id] - description = Shelf.specification()[:description] + spec_id = ShelfInstance.specification()[:id] + description = ShelfInstance.specification()[:description] Servo.build_shelf(%{name: "s"}) @@ -27,22 +27,22 @@ defmodule Diffo.Provider.Extension.SpecificationTest do test "minor_version declared in specification DSL roundtrips to the persisted specification" do Servo.build_shelf(%{name: "s"}) - {:ok, specification} = Diffo.Provider.get_specification_by_id(Shelf.specification()[:id]) - assert specification.minor_version == Shelf.specification()[:minor_version] + {:ok, specification} = Diffo.Provider.get_specification_by_id(ShelfInstance.specification()[:id]) + assert specification.minor_version == ShelfInstance.specification()[:minor_version] end test "patch_version declared in specification DSL roundtrips to the persisted specification" do Servo.build_shelf(%{name: "s"}) - {:ok, specification} = Diffo.Provider.get_specification_by_id(Shelf.specification()[:id]) - assert specification.patch_version == Shelf.specification()[:patch_version] + {:ok, specification} = Diffo.Provider.get_specification_by_id(ShelfInstance.specification()[:id]) + assert specification.patch_version == ShelfInstance.specification()[:patch_version] end test "tmf_version declared in specification DSL roundtrips to the persisted specification" do Servo.build_shelf(%{name: "s"}) - {:ok, specification} = Diffo.Provider.get_specification_by_id(Shelf.specification()[:id]) - assert specification.tmf_version == Shelf.specification()[:tmf_version] + {:ok, specification} = Diffo.Provider.get_specification_by_id(ShelfInstance.specification()[:id]) + assert specification.tmf_version == ShelfInstance.specification()[:tmf_version] end end end diff --git a/test/support/resource/characteristic/card.ex b/test/support/resource/characteristic/card_characteristic.ex similarity index 95% rename from test/support/resource/characteristic/card.ex rename to test/support/resource/characteristic/card_characteristic.ex index 455a082..0d85b4c 100644 --- a/test/support/resource/characteristic/card.ex +++ b/test/support/resource/characteristic/card_characteristic.ex @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: MIT -defmodule Diffo.Test.Characteristic.Card do +defmodule Diffo.Test.Characteristic.CardCharacteristic do @moduledoc "Typed characteristic for a Card's identity." use Ash.Resource, fragments: [Diffo.Provider.BaseCharacteristic], diff --git a/test/support/resource/characteristic/card/value.ex b/test/support/resource/characteristic/card_characteristic/value.ex similarity index 89% rename from test/support/resource/characteristic/card/value.ex rename to test/support/resource/characteristic/card_characteristic/value.ex index 2e52661..e1d4835 100644 --- a/test/support/resource/characteristic/card/value.ex +++ b/test/support/resource/characteristic/card_characteristic/value.ex @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: MIT -defmodule Diffo.Test.Characteristic.Card.Value do +defmodule Diffo.Test.Characteristic.CardCharacteristic.Value do @moduledoc "Typed value struct for a Card characteristic." use Ash.TypedStruct, extensions: [AshJason.TypedStruct] diff --git a/test/support/resource/characteristic/shelf.ex b/test/support/resource/characteristic/shelf_characteristic.ex similarity index 95% rename from test/support/resource/characteristic/shelf.ex rename to test/support/resource/characteristic/shelf_characteristic.ex index a400bf9..7545df1 100644 --- a/test/support/resource/characteristic/shelf.ex +++ b/test/support/resource/characteristic/shelf_characteristic.ex @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: MIT -defmodule Diffo.Test.Characteristic.Shelf do +defmodule Diffo.Test.Characteristic.ShelfCharacteristic do @moduledoc "Typed characteristic for a Shelf's identity." use Ash.Resource, fragments: [Diffo.Provider.BaseCharacteristic], diff --git a/test/support/resource/characteristic/shelf/value.ex b/test/support/resource/characteristic/shelf_characteristic/value.ex similarity index 89% rename from test/support/resource/characteristic/shelf/value.ex rename to test/support/resource/characteristic/shelf_characteristic/value.ex index 11cb450..57aaf70 100644 --- a/test/support/resource/characteristic/shelf/value.ex +++ b/test/support/resource/characteristic/shelf_characteristic/value.ex @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: MIT -defmodule Diffo.Test.Characteristic.Shelf.Value do +defmodule Diffo.Test.Characteristic.ShelfCharacteristic.Value do @moduledoc "Typed value struct for a Shelf characteristic." use Ash.TypedStruct, extensions: [AshJason.TypedStruct] diff --git a/test/support/resource/instance/card.ex b/test/support/resource/instance/card_instance.ex similarity index 96% rename from test/support/resource/instance/card.ex rename to test/support/resource/instance/card_instance.ex index 11bf7f4..8e432ce 100644 --- a/test/support/resource/instance/card.ex +++ b/test/support/resource/instance/card_instance.ex @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: MIT -defmodule Diffo.Test.Instance.Card do +defmodule Diffo.Test.Instance.CardInstance do @moduledoc """ Diffo - TMF Service and Resource Management with a difference @@ -15,7 +15,7 @@ defmodule Diffo.Test.Instance.Card do alias Diffo.Provider.Assigner alias Diffo.Provider.Assignment alias Diffo.Test.Servo - alias Diffo.Test.Characteristic.Card, as: CardCharacteristic + alias Diffo.Test.Characteristic.CardCharacteristic use Ash.Resource, fragments: [BaseInstance], diff --git a/test/support/resource/instance/shelf.ex b/test/support/resource/instance/shelf_instance.ex similarity index 97% rename from test/support/resource/instance/shelf.ex rename to test/support/resource/instance/shelf_instance.ex index 1643880..baeee6e 100644 --- a/test/support/resource/instance/shelf.ex +++ b/test/support/resource/instance/shelf_instance.ex @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: MIT -defmodule Diffo.Test.Instance.Shelf do +defmodule Diffo.Test.Instance.ShelfInstance do @moduledoc """ Diffo - TMF Service and Resource Management with a difference @@ -15,9 +15,8 @@ defmodule Diffo.Test.Instance.Shelf do alias Diffo.Provider.Assigner alias Diffo.Provider.Assignment alias Diffo.Provider.AssignableValue - alias Diffo.Test.Servo - alias Diffo.Test.Characteristic.Shelf, as: ShelfCharacteristic + alias Diffo.Test.Characteristic.ShelfCharacteristic alias Diffo.Test.Characteristic.DeploymentClass alias Diffo.Test.Party.Organization alias Diffo.Test.Party.Person diff --git a/test/support/servo.ex b/test/support/servo.ex index 54bafeb..7a2f49b 100644 --- a/test/support/servo.ex +++ b/test/support/servo.ex @@ -12,12 +12,12 @@ defmodule Diffo.Test.Servo do otp_app: :diffo, validate_config_inclusion?: false - alias Diffo.Test.Instance.Shelf - alias Diffo.Test.Instance.Card + alias Diffo.Test.Instance.ShelfInstance + alias Diffo.Test.Instance.CardInstance alias Diffo.Test.Instance.Broadband alias Diffo.Test.Instance.BroadbandV2 - alias Diffo.Test.Characteristic.Shelf, as: ShelfCharacteristic - alias Diffo.Test.Characteristic.Card, as: CardCharacteristic + alias Diffo.Test.Characteristic.ShelfCharacteristic + alias Diffo.Test.Characteristic.CardCharacteristic alias Diffo.Test.Characteristic.DeploymentClass alias Diffo.Provider.AssignableCharacteristic @@ -26,7 +26,7 @@ defmodule Diffo.Test.Servo do end resources do - resource Shelf do + resource ShelfInstance do define :get_shelf_by_id, action: :read, get_by: :id define :build_shelf, action: :build define :define_shelf, action: :define @@ -34,7 +34,7 @@ defmodule Diffo.Test.Servo do define :assign_slot, action: :assign_slot end - resource Card do + resource CardInstance do define :get_card_by_id, action: :read, get_by: :id define :build_card, action: :build define :define_card, action: :define diff --git a/test/type/dynamic_test.exs b/test/type/dynamic_test.exs index f30dfec..8989f73 100644 --- a/test/type/dynamic_test.exs +++ b/test/type/dynamic_test.exs @@ -8,7 +8,7 @@ defmodule Diffo.Type.DynamicTest do use Outstand alias Diffo.Type.Dynamic alias Diffo.Test.Patch - alias Diffo.Test.Characteristic.Card, as: CardValue + alias Diffo.Test.Characteristic.CardCharacteristic, as: CardValue describe "dynamic type validation" do test "cast_input rejects non-NewType scalar Ash type" do diff --git a/usage-rules.md b/usage-rules.md index 5b71fc3..1381c65 100644 --- a/usage-rules.md +++ b/usage-rules.md @@ -340,6 +340,24 @@ modules are still available as thin delegating wrappers for backwards compatibil - **Never change the `id`** of an existing specification. It is a stable cross-environment identity; changing it orphans existing instances. +## Neo4j label naming convention + +AshNeo4j derives each resource's primary node label from the **last segment** of the module +name. If two different resource kinds share the same last segment, all reads and writes for +one will also match nodes belonging to the other — a silent data corruption. + +**Always suffix the module with its resource kind** so the derived label is unique: + +| Kind | Pattern | Example | +|------|---------|---------| +| Instance | `…Instance` | `MyApp.Instance.WidgetInstance` → `:WidgetInstance` | +| Characteristic | `…Characteristic` | `MyApp.Characteristic.SpeedCharacteristic` → `:SpeedCharacteristic` | +| Party | `…Party` or unique name | `MyApp.Party.ProviderOrganization` → `:ProviderOrganization` | +| Place | `…Place` or unique name | `MyApp.Place.InstallationSite` → `:InstallationSite` | + +If a domain has both `MyApp.Instance.Card` and `MyApp.Characteristic.Card`, both resolve to +label `:Card` and queries are ambiguous. Rename to `CardInstance` and `CardCharacteristic`. + ## Complete example ```elixir