From badf4bbb5cefde6b04186fbcc9e3eabfc6f79a9f Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Tue, 19 May 2026 00:23:42 +0930 Subject: [PATCH] doc improvements --- AGENTS.md | 71 +++++++++++++++++--------------- CHANGELOG.md | 4 +- README.md | 4 +- lib/data_layer.ex | 11 +++-- lib/data_layer/domain/info.ex | 18 +------- lib/data_layer/info.ex | 38 +---------------- lib/persisters/persist_labels.ex | 5 ++- lib/resource/info.ex | 15 ++++--- test/data_layer/info_test.exs | 22 +++++----- usage-rules.md | 4 +- usage-rules/dsl.md | 23 +++++++---- 11 files changed, 92 insertions(+), 123 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 406d283..c158e03 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,7 @@ is the Diffo project; upstream bugs found while working in Diffo belong here. 1. Read `usage-rules.md` — the canonical rules for using AshNeo4j, including naming conventions, relationship semantics, aggregate kinds, and the test sandbox. -2. Understand the label system (see **Label system** below) — the three-level label concept is +2. Understand the label system (see **Label system** below) — the label concept is a frequent source of bugs and the most important thing to get right. 3. Run `mix test` before and after your change to confirm nothing regressed. @@ -40,10 +40,12 @@ lib/ functions for every query shape used by the data layer query_helper.ex — Translates Ash.Query (filter, sort, offset, limit) into a Cypher.Query; entry point is query_nodes/1 - resource/info.ex — All DSL introspection: label/1, module_label/1, labels/1, + resource/info.ex — All DSL introspection: label/1, module_label/1, domain_label/1, + domain_fragment_label/1, all_labels/1, label_pair/1, mapping/1, relate/1, translations/1, and relationship helpers resource_mapping.ex — %ResourceMapping{} struct (module, label, module_label, - labels, properties, edges, guards, skip) + domain_fragment_label, all_labels, label_pair, + properties, edges, guards, skip) edge_descriptor.ex — %EdgeDescriptor{} struct (relationship, label, direction, destination_label) neo4j_helper.ex — Low-level node/edge operations via Bolty @@ -53,7 +55,8 @@ lib/ sandbox.ex — AshNeo4j.Sandbox: per-test transaction isolation util.ex — short_name/1, to_camel_case/1, reverse/1 persisters/ - persist_labels.ex — Computes and persists domain_label, module_label, label, labels + persist_labels.ex — Computes and persists domain_label, module_label, label, + domain_fragment_label, all_labels, label_pair persist_translations.ex — Builds attribute → property name keyword list; excludes belongs_to source attributes and skip attributes persist_relate.ex — Merges explicit relate DSL with default auto-generated edges @@ -78,26 +81,27 @@ test/ ## Label system -Every node has three distinct label concepts. Getting them confused is the most common +Every node has several distinct label concepts. Getting them confused is the most common source of bugs: | Name | Persisted as | Example | When used | |---|---|---|---| -| `domain_label` | `:domain_label` | `:Servo` | Written on CREATE only — never used to match | -| `module_label` | `:module_label` | `:ShelfInstance` | Written on CREATE; should be part of MATCH | -| `label` | `:label` | `:Instance` | May differ from module_label when a fragment declares a base type label; used as the MATCH label | -| `labels` | `:labels` | `[:Servo, :ShelfInstance, :Instance]` | Full CREATE label list — `[domain_label | [module_label, label] |> Enum.uniq()]` | +| `domain_label` | `:domain_label` | `:Servo` | Written on CREATE; also part of MATCH via `label_pair` | +| `module_label` | `:module_label` | `:ShelfInstance` | Written on CREATE; also part of MATCH via `label_pair` | +| `label` | `:label` | `:Instance` | May differ from `module_label` when a resource fragment declares a base type; written on CREATE only | +| `domain_fragment_label` | `:domain_fragment_label` | `:Telco` | Written on CREATE only — from a domain fragment using `AshNeo4j.DataLayer.Domain`; `nil` when none declared | +| `all_labels` | `:all_labels` | `[:Servo, :ShelfInstance, :Instance, :Telco]` | Full CREATE label list — `[domain_label, module_label, label, domain_fragment_label]` deduped | +| `label_pair` | `:label_pair` | `[:Servo, :ShelfInstance]` | MATCH label list — always `[domain_label, module_label]`; uniquely identifies this resource type | -**Key invariant:** `labels` (all three) are written on `CREATE`. For `MATCH` / `UPDATE` / -`DELETE`, the domain label is never used. When the resource uses a fragment that contributes a -different `label` (e.g. `:Instance` from `BaseInstance`), reading with only that label matches -nodes from all resources that extend the same fragment — a correctness bug. Use -`[module_label, label]` (deduped) for MATCH so reads are scoped to the exact resource. +**Key invariant:** `all_labels` are written on `CREATE`. For `MATCH` / `UPDATE` / `DELETE`, +use `mapping.label_pair` — always `[domain_label, module_label]`. This two-label combination +uniquely identifies the exact resource type and prevents cross-fragment contamination. -`Cypher.node(:s, [module_label, label])` produces `"(s:ShelfInstance:Instance)"` — correct. -`Cypher.node(:s, [label])` produces `"(s:Instance)"` — scans the whole fragment family. +`Cypher.node(:s, [:Servo, :ShelfInstance])` produces `"(s:Servo:ShelfInstance)"` — correct. +`Cypher.node(:s, [:Instance])` produces `"(s:Instance)"` — scans every resource extending the same fragment. +`Cypher.node(:s, [:ShelfInstance])` produces `"(s:ShelfInstance)"` — scopes to module but not domain (avoid). -`ResourceInfo.module_label/1` and `mapping.module_label` always hold the resource-specific label. +`mapping.label_pair` always holds `[domain_label, module_label]`. Use it for all MATCH patterns. ## Translations (attribute ↔ property name mapping) @@ -174,13 +178,14 @@ a `{cypher_string, params}` tuple for `Cypher.run/1`. `Cypher.node(variable, labels)` takes a list of label atoms and produces `"(var:L1:L2)"`. `Cypher.parameterized_node/3` does the same with a property map for parameterized MATCH patterns. -When adding a new builder or modifying an existing one, keep `label` parameters as `atom()` -for single-label callers. If a builder needs to support multi-label MATCH (e.g. after the -#257 fix), update the typespec to `atom() | [atom()]` and handle both. +All MATCH/UPDATE/DELETE builders accept `atom() | [atom()]` for source label parameters — pass +`mapping.label_pair` (a list) for all resource operations. Single-atom callers still work for +destination labels (which remain a single label in most patterns). -The aggregate builders (`aggregate_per_record`, `aggregate_total`, `related_nodes`) use direct -string interpolation for the source node pattern — `"(s:#{source_label})"`. To support -multi-label source MATCH these must be updated alongside the read builders. +The aggregate builders (`aggregate_per_record`, `aggregate_total`, `related_nodes`) use a +`labels_string/1` private helper to render `[domain, module]` as `"Domain:Module"` inside +string-interpolated Cypher patterns — `"(s:#{labels_string(label_pair)})"`. When modifying +aggregate builders, use `labels_string/1` for the source pattern, not direct atom interpolation. ## Running tests @@ -214,10 +219,10 @@ as a follow-up comment, then leave it with the upstream maintainers. ## Common agent mistakes -- **Matching on `mapping.label` alone** when the resource uses a fragment with a different base - type label (e.g. `:Instance`). MATCH must use `[mapping.module_label, mapping.label]` so - reads are scoped to the exact resource module. `mapping.label` alone matches every resource - that extends the same fragment. +- **Not using `mapping.label_pair` for MATCH.** All read, update, delete, and aggregate queries + must use `mapping.label_pair` (`[domain_label, module_label]`) as the source node pattern. + Using `mapping.label` alone matches every resource that extends the same fragment. Using + `mapping.module_label` alone (without domain) risks collisions across domains. - **Re-adding `belongs_to` source attributes to translations.** They are intentionally excluded by `PersistTranslations`. Their values come from enrichments (the OPTIONAL MATCH result). @@ -230,9 +235,9 @@ as a follow-up comment, then leave it with the upstream maintainers. visible, `PersistRelate` generates default edges with wrong labels (e.g. `:BELONGS_TO` instead of `:SPECIFIED_BY`), causing enrichments to silently fail. -- **Using `mapping.label` in aggregate Cypher builders** (`aggregate_per_record`, - `aggregate_total`, `related_nodes`). These use `"(s:#{source_label})"` directly and have the - same fragment-scoping bug as the read builders. Fix them alongside the read path. +- **Using a single label in aggregate Cypher builders** (`aggregate_per_record`, + `aggregate_total`, `related_nodes`). These use `"(s:#{labels_string(source_label)})"` with a + `labels_string/1` helper. Always pass `mapping.label_pair` as the source label here too. - **Registering a transformer under `persisters:`** and expecting `before?`/`after?` ordering relative to other transformers to be honoured. Persisters always run after ALL transformers. @@ -243,9 +248,9 @@ as a follow-up comment, then leave it with the upstream maintainers. label, only one instance is removed. Prefer `List.delete_at` or label filtering by explicit set membership when precision matters. -- **Treating `domain_label` as a MATCH label.** The domain label is written on CREATE so that - domain-scoped traversals work, but it is never used for reading. Matching on it would return - every node in the domain, not just the target resource. +- **Treating `domain_label` alone as a MATCH label.** The domain label is part of `label_pair` + and is used in MATCH, but always paired with `module_label`. Matching on domain label alone + would return every node in the domain, not just the target resource. - **Forgetting to update `relation_read` in `Cypher.Query`** when changing MATCH label logic. The `relationship_read/7` builder emits a separate `MATCH (s:SrcLabel)-[r:EdgeLabel]-(d:DestLabel)` diff --git a/CHANGELOG.md b/CHANGELOG.md index c4bc704..2c88306 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,14 +29,14 @@ See [Conventional Commits](Https://conventionalcommits.org) for commit guideline * **Aggregates** — full support for `:count`, `:exists`, `:sum`, `:avg`, `:min`, `:max`, `:first`, `:list` aggregate kinds, declared in the standard Ash `aggregates` block. Aggregates are executed as Cypher `OPTIONAL MATCH` traversals; single-hop and multi-hop relationship paths are both supported. * **Aggregates on embedded/JSON-type fields** — when `field:` points to an attribute stored as JSON (`Ash.TypedStruct`, `Ash.Type.NewType`, embedded resources, `Ash.Type.Map`, etc.) AshNeo4j collects raw JSON from Neo4j and deserializes in Elixir. `:list` and `:first` return fully-typed structs; `:sum`/`:avg`/`:min`/`:max` work on directly comparable values. -* **Expression aggregates (`expr:`)** — programmatic aggregate API (`Ash.aggregate/3`) accepts `expr:` to aggregate over a sub-field of an embedded struct or any Ash expression, without needing to elevate the field. Uses `Ash.Expr.eval_hydrated/2` on full destination records. +* **Expression aggregates (`expr:`)** — programmatic aggregate API (`Ash.aggregate/3`) accepts `expr:` to aggregate over a sub-field of an embedded struct or any Ash expression, without needing to elevate the field. Fetches full destination records and evaluates expressions in Elixir. * **Expression calculations** — `calculate :name, :type, expr(...)` declarations are now evaluated in Elixir after records are loaded. Supports load (`Ash.load!`), filter (`Ash.Query.filter`), and sort (`Ash.Query.sort`). Embedded struct fields work directly via `get_path` — no elevation needed. ### Improvements * Cypher query struct family extended; `Neo4jHelper` refactored to use it * Calculation-based filter predicates are excluded from Cypher WHERE and evaluated in-memory via `Ash.Filter.Runtime` -* Calculation-based sort terms are applied post-load via `Ash.Actions.Sort.runtime_sort/3` +* Calculation-based sort terms are applied in Elixir after records are loaded ## [v0.4.1](https://github.com/diffo-dev/ash_neo4j/compare/v0.4.0...v0.4.1) (2026-05-06) diff --git a/README.md b/README.md index d00d362..dabb269 100644 --- a/README.md +++ b/README.md @@ -309,7 +309,7 @@ Ash.aggregate(MyResource, {:total_bandwidth, :sum, [ ]}) ``` -For `expr:` aggregates, AshNeo4j fetches full destination records, evaluates the Ash expression on each via `Ash.Expr.eval_hydrated/2`, and aggregates in Elixir. Any valid Ash expression works — `get_path` for nested struct navigation, arithmetic, etc. Note: `expr:` is a programmatic API and is not available in the resource-level `aggregates do` DSL block. +For `expr:` aggregates, AshNeo4j fetches full destination records, evaluates the Ash expression on each in Elixir, and aggregates the results. Any valid Ash expression works — `get_path` for nested struct navigation, arithmetic, etc. Note: `expr:` is a programmatic API and is not available in the resource-level `aggregates do` DSL block. ## Calculations @@ -327,7 +327,7 @@ Calculations can be: - **Loaded** — `Ash.load!(records, [:score_doubled])` - **Filtered on** — `Ash.Query.filter(score_doubled > 10)` — AshNeo4j loads all matching nodes then evaluates the filter in Elixir -- **Sorted on** — `Ash.Query.sort(score_doubled: :asc)` — applied in Elixir after records are loaded via `Ash.Actions.Sort.runtime_sort/3` +- **Sorted on** — `Ash.Query.sort(score_doubled: :asc)` — applied in Elixir after records are loaded **Embedded struct fields work without elevation.** `get_path(dog, [:age])` navigates into a `DogTypedStruct` directly — records arrive with embedded types fully deserialized, so any Ash expression that works in-memory works in a calculation. diff --git a/lib/data_layer.ex b/lib/data_layer.ex index 783c6da..1d944bb 100644 --- a/lib/data_layer.ex +++ b/lib/data_layer.ex @@ -69,7 +69,7 @@ defmodule AshNeo4j.DataLayer do """ neo4j do label :Comment - relate [{:post, :BELONGS_TO, :outgoing}] + relate [{:post, :BELONGS_TO, :outgoing, :Post}] end """ ], @@ -83,17 +83,20 @@ defmodule AshNeo4j.DataLayer do type: {:list, {:tuple, [:atom, :atom, :atom, :atom]}}, doc: "Optional list of relationships, as tuples of {relationship_name, edge_label, edge_direction, destination_label}", - required: false + required: false, + default: [] ], guard: [ type: {:list, {:tuple, [:atom, :atom, :atom]}}, doc: "Optional list of node relationships, as tuples of {edge_label, edge_direction, destination_label}", - required: false + required: false, + default: [] ], skip: [ type: {:list, :atom}, doc: "Optional list of attributes not to be stored directly as node properties", - required: false + required: false, + default: [] ] ] } diff --git a/lib/data_layer/domain/info.ex b/lib/data_layer/domain/info.ex index 233d297..1985f05 100644 --- a/lib/data_layer/domain/info.ex +++ b/lib/data_layer/domain/info.ex @@ -4,21 +4,5 @@ defmodule AshNeo4j.DataLayer.Domain.Info do @moduledoc "Introspection helpers for AshNeo4j.DataLayer.Domain" - - alias Spark.Dsl.Extension - - @doc """ - Returns the label declared in the domain's `neo4j do` block. - - The label is written on CREATE for every node whose resource belongs - to this domain. It provides an additional axis for graph traversal - independent of the specific resource type. - - Returns `nil` if the domain does not use `AshNeo4j.DataLayer.Domain` or - declares no label. - """ - @spec label(Ash.Domain.t()) :: atom() | nil - def label(domain) do - Extension.get_opt(domain, [:neo4j], :label, nil, true) - end + use Spark.InfoGenerator, extension: AshNeo4j.DataLayer.Domain, sections: [:neo4j] end diff --git a/lib/data_layer/info.ex b/lib/data_layer/info.ex index 4df5c02..eb39bcf 100644 --- a/lib/data_layer/info.ex +++ b/lib/data_layer/info.ex @@ -4,41 +4,5 @@ defmodule AshNeo4j.DataLayer.Info do @moduledoc "Introspection helpers for AshNeo4j.DataLayer" - - alias Spark.Dsl.Extension - - @doc """ - Returns the label DSL of the resource. - The label is the PascalCase short name of the resource's Elixir Module name by default, but can be overridden by setting the :label option in the DSL. It is used as a Neo4j label for all nodes of the resource. - """ - @spec label(Ash.Resource.t()) :: atom() | nil - def label(resource) do - Extension.get_opt(resource, [:neo4j], :label, nil, true) - end - - @doc """ - Returns the relate DSL of the resource - """ - @spec relate(Ash.Resource.t()) :: list(tuple()) | nil - def relate(resource) do - Extension.get_opt(resource, [:neo4j], :relate, [], true) - end - - @doc """ - Returns the guard DSL of the resource - """ - @spec guard(Ash.Resource.t()) :: list(tuple()) | nil - def guard(resource) do - Extension.get_opt(resource, [:neo4j], :guard, [], true) - end - - @doc """ - Returns the skip DSL of the resource. - The skip DSL is a list of attribute names which are not translated to node properties, either because they are transient or because they will be stored as relationships rather than properties. - By default, all attributes which are the source of a 1:1 belongs_to relationship are skipped, but additional attributes can be skipped by setting the :skip option in the DSL. - """ - @spec skip(Ash.Resource.t()) :: list() | nil - def skip(resource) do - Extension.get_opt(resource, [:neo4j], :skip, [], true) - end + use Spark.InfoGenerator, extension: AshNeo4j.DataLayer, sections: [:neo4j] end diff --git a/lib/persisters/persist_labels.ex b/lib/persisters/persist_labels.ex index 3dc322d..511b0b6 100644 --- a/lib/persisters/persist_labels.ex +++ b/lib/persisters/persist_labels.ex @@ -21,7 +21,10 @@ defmodule AshNeo4j.Persisters.PersistLabels do domain_fragment_label = if Code.ensure_loaded?(domain_module) and function_exported?(domain_module, :spark_dsl_config, 0) do - AshNeo4j.DataLayer.Domain.Info.label(domain_module) + case AshNeo4j.DataLayer.Domain.Info.neo4j_label(domain_module) do + {:ok, label} -> label + :error -> nil + end end # module_label is always the short name of the resource module itself (:Shelf). diff --git a/lib/resource/info.ex b/lib/resource/info.ex index 635315b..a3e94f2 100644 --- a/lib/resource/info.ex +++ b/lib/resource/info.ex @@ -47,7 +47,12 @@ defmodule AshNeo4j.Resource.Info do case Extension.get_persisted(resource, :domain_fragment_label, nil) do nil -> domain = Extension.get_persisted(resource, :domain, nil) - if domain, do: AshNeo4j.DataLayer.Domain.Info.label(domain), else: nil + if domain do + case AshNeo4j.DataLayer.Domain.Info.neo4j_label(domain) do + {:ok, label} -> label + :error -> nil + end + end val -> val @@ -97,8 +102,8 @@ defmodule AshNeo4j.Resource.Info do properties: translations(resource), edges: Enum.map(relate(resource), &EdgeDescriptor.from_relate/1), relationship_attributes: relationship_attributes(resource), - guards: AshNeo4j.DataLayer.Info.guard(resource), - skip: AshNeo4j.DataLayer.Info.skip(resource) + guards: AshNeo4j.DataLayer.Info.neo4j_guard!(resource), + skip: AshNeo4j.DataLayer.Info.neo4j_skip!(resource) } end @@ -296,7 +301,7 @@ defmodule AshNeo4j.Resource.Info do @doc """ Converts an attribute name to a node property name string, translating if necessary """ - @spec convert_to_property_name(Ash.Resource.t(), Ash.Query.Ref.t()) :: String.t() | nil + @spec convert_to_property_name(Ash.Resource.t(), struct()) :: String.t() | nil def convert_to_property_name(resource, ash_query_ref) when is_atom(resource) and is_struct(ash_query_ref, Ash.Query.Ref) do attribute_name = Ash.Query.Ref.name(ash_query_ref) @@ -347,7 +352,7 @@ defmodule AshNeo4j.Resource.Info do """ @spec preserve_node_relationships(Ash.Resource.t()) :: list(tuple()) def preserve_node_relationships(resource) when is_atom(resource) do - Enum.reduce(relate(resource), AshNeo4j.DataLayer.Info.guard(resource), fn {name, edge_label, edge_direction, + Enum.reduce(relate(resource), AshNeo4j.DataLayer.Info.neo4j_guard!(resource), fn {name, edge_label, edge_direction, destination_label}, acc -> relationship = Ash.Resource.Info.relationship(resource, name) diff --git a/test/data_layer/info_test.exs b/test/data_layer/info_test.exs index 9a45a3a..57c1c82 100644 --- a/test/data_layer/info_test.exs +++ b/test/data_layer/info_test.exs @@ -15,41 +15,41 @@ defmodule AshNeo4j.DataLayer.InfoTest do describe "datalayer info" do test "label" do - refute DataLayerInfo.label(Specification) - assert DataLayerInfo.label(Event) == :Event + assert DataLayerInfo.neo4j_label(Specification) == :error + assert DataLayerInfo.neo4j_label(Event) == {:ok, :Event} end test "relate" do - assert DataLayerInfo.relate(Specification) == [] + assert DataLayerInfo.neo4j_relate!(Specification) == [] - assert DataLayerInfo.relate(Event) == [ + assert DataLayerInfo.neo4j_relate!(Event) == [ {:service, :RAISED, :incoming, :Service}, {:resource, :FIRED, :incoming, :Resource} ] end test "guard" do - assert DataLayerInfo.guard(Specification) == [ + assert DataLayerInfo.neo4j_guard!(Specification) == [ {:SPECIFIES, :outgoing, :Service}, {:SPECIFIES, :outgoing, :Resource} ] - assert DataLayerInfo.guard(Event) == [] + assert DataLayerInfo.neo4j_guard!(Event) == [] end test "skip" do - assert DataLayerInfo.skip(Specification) == [] - assert DataLayerInfo.skip(Event) == [:service_id, :resource_id] + assert DataLayerInfo.neo4j_skip!(Specification) == [] + assert DataLayerInfo.neo4j_skip!(Event) == [:service_id, :resource_id] end end describe "domain info" do - test "label returns nil for domains without AshNeo4j.DataLayer.Domain" do - assert DomainInfo.label(AshNeo4j.Test.SRM) == nil + test "label returns error for domains without AshNeo4j.DataLayer.Domain" do + assert DomainInfo.neo4j_label(AshNeo4j.Test.SRM) == :error end test "label returns the declared label for a domain using a domain fragment" do - assert DomainInfo.label(Provider) == :MyTestDomain + assert DomainInfo.neo4j_label(Provider) == {:ok, :MyTestDomain} end end diff --git a/usage-rules.md b/usage-rules.md index a89e52e..56b0316 100644 --- a/usage-rules.md +++ b/usage-rules.md @@ -36,7 +36,7 @@ Do not carry SQL assumptions into AshNeo4j. The differences are fundamental: - **Never add foreign key attributes** to an AshNeo4j resource for the purpose of expressing a relationship. Relationships are graph edges managed by the `relate` DSL and the Ash `relationships` block. - **Many-to-many requires a joiner resource** — a dedicated node with two `belongs_to` relationships. AshNeo4j does not use edge properties. Do not attempt a direct many-to-many edge. - There is no `Ecto.Repo`. The Neo4j connection pool is a Bolty named process (`Bolt`), configured in `runtime.exs` and added to your supervision tree. -- **Every node is created with at least two labels**: the domain label (PascalCase short name of the Ash domain module) and the resource label. When a resource uses a fragment that declares a `label`, that fragment label is also written on create — so a resource extending `BaseInstance` (which declares `label :Instance`) will produce nodes with three labels: `[:Domain, :ResourceName, :Instance]`. Only the resource label is used when reading, updating, or destroying. The domain label cannot be overridden. +- **Every node is created with at least two labels**: the domain label (PascalCase short name of the Ash domain module) and the module label (PascalCase short name of the resource module). When a resource uses a fragment that declares a `label`, that fragment label is also written on CREATE — so a resource extending `BaseInstance` (which declares `label :Instance`) produces nodes with three labels: `[:Domain, :ResourceName, :Instance]`. When the domain uses `AshNeo4j.DataLayer.Domain` via a domain fragment, an additional domain fragment label is also written. Reads, updates, and deletes match on `[domain_label, module_label]` — always uniquely scoped to the resource type. - **Transactions are supported.** A test sandbox (`AshNeo4j.Sandbox`) provides per-test transaction isolation — see `usage-rules/testing.md`. - **Aggregates are supported** for kinds `:count`, `:exists`, `:sum`, `:avg`, `:min`, `:max`, `:first`, `:list`. The `:custom` kind is not supported. Fields stored as JSON (embedded resources, `Ash.TypedStruct`, `Ash.Type.NewType`, `Ash.Type.Map`, etc.) are also aggregatable — see the Aggregates section below. @@ -100,7 +100,7 @@ Ash.aggregate(Post, {:total_dog_age, :sum, [ ]}) ``` -When `expr:` is used, AshNeo4j fetches full destination node records, casts them to resource structs, evaluates the Ash expression on each via `Ash.Expr.eval_hydrated/2`, and applies the aggregate kind in Elixir. This supports arbitrary Ash expressions — field access, `get_path` for nested struct navigation, arithmetic, etc. +When `expr:` is used, AshNeo4j fetches full destination node records, casts them to resource structs, evaluates the Ash expression on each in Elixir, and applies the aggregate kind. This supports arbitrary Ash expressions — field access, `get_path` for nested struct navigation, arithmetic, etc. Note: `expr:` in aggregate declarations is a programmatic API (`Ash.aggregate/3`, `Ash.Query.aggregate/3`). It is not available in the resource-level `aggregates do` DSL block. diff --git a/usage-rules/dsl.md b/usage-rules/dsl.md index 5f54c84..4769818 100644 --- a/usage-rules/dsl.md +++ b/usage-rules/dsl.md @@ -31,20 +31,25 @@ label :BlogComment ### Labels per node -Every node is created with **at least two** labels: the domain label and the resource label. +Every node is created with **at least two** labels: the domain label and the module label. -- The **domain label** is the PascalCase short name of the Ash domain module (e.g. `MyApp.Blog` → `:Blog`). It is applied automatically and cannot be overridden. -- The **resource label** is the value of `label` in the `neo4j do` block, defaulting to the PascalCase short name of the resource module. This is the label used to match nodes on read, update, and destroy. +- The **domain label** is the PascalCase short name of the Ash domain module (e.g. `MyApp.Blog` → `:Blog`). Applied automatically; cannot be overridden. +- The **module label** is the PascalCase short name of the resource module (e.g. `MyApp.Blog.Comment` → `:Comment`). Always present and always resource-specific. +- The **resource label** (`label` in the DSL) defaults to the module label. Set it explicitly only when a resource fragment overrides the base type (e.g. a `BaseInstance` fragment declares `label :Instance` — all resources that extend it get `:Instance` as an additional label on CREATE). +- The **domain fragment label** is written on CREATE when the Ash domain uses `AshNeo4j.DataLayer.Domain` via a fragment (e.g. a `Telco` fragment contributes `:Telco` to every node in the domain). -When a resource uses a fragment that declares its own `label`, that fragment label is also written on CREATE as an additional label. A resource using `BaseInstance` (which declares `label :Instance`) will store nodes with `[:Domain, :ResourceName, :Instance]`. This enables polymorphic graph traversals — a relationship targeting `:Instance` will match any resource that extends `BaseInstance`, regardless of domain. A resource can only extend one fragment this way since full resources are not fragments. +So a `MyApp.Access.ShelfInstance` resource, in an `Access` domain that includes a `Telco` fragment, extending `BaseInstance`, will store nodes with `[:Access, :ShelfInstance, :Instance, :Telco]`. -Because reads match on the base type label (`:Instance`), `Provider.Instance.read()` and `Access.Shelf.read()` both issue `MATCH (n:Instance)` — they will return the same nodes from the graph. This is intentional: the Provider domain provides a broad cross-domain API, while domain-specific resources like `Access.Shelf` provide a typed view into the same underlying nodes. Use domain-specific resources when you need a typed API; use the base resource when you need to traverse or query across domains. +**Reads, updates, and deletes match on `[domain_label, module_label]` only.** This pair uniquely identifies the resource type and prevents one resource from inadvertently reading nodes belonging to another resource that shares the same fragment base label. -The `AshNeo4j.Resource.Info` module exposes three distinct label accessors: +The `AshNeo4j.Resource.Info` module exposes label accessors: -- `label/1` — the match label used for read/update/destroy (e.g. `:Instance` if set by a fragment) -- `module_label/1` — the label derived from the resource module's own short name (e.g. `:Shelf`) -- `labels/1` — the full list written on CREATE (e.g. `[:Access, :Shelf, :Instance]`) +- `label/1` — the `label` DSL value; equals `module_label/1` unless a fragment overrides it +- `module_label/1` — always the PascalCase short name of the resource module +- `domain_label/1` — the PascalCase short name of the domain module +- `domain_fragment_label/1` — the label from a domain fragment, or `nil` +- `label_pair/1` — `[domain_label, module_label]` — use this for all MATCH patterns +- `all_labels/1` — the full list written on CREATE ## relate