diff --git a/lib/access/calculations/shelf_total_ports.ex b/lib/access/calculations/shelf_total_ports.ex new file mode 100644 index 0000000..08b12a6 --- /dev/null +++ b/lib/access/calculations/shelf_total_ports.ex @@ -0,0 +1,42 @@ +# SPDX-FileCopyrightText: 2025 diffo_example contributors +# +# SPDX-License-Identifier: MIT + +defmodule DiffoExample.Access.Calculations.ShelfTotalPorts do + @moduledoc """ + Sums the `:ports` pool capacity across every card a shelf has assigned + a slot to. + + For each outgoing slot-assignment (alias `:slot`, source = shelf), looks + up the assigned card's `AssignableCharacteristic` for the `:ports` pool + and sums `(last - first + 1)` across all of them. + + Local-to-this-repo for now. Could in time become a more general + diffo-side primitive (`SumPoolCapacityOfAssignees` or similar) once the + pattern repeats; the cleanest path may also be an ash_neo4j aggregate + primitive that walks assignment edges natively. Worth its own yarn. + """ + use Ash.Resource.Calculation + + @impl true + def load(_query, _opts, _context), do: [] + + @impl true + def calculate(records, _opts, _context) do + Enum.map(records, fn shelf -> + assignments = + Diffo.Provider.AssignmentRelationship + |> Ash.Query.filter_input(source_id: shelf.id, alias: :slot) + |> Ash.read!(domain: Diffo.Provider) + + Enum.reduce(assignments, 0, fn assignment, acc -> + case Diffo.Provider.AssignableCharacteristic + |> Ash.Query.filter_input(instance_id: assignment.target_id, name: :ports) + |> Ash.read_one!(domain: Diffo.Provider) do + nil -> acc + ports -> acc + (ports.last - ports.first + 1) + end + end) + end) + end +end diff --git a/lib/access/resources/card.ex b/lib/access/resources/card.ex index 4faff7b..cc160ad 100644 --- a/lib/access/resources/card.ex +++ b/lib/access/resources/card.ex @@ -87,4 +87,23 @@ defmodule DiffoExample.Access.Card do change {DiffoExample.Changes.Assign, pool: :ports} end end + + calculations do + # The shelf characteristic value brought up from the shelf this card is + # in — derived live via the :slot assignment. + calculate :shelf, + {:array, :map}, + {DiffoExample.Calculations.InheritedCharacteristic, + [via: [:slot], characteristic_module: DiffoExample.Access.ShelfCharacteristic]} do + public? true + end + + # The slot number this card occupies on its shelf — the value of the + # shelf's :slots-pool assignment to this card. + calculate :slot, + {:array, :integer}, + {Diffo.Provider.Calculations.FieldFromAssignment, [alias: :slot, field: :value]} do + public? true + end + end end diff --git a/lib/access/resources/path.ex b/lib/access/resources/path.ex index 7e5b1e9..816e511 100644 --- a/lib/access/resources/path.ex +++ b/lib/access/resources/path.ex @@ -74,4 +74,35 @@ defmodule DiffoExample.Access.Path do change DiffoExample.Changes.Relate end end + + calculations do + # The card characteristic value brought up from the card this path is + # assigned a port on — via the :port assignment. + calculate :card, + {:array, :map}, + {DiffoExample.Calculations.InheritedCharacteristic, + [via: [:port], characteristic_module: DiffoExample.Access.CardCharacteristic]} do + public? true + end + + # The port number this path occupies on its card — the value of the + # card's :ports-pool assignment to this path. + calculate :port, + {:array, :integer}, + {Diffo.Provider.Calculations.FieldFromAssignment, [alias: :port, field: :value]} do + public? true + end + + # The shelf characteristic value brought up transitively — port to the + # card, then the card's slot to its shelf. Two-hop via [:port, :slot]. + calculate :shelf, + {:array, :map}, + {DiffoExample.Calculations.InheritedCharacteristic, + [ + via: [:port, :slot], + characteristic_module: DiffoExample.Access.ShelfCharacteristic + ]} do + public? true + end + end end diff --git a/lib/access/resources/shelf.ex b/lib/access/resources/shelf.ex index 7b77732..243de66 100644 --- a/lib/access/resources/shelf.ex +++ b/lib/access/resources/shelf.ex @@ -87,4 +87,26 @@ defmodule DiffoExample.Access.Shelf do change {DiffoExample.Changes.Assign, pool: :slots} end end + + calculations do + # Brings up the card characteristic of every card this shelf has + # assigned a slot to, ordered by slot number. Cards-as-assignees name + # their slot :slot when requesting; the calc filters outgoing + # AssignmentRelationship records by that alias. + calculate :cards, + {:array, :map}, + {DiffoExample.Calculations.ReverseInheritedCharacteristic, + [alias: :slot, characteristic_module: DiffoExample.Access.CardCharacteristic]} do + public? true + end + + # Sum of port capacity across every card assigned to this shelf. + # Each card's :ports pool size is `(last - first + 1)`. Reaches across + # the slot-assignment chain to AssignableCharacteristic on each card. + calculate :total_ports, + :integer, + DiffoExample.Access.Calculations.ShelfTotalPorts do + public? true + end + end end diff --git a/lib/diffo_example/calculations/inherited_characteristic.ex b/lib/diffo_example/calculations/inherited_characteristic.ex new file mode 100644 index 0000000..1495028 --- /dev/null +++ b/lib/diffo_example/calculations/inherited_characteristic.ex @@ -0,0 +1,74 @@ +# SPDX-FileCopyrightText: 2025 diffo_example contributors +# +# SPDX-License-Identifier: MIT + +defmodule DiffoExample.Calculations.InheritedCharacteristic do + @moduledoc """ + Brings up a typed characteristic value from an assignment-source instance. + + Mirrors the shape of `Diffo.Provider.Calculations.InheritedPlace` and + `Diffo.Provider.Calculations.InheritedParty` — traverses + `AssignmentRelationship` by alias to reach source instances, then queries + the typed characteristic resource on each source and returns its `.value`. + + Local-to-this-repo for now. Worth yarning upstream as a diffo-side + `inherited_characteristic` DSL declaration backed by a + `Diffo.Provider.Calculations.InheritedCharacteristic` calc, sitting + alongside the existing inherited-place and inherited-party machinery. + + ## Options + + - `via:` *(required)* — list of alias atoms naming the assignment chain + from this instance back to the source whose characteristic we want. + Each step filters `AssignmentRelationship` by `target_id` and `alias`, + then follows `source_id` to the next set of instances. The aliases are + the assignee's slot names, supplied when the assignment is made. + - `characteristic_module:` *(required)* — the typed characteristic Ash + resource on the final source (e.g. `ShelfCharacteristic`). The calc + queries this resource by `instance_id` and returns the `.value`. + + ## Example + + # Card brings up its shelf's typed characteristic via the slot + # assignment the shelf made to it (alias :slot on the incoming + # AssignmentRelationship). + calculate :shelf, :map, + {DiffoExample.Calculations.InheritedCharacteristic, + [via: [:slot], characteristic_module: ShelfCharacteristic]} + + # Path brings up the same via a two-hop chain — port-then-slot. + calculate :shelf, :map, + {DiffoExample.Calculations.InheritedCharacteristic, + [via: [:port, :slot], characteristic_module: ShelfCharacteristic]} + """ + use Ash.Resource.Calculation + + @impl true + def load(_query, _opts, _context), do: [] + + @impl true + def calculate(records, opts, _context) do + via = Keyword.fetch!(opts, :via) + characteristic_module = Keyword.fetch!(opts, :characteristic_module) + + Enum.map(records, fn record -> + final_ids = + Enum.reduce(via, [record.id], fn alias_step, ids -> + Enum.flat_map(ids, fn id -> + Diffo.Provider.AssignmentRelationship + |> Ash.Query.filter_input(target_id: id, alias: alias_step) + |> Ash.read!(domain: Diffo.Provider) + |> Enum.map(& &1.source_id) + end) + end) + + Enum.flat_map(final_ids, fn id -> + characteristic_module + |> Ash.Query.filter_input(instance_id: id) + |> Ash.Query.load(:value) + |> Ash.read!() + |> Enum.map(& &1.value) + end) + end) + end +end diff --git a/lib/diffo_example/calculations/reverse_inherited_characteristic.ex b/lib/diffo_example/calculations/reverse_inherited_characteristic.ex new file mode 100644 index 0000000..af146f9 --- /dev/null +++ b/lib/diffo_example/calculations/reverse_inherited_characteristic.ex @@ -0,0 +1,75 @@ +# SPDX-FileCopyrightText: 2025 diffo_example contributors +# +# SPDX-License-Identifier: MIT + +defmodule DiffoExample.Calculations.ReverseInheritedCharacteristic do + @moduledoc """ + Brings up typed characteristic values from instances this one has + assigned to — the reverse of inheritance. + + `InheritedCharacteristic` is the conventional direction: the assignee + inherits its characteristic from its assigner (you inherit from your + parents). This is the reverse: the assigner brings up the characteristic + of every assignee it's connected to (insanity is hereditary — you get + it from your kids). + + Traverses OUTGOING `AssignmentRelationship` records (this instance as + source) optionally filtered by alias, then reads the typed characteristic + on each assignee. + + Useful when the assigner wants to compose its assignees into its own + view — e.g. a shelf bringing up the cards it has assigned slots to, + ordered by slot number. + + Worth yarning upstream alongside `inherited_characteristic` as a pair + of diffo-side DSL declarations. + + ## Options + + - `alias:` *(optional)* — filter outgoing assignments by alias (the + assignee's slot name). When omitted, all outgoing assignments are + included. + - `characteristic_module:` *(required)* — the typed characteristic Ash + resource on each assignee (e.g. `CardCharacteristic`). + + ## Example + + # Shelf brings up the card characteristic from every card it's + # assigned a slot to, ordered by slot number. + calculate :cards, {:array, :map}, + {DiffoExample.Calculations.ReverseInheritedCharacteristic, + [alias: :slot, characteristic_module: CardCharacteristic]} + """ + use Ash.Resource.Calculation + + @impl true + def load(_query, _opts, _context), do: [] + + @impl true + def calculate(records, opts, _context) do + alias_filter = Keyword.get(opts, :alias) + characteristic_module = Keyword.fetch!(opts, :characteristic_module) + + Enum.map(records, fn record -> + assignments = + Diffo.Provider.AssignmentRelationship + |> filter_outgoing(record.id, alias_filter) + |> Ash.Query.sort(value: :asc) + |> Ash.read!(domain: Diffo.Provider) + + Enum.flat_map(assignments, fn assignment -> + characteristic_module + |> Ash.Query.filter_input(instance_id: assignment.target_id) + |> Ash.Query.load(:value) + |> Ash.read!() + |> Enum.map(& &1.value) + end) + end) + end + + defp filter_outgoing(query, source_id, nil), + do: query |> Ash.Query.filter_input(source_id: source_id) + + defp filter_outgoing(query, source_id, alias_filter), + do: query |> Ash.Query.filter_input(source_id: source_id, alias: alias_filter) +end diff --git a/test/access/path_test.exs b/test/access/path_test.exs index 7b66877..1a135c7 100644 --- a/test/access/path_test.exs +++ b/test/access/path_test.exs @@ -109,8 +109,11 @@ defmodule DiffoExample.Access.PathTest do # now assign a port from a line card [_dslam, line_card] = create_dslam_with_line_card("QDONC-0001", tl(places), parties) + # path-as-assignee names its slot :port when requesting the port-assignment + # from the line card. This alias lets the InheritedCharacteristic calc + # traverse path → port → card (and transitively card → slot → shelf). Access.assign_port!(line_card, %{ - assignment: %Assignment{assignee_id: path.id, operation: :auto_assign} + assignment: %Assignment{assignee_id: path.id, alias: :port, operation: :auto_assign} }) # 5 cables each assigned a pair to the path, plus 1 line card assigned a port @@ -124,6 +127,19 @@ defmodule DiffoExample.Access.PathTest do {:ok, path} = Access.get_path_by_id(path.id) + # the path brings up its card and (transitively) its shelf via the + # port-then-slot assignment chain — each instance shows itself, the path + # also shows what's brought up from below. + {:ok, path_with_brought_up} = + Ash.load(path, [:card, :shelf, :port]) + + [%{family: :ISAM, model: "EBLT48", technology: :adsl2Plus}] = path_with_brought_up.card + + [%{device_name: "QDONC-0001", family: :ISAM, model: "ISAM7330", technology: :DSLAM}] = + path_with_brought_up.shelf + + assert path_with_brought_up.port == [1] + encoding = Jason.encode!(path) |> Diffo.Util.summarise_dates() @@ -220,18 +236,26 @@ defmodule DiffoExample.Access.PathTest do shelf = Access.define_shelf!(shelf, %{ - characteristic_value_updates: [slots: [first: 1, last: 10, assignable_type: "LineCard"]] + characteristic_value_updates: [ + shelf: [device_name: name, family: :ISAM, model: "ISAM7330", technology: :DSLAM], + slots: [first: 1, last: 10, assignable_type: "LineCard"] + ] }) card = Access.build_card!(%{name: "dslam line card #{name} 1"}) card = Access.define_card!(card, %{ - characteristic_value_updates: [ports: [first: 1, last: 48, assignable_type: "ADSL2+"]] + characteristic_value_updates: [ + card: [family: :ISAM, model: "EBLT48", technology: :adsl2Plus], + ports: [first: 1, last: 48, assignable_type: "ADSL2+"] + ] }) + # card-as-assignee names its slot :slot when requesting; alias lets + # downstream calculations traverse the assignment by name. Access.assign_slot!(shelf, %{ - assignment: %Assignment{assignee_id: card.id, operation: :auto_assign} + assignment: %Assignment{assignee_id: card.id, alias: :slot, operation: :auto_assign} }) [shelf, card] diff --git a/test/access/shelf_test.exs b/test/access/shelf_test.exs index 15703cf..0f31cce 100644 --- a/test/access/shelf_test.exs +++ b/test/access/shelf_test.exs @@ -153,6 +153,64 @@ defmodule DiffoExample.Access.ShelfTest do [psu1, psu2, transport1, transport2] end + test "shelf brings up its cards and total_ports" do + places = [create_esa_place()] + parties = [create_provider_party()] + + {:ok, shelf} = Access.build_shelf(%{name: "QDONC-0001", places: places, parties: parties}) + + {:ok, shelf} = + Access.define_shelf(shelf, %{ + characteristic_value_updates: [ + shelf: [device_name: "QDONC-1001", family: :ISAM, model: "ISAM7330", technology: :DSLAM], + slots: [first: 1, last: 10, assignable_type: "LineCard"] + ] + }) + + # Card A — 48 ports + {:ok, card_a} = Access.build_card(%{name: "card a"}) + + {:ok, card_a} = + Access.define_card(card_a, %{ + characteristic_value_updates: [ + card: [family: :ISAM, model: "EBLT48", technology: :adsl2Plus], + ports: [first: 1, last: 48, assignable_type: "ADSL2+"] + ] + }) + + # Card B — 24 ports + {:ok, card_b} = Access.build_card(%{name: "card b"}) + + {:ok, card_b} = + Access.define_card(card_b, %{ + characteristic_value_updates: [ + card: [family: :ISAM, model: "EBLT24", technology: :adsl2Plus], + ports: [first: 1, last: 24, assignable_type: "ADSL2+"] + ] + }) + + # Each card-as-assignee names its slot :slot when requesting. + {:ok, _shelf} = + Access.assign_slot(shelf, %{ + assignment: %Assignment{assignee_id: card_a.id, alias: :slot, operation: :auto_assign} + }) + + {:ok, shelf} = + Access.assign_slot(shelf, %{ + assignment: %Assignment{assignee_id: card_b.id, alias: :slot, operation: :auto_assign} + }) + + # Shelf brings up its cards (in slot order) and aggregates total ports. + {:ok, shelf_with_brought_up} = Ash.load(shelf, [:cards, :total_ports]) + + assert [ + %{family: :ISAM, model: "EBLT48", technology: :adsl2Plus}, + %{family: :ISAM, model: "EBLT24", technology: :adsl2Plus} + ] = shelf_with_brought_up.cards + + assert shelf_with_brought_up.total_ports == 72 + end + defp create_line_card(name) do card = Access.build_card!(%{name: "#{name}"})