Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 22 additions & 15 deletions src/Graph.Age/COMPLIANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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:

Expand Down
16 changes: 9 additions & 7 deletions src/Graph.Age/Querying/Cypher/AgeDialect.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -48,6 +52,7 @@ public sealed class AgeDialect : ICypherDialect
GraphCapability.CallSubqueries,
GraphCapability.PatternSizeProjection,
GraphCapability.GroupByAggregation,
GraphCapability.SetOperations,
GraphCapability.OrderByEntity,
GraphCapability.RelationshipPredicates);

Expand All @@ -67,10 +72,8 @@ public AgeDialect()
internal static AgeDialect PlanningInstance { get; } = new(PlanningCapabilities);

/// <summary>
/// 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.
/// </summary>
internal static AgeDialect CommandPlanningInstance { get; } = new(CommandPlanningCapabilities);

Expand All @@ -85,8 +88,7 @@ public AgeDialect()
/// (<see cref="Querying.AgeFullTextSearch"/>).
/// Scalar-key grouping uses AGE's native grouping and aggregate support through the shared
/// structured <c>WITH</c> 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.
/// </remarks>
public CapabilitySet Capabilities => capabilities;

Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>Recursively applies AGE lowering to independently scoped clause containers.</summary>
internal static class AgeClauseContainerTraversal
{
public static CypherStatement RunSetOperationBranches(
CypherStatement input,
Func<CypherStatement, CypherStatement> 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -333,15 +333,16 @@ 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;
}

var extractor = new PatternSubqueryExtractor(countAliasPrefix);
var output = new List<ICypherClause>();
var scope = new List<string>();
OrderByClause? entityResultOrdering = null;
for (var index = 0; index < input.Clauses.Count; index++)
{
var clause = input.Clauses[index];
Expand Down Expand Up @@ -369,27 +370,41 @@ 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))
.ToArray();

// 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());
Expand All @@ -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;
Expand All @@ -412,6 +451,19 @@ private static CypherStatement LowerPatternSubqueryProjections(
: new CypherStatement(output, input.Parameters, input.PathTypes);
}

private static int FindProjectionTerminalIndex(IReadOnlyList<ICypherClause> 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))
Expand Down
Loading
Loading