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()); + } +}