diff --git a/README.md b/README.md index 0a7f57b..0b19036 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/diffo.livemd b/diffo.livemd index bea2be2..9fa9b38 100644 --- a/diffo.livemd +++ b/diffo.livemd @@ -9,7 +9,7 @@ SPDX-License-Identifier: MIT ```elixir Mix.install( [ - {:diffo, "~> 0.1.6"} + {:diffo, "~> 0.2.0"} ], consolidate_protocols: false ) diff --git a/documentation/dsls/DSL-Diffo.Provider.Instance.Extension.md b/documentation/dsls/DSL-Diffo.Provider.Instance.Extension.md index a42a05b..3a7a303 100644 --- a/documentation/dsls/DSL-Diffo.Provider.Instance.Extension.md +++ b/documentation/dsls/DSL-Diffo.Provider.Instance.Extension.md @@ -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 diff --git a/documentation/how_to/use_diffo_provider_instance_extension.livemd b/documentation/how_to/use_diffo_provider_instance_extension.livemd index f07df85..45348f3 100644 --- a/documentation/how_to/use_diffo_provider_instance_extension.livemd +++ b/documentation/how_to/use_diffo_provider_instance_extension.livemd @@ -9,7 +9,7 @@ SPDX-License-Identifier: MIT ```elixir Mix.install( [ - {:diffo, "~> 0.1.6"} + {:diffo, "~> 0.2.0"} ], consolidate_protocols: false ) diff --git a/documentation/how_to/use_diffo_type.livemd b/documentation/how_to/use_diffo_type.livemd new file mode 100644 index 0000000..19db48a --- /dev/null +++ b/documentation/how_to/use_diffo_type.livemd @@ -0,0 +1,277 @@ + + +# 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 diff --git a/lib/diffo/type/dynamic.ex b/lib/diffo/type/dynamic.ex index a12fd3d..53ea9a8 100644 --- a/lib/diffo/type/dynamic.ex +++ b/lib/diffo/type/dynamic.ex @@ -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] diff --git a/lib/diffo/type/primitive.ex b/lib/diffo/type/primitive.ex index 1294c85..7b596ad 100644 --- a/lib/diffo/type/primitive.ex +++ b/lib/diffo/type/primitive.ex @@ -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 diff --git a/lib/diffo/type/value.ex b/lib/diffo/type/value.ex index 7259eae..e3c8f5c 100644 --- a/lib/diffo/type/value.ex +++ b/lib/diffo/type/value.ex @@ -5,7 +5,31 @@ defmodule Diffo.Type.Value do @moduledoc """ Diffo - TMF Service and Resource Management with a difference - Value - an Ash.Type.NewType subtype_of :union representing a primitive or Dynamic value + `Diffo.Type.Value` is an `Ash.Type.NewType` union that holds either a `Diffo.Type.Primitive` + or a `Diffo.Type.Dynamic` value. + + It is the intended attribute type for `Diffo.Provider.Characteristic.value` and any resource + field that needs to carry a value whose type is known only at runtime. + + Use `primitive/2` to build a primitive value and `dynamic/1` to build a dynamic value. + Use `Diffo.Unwrap.unwrap/1` on the stored `%Ash.Union{}` to extract the underlying Elixir value. + + ## Examples + + iex> Diffo.Type.Value.primitive("string", "connectivity") |> Diffo.Unwrap.unwrap() + "connectivity" + + iex> Diffo.Type.Value.primitive("integer", 42) |> Diffo.Unwrap.unwrap() + 42 + + iex> Diffo.Type.Value.primitive("float", 3.14) |> Diffo.Unwrap.unwrap() + 3.14 + + iex> Diffo.Type.Value.primitive("boolean", true) |> Diffo.Unwrap.unwrap() + true + + iex> Diffo.Type.Value.primitive("date", ~D[2026-04-24]) |> Diffo.Unwrap.unwrap() + "2026-04-24" """ use Ash.Type.NewType, diff --git a/lib/diffo/unwrap.ex b/lib/diffo/unwrap.ex index 9a4b431..2af850d 100644 --- a/lib/diffo/unwrap.ex +++ b/lib/diffo/unwrap.ex @@ -6,7 +6,45 @@ defprotocol Diffo.Unwrap do @moduledoc """ Diffo - TMF Service and Resource Management with a difference - Unwrap - A Diffo protocol for unwrapping values + `Diffo.Unwrap` is a protocol for extracting the underlying Elixir value from Diffo and Ash + wrapper types. It is defined with `@fallback_to_any true`, so any value without an explicit + implementation is returned unchanged. + + By convention, implementations are recursive — each one calls `Diffo.Unwrap.unwrap/1` on its + inner value, so nested wrappers are fully peeled in a single call. + + Built-in implementations are provided for `Ash.Union`, `Ash.CiString`, `Ash.NotLoaded`, + `Diffo.Type.Primitive`, `Diffo.Type.Dynamic`, and `List`. + + ## Examples + + Any plain Elixir value is returned as-is: + + iex> Diffo.Unwrap.unwrap(42) + 42 + + iex> Diffo.Unwrap.unwrap("hello") + "hello" + + iex> Diffo.Unwrap.unwrap(nil) + nil + + A `Diffo.Type.Primitive` unwraps to the raw value: + + iex> Diffo.Type.Primitive.wrap("integer", 7) |> Diffo.Unwrap.unwrap() + 7 + + An `Ash.Union` wrapping a `Diffo.Type.Primitive` unwraps recursively: + + iex> %Ash.Union{type: :integer, value: Diffo.Type.Primitive.wrap("integer", 7)} + ...> |> Diffo.Unwrap.unwrap() + 7 + + A list of wrapped values is unwrapped element-by-element: + + iex> [Diffo.Type.Primitive.wrap("integer", 1), Diffo.Type.Primitive.wrap("integer", 2)] + ...> |> Diffo.Unwrap.unwrap() + [1, 2] """ @fallback_to_any true diff --git a/lib/diffo/unwrap/list.ex b/lib/diffo/unwrap/list.ex new file mode 100644 index 0000000..c3779eb --- /dev/null +++ b/lib/diffo/unwrap/list.ex @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defimpl Diffo.Unwrap, for: List do + def unwrap(list), do: Enum.map(list, &Diffo.Unwrap.unwrap/1) +end diff --git a/test/diffo_test.exs b/test/diffo_test.exs index 4bd2e52..b893a49 100644 --- a/test/diffo_test.exs +++ b/test/diffo_test.exs @@ -6,6 +6,10 @@ defmodule DiffoTest do @moduledoc false use ExUnit.Case doctest Diffo + doctest Diffo.Unwrap + doctest Diffo.Type.Primitive + doctest Diffo.Type.Value + doctest Diffo.Type.Dynamic doctest Diffo.Uuid doctest Diffo.Util doctest Diffo.Provider.Reference diff --git a/test/type/unwrap_test.exs b/test/type/unwrap_test.exs new file mode 100644 index 0000000..9c0c9db --- /dev/null +++ b/test/type/unwrap_test.exs @@ -0,0 +1,32 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.UnwrapTest do + use ExUnit.Case + + alias Diffo.Type.Primitive + alias Diffo.Type.Value + + describe "List" do + test "unwraps a list of primitives" do + list = [Primitive.wrap("integer", 1), Primitive.wrap("integer", 2)] + assert Diffo.Unwrap.unwrap(list) == [1, 2] + end + + test "unwraps a list of Value unions" do + list = [Value.primitive("string", "a"), Value.primitive("string", "b")] + {:ok, cast_a} = Ash.Type.cast_input(Value, Enum.at(list, 0), Value.subtype_constraints()) + {:ok, cast_b} = Ash.Type.cast_input(Value, Enum.at(list, 1), Value.subtype_constraints()) + assert Diffo.Unwrap.unwrap([cast_a, cast_b]) == ["a", "b"] + end + + test "returns plain values unchanged" do + assert Diffo.Unwrap.unwrap([1, 2, 3]) == [1, 2, 3] + end + + test "unwraps empty list" do + assert Diffo.Unwrap.unwrap([]) == [] + end + end +end