diff --git a/src/Graph.Age/COMPLIANCE.md b/src/Graph.Age/COMPLIANCE.md
index 2581cf23..f54d1dd1 100644
--- a/src/Graph.Age/COMPLIANCE.md
+++ b/src/Graph.Age/COMPLIANCE.md
@@ -20,16 +20,22 @@
| MultiLabelMatch | Yes | An AGE AST pass lowers logical inheritance labels to AGE-compatible predicates. |
| LabelFiltering | Yes | Caller-supplied labels lower to escaped literal membership tests over physical and logical inheritance labels. |
| OrderByEntity | No | Scalar property ordering is supported; bare whole-entity ordering is rejected rather than rewritten to public `Id`. |
-| ShortestPath | No | |
+| ShortestPath | No | Apache AGE 1.7 does not provide the required native capability; support remains tracked by #355 and is deferred until AGE 1.8 is released. |
| OptionalTraversal | Yes | Optional matches are lowered while preserving owners with absent paths. |
| GroupByAggregation | Yes | The shared structured `WITH` plan uses AGE-native grouping and aggregate functions. |
-| RelationshipPredicates | No | AGE declines variable-path relationship predicates and anchored relationship-existence patterns at translation time. |
-| SetOperations | No | AGE rejects typed `Union` and `Concat` at translation time. |
+| RelationshipPredicates | Yes | Per-hop predicates use indexed list filtering; anchored existence uses optional-match count stages. |
+| SetOperations | Yes | Every nested `Union`/`Concat` branch runs through the complete AGE lowering pipeline with disjoint parameters and normalized projection aliases. |
## Structured Cypher lowering
-After the shared planner produces a `CypherStatement`, the AGE query adapter runs an ordered
-`CypherPassRunner` before rendering. `AgeCorrelatedProjectionPass` replaces AGE-unsupported pattern
+After the shared planner produces a `CypherStatement`, the AGE query adapter recursively lowers every
+`SetOperationClause` branch, including nested and chained trees, through the same ordered
+`CypherPassRunner` before rendering. Branch parameter namespaces remain disjoint; temporal parameters
+added during lowering use the first free provider-local name. `AgeRelationshipPredicatePass` replaces
+AGE-unsupported universal relationship predicates with indexed list filtering so every expanded hop
+must satisfy the caller predicate.
+
+`AgeCorrelatedProjectionPass` replaces AGE-unsupported pattern
comprehensions and `CALL {}` clauses with one correlated match plus grouped `collect`/aggregate
projections — per-projection filters become conditional aggregation so one filtered projection
cannot narrow its siblings, and the anchoring existence filter becomes a grouped row-count guard;
@@ -49,9 +55,9 @@ hydration into typed match, predicate, projection, and ordering clauses, then st
named optional paths, `ALL`/`reduce` compatibility, temporal members, string containment, path
indexes, reserved aliases, and empty sums. `AgeInlineComplexPropertyProjectionPass` expands inline
node hydration into typed optional matches, list comprehensions, and collection clauses before the
-entity pass applies AGE compatibility lowering. `AgeQueryRunner` no longer performs any of #293's
-compatibility rewrites after rendering; these passes preserve the former query semantics without
-parsing rendered text.
+entity pass applies AGE compatibility lowering. `AgeSetOperationProjectionPass` assigns every leaf
+projection one compatible alias shape before rendering. `AgeQueryRunner` performs no textual
+compatibility rewrites after rendering.
Scalar-key grouped aggregation needs no AGE-specific grouping pass: the shared planner emits a
structured `WITH` stage whose non-aggregate key establishes AGE's implicit group, with aggregate
@@ -95,15 +101,16 @@ validation, cancellation, or database failure.
| Inventory test methods | Executed | Capability-skipped | Statically skipped | Failed |
|---|---|---|---|---|
-| 465 | 456 | 9 | 0 | 0 |
+| 465 | 463 | 2 | 0 | 0 |
The compatibility inventory contains 465 runnable test methods. For this capability set,
-`ComplianceInventory.MinimumExecuted(declared)` is 456 methods and the strict compliance guard
-confirms all 456 expected method identities with 9 expected skips for undeclared capabilities:
-`RelationshipPredicates`, `ShortestPath`, `SetOperations`, and `OrderByEntity`. Theory data rows
-make the runtime case count slightly larger than the method inventory but do not increase method
-coverage. The provider-specific adapter, dialect, SQL-envelope, full-text, and security tests are
-excluded from the table.
+`ComplianceInventory.MinimumExecuted(declared)` is 463 methods and the strict compliance guard
+passes with 2 expected skips for the undeclared `ShortestPath` and `OrderByEntity` capabilities.
+The provider-neutral shortest-path contract remains in `IQueryTraversalTests` and pins one/all
+shortest-path selection, endpoint direction, no-path, and same-node semantics; AGE skips that
+contract until AGE 1.8 supplies the native capability tracked by #355. Theory data rows make the
+runtime case count slightly larger than the method inventory. The provider-specific adapter,
+dialect, SQL-envelope, full-text, and security tests are excluded from the table.
Reproduce:
diff --git a/src/Graph.Age/Querying/Cypher/AgeDialect.cs b/src/Graph.Age/Querying/Cypher/AgeDialect.cs
index 8ef8ad53..ede6057b 100644
--- a/src/Graph.Age/Querying/Cypher/AgeDialect.cs
+++ b/src/Graph.Age/Querying/Cypher/AgeDialect.cs
@@ -21,7 +21,9 @@ public sealed class AgeDialect : ICypherDialect
GraphCapability.OptionalTraversal,
GraphCapability.CallSubqueries,
GraphCapability.PatternSizeProjection,
- GraphCapability.GroupByAggregation);
+ GraphCapability.GroupByAggregation,
+ GraphCapability.RelationshipPredicates,
+ GraphCapability.SetOperations);
// The shared planner may represent scalar projected sort keys and path-decomposition order
// coordinates as variable references. Let it construct those internal rows, while the AGE
@@ -36,6 +38,8 @@ public sealed class AgeDialect : ICypherDialect
GraphCapability.CallSubqueries,
GraphCapability.PatternSizeProjection,
GraphCapability.GroupByAggregation,
+ GraphCapability.RelationshipPredicates,
+ GraphCapability.SetOperations,
GraphCapability.OrderByEntity);
private static readonly CapabilitySet CommandPlanningCapabilities = CapabilitySet.Of(
@@ -48,6 +52,7 @@ public sealed class AgeDialect : ICypherDialect
GraphCapability.CallSubqueries,
GraphCapability.PatternSizeProjection,
GraphCapability.GroupByAggregation,
+ GraphCapability.SetOperations,
GraphCapability.OrderByEntity,
GraphCapability.RelationshipPredicates);
@@ -67,10 +72,8 @@ public AgeDialect()
internal static AgeDialect PlanningInstance { get; } = new(PlanningCapabilities);
///
- /// Gets the planning dialect for the narrower command-selection grammar. Command selections
- /// end in a scalar native-id projection, which AGE can structurally lower to optional-match
- /// counts even though general entity-hydrating relationship-existence queries remain outside
- /// the provider's declared read capability.
+ /// Gets the planning dialect for command selections. Command selections end in a scalar
+ /// native-id projection and share the same relationship-existence lowering as read queries.
///
internal static AgeDialect CommandPlanningInstance { get; } = new(CommandPlanningCapabilities);
@@ -85,8 +88,7 @@ public AgeDialect()
/// ().
/// Scalar-key grouping uses AGE's native grouping and aggregate support through the shared
/// structured WITH plan. Capabilities describe user-visible read behavior, whether
- /// native or lowered. The internal command planner has one additional selection-only lowering
- /// for relationship existence; it does not broaden this public capability set.
+ /// native or lowered.
///
public CapabilitySet Capabilities => capabilities;
diff --git a/src/Graph.Age/Querying/Cypher/Lowering/AgeClauseContainerTraversal.cs b/src/Graph.Age/Querying/Cypher/Lowering/AgeClauseContainerTraversal.cs
new file mode 100644
index 00000000..d414a28c
--- /dev/null
+++ b/src/Graph.Age/Querying/Cypher/Lowering/AgeClauseContainerTraversal.cs
@@ -0,0 +1,43 @@
+// Copyright CVOYA LLC. Licensed under the Apache License, Version 2.0.
+// See LICENSE in the project root for full license terms.
+
+namespace Cvoya.Graph.Age.Querying.Cypher.Lowering;
+
+using Cvoya.Graph.Cypher.Ast;
+
+/// Recursively applies AGE lowering to independently scoped clause containers.
+internal static class AgeClauseContainerTraversal
+{
+ public static CypherStatement RunSetOperationBranches(
+ CypherStatement input,
+ Func lower)
+ {
+ ArgumentNullException.ThrowIfNull(input);
+ ArgumentNullException.ThrowIfNull(lower);
+
+ var parameters = input.Parameters;
+ var changed = false;
+ var clauses = new ICypherClause[input.Clauses.Count];
+ for (var index = 0; index < input.Clauses.Count; index++)
+ {
+ if (input.Clauses[index] is not SetOperationClause setOperation)
+ {
+ clauses[index] = input.Clauses[index];
+ continue;
+ }
+
+ var first = lower(new CypherStatement(setOperation.First, parameters));
+ var second = lower(new CypherStatement(setOperation.Second, first.Parameters));
+ clauses[index] = new SetOperationClause(
+ first.Clauses,
+ second.Clauses,
+ setOperation.PreserveDuplicates);
+ parameters = second.Parameters;
+ changed = true;
+ }
+
+ return changed
+ ? new CypherStatement(clauses, parameters, input.PathTypes)
+ : input;
+ }
+}
diff --git a/src/Graph.Age/Querying/Cypher/Lowering/AgeCorrelatedProjectionPass.cs b/src/Graph.Age/Querying/Cypher/Lowering/AgeCorrelatedProjectionPass.cs
index bcf975cf..e7ed7ce4 100644
--- a/src/Graph.Age/Querying/Cypher/Lowering/AgeCorrelatedProjectionPass.cs
+++ b/src/Graph.Age/Querying/Cypher/Lowering/AgeCorrelatedProjectionPass.cs
@@ -333,8 +333,8 @@ private static CypherStatement LowerPatternSubqueryProjections(
CypherStatement input,
string countAliasPrefix)
{
- var returnIndex = FindReturnIndex(input.Clauses, required: false);
- if (returnIndex < 0)
+ var terminalIndex = FindProjectionTerminalIndex(input.Clauses);
+ if (terminalIndex < 0)
{
return input;
}
@@ -342,6 +342,7 @@ private static CypherStatement LowerPatternSubqueryProjections(
var extractor = new PatternSubqueryExtractor(countAliasPrefix);
var output = new List();
var scope = new List();
+ OrderByClause? entityResultOrdering = null;
for (var index = 0; index < input.Clauses.Count; index++)
{
var clause = input.Clauses[index];
@@ -369,14 +370,28 @@ private static CypherStatement LowerPatternSubqueryProjections(
break;
}
- case OrderByClause orderBy when index < returnIndex:
- // A leading ORDER BY holds the final result ordering (AgeClauseOrderPass
- // repositions it after lowering), so its counts stay pending and their stages
- // are emitted with the RETURN counts, where the aliases are in scope.
- output.Add(RewriteOrdering(orderBy, extractor));
- break;
+ case OrderByClause orderBy when index < terminalIndex:
+ {
+ // The explicit ordering still controls paging before entity hydration, while
+ // entity projections must also carry the ordering through their property-load
+ // pipeline. Keep the original expression so it can be lowered again at the
+ // projection boundary if no explicit result ordering is already attached.
+ var rewritten = RewriteOrdering(orderBy, extractor);
+ if (input.Clauses[terminalIndex] is EntityProjectionClause)
+ {
+ var counts = extractor.Drain();
+ if (counts.Length > 0)
+ {
+ entityResultOrdering = orderBy;
+ EmitCountStages(output, scope, counts);
+ }
+ }
- case ReturnClause @return when index == returnIndex:
+ output.Add(rewritten);
+ break;
+ }
+
+ case ReturnClause @return when index == terminalIndex:
{
var items = @return.Items
.Select(item => new ReturnItem(extractor.Rewrite(item.Expression), item.Alias))
@@ -384,12 +399,12 @@ private static CypherStatement LowerPatternSubqueryProjections(
// Trailing ORDER BY may reference the same counts; rewrite it now so the
// stages land before RETURN, where the aliases are still in scope.
- var trailing = new ICypherClause[input.Clauses.Count - returnIndex - 1];
+ var trailing = new ICypherClause[input.Clauses.Count - terminalIndex - 1];
for (var offset = 0; offset < trailing.Length; offset++)
{
- trailing[offset] = input.Clauses[returnIndex + 1 + offset] is OrderByClause trailingOrder
+ trailing[offset] = input.Clauses[terminalIndex + 1 + offset] is OrderByClause trailingOrder
? RewriteOrdering(trailingOrder, extractor)
- : input.Clauses[returnIndex + 1 + offset];
+ : input.Clauses[terminalIndex + 1 + offset];
}
EmitCountStages(output, scope, extractor.Drain());
@@ -399,6 +414,30 @@ private static CypherStatement LowerPatternSubqueryProjections(
break;
}
+ case EntityProjectionClause projection when index == terminalIndex:
+ {
+ var sourceOrdering = projection.Ordering.Count > 0
+ ? projection.Ordering
+ : entityResultOrdering?.Items ?? [];
+ var ordering = sourceOrdering
+ .Select(item => new OrderByItem(
+ extractor.Rewrite(item.Expression),
+ item.Descending))
+ .ToArray();
+ EmitCountStages(output, scope, extractor.Drain());
+ output.Add(new EntityProjectionClause(
+ projection.Shape,
+ projection.SourceAlias,
+ projection.RelationshipAlias,
+ projection.TargetAlias,
+ projection.LoadSourceProperties,
+ projection.LoadTargetProperties,
+ projection.IncludePathCoordinates,
+ ordering,
+ projection.RowIdentityAliases));
+ break;
+ }
+
default:
output.Add(clause);
break;
@@ -412,6 +451,19 @@ private static CypherStatement LowerPatternSubqueryProjections(
: new CypherStatement(output, input.Parameters, input.PathTypes);
}
+ private static int FindProjectionTerminalIndex(IReadOnlyList clauses)
+ {
+ for (var index = clauses.Count - 1; index >= 0; index--)
+ {
+ if (clauses[index] is ReturnClause or EntityProjectionClause)
+ {
+ return index;
+ }
+ }
+
+ return -1;
+ }
+
private static OrderByClause RewriteOrdering(OrderByClause orderBy, PatternSubqueryExtractor extractor) =>
new(orderBy.Items
.Select(item => new OrderByItem(extractor.Rewrite(item.Expression), item.Descending))
diff --git a/src/Graph.Age/Querying/Cypher/Lowering/AgeRelationshipPredicatePass.cs b/src/Graph.Age/Querying/Cypher/Lowering/AgeRelationshipPredicatePass.cs
new file mode 100644
index 00000000..aeac85d7
--- /dev/null
+++ b/src/Graph.Age/Querying/Cypher/Lowering/AgeRelationshipPredicatePass.cs
@@ -0,0 +1,181 @@
+// Copyright CVOYA LLC. Licensed under the Apache License, Version 2.0.
+// See LICENSE in the project root for full license terms.
+
+namespace Cvoya.Graph.Age.Querying.Cypher.Lowering;
+
+using Cvoya.Graph.Cypher.Ast;
+using Cvoya.Graph.Cypher.Ast.Expressions;
+using Cvoya.Graph.Cypher.Validation;
+
+/// Lowers universal relationship-list predicates to AGE-compatible list filtering.
+internal sealed class AgeRelationshipPredicatePass : ICypherPass
+{
+ public CypherStatement Run(CypherStatement input)
+ {
+ ArgumentNullException.ThrowIfNull(input);
+
+ var rewriter = new Rewriter();
+ var clauses = rewriter.RewriteClauses(input.Clauses);
+ return rewriter.Changed
+ ? new CypherStatement(clauses, input.Parameters, input.PathTypes)
+ : input;
+ }
+
+ private sealed class Rewriter
+ {
+ private int iteratorIndex;
+
+ public bool Changed { get; private set; }
+
+ public ICypherClause[] RewriteClauses(IReadOnlyList clauses) =>
+ clauses.Select(RewriteClause).ToArray();
+
+ private ICypherClause RewriteClause(ICypherClause clause) => clause switch
+ {
+ WhereClause where => new WhereClause(Rewrite(where.Predicate)),
+ WithClause { Wildcard: true } => WithClause.All,
+ WithClause with => new WithClause(RewriteItems(with.Items), with.Distinct),
+ ReturnClause @return => new ReturnClause(RewriteItems(@return.Items), @return.Distinct),
+ CallSubqueryClause subquery => new CallSubqueryClause(
+ subquery.ImportedVariables,
+ RewriteClauses(subquery.Body)),
+ CallClause call => CallClause.WithAliasedYields(
+ call.Procedure,
+ call.Arguments.Select(Rewrite).ToArray(),
+ call.Yields),
+ UnwindClause unwind => new UnwindClause(Rewrite(unwind.Source), unwind.Alias),
+ OrderByClause orderBy => new OrderByClause(orderBy.Items
+ .Select(item => new OrderByItem(Rewrite(item.Expression), item.Descending))
+ .ToArray()),
+ SkipClause skip => new SkipClause(Rewrite(skip.Count)),
+ LimitClause limit => new LimitClause(Rewrite(limit.Count)),
+ _ => clause,
+ };
+
+ private ReturnItem[] RewriteItems(IReadOnlyList items) => items
+ .Select(item => new ReturnItem(Rewrite(item.Expression), item.Alias))
+ .ToArray();
+
+ private CypherExpression Rewrite(CypherExpression expression)
+ {
+ if (expression is AllExpression all)
+ {
+ Changed = true;
+ var source = Rewrite(all.Source);
+ var indexAlias = $"__age_relationship_hop{iteratorIndex++}";
+ var relationship = new IndexExpression(
+ source,
+ new FunctionCall("toInteger", [new VariableRef(indexAlias)]));
+ var predicate = ReplaceVariable(
+ Rewrite(all.Predicate),
+ all.IteratorAlias,
+ relationship);
+ var indexes = new FunctionCall(
+ "range",
+ [
+ new Literal(0),
+ new BinaryExpression(
+ CypherBinaryOperator.Subtract,
+ new FunctionCall("size", [source]),
+ new Literal(1)),
+ ]);
+ return new BinaryExpression(
+ CypherBinaryOperator.Equal,
+ new FunctionCall(
+ "size",
+ [new ListComprehensionExpression(
+ indexes,
+ indexAlias,
+ predicate: predicate)]),
+ new FunctionCall("size", [source]));
+ }
+
+ return expression switch
+ {
+ BinaryExpression binary => new BinaryExpression(binary.Op, Rewrite(binary.Left), Rewrite(binary.Right)),
+ UnaryExpression unary => new UnaryExpression(unary.Op, Rewrite(unary.Operand)),
+ PropertyAccess property => new PropertyAccess(Rewrite(property.Target), property.Property),
+ EscapedPropertyAccess property => new EscapedPropertyAccess(Rewrite(property.Target), property.Property),
+ NativeElementIdentity identity => new NativeElementIdentity(Rewrite(identity.Target)),
+ FunctionCall function => new FunctionCall(function.Name, function.Arguments.Select(Rewrite).ToArray()),
+ LabelTest label => new LabelTest(Rewrite(label.Target), label.Labels),
+ ListExpression list => new ListExpression(list.Items.Select(Rewrite).ToArray()),
+ ListComprehensionExpression comprehension => new ListComprehensionExpression(
+ Rewrite(comprehension.Source),
+ comprehension.IteratorAlias,
+ comprehension.Predicate is null ? null : Rewrite(comprehension.Predicate),
+ comprehension.Projection is null ? null : Rewrite(comprehension.Projection)),
+ ReduceExpression reduce => new ReduceExpression(
+ reduce.AccumulatorAlias,
+ Rewrite(reduce.Seed),
+ reduce.IteratorAlias,
+ Rewrite(reduce.Source),
+ Rewrite(reduce.Reducer)),
+ MapExpression map => new MapExpression(map.Entries
+ .Select(entry => new MapEntry(entry.Key, Rewrite(entry.Value)))
+ .ToArray()),
+ IndexExpression index => new IndexExpression(Rewrite(index.Target), Rewrite(index.Index)),
+ CaseExpression @case => new CaseExpression(
+ Rewrite(@case.Condition),
+ Rewrite(@case.WhenTrue),
+ @case.WhenFalse is null ? null : Rewrite(@case.WhenFalse)),
+ ConjunctionExpression conjunction => new ConjunctionExpression(
+ conjunction.Predicates.Select(Rewrite).ToArray()),
+ PatternSubqueryExpression subquery => new PatternSubqueryExpression(
+ subquery.Kind,
+ subquery.Pattern,
+ subquery.Predicate is null ? null : Rewrite(subquery.Predicate)),
+ PatternComprehensionExpression comprehension => new PatternComprehensionExpression(
+ comprehension.Pattern,
+ Rewrite(comprehension.Projection),
+ comprehension.Predicate is null ? null : Rewrite(comprehension.Predicate)),
+ _ => expression,
+ };
+ }
+
+ private static CypherExpression ReplaceVariable(
+ CypherExpression expression,
+ string alias,
+ CypherExpression replacement) => expression switch
+ {
+ VariableRef variable when variable.Alias == alias => replacement,
+ BinaryExpression binary => new BinaryExpression(
+ binary.Op,
+ ReplaceVariable(binary.Left, alias, replacement),
+ ReplaceVariable(binary.Right, alias, replacement)),
+ UnaryExpression unary => new UnaryExpression(
+ unary.Op,
+ ReplaceVariable(unary.Operand, alias, replacement)),
+ PropertyAccess property => new PropertyAccess(
+ ReplaceVariable(property.Target, alias, replacement),
+ property.Property),
+ EscapedPropertyAccess property => new EscapedPropertyAccess(
+ ReplaceVariable(property.Target, alias, replacement),
+ property.Property),
+ NativeElementIdentity identity => new NativeElementIdentity(
+ ReplaceVariable(identity.Target, alias, replacement)),
+ FunctionCall function => new FunctionCall(
+ function.Name,
+ function.Arguments.Select(argument => ReplaceVariable(argument, alias, replacement)).ToArray()),
+ LabelTest label => new LabelTest(ReplaceVariable(label.Target, alias, replacement), label.Labels),
+ ListExpression list => new ListExpression(list.Items
+ .Select(item => ReplaceVariable(item, alias, replacement)).ToArray()),
+ MapExpression map => new MapExpression(map.Entries
+ .Select(entry => new MapEntry(
+ entry.Key,
+ ReplaceVariable(entry.Value, alias, replacement))).ToArray()),
+ IndexExpression index => new IndexExpression(
+ ReplaceVariable(index.Target, alias, replacement),
+ ReplaceVariable(index.Index, alias, replacement)),
+ CaseExpression @case => new CaseExpression(
+ ReplaceVariable(@case.Condition, alias, replacement),
+ ReplaceVariable(@case.WhenTrue, alias, replacement),
+ @case.WhenFalse is null
+ ? null
+ : ReplaceVariable(@case.WhenFalse, alias, replacement)),
+ ConjunctionExpression conjunction => new ConjunctionExpression(conjunction.Predicates
+ .Select(predicate => ReplaceVariable(predicate, alias, replacement)).ToArray()),
+ _ => expression,
+ };
+ }
+}
diff --git a/src/Graph.Age/Querying/Cypher/Lowering/AgeSetOperationProjectionPass.cs b/src/Graph.Age/Querying/Cypher/Lowering/AgeSetOperationProjectionPass.cs
new file mode 100644
index 00000000..ef0a6943
--- /dev/null
+++ b/src/Graph.Age/Querying/Cypher/Lowering/AgeSetOperationProjectionPass.cs
@@ -0,0 +1,123 @@
+// Copyright CVOYA LLC. Licensed under the Apache License, Version 2.0.
+// See LICENSE in the project root for full license terms.
+
+namespace Cvoya.Graph.Age.Querying.Cypher.Lowering;
+
+using Cvoya.Graph.Cypher.Ast;
+using Cvoya.Graph.Cypher.Validation;
+
+/// Normalizes every leaf projection in an AGE set-operation tree to one rendered shape.
+internal sealed class AgeSetOperationProjectionPass : ICypherPass
+{
+ public CypherStatement Run(CypherStatement input)
+ {
+ ArgumentNullException.ThrowIfNull(input);
+
+ var changed = false;
+ var clauses = RewriteContainers(input.Clauses, ref changed);
+ return changed
+ ? new CypherStatement(clauses, input.Parameters, input.PathTypes)
+ : input;
+ }
+
+ private static ICypherClause[] RewriteContainers(
+ IReadOnlyList clauses,
+ ref bool changed)
+ {
+ var rewritten = clauses.ToArray();
+ for (var index = 0; index < rewritten.Length; index++)
+ {
+ if (rewritten[index] is not SetOperationClause setOperation)
+ {
+ continue;
+ }
+
+ var first = RewriteContainers(setOperation.First, ref changed);
+ var second = RewriteContainers(setOperation.Second, ref changed);
+ var aliases = GetProjectionAliases(first);
+ ValidateProjectionWidth(second, aliases.Length);
+ first = ApplyProjectionAliases(first, aliases, ref changed);
+ second = ApplyProjectionAliases(second, aliases, ref changed);
+ rewritten[index] = new SetOperationClause(first, second, setOperation.PreserveDuplicates);
+ changed = true;
+ }
+
+ return rewritten;
+ }
+
+ private static string[] GetProjectionAliases(IReadOnlyList clauses)
+ {
+ var projection = FindFirstLeafProjection(clauses);
+ return projection.Items
+ .Select((item, index) => item.Alias ?? $"age_column_{index}")
+ .ToArray();
+ }
+
+ private static void ValidateProjectionWidth(IReadOnlyList clauses, int expected)
+ {
+ var actual = FindFirstLeafProjection(clauses).Items.Count;
+ if (actual != expected)
+ {
+ throw new GraphQueryTranslationException(
+ $"Apache AGE set-operation branches must project the same number of values; " +
+ $"the first branch projects {expected} and the second projects {actual}.");
+ }
+ }
+
+ private static ReturnClause FindFirstLeafProjection(IReadOnlyList clauses)
+ {
+ for (var index = clauses.Count - 1; index >= 0; index--)
+ {
+ switch (clauses[index])
+ {
+ case ReturnClause projection:
+ return projection;
+ case SetOperationClause setOperation:
+ return FindFirstLeafProjection(setOperation.First);
+ }
+ }
+
+ throw new GraphQueryTranslationException(
+ "Apache AGE set-operation lowering requires every branch to end in a RETURN projection.");
+ }
+
+ private static ICypherClause[] ApplyProjectionAliases(
+ IReadOnlyList clauses,
+ IReadOnlyList aliases,
+ ref bool changed)
+ {
+ var rewritten = clauses.ToArray();
+ for (var index = rewritten.Length - 1; index >= 0; index--)
+ {
+ switch (rewritten[index])
+ {
+ case ReturnClause projection:
+ if (projection.Items.Count != aliases.Count)
+ {
+ throw new GraphQueryTranslationException(
+ "Apache AGE set-operation branches must project the same number of values.");
+ }
+
+ var items = projection.Items
+ .Select((item, itemIndex) => new ReturnItem(item.Expression, aliases[itemIndex]))
+ .ToArray();
+ changed |= items.Where((item, itemIndex) =>
+ !string.Equals(item.Alias, projection.Items[itemIndex].Alias, StringComparison.Ordinal)).Any();
+ rewritten[index] = new ReturnClause(items, projection.Distinct);
+ return rewritten;
+
+ case SetOperationClause setOperation:
+ var first = ApplyProjectionAliases(setOperation.First, aliases, ref changed);
+ var second = ApplyProjectionAliases(setOperation.Second, aliases, ref changed);
+ rewritten[index] = new SetOperationClause(
+ first,
+ second,
+ setOperation.PreserveDuplicates);
+ return rewritten;
+ }
+ }
+
+ throw new GraphQueryTranslationException(
+ "Apache AGE set-operation lowering requires every branch to end in a RETURN projection.");
+ }
+}
diff --git a/src/Graph.Age/Querying/Cypher/Lowering/AgeTemporalParameterArithmeticPass.cs b/src/Graph.Age/Querying/Cypher/Lowering/AgeTemporalParameterArithmeticPass.cs
index b1fc99a2..6ec42213 100644
--- a/src/Graph.Age/Querying/Cypher/Lowering/AgeTemporalParameterArithmeticPass.cs
+++ b/src/Graph.Age/Querying/Cypher/Lowering/AgeTemporalParameterArithmeticPass.cs
@@ -246,7 +246,13 @@ private static bool IsDurationUnit(string unit) => unit.ToLowerInvariant() is
private QueryParameter AddParameter(object value)
{
- var name = $"age_temporal_{parameterIndex++}";
+ string name;
+ do
+ {
+ name = $"age_temporal_{parameterIndex++}";
+ }
+ while (parameters.ContainsKey(name));
+
parameters[name] = value;
Changed = true;
return new QueryParameter(name);
diff --git a/src/Graph.Age/Querying/Cypher/Visitors/Core/CypherQueryVisitor.cs b/src/Graph.Age/Querying/Cypher/Visitors/Core/CypherQueryVisitor.cs
index 1da7f4f5..df7cbaf9 100644
--- a/src/Graph.Age/Querying/Cypher/Visitors/Core/CypherQueryVisitor.cs
+++ b/src/Graph.Age/Querying/Cypher/Visitors/Core/CypherQueryVisitor.cs
@@ -19,6 +19,7 @@ internal sealed class CypherQueryVisitor : ExpressionVisitor
{
private static readonly CypherPassRunner LoweringPasses = new(
[
+ new AgeRelationshipPredicatePass(),
new AgeCorrelatedProjectionPass(),
new AgeLabelPatternPass(),
// Label lowering adds marker-based root-isolation EXISTS predicates. AGE parses those
@@ -30,6 +31,7 @@ internal sealed class CypherQueryVisitor : ExpressionVisitor
new AgeTemporalParameterArithmeticPass(),
new AgeInlineComplexPropertyProjectionPass(),
new AgeEntityProjectionPass(),
+ new AgeSetOperationProjectionPass(),
]);
private readonly Type _rootType;
@@ -134,8 +136,11 @@ private static Expression StripConvert(Expression expression)
}
}
- internal static CypherStatement LowerStatement(CypherStatement statement) =>
- LoweringPasses.Run(statement);
+ internal static CypherStatement LowerStatement(CypherStatement statement)
+ {
+ var branches = AgeClauseContainerTraversal.RunSetOperationBranches(statement, LowerStatement);
+ return LoweringPasses.Run(branches);
+ }
private static Exception PreserveProviderException(GraphQueryTranslationException exception)
{
diff --git a/src/Graph.Age/README.md b/src/Graph.Age/README.md
index 58c40942..3733df9f 100644
--- a/src/Graph.Age/README.md
+++ b/src/Graph.Age/README.md
@@ -57,8 +57,9 @@ An application that already owns its connection pool can pass an AGE-enabled
ISO-8601 temporal values.
The provider declares full-text search. It does not currently declare nested transactions or
-shortest path. Unsupported operations fail during translation or are capability-skipped by the
-provider compatibility suite.
+shortest path. Shortest-path support remains tracked by #355 and is deferred until Apache AGE 1.8
+releases its native capability. Unsupported operations fail during translation or are
+capability-skipped by the provider compatibility suite.
### Native storage and commands
diff --git a/tests/Graph.Age.Tests/AgeCorrelatedProjectionPassTests.cs b/tests/Graph.Age.Tests/AgeCorrelatedProjectionPassTests.cs
index 2dc354ad..cd11b357 100644
--- a/tests/Graph.Age.Tests/AgeCorrelatedProjectionPassTests.cs
+++ b/tests/Graph.Age.Tests/AgeCorrelatedProjectionPassTests.cs
@@ -128,6 +128,27 @@ public async Task LowersRelationshipCountsSequentiallyAndReusesCountForOrdering(
Assert.Contains("ORDER BY __age_count0 DESC", rendered);
}
+ [Fact]
+ public async Task EmitsRelationshipCountStagesBeforeEntityProjectionOrdering()
+ {
+ await using var store = new AgeGraphStore(
+ "Host=localhost;Port=5455;Username=postgres;Password=postgres;Database=postgres",
+ "translation");
+ var query = store.Graph.Nodes()
+ .OrderByDescending(person =>
+ person.CountRelationships(GraphTraversalDirection.Outgoing));
+
+ var rendered = Translate(query).Text;
+
+ Assert.DoesNotContain("COUNT {", rendered);
+ Assert.Contains("count(__age_count0_relationship0) AS __age_count0", rendered);
+ Assert.Contains("count(__age_count1_relationship0) AS __age_count1", rendered);
+ Assert.Contains("ORDER BY __projectionOrder0 DESC", rendered);
+ Assert.True(
+ rendered.IndexOf("AS __age_count1", StringComparison.Ordinal) <
+ rendered.IndexOf("__age_count1 AS __projectionOrder0", StringComparison.Ordinal));
+ }
+
[Fact]
public async Task LowersComplexCollectionSizeAndEscapesItsReservedProjectionAlias()
{
diff --git a/tests/Graph.Age.Tests/AgeDialectTests.cs b/tests/Graph.Age.Tests/AgeDialectTests.cs
index 9255e459..8e199596 100644
--- a/tests/Graph.Age.Tests/AgeDialectTests.cs
+++ b/tests/Graph.Age.Tests/AgeDialectTests.cs
@@ -25,7 +25,9 @@ public void DeclaresOnlyVerifiedCapabilities()
GraphCapability.OptionalTraversal,
GraphCapability.CallSubqueries,
GraphCapability.PatternSizeProjection,
- GraphCapability.GroupByAggregation),
+ GraphCapability.GroupByAggregation,
+ GraphCapability.RelationshipPredicates,
+ GraphCapability.SetOperations),
capabilities);
}
diff --git a/tests/Graph.Age.Tests/AgeRelationshipPredicateTranslationTests.cs b/tests/Graph.Age.Tests/AgeRelationshipPredicateTranslationTests.cs
index ded94fa9..12f251a3 100644
--- a/tests/Graph.Age.Tests/AgeRelationshipPredicateTranslationTests.cs
+++ b/tests/Graph.Age.Tests/AgeRelationshipPredicateTranslationTests.cs
@@ -13,24 +13,45 @@ namespace Cvoya.Graph.Age.Tests;
public sealed class AgeRelationshipPredicateTranslationTests
{
[Fact]
- public async Task DeclinesRelationshipExistenceAtTranslationTime()
+ public async Task LowersRelationshipExistenceToOptionalMatchCount()
{
await using var store = new AgeGraphStore(
"Host=localhost;Port=5455;Username=postgres;Password=postgres;Database=postgres",
"translation");
+ var epoch = DateTime.UnixEpoch;
var query = store.Graph.Nodes()
.WhereHasRelationship(
GraphTraversalDirection.Both,
- relationship => relationship.Since >= DateTime.UnixEpoch);
+ relationship => relationship.Since >= epoch);
+
+ var text = Translate(query).Text;
+
+ Assert.DoesNotContain("EXISTS {", text, StringComparison.Ordinal);
+ Assert.Contains("OPTIONAL MATCH", text, StringComparison.Ordinal);
+ Assert.Contains("count(CASE WHEN", text, StringComparison.Ordinal);
+ Assert.Contains("__age_count0 > 0", text, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task LowersVariablePathRelationshipPredicateToListFilter()
+ {
+ await using var store = new AgeGraphStore(
+ "Host=localhost;Port=5455;Username=postgres;Password=postgres;Database=postgres",
+ "translation");
+ var cutoff = DateTime.UnixEpoch;
+ var query = store.Graph.Nodes()
+ .Traverse(options => options
+ .Depth(1, 3)
+ .WhereRelationship(relationship => relationship.Since >= cutoff));
- var exception = Assert.Throws(() =>
- {
- var visitor = new CypherQueryVisitor(query.ElementType);
- visitor.Visit(query.Expression);
- });
+ var text = Translate(query).Text;
- Assert.Contains(nameof(GraphCapability.RelationshipPredicates), exception.Message, StringComparison.Ordinal);
- Assert.Contains("Apache AGE", exception.Message, StringComparison.Ordinal);
+ Assert.DoesNotContain("ALL(", text, StringComparison.Ordinal);
+ Assert.Contains(
+ "size([__age_relationship_hop0 IN range(0, size(r) - 1) WHERE " +
+ "r[toInteger(__age_relationship_hop0)].Since >= $p0]) = size(r)",
+ text,
+ StringComparison.Ordinal);
}
[Fact]
@@ -57,4 +78,11 @@ public async Task CommandSelection_LowersRelationshipExistenceToOptionalMatchCou
Assert.Contains("AS __age_count0", text, StringComparison.Ordinal);
Assert.Contains("__age_count0 > 0", text, StringComparison.Ordinal);
}
+
+ private static Cvoya.Graph.Age.Querying.Cypher.CypherQuery Translate(IQueryable query)
+ {
+ var visitor = new CypherQueryVisitor(query.ElementType);
+ visitor.Visit(query.Expression);
+ return visitor.Query;
+ }
}
diff --git a/tests/Graph.Age.Tests/AgeSetOperationTranslationTests.cs b/tests/Graph.Age.Tests/AgeSetOperationTranslationTests.cs
index dc8aae00..8978e181 100644
--- a/tests/Graph.Age.Tests/AgeSetOperationTranslationTests.cs
+++ b/tests/Graph.Age.Tests/AgeSetOperationTranslationTests.cs
@@ -9,7 +9,7 @@ namespace Cvoya.Graph.Age.Tests;
public sealed class AgeSetOperationTranslationTests
{
[Fact]
- public async Task DeclinesTypedUnionChainAtTranslationTime()
+ public async Task RecursivelyLowersTypedUnionChain()
{
await using var store = new AgeGraphStore(
"Host=localhost;Port=5455;Username=postgres;Password=postgres;Database=postgres",
@@ -18,12 +18,109 @@ public async Task DeclinesTypedUnionChainAtTranslationTime()
.Union(store.Graph.Nodes())
.Union(store.Graph.Nodes());
- var exception = Assert.Throws(() =>
- {
- var visitor = new CypherQueryVisitor(query.ElementType);
- visitor.Visit(query.Expression);
- });
+ var translated = Translate(query);
- Assert.Contains(nameof(GraphCapability.SetOperations), exception.Message, StringComparison.Ordinal);
+ Assert.Equal(2, translated.Text.Split('\n').Count(line =>
+ string.Equals(line, "UNION", StringComparison.Ordinal)));
+ Assert.Equal(3, translated.Text.Split('\n').Count(line =>
+ line.StartsWith("RETURN { Node:", StringComparison.Ordinal)));
+ Assert.DoesNotContain(":Person", translated.Text, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task PreservesNestedConcatParameterNamespacesAndTemporalLowering()
+ {
+ await using var store = new AgeGraphStore(
+ "Host=localhost;Port=5455;Username=postgres;Password=postgres;Database=postgres",
+ "translation");
+ var firstCutoff = DateTime.UnixEpoch;
+ var secondCutoff = firstCutoff.AddDays(1);
+ var thirdCutoff = firstCutoff.AddDays(2);
+ var query = store.Graph.Nodes()
+ .Where(person => person.DateOfBirth >= firstCutoff.AddDays(1))
+ .Select(person => person.FirstName)
+ .Concat(store.Graph.Nodes()
+ .Where(person => person.DateOfBirth >= secondCutoff.AddDays(1))
+ .Select(person => person.FirstName)
+ .Concat(store.Graph.Nodes()
+ .Where(person => person.DateOfBirth >= thirdCutoff.AddDays(1))
+ .Select(person => person.FirstName)));
+
+ var translated = Translate(query);
+
+ Assert.Equal(2, translated.Text.Split('\n').Count(line =>
+ string.Equals(line, "UNION ALL", StringComparison.Ordinal)));
+ Assert.Equal(3, translated.Parameters.Keys.Count(name =>
+ name.StartsWith("age_temporal_", StringComparison.Ordinal)));
+ Assert.Equal(translated.Parameters.Count, translated.Parameters.Keys.Distinct(StringComparer.Ordinal).Count());
+ Assert.DoesNotContain("duration(", translated.Text, StringComparison.Ordinal);
+ Assert.Equal(3, translated.Text.Split('\n').Count(line =>
+ string.Equals(line, "RETURN src.FirstName AS age_column_0", StringComparison.Ordinal)));
+ }
+
+ [Fact]
+ public async Task RecursivelyLowersLabelsAndCorrelatedCountsInEveryBranch()
+ {
+ await using var store = new AgeGraphStore(
+ "Host=localhost;Port=5455;Username=postgres;Password=postgres;Database=postgres",
+ "translation");
+ var left = store.Graph.Nodes()
+ .OfLabel(nameof(Manager))
+ .Select(person => new
+ {
+ person.FirstName,
+ Relationships = person.CountRelationships(GraphTraversalDirection.Both),
+ });
+ var right = store.Graph.Nodes()
+ .Where(person => person.Age >= 21)
+ .Select(person => new
+ {
+ person.FirstName,
+ Relationships = person.CountRelationships(GraphTraversalDirection.Both),
+ });
+
+ var translated = Translate(left.Union(right));
+
+ Assert.DoesNotContain("COUNT {", translated.Text, StringComparison.Ordinal);
+ Assert.DoesNotContain("EXISTS {", translated.Text, StringComparison.Ordinal);
+ Assert.DoesNotContain(":Person", translated.Text, StringComparison.Ordinal);
+ Assert.Equal(2, translated.Text.Split('\n').Count(line =>
+ line.StartsWith("OPTIONAL MATCH", StringComparison.Ordinal) &&
+ line.Contains("__age_count0", StringComparison.Ordinal)));
+ Assert.Contains("'Manager' IN labels(src)", translated.Text, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task RecursivelyLowersOrderedComplexEntityBranches()
+ {
+ await using var store = new AgeGraphStore(
+ "Host=localhost;Port=5455;Username=postgres;Password=postgres;Database=postgres",
+ "translation");
+ var left = store.Graph.Nodes()
+ .Where(person => person.Age >= 21)
+ .OrderBy(person => person.FirstName)
+ .Take(1);
+ var right = store.Graph.Nodes()
+ .Where(person => person.Age < 21)
+ .OrderByDescending(person => person.FirstName)
+ .Take(1);
+
+ var translated = Translate(left.Concat(right));
+
+ Assert.Equal(2, translated.Text.Split('\n').Count(line =>
+ line.StartsWith("OPTIONAL MATCH (src)-[rels", StringComparison.Ordinal)));
+ Assert.Equal(2, translated.Text.Split('\n').Count(line =>
+ line.StartsWith("ORDER BY src.FirstName", StringComparison.Ordinal)));
+ Assert.Equal(2, translated.Text.Split('\n').Count(line =>
+ string.Equals(line, "LIMIT 1", StringComparison.Ordinal)));
+ Assert.Equal(2, translated.Text.Split('\n').Count(line =>
+ line.StartsWith("RETURN { Node:", StringComparison.Ordinal)));
+ }
+
+ private static Cvoya.Graph.Age.Querying.Cypher.CypherQuery Translate(IQueryable query)
+ {
+ var visitor = new CypherQueryVisitor(query.ElementType);
+ visitor.Visit(query.Expression);
+ return visitor.Query;
}
}
diff --git a/tests/Graph.Age.Tests/GraphTests/AgeRelationshipPredicateIntegrationTests.cs b/tests/Graph.Age.Tests/GraphTests/AgeRelationshipPredicateIntegrationTests.cs
new file mode 100644
index 00000000..b1d549a9
--- /dev/null
+++ b/tests/Graph.Age.Tests/GraphTests/AgeRelationshipPredicateIntegrationTests.cs
@@ -0,0 +1,69 @@
+// Copyright CVOYA LLC. Licensed under the Apache License, Version 2.0.
+// See LICENSE in the project root for full license terms.
+
+namespace Cvoya.Graph.Age.Tests.GraphTests;
+
+using Cvoya.Graph.CompatibilityTests;
+
+public sealed class AgeRelationshipPredicateIntegrationTests(AgeHarness harness)
+ : AgeTest(harness, StoreIsolation.FreshStore)
+{
+ [Fact]
+ public async Task BothDirectionPredicatesStayAnchoredAndRejectAnyFailingHop()
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ var cutoff = DateTime.UtcNow.AddDays(-7);
+ var marker = $"AgeRelPredicate-{Guid.NewGuid():N}";
+ var alice = new Person { FirstName = $"{marker}-alice" };
+ var bob = new Person { FirstName = $"{marker}-bob" };
+ var charlie = new Person { FirstName = $"{marker}-charlie" };
+ var old = new Person { FirstName = $"{marker}-old" };
+ foreach (var person in new[] { alice, bob, charlie, old })
+ {
+ await Graph.CreateNodeAsync(person, cancellationToken: cancellationToken);
+ }
+
+ await Graph.ConnectAsync(
+ alice,
+ new Knows { Since = cutoff.AddDays(1) },
+ bob,
+ cancellationToken: cancellationToken);
+ await Graph.ConnectAsync(
+ charlie,
+ new Knows { Since = cutoff.AddDays(1) },
+ bob,
+ cancellationToken: cancellationToken);
+ await Graph.ConnectAsync(
+ old,
+ new Knows { Since = cutoff.AddDays(-1) },
+ alice,
+ cancellationToken: cancellationToken);
+ await Graph.ConnectAsync(
+ alice,
+ new Knows { Since = cutoff.AddDays(1) },
+ alice,
+ cancellationToken: cancellationToken);
+
+ var traversed = await Graph.Nodes()
+ .Where(person => person.TestKey == alice.TestKey)
+ .Traverse(options => options
+ .Direction(GraphTraversalDirection.Both)
+ .Depth(1, 2)
+ .WhereRelationship(relationship => relationship.Since >= cutoff))
+ .ToListAsync(cancellationToken);
+ var existing = await Graph.Nodes()
+ .Where(person => person.FirstName.StartsWith(marker))
+ .WhereHasRelationship(
+ GraphTraversalDirection.Both,
+ relationship => relationship.Since >= cutoff)
+ .ToListAsync(cancellationToken);
+
+ Assert.Contains(traversed, person => person.TestKey == alice.TestKey);
+ Assert.Contains(traversed, person => person.TestKey == bob.TestKey);
+ Assert.Contains(traversed, person => person.TestKey == charlie.TestKey);
+ Assert.DoesNotContain(traversed, person => person.TestKey == old.TestKey);
+ Assert.Equal(
+ new[] { alice.TestKey, bob.TestKey, charlie.TestKey }.Order(),
+ existing.Select(person => person.TestKey).Order());
+ }
+}