v0.10.0
This release opened on functionality and pivoted to industrialisation and integrity: the write path is now atomic and constraint-backed, the read path gains a graph-native traversal expression, and the whole data layer returns typed errors rather than raising. The headline is graph traversal as a first-class Ash expression — a multi-hop path that pushes down to Cypher and composes with the rest of your filter, which a relational data layer structurally cannot offer.
Breaking Changes
AshNeo4j.Types.*→AshNeo4j.Type.*(#323) — the type namespace is renamed to singular, matchingAsh.Type. UpdateAshNeo4j.Types.Vector→AshNeo4j.Type.Vector(and any otherAshNeo4j.Types.*reference) in your attribute declarations. No storage or behaviour change.
Features
-
Graph traversal as an Ash expression (#321) —
traverse(^hop_chain, projection)expresses a multi-hop, direction-and-type-selected path inside anAsh.Expr, and the data layer pushes it down to a Cypher path pattern instead of an imperative load-time Elixir walk.hop_chainis a list of{:forward | :reverse, edge_selector}hops;edge_selectoris an Ash relationship name or an explicit{:edge, label}/{:edge, label, dest}. The reached node composes as a value in afilter:- reached-node field comparison —
filter(traverse(^chain, :status) == "active") - spatial composition (#330, #332) —
filter(st_dwithin(traverse(^chain, :location), ^point, 5_000))("services whose site is within 5 km of a point") in one query - membership / cardinality (#334) —
traverse(^chain, :exists) == true,traverse(^chain, :count) > 0 - field aggregates (#338) —
traverse(^chain, {:min | :max | :avg | :sum, :field}) <op> value - reverse-terminal node typing (#336)
This is radical for Ash: relational data layers model relationships as joins and have no notion of a path as an expression value — so this isn't parity work, it's a graph-native differentiator. The filter context ships now;
sort(#335),calculate/policy, and variable-length are tracked on the open epic #321. Seeusage-rules/traverse.md. - reached-node field comparison —
-
Read-time polymorphic projection —
AshNeo4j.Calculations.ProjectedTraversal+AshNeo4j.Unknown(#329) — a calculation that follows a hop chain and returns the reached node, late-binding its concrete type at read time. IntroducesAshNeo4j.Unknown, a first-class value complementary toAsh.NotLoaded:NotLoadedmeans "not fetched yet";Unknownmeans "reached, but couldn't be determined in the current view of the graph". Never collapse it intonil. -
Atomic & bulk writes (#361) — atomic updates render
changeset.atomicsstraight to a CypherSET(numeric, stringconcat/trim, and enum/atom forms); bulk update and destroy run as a singleupdate_query/4/destroy_query/4viaAsh.bulk_update/Ash.bulk_destroywithstrategy: :atomic; a single filtered (optimistic-lock) update or destroy whose guard no longer holds returnsAsh.Error.Changes.StaleRecordrather than a silent no-op. -
Atomic upsert (#379) — create-or-update keyed on an identity renders an atomic Cypher
MERGE, so concurrent upserts converge on one node instead of racing to duplicates. -
Identities & primary keys as Neo4j uniqueness constraints (#20, #32) —
AshNeo4j.Constraint.create_constraints/1buildsCREATE CONSTRAINT … IS UNIQUEfor every enforceable identity and for the primary key (single and composite, Community Edition). A conflict surfaces as Ash's ownAsh.Error.Changes.InvalidAttribute("has already been taken"), sopre_check?and its race window are no longer needed. Identities Neo4j can't enforce (nils_distinct?: false, filteredwhere:) are refused rather than silently unenforced. Like indexes, AshNeo4j runs no migrations on boot — you invoke the helper. Seeusage-rules/identities.md. -
Typed tensor attribute —
AshNeo4j.Type.NxTensor(#309) — a shape-and-element-typed tensor backed byNx.Tensor, rank 1 to 3 (vector / matrix / 3-tensor), stored row-major as a native propertyLIST(:property, default) or a base64 binary blob (:packed); neither type nor shape is stored — both are declared constraints recovered on read. Foundation slice of the hybrid tensor/compute epic (#308); structural ops areNx's own (the value is anNx.Tensor). -
Dynamic node labels (#339) — a capability + pattern-position render primitive letting a label be supplied at query time (Cypher 5 ≥ 5.26), groundwork for polymorphic-label reads.
-
Cypher fragment filter escape hatch (#33) —
cypher_fragment(...)drops a raw, parameterised Cypher predicate into afilterfor the rare case the expression surface can't reach, without abandoning the data layer. A caveated last resort, not a default. Seeusage-rules/cypher-fragments.md. -
Query results as Mermaid flowcharts (#60) —
AshNeo4j.Mermaidrenders a graph-level query result as a Mermaid diagram for docs and Livebooks. -
APOC availability healthcheck (#386) — detects whether APOC procedures are installed on the connected server, so APOC-dependent paths can degrade explicitly.
-
Nested arrays (#317) —
{:array, {:array, _}}round-trips via an outer nativeLISTwith inner JSON.
Improvements
-
The data layer returns typed errors, never raises (#342, #350, #358, #372) — every read/write-path failure is a returned
{:error, Splode}with a class, not a raised string. New errors:UnresolvableTraversal(a traverse filter that can't be formed — never a fabricated edge),GeoDimensionMismatch,Unsupported3DGeometry,RequiresCypher25. Neo4j server errors are surfaced and classified rather than flattened. Bare-string errors throughout the data layer are now typed (#372). -
Many-to-many modelled as back-to-back
has_manyfails fast (#127) — with a clear error pointing at a joiner resource node, instead of silently mis-relating. -
Guarded relationship attach/detach honours
changeset.filter(#368) — aStaleRecordon miss, consistent with the guarded update/destroy path. -
Type-check gate (#347) — CI compiles
--warnings-as-errorsand runs Dialyzer on a clean baseline; the test suite compiles warning-free. -
Logging unified (#373, #374) — one data-layer log format; "nothing deleted" demoted from error to debug.
-
CYPHER 5 sunset tripwire (#363) and
bolty0.2.0 (#362); toolchain bumped to Elixir 1.20 / OTP 29 and Neo4j 5 to 5.26.27 (#318, #320).