Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ To get started you need a running instance of [Livebook](https://livebook.dev/)

[![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)

### Diffo.Type — no Neo4j required

Explore `Diffo.Type.Value`, `Diffo.Type.Primitive`, and `Diffo.Type.Dynamic` in pure Elixir without a database connection.

[![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)


## Future Work

Expand Down
2 changes: 1 addition & 1 deletion diffo.livemd
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ SPDX-License-Identifier: MIT
```elixir
Mix.install(
[
{:diffo, "~> 0.1.6"}
{:diffo, "~> 0.2.0"}
],
consolidate_protocols: false
)
Expand Down
4 changes: 3 additions & 1 deletion documentation/dsls/DSL-Diffo.Provider.Instance.Extension.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,9 @@ Configuration for Instance Features
features do
feature :dynamic_line_management do
is_enabled? true
characteristic :constraints, Diffo.Access.Constraints
characteristics do
characteristic :constraints, Diffo.Access.Constraints
end
end
end

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ SPDX-License-Identifier: MIT
```elixir
Mix.install(
[
{:diffo, "~> 0.1.6"}
{:diffo, "~> 0.2.0"}
],
consolidate_protocols: false
)
Expand Down
277 changes: 277 additions & 0 deletions documentation/how_to/use_diffo_type.livemd
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
<!--
SPDX-FileCopyrightText: 2025 diffo contributors <https://github.com/diffo-dev/diffo/graphs.contributors>

SPDX-License-Identifier: MIT
-->

# Using Diffo.Type

```elixir
Mix.install(
[
#{:diffo, "~> 0.2.0"},
{:diffo, path: "/Users/beanlanda/git/diffo", override: true}
],
consolidate_protocols: false
)
```

## Overview

`Diffo.Type` provides three complementary types for carrying values on Diffo resources:

* **`Diffo.Type.Primitive`** — a discriminated union of the standard TMF primitive types (string, integer, float, boolean, date, time, datetime, duration)
* **`Diffo.Type.Dynamic`** — a runtime-typed wrapper for any `Ash.TypedStruct` or map-storage `Ash.Type.NewType`
* **`Diffo.Type.Value`** — a union of Primitive or Dynamic; the attribute type used by `Diffo.Provider.Characteristic.value`

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.

These types do not require a Neo4j connection. Everything in this livebook runs in pure Elixir.

```elixir
alias Diffo.Type.Value
alias Diffo.Type.Primitive
alias Diffo.Type.Dynamic
```

## Diffo.Unwrap protocol

`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.

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.

Built-in implementations:

| Type | Behaviour |
| ---------------------- | ------------------------------- |
| `Ash.Union` | delegates to inner `:value` |
| `Diffo.Type.Primitive` | returns the primitive value |
| `Diffo.Type.Dynamic` | delegates to inner `:value` |
| `Ash.CiString` | returns the comparable string |
| `Ash.NotLoaded` | raises — field was not loaded |
| `List` | unwraps each element |
| `Any` | returns the value unchanged |

### Implementing Diffo.Unwrap on your own types

If your domain defines a struct that wraps a value, implement the protocol to make it transparent to Diffo:

```elixir
defmodule MyApp.Tagged do
defstruct [:tag, :value]
end

defimpl Diffo.Unwrap, for: MyApp.Tagged do
def unwrap(%{value: value}), do: Diffo.Unwrap.unwrap(value)
end
```

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:

```elixir
tagged = %MyApp.Tagged{tag: "example", value: Primitive.wrap("integer", 7)}
Diffo.Unwrap.unwrap(tagged)
```

## Arrays

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.

```elixir
primitives = [
Primitive.wrap("integer", 1),
Primitive.wrap("integer", 2),
Primitive.wrap("integer", 3)
]

Diffo.Unwrap.unwrap(primitives)
```

The same applies to `Value` — after a cast roundtrip, unwrapping the list gives back the raw values:

```elixir
values = [Value.primitive("string", "a"), Value.primitive("string", "b")]

cast_values =
Enum.map(values, fn v ->
{:ok, cast} = Ash.Type.cast_input(Value, v, Value.subtype_constraints())
cast
end)

Diffo.Unwrap.unwrap(cast_values)
```

## Primitive

`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.

```elixir
Primitive.wrap("string", "connectivity") |> Diffo.Unwrap.unwrap()
```

```elixir
Primitive.wrap("integer", 42) |> Diffo.Unwrap.unwrap()
```

```elixir
Primitive.wrap("float", 1.5) |> Diffo.Unwrap.unwrap()
```

```elixir
Primitive.wrap("boolean", false) |> Diffo.Unwrap.unwrap()
```

### Temporal types

Date, time, datetime, and duration values are converted to ISO 8601 strings internally. This avoids nested serialisation issues when storing through AshNeo4j.

```elixir
Primitive.wrap("date", ~D[2026-04-24]) |> Diffo.Unwrap.unwrap()
```

```elixir
Primitive.wrap("time", ~T[09:30:00]) |> Diffo.Unwrap.unwrap()
```

```elixir
Primitive.wrap("datetime", ~U[2026-04-24 09:30:00Z]) |> Diffo.Unwrap.unwrap()
```

### Unknown types

`wrap/2` returns `nil` for unrecognised type names.

```elixir
Primitive.wrap("unknown", "x")
```

### Cast and dump roundtrip

The Primitive type integrates with the Ash type system.

```elixir
value = Primitive.wrap("string", "connectivity")
{:ok, cast} = Ash.Type.cast_input(Primitive, value, Primitive.subtype_constraints())
{:ok, dumped} = Ash.Type.dump_to_native(Primitive, cast, Primitive.subtype_constraints())
{:ok, result} = Ash.Type.cast_stored(Primitive, dumped, Primitive.subtype_constraints())
Diffo.Unwrap.unwrap(result)
```

## Value

`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.

```elixir
Value.primitive("string", "connectivity") |> Diffo.Unwrap.unwrap()
```

```elixir
Value.primitive("integer", 42) |> Diffo.Unwrap.unwrap()
```

### Nil values

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.

```elixir
Ash.Type.handle_change(Value, nil, nil, Value.subtype_constraints())
```

```elixir
old = %Ash.Union{type: :string, value: Primitive.wrap("string", "old")}
Ash.Type.handle_change(Value, old, nil, Value.subtype_constraints())
```

### Full roundtrip for a primitive Value

```elixir
value = Value.primitive("float", 3.14)
{:ok, cast} = Ash.Type.cast_input(Value, value, Value.subtype_constraints())
{:ok, dumped} = Ash.Type.dump_to_native(Value, cast, Value.subtype_constraints())
{:ok, result} = Ash.Type.cast_stored(Value, dumped, Value.subtype_constraints())
Diffo.Unwrap.unwrap(result)
```

## Dynamic

`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.

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.

### Defining a typed struct

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.

```elixir
defmodule MyApp.Patch do
use Ash.TypedStruct

typed_struct do
field :a_end, :integer, constraints: [min: 0]
field :z_end, :integer, constraints: [min: 0]
end
end
```

### Creating a Dynamic value

```elixir
dynamic = %Dynamic{type: MyApp.Patch, value: %MyApp.Patch{a_end: 1, z_end: 42}}
```

### Cast roundtrip

```elixir
{:ok, cast} = Ash.Type.cast_input(Dynamic, dynamic, [])
{:ok, dumped} = Ash.Type.dump_to_native(Dynamic, cast, [])
{:ok, result} = Ash.Type.cast_stored(Dynamic, dumped, [])
result
```

### Unwrapping

```elixir
Diffo.Unwrap.unwrap(result)
```

### Using Dynamic inside Value

Wrap the dynamic value using `Value.dynamic/1`, then round-trip through the Value union.

```elixir
value = Value.dynamic(%MyApp.Patch{a_end: 1, z_end: 42})
{:ok, cast} = Ash.Type.cast_input(Value, value, Value.subtype_constraints())
{:ok, dumped} = Ash.Type.dump_to_native(Value, cast, Value.subtype_constraints())
{:ok, result} = Ash.Type.cast_stored(Value, dumped, Value.subtype_constraints())
Diffo.Unwrap.unwrap(result)
```

### Nil handling

```elixir
{:ok, nil} = Ash.Type.cast_input(Dynamic, nil, [])
{:ok, nil} = Ash.Type.dump_to_native(Dynamic, nil, [])
{:ok, nil} = Ash.Type.cast_stored(Dynamic, nil, [])
:ok
```

### Constraint validation

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:

```elixir
invalid = %Dynamic{type: MyApp.Patch, value: %MyApp.Patch{a_end: -1, z_end: 42}}
Ash.Type.cast_input(Dynamic, invalid, [])
```

A valid value casts successfully:

```elixir
valid = %Dynamic{type: MyApp.Patch, value: %MyApp.Patch{a_end: 0, z_end: 42}}
Ash.Type.cast_input(Dynamic, valid, [])
```

## Further reading

* [Diffo Livebook](../../diffo.livemd) — full tutorial including Neo4j setup and Provider resources
* [Using Diffo Provider Instance Extension](./use_diffo_provider_instance_extension.livemd) — defining custom resources with typed characteristics
26 changes: 25 additions & 1 deletion lib/diffo/type/dynamic.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,31 @@ defmodule Diffo.Type.Dynamic do
@moduledoc """
Diffo - TMF Service and Resource Management with a difference

Dynamic - an Ash.Type subtype_of :struct with dynamic Ash.Type.NewType typing
`Diffo.Type.Dynamic` is an `Ash.Type.NewType` for values whose exact type is not known until
runtime. The `:type` field holds the `Ash.Type.NewType` module and `:value` holds the cast value.

Dynamic is limited to types that have `storage_type: :map` — that is, `Ash.TypedStruct` and
`Ash.Type.NewType` subtypes of `:struct`, `:map`, `:union`, `:keyword`, or `:tuple`.
Scalar Ash types such as `Ash.Type.Date` or `Ash.Type.Decimal` are not supported.

In practice, `Diffo.Type.Dynamic` is used as a member of `Diffo.Type.Value` and is not
typically used as a standalone attribute type.

## Nil handling

iex> Ash.Type.cast_input(Diffo.Type.Dynamic, nil, [])
{:ok, nil}

iex> Ash.Type.dump_to_native(Diffo.Type.Dynamic, nil, [])
{:ok, nil}

iex> Ash.Type.cast_stored(Diffo.Type.Dynamic, nil, [])
{:ok, nil}

## Constraints

iex> Diffo.Type.Dynamic.dynamic_constraints(nil)
[]
"""

defstruct [:type, :value]
Expand Down
32 changes: 30 additions & 2 deletions lib/diffo/type/primitive.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,36 @@ defmodule Diffo.Type.Primitive do
@moduledoc """
Diffo - TMF Service and Resource Management with a difference

Primitive - an Ash.TypedStruct representing a single TMF primitive value.
The :type field identifies which primitive field is populated.
`Diffo.Type.Primitive` is a discriminated union of primitive types: string, integer, float,
boolean, date, time, datetime, and duration.

Use `wrap/2` to construct a Primitive from a type name string and a value.
Use `Diffo.Unwrap.unwrap/1` to extract the value.

> #### Temporal types {: .info}
>
> Date, time, datetime, and duration values are stored internally as ISO 8601 strings
> to avoid nested serialisation issues. `Diffo.Unwrap.unwrap/1` returns the string form.

## Examples

iex> Diffo.Type.Primitive.wrap("string", "connectivity") |> Diffo.Unwrap.unwrap()
"connectivity"

iex> Diffo.Type.Primitive.wrap("integer", 42) |> Diffo.Unwrap.unwrap()
42

iex> Diffo.Type.Primitive.wrap("float", 3.14) |> Diffo.Unwrap.unwrap()
3.14

iex> Diffo.Type.Primitive.wrap("boolean", false) |> Diffo.Unwrap.unwrap()
false

iex> Diffo.Type.Primitive.wrap("date", ~D[2026-04-24]) |> Diffo.Unwrap.unwrap()
"2026-04-24"

iex> Diffo.Type.Primitive.wrap("unknown", "x")
nil
"""
use Ash.TypedStruct

Expand Down
Loading
Loading