|
| 1 | +# SPDX-FileCopyrightText: 2025 diffo_example contributors <https://github.com/diffo-dev/diffo_example/graphs.contributors> |
| 2 | +# |
| 3 | +# SPDX-License-Identifier: MIT |
| 4 | + |
| 5 | +defmodule DiffoExample.Calculations.InheritedCharacteristicViaRelationship do |
| 6 | + @moduledoc """ |
| 7 | + Brings up typed characteristic values from target instances reached via |
| 8 | + forward `Diffo.Provider.Relationship` edges (source → target), optionally |
| 9 | + filtered by `type:` and/or `alias:`. |
| 10 | +
|
| 11 | + Sibling to `InheritedCharacteristicViaAssignment`, which performs the |
| 12 | + analogous traversal over `AssignmentRelationship` edges. Pick the right |
| 13 | + calc by the kind of edge being traversed — relationship vs. assignment. |
| 14 | +
|
| 15 | + Use this when the edge between the consuming instance and the target was |
| 16 | + created by a `:relate` action (a `Provider.Relationship` record). Use |
| 17 | + `InheritedCharacteristicViaAssignment` when the edge was created by the |
| 18 | + Assigner (an `AssignmentRelationship` record). |
| 19 | +
|
| 20 | + Local-to-this-repo for now. Worth yarning upstream alongside the |
| 21 | + assignment variant as a pair of provider-side calcs. |
| 22 | +
|
| 23 | + ## Options |
| 24 | +
|
| 25 | + - `characteristic_module:` *(required)* — the typed characteristic Ash |
| 26 | + resource on the final source (e.g. `NniCharacteristic`). The calc |
| 27 | + queries this resource by `instance_id` and returns the `.value`. |
| 28 | + - `type:` *(optional)* — filter relationships by type atom (e.g. `:contains`). |
| 29 | + - `alias:` *(optional)* — filter relationships by alias atom (e.g. `:avc`). |
| 30 | + - `then_via:` *(optional)* — list of consumer-alias atoms to walk back |
| 31 | + via `AssignmentRelationship` **after** the relationship hop. Each step |
| 32 | + walks back through the target's incoming assignments (`target_id + |
| 33 | + alias` identity, so each step has cardinality ≤1). Aliases name the |
| 34 | + upstream related resource each consumer is part of. Use this for mixed |
| 35 | + paths — one relationship hop followed by one or more assignment hops |
| 36 | + — e.g. PRI's `:cvc` bring-up: `:circuit` owns relationship, then `:cvc` |
| 37 | + assignment back to the CVC. |
| 38 | + - `singular?:` *(optional, default `false`)* — unwrap to a single value |
| 39 | + when the consumer expects a 1-cardinality result (e.g. PRI's `:avc` or |
| 40 | + `:uni` aliased owns-relationship). Declare the calc's return type as |
| 41 | + `:map` (rather than `{:array, :map}`) when using this option. |
| 42 | +
|
| 43 | + ## Examples |
| 44 | +
|
| 45 | + # NniGroup brings up the typed characteristic of every NNI it |
| 46 | + # comprises — forward traversal of :contains relationships, returns |
| 47 | + # a list of NniCharacteristic values. |
| 48 | + calculate :nnis, {:array, :map}, |
| 49 | + {DiffoExample.Calculations.InheritedCharacteristicViaRelationship, |
| 50 | + [type: :contains, characteristic_module: NniCharacteristic]} |
| 51 | +
|
| 52 | + # PRI brings up the singular AVC it owns — PRI calls this related |
| 53 | + # resource :circuit (its domain role), set as the alias on PRI's |
| 54 | + # owns relationship. |
| 55 | + calculate :avc, :map, |
| 56 | + {DiffoExample.Calculations.InheritedCharacteristicViaRelationship, |
| 57 | + [alias: :circuit, characteristic_module: AvcCharacteristic, singular?: true]} |
| 58 | +
|
| 59 | + # PRI brings up the singular CVC two-hop — :circuit owns relationship |
| 60 | + # from PRI to AVC, then back via the AVC's :cvc consumer-alias |
| 61 | + # assignment from CVC. |
| 62 | + calculate :cvc, :map, |
| 63 | + {DiffoExample.Calculations.InheritedCharacteristicViaRelationship, |
| 64 | + [alias: :circuit, then_via: [:cvc], |
| 65 | + characteristic_module: CvcCharacteristic, singular?: true]} |
| 66 | + """ |
| 67 | + use Ash.Resource.Calculation |
| 68 | + require Ash.Query |
| 69 | + |
| 70 | + @impl true |
| 71 | + def load(_query, _opts, _context), do: [] |
| 72 | + |
| 73 | + @impl true |
| 74 | + def calculate(records, opts, _context) do |
| 75 | + characteristic_module = Keyword.fetch!(opts, :characteristic_module) |
| 76 | + type_filter = Keyword.get(opts, :type) |
| 77 | + alias_filter = Keyword.get(opts, :alias) |
| 78 | + then_via = Keyword.get(opts, :then_via, []) |
| 79 | + singular? = Keyword.get(opts, :singular?, false) |
| 80 | + |
| 81 | + Enum.map(records, fn record -> |
| 82 | + target_ids = |
| 83 | + Diffo.Provider.Relationship |
| 84 | + |> filter_relationships(record.id, type_filter, alias_filter) |
| 85 | + |> Ash.read!(domain: Diffo.Provider) |
| 86 | + |> Enum.map(& &1.target_id) |
| 87 | + |
| 88 | + final_ids = walk_assignments(target_ids, then_via) |
| 89 | + |
| 90 | + values = |
| 91 | + Enum.flat_map(final_ids, fn id -> |
| 92 | + characteristic_module |
| 93 | + |> Ash.Query.filter_input(instance_id: id) |
| 94 | + |> Ash.Query.load(:value) |
| 95 | + |> Ash.read!() |
| 96 | + |> Enum.map(& &1.value) |
| 97 | + end) |
| 98 | + |
| 99 | + if singular?, do: List.first(values), else: values |
| 100 | + end) |
| 101 | + end |
| 102 | + |
| 103 | + # Walks back through incoming `AssignmentRelationship` records for each |
| 104 | + # id, following `target_id + alias` (identity, ≤1 source per step). |
| 105 | + defp walk_assignments(ids, []), do: ids |
| 106 | + |
| 107 | + defp walk_assignments(ids, [alias_step | rest]) do |
| 108 | + next_ids = |
| 109 | + Enum.flat_map(ids, fn id -> |
| 110 | + Diffo.Provider.AssignmentRelationship |
| 111 | + |> Ash.Query.filter_input(target_id: id, alias: alias_step) |
| 112 | + |> Ash.read!(domain: Diffo.Provider) |
| 113 | + |> Enum.map(& &1.source_id) |
| 114 | + end) |
| 115 | + |
| 116 | + walk_assignments(next_ids, rest) |
| 117 | + end |
| 118 | + |
| 119 | + defp filter_relationships(query, source_id, nil, nil), |
| 120 | + do: Ash.Query.filter_input(query, source_id: source_id) |
| 121 | + |
| 122 | + defp filter_relationships(query, source_id, type, nil), |
| 123 | + do: Ash.Query.filter_input(query, source_id: source_id, type: type) |
| 124 | + |
| 125 | + defp filter_relationships(query, source_id, nil, alias_name), |
| 126 | + do: Ash.Query.filter_input(query, source_id: source_id, alias: alias_name) |
| 127 | + |
| 128 | + defp filter_relationships(query, source_id, type, alias_name), |
| 129 | + do: Ash.Query.filter_input(query, source_id: source_id, type: type, alias: alias_name) |
| 130 | +end |
0 commit comments