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
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,7 @@ diffo-*.tar

/.elixir_ls

.DS_Store
.DS_Store

# Agent related
.claude/*
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions lib/diffo/type/dynamic.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)

Expand Down
3 changes: 3 additions & 0 deletions lib/diffo/type/value.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 2 additions & 3 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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",
Expand Down
6 changes: 3 additions & 3 deletions mix.lock
Original file line number Diff line number Diff line change
@@ -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"},
Expand Down
25 changes: 25 additions & 0 deletions test/type/dynamic_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,43 @@ 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, [])

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
Expand Down
18 changes: 18 additions & 0 deletions test/type/value_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading