diff --git a/AGENTS.md b/AGENTS.md index 25ffd94..57adba5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,13 @@ on [Ash Framework](https://www.ash-hq.org/) + [AshNeo4j](https://github.com/diff ## Project structure ``` +lib/diffo/type/ + primitive.ex # Diffo.Type.Primitive — discriminated union of primitive Elixir types + value.ex # Diffo.Type.Value — union of Primitive and Dynamic + dynamic.ex # Diffo.Type.Dynamic — runtime-typed value (NewType with map storage) + name_value_primitive.ex # Diffo.Type.NameValuePrimitive — name/Primitive pair TypedStruct + name_value_array_primitive.ex # Diffo.Type.NameValueArrayPrimitive — name/[Primitive] pair TypedStruct + lib/diffo/provider/ extension.ex # Unified Spark DSL extension (provider do) extension/ @@ -46,7 +53,9 @@ lib/diffo/provider/ 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 + base_relationship.ex # Ash Fragment for shared Relationship structure + defined_simple_relationship.ex # DefinedSimpleRelationship — relationship with one optional embedded characteristic, frozen at creation + relationship.ex # Relationship — mutable TMF service/resource relationship with graph Characteristic nodes calculations/ characteristic_value.ex # Calculation: builds .Value TypedStruct from record fields assigned_values.ex # Calculation: returns list of assigned integers for a pool+thing @@ -54,6 +63,9 @@ lib/diffo/provider/ party/extension.ex # Thin marker place/extension.ex # Thin marker +test/provider/ + defined_simple_relationship_test.exs # Integration: DefinedSimpleRelationship create/destroy + DefinedCharacteristic encoding + test/provider/extension/ # All provider extension tests instance_transformer_test.exs party_transformer_test.exs diff --git a/lib/diffo/provider.ex b/lib/diffo/provider.ex index 65d16a6..cddd8f8 100644 --- a/lib/diffo/provider.ex +++ b/lib/diffo/provider.ex @@ -77,6 +77,12 @@ defmodule Diffo.Provider do define :delete_relationship, action: :destroy end + resource Diffo.Provider.DefinedSimpleRelationship do + define :create_defined_simple_relationship, action: :create + define :get_defined_simple_relationship_by_id, action: :read, get_by: :id + define :delete_defined_simple_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 diff --git a/lib/diffo/provider/components/defined_simple_relationship.ex b/lib/diffo/provider/components/defined_simple_relationship.ex new file mode 100644 index 0000000..5872162 --- /dev/null +++ b/lib/diffo/provider/components/defined_simple_relationship.ex @@ -0,0 +1,107 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.DefinedSimpleRelationship do + @moduledoc """ + Ash Resource for a relationship with an optional single embedded characteristic, + set at creation and never changed. + + Extends `BaseRelationship` (source, target, type, timestamps). Optionally carries + one `DefinedCharacteristic` — a name/value pair stored directly on the Neo4j node. + The value is a `Diffo.Type.Primitive`, covering string, integer, float, boolean, + and temporal types. + + Actions: **create** and **destroy** only. No update, no relate/unrelate. Once + defined, the characteristic is closed — that is the commitment. + + Contrast with `Provider.Relationship` which allows mutable graph-based `Characteristic` + nodes to be added, removed, and updated over time. + + `DefinedSimpleRelationship` is a general Provider primitive for any relationship + whose characteristic is a commitment or promise made at creation time. + """ + use Ash.Resource, + fragments: [Diffo.Provider.BaseRelationship], + otp_app: :diffo, + domain: Diffo.Provider + + resource do + description "An Ash Resource for a relationship with a single optional characteristic, defined at creation and closed thereafter" + plural_name :defined_simple_relationships + end + + neo4j do + relate [ + {:source, :RELATES, :incoming, :Instance}, + {:target, :RELATES, :outgoing, :Instance} + ] + end + + jason do + pick [:alias, :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.suppress(:alias) + |> then(fn r -> + case Map.get(record, :characteristic) do + nil -> r + char -> Diffo.Util.set(r, list_name, [char]) + end + end) + end + + order [ + :alias, + :type, + :service, + :resource, + :serviceRelationshipCharacteristic, + :resourceRelationshipCharacteristic + ] + end + + actions do + create :create do + description "creates a defined simple relationship between a source and target instance" + accept [:alias, :type, :characteristic] + + argument :source_id, :uuid + argument :target_id, :string + + 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 :alias, :atom do + description "an optional alias for this relationship" + allow_nil? true + public? true + end + + attribute :characteristic, Diffo.Type.NameValuePrimitive do + description "an optional single defining characteristic, set at creation and closed thereafter" + allow_nil? true + public? true + end + end + + preparations do + prepare build(sort: [created_at: :asc]) + end +end diff --git a/lib/diffo/type/name_value_array_primitive.ex b/lib/diffo/type/name_value_array_primitive.ex new file mode 100644 index 0000000..1c3b45c --- /dev/null +++ b/lib/diffo/type/name_value_array_primitive.ex @@ -0,0 +1,22 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Type.NameValueArrayPrimitive do + @moduledoc """ + Ash TypedStruct for a named array of primitive values. + + A name/values pair where each value is a `Diffo.Type.Primitive` — covering string, + integer, float, boolean, date, time, datetime, and duration. + """ + use Ash.TypedStruct, extensions: [AshJason.TypedStruct] + + jason do + order [:name, :values] + end + + typed_struct do + field :name, :atom, allow_nil?: false, description: "the name" + field :values, {:array, Diffo.Type.Primitive}, default: [], description: "the primitive values" + end +end diff --git a/lib/diffo/type/name_value_primitive.ex b/lib/diffo/type/name_value_primitive.ex new file mode 100644 index 0000000..75afeb9 --- /dev/null +++ b/lib/diffo/type/name_value_primitive.ex @@ -0,0 +1,22 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Type.NameValuePrimitive do + @moduledoc """ + Ash TypedStruct for a named primitive value. + + A name/value pair where the value is a `Diffo.Type.Primitive` — covering string, + integer, float, boolean, date, time, datetime, and duration. + """ + use Ash.TypedStruct, extensions: [AshJason.TypedStruct] + + jason do + order [:name, :value] + end + + typed_struct do + field :name, :atom, allow_nil?: false, description: "the name" + field :value, Diffo.Type.Primitive, description: "the primitive value" + end +end diff --git a/test/provider/defined_simple_relationship_test.exs b/test/provider/defined_simple_relationship_test.exs new file mode 100644 index 0000000..2e48b67 --- /dev/null +++ b/test/provider/defined_simple_relationship_test.exs @@ -0,0 +1,168 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.DefinedSimpleRelationshipTest do + @moduledoc false + use ExUnit.Case, async: true + + alias Diffo.Type.NameValuePrimitive + alias Diffo.Type.Primitive + + setup do + AshNeo4j.Sandbox.checkout() + on_exit(&AshNeo4j.Sandbox.rollback/0) + end + + defp build_instances do + spec_a = Diffo.Provider.create_specification!(%{name: "accessEvc"}) + spec_b = Diffo.Provider.create_specification!(%{name: "aggregationEvc"}) + source = Diffo.Provider.create_instance!(%{specified_by: spec_a.id, name: "access1"}) + target = Diffo.Provider.create_instance!(%{specified_by: spec_b.id, name: "agg1"}) + {source, target} + end + + describe "DefinedSimpleRelationship create" do + test "creates a relationship with no characteristic" do + {source, target} = build_instances() + + rel = + Diffo.Provider.create_defined_simple_relationship!(%{ + type: :assignedTo, + source_id: source.id, + target_id: target.id + }) + + assert rel.type == :assignedTo + assert rel.source_id == source.id + assert rel.target_id == target.id + assert rel.characteristic == nil + end + + test "creates a relationship with an integer characteristic" do + {source, target} = build_instances() + + rel = + Diffo.Provider.create_defined_simple_relationship!(%{ + type: :assignedTo, + source_id: source.id, + target_id: target.id, + characteristic: %NameValuePrimitive{ + name: :slot, + value: Primitive.wrap("integer", 7) + } + }) + + assert rel.characteristic.name == :slot + assert Diffo.Unwrap.unwrap(rel.characteristic.value) == 7 + end + + test "creates a relationship with a string characteristic" do + {source, target} = build_instances() + + rel = + Diffo.Provider.create_defined_simple_relationship!(%{ + type: :definedBy, + source_id: source.id, + target_id: target.id, + characteristic: %NameValuePrimitive{ + name: :bandwidth, + value: Primitive.wrap("string", "1000Mbps") + } + }) + + assert rel.characteristic.name == :bandwidth + assert Diffo.Unwrap.unwrap(rel.characteristic.value) == "1000Mbps" + end + + test "characteristic is persisted and reloaded correctly" do + {source, target} = build_instances() + + created = + Diffo.Provider.create_defined_simple_relationship!(%{ + type: :assignedTo, + source_id: source.id, + target_id: target.id, + characteristic: %NameValuePrimitive{ + name: :slot, + value: Primitive.wrap("integer", 42) + } + }) + + reloaded = Diffo.Provider.get_defined_simple_relationship_by_id!(created.id) + + assert reloaded.characteristic.name == :slot + assert Diffo.Unwrap.unwrap(reloaded.characteristic.value) == 42 + end + + test "target_href and target_type are populated" do + {source, target} = build_instances() + + rel = + Diffo.Provider.create_defined_simple_relationship!(%{ + type: :assignedTo, + source_id: source.id, + target_id: target.id + }) + + assert rel.target_type == :service + assert is_binary(rel.target_href) + end + end + + describe "DefinedSimpleRelationship read" do + test "get by id returns the relationship" do + {source, target} = build_instances() + + created = + Diffo.Provider.create_defined_simple_relationship!(%{ + type: :assignedTo, + source_id: source.id, + target_id: target.id + }) + + fetched = Diffo.Provider.get_defined_simple_relationship_by_id!(created.id) + assert fetched.id == created.id + assert fetched.type == :assignedTo + end + end + + describe "DefinedSimpleRelationship destroy" do + test "destroys the relationship" do + {source, target} = build_instances() + + rel = + Diffo.Provider.create_defined_simple_relationship!(%{ + type: :assignedTo, + source_id: source.id, + target_id: target.id + }) + + Diffo.Provider.delete_defined_simple_relationship!(rel) + + assert_raise Ash.Error.Invalid, fn -> + Diffo.Provider.get_defined_simple_relationship_by_id!(rel.id) + end + end + end + + describe "NameValuePrimitive TypedStruct" do + test "new!/1 constructs with a Primitive value" do + char = NameValuePrimitive.new!(name: :slot, value: Primitive.wrap("integer", 7)) + assert char.name == :slot + assert Diffo.Unwrap.unwrap(char.value) == 7 + end + + test "new!/1 raises when name is nil" do + assert_raise Ash.Error.Invalid, fn -> + NameValuePrimitive.new!(name: nil, value: Primitive.wrap("string", "x")) + end + end + + test "Jason encoding produces name then unwrapped value" do + char = NameValuePrimitive.new!(name: :slot, value: Primitive.wrap("integer", 7)) + json = Jason.encode!(char) + assert json == ~s({"name":"slot","value":7}) + end + end +end