Skip to content

Commit 84efbde

Browse files
committed
base characteristic
1 parent d678402 commit 84efbde

41 files changed

Lines changed: 667 additions & 260 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
# SPDX-FileCopyrightText: 2025 diffo contributors <https://github.com/diffo-dev/diffo/graphs.contributors>
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
defmodule Diffo.Provider.BaseCharacteristic do
6+
@moduledoc """
7+
Ash Resource Fragment which is the point of extension for typed TMF Characteristics.
8+
9+
`BaseCharacteristic` is the foundation for domain-specific Characteristic kinds.
10+
Include it as a fragment on an `Ash.Resource` to get a typed characteristic node
11+
in Neo4j with real Ash attributes — no `Ash.Type.Dynamic` required.
12+
13+
`Diffo.Provider.Characteristic` remains available as the generic dynamic option
14+
(storing values via `Diffo.Type.Value`); it includes `Characteristic.Extension` so
15+
the DSL verifier accepts it alongside typed resources.
16+
17+
## Usage
18+
19+
defmodule MyApp.CircuitCharacteristic do
20+
use Ash.Resource, fragments: [BaseCharacteristic], domain: MyApp.Domain
21+
22+
attributes do
23+
attribute :bandwidth_mbps, :integer, public?: true
24+
attribute :technology, :atom, public?: true
25+
end
26+
27+
actions do
28+
create :create do
29+
accept [:name, :bandwidth_mbps, :technology]
30+
argument :instance_id, :uuid
31+
argument :feature_id, :uuid
32+
end
33+
34+
update :update do
35+
accept [:bandwidth_mbps, :technology]
36+
end
37+
end
38+
39+
calculations do
40+
calculate :value, Diffo.Type.CharacteristicValue, Diffo.Provider.Calculations.CharacteristicValue do
41+
public? true
42+
end
43+
end
44+
45+
preparations do
46+
prepare build(load: [:value])
47+
end
48+
49+
jason do
50+
pick [:name, :value]
51+
compact true
52+
end
53+
end
54+
55+
## DSL declaration
56+
57+
provider do
58+
characteristics do
59+
characteristic :circuit, MyApp.CircuitCharacteristic
60+
end
61+
end
62+
63+
At build time a `CircuitCharacteristic` node is created and connected to the
64+
instance via an `:HAS` edge. The `name` attribute (e.g. `:circuit`) identifies
65+
the characteristic's role on the instance.
66+
67+
## Typed vs dynamic
68+
69+
| Style | DSL target | Neo4j node | Value storage |
70+
|-------|-----------|------------|---------------|
71+
| Typed | `BaseCharacteristic`-derived | per-type label (e.g. `:CircuitCharacteristic`) | direct Ash attributes |
72+
| Dynamic | `Diffo.Provider.Characteristic` | `:Characteristic` | `Diffo.Type.Value` dynamic |
73+
"""
74+
use Spark.Dsl.Fragment,
75+
of: Ash.Resource,
76+
otp_app: :diffo,
77+
domain: Diffo.Provider,
78+
data_layer: AshNeo4j.DataLayer,
79+
extensions: [
80+
AshJason.Resource,
81+
Diffo.Provider.Characteristic.Extension
82+
]
83+
84+
85+
neo4j do
86+
relate [
87+
{:instance, :HAS, :incoming, :Instance},
88+
{:feature, :HAS, :incoming, :Feature}
89+
]
90+
91+
guard [
92+
{:HAS, :incoming, :Instance},
93+
{:HAS, :incoming, :Feature}
94+
]
95+
end
96+
97+
attributes do
98+
uuid_primary_key :id do
99+
public? false
100+
end
101+
102+
attribute :name, :atom do
103+
description "the role name of this characteristic on the owning instance or feature"
104+
allow_nil? false
105+
public? true
106+
end
107+
108+
create_timestamp :created_at
109+
update_timestamp :updated_at
110+
end
111+
112+
relationships do
113+
belongs_to :instance, Diffo.Provider.Instance do
114+
allow_nil? true
115+
public? true
116+
end
117+
118+
belongs_to :feature, Diffo.Provider.Feature do
119+
allow_nil? true
120+
public? true
121+
end
122+
end
123+
124+
validations do
125+
validate present([:instance_id, :feature_id], at_most: 1) do
126+
message "characteristic must belong to at most one of an instance or feature"
127+
end
128+
end
129+
130+
actions do
131+
defaults [:read, :destroy]
132+
end
133+
end
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# SPDX-FileCopyrightText: 2025 diffo contributors <https://github.com/diffo-dev/diffo/graphs.contributors>
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
defmodule Diffo.Provider.Calculations.CharacteristicValue do
6+
@moduledoc false
7+
use Ash.Resource.Calculation
8+
9+
@impl true
10+
def load(_query, _opts, _context), do: []
11+
12+
@impl true
13+
def calculate(records, _opts, _context) do
14+
Enum.map(records, fn record ->
15+
value_module = Module.concat(record.__struct__, :Value)
16+
field_names = value_module |> struct() |> Map.from_struct() |> Map.keys()
17+
struct(value_module, Map.take(Map.from_struct(record), field_names))
18+
end)
19+
end
20+
end

lib/diffo/provider/components/characteristic.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ defmodule Diffo.Provider.Characteristic do
1010
otp_app: :diffo,
1111
domain: Diffo.Provider,
1212
data_layer: AshNeo4j.DataLayer,
13-
extensions: [AshOutstanding.Resource, AshJason.Resource]
13+
extensions: [AshOutstanding.Resource, AshJason.Resource, Diffo.Provider.Characteristic.Extension]
1414

1515
resource do
1616
description "An Ash Resource for a TMF Characteristic"
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# SPDX-FileCopyrightText: 2025 diffo contributors <https://github.com/diffo-dev/diffo/graphs.contributors>
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
defmodule Diffo.Provider.Characteristic.Extension do
6+
@moduledoc "Marker extension identifying a module as a valid characteristic resource."
7+
use Spark.Dsl.Extension, sections: []
8+
end

lib/diffo/provider/extension/characteristic.ex

Lines changed: 120 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,13 @@ defmodule Diffo.Provider.Extension.Characteristic do
1212

1313
defstruct [:name, :value_type, __spark_metadata__: nil]
1414

15+
# ── build_before: dynamic characteristics only ─────────────────────────────
16+
1517
def set_characteristics_argument(changeset, declarations)
1618
when is_struct(changeset, Ash.Changeset) and is_list(declarations) do
17-
case characteristics = create_characteristics_from_declarations(declarations, :instance) do
19+
dynamic = Enum.reject(declarations, &typed?(&1.value_type))
20+
21+
case characteristics = create_characteristics_from_declarations(dynamic, :instance) do
1822
[] ->
1923
changeset
2024

@@ -54,6 +58,8 @@ defmodule Diffo.Provider.Extension.Characteristic do
5458
end)
5559
end
5660

61+
# ── build_after: relate dynamic, create typed ──────────────────────────────
62+
5763
def relate_instance(result, changeset)
5864
when is_struct(result) and is_struct(changeset, Ash.Changeset) do
5965
characteristics = Ash.Changeset.get_argument(changeset, :characteristics)
@@ -63,72 +69,134 @@ defmodule Diffo.Provider.Extension.Characteristic do
6369
})
6470
end
6571

72+
def create_typed(result, declarations) when is_struct(result) and is_list(declarations) do
73+
typed = Enum.filter(declarations, &typed?(&1.value_type))
74+
75+
Enum.reduce_while(typed, {:ok, result}, fn %{name: name, value_type: module}, {:ok, acc} ->
76+
case module
77+
|> Ash.Changeset.for_create(:create, %{name: name, instance_id: acc.id})
78+
|> Ash.create() do
79+
{:ok, _} -> {:cont, {:ok, acc}}
80+
{:error, error} -> {:halt, {:error, error}}
81+
end
82+
end)
83+
end
84+
85+
# ── update: handle both typed and dynamic characteristics ──────────────────
86+
6687
def update_values(result, changeset)
6788
when is_struct(result) and is_struct(changeset, Ash.Changeset) do
89+
update_all(result, changeset, [])
90+
end
91+
92+
def update_all(result, changeset, declarations)
93+
when is_struct(result) and is_struct(changeset, Ash.Changeset) and is_list(declarations) do
6894
characteristic_value_updates =
6995
Ash.Changeset.get_argument(changeset, :characteristic_value_updates)
7096

7197
case characteristic_value_updates do
72-
nil ->
73-
{:ok, result}
98+
nil -> {:ok, result}
99+
[] -> {:ok, result}
100+
_ -> apply_updates(result, characteristic_value_updates, declarations)
101+
end
102+
end
74103

75-
[] ->
76-
{:ok, result}
104+
defp apply_updates(result, updates, declarations) do
105+
Enum.reduce_while(updates, {:ok, result}, fn {name, update}, {:ok, acc} ->
106+
decl = Enum.find(declarations, &(&1.name == name))
77107

78-
_ ->
79-
characteristic_updates =
80-
Enum.reduce(characteristic_value_updates, [], fn {name, update}, acc ->
81-
characteristic =
82-
Enum.find(changeset.data.characteristics, fn %{name: n} -> n == name end)
83-
84-
if characteristic do
85-
cond do
86-
is_list(update) ->
87-
unwrapped = Diffo.Unwrap.unwrap(characteristic.value)
88-
value_type = unwrapped.__struct__
89-
90-
updated =
91-
Enum.reduce(update, unwrapped, fn {field, val}, acc ->
92-
Map.put(acc, field, val)
93-
end)
94-
95-
new_value = Value.dynamic(struct(value_type, Map.from_struct(updated)))
96-
[{characteristic, new_value} | acc]
97-
98-
true ->
99-
[{characteristic, update} | acc]
100-
end
101-
else
102-
Logger.warning("couldn't find characteristic #{name}")
103-
acc
104-
end
105-
end)
106-
107-
characteristics =
108-
Enum.reduce_while(characteristic_updates, [], fn {characteristic, value}, acc ->
109-
case Provider.update_characteristic(characteristic, %{value: value}) do
110-
{:ok, characteristic} ->
111-
{:cont, [characteristic | acc]}
112-
113-
{:error, error} ->
114-
{:halt, {:error, error}}
115-
end
116-
end)
117-
118-
case characteristics do
119-
{:error, error} ->
120-
{:error, error}
108+
if decl && typed?(decl.value_type) do
109+
apply_typed_update(acc, name, decl.value_type, update)
110+
else
111+
apply_dynamic_update(acc, name, update)
112+
end
113+
end)
114+
end
121115

122-
[] ->
123-
{:error, "couldn't update characteristics"}
116+
defp apply_typed_update(result, name, module, field_updates) do
117+
case module
118+
|> Ash.Query.new()
119+
|> Ash.Query.filter_input(instance_id: result.id, name: name)
120+
|> Ash.read_one() do
121+
{:ok, nil} ->
122+
Logger.warning("couldn't find typed characteristic #{name}")
123+
{:cont, {:ok, result}}
124+
125+
{:ok, char} ->
126+
attrs = if is_list(field_updates), do: Map.new(field_updates), else: field_updates
127+
128+
case char
129+
|> Ash.Changeset.for_update(:update, attrs)
130+
|> Ash.update() do
131+
{:ok, _} -> {:cont, {:ok, result}}
132+
{:error, error} -> {:halt, {:error, error}}
133+
end
124134

125-
_ ->
126-
{:ok, Map.put(result, :characteristics, characteristics)}
135+
{:error, error} ->
136+
{:halt, {:error, error}}
137+
end
138+
end
139+
140+
defp apply_dynamic_update(result, name, update) do
141+
characteristic = Enum.find(result.characteristics, fn %{name: n} -> n == name end)
142+
143+
if characteristic do
144+
new_value =
145+
cond do
146+
is_list(update) ->
147+
unwrapped = Diffo.Unwrap.unwrap(characteristic.value)
148+
value_type = unwrapped.__struct__
149+
150+
updated =
151+
Enum.reduce(update, unwrapped, fn {field, val}, acc ->
152+
Map.put(acc, field, val)
153+
end)
154+
155+
Value.dynamic(struct(value_type, Map.from_struct(updated)))
156+
157+
true ->
158+
update
127159
end
160+
161+
case Provider.update_characteristic(characteristic, %{value: new_value}) do
162+
{:ok, updated_char} ->
163+
updated_chars =
164+
Enum.map(result.characteristics, fn c ->
165+
if c.id == updated_char.id, do: updated_char, else: c
166+
end)
167+
168+
{:cont, {:ok, %{result | characteristics: updated_chars}}}
169+
170+
{:error, error} ->
171+
{:halt, {:error, error}}
172+
end
173+
else
174+
Logger.warning("couldn't find characteristic #{name}")
175+
{:cont, {:ok, result}}
128176
end
129177
end
130178

131179
defimpl String.Chars do
132180
def to_string(struct), do: inspect(struct)
133181
end
182+
183+
# ── helpers ────────────────────────────────────────────────────────────────
184+
185+
def typed?(module) when is_atom(module) and not is_nil(module) do
186+
case Code.ensure_loaded(module) do
187+
{:module, _} ->
188+
try do
189+
module != Diffo.Provider.Characteristic and
190+
Diffo.Provider.Characteristic.Extension in Ash.Resource.Info.extensions(module)
191+
rescue
192+
_ -> false
193+
end
194+
195+
_ ->
196+
false
197+
end
198+
end
199+
200+
def typed?(_), do: false
201+
134202
end

0 commit comments

Comments
 (0)