Skip to content

Commit be5f98e

Browse files
Merge pull request #71 from diffo-dev/68-dynamic-nil-safety
dynamic nil safety and deps update
2 parents 9640dd4 + 0873a67 commit be5f98e

8 files changed

Lines changed: 80 additions & 7 deletions

File tree

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,7 @@ diffo-*.tar
2424

2525
/.elixir_ls
2626

27-
.DS_Store
27+
.DS_Store
28+
29+
# Agent related
30+
.claude/*

CHANGELOG.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,27 @@ See [Conventional Commits](Https://conventionalcommits.org) for commit guideline
5959

6060
* fixed relationship enrichment inconsistent across neo4j versions
6161

62+
## [v0.2.0](https://github.com/diffo-dev/diffo/compare/v0.1.6...v0.2.0) (2026-04-24)
63+
64+
### Breaking Changes
65+
66+
* Updated to ash_neo4j 0.3.1 and bolty 0.0.10 — no database compatibility with prior versions due to significant changes in the data layer and Bolt protocol handling
67+
68+
### Features
69+
70+
* `Diffo.Type.Value` — union of `Diffo.Type.Primitive` and `Diffo.Type.Dynamic`, enabling mixed primitive and typed-struct values on characteristics and other resources
71+
* `Diffo.Type.Primitive` — typed union of string, integer, float, boolean, date, time, datetime, duration
72+
* `Diffo.Type.Dynamic` — runtime-typed struct for Ash.Type.NewType values
73+
74+
### Fixes
75+
76+
* `Diffo.Type.Value` nil update — override `handle_change/3` to prevent Ash union type from wrapping nil in the previous member type, which caused malformed JSON to be written to Neo4j
77+
* `Diffo.Type.Dynamic` nil safety — added nil clauses to `cast_stored/2` and `dump_to_native/2`
78+
79+
### Maintenance
80+
81+
* bolty 0.0.10 — native DateTime handling for both BOLT 4.x and BOLT 5.x
82+
6283
## [v0.1.6](https://github.com/diffo-dev/diffo/compare/v0.1.5...v0.1.6) (2026-03-19)
6384

6485
### Fixes

lib/diffo/type/dynamic.ex

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@ defmodule Diffo.Type.Dynamic do
9494
end
9595

9696
@impl true
97+
def cast_stored(nil, _constraints), do: {:ok, nil}
98+
9799
def cast_stored(%{"type" => type_string, "value" => value}, _constraints) do
98100
type = String.to_existing_atom(type_string)
99101
constraints = dynamic_constraints(type_string)
@@ -105,6 +107,8 @@ defmodule Diffo.Type.Dynamic do
105107
end
106108

107109
@impl true
110+
def dump_to_native(nil, _constraints), do: {:ok, nil}
111+
108112
def dump_to_native(%__MODULE__{type: type, value: value}, _constraints) do
109113
constraints = dynamic_constraints(type)
110114

lib/diffo/type/value.ex

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,9 @@ defmodule Diffo.Type.Value do
6565
storage: :type_and_value
6666
]
6767

68+
def handle_change(_old_value, nil, _constraints), do: {:ok, nil}
69+
def handle_change(old_value, new_value, constraints), do: super(old_value, new_value, constraints)
70+
6871
def primitive(type, value), do: Diffo.Type.Primitive.wrap(type, value)
6972

7073
def dynamic(%type{} = dynamic), do: dynamic(type, dynamic)

mix.exs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ defmodule Diffo.MixProject do
66
@moduledoc false
77
use Mix.Project
88

9-
@version "0.1.6"
9+
@version "0.2.0"
1010
@name "Diffo"
1111
@description "TMF Service and Resource Manager with a difference"
1212
@github_url "https://github.com/diffo-dev/diffo"
@@ -95,8 +95,7 @@ defmodule Diffo.MixProject do
9595
{:ash_outstanding, "~> 0.2.3"},
9696
{:ash_jason, "~> 3.0"},
9797
{:ash_state_machine, "~> 0.2.12"},
98-
# {:ash_neo4j, ash_neo4j_version("~> 0.2.15")},
99-
{:ash_neo4j, github: "diffo-dev/ash_neo4j", branch: "dev"},
98+
{:ash_neo4j, ash_neo4j_version("~> 0.3.1")},
10099
{:ash, ash_version("~> 3.0 and >= 3.24.2")},
101100
{:uuid, "~> 1.1"},
102101
{:igniter, ">= 0.6.29 and < 1.0.0-0",

mix.lock

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
%{
2-
"ash": {:hex, :ash, "3.24.2", "38beca133e0dcab07e3c8a7c26e573287ada26e8ba8d4c90ac692b52b34b0309", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 1.0", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.6.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.3", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3fd2a99504c1f58290efc3382501369ee9070098784925bdd7df9dbea8611d32"},
2+
"ash": {:hex, :ash, "3.24.3", "f7280a43c5e64f769a450f3dd59ace6dcd73edcdd0de7599815b1b31f59292fb", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 1.0", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.6.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.3", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c1022f8c549632137cbc8956f07bb4981405297f5abe7a752b4dffac175c3381"},
33
"ash_jason": {:hex, :ash_jason, "3.1.0", "84a88dfe5e25a20d55cf2d2664885cd086fa45871e8777aedc3ad96a282e2a6f", [:mix], [{:ash, ">= 3.6.2 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:spark, ">= 2.1.21 and < 3.0.0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "71e6bbc421fb2cf7079f8804814145cca458116c839fc798f9606b806e07eb2b"},
4-
"ash_neo4j": {:git, "https://github.com/diffo-dev/ash_neo4j.git", "044d9d123af30719a9f1f377e2c24b5cc8e21ea8", [branch: "dev"]},
4+
"ash_neo4j": {:hex, :ash_neo4j, "0.3.1", "52b81e870d020815ffb2699f3fa207e10e909418e80c8aec4c64ed668418299a", [:mix], [{:ash, ">= 3.24.2 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:bolty, ">= 0.0.10", [hex: :bolty, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "5da556d93e03fda97e1bb626941114b7011a64173b1c10deb12cf66523e82001"},
55
"ash_outstanding": {:hex, :ash_outstanding, "0.2.4", "c72b91f1b8e4859fb033eddf66d0ba36cfd8af0c2a9748c7ef9e6ccfdb5d093d", [:mix], [{:ash, ">= 3.6.2 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:outstanding, "~> 0.2.4", [hex: :outstanding, repo: "hexpm", optional: false]}], "hexpm", "64ba8f582ce69c9050352c75f0895db186c7a56f35039dab34c8e1ab7516f9ce"},
66
"ash_state_machine": {:hex, :ash_state_machine, "0.2.13", "e1c368ebf01ef73477739ee76d53e513d073b141ec11e7bf7f91d8f2d8fc9569", [:mix], [{:ash, ">= 3.4.66 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}], "hexpm", "aa21c92a8950850df69b5205bf41efc1e502f5ab839425ba08561f0421c9f226"},
7-
"bolty": {:hex, :bolty, "0.0.9", "c8026ce9804347f71e23b3a0cbc01b918ef94b61e159b5ba7fb48527878033ad", [:mix], [{:db_connection, "~> 2.7.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:poison, "~> 6.0", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm", "fc20c42550c0fce370276b4ef119e92792761b2fea1aef9cccf8de946bc39d35"},
7+
"bolty": {:hex, :bolty, "0.0.10", "ec88948d30cfc213cdb1168f86d96cdcadd80f16e4f29701966e69dfbac43ded", [:mix], [{:db_connection, "~> 2.7.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:poison, "~> 6.0", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm", "2ce63d6c23301d1c9a61fd29ef06ebb7d2e775d4fd4144e86c2717aa43f409c9"},
88
"crux": {:hex, :crux, "0.1.2", "4441c9e3a34f1e340954ce96b9ad5a2de13ceb4f97b3f910211227bb92e2ca90", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "563ea3748ebfba9cc078e6d198a1d6a06015a8fae503f0b721363139f0ddb350"},
99
"db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"},
1010
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},

test/type/dynamic_test.exs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,18 +18,43 @@ defmodule Diffo.Type.DynamicTest do
1818
Ash.Type.cast_input(Dynamic, value, [])
1919
end
2020

21+
test "cast_input nil" do
22+
assert {:ok, nil} = Ash.Type.cast_input(Dynamic, nil, [])
23+
end
24+
2125
test "dump_to_native" do
2226
value = %Dynamic{type: Patch, value: %Patch{aEnd: 1, zEnd: 42}}
2327
assert {:ok, _dumped} = Ash.Type.dump_to_native(Dynamic, value, [])
2428
end
2529

30+
test "dump_to_native nil" do
31+
assert {:ok, nil} = Ash.Type.dump_to_native(Dynamic, nil, [])
32+
end
33+
2634
test "cast_stored roundtrip" do
2735
value = %Dynamic{type: Patch, value: %Patch{aEnd: 1, zEnd: 42}}
2836
{:ok, dumped} = Ash.Type.dump_to_native(Dynamic, value, [])
2937

3038
assert {:ok, %Dynamic{type: Patch, value: %Patch{aEnd: 1, zEnd: 42}}} =
3139
Ash.Type.cast_stored(Dynamic, dumped, [])
3240
end
41+
42+
test "cast_stored nil" do
43+
assert {:ok, nil} = Ash.Type.cast_stored(Dynamic, nil, [])
44+
end
45+
46+
test "apply_constraints with valid struct" do
47+
value = %Dynamic{type: Patch, value: %Patch{aEnd: 1, zEnd: 42}}
48+
assert {:ok, ^value} = Ash.Type.apply_constraints(Dynamic, value, [])
49+
end
50+
51+
test "apply_constraints nil" do
52+
assert {:ok, nil} = Ash.Type.apply_constraints(Dynamic, nil, [])
53+
end
54+
55+
test "apply_constraints with invalid value" do
56+
assert {:error, _} = Ash.Type.apply_constraints(Dynamic, "not a dynamic", [])
57+
end
3358
end
3459

3560
describe "dynamic json" do

test/type/value_test.exs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,24 @@ defmodule Diffo.Type.ValueTest do
9898
{:ok, result} = Ash.Type.cast_stored(Value, dumped, Value.subtype_constraints())
9999
assert Diffo.Unwrap.unwrap(result) == %Patch{aEnd: 1, zEnd: 42}
100100
end
101+
102+
test "cast_input nil" do
103+
assert {:ok, nil} = Ash.Type.cast_input(Value, nil, Value.subtype_constraints())
104+
end
105+
106+
test "cast_stored nil" do
107+
assert {:ok, nil} = Ash.Type.cast_stored(Value, nil, Value.subtype_constraints())
108+
end
109+
110+
test "handle_change from primitive to nil" do
111+
old = %Ash.Union{type: :string, value: Primitive.wrap("string", "hello")}
112+
assert {:ok, nil} = Ash.Type.handle_change(Value, old, nil, Value.subtype_constraints())
113+
end
114+
115+
test "handle_change from nil to primitive" do
116+
new = %Ash.Union{type: :string, value: Primitive.wrap("string", "hello")}
117+
assert {:ok, ^new} = Ash.Type.handle_change(Value, nil, new, Value.subtype_constraints())
118+
end
101119
end
102120

103121
describe "value json" do

0 commit comments

Comments
 (0)