diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c158e03 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,266 @@ + + +# AGENTS.md — AshNeo4j + +AI agent guidance for the AshNeo4j source repository. + +## What this project is + +AshNeo4j is an `Ash.DataLayer` that stores Ash resources as nodes in a Neo4j graph database. +It is a library published on hex.pm and maintained at `diffo-dev/ash_neo4j`. Its primary consumer +is the Diffo project; upstream bugs found while working in Diffo belong here. + +## Before making changes + +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 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. + +## Fixing bugs + +Before writing any fix, review existing test coverage for the affected behaviour. If the bug +has no test, write the failing test first — this confirms the reproduction and guards the fix +against regression. Only then implement the fix and verify the test passes. + +## Project structure + +``` +lib/ + data_layer.ex — Ash.DataLayer behaviour: CRUD, aggregates, calculations, + transaction, enrichments (OPTIONAL MATCH → source attributes) + cypher.ex — Cypher string helpers: node/2, relationship/3, expression/5, + parameterized_node/3, render/1, run/1 + cypher/query.ex — Typed clause structs (Match, Where, Return, …) and builder + 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, 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, + 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 + data_layer/cast.ex — Casts Neo4j return values to Ash types + data_layer/dump.ex — Dumps Ash values to Neo4j-compatible primitives + data_layer/type_classifier.ex — Classifies types as :ash_json (embedded/struct/map) or scalar + 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, + 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 + persist_relationship_attributes.ex — Maps source attributes to relationship names + persist_mapping.ex — Bakes __ash_neo4j_mapping__/0 onto each resource module + verifiers/ + verify_labels_pascal_case.ex + verify_relate.ex + verify_guard.ex + verify_properties_camel_case.ex + verify_enrichable.ex + verify_attribute_type.ex + +test/ + support/resource/ — Test resources (Post, Comment, Author, Specification, …) + support/srm.ex — Test domain (Srm) + blog_test.exs — CRUD, filter, relationship tests + aggregate_test.exs — All aggregate kinds including filtered and expr aggregates + calculation_test.exs — Expression calculations + data_layer/ — Unit tests for Cast, Dump, TypeClassifier, Info +``` + +## Label system + +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; 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:** `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, [: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). + +`mapping.label_pair` always holds `[domain_label, module_label]`. Use it for all MATCH patterns. + +## Translations (attribute ↔ property name mapping) + +`mapping.properties` is a keyword list of `{ash_attribute_name, neo4j_property_name}` pairs +built by `PersistTranslations`. Rules: + +- `snake_case` attributes → `camelCase` properties (via `Util.to_camel_case/1`). +- The `:id` attribute is special: its property name is the camelCase of the Ash type's short + name (e.g. `Ash.Type.UUID` → property `:uuid`). This avoids colliding with Neo4j's internal + `id` field. +- `belongs_to` source attributes (e.g. `specification_id`) are **excluded** from translations. + They are not stored as node properties; their values come from `enrichments/3` (reading the + OPTIONAL MATCH destination node). Do not re-add them to translations. +- Attributes listed in the `skip` DSL option are also excluded. + +The `convert_node_to_resource_impl/4` loop iterates translations and reads node properties. +Because `belongs_to` source attributes are excluded, the loop does not touch them — their +values must survive intact from the enrichments map that seeds the accumulator. + +## Enrichments (OPTIONAL MATCH → source attributes) + +After a read query `MATCH (s:Label) OPTIONAL MATCH (s)-[r]-(d) RETURN s, r, d`, `enrichments/3` +in `DataLayer` processes each `{edge, dest_node}` pair and populates: + +- `belongs_to` relationships: sets `source_attribute` (e.g. `specification_id`) from + `dest_node.properties[destination_property]`. +- `has_one` reverse relationships: sets `destination_attribute` from source node property. +- `many_to_many` relationships: converts dest_node to a resource struct and appends to a list. + +The lookup uses `mapping.edges` (from `mapping.module`). If an edge returned by the OPTIONAL +MATCH has no matching entry in `mapping.edges` (wrong label, wrong direction, or missing relate +entry), `enrichments/3` silently returns `acc` unchanged and the source attribute remains nil. + +`edge_direction/2` determines direction by comparing `dest_node.id` with `edge.start` / +`edge.end`: +- `dest_node.id == edge.start` → `:incoming` (destination is the start of the edge) +- `dest_node.id == edge.end` → `:outgoing` (destination is the end of the edge) + +## PersistRelate: explicit vs default edges + +`PersistRelate` builds `mapping.edges` from two sources: + +1. **Explicit entries** — the `relate` list in the resource's `neo4j do` block: + `{relationship_name, edge_label, direction, destination_label}`. +2. **Default entries** — auto-generated for any Ash relationship that has no explicit entry. + Default edge label = `String.upcase(relationship.type)` (e.g. `:BELONGS_TO`), default + destination label = last segment of `relationship.destination` module name. + +Explicit entries always take precedence. If a relationship is declared in a fragment's +`neo4j do` block, check whether the extending resource's `relate` DSL correctly merges those +entries — a mismatch between the explicit edge label and the default generates a wrong label +in `mapping.edges`, causing enrichments to silently fail. + +## Aggregate execution paths + +`run_aggregate_for_ids/6` selects one of four paths based on the aggregate's properties: + +| Condition | Path | Description | +|---|---|---| +| `aggregate.field` is an `Ash.Query.Calculation` | expr path | Loads full dest records, evaluates Ash expression per record in Elixir | +| `aggregate_has_filter?(aggregate)` is true | filtered path | Loads full dest records, applies `Ash.Filter.Runtime.filter_matches`, computes aggregate in Elixir | +| field type is `:ash_json` (embedded/struct/map) | embedded path | Runs `collect(d.prop)` in Cypher, casts each raw JSON value via `Cast.cast/3` in Elixir | +| otherwise | Cypher path | Fully pushed down: `COUNT`, `SUM`, `AVG`, `MIN`, `MAX`, `collect`, `head(collect(...))` | + +`aggregate_has_filter?` treats `%Ash.Filter{expression: true}` as "no filter" (Ash always +attaches a trivial filter to unfiltered aggregates). Do not change this sentinel check. + +## Cypher.Query builders + +Every query shape used by the data layer has a typed builder in `Cypher.Query`. Builders +return `%Cypher.Query{clauses: [...], params: %{}}` structs that `Cypher.render/1` turns into +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. + +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 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 + +Tests require a running Neo4j instance (configured in `config/runtime.exs` via `BOLT_URL` +or similar). `AshNeo4j.Sandbox` wraps each test in a transaction that rolls back on completion. + +```sh +mix test # full suite +mix test test/blog_test.exs # single file +mix test test/blog_test.exs:LINE # single test +mix test --max-failures 5 # stop early +``` + +The sandbox uses `Process` dictionary flags (`ash_neo4j_in_sandbox_tx`, +`ash_neo4j_tx_stack`). Tests that bypass the sandbox or start their own transactions may +interfere with isolation — check the sandbox implementation before adding transaction logic +in tests. + +## Raising upstream bugs + +When a bug is found in a dependency (Bolty, Ash, Spark), raise a GitHub issue on that +repository. Use **diffo issue #125** as the style reference: + +- **## Description** — explain what the system does, what the code path is, and where it + breaks. Include a short Cypher or Elixir snippet if it makes the failure concrete. +- **## What we need** — state the correct behaviour plainly. +- **## Why it matters** — explain the practical impact. + +Do not attempt to locate or fix the root cause in the dependency. Add useful hypotheses +as a follow-up comment, then leave it with the upstream maintainers. + +## Common agent mistakes + +- **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). + Including them in translations would cause the property-read loop to overwrite the + enriched value with nil (the attribute has no corresponding node property). + +- **Assuming `Verifier.get_option(dsl, [:neo4j], :relate, [])` picks up fragment DSL options.** + `get_entities` picks up entities from fragments; option merging behaviour for `relate` (a + list option) must be verified separately. If a fragment's explicit `relate` entries are not + visible, `PersistRelate` generates default edges with wrong labels (e.g. `:BELONGS_TO` + instead of `:SPECIFIED_BY`), causing enrichments to silently fail. + +- **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. + Ordering declarations that target transformers from a persister are silently ignored. + +- **Using `List.delete/2` to filter domain labels** from destination node labels. It removes + only the first occurrence. If the source domain label happens to match a destination node + label, only one instance is removed. Prefer `List.delete_at` or label filtering by explicit + set membership when precision matters. + +- **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)` + pattern. It must use the same multi-label source pattern as `node_read`. + +- **Changing `aggregate_has_filter?` sentinel without understanding Ash's trivial filter.** + Ash attaches `%Ash.Filter{expression: true}` to every aggregate, even unfiltered ones. The + check `%Ash.Filter{expression: true} -> false` is intentional — it means "no user filter". + Removing or loosening it routes all aggregates through the Elixir path unnecessarily. + +- **Modifying `Cypher.render/1` to reorder clauses.** The clause list is ordered; render + outputs them in insertion order. Query correctness depends on this ordering. Always add + clauses in the correct semantic position in the builder, not in render. diff --git a/CHANGELOG.md b/CHANGELOG.md index c4bc704..d7a383e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,26 @@ See [Conventional Commits](Https://conventionalcommits.org) for commit guideline +## [v0.6.0](https://github.com/diffo-dev/ash_neo4j/compare/v0.5.1...v0.6.0) (2026-05-19) + +### Breaking Changes + +* **Introspection API renamed** (#105) — `AshNeo4j.DataLayer.Info` and `AshNeo4j.DataLayer.Domain.Info` are now generated by `Spark.InfoGenerator`. AshNeo4j now declares a direct `spark >= 2.7.0` dependency to guarantee availability. All functions follow the InfoGenerator convention: `neo4j_label/1` returns `{:ok, value} | :error`; `neo4j_label!/1` returns the value or raises; list options (`relate`, `guard`, `skip`) always return the list via the `!` variant. Previous hand-rolled helpers (`label/1`, `relate/1`, `guard/1`, `skip/1`) are removed. + +### Features + +* **Domain fragment label** (#261) — domains can declare a cross-domain graph label via `AshNeo4j.DataLayer.Domain` (`use Ash.Domain, extensions: [AshNeo4j.DataLayer.Domain]` with `neo4j do label :MyLabel end`). The fragment label is written as an additional Neo4j node label on CREATE, enabling polymorphic graph traversals across domains. Exposed via `ResourceInfo.domain_fragment_label/1` and included in `ResourceInfo.all_labels/1` and `ResourceInfo.mapping/1`. + +### Bug Fixes + +* **`belongs_to` FK always nil after read** (#258) — `belongs_to` source attributes (e.g. `specification_id`) were correctly populated on create but lost on any subsequent read. The enrichment step now correctly extracts the FK from the destination node returned by the OPTIONAL MATCH traversal when the source resource uses a fragment-inherited relationship whose destination lives in a different domain. + +* **Domain fragment label dropped on Ash 3.25+** — `ResourceInfo.all_labels/1` was returning the compile-time persisted label list, which is baked before the domain extension compiles under Ash 3.25's updated compilation order, causing the domain fragment label to be silently omitted. `all_labels/1` now always computes dynamically from the individual label accessors, consistent with how `mapping/1` already worked. + +### Improvements + +* **Scalar filter pushdown for aggregates** (#253) — filtered aggregates whose filter consists entirely of scalar `==` equality predicates on non-embedded destination attributes now push a `WHERE d.prop = $val` clause directly into Cypher, avoiding full destination record loading in Elixir. Complex filters (OR, embedded fields, non-equality operators) continue to use the Elixir-side path introduced in #252. + ## [v0.5.1](https://github.com/diffo-dev/ash_neo4j/compare/v0.5.0...v0.5.1) (2026-05-10) ### Improvements @@ -29,14 +49,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/ash_neo4j_datalayer.livemd b/ash_neo4j_datalayer.livemd index 736a43b..4df71c8 100644 --- a/ash_neo4j_datalayer.livemd +++ b/ash_neo4j_datalayer.livemd @@ -9,7 +9,7 @@ SPDX-License-Identifier: MIT ```elixir Mix.install( [ - {:ash_neo4j, "~> 0.5.0"} + {:ash_neo4j, "~> 0.6.0"} ], consolidate_protocols: false ) diff --git a/lib/cypher/query.ex b/lib/cypher/query.ex index 532f78e..a200ec8 100644 --- a/lib/cypher/query.ex +++ b/lib/cypher/query.ex @@ -153,13 +153,13 @@ defmodule AshNeo4j.Cypher.Query do # --------------------------------------------------------------------------- @doc """ - `MATCH (s:Label) OPTIONAL MATCH (s)-[r]-(d) RETURN s, r, d` + `MATCH (s:L1:L2) OPTIONAL MATCH (s)-[r]-(d) RETURN s, r, d` """ - @spec node_read(atom()) :: t() - def node_read(label) when is_atom(label) do + @spec node_read(atom() | [atom()]) :: t() + def node_read(label) do %__MODULE__{ clauses: [ - %Match{pattern: Cypher.node(:s, [label])}, + %Match{pattern: Cypher.node(:s, List.wrap(label))}, %OptionalMatch{pattern: "(s)-[r]-(d)"}, %Return{items: ["s", "r", "d"]} ] @@ -167,19 +167,19 @@ defmodule AshNeo4j.Cypher.Query do end @doc """ - `MATCH (s:Label) WHERE OPTIONAL MATCH (s)-[r]-(d) RETURN s, r, d` + `MATCH (s:L1:L2) WHERE OPTIONAL MATCH (s)-[r]-(d) RETURN s, r, d` Returns `node_read/1` when `conditions` is empty. """ - @spec node_read_filtered(atom(), [condition()]) :: t() - def node_read_filtered(label, []) when is_atom(label), do: node_read(label) + @spec node_read_filtered(atom() | [atom()], [condition()]) :: t() + def node_read_filtered(label, []), do: node_read(label) - def node_read_filtered(label, conditions) when is_atom(label) and is_list(conditions) do + def node_read_filtered(label, conditions) when is_list(conditions) do {where_string, params} = build_conditions(:s, conditions) %__MODULE__{ clauses: [ - %Match{pattern: Cypher.node(:s, [label])}, + %Match{pattern: Cypher.node(:s, List.wrap(label))}, %Where{conditions: [where_string]}, %OptionalMatch{pattern: "(s)-[r]-(d)"}, %Return{items: ["s", "r", "d"]} @@ -189,15 +189,15 @@ defmodule AshNeo4j.Cypher.Query do end @doc """ - `MATCH (s:SrcLabel)-[r:EdgeLabel]-(d:DestLabel) WHERE d.prop $param WITH s MATCH (s)-[r0]-(d0) RETURN s, r0, d0` + `MATCH (s:SrcLabels)-[r:EdgeLabel]-(d:DestLabel) WHERE d.prop $param WITH s MATCH (s)-[r0]-(d0) RETURN s, r0, d0` """ - @spec relationship_read(atom(), atom(), atom(), atom(), String.t(), atom(), any()) :: t() + @spec relationship_read(atom() | [atom()], atom(), atom(), atom(), String.t(), atom(), any()) :: t() def relationship_read(src_label, edge_label, direction, dest_label, dest_property, operator, value) - when is_atom(src_label) and is_atom(edge_label) and is_atom(direction) and is_atom(dest_label) do + when is_atom(edge_label) and is_atom(direction) and is_atom(dest_label) do param_key = "d_#{dest_property}" match_pattern = - Cypher.node(:s, [src_label]) <> + Cypher.node(:s, List.wrap(src_label)) <> Cypher.relationship(:r, edge_label, direction) <> Cypher.node(:d, [dest_label]) @@ -216,13 +216,13 @@ defmodule AshNeo4j.Cypher.Query do end @doc """ - `MATCH (n:Label {props}) OPTIONAL MATCH (n)-[r]-(d) RETURN n, r, d` + `MATCH (n:L1:L2 {props}) OPTIONAL MATCH (n)-[r]-(d) RETURN n, r, d` Like `node_read/1` but matches by properties in the MATCH pattern (not a WHERE clause). """ - @spec node_read_with_properties(atom(), map()) :: t() - def node_read_with_properties(label, properties) when is_atom(label) and is_map(properties) do - {pattern, params} = Cypher.parameterized_node(:s, [label], properties) + @spec node_read_with_properties(atom() | [atom()], map()) :: t() + def node_read_with_properties(label, properties) when is_map(properties) do + {pattern, params} = Cypher.parameterized_node(:s, List.wrap(label), properties) %__MODULE__{ clauses: [ @@ -270,16 +270,17 @@ defmodule AshNeo4j.Cypher.Query do Related-nodes query — returns one row per (source, destination) pair for expression-based aggregates that need full destination records for Elixir-side evaluation. - `MATCH (s:Label) WHERE s.pk IN $agg_ids OPTIONAL MATCH (s)(d) RETURN s.pk AS source_id, d AS dest_node` + `MATCH (s:L1:L2) WHERE s.pk IN $agg_ids OPTIONAL MATCH (s)(d) RETURN s.pk AS source_id, d AS dest_node` """ - @spec related_nodes(atom(), atom(), [any()], [{atom(), atom(), atom()}]) :: t() + @spec related_nodes(atom() | [atom()], atom(), [any()], [{atom(), atom(), atom()}]) :: t() def related_nodes(source_label, pk_field, ids, path_segments) - when is_atom(source_label) and is_atom(pk_field) and is_list(ids) and is_list(path_segments) do + when is_atom(pk_field) and is_list(ids) and is_list(path_segments) do path = build_agg_path(path_segments) + src = labels_string(source_label) %__MODULE__{ clauses: [ - %Match{pattern: "(s:#{source_label})"}, + %Match{pattern: "(s:#{src})"}, %Where{conditions: ["s.#{pk_field} IN $agg_ids"]}, %OptionalMatch{pattern: "(s)#{path}"}, %Return{items: ["s.#{pk_field} AS source_id", "d AS dest_node"]} @@ -291,57 +292,91 @@ defmodule AshNeo4j.Cypher.Query do @doc """ Per-record aggregate — returns one row per source node with the aggregate value. - `MATCH (s:Label) WHERE s.pk IN $agg_ids OPTIONAL MATCH (s)(d) RETURN s.pk AS source_id, agg_fn AS name` + `MATCH (s:L1:L2) WHERE s.pk IN $agg_ids OPTIONAL MATCH (s)(d) RETURN s.pk AS source_id, agg_fn AS name` `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() | [atom()], atom(), [any()], [{atom(), atom(), atom()}], atom(), atom() | nil, atom(), - boolean() + boolean(), + [{String.t(), any()}] ) :: 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 + def aggregate_per_record( + source_label, + pk_field, + ids, + path_segments, + kind, + field, + name, + uniq? \\ false, + dest_conditions \\ [] + ) + when is_atom(pk_field) and is_list(ids) and is_list(path_segments) and is_atom(kind) do path = build_agg_path(path_segments) expr = aggregate_expr(kind, field, name, uniq?) + src = labels_string(source_label) + {dest_where, dest_params} = build_dest_conditions(dest_conditions) %__MODULE__{ - clauses: [ - %Match{pattern: "(s:#{source_label})"}, - %Where{conditions: ["s.#{pk_field} IN $agg_ids"]}, - %OptionalMatch{pattern: "(s)#{path}"}, - %Return{items: ["s.#{pk_field} AS source_id", expr]} - ], - params: %{"agg_ids" => ids} + clauses: + [ + %Match{pattern: "(s:#{src})"}, + %Where{conditions: ["s.#{pk_field} IN $agg_ids"]}, + %OptionalMatch{pattern: "(s)#{path}"} + ] ++ dest_where ++ [%Return{items: ["s.#{pk_field} AS source_id", expr]}], + params: Map.merge(%{"agg_ids" => ids}, dest_params) } end @doc """ Total aggregate — returns a single row with the aggregate value across all source nodes. - `MATCH (s:Label) WHERE s.pk IN $agg_ids OPTIONAL MATCH (s)(d) RETURN agg_fn AS name` + `MATCH (s:L1:L2) WHERE s.pk IN $agg_ids OPTIONAL MATCH (s)(d) RETURN agg_fn AS name` """ - @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 + @spec aggregate_total( + atom() | [atom()], + atom(), + [any()], + [{atom(), atom(), atom()}], + atom(), + atom() | nil, + atom(), + boolean(), + [{String.t(), any()}] + ) :: t() + def aggregate_total( + source_label, + pk_field, + ids, + path_segments, + kind, + field, + name, + uniq? \\ false, + dest_conditions \\ [] + ) + when is_atom(pk_field) and is_list(ids) and is_list(path_segments) and is_atom(kind) do path = build_agg_path(path_segments) expr = aggregate_expr(kind, field, name, uniq?) + src = labels_string(source_label) + {dest_where, dest_params} = build_dest_conditions(dest_conditions) %__MODULE__{ - clauses: [ - %Match{pattern: "(s:#{source_label})"}, - %Where{conditions: ["s.#{pk_field} IN $agg_ids"]}, - %OptionalMatch{pattern: "(s)#{path}"}, - %Return{items: [expr]} - ], - params: %{"agg_ids" => ids} + clauses: + [ + %Match{pattern: "(s:#{src})"}, + %Where{conditions: ["s.#{pk_field} IN $agg_ids"]}, + %OptionalMatch{pattern: "(s)#{path}"} + ] ++ dest_where ++ [%Return{items: [expr]}], + params: Map.merge(%{"agg_ids" => ids}, dest_params) } end @@ -368,14 +403,14 @@ defmodule AshNeo4j.Cypher.Query do end @doc """ - `MATCH (n:Label {match_props}) SET n += {set_props} REMOVE n.p1, n.p2 RETURN n` + `MATCH (n:L1:L2 {match_props}) SET n += {set_props} REMOVE n.p1, n.p2 RETURN n` Handles all combinations of empty/non-empty set_props and remove_props. """ - @spec update_node(atom(), map(), map(), [atom()]) :: t() + @spec update_node(atom() | [atom()], map(), map(), [atom()]) :: t() def update_node(label, match_props, set_props, remove_props \\ []) - when is_atom(label) and is_map(match_props) and is_map(set_props) and is_list(remove_props) do - {match_pattern, match_params} = Cypher.parameterized_node(:n, [label], match_props) + when is_map(match_props) and is_map(set_props) and is_list(remove_props) do + {match_pattern, match_params} = Cypher.parameterized_node(:n, List.wrap(label), match_props) {props_cypher, set_params} = Cypher.parameterized_properties(:n, set_props) set_clauses = if map_size(set_props) > 0, do: [%Set{expression: "n += #{props_cypher}"}], else: [] @@ -388,26 +423,26 @@ defmodule AshNeo4j.Cypher.Query do end @doc """ - `MATCH (n:Label {props}) DETACH DELETE n` + `MATCH (n:L1:L2 {props}) DETACH DELETE n` """ - @spec delete_nodes(atom(), map()) :: t() - def delete_nodes(label, properties \\ %{}) when is_atom(label) and is_map(properties) do - {pattern, params} = Cypher.parameterized_node(:n, [label], properties) + @spec delete_nodes(atom() | [atom()], map()) :: t() + def delete_nodes(label, properties \\ %{}) when is_map(properties) do + {pattern, params} = Cypher.parameterized_node(:n, List.wrap(label), properties) %__MODULE__{clauses: [%Match{pattern: pattern}, %DetachDelete{items: ["n"]}], params: params} end @doc """ - `MATCH (n:Label {props}) WHERE NOT guard1 AND NOT guard2 DETACH DELETE n` + `MATCH (n:L1:L2 {props}) WHERE NOT guard1 AND NOT guard2 DETACH DELETE n` `guards` is a list of `{edge_label, direction, dest_label}` tuples. Falls back to `delete_nodes/2` when guards is empty. """ - @spec delete_nodes_guarded(atom(), map(), list()) :: t() + @spec delete_nodes_guarded(atom() | [atom()], map(), list()) :: t() def delete_nodes_guarded(label, properties, []), do: delete_nodes(label, properties) 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) + when is_map(properties) and is_list(guards) do + {pattern, params} = Cypher.parameterized_node(:n, List.wrap(label), properties) conditions = Enum.map(guards, fn {edge_label, direction, dest_label} -> @@ -448,10 +483,10 @@ defmodule AshNeo4j.Cypher.Query do DELETE r0 WITH s MATCH (d:DestLabel {d_props}) MERGE (s)-[r:EDGE]->(d) RETURN s, r, d """ - @spec relate_unrelating_source(atom(), map(), atom(), map(), atom(), atom()) :: t() + @spec relate_unrelating_source(atom() | [atom()], map(), atom(), map(), atom(), atom()) :: t() def relate_unrelating_source(src_label, src_props, dest_label, dest_props, edge_label, direction) - when is_atom(src_label) and is_atom(dest_label) and is_atom(edge_label) and is_atom(direction) do - {src_pattern, src_params} = Cypher.parameterized_node(:s, [src_label], src_props) + when is_atom(dest_label) and is_atom(edge_label) and is_atom(direction) do + {src_pattern, src_params} = Cypher.parameterized_node(:s, List.wrap(src_label), src_props) {dest_pattern, dest_params} = Cypher.parameterized_node(:d, [dest_label], dest_props) %__MODULE__{ @@ -478,10 +513,11 @@ defmodule AshNeo4j.Cypher.Query do WITH s, d OPTIONAL MATCH (s0:SrcLabel)-[r0:EDGE]->(d) WHERE s0 <> s DELETE r0 WITH s, d MERGE (s)-[r:EDGE]->(d) RETURN s, r, d """ - @spec relate_unrelating_destination(atom(), map(), atom(), map(), atom(), atom()) :: t() + @spec relate_unrelating_destination(atom() | [atom()], map(), atom(), map(), atom(), atom()) :: t() def relate_unrelating_destination(src_label, src_props, dest_label, dest_props, edge_label, direction) - when is_atom(src_label) and is_atom(dest_label) and is_atom(edge_label) and is_atom(direction) do - {src_pattern, src_params} = Cypher.parameterized_node(:s, [src_label], src_props) + when is_atom(dest_label) and is_atom(edge_label) and is_atom(direction) do + src_labels = List.wrap(src_label) + {src_pattern, src_params} = Cypher.parameterized_node(:s, src_labels, src_props) {dest_pattern, dest_params} = Cypher.parameterized_node(:d, [dest_label], dest_props) %__MODULE__{ @@ -490,7 +526,7 @@ defmodule AshNeo4j.Cypher.Query do %OptionalMatch{pattern: dest_pattern}, %With{items: ["s", "d"]}, %OptionalMatch{ - pattern: Cypher.node(:s0, [src_label]) <> Cypher.relationship(:r0, edge_label, direction) <> "(d)" + pattern: Cypher.node(:s0, src_labels) <> Cypher.relationship(:r0, edge_label, direction) <> "(d)" }, %Where{conditions: ["s0 <> s"]}, %Delete{items: ["r0"]}, @@ -511,10 +547,11 @@ defmodule AshNeo4j.Cypher.Query do OPTIONAL MATCH (s0:SrcLabel)-[r0:EDGE]->(d) WHERE s0 <> s DELETE r0 WITH s, d MERGE (s)-[r:EDGE]->(d) RETURN s, r, d """ - @spec relate_unrelating_both(atom(), map(), atom(), map(), atom(), atom()) :: t() + @spec relate_unrelating_both(atom() | [atom()], map(), atom(), map(), atom(), atom()) :: t() def relate_unrelating_both(src_label, src_props, dest_label, dest_props, edge_label, direction) - when is_atom(src_label) and is_atom(dest_label) and is_atom(edge_label) and is_atom(direction) do - {src_pattern, src_params} = Cypher.parameterized_node(:s, [src_label], src_props) + when is_atom(dest_label) and is_atom(edge_label) and is_atom(direction) do + src_labels = List.wrap(src_label) + {src_pattern, src_params} = Cypher.parameterized_node(:s, src_labels, src_props) {dest_pattern, dest_params} = Cypher.parameterized_node(:d, [dest_label], dest_props) %__MODULE__{ @@ -527,7 +564,7 @@ defmodule AshNeo4j.Cypher.Query do %OptionalMatch{pattern: dest_pattern}, %With{items: ["s", "d"]}, %OptionalMatch{ - pattern: Cypher.node(:s0, [src_label]) <> Cypher.relationship(:r0, edge_label, direction) <> "(d)" + pattern: Cypher.node(:s0, src_labels) <> Cypher.relationship(:r0, edge_label, direction) <> "(d)" }, %Where{conditions: ["s0 <> s"]}, %Delete{items: ["r0"]}, @@ -542,10 +579,10 @@ defmodule AshNeo4j.Cypher.Query do @doc """ `MATCH (s:SrcLabel {s_props})-[r:EDGE]->(d:DestLabel {d_props}) DELETE r RETURN s, d` """ - @spec unrelate(atom(), map(), atom(), map(), atom(), atom()) :: t() + @spec unrelate(atom() | [atom()], map(), atom(), map(), atom(), atom()) :: t() def unrelate(src_label, src_props, dest_label, dest_props, edge_label, direction) - when is_atom(src_label) and is_atom(dest_label) and is_atom(edge_label) and is_atom(direction) do - {src_pattern, src_params} = Cypher.parameterized_node(:s, [src_label], src_props) + when is_atom(dest_label) and is_atom(edge_label) and is_atom(direction) do + {src_pattern, src_params} = Cypher.parameterized_node(:s, List.wrap(src_label), src_props) {dest_pattern, dest_params} = Cypher.parameterized_node(:d, [dest_label], dest_props) path_pattern = src_pattern <> Cypher.relationship(:r, edge_label, direction) <> dest_pattern @@ -564,6 +601,8 @@ defmodule AshNeo4j.Cypher.Query do # Private helpers # --------------------------------------------------------------------------- + defp labels_string(label) when is_list(label), do: Enum.join(label, ":") + defp guard_condition(variable, edge_label, direction, dest_label) do rel = case direction do @@ -575,6 +614,20 @@ defmodule AshNeo4j.Cypher.Query do "NOT (#{variable})#{rel}(:#{dest_label})" end + defp build_dest_conditions([]), do: {[], %{}} + + defp build_dest_conditions(dest_conditions) do + {cond_strings, params} = + dest_conditions + |> Enum.with_index() + |> Enum.reduce({[], %{}}, fn {{prop, val}, idx}, {parts, params} -> + key = "agg_filter_#{idx}" + {["d.#{prop} = $#{key}" | parts], Map.put(params, key, val)} + end) + + {[%Where{conditions: Enum.reverse(cond_strings)}], params} + end + defp build_conditions(variable, conditions) do conditions |> Enum.with_index() diff --git a/lib/data_layer.ex b/lib/data_layer.ex index 04dde5a..7a5b2ce 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: [] ] ] } @@ -320,7 +323,7 @@ defmodule AshNeo4j.DataLayer do mapping = ResourceInfo.mapping(resource) subject_id = id_properties(mapping, changeset.data) - subject_label = mapping.label + subject_label = mapping.label_pair update_properties = dump_properties(mapping, changeset.attributes) @@ -532,7 +535,7 @@ defmodule AshNeo4j.DataLayer do """) mapping = ResourceInfo.mapping(resource) - label = mapping.label + label = mapping.label_pair id_properties = id_properties(mapping, changeset.data) result = @@ -895,7 +898,7 @@ defmodule AshNeo4j.DataLayer do end ) - label = mapping.label + label = mapping.label_pair id_properties = id_properties(mapping, attributes) case Neo4jHelper.relate_nodes(label, id_properties, relationships) do @@ -924,7 +927,7 @@ defmodule AshNeo4j.DataLayer do end defp create_node(%ResourceMapping{} = mapping, properties) when is_map(properties) do - case mapping.labels |> Neo4jHelper.create_node(properties) do + case mapping.all_labels |> Neo4jHelper.create_node(properties) do {:ok, %Bolty.Response{results: [node_map | _]}} -> node = Map.get(node_map, "n") convert_node_to_resource(mapping.module, node) @@ -1084,11 +1087,25 @@ defmodule AshNeo4j.DataLayer do is_struct(aggregate.field, Ash.Query.Calculation) -> run_expr_agg(mapping, neo4j_pk, ids, aggregate, mode, path_segments, dest_mapping) - # When a filter is present on a plain or embedded aggregate, load full - # destination records in Elixir so Ash.Filter.Runtime can evaluate it. - # Honouring the filter is a contract implied by can?({:aggregate, kind}). + # When a filter is present, try to push scalar == conditions into Cypher. + # Falls back to Elixir-side filtering for complex or embedded-field filters. aggregate_has_filter?(aggregate) -> - run_filtered_aggregate(mapping, neo4j_pk, ids, aggregate, mode, path_segments, dest_mapping) + case {simple_agg_filter(aggregate, dest_mapping), embedded} do + {{:ok, dest_conditions}, nil} -> + run_simple_filtered_aggregate( + mapping, + neo4j_pk, + ids, + aggregate, + mode, + path_segments, + neo4j_field, + dest_conditions + ) + + _ -> + run_filtered_aggregate(mapping, neo4j_pk, ids, aggregate, mode, path_segments, dest_mapping) + end embedded -> {field_type, field_constraints} = embedded @@ -1110,7 +1127,7 @@ defmodule AshNeo4j.DataLayer do case mode do :per_record -> CypherQuery.aggregate_per_record( - mapping.label, + mapping.label_pair, neo4j_pk, ids, path_segments, @@ -1122,7 +1139,7 @@ defmodule AshNeo4j.DataLayer do :total -> CypherQuery.aggregate_total( - mapping.label, + mapping.label_pair, neo4j_pk, ids, path_segments, @@ -1164,7 +1181,7 @@ defmodule AshNeo4j.DataLayer do # This path is also used for expression-based aggregates (Ash.Query.Calculation # field) when a filter is present, because we already load full records there. defp run_filtered_aggregate(mapping, neo4j_pk, ids, aggregate, mode, path_segments, dest_mapping) do - query = CypherQuery.related_nodes(mapping.label, neo4j_pk, ids, path_segments) + query = CypherQuery.related_nodes(mapping.label_pair, neo4j_pk, ids, path_segments) dest_resource = dest_mapping.module domain = Ash.Resource.Info.domain(dest_resource) @@ -1206,6 +1223,100 @@ defmodule AshNeo4j.DataLayer do end end + # Handles aggregates whose filter is a set of simple scalar == conditions that can be + # expressed as WHERE clauses in Cypher, avoiding full record loading in Elixir. + defp run_simple_filtered_aggregate(mapping, neo4j_pk, ids, aggregate, mode, path_segments, neo4j_field, dest_conditions) do + query = + case mode do + :per_record -> + CypherQuery.aggregate_per_record( + mapping.label_pair, + neo4j_pk, + ids, + path_segments, + aggregate.kind, + neo4j_field, + aggregate.name, + aggregate.uniq?, + dest_conditions + ) + + :total -> + CypherQuery.aggregate_total( + mapping.label_pair, + neo4j_pk, + ids, + path_segments, + aggregate.kind, + neo4j_field, + aggregate.name, + aggregate.uniq?, + dest_conditions + ) + end + + case Cypher.run(query) do + {:ok, %Bolty.Response{results: rows}} -> + case mode do + :per_record -> + {:ok, + Map.new(rows, fn row -> + {Map.get(row, "source_id"), Map.get(row, to_string(aggregate.name))} + end)} + + :total -> + value = rows |> List.first(%{}) |> Map.get(to_string(aggregate.name), aggregate.default_value) + {:ok, value} + end + + {:error, e} -> + {:error, e} + end + end + + # Returns {:ok, [{prop_string, value}]} when the aggregate filter consists entirely of + # scalar == equality predicates on non-embedded destination attributes, enabling + # WHERE pushdown into Cypher. Returns :complex otherwise and falls back to Elixir-side filtering. + defp simple_agg_filter(aggregate, dest_mapping) do + filter = aggregate_query_filter(aggregate) + + try do + simple = Ash.Filter.to_simple_filter(filter, skip_invalid?: false) + predicates = Map.get(simple, :predicates, []) + + if Enum.empty?(predicates) do + :complex + else + Enum.reduce_while(predicates, {:ok, []}, fn predicate, {:ok, acc} -> + cond do + Map.get(predicate, :operator) != :== -> + {:halt, :complex} + + not match?(%Ash.Query.Ref{}, Map.get(predicate, :left)) -> + {:halt, :complex} + + match?(%Ash.Query.Calculation{}, Map.get(predicate.left, :attribute)) -> + {:halt, :complex} + + true -> + attr_name = Ash.Query.Ref.name(predicate.left) + + case embedded_field_type(dest_mapping.module, attr_name) do + nil -> + prop = Keyword.get(dest_mapping.properties, attr_name, attr_name) |> to_string() + {:cont, {:ok, acc ++ [{prop, predicate.right}]}} + + _ -> + {:halt, :complex} + end + end + end) + end + rescue + _ -> :complex + end + end + # Extracts the aggregate's target field value from each record, respecting uniq?. defp extract_aggregate_field_values(records, aggregate) do values = @@ -1252,7 +1363,7 @@ defmodule AshNeo4j.DataLayer do case mode do :per_record -> CypherQuery.aggregate_per_record( - mapping.label, + mapping.label_pair, neo4j_pk, ids, path_segments, @@ -1264,7 +1375,7 @@ defmodule AshNeo4j.DataLayer do :total -> CypherQuery.aggregate_total( - mapping.label, + mapping.label_pair, neo4j_pk, ids, path_segments, @@ -1301,7 +1412,7 @@ defmodule AshNeo4j.DataLayer do end defp run_expr_agg(mapping, neo4j_pk, ids, aggregate, mode, path_segments, dest_mapping) do - query = CypherQuery.related_nodes(mapping.label, neo4j_pk, ids, path_segments) + query = CypherQuery.related_nodes(mapping.label_pair, neo4j_pk, ids, path_segments) dest_resource = dest_mapping.module domain = Ash.Resource.Info.domain(dest_resource) calc = aggregate.field diff --git a/lib/data_layer/domain.ex b/lib/data_layer/domain.ex new file mode 100644 index 0000000..6869ec2 --- /dev/null +++ b/lib/data_layer/domain.ex @@ -0,0 +1,56 @@ +# SPDX-FileCopyrightText: 2025 ash_neo4j contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshNeo4j.DataLayer.Domain.PersistFragmentLabel do + @moduledoc false + use Spark.Dsl.Transformer + alias Spark.Dsl.Transformer + + @impl true + def transform(dsl) do + fragment_label = Transformer.get_option(dsl, [:neo4j], :label) + {:ok, Transformer.persist(dsl, :neo4j_domain_label, fragment_label)} + end +end + +defmodule AshNeo4j.DataLayer.Domain do + @moduledoc """ + Domain-level DSL extension for AshNeo4j. + + Attach to an Ash domain (directly or via a domain fragment) to write an additional + label on every node in that domain. + + defmodule Telco do + use Spark.Dsl.Fragment, + of: Ash.Domain, + extensions: [AshNeo4j.DataLayer.Domain] + + neo4j do + label :Telco + end + end + + defmodule Provider do + use Ash.Domain, fragments: [Telco] + end + + Nodes for resources in `Provider` will have `:Telco` written as an additional + label on CREATE, giving the graph a semantically navigable axis. + """ + + @neo4j %Spark.Dsl.Section{ + name: :neo4j, + schema: [ + label: [ + type: :atom, + doc: "Label written on CREATE for all nodes whose resource belongs to this domain.", + required: false + ] + ] + } + + use Spark.Dsl.Extension, + sections: [@neo4j], + transformers: [AshNeo4j.DataLayer.Domain.PersistFragmentLabel] +end diff --git a/lib/data_layer/domain/info.ex b/lib/data_layer/domain/info.ex new file mode 100644 index 0000000..1985f05 --- /dev/null +++ b/lib/data_layer/domain/info.ex @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: 2025 ash_neo4j contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshNeo4j.DataLayer.Domain.Info do + @moduledoc "Introspection helpers for AshNeo4j.DataLayer.Domain" + 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/neo4j_helper.ex b/lib/neo4j_helper.ex index f153e7a..dbfa193 100644 --- a/lib/neo4j_helper.ex +++ b/lib/neo4j_helper.ex @@ -83,7 +83,7 @@ defmodule AshNeo4j.Neo4jHelper do :ok ``` """ - def safe_delete_nodes(label, properties, relationships) when is_atom(label) do + def safe_delete_nodes(label, properties, relationships) when is_atom(label) or is_list(label) do Query.delete_nodes_guarded(label, properties, relationships) |> Cypher.run_expecting_deletions() end @@ -121,7 +121,7 @@ defmodule AshNeo4j.Neo4jHelper do ``` """ def update_node(label, match_properties, set_properties, remove_properties \\ []) - when is_atom(label) and is_map(set_properties) do + when (is_atom(label) or is_list(label)) and is_map(set_properties) do Query.update_node(label, match_properties, set_properties, remove_properties) |> Cypher.run() end @@ -164,7 +164,8 @@ defmodule AshNeo4j.Neo4jHelper do ``` """ def unrelate_nodes(source_label, source_properties, dest_label, dest_properties, edge_label, edge_direction) - when is_atom(source_label) and is_map(source_properties) and is_atom(dest_label) and is_map(dest_properties) and + when (is_atom(source_label) or is_list(source_label)) and is_map(source_properties) and + is_atom(dest_label) and is_map(dest_properties) and is_atom(edge_label) and is_atom(edge_direction) do Query.unrelate(source_label, source_properties, dest_label, dest_properties, edge_label, edge_direction) |> Cypher.run() @@ -194,7 +195,8 @@ defmodule AshNeo4j.Neo4jHelper do edge_label, edge_direction ) - when is_atom(source_label) and is_map(source_properties) and is_atom(dest_label) and is_map(dest_properties) and + when (is_atom(source_label) or is_list(source_label)) and is_map(source_properties) and + is_atom(dest_label) and is_map(dest_properties) and is_atom(edge_label) and is_atom(edge_direction) do Query.relate_unrelating_source( source_label, @@ -231,7 +233,8 @@ defmodule AshNeo4j.Neo4jHelper do edge_label, edge_direction ) - when is_atom(source_label) and is_map(source_properties) and is_atom(dest_label) and is_map(dest_properties) and + when (is_atom(source_label) or is_list(source_label)) and is_map(source_properties) and + is_atom(dest_label) and is_map(dest_properties) and is_atom(edge_label) and is_atom(edge_direction) do Query.relate_unrelating_destination( source_label, @@ -270,7 +273,8 @@ defmodule AshNeo4j.Neo4jHelper do edge_label, edge_direction ) - when is_atom(source_label) and is_map(source_properties) and is_atom(dest_label) and is_map(dest_properties) and + when (is_atom(source_label) or is_list(source_label)) and is_map(source_properties) and + is_atom(dest_label) and is_map(dest_properties) and is_atom(edge_label) and is_atom(edge_direction) do Query.relate_unrelating_both( source_label, @@ -342,7 +346,7 @@ defmodule AshNeo4j.Neo4jHelper do ``` """ def relate_nodes(label, properties, relationships) - when is_atom(label) and is_map(properties) and is_list(relationships) do + when (is_atom(label) or is_list(label)) and is_map(properties) and is_list(relationships) do results = Enum.reduce_while(relationships, [], fn {dest_label, dest_properties, edge_label, edge_direction, exclusive}, acc -> @@ -503,7 +507,7 @@ defmodule AshNeo4j.Neo4jHelper do 1 ``` """ - def read_nodes_related(label, properties \\ %{}) when is_atom(label) and is_map(properties) do + def read_nodes_related(label, properties \\ %{}) when (is_atom(label) or is_list(label)) and is_map(properties) do Query.node_read_with_properties(label, properties) |> Cypher.run() end diff --git a/lib/persisters/persist_labels.ex b/lib/persisters/persist_labels.ex index af866d1..511b0b6 100644 --- a/lib/persisters/persist_labels.ex +++ b/lib/persisters/persist_labels.ex @@ -18,16 +18,33 @@ defmodule AshNeo4j.Persisters.PersistLabels do module_label = short_name(resource_module) resource_label = Transformer.get_option(dsl, [:neo4j], :label, module_label) + domain_fragment_label = + if Code.ensure_loaded?(domain_module) and + function_exported?(domain_module, :spark_dsl_config, 0) do + 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). # resource_label may differ when a fragment contributes a base type label (e.g. :Instance from BaseInstance). - # Both are written on CREATE so polymorphic traversals work; reads match on resource_label only. - labels = [domain_label | Enum.uniq([module_label, resource_label])] + # domain_fragment_label comes from a domain fragment using AshNeo4j.DataLayer.Domain. + # all_labels: written on CREATE (up to 4 labels). + # label_pair: [domain_label, module_label] — used for MATCH on read, update, delete. + all_labels = + [domain_label | Enum.uniq([module_label, resource_label])] + |> then(fn ls -> if domain_fragment_label, do: ls ++ [domain_fragment_label], else: ls end) + + label_pair = [domain_label, module_label] {:ok, dsl |> Transformer.persist(:domain_label, domain_label) |> Transformer.persist(:module_label, module_label) |> Transformer.persist(:label, resource_label) - |> Transformer.persist(:labels, labels)} + |> Transformer.persist(:domain_fragment_label, domain_fragment_label) + |> Transformer.persist(:all_labels, all_labels) + |> Transformer.persist(:label_pair, label_pair)} end end diff --git a/lib/persisters/persist_mapping.ex b/lib/persisters/persist_mapping.ex index 148edf3..262f57a 100644 --- a/lib/persisters/persist_mapping.ex +++ b/lib/persisters/persist_mapping.ex @@ -22,7 +22,9 @@ defmodule AshNeo4j.Persisters.PersistMapping do domain_label = Verifier.get_persisted(dsl, :domain_label) module_label = Verifier.get_persisted(dsl, :module_label) label = Verifier.get_persisted(dsl, :label) - labels = Verifier.get_persisted(dsl, :labels, []) + domain_fragment_label = Verifier.get_persisted(dsl, :domain_fragment_label) + all_labels = Verifier.get_persisted(dsl, :all_labels, []) + label_pair = Verifier.get_persisted(dsl, :label_pair, []) properties = Verifier.get_persisted(dsl, :translations, []) relate = Verifier.get_persisted(dsl, :relate, []) relationship_attributes = Verifier.get_persisted(dsl, :relationship_attributes, []) @@ -34,7 +36,9 @@ defmodule AshNeo4j.Persisters.PersistMapping do domain_label: domain_label, module_label: module_label, label: label, - labels: labels, + domain_fragment_label: domain_fragment_label, + all_labels: all_labels, + label_pair: label_pair, properties: properties, edges: Enum.map(relate, &EdgeDescriptor.from_relate/1), relationship_attributes: relationship_attributes, diff --git a/lib/query_helper.ex b/lib/query_helper.ex index f1e5934..f088cb1 100644 --- a/lib/query_helper.ex +++ b/lib/query_helper.ex @@ -40,7 +40,7 @@ defmodule AshNeo4j.QueryHelper do defp build_query(ash_query, %ResourceMapping{} = mapping) do if ash_query.filter == nil do - Query.node_read(mapping.label) + Query.node_read(mapping.label_pair) else simple_filter = Ash.Filter.to_simple_filter(ash_query.filter, skip_invalid?: true) @@ -53,7 +53,7 @@ defmodule AshNeo4j.QueryHelper do if predicates == [] do Logger.debug("AshNeo4j.QueryHelper: filter #{inspect(ash_query.filter)} is not a simple filter") - Query.node_read(mapping.label) + Query.node_read(mapping.label_pair) else build_filtered_query(mapping, predicates) end @@ -76,7 +76,7 @@ defmodule AshNeo4j.QueryHelper do cond do Enum.empty?(relationship_predicates) -> conditions = to_conditions(mapping, property_predicates) - Query.node_read_filtered(mapping.label, conditions) + Query.node_read_filtered(mapping.label_pair, conditions) length(relationship_predicates) == 1 -> predicate = hd(relationship_predicates) @@ -90,7 +90,7 @@ defmodule AshNeo4j.QueryHelper do ResourceInfo.convert_to_property_name(relationship.destination, relationship.destination_attribute) Query.relationship_read( - mapping.label, + mapping.label_pair, edge.label, edge.direction, dest_label, @@ -101,7 +101,7 @@ defmodule AshNeo4j.QueryHelper do true -> Logger.debug("AshNeo4j.QueryHelper: combination of predicates #{inspect(predicates)} not supported") - Query.node_read(mapping.label) + Query.node_read(mapping.label_pair) end end diff --git a/lib/resource/info.ex b/lib/resource/info.ex index 670664f..8228cfe 100644 --- a/lib/resource/info.ex +++ b/lib/resource/info.ex @@ -37,15 +37,49 @@ defmodule AshNeo4j.Resource.Info do Extension.get_persisted(resource, :domain_label, nil) end + @doc """ + The label contributed by a domain fragment using `AshNeo4j.DataLayer.Domain`. + Written on CREATE as an additional label for graph traversal. `nil` when the domain + declares no fragment label. + """ + @spec domain_fragment_label(Ash.Resource.t()) :: atom() | nil + def domain_fragment_label(resource) do + case Extension.get_persisted(resource, :domain_fragment_label, nil) do + nil -> + domain = Extension.get_persisted(resource, :domain, nil) + if domain do + case AshNeo4j.DataLayer.Domain.Info.neo4j_label(domain) do + {:ok, label} -> label + :error -> nil + end + end + + val -> + val + end + end + + @doc """ + The two-label pair `[domain_label, module_label]` used in MATCH for all read, update, + delete, and aggregate operations. Always uniquely identifies this specific resource type. + """ + @spec label_pair(Ash.Resource.t()) :: [atom()] + def label_pair(resource) do + Extension.get_persisted(resource, :label_pair, [domain_label(resource), module_label(resource)]) + end + @doc """ Returns the full list of labels written to the node on CREATE. Always starts with the domain - label, followed by the module label, then any additional base type labels from fragments. - For example, `DiffoExample.Access.Shelf` (using `BaseInstance`) returns `[:Access, :Shelf, :Instance]`. + label, followed by the module label, then any additional base type label from a resource + fragment, then the domain fragment label if the domain uses `AshNeo4j.DataLayer.Domain`. + For example, `DiffoExample.Access.Shelf` (using `BaseInstance` and a `Telco` domain fragment) + returns `[:Access, :Shelf, :Instance, :Telco]`. """ - @spec labels(Ash.Resource.t()) :: list(atom()) | nil - def labels(resource) do - Extension.get_persisted(resource, :labels, nil) || - [domain_label(resource), label(resource)] |> Enum.uniq() |> Enum.filter(& &1) + @spec all_labels(Ash.Resource.t()) :: list(atom()) | nil + def all_labels(resource) do + [domain_label(resource), module_label(resource), label(resource), domain_fragment_label(resource)] + |> Enum.uniq() + |> Enum.filter(& &1) end @doc """ @@ -54,22 +88,33 @@ defmodule AshNeo4j.Resource.Info do """ @spec mapping(Ash.Resource.t()) :: ResourceMapping.t() def mapping(resource) do - if function_exported?(resource, :__ash_neo4j_mapping__, 0) do - resource.__ash_neo4j_mapping__() - else - %ResourceMapping{ - module: resource, - domain_label: domain_label(resource), - module_label: module_label(resource), - label: label(resource), - labels: labels(resource), - 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) - } - end + base = + if function_exported?(resource, :__ash_neo4j_mapping__, 0) do + resource.__ash_neo4j_mapping__() + else + %ResourceMapping{ + module: resource, + domain_label: domain_label(resource), + module_label: module_label(resource), + label: label(resource), + label_pair: label_pair(resource), + properties: translations(resource), + edges: Enum.map(relate(resource), &EdgeDescriptor.from_relate/1), + relationship_attributes: relationship_attributes(resource), + guards: AshNeo4j.DataLayer.Info.neo4j_guard!(resource), + skip: AshNeo4j.DataLayer.Info.neo4j_skip!(resource) + } + end + + frag_label = domain_fragment_label(resource) + + %{base | domain_fragment_label: frag_label, all_labels: all_labels_for(base, frag_label)} + end + + defp all_labels_for(%ResourceMapping{} = base, frag_label) do + [base.domain_label, base.module_label, base.label, frag_label] + |> Enum.uniq() + |> Enum.filter(& &1) end @doc """ @@ -255,7 +300,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) @@ -306,7 +351,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/lib/resource_mapping.ex b/lib/resource_mapping.ex index 311d164..7204d85 100644 --- a/lib/resource_mapping.ex +++ b/lib/resource_mapping.ex @@ -21,8 +21,13 @@ defmodule AshNeo4j.ResourceMapping do - `:label` — the label used in MATCH for reads, updates, and deletes; comes from the DSL `label` option and may be a fragment base-type label (e.g. `:Instance` when `Shelf` extends `BaseInstance`). - - `:labels` — full ordered list of labels written on CREATE: `[domain_label, module_label, ...]` - including any additional base-type labels from fragments (e.g. `[:Access, :Shelf, :Instance]`). + - `:domain_fragment_label` — optional label contributed by a domain fragment using + `AshNeo4j.DataLayer.Domain` (e.g. `:Telco`). `nil` when the domain declares none. + - `:all_labels` — full ordered list of labels written on CREATE: `[domain_label, module_label, ...]` + including any base-type label from a resource fragment and the domain fragment label if present + (e.g. `[:Access, :Shelf, :Instance, :Telco]`). + - `:label_pair` — the two-label pair `[domain_label, module_label]` used in MATCH for all + read, update, delete, and aggregate operations. Always uniquely identifies this resource. - `:properties` — keyword list of `{ash_attribute_name, neo4j_property_name}` translations; insertion order is preserved. - `:edges` — list of `AshNeo4j.EdgeDescriptor.t()` structs, one per `relate` entry. @@ -39,7 +44,9 @@ defmodule AshNeo4j.ResourceMapping do domain_label: atom(), module_label: atom(), label: atom(), - labels: [atom()], + domain_fragment_label: atom() | nil, + all_labels: [atom()], + label_pair: [atom()], properties: keyword(String.t()), edges: [EdgeDescriptor.t()], relationship_attributes: keyword(atom()), @@ -52,7 +59,9 @@ defmodule AshNeo4j.ResourceMapping do :domain_label, :module_label, :label, - :labels, + :domain_fragment_label, + :all_labels, + :label_pair, :properties, :edges, :relationship_attributes, diff --git a/lib/verifiers/verify_labels_pascal_case.ex b/lib/verifiers/verify_labels_pascal_case.ex index a39117c..519040d 100644 --- a/lib/verifiers/verify_labels_pascal_case.ex +++ b/lib/verifiers/verify_labels_pascal_case.ex @@ -13,7 +13,7 @@ defmodule AshNeo4j.Verifiers.VerifyLabelsPascalCase do @impl true def verify(dsl) do resource = Verifier.get_persisted(dsl, :module) - labels = Verifier.get_persisted(dsl, :labels) + labels = Verifier.get_persisted(dsl, :all_labels) cond do labels == [] -> diff --git a/mix.exs b/mix.exs index ff137d6..e1ef3fd 100644 --- a/mix.exs +++ b/mix.exs @@ -6,7 +6,7 @@ defmodule AshNeo4j.MixProject do @moduledoc false use Mix.Project - @version "0.5.1" + @version "0.6.0" @name "AshNeo4j" @description "Ash DataLayer for Neo4j" @github_url "https://github.com/diffo-dev/ash_neo4j" @@ -124,6 +124,7 @@ defmodule AshNeo4j.MixProject do defp deps do [ {:ash, ash_version("~> 3.0 and >= 3.24.2")}, + {:spark, ">= 2.7.0"}, {:ash_state_machine, "~> 0.2.12", only: [:dev, :test]}, {:bolty, bolty_version(">= 0.0.12")}, {:jason, "~> 1.4"}, @@ -167,8 +168,8 @@ defmodule AshNeo4j.MixProject do "docs", "spark.replace_doc_links" ], - "spark.formatter": "spark.formatter --extensions AshNeo4j.DataLayer", - "spark.cheat_sheets": "spark.cheat_sheets --extensions AshNeo4j.DataLayer" + "spark.formatter": "spark.formatter --extensions AshNeo4j.DataLayer,AshNeo4j.DataLayer.Domain", + "spark.cheat_sheets": "spark.cheat_sheets --extensions AshNeo4j.DataLayer,AshNeo4j.DataLayer.Domain" ] end end diff --git a/mix.lock b/mix.lock index b014c14..edabdf9 100644 --- a/mix.lock +++ b/mix.lock @@ -1,50 +1,52 @@ %{ - "ash": {:hex, :ash, "3.24.7", "6e2f32011e7c8f0809dae36712ccfb2efaf3c669cbda7443685436e80acdebf7", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0 or ~> 3.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", "c9fb4d21c3c8bb85636338d448afdc283dd98a433d869e4b2210ac57ade00624"}, + "ash": {:hex, :ash, "3.25.2", "d23c52a9f823e98895d0cf1dc8bbf5d22943ffa45ba087e583d94bb05d205b2e", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0 or ~> 3.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", "c4e3fb9252719dd3fec84610a5a19e309f298265076da23c0bef21de237e98bb"}, "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.12", "5311de46c29c71000c51cfb23fc181359daa49cedb9c8c4ba1e245f3e54079ae", [: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", "0760661dd2f0ba9f2901448c1be00fc1ed228780644ba21a2400d0662595ee10"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "credo": {:hex, :credo, "1.7.18", "5c5596bf7aedf9c8c227f13272ac499fe8eae6237bd326f2f07dfc173786f042", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "a189d164685fd945809e862fe76a7420c4398fa288d76257662aecb909d6b3e5"}, - "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"}, + "crux": {:hex, :crux, "0.1.3", "c698dee09d811678dcddad11a02a832c6bff100f1a7aee49ac44c87485bdbac8", [: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", "04188ea9c1cee13e3ef132417200765857402dcc581f45a8a7862eec3b0530ff"}, "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"}, + "decimal": {:hex, :decimal, "3.1.0", "9ede268cff827e6f0c4fb1b34747c82630dce5d7b877dfb22ec8f0cb25855fce", [:mix], [], "hexpm", "e8b3efb3bb3a13cb5e4268ffe128569067b1972e9dee013537c71a5b073168f9"}, "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"}, "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, "ecto": {:hex, :ecto, "3.13.6", "352135b474f91d1ab99a1b502171d207e9db60421c9e3d0ecab4c7ab96b24d14", [:mix], [{:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8afa059bc16cd2c94739ec0a11e3e5df69d828125119109bef35f20a21a76af2"}, - "erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"}, + "erlex": {:hex, :erlex, "0.2.9", "7debbbaa9f4f368b8cd648983e0f1d7963028508e9c59e9d4ed504e94ef52a55", [:mix], [], "hexpm", "8cfffc0ec7159e6d73de2ab28a588064de80f88b2798d5cbe4482cbbc200178b"}, "ets": {:hex, :ets, "0.9.0", "79c6a6c205436780486f72d84230c6cba2f8a9920456750ddd1e47389107d5fd", [:mix], [], "hexpm", "2861fdfb04bcaeff370f1a5904eec864f0a56dcfebe5921ea9aadf2a481c822b"}, + "ex_ast": {:hex, :ex_ast, "0.12.0", "052ad63711da41b7efbfb3490dbf3d757bb67caec17d02f6deb0db4a0363e5f6", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.7", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "66b4797f157d32f0a63c6da227515f78816c0ac8f621f6d7a2b22108e7b4dd85"}, "ex_check": {:hex, :ex_check, "0.16.0", "07615bef493c5b8d12d5119de3914274277299c6483989e52b0f6b8358a26b5f", [:mix], [], "hexpm", "4d809b72a18d405514dda4809257d8e665ae7cf37a7aee3be6b74a34dec310f5"}, - "ex_doc": {:hex, :ex_doc, "0.40.1", "67542e4b6dde74811cfd580e2c0149b78010fd13001fda7cfeb2b2c2ffb1344d", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "bcef0e2d360d93ac19f01a85d58f91752d930c0a30e2681145feea6bd3516e00"}, + "ex_doc": {:hex, :ex_doc, "0.40.2", "f50edec428c4b0a457a167de42414c461122a3585a99515a69d09fff19e5597e", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "4fa426e2beb47854a162e2c488727fdec51cd4692e319b23810c2804cb1a40fe"}, "excoveralls": {:hex, :excoveralls, "0.18.5", "e229d0a65982613332ec30f07940038fe451a2e5b29bce2a5022165f0c9b157e", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "523fe8a15603f86d64852aab2abe8ddbd78e68579c8525ae765facc5eae01562"}, "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, - "finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"}, + "finch": {:hex, :finch, "0.22.0", "5c48fa6f9706a78eb9036cacb67b8b996b4e66d111c543f4c29bb0f879a6806b", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b94e83c47780fc6813f746a1f1a34ee65cda42da4c5ea26a68f0acc4498e23dc"}, "git_cli": {:hex, :git_cli, "0.3.0", "a5422f9b95c99483385b976f5d43f7e8233283a47cda13533d7c16131cb14df5", [:mix], [], "hexpm", "78cb952f4c86a41f4d3511f1d3ecb28edb268e3a7df278de2faa1bd4672eaf9b"}, "git_ops": {:hex, :git_ops, "2.10.0", "225780d8dcf9ef3393d26fa8d41d6a454a71149393c040e4b708c7c0b9c2b0f1", [:mix], [{:git_cli, "~> 0.2", [hex: :git_cli, repo: "hexpm", optional: false]}, {:igniter, ">= 0.5.27 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "acd4a542eb425a58ce54505de69be704af3d0ef23c9b8cf9a3299d88e5d098ae"}, "glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, - "igniter": {:hex, :igniter, "0.7.9", "8c573440b8127fd80be8220fb197e7422317a81072054fcc0b336029f035a416", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "123513d09f3af149db851aad8492b5b49f861d2c466a72031b2a0cbd9f45526f"}, + "igniter": {:hex, :igniter, "0.8.0", "c7cab589440e5f20ff68e00f60eb094378114dab3105c0784ce8140f8dfdd2c0", [:mix], [{:ex_ast, "~> 0.5", [hex: :ex_ast, repo: "hexpm", optional: false]}, {:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "fcd99096fde4797f7b48bebddcfc58785569acd696346a3eb385bf813f47a7cc"}, "iterex": {:hex, :iterex, "0.1.2", "58f9b9b9a22a55cbfc7b5234a9c9c63eaac26d276b3db80936c0e1c60355a5a6", [:mix], [], "hexpm", "2e103b8bcc81757a9af121f6dc0df312c9a17220f302b1193ef720460d03029d"}, "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, "libgraph": {:hex, :libgraph, "0.16.0", "3936f3eca6ef826e08880230f806bfea13193e49bf153f93edcf0239d4fd1d07", [:mix], [], "hexpm", "41ca92240e8a4138c30a7e06466acc709b0cbb795c643e9e17174a178982d6bf"}, "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, - "makeup_erlang": {:hex, :makeup_erlang, "1.0.3", "4252d5d4098da7415c390e847c814bad3764c94a814a0b4245176215615e1035", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "953297c02582a33411ac6208f2c6e55f0e870df7f80da724ed613f10e6706afd"}, + "makeup_erlang": {:hex, :makeup_erlang, "1.1.0", "835f7e60792e08824cda445639555d7bf1bbbddb1b60b306e33cb6f6db24dc74", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "1cd6780fb1dd1a03979abaed0fe82712b0625118fd5257d3ebbf73f960c73c3c"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, - "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, + "mint": {:hex, :mint, "1.8.0", "b964eaf4416f2dee2ba88968d52239fca5621b0402b9c95f55a08eb9d74803e9", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "f3c572c11355eccf00f22275e9b42463bc17bd28db13be1e28f8e0bb4adbc849"}, "mix_audit": {:hex, :mix_audit, "2.1.5", "c0f77cee6b4ef9d97e37772359a187a166c7a1e0e08b50edf5bf6959dfe5a016", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "87f9298e21da32f697af535475860dc1d3617a010e0b418d2ec6142bc8b42d69"}, + "multigraph": {:hex, :multigraph, "0.16.1-mg.4", "2bbe149f5411b0e3bf0624c7bf2e3da2738efeac2f9a67bbbcb807ab171f0a76", [:mix], [], "hexpm", "b9f3e2577cef4658eeedf97c76d22a86d33a7aab702a93c1da9c122e849e9037"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "owl": {:hex, :owl, "0.13.0", "26010e066d5992774268f3163506972ddac0a7e77bfe57fa42a250f24d6b876e", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "59bf9d11ce37a4db98f57cb68fbfd61593bf419ec4ed302852b6683d3d2f7475"}, - "reactor": {:hex, :reactor, "1.0.1", "ca3b5cf3c04ec8441e67ea2625d0294939822060b1bfd00ffdaaf75b7682d991", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "3497db2b204c9a3cabdaf1b26d2405df1dfbb138ce0ce50e616e9db19fec0043"}, + "reactor": {:hex, :reactor, "1.0.2", "79e4e81d016ab0016afd10bb4c18cb3a574f08f10f8e53be5f08ce27f8eed541", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:multigraph, "~> 0.16.1-mg.2", [hex: :multigraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "19fd55aaaadaae28f55133351051c25d4ac217f99e3e5a67940cc4a321e3948e"}, "req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"}, "rewrite": {:hex, :rewrite, "1.3.0", "67448ba7975690b35ba7e7f35717efcce317dbd5963cb0577aa7325c1923121a", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "d111ac7ff3a58a802ef4f193bbd1831e00a9c57b33276e5068e8390a212714a5"}, "sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"}, "sourceror": {:hex, :sourceror, "1.12.0", "da354c5f35aad3cc1132f5d5b0d8437d865e2661c263260480bab51b5eedb437", [:mix], [], "hexpm", "755703683bd014ebcd5de9acc24b68fb874a660a568d1d63f8f98cd8a6ef9cd0"}, "spark": {:hex, :spark, "2.7.0", "e685b33c038f12851993880bb7e3b326117612eb746fe15828678c152f8321c6", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "e2f675fbda32375b01d9ee7c652671531027fd043bf4a91bafdb2ab716aa1122"}, - "spitfire": {:hex, :spitfire, "0.3.11", "79dfcb033762470de472c1c26ea2b4e3aca74700c685dbffd9a13466272c323d", [:mix], [], "hexpm", "eb6e2dadf63214e8bfe65ca9788cef2b03b01027365d78d3c0e3d9ebd3d5b7b4"}, + "spitfire": {:hex, :spitfire, "0.3.12", "0f7780e4c6ea3753b65ea0c4924f3dfd5c21a51aaa734ffb9dd0b68d2544f27e", [:mix], [], "hexpm", "a389931287b85330c0e954ab06447e198516ab368a232a0200ed77ca13ca9acf"}, "splode": {:hex, :splode, "0.3.1", "9843c54f84f71b7833fec3f0be06c3cfb5be6b35960ee195ea4fad84b1c25030", [:mix], [], "hexpm", "8f2309b6ec2ecbb01435656429ed1d9ed04ba28797a3280c3b0d1217018ecfbd"}, "stream_data": {:hex, :stream_data, "1.3.0", "bde37905530aff386dea1ddd86ecbf00e6642dc074ceffc10b7d4e41dfd6aac9", [:mix], [], "hexpm", "3cc552e286e817dca43c98044c706eec9318083a1480c52ae2688b08e2936e3c"}, - "telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"}, + "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, "text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"}, "usage_rules": {:hex, :usage_rules, "1.2.6", "a7b3f8d6e5d265701139d5714749c37c54bb82230a4c51ec54a12a1e4769b9d1", [:mix], [{:igniter, ">= 0.6.6 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "608411b9876a16a9d62a427dbaf42faf458e4cd0a508b3bd7e5ee71502073582"}, "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, diff --git a/test/aggregate_test.exs b/test/aggregate_test.exs index 5958d00..a9706ce 100644 --- a/test/aggregate_test.exs +++ b/test/aggregate_test.exs @@ -266,6 +266,60 @@ defmodule AshNeo4j.AggregateTest do end end + describe "scalar filter pushdown (#253 — == filters pushed to Cypher WHERE)" do + test "count with integer == filter counts only matching records" do + author = create_author() + post1 = create_post(author, "post1") + post2 = create_post(author, "post2") + create_comment_with_score(post1, "a", 10) + create_comment_with_score(post1, "b", 5) + create_comment_with_score(post1, "c", 10) + create_comment_with_score(post2, "d", 5) + + [p1, p2] = Post |> Ash.read!() |> Ash.load!([:high_score_count]) |> Enum.sort_by(& &1.title) + assert p1.high_score_count == 2 + assert p2.high_score_count == 0 + end + + test "exists with integer == filter is false when no matching records" do + author = create_author() + post = create_post(author, "post") + create_comment_with_score(post, "a", 5) + + [loaded] = Post |> Ash.read!() |> Ash.load!([:has_high_score]) + assert loaded.has_high_score == false + end + + test "exists with integer == filter is true when a matching record exists" do + author = create_author() + post = create_post(author, "post") + create_comment_with_score(post, "a", 5) + create_comment_with_score(post, "b", 10) + + [loaded] = Post |> Ash.read!() |> Ash.load!([:has_high_score]) + assert loaded.has_high_score == true + end + + test "sum with integer == filter totals only matching records" do + author = create_author() + post = create_post(author, "post") + create_comment_with_score(post, "a", 10) + create_comment_with_score(post, "b", 5) + create_comment_with_score(post, "c", 10) + + [loaded] = Post |> Ash.read!() |> Ash.load!([:high_score_total]) + assert loaded.high_score_total == 20 + end + + test "count with integer == filter returns 0 for post with no comments" do + author = create_author() + create_post(author, "empty post") + + [loaded] = Post |> Ash.read!() |> Ash.load!([:high_score_count]) + assert loaded.high_score_count == 0 + end + end + describe "aggregates on embedded struct fields" do test "list aggregate returns deserialized typed structs" do author = create_author() diff --git a/test/data_layer/info_test.exs b/test/data_layer/info_test.exs index ea9d4a8..57c1c82 100644 --- a/test/data_layer/info_test.exs +++ b/test/data_layer/info_test.exs @@ -6,36 +6,70 @@ defmodule AshNeo4j.DataLayer.InfoTest do @moduledoc false use ExUnit.Case, async: true alias AshNeo4j.DataLayer.Info, as: DataLayerInfo + alias AshNeo4j.DataLayer.Domain.Info, as: DomainInfo + alias AshNeo4j.Resource.Info, as: ResourceInfo + alias AshNeo4j.Test.Provider + alias AshNeo4j.Test.Resource.Blueprint alias AshNeo4j.Test.Resource.Specification alias AshNeo4j.Test.Resource.Event 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 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.neo4j_label(Provider) == {:ok, :MyTestDomain} + end + end + + describe "resource info — domain fragment label" do + test "domain_fragment_label is nil for resources in a plain domain" do + assert ResourceInfo.domain_fragment_label(Specification) == nil + end + + test "domain_fragment_label is populated for resources in a domain with a domain fragment" do + assert ResourceInfo.domain_fragment_label(Blueprint) == :MyTestDomain + end + + test "all_labels includes domain fragment label when domain fragment is present" do + assert ResourceInfo.all_labels(Blueprint) == [:Provider, :Blueprint, :MyTestDomain] + end + + test "mapping includes domain_fragment_label field" do + mapping = ResourceInfo.mapping(Blueprint) + assert mapping.domain_fragment_label == :MyTestDomain + assert :MyTestDomain in mapping.all_labels end end end diff --git a/test/fragment_test.exs b/test/fragment_test.exs new file mode 100644 index 0000000..cd45b0f --- /dev/null +++ b/test/fragment_test.exs @@ -0,0 +1,118 @@ +# SPDX-FileCopyrightText: 2025 ash_neo4j contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshNeo4j.FragmentTest do + @moduledoc false + use ExUnit.Case, async: true + alias AshNeo4j.BoltyHelper + alias AshNeo4j.Sandbox + alias AshNeo4j.Test.Resource.Blueprint + alias AshNeo4j.Test.Resource.CrossDomainInstance + alias AshNeo4j.Test.Resource.NoiseInstance + alias AshNeo4j.Test.Resource.Specification + alias AshNeo4j.Test.Resource.TypedInstance + + setup_all do + BoltyHelper.start() + end + + setup do + Sandbox.checkout() + on_exit(&Sandbox.rollback/0) + end + + describe "belongs_to enrichment from fragment" do + test "specification_id is populated on read when belongs_to is declared on the fragment" do + spec = Specification |> Ash.create!(%{name: "mySpec"}) + + instance = + TypedInstance + |> Ash.create!(%{name: "instance_001", specified_by: spec.id}) + + # Value is correct immediately after create + assert instance.specification_id == spec.id + + # Reload via Ash.get — this is where the bug manifests + reloaded = TypedInstance |> Ash.get!(instance.id) + + assert reloaded.specification_id == spec.id + end + + test "specification_id is nil when no specification edge exists" do + instance = TypedInstance |> Ash.create!(%{name: "instance_no_spec"}) + + reloaded = TypedInstance |> Ash.get!(instance.id) + + assert reloaded.specification_id == nil + end + end + + describe "belongs_to enrichment across domain boundary" do + test "blueprint_id is populated on read when belongs_to target is in a different domain" do + blueprint = Blueprint |> Ash.create!(%{name: "myBlueprint"}) + + instance = + CrossDomainInstance + |> Ash.create!(%{name: "cross_001", blueprinted_by: blueprint.id}) + + assert instance.blueprint_id == blueprint.id + + reloaded = CrossDomainInstance |> Ash.get!(instance.id) + + assert reloaded.blueprint_id == blueprint.id + end + + test "blueprint_id is nil when no blueprint edge exists" do + instance = CrossDomainInstance |> Ash.create!(%{name: "cross_no_blueprint"}) + + reloaded = CrossDomainInstance |> Ash.get!(instance.id) + + assert reloaded.blueprint_id == nil + end + end + + describe "domain fragment label" do + test "domain fragment label appears in resource mapping all_labels" do + all_labels = Blueprint.__ash_neo4j_mapping__().all_labels + assert :MyTestDomain in all_labels + end + + test "domain fragment label is written on CREATE" do + blueprint = Blueprint |> Ash.create!(%{name: "labelTest"}) + reloaded = Blueprint |> Ash.get!(blueprint.id) + assert reloaded != nil + all_labels = Blueprint.__ash_neo4j_mapping__().all_labels + assert all_labels == [:Provider, :Blueprint, :MyTestDomain] + end + end + + describe "label scoping with fragment noise" do + test "Ash.read! returns only the target resource when a sibling fragment resource exists" do + # Create a NoiseInstance — shares :CrossDomainType label with CrossDomainInstance. + # If reads scope only by fragment label, this noise node will appear in + # CrossDomainInstance reads, revealing the #257 label scoping bug. + _noise = NoiseInstance |> Ash.create!(%{name: "noise_node"}) + + blueprint = Blueprint |> Ash.create!(%{name: "scopingBlueprint"}) + instance = CrossDomainInstance |> Ash.create!(%{name: "scoped_001", blueprinted_by: blueprint.id}) + + results = CrossDomainInstance |> Ash.read!() + + assert length(results) == 1 + assert hd(results).id == instance.id + assert hd(results).blueprint_id == blueprint.id + end + + test "Ash.get! populates blueprint_id even when sibling fragment nodes exist" do + _noise = NoiseInstance |> Ash.create!(%{name: "noise_for_get"}) + + blueprint = Blueprint |> Ash.create!(%{name: "getBlueprintNoise"}) + instance = CrossDomainInstance |> Ash.create!(%{name: "get_scoped", blueprinted_by: blueprint.id}) + + reloaded = CrossDomainInstance |> Ash.get!(instance.id) + + assert reloaded.blueprint_id == blueprint.id + end + end +end diff --git a/test/resource/info_test.exs b/test/resource/info_test.exs index a4ed5b6..85767a3 100644 --- a/test/resource/info_test.exs +++ b/test/resource/info_test.exs @@ -32,11 +32,11 @@ defmodule AshNeo4j.Resource.InfoTest do end end - describe "labels" do + describe "all_labels" do test "returns domain label then resource label" do - assert ResourceInfo.labels(Specification) == [:SRM, :Specification] - assert ResourceInfo.labels(Service) == [:SRM, :Service] - assert ResourceInfo.labels(Event) == [:SRM, :Event] + assert ResourceInfo.all_labels(Specification) == [:SRM, :Specification] + assert ResourceInfo.all_labels(Service) == [:SRM, :Service] + assert ResourceInfo.all_labels(Event) == [:SRM, :Event] end end diff --git a/test/support/fragment/test_domain.ex b/test/support/fragment/test_domain.ex new file mode 100644 index 0000000..53b1cc6 --- /dev/null +++ b/test/support/fragment/test_domain.ex @@ -0,0 +1,18 @@ +# SPDX-FileCopyrightText: 2025 ash_neo4j contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshNeo4j.Test.Fragment.TestDomain do + @moduledoc false + + # Domain fragment that contributes a :MyTestDomain label to any domain that + # uses it. Exercises AshNeo4j.DataLayer.Domain and the domain fragment label + # path in PersistLabels. + use Spark.Dsl.Fragment, + of: Ash.Domain, + extensions: [AshNeo4j.DataLayer.Domain] + + neo4j do + label :MyTestDomain + end +end diff --git a/test/support/provider.ex b/test/support/provider.ex new file mode 100644 index 0000000..ea2b089 --- /dev/null +++ b/test/support/provider.ex @@ -0,0 +1,12 @@ +# SPDX-FileCopyrightText: 2025 ash_neo4j contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshNeo4j.Test.Provider do + @moduledoc false + use Ash.Domain, fragments: [AshNeo4j.Test.Fragment.TestDomain] + + resources do + allow_unregistered? true + end +end diff --git a/test/support/resource/base_type.ex b/test/support/resource/base_type.ex new file mode 100644 index 0000000..d383520 --- /dev/null +++ b/test/support/resource/base_type.ex @@ -0,0 +1,39 @@ +# SPDX-FileCopyrightText: 2025 ash_neo4j contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshNeo4j.Test.Resource.BaseType do + @moduledoc false + + # Fragment that declares belongs_to :specification with an explicit edge label. + # Specification has no reverse relationship back (too many instances to load). + # This mirrors the BaseInstance → Specification setup in diffo. + use Spark.Dsl.Fragment, + of: Ash.Resource, + data_layer: AshNeo4j.DataLayer + + neo4j do + label :Type + relate [{:specification, :SPECIFIED_BY, :outgoing, :Specification}] + end + + actions do + default_accept :* + defaults [:read, :destroy] + + create :create do + primary? true + argument :specified_by, :uuid + change manage_relationship(:specified_by, :specification, type: :append_and_remove) + end + end + + attributes do + uuid_primary_key :id + attribute :name, :string, public?: true + end + + relationships do + belongs_to :specification, AshNeo4j.Test.Resource.Specification, public?: true + end +end diff --git a/test/support/resource/blueprint.ex b/test/support/resource/blueprint.ex new file mode 100644 index 0000000..4bdfa0d --- /dev/null +++ b/test/support/resource/blueprint.ex @@ -0,0 +1,28 @@ +# SPDX-FileCopyrightText: 2025 ash_neo4j contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshNeo4j.Test.Resource.Blueprint do + @moduledoc false + + # Destination resource in the Provider domain — no reverse relationship back. + # Mirrors the Specification → BaseInstance direction in diffo: many instances + # may reference one Blueprint, so Blueprints do not load their instances. + use Ash.Resource, + domain: AshNeo4j.Test.Provider, + data_layer: AshNeo4j.DataLayer + + actions do + defaults [:read, :destroy, update: :*] + + create :create do + primary? true + accept [:name] + end + end + + attributes do + uuid_primary_key :id + attribute :name, :string, public?: true + end +end diff --git a/test/support/resource/cross_domain_base.ex b/test/support/resource/cross_domain_base.ex new file mode 100644 index 0000000..246f4d7 --- /dev/null +++ b/test/support/resource/cross_domain_base.ex @@ -0,0 +1,39 @@ +# SPDX-FileCopyrightText: 2025 ash_neo4j contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshNeo4j.Test.Resource.CrossDomainBase do + @moduledoc false + + # Fragment that declares belongs_to :blueprint where Blueprint is in a different + # Ash domain (Provider vs SRM). Tests that enrichments resolve source attributes + # across domain boundaries. + use Spark.Dsl.Fragment, + of: Ash.Resource, + data_layer: AshNeo4j.DataLayer + + neo4j do + label :CrossDomainType + relate [{:blueprint, :BLUEPRINTED_BY, :outgoing, :Blueprint}] + end + + actions do + default_accept :* + defaults [:read, :destroy] + + create :create do + primary? true + argument :blueprinted_by, :uuid + change manage_relationship(:blueprinted_by, :blueprint, type: :append_and_remove) + end + end + + attributes do + uuid_primary_key :id + attribute :name, :string, public?: true + end + + relationships do + belongs_to :blueprint, AshNeo4j.Test.Resource.Blueprint, public?: true + end +end diff --git a/test/support/resource/cross_domain_instance.ex b/test/support/resource/cross_domain_instance.ex new file mode 100644 index 0000000..70e0989 --- /dev/null +++ b/test/support/resource/cross_domain_instance.ex @@ -0,0 +1,13 @@ +# SPDX-FileCopyrightText: 2025 ash_neo4j contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshNeo4j.Test.Resource.CrossDomainInstance do + @moduledoc false + + # Resource in SRM domain that extends CrossDomainBase, whose belongs_to target + # (Blueprint) lives in the Provider domain. Exercises cross-domain enrichment. + use Ash.Resource, + domain: AshNeo4j.Test.SRM, + fragments: [AshNeo4j.Test.Resource.CrossDomainBase] +end diff --git a/test/support/resource/noise_instance.ex b/test/support/resource/noise_instance.ex new file mode 100644 index 0000000..1cb1769 --- /dev/null +++ b/test/support/resource/noise_instance.ex @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: 2025 ash_neo4j contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshNeo4j.Test.Resource.NoiseInstance do + @moduledoc false + + # Second resource extending CrossDomainBase — used as "noise" to verify label + # scoping: if reads use only the fragment label (:CrossDomainType), this + # resource's nodes will appear in CrossDomainInstance reads and vice versa. + use Ash.Resource, + domain: AshNeo4j.Test.SRM, + fragments: [AshNeo4j.Test.Resource.CrossDomainBase] +end diff --git a/test/support/resource/post.ex b/test/support/resource/post.ex index 349b78d..4efd53e 100644 --- a/test/support/resource/post.ex +++ b/test/support/resource/post.ex @@ -101,6 +101,19 @@ defmodule AshNeo4j.Test.Resource.Post do list :alpha_comment_titles, :comments, :title do filter expr(title == "alpha") end + + # Integer equality filters — used to verify #253 (scalar == pushed to Cypher WHERE). + count :high_score_count, :comments do + filter expr(score == 10) + end + + exists :has_high_score, :comments do + filter expr(score == 10) + end + + sum :high_score_total, :comments, :score do + filter expr(score == 10) + end end preparations do diff --git a/test/support/resource/typed_instance.ex b/test/support/resource/typed_instance.ex new file mode 100644 index 0000000..ecbe4f9 --- /dev/null +++ b/test/support/resource/typed_instance.ex @@ -0,0 +1,12 @@ +# SPDX-FileCopyrightText: 2025 ash_neo4j contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshNeo4j.Test.Resource.TypedInstance do + @moduledoc false + + # Resource that extends BaseType fragment — mirrors a concrete Instance kind in diffo. + use Ash.Resource, + domain: AshNeo4j.Test.SRM, + fragments: [AshNeo4j.Test.Resource.BaseType] +end diff --git a/test/type_test.exs b/test/type_test.exs index befbacb..7f1c052 100644 --- a/test/type_test.exs +++ b/test/type_test.exs @@ -215,7 +215,7 @@ defmodule AshNeo4j.TypeTest do describe "Ash Read Type tests" do test "type node can be read using ash" do properties = Map.put(@type_node_properties, :uuid, Ash.UUID.generate()) - Neo4jHelper.create_node([:Type], properties) + Neo4jHelper.create_node([:SRM, :Type], properties) type = Ash.read_one!(Type) assert type.uuid == properties.uuid @@ -226,14 +226,14 @@ defmodule AshNeo4j.TypeTest do test "type node has metadata on read" do properties = Map.put(@type_node_properties, :uuid, Ash.UUID.generate()) - Neo4jHelper.create_node([:Domain, :Type], properties) + Neo4jHelper.create_node([:SRM, :Type], properties) type = Ash.read_one!(Type) assert is_struct(type.__meta__, Ecto.Schema.Metadata) assert type.__meta__.state == :loaded assert type.__metadata__ assert type.__metadata__.data_layer == AshNeo4j.DataLayer assert "Type" in type.__metadata__.labels - assert "Domain" in type.__metadata__.labels + assert "SRM" in type.__metadata__.labels assert is_integer(type.__metadata__.node_id) end end @@ -318,7 +318,7 @@ defmodule AshNeo4j.TypeTest do describe "defensive tests" do test "cast function - module not loaded returns error" do - Neo4jHelper.create_node([:Type], %{ + Neo4jHelper.create_node([:SRM, :Type], %{ "uuid" => Ash.UUID.generate(), "function" => "&NonExistent.Module.my_fun/2" }) @@ -327,7 +327,7 @@ defmodule AshNeo4j.TypeTest do end test "cast module - module not loaded returns error" do - Neo4jHelper.create_node([:Type], %{ + Neo4jHelper.create_node([:SRM, :Type], %{ "uuid" => Ash.UUID.generate(), "module" => "Elixir.NonExistent.Module" }) 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..b5cacd9 100644 --- a/usage-rules/dsl.md +++ b/usage-rules/dsl.md @@ -31,20 +31,27 @@ 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: +Cross-domain relationships between AshNeo4j resources just work — each domain's resources see only their own nodes. `AshNeo4j.DataLayer.Domain` is an opt-in feature for intentional polymorphic graph traversals (e.g. a single query that matches nodes from multiple domains via a shared base label). You do not need it simply because your application spans multiple domains. -- `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]`) +The `AshNeo4j.Resource.Info` module exposes label accessors: + +- `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