Skip to content
Merged

dev #256

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
2 changes: 1 addition & 1 deletion .formatter.exs
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
# SPDX-License-Identifier: MIT

spark_locals_without_parens = [
guard: 1,
label: 1,
relate: 1,
guard: 1,
skip: 1
]

Expand Down
14 changes: 13 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,18 @@ See [Conventional Commits](Https://conventionalcommits.org) for commit guideline

<!-- changelog -->

## [v0.5.1](https://github.com/diffo-dev/ash_neo4j/compare/v0.5.0...v0.5.1) (2026-05-10)

### Improvements

* **Documentation** (#249) — ex_doc configuration overhauled: extras reorganised with titled entries, module groups defined for AshNeo4j, Introspection, Cypher, Utilities and Internals, Livebook added to How To, CHANGELOG included in About AshNeo4j, maintainer contact updated.

### Bug Fixes

* **Aggregate filters honoured** (#252) — filters declared via `filter expr(...)` on aggregate definitions were silently dropped. Filtered aggregates now load full destination records in Elixir and apply `Ash.Filter.Runtime.filter_matches/3` per source group before reducing. The fast Cypher push-down path is preserved for unfiltered aggregates.

* **Aggregate names with `?` suffix** (#251) — aggregate names following the Elixir predicate convention (e.g. `exists :cvc_defined?, :characteristics`) caused Neo4j to reject the generated Cypher with an invalid identifier error. Column aliases are now backtick-quoted, allowing any valid Elixir atom as an aggregate name.

## [v0.5.0](https://github.com/diffo-dev/ash_neo4j/compare/v0.4.1...v0.5.0) (2026-05-08)

### Features
Expand All @@ -28,7 +40,7 @@ See [Conventional Commits](Https://conventionalcommits.org) for commit guideline

## [v0.4.1](https://github.com/diffo-dev/ash_neo4j/compare/v0.4.0...v0.4.1) (2026-05-06)

## What's Changed
### What's Changed
* fix in_transaction? by @matt-beanland in https://github.com/diffo-dev/ash_neo4j/pull/226
* fixed sandbox and non-sandbox paths by @matt-beanland in https://github.com/diffo-dev/ash_neo4j/pull/227
* fix unhandled throws by @matt-beanland in https://github.com/diffo-dev/ash_neo4j/pull/228
Expand Down
17 changes: 15 additions & 2 deletions lib/cypher.ex
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,21 @@ defmodule AshNeo4j.Cypher do
require Logger

alias AshNeo4j.Cypher.{
Query, Match, OptionalMatch, Create, Merge, Where, With,
Set, Remove, Delete, DetachDelete, Return, OrderBy, Skip, Limit
Query,
Match,
OptionalMatch,
Create,
Merge,
Where,
With,
Set,
Remove,
Delete,
DetachDelete,
Return,
OrderBy,
Skip,
Limit
}

@spec remove_properties(atom(), maybe_improper_list()) :: binary()
Expand Down
105 changes: 76 additions & 29 deletions lib/cypher/query.ex
Original file line number Diff line number Diff line change
Expand Up @@ -104,16 +104,39 @@ defmodule AshNeo4j.Cypher.Query do
"""

alias AshNeo4j.Cypher

alias AshNeo4j.Cypher.{
Match, OptionalMatch, Create, Merge, Where, With,
Set, Remove, Delete, DetachDelete, Return, OrderBy, Skip, Limit
Match,
OptionalMatch,
Create,
Merge,
Where,
With,
Set,
Remove,
Delete,
DetachDelete,
Return,
OrderBy,
Skip,
Limit
}

@type clause ::
Match.t() | OptionalMatch.t() | Create.t() | Merge.t()
| Where.t() | With.t() | Set.t() | Remove.t()
| Delete.t() | DetachDelete.t() | Return.t()
| OrderBy.t() | Skip.t() | Limit.t()
Match.t()
| OptionalMatch.t()
| Create.t()
| Merge.t()
| Where.t()
| With.t()
| Set.t()
| Remove.t()
| Delete.t()
| DetachDelete.t()
| Return.t()
| OrderBy.t()
| Skip.t()
| Limit.t()

@type t :: %__MODULE__{clauses: [clause()], params: map()}

Expand Down Expand Up @@ -273,7 +296,16 @@ defmodule AshNeo4j.Cypher.Query do
`path_segments` is a list of `{edge_label, direction, dest_label}` tuples describing
the traversal from source to the node being aggregated.
"""
@spec aggregate_per_record(atom(), atom(), [any()], [{atom(), atom(), atom()}], atom(), atom() | nil, atom(), boolean()) :: t()
@spec aggregate_per_record(
atom(),
atom(),
[any()],
[{atom(), atom(), atom()}],
atom(),
atom() | nil,
atom(),
boolean()
) :: t()
def aggregate_per_record(source_label, pk_field, ids, path_segments, kind, field, name, uniq? \\ false)
when is_atom(source_label) and is_atom(pk_field) and is_list(ids) and is_list(path_segments) and is_atom(kind) do
path = build_agg_path(path_segments)
Expand All @@ -295,7 +327,8 @@ defmodule AshNeo4j.Cypher.Query do

`MATCH (s:Label) WHERE s.pk IN $agg_ids OPTIONAL MATCH (s)<path>(d) RETURN agg_fn AS name`
"""
@spec aggregate_total(atom(), atom(), [any()], [{atom(), atom(), atom()}], atom(), atom() | nil, atom(), boolean()) :: t()
@spec aggregate_total(atom(), atom(), [any()], [{atom(), atom(), atom()}], atom(), atom() | nil, atom(), boolean()) ::
t()
def aggregate_total(source_label, pk_field, ids, path_segments, kind, field, name, uniq? \\ false)
when is_atom(source_label) and is_atom(pk_field) and is_list(ids) and is_list(path_segments) and is_atom(kind) do
path = build_agg_path(path_segments)
Expand Down Expand Up @@ -375,7 +408,11 @@ defmodule AshNeo4j.Cypher.Query do
def delete_nodes_guarded(label, properties, guards)
when is_atom(label) and is_map(properties) and is_list(guards) do
{pattern, params} = Cypher.parameterized_node(:n, [label], properties)
conditions = Enum.map(guards, fn {edge_label, direction, dest_label} -> guard_condition(:n, edge_label, direction, dest_label) end)

conditions =
Enum.map(guards, fn {edge_label, direction, dest_label} ->
guard_condition(:n, edge_label, direction, dest_label)
end)

%__MODULE__{
clauses: [%Match{pattern: pattern}, %Where{conditions: conditions}, %DetachDelete{items: ["n"]}],
Expand Down Expand Up @@ -421,7 +458,9 @@ defmodule AshNeo4j.Cypher.Query do
clauses: [
%Match{pattern: src_pattern},
%With{items: ["s"]},
%OptionalMatch{pattern: "(s)" <> Cypher.relationship(:r0, edge_label, direction) <> Cypher.node(:d0, [dest_label])},
%OptionalMatch{
pattern: "(s)" <> Cypher.relationship(:r0, edge_label, direction) <> Cypher.node(:d0, [dest_label])
},
%Delete{items: ["r0"]},
%With{items: ["s"]},
%Match{pattern: dest_pattern},
Expand Down Expand Up @@ -450,7 +489,9 @@ defmodule AshNeo4j.Cypher.Query do
%Match{pattern: src_pattern},
%OptionalMatch{pattern: dest_pattern},
%With{items: ["s", "d"]},
%OptionalMatch{pattern: Cypher.node(:s0, [src_label]) <> Cypher.relationship(:r0, edge_label, direction) <> "(d)"},
%OptionalMatch{
pattern: Cypher.node(:s0, [src_label]) <> Cypher.relationship(:r0, edge_label, direction) <> "(d)"
},
%Where{conditions: ["s0 <> s"]},
%Delete{items: ["r0"]},
%With{items: ["s", "d"]},
Expand Down Expand Up @@ -485,7 +526,9 @@ defmodule AshNeo4j.Cypher.Query do
%With{items: ["s"]},
%OptionalMatch{pattern: dest_pattern},
%With{items: ["s", "d"]},
%OptionalMatch{pattern: Cypher.node(:s0, [src_label]) <> Cypher.relationship(:r0, edge_label, direction) <> "(d)"},
%OptionalMatch{
pattern: Cypher.node(:s0, [src_label]) <> Cypher.relationship(:r0, edge_label, direction) <> "(d)"
},
%Where{conditions: ["s0 <> s"]},
%Delete{items: ["r0"]},
%With{items: ["s", "d"]},
Expand Down Expand Up @@ -566,11 +609,14 @@ defmodule AshNeo4j.Cypher.Query do
|> Enum.with_index()
|> Enum.reduce("", fn {{edge_label, direction, dest_label}, i}, acc ->
node_var = if i == last_idx, do: "d", else: "h#{i}"
rel = case direction do
:outgoing -> "-[:#{edge_label}]->"
:incoming -> "<-[:#{edge_label}]-"
_ -> "-[:#{edge_label}]-"
end

rel =
case direction do
:outgoing -> "-[:#{edge_label}]->"
:incoming -> "<-[:#{edge_label}]-"
_ -> "-[:#{edge_label}]-"
end

acc <> rel <> "(#{node_var}:#{dest_label})"
end)
end
Expand All @@ -579,17 +625,18 @@ defmodule AshNeo4j.Cypher.Query do
distinct = if uniq?, do: "DISTINCT ", else: ""
field_ref = if field, do: "d.#{field}", else: "d"

fn_str = case kind do
:count -> "COUNT(#{distinct}d)"
:exists -> "COUNT(d) > 0"
:sum -> "sum(#{distinct}#{field_ref})"
:avg -> "avg(#{distinct}#{field_ref})"
:min -> "min(#{field_ref})"
:max -> "max(#{field_ref})"
:list -> "collect(#{distinct}#{field_ref})"
:first -> "head(collect(#{field_ref}))"
end

"#{fn_str} AS #{name}"
fn_str =
case kind do
:count -> "COUNT(#{distinct}d)"
:exists -> "COUNT(d) > 0"
:sum -> "sum(#{distinct}#{field_ref})"
:avg -> "avg(#{distinct}#{field_ref})"
:min -> "min(#{field_ref})"
:max -> "max(#{field_ref})"
:list -> "collect(#{distinct}#{field_ref})"
:first -> "head(collect(#{field_ref}))"
end

"#{fn_str} AS `#{name}`"
end
end
Loading
Loading