Skip to content

Commit e2770e6

Browse files
committed
type and unwrap docs improvement, unwrap list, type livebook
1 parent be5f98e commit e2770e6

12 files changed

Lines changed: 450 additions & 8 deletions

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,12 @@ To get started you need a running instance of [Livebook](https://livebook.dev/)
5353

5454
[![Run in Livebook](https://livebook.dev/badge/v1/blue.svg)](https://livebook.dev/run?url=https%3A%2F%2Fgithub.com%2Fdiffo%2Ddev%2Fdiffo%2Fblob%2Fdev%2Fdiffo.livemd)
5555

56+
### Diffo.Type — no Neo4j required
57+
58+
Explore `Diffo.Type.Value`, `Diffo.Type.Primitive`, and `Diffo.Type.Dynamic` in pure Elixir without a database connection.
59+
60+
[![Run in Livebook](https://livebook.dev/badge/v1/blue.svg)](https://livebook.dev/run?url=https%3A%2F%2Fgithub.com%2Fdiffo%2Ddev%2Fdiffo%2Fblob%2Fdev%2Fdocumentation%2Fhow_to%2Fuse_diffo_type.livemd)
61+
5662

5763
## Future Work
5864

diffo.livemd

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ SPDX-License-Identifier: MIT
99
```elixir
1010
Mix.install(
1111
[
12-
{:diffo, "~> 0.1.6"}
12+
{:diffo, "~> 0.2.0"}
1313
],
1414
consolidate_protocols: false
1515
)

documentation/dsls/DSL-Diffo.Provider.Instance.Extension.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,9 @@ Configuration for Instance Features
5656
features do
5757
feature :dynamic_line_management do
5858
is_enabled? true
59-
characteristic :constraints, Diffo.Access.Constraints
59+
characteristics do
60+
characteristic :constraints, Diffo.Access.Constraints
61+
end
6062
end
6163
end
6264

documentation/how_to/use_diffo_provider_instance_extension.livemd

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ SPDX-License-Identifier: MIT
99
```elixir
1010
Mix.install(
1111
[
12-
{:diffo, "~> 0.1.6"}
12+
{:diffo, "~> 0.2.0"}
1313
],
1414
consolidate_protocols: false
1515
)
Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
<!--
2+
SPDX-FileCopyrightText: 2025 diffo contributors <https://github.com/diffo-dev/diffo/graphs.contributors>
3+
4+
SPDX-License-Identifier: MIT
5+
-->
6+
7+
# Using Diffo.Type
8+
9+
```elixir
10+
Mix.install(
11+
[
12+
#{:diffo, "~> 0.2.0"},
13+
{:diffo, path: "/Users/beanlanda/git/diffo", override: true}
14+
],
15+
consolidate_protocols: false
16+
)
17+
```
18+
19+
## Overview
20+
21+
`Diffo.Type` provides three complementary types for carrying values on Diffo resources:
22+
23+
* **`Diffo.Type.Primitive`** — a discriminated union of the standard TMF primitive types (string, integer, float, boolean, date, time, datetime, duration)
24+
* **`Diffo.Type.Dynamic`** — a runtime-typed wrapper for any `Ash.TypedStruct` or map-storage `Ash.Type.NewType`
25+
* **`Diffo.Type.Value`** — a union of Primitive or Dynamic; the attribute type used by `Diffo.Provider.Characteristic.value`
26+
27+
All three types can also be used as array element types — `{:array, Diffo.Type.Value}`, `{:array, Diffo.Type.Primitive}`, or `{:array, Diffo.Type.Dynamic}` — when an attribute needs to hold multiple values.
28+
29+
These types do not require a Neo4j connection. Everything in this livebook runs in pure Elixir.
30+
31+
```elixir
32+
alias Diffo.Type.Value
33+
alias Diffo.Type.Primitive
34+
alias Diffo.Type.Dynamic
35+
```
36+
37+
## Diffo.Unwrap protocol
38+
39+
`Diffo.Unwrap` is a protocol that extracts the underlying Elixir value from Diffo and Ash wrapper types. It is defined with `@fallback_to_any true`, so any value that does not have an explicit implementation is returned as-is.
40+
41+
The protocol is recursive: each implementation calls `Diffo.Unwrap.unwrap/1` on its inner value, so nested wrappers are peeled all the way down to the plain Elixir value in one call.
42+
43+
Built-in implementations:
44+
45+
| Type | Behaviour |
46+
| ---------------------- | ------------------------------- |
47+
| `Ash.Union` | delegates to inner `:value` |
48+
| `Diffo.Type.Primitive` | returns the primitive value |
49+
| `Diffo.Type.Dynamic` | delegates to inner `:value` |
50+
| `Ash.CiString` | returns the comparable string |
51+
| `Ash.NotLoaded` | raises — field was not loaded |
52+
| `List` | unwraps each element |
53+
| `Any` | returns the value unchanged |
54+
55+
### Implementing Diffo.Unwrap on your own types
56+
57+
If your domain defines a struct that wraps a value, implement the protocol to make it transparent to Diffo:
58+
59+
```elixir
60+
defmodule MyApp.Tagged do
61+
defstruct [:tag, :value]
62+
end
63+
64+
defimpl Diffo.Unwrap, for: MyApp.Tagged do
65+
def unwrap(%{value: value}), do: Diffo.Unwrap.unwrap(value)
66+
end
67+
```
68+
69+
Because the implementation calls `Diffo.Unwrap.unwrap/1` on the inner value, nesting works automatically — a `MyApp.Tagged` wrapping a `Diffo.Type.Primitive` unwraps all the way to the raw Elixir value:
70+
71+
```elixir
72+
tagged = %MyApp.Tagged{tag: "example", value: Primitive.wrap("integer", 7)}
73+
Diffo.Unwrap.unwrap(tagged)
74+
```
75+
76+
## Arrays
77+
78+
All three types work as array element types. `Diffo.Unwrap` handles lists by unwrapping each element, so a stored list of wrapped values reduces to a plain Elixir list in one call.
79+
80+
```elixir
81+
primitives = [
82+
Primitive.wrap("integer", 1),
83+
Primitive.wrap("integer", 2),
84+
Primitive.wrap("integer", 3)
85+
]
86+
87+
Diffo.Unwrap.unwrap(primitives)
88+
```
89+
90+
The same applies to `Value` — after a cast roundtrip, unwrapping the list gives back the raw values:
91+
92+
```elixir
93+
values = [Value.primitive("string", "a"), Value.primitive("string", "b")]
94+
95+
cast_values =
96+
Enum.map(values, fn v ->
97+
{:ok, cast} = Ash.Type.cast_input(Value, v, Value.subtype_constraints())
98+
cast
99+
end)
100+
101+
Diffo.Unwrap.unwrap(cast_values)
102+
```
103+
104+
## Primitive
105+
106+
`Diffo.Type.Primitive` wraps a single primitive value. Use `wrap/2` to construct one from a type name and a value. Use `Diffo.Unwrap.unwrap/1` to extract the value.
107+
108+
```elixir
109+
Primitive.wrap("string", "connectivity") |> Diffo.Unwrap.unwrap()
110+
```
111+
112+
```elixir
113+
Primitive.wrap("integer", 42) |> Diffo.Unwrap.unwrap()
114+
```
115+
116+
```elixir
117+
Primitive.wrap("float", 1.5) |> Diffo.Unwrap.unwrap()
118+
```
119+
120+
```elixir
121+
Primitive.wrap("boolean", false) |> Diffo.Unwrap.unwrap()
122+
```
123+
124+
### Temporal types
125+
126+
Date, time, datetime, and duration values are converted to ISO 8601 strings internally. This avoids nested serialisation issues when storing through AshNeo4j.
127+
128+
```elixir
129+
Primitive.wrap("date", ~D[2026-04-24]) |> Diffo.Unwrap.unwrap()
130+
```
131+
132+
```elixir
133+
Primitive.wrap("time", ~T[09:30:00]) |> Diffo.Unwrap.unwrap()
134+
```
135+
136+
```elixir
137+
Primitive.wrap("datetime", ~U[2026-04-24 09:30:00Z]) |> Diffo.Unwrap.unwrap()
138+
```
139+
140+
### Unknown types
141+
142+
`wrap/2` returns `nil` for unrecognised type names.
143+
144+
```elixir
145+
Primitive.wrap("unknown", "x")
146+
```
147+
148+
### Cast and dump roundtrip
149+
150+
The Primitive type integrates with the Ash type system.
151+
152+
```elixir
153+
value = Primitive.wrap("string", "connectivity")
154+
{:ok, cast} = Ash.Type.cast_input(Primitive, value, Primitive.subtype_constraints())
155+
{:ok, dumped} = Ash.Type.dump_to_native(Primitive, cast, Primitive.subtype_constraints())
156+
{:ok, result} = Ash.Type.cast_stored(Primitive, dumped, Primitive.subtype_constraints())
157+
Diffo.Unwrap.unwrap(result)
158+
```
159+
160+
## Value
161+
162+
`Diffo.Type.Value` is the union type used for `Diffo.Provider.Characteristic.value`. Use `Value.primitive/2` and `Value.dynamic/1` to construct values. Stored values are `%Ash.Union{}` structs — use `Diffo.Unwrap.unwrap/1` to extract the underlying Elixir value.
163+
164+
```elixir
165+
Value.primitive("string", "connectivity") |> Diffo.Unwrap.unwrap()
166+
```
167+
168+
```elixir
169+
Value.primitive("integer", 42) |> Diffo.Unwrap.unwrap()
170+
```
171+
172+
### Nil values
173+
174+
Setting a characteristic value to nil is fully supported. The `handle_change/3` override ensures Ash does not wrap nil in the previous member type.
175+
176+
```elixir
177+
Ash.Type.handle_change(Value, nil, nil, Value.subtype_constraints())
178+
```
179+
180+
```elixir
181+
old = %Ash.Union{type: :string, value: Primitive.wrap("string", "old")}
182+
Ash.Type.handle_change(Value, old, nil, Value.subtype_constraints())
183+
```
184+
185+
### Full roundtrip for a primitive Value
186+
187+
```elixir
188+
value = Value.primitive("float", 3.14)
189+
{:ok, cast} = Ash.Type.cast_input(Value, value, Value.subtype_constraints())
190+
{:ok, dumped} = Ash.Type.dump_to_native(Value, cast, Value.subtype_constraints())
191+
{:ok, result} = Ash.Type.cast_stored(Value, dumped, Value.subtype_constraints())
192+
Diffo.Unwrap.unwrap(result)
193+
```
194+
195+
## Dynamic
196+
197+
`Diffo.Type.Dynamic` carries a value whose type is known only at runtime. The `:type` field is the `Ash.Type.NewType` module; `:value` is the cast value.
198+
199+
Dynamic is limited to types with `storage_type: :map``Ash.TypedStruct` and `Ash.Type.NewType` subtypes of `:struct`, `:map`, `:union`, `:keyword`, or `:tuple`. Scalar Ash types such as `Ash.Type.Date` are not supported.
200+
201+
### Defining a typed struct
202+
203+
First, define a struct that will be the dynamic value. In a real application this is defined in your own domain — it does not need to be in Diffo itself.
204+
205+
```elixir
206+
defmodule MyApp.Patch do
207+
use Ash.TypedStruct
208+
209+
typed_struct do
210+
field :a_end, :integer, constraints: [min: 0]
211+
field :z_end, :integer, constraints: [min: 0]
212+
end
213+
end
214+
```
215+
216+
### Creating a Dynamic value
217+
218+
```elixir
219+
dynamic = %Dynamic{type: MyApp.Patch, value: %MyApp.Patch{a_end: 1, z_end: 42}}
220+
```
221+
222+
### Cast roundtrip
223+
224+
```elixir
225+
{:ok, cast} = Ash.Type.cast_input(Dynamic, dynamic, [])
226+
{:ok, dumped} = Ash.Type.dump_to_native(Dynamic, cast, [])
227+
{:ok, result} = Ash.Type.cast_stored(Dynamic, dumped, [])
228+
result
229+
```
230+
231+
### Unwrapping
232+
233+
```elixir
234+
Diffo.Unwrap.unwrap(result)
235+
```
236+
237+
### Using Dynamic inside Value
238+
239+
Wrap the dynamic value using `Value.dynamic/1`, then round-trip through the Value union.
240+
241+
```elixir
242+
value = Value.dynamic(%MyApp.Patch{a_end: 1, z_end: 42})
243+
{:ok, cast} = Ash.Type.cast_input(Value, value, Value.subtype_constraints())
244+
{:ok, dumped} = Ash.Type.dump_to_native(Value, cast, Value.subtype_constraints())
245+
{:ok, result} = Ash.Type.cast_stored(Value, dumped, Value.subtype_constraints())
246+
Diffo.Unwrap.unwrap(result)
247+
```
248+
249+
### Nil handling
250+
251+
```elixir
252+
{:ok, nil} = Ash.Type.cast_input(Dynamic, nil, [])
253+
{:ok, nil} = Ash.Type.dump_to_native(Dynamic, nil, [])
254+
{:ok, nil} = Ash.Type.cast_stored(Dynamic, nil, [])
255+
:ok
256+
```
257+
258+
### Constraint validation
259+
260+
Dynamic enforces the constraints defined on the inner type during casting. Here `MyApp.Patch` requires both fields to be `>= 0`, so passing a negative value returns an error:
261+
262+
```elixir
263+
invalid = %Dynamic{type: MyApp.Patch, value: %MyApp.Patch{a_end: -1, z_end: 42}}
264+
Ash.Type.cast_input(Dynamic, invalid, [])
265+
```
266+
267+
A valid value casts successfully:
268+
269+
```elixir
270+
valid = %Dynamic{type: MyApp.Patch, value: %MyApp.Patch{a_end: 0, z_end: 42}}
271+
Ash.Type.cast_input(Dynamic, valid, [])
272+
```
273+
274+
## Further reading
275+
276+
* [Diffo Livebook](../../diffo.livemd) — full tutorial including Neo4j setup and Provider resources
277+
* [Using Diffo Provider Instance Extension](./use_diffo_provider_instance_extension.livemd) — defining custom resources with typed characteristics

lib/diffo/type/dynamic.ex

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,31 @@ defmodule Diffo.Type.Dynamic do
55
@moduledoc """
66
Diffo - TMF Service and Resource Management with a difference
77
8-
Dynamic - an Ash.Type subtype_of :struct with dynamic Ash.Type.NewType typing
8+
`Diffo.Type.Dynamic` is an `Ash.Type.NewType` for values whose exact type is not known until
9+
runtime. The `:type` field holds the `Ash.Type.NewType` module and `:value` holds the cast value.
10+
11+
Dynamic is limited to types that have `storage_type: :map` — that is, `Ash.TypedStruct` and
12+
`Ash.Type.NewType` subtypes of `:struct`, `:map`, `:union`, `:keyword`, or `:tuple`.
13+
Scalar Ash types such as `Ash.Type.Date` or `Ash.Type.Decimal` are not supported.
14+
15+
In practice, `Diffo.Type.Dynamic` is used as a member of `Diffo.Type.Value` and is not
16+
typically used as a standalone attribute type.
17+
18+
## Nil handling
19+
20+
iex> Ash.Type.cast_input(Diffo.Type.Dynamic, nil, [])
21+
{:ok, nil}
22+
23+
iex> Ash.Type.dump_to_native(Diffo.Type.Dynamic, nil, [])
24+
{:ok, nil}
25+
26+
iex> Ash.Type.cast_stored(Diffo.Type.Dynamic, nil, [])
27+
{:ok, nil}
28+
29+
## Constraints
30+
31+
iex> Diffo.Type.Dynamic.dynamic_constraints(nil)
32+
[]
933
"""
1034

1135
defstruct [:type, :value]

lib/diffo/type/primitive.ex

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,36 @@ defmodule Diffo.Type.Primitive do
55
@moduledoc """
66
Diffo - TMF Service and Resource Management with a difference
77
8-
Primitive - an Ash.TypedStruct representing a single TMF primitive value.
9-
The :type field identifies which primitive field is populated.
8+
`Diffo.Type.Primitive` is a discriminated union of primitive types: string, integer, float,
9+
boolean, date, time, datetime, and duration.
10+
11+
Use `wrap/2` to construct a Primitive from a type name string and a value.
12+
Use `Diffo.Unwrap.unwrap/1` to extract the value.
13+
14+
> #### Temporal types {: .info}
15+
>
16+
> Date, time, datetime, and duration values are stored internally as ISO 8601 strings
17+
> to avoid nested serialisation issues. `Diffo.Unwrap.unwrap/1` returns the string form.
18+
19+
## Examples
20+
21+
iex> Diffo.Type.Primitive.wrap("string", "connectivity") |> Diffo.Unwrap.unwrap()
22+
"connectivity"
23+
24+
iex> Diffo.Type.Primitive.wrap("integer", 42) |> Diffo.Unwrap.unwrap()
25+
42
26+
27+
iex> Diffo.Type.Primitive.wrap("float", 3.14) |> Diffo.Unwrap.unwrap()
28+
3.14
29+
30+
iex> Diffo.Type.Primitive.wrap("boolean", false) |> Diffo.Unwrap.unwrap()
31+
false
32+
33+
iex> Diffo.Type.Primitive.wrap("date", ~D[2026-04-24]) |> Diffo.Unwrap.unwrap()
34+
"2026-04-24"
35+
36+
iex> Diffo.Type.Primitive.wrap("unknown", "x")
37+
nil
1038
"""
1139
use Ash.TypedStruct
1240

0 commit comments

Comments
 (0)