Current section

Files

Jump to
ash_neo4j lib error.ex
Raw

lib/error.ex

# SPDX-FileCopyrightText: 2025 ash_neo4j contributors <https://github.com/diffo-dev/ash_neo4j/graphs.contributors>
#
# SPDX-License-Identifier: MIT
defmodule AshNeo4j.Error.RequiresCypher25 do
@moduledoc """
Returned (never raised) when a Cypher 25 operation is attempted against a Neo4j
server older than 2025.06. Upgrade to Neo4j 2025.06 or later to use this feature.
"""
use Splode.Error, fields: [], class: :invalid
def message(_), do: "This operation requires Cypher 25 (Neo4j ≥ 2025.06)"
end
defmodule AshNeo4j.Error.RequiresDynamicLabels do
@moduledoc """
Returned (never raised) when a dynamic label/type operation (`MATCH (n:$(expr))`,
`-[r:$(expr)]->`) is attempted against a Neo4j server older than 5.26. This is
the **server-feature axis** — dynamic labels run under plain `CYPHER 5` and are
unrelated to the `CYPHER 25` selector (`AshNeo4j.Error.RequiresCypher25`).
Upgrade to Neo4j 5.26 or later to use this feature.
"""
use Splode.Error, fields: [], class: :invalid
def message(_), do: "This operation requires dynamic labels (Neo4j ≥ 5.26)"
end
defmodule AshNeo4j.Error.UnsupportedChangesetFilter do
@moduledoc """
**Returned** (never raised) when a `changeset.filter` guard (the "only-update-if"
predicate) can't be honoured (#361). We refuse rather than apply the write
**unguarded**: silently dropping the guard would write when the predicate says
not to (the write-side analogue of the traverse synthesis leaks, #342).
Two cases today:
* **update** — the guard doesn't reduce to a conjunction of supported attribute
predicates (e.g. it contains `or`/`not`, a calculation, or a predicate the
pushdown doesn't cover). Narrow it, or do the conditional update outside the
data layer.
* **destroy** — a filtered destroy is not supported yet (the `destroy_query`
decision point is deferred); the whole guard is refused.
"""
use Splode.Error, fields: [:resource, :filter], class: :invalid
def message(%{resource: resource, filter: filter}) do
"cannot honour the changeset filter guard on #{inspect(resource)} (#{inspect(filter)})"
end
end
defmodule AshNeo4j.Error.UnsupportedAtomic do
@moduledoc """
**Returned** (never raised) when a `changeset.atomics` expression can't be
rendered into the update Cypher `SET` (#361). The renderer covers arithmetic
(`+ - * /`), comparisons, `if/3` (⇒ `CASE`), string concat / `string_trim`
(Ash's string cast), attribute refs and scalar literals; an expression using any
other Ash function is refused rather than mis-written.
Express the change with supported operations, or perform it outside the data layer.
"""
use Splode.Error, fields: [:resource, :expression], class: :invalid
def message(%{resource: resource, expression: expression}) do
"cannot render atomic update on #{inspect(resource)} into Cypher (#{inspect(expression)})"
end
end
defmodule AshNeo4j.Error.GeoDimensionMismatch do
@moduledoc """
Returned (never raised) when a spatial operation combines geometries of
different coordinate dimensions (2D vs 3D) — e.g. a 3D `%Geo.PointZ{}` against
a 2D attribute, or vice versa. Neo4j silently returns `null` for mixed-CRS
operations (which then drops rows in a `WHERE`), so AshNeo4j refuses up front
(#270) by returning `{:error, error}`.
Bridge worlds explicitly with a downward projection
(`AshNeo4j.Geo.force_2d/1`) — collapse the 3D operand to its 2D footprint
and evaluate in 2D. There is no implicit 2D→3D lift (a height cannot be
fabricated).
"""
use Splode.Error, fields: [:attr_dim, :value_dim], class: :invalid
def message(%{attr_dim: attr_dim, value_dim: value_dim}) do
"spatial dimension mismatch: a #{value_dim}D value against a #{attr_dim}D attribute. " <>
"Project the 3D operand to 2D with AshNeo4j.Geo.force_2d/1 to evaluate in the 2D world, " <>
"or use genuinely matching dimensions."
end
end
defmodule AshNeo4j.Error.Unsupported3DGeometry do
@moduledoc """
Returned (never raised) when a 3D areal or linear geometry (`%Geo.PolygonZ{}`,
`%Geo.LineStringZ{}`, …) is written. #270 Phase 1 supports 3D **points**
(`%Geo.PointZ{}`) only; 3D areal/linear geometries are deferred to Phase 2,
because exact 3D containment/distance needs a model the 2D `topo` refinement
cannot provide. Storing 2D bbox companions would silently drop the z.
"""
use Splode.Error, fields: [:geometry], class: :invalid
def message(%{geometry: geometry}) do
"#{inspect(geometry)} (3D areal/linear geometry) is not supported yet — #270 Phase 1 covers " <>
"Geo.PointZ only. Use a 2D geometry, or project to 2D, until 3D areal support lands."
end
end
defmodule AshNeo4j.Error.UnresolvableTraversal do
@moduledoc """
**Returned** (never raised) when a `traverse(^chain, …)` filter predicate can't
be formed because the current graph view can't resolve part of it — a reached
node whose label resolves to no loaded resource, or a field that isn't a
mapped property of the reached resource.
The filter-context counterpart to `AshNeo4j.Unknown` (the value-context
"couldn't determine"): same `{world, reason, context}` shape. A data layer
returns this as `{:error, error}` and never raises — `:reason` is a structural
atom, `:context` is diagnostic.
"""
use Splode.Error, fields: [:world, :reason, :context], class: :invalid
def message(%{world: world, reason: reason, context: context}) do
base = "cannot push down a traverse filter on #{inspect(world)}#{reason}"
if context in [nil, %{}], do: base, else: base <> " (#{inspect(context)})"
end
end
defmodule AshNeo4j.Error.UnsupportedFilterFragment do
@moduledoc """
**Returned** (never raised) when a `fragment(...)` filter (the Cypher escape
hatch, #33) can't be pushed down — and, because raw Cypher can't be evaluated in
Elixir, it can't fall back to in-memory filtering either, so the data layer
refuses rather than silently return wrong results.
Two cases today:
* the fragment is **combined with other filter conditions** (it isn't the whole
filter) — only a filter that is a single `fragment(...)` is pushed down so far;
* a `?` argument is **not a plain attribute reference or a literal** (e.g. a
related field, calculation or nested expression).
Make the fragment the whole filter and reference attributes (`?` with an attribute)
or literals, or express the condition without a fragment.
"""
use Splode.Error, fields: [:resource, :reason], class: :invalid
def message(%{resource: resource, reason: reason}) do
"cannot push down a fragment filter on #{inspect(resource)}#{explain(reason)}."
end
defp explain(:combined),
do: "the fragment is combined with other filter conditions; only a filter that is a single fragment(...) is supported"
defp explain({:unsupported_argument, arg}),
do: "a fragment argument must be an attribute reference or a literal, got #{inspect(arg)}"
defp explain(other), do: inspect(other)
end
defmodule AshNeo4j.Error.UnsupportedManyToMany do
@moduledoc """
**Returned** (never raised) when a relationship write resolves to a many-to-many
modelled as **back-to-back `has_many`** — two resources each declaring a
`has_many` to the other over one edge (#127). Ash anchors a `has_many` by an
attribute on the *related* resource; with a `has_many` on both sides there is no
to-one side to anchor the connect, so the data layer can't determine which node
to relate. The edge itself already represents the many-to-many; the model just
needs a to-one side on each hop to anchor the connect.
Model many-to-many with a **joiner node** instead — two to-one (`belongs_to`)
hops through a join resource (`Source →[E1] Join →[E2] Dest`); see the
`Party → PlaceRef → Place` example. Native `many_to_many` is tracked in #370.
"""
use Splode.Error, fields: [:resource, :related, :relationship], class: :invalid
def message(%{resource: resource, related: related, relationship: relationship}) do
"cannot relate #{inspect(resource)} to #{inspect(related)} via :#{relationship} — this is a " <>
"many-to-many modelled as back-to-back has_many (a has_many on both sides of one edge), which " <>
"leaves no to-one side to anchor the connect. Model it with a joiner node (two to-one hops " <>
"through a join resource; see Party → PlaceRef → Place). Native many_to_many is tracked in #370."
end
end
defmodule AshNeo4j.Error.UnsupportedIdentity do
@moduledoc """
**Returned** (never raised) when an `identity` can't be enforced as a Neo4j
uniqueness constraint (#20), so it can't be created by `AshNeo4j.Constraint`
and is refused rather than skipped, since silently leaving it unenforced would
permit the duplicate/split records the constraint exists to prevent.
Two cases Neo4j's `REQUIRE … IS UNIQUE` can't express:
* `nils_distinct?: false` — Neo4j treats `nil`s as distinct (a node missing a
listed property is not constrained) and can't be made to treat them as equal.
* a **filtered** identity (`where:`) — Neo4j has no partial/filtered constraint.
Drop the unsupported option, or enforce that identity another way (it won't fall
back to a database constraint). The same refusal is raised at compile time by
`AshNeo4j.Verifiers.VerifyIdentities`.
"""
use Splode.Error, fields: [:resource, :identity, :keys, :reason], class: :invalid
# Framed in Ash terms (resource, identity, attributes, identity options) — the
# Ash user shouldn't need to reason about the graph mapping; the *why* (Neo4j's
# constraint limitations) lives in the moduledoc above, for maintainers.
def message(%{resource: resource, identity: identity, keys: keys, reason: reason}) do
"cannot enforce uniqueness for identity :#{identity} (#{inspect(keys)}) on #{inspect(resource)} — " <>
"the AshNeo4j data layer doesn't support #{explain(reason)}. Drop the option, or enforce that " <>
"identity another way (#20)."
end
defp explain(:nils_not_distinct), do: "identities with `nils_distinct?: false`"
defp explain(:filtered), do: "filtered identities (`where:`)"
defp explain(other), do: inspect(other)
end
defmodule AshNeo4j.Error.Internal do
@moduledoc """
**Returned** (never raised) for an internal invariant the data layer couldn't
satisfy — an input shape it didn't expect, or a relationship/aggregate path it
couldn't resolve (#372). Distinct from the `Unsupported*` / `Requires*` family
(deliberate refusals, `:invalid`) and from `Neo4j` (server errors): this is "we
reached a state we shouldn't have", so its class is `:unknown`.
Carries a `:detail` string (and optional `:context`) for diagnosis. A typed,
matchable replacement for the bare-string `{:error, "…"}` returns the data layer
used to leak — which Ash wrapped as `Ash.Error.Unknown.UnknownError`, unclassified
and only substring-matchable.
"""
use Splode.Error, fields: [:detail, :context], class: :unknown
def message(%{detail: detail, context: context}) when context not in [nil, %{}] do
"AshNeo4j internal error: #{detail} (#{inspect(context)})"
end
def message(%{detail: detail}), do: "AshNeo4j internal error: #{detail}"
end
defmodule AshNeo4j.Error.Neo4j do
@moduledoc """
Wraps a Neo4j server error surfaced from `AshNeo4j.Cypher.run/1` (a
`%Bolty.Error{}`), preserving its Neo4j status code and message and classifying
it by `:category` (#358) — so a failure surfaces as the actual error, not a
generic string. The server-side complement to the returned-error work (#350).
`:category` is one of `:constraint` (a `ConstraintValidationFailed` — what
`identities` / primary-key constraints rely on), `:transient` (retryable, e.g.
a deadlock), `:statement` (a syntax/parameter error — our generated Cypher is
wrong), `:security`, or `:other`.
"""
use Splode.Error, fields: [:neo4j_code, :neo4j_message, :category], class: :invalid
def message(%{neo4j_code: nil, neo4j_message: msg}), do: "Neo4j error: #{msg}"
def message(%{neo4j_code: code, neo4j_message: nil}), do: "Neo4j #{code}"
def message(%{neo4j_code: code, neo4j_message: msg}), do: "Neo4j #{code}: #{msg}"
@doc """
Maps an error surfaced from `Cypher.run/1` to a classified `AshNeo4j.Error.Neo4j`.
A `%Bolty.Error{}` with a Neo4j status code is classified by that code; any
other error term is wrapped with `category: :other`.
"""
def from_bolt(%{bolt: %{code: code, message: msg}}) when is_binary(code) do
exception(neo4j_code: code, neo4j_message: msg, category: category(code))
end
def from_bolt(%{__exception__: true} = error) do
exception(neo4j_code: nil, neo4j_message: Exception.message(error), category: :other)
end
def from_bolt(other) do
exception(neo4j_code: nil, neo4j_message: if(is_binary(other), do: other, else: inspect(other)), category: :other)
end
@doc "True for a Neo4j constraint-violation error (drives `identities` / PK constraints)."
def constraint_violation?(%__MODULE__{category: :constraint}), do: true
def constraint_violation?(_), do: false
defp category("Neo.ClientError.Schema.ConstraintValidationFailed"), do: :constraint
defp category("Neo.TransientError." <> _), do: :transient
defp category("Neo.ClientError.Statement." <> _), do: :statement
defp category("Neo.ClientError.Security." <> _), do: :security
defp category(_), do: :other
end