From 2c587a8e432a0dee2b37d1440350f6e2235409f2 Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Fri, 24 Apr 2026 16:03:28 +0930 Subject: [PATCH 1/2] dynamic nil safety and deps update --- CHANGELOG.md | 21 +++++++++++++++++++++ lib/diffo/type/dynamic.ex | 4 ++++ lib/diffo/type/value.ex | 3 +++ mix.exs | 5 ++--- mix.lock | 6 +++--- test/type/dynamic_test.exs | 25 +++++++++++++++++++++++++ test/type/value_test.exs | 18 ++++++++++++++++++ 7 files changed, 76 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b0f4db..36262ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,27 @@ See [Conventional Commits](Https://conventionalcommits.org) for commit guideline * fixed relationship enrichment inconsistent across neo4j versions +## [v0.2.0](https://github.com/diffo-dev/diffo/compare/v0.1.6...v0.2.0) (2026-04-24) + +### Breaking Changes + +* 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 + +### Features + +* `Diffo.Type.Value` — union of `Diffo.Type.Primitive` and `Diffo.Type.Dynamic`, enabling mixed primitive and typed-struct values on characteristics and other resources +* `Diffo.Type.Primitive` — typed union of string, integer, float, boolean, date, time, datetime, duration +* `Diffo.Type.Dynamic` — runtime-typed struct for Ash.Type.NewType values + +### Fixes + +* `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 +* `Diffo.Type.Dynamic` nil safety — added nil clauses to `cast_stored/2` and `dump_to_native/2` + +### Maintenance + +* bolty 0.0.10 — native DateTime handling for both BOLT 4.x and BOLT 5.x + ## [v0.1.6](https://github.com/diffo-dev/diffo/compare/v0.1.5...v0.1.6) (2026-03-19) ### Fixes diff --git a/lib/diffo/type/dynamic.ex b/lib/diffo/type/dynamic.ex index 80ba70e..a12fd3d 100644 --- a/lib/diffo/type/dynamic.ex +++ b/lib/diffo/type/dynamic.ex @@ -94,6 +94,8 @@ defmodule Diffo.Type.Dynamic do end @impl true + def cast_stored(nil, _constraints), do: {:ok, nil} + def cast_stored(%{"type" => type_string, "value" => value}, _constraints) do type = String.to_existing_atom(type_string) constraints = dynamic_constraints(type_string) @@ -105,6 +107,8 @@ defmodule Diffo.Type.Dynamic do end @impl true + def dump_to_native(nil, _constraints), do: {:ok, nil} + def dump_to_native(%__MODULE__{type: type, value: value}, _constraints) do constraints = dynamic_constraints(type) diff --git a/lib/diffo/type/value.ex b/lib/diffo/type/value.ex index aa91de1..7259eae 100644 --- a/lib/diffo/type/value.ex +++ b/lib/diffo/type/value.ex @@ -65,6 +65,9 @@ defmodule Diffo.Type.Value do storage: :type_and_value ] + def handle_change(_old_value, nil, _constraints), do: {:ok, nil} + def handle_change(old_value, new_value, constraints), do: super(old_value, new_value, constraints) + def primitive(type, value), do: Diffo.Type.Primitive.wrap(type, value) def dynamic(%type{} = dynamic), do: dynamic(type, dynamic) diff --git a/mix.exs b/mix.exs index 00d7bde..cefb48b 100644 --- a/mix.exs +++ b/mix.exs @@ -6,7 +6,7 @@ defmodule Diffo.MixProject do @moduledoc false use Mix.Project - @version "0.1.6" + @version "0.2.0" @name "Diffo" @description "TMF Service and Resource Manager with a difference" @github_url "https://github.com/diffo-dev/diffo" @@ -95,8 +95,7 @@ defmodule Diffo.MixProject do {:ash_outstanding, "~> 0.2.3"}, {:ash_jason, "~> 3.0"}, {:ash_state_machine, "~> 0.2.12"}, - # {:ash_neo4j, ash_neo4j_version("~> 0.2.15")}, - {:ash_neo4j, github: "diffo-dev/ash_neo4j", branch: "dev"}, + {:ash_neo4j, ash_neo4j_version("~> 0.3.1")}, {:ash, ash_version("~> 3.0 and >= 3.24.2")}, {:uuid, "~> 1.1"}, {:igniter, ">= 0.6.29 and < 1.0.0-0", diff --git a/mix.lock b/mix.lock index 15167ac..a4888a5 100644 --- a/mix.lock +++ b/mix.lock @@ -1,10 +1,10 @@ %{ - "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"}, + "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"}, "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"}, - "ash_neo4j": {:git, "https://github.com/diffo-dev/ash_neo4j.git", "044d9d123af30719a9f1f377e2c24b5cc8e21ea8", [branch: "dev"]}, + "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"}, "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"}, "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"}, - "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"}, + "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"}, "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"}, "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, diff --git a/test/type/dynamic_test.exs b/test/type/dynamic_test.exs index 2e534e7..77b10c0 100644 --- a/test/type/dynamic_test.exs +++ b/test/type/dynamic_test.exs @@ -18,11 +18,19 @@ defmodule Diffo.Type.DynamicTest do Ash.Type.cast_input(Dynamic, value, []) end + test "cast_input nil" do + assert {:ok, nil} = Ash.Type.cast_input(Dynamic, nil, []) + end + test "dump_to_native" do value = %Dynamic{type: Patch, value: %Patch{aEnd: 1, zEnd: 42}} assert {:ok, _dumped} = Ash.Type.dump_to_native(Dynamic, value, []) end + test "dump_to_native nil" do + assert {:ok, nil} = Ash.Type.dump_to_native(Dynamic, nil, []) + end + test "cast_stored roundtrip" do value = %Dynamic{type: Patch, value: %Patch{aEnd: 1, zEnd: 42}} {:ok, dumped} = Ash.Type.dump_to_native(Dynamic, value, []) @@ -30,6 +38,23 @@ defmodule Diffo.Type.DynamicTest do assert {:ok, %Dynamic{type: Patch, value: %Patch{aEnd: 1, zEnd: 42}}} = Ash.Type.cast_stored(Dynamic, dumped, []) end + + test "cast_stored nil" do + assert {:ok, nil} = Ash.Type.cast_stored(Dynamic, nil, []) + end + + test "apply_constraints with valid struct" do + value = %Dynamic{type: Patch, value: %Patch{aEnd: 1, zEnd: 42}} + assert {:ok, ^value} = Ash.Type.apply_constraints(Dynamic, value, []) + end + + test "apply_constraints nil" do + assert {:ok, nil} = Ash.Type.apply_constraints(Dynamic, nil, []) + end + + test "apply_constraints with invalid value" do + assert {:error, _} = Ash.Type.apply_constraints(Dynamic, "not a dynamic", []) + end end describe "dynamic json" do diff --git a/test/type/value_test.exs b/test/type/value_test.exs index 122e410..3ed3c4d 100644 --- a/test/type/value_test.exs +++ b/test/type/value_test.exs @@ -98,6 +98,24 @@ defmodule Diffo.Type.ValueTest do {:ok, result} = Ash.Type.cast_stored(Value, dumped, Value.subtype_constraints()) assert Diffo.Unwrap.unwrap(result) == %Patch{aEnd: 1, zEnd: 42} end + + test "cast_input nil" do + assert {:ok, nil} = Ash.Type.cast_input(Value, nil, Value.subtype_constraints()) + end + + test "cast_stored nil" do + assert {:ok, nil} = Ash.Type.cast_stored(Value, nil, Value.subtype_constraints()) + end + + test "handle_change from primitive to nil" do + old = %Ash.Union{type: :string, value: Primitive.wrap("string", "hello")} + assert {:ok, nil} = Ash.Type.handle_change(Value, old, nil, Value.subtype_constraints()) + end + + test "handle_change from nil to primitive" do + new = %Ash.Union{type: :string, value: Primitive.wrap("string", "hello")} + assert {:ok, ^new} = Ash.Type.handle_change(Value, nil, new, Value.subtype_constraints()) + end end describe "value json" do From 0873a67471d83c2f4610f0e3051e3318dc21497c Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Fri, 24 Apr 2026 16:08:35 +0930 Subject: [PATCH 2/2] gitignore --- .gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 03f6fe6..9481f4e 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,7 @@ diffo-*.tar /.elixir_ls -.DS_Store \ No newline at end of file +.DS_Store + +# Agent related +.claude/* \ No newline at end of file