Allow Steps to Accept Traversal#3458
Conversation
Extend P, TextP, NotP, and the connective predicates (AndP/OrP) to carry a child traversal whose result is resolved per-traverser at runtime instead of a literal value. P.resolve() splits the traverser, seeds and runs the child traversal, and installs the result as the comparison value for the current test cycle. Semantics: - Scalar predicates (eq/neq/gt/lt/gte/lte) take the first result; an empty result cannot be satisfied and is flagged resolved-empty so steps short-circuit. - Collection predicates (within/without) resolve to a collection; an empty result resolves to an empty set so within(empty) is false and without(empty) is true, matching literal P.within([])/P.without([]) semantics. - Multi-traversal within/without combine the first result of each child. - AndP short-circuits resolution at the first child that resolves empty. - NotP exposes getWrapped() so traversal collection does not rely on negate(). GremlinLang serializes traversal-bearing predicates as op(traversal).
…mutation guards Wire the runtime-resolved predicates into the filter, lookup, and mutation steps and their DSL entry points: - has(key|T|label, traversal), hasLabel(traversal): normalized to a HasContainer holding P.eq(traversal) so there is a single resolution path; the direct traversal form on HasContainer is removed. - is(traversal), where(P) with a traversal-bearing predicate. - V(traversal)/E(traversal) start steps seed a synthetic traverser (consistent with mergeV/mergeE) so the id traversal can run without an incoming traverser. - property(traversal) producing a Map of key/value pairs, dispatched by an internal mapForm flag rather than a sentinel key. - All/Any/None step support for traversal-bearing predicates. Mutation safety is enforced in two layers: ChildTraversalValidator at DSL construction time and ChildTraversalVerificationStrategy at strategy time, the latter validating any step marked with the new AcceptsChildPredicateTraversal interface. GraphStep/TinkerGraphStepStrategy skip folding traversal-bearing HasContainers into index lookups since their value is dynamic.
Extend Gremlin.g4 and the traversal/predicate/spawn visitors to accept a nestedTraversal where a literal or predicate is expected in has(), hasLabel(), is(), V(), E(), property(), and the P/TextP predicate productions. Start-step V(traversal)/E(traversal) parsing is supported; mutating child traversals are rejected downstream by the verification strategy.
Mirror the Java overloads in gremlin-python, gremlin-go, gremlin-dotnet, and gremlin-javascript so has(), hasLabel(), is(), V(), E(), property(), and the P/TextP predicates accept child traversals. Update the .NET translator (Java and TS) and the translation fixtures, and add the generated cucumber step bindings for the new feature scenarios.
…ng steps Add cross-language Gherkin scenarios covering has/is/where/property/V/E with child-traversal arguments and the child-traversal mutation verification, plus CHANGELOG, upgrade, and reference documentation describing the feature, its first-result/empty-set semantics, and the provider-side HasContainer folding guard.
…te leftover null guards and dead code from HasContainer unification Assisted-by: Kiro:claude-opus-4-6
| // If the value is a DefaultGraphTraversal (the type created by __.xxx() anonymous traversals), | ||
| // treat it as a child traversal rather than a literal. This handles the case where Java's | ||
| // overload resolution picks P(BiPredicate, V) instead of P(BiPredicate, Traversal) when | ||
| // the caller passes a GraphTraversal. We specifically check for DefaultGraphTraversal | ||
| // rather than Traversal to avoid catching internal traversal types like ConstantTraversal, | ||
| // ValueTraversal, and IdentityTraversal which are used as literal values in P. |
There was a problem hiding this comment.
I'm not sure I agree with handling Traversals other than DefaultGraphTraversal as literal values. I can't imagine a case where I would want to P.eq() a ConstantTraversal, and have that mean constantTraversal.equals(myValue) instead of constantTraversal().next().equals(myValue) like any regular GraphTraversal. That said, I'm not sure when I would ever want to pass a ConstantTraversal into a predicate.
Even if the choice is kept to exclude most of the oddball traversals, I would think testing for GraphTraversal instead of DefaultGraphTraversal would be more reasonable.
| // the caller passes a GraphTraversal. We specifically check for DefaultGraphTraversal | ||
| // rather than Traversal to avoid catching internal traversal types like ConstantTraversal, | ||
| // ValueTraversal, and IdentityTraversal which are used as literal values in P. | ||
| if (value instanceof org.apache.tinkerpop.gremlin.process.traversal.dsl.graph.DefaultGraphTraversal) { |
There was a problem hiding this comment.
I think this logic should be folded into setValue() instead of kept exclusively in the constructor. I don't like the implication that new P(Compare.eq, __.V(5)) won't behave the same as new P(Compare.eq, 10).setValue(__.V(5))
| private Traversal.Admin<?, ?> traversalValue; | ||
| private List<Traversal.Admin<?, ?>> traversalValues; | ||
|
|
||
| public P(final PBiPredicate<V, V> biPredicate, final V value) { |
There was a problem hiding this comment.
I think we should be careful to avoid the assumption that V value here can't also be a List<Traversal> simply because that explicit overload is present, or because the type parameter theoretically forbids it. This class really abuses its generics, and relies heavily on type erasure to even function. It's not exactly type safe, and generally best to treat V as Object in most instances here.
| * Recursively integrates all child traversals found in the predicate tree into the given parent step. | ||
| * Handles {@link ConnectiveP} (recurses into children) and {@link NotP} (recurses into wrapped predicate). | ||
| */ | ||
| public static void integrateTraversals(final P<?> p, final TraversalParent parent) { |
There was a problem hiding this comment.
This feels like a bit of an odd API for P to surface, we already have collectTraversals() below. Couldn't the parent steps use this to integrate all of their children directly, instead of delegating this through P?
| private Traversal.Admin<?, ?> traversalValue; | ||
| private List<Traversal.Admin<?, ?>> traversalValues; |
There was a problem hiding this comment.
Nit: The result types of these traversals should arguably be V throughout this class. I expect that would help with a lot of the @SuppressWarnings("unchecked") annotations throughout this class. There's probably an argument that the generics are so broken in this class already that it hardly matters, but it would be more proper this way.
| private Traversal.Admin<?, ?> traversalValue; | |
| private List<Traversal.Admin<?, ?>> traversalValues; | |
| private Traversal.Admin<?, V> traversalValue; | |
| private List<Traversal.Admin<?, V>> traversalValues; |
| return new P(Contains.without, value); | ||
| } | ||
|
|
||
| /** |
There was a problem hiding this comment.
Nit on organization, it would be nice to interleave these overloads with their matching pairs (all the eq's together, all the gt's together...)
| * | ||
| * @since 4.0.0 | ||
| */ | ||
| public static <V> P<V> eq(final Traversal<?, ?> traversalValue) { |
There was a problem hiding this comment.
We shouldn't assume that traversals will never end up in the original eq(V value) overload, it's essentially eq(Object value) after erasure and java can be unreliable when selecting overloads sometimes. We ran into that problem quite a bit with GValue, the only value the GValue overloads really gave was making the generics behave a bit nicer in the average case. There's plenty of times where Java chooses to ignore the narrow overloads.
| * | ||
| * @since 4.0.0 | ||
| */ | ||
| public static <V> P<V> without(final Traversal<?, ?> first, final Traversal<?, ?> second, final Traversal<?, ?>... more) { |
There was a problem hiding this comment.
Why do we need special cases for these extra first, second, more overloads?
| * Throws {@link IllegalArgumentException} if one is found. | ||
| */ | ||
| public static void validate(final Traversal.Admin<?, ?> child) { | ||
| for (final Step<?, ?> step : child.getSteps()) { |
There was a problem hiding this comment.
Nit: this should probably just use
| * @since 4.0.0 | ||
| */ | ||
| public static <V> P<V> lt(final Traversal<?, ?> traversalValue) { | ||
| ChildTraversalValidator.validate(traversalValue.asAdmin()); |
There was a problem hiding this comment.
It might be better to pull this validation into the P constructor or even setValue(), in most cases P's are built via these static methods but not always.
Summary
Allow child traversals as arguments to
has(),is(),V(),E(),property(),where(P), and allP/TextPpredicates. The child traversal is evaluated per-traverser at runtime, enabling dynamic value resolution.JIRA:
within()silently return empty instead of executing or erroring - fully addressed (traversals are now executed)V()cannot accept traversals likeselect()for data-driven lookups - fully addressedproperty()should accept a traversal producing a Map - fully addressedhas(key, traversal)doesn't work as expected - partially addressed (dynamic filtering works; runtime folding into index lookups is deferred as a future optimization)What it enables
Review Guide
This is a large change (68 files, ~7k lines) but structured in layers. Review bottom-up:
5f37d11P.resolve(), empty-result semantics,AndPshort-circuit,ConnectiveP/NotPdelegation6a547d2P.resolve().HasStepsingle path,GraphStepidTraversal,AddPropertyStepmapForm,AcceptsChildPredicateTraversalmarkere80423aGremlin.g4nestedTraversalalternatives. Visitors delegate to API (notaddStep())1b21d15within()/without()serialization. .NET typed overloads793e7a8.featurefiles define behavioral contract. Reference doc updates400f5aeArchitecture (single resolution path)
Key decisions to scrutinize
Single resolution path - At the DSL level,
has(key, traversal)wraps the traversal inP.eq(traversal)before creating theHasContainer. This means HasStep only has one code path for traversal resolution: callP.resolve(traverser)on the predicate, thenhc.test(element). The GremlinLang serialization still records the originalhas(key, traversal)form (verified by round-trip tests). This is a new 4.0.0 feature, so no existing provider code is affected. Providers implementing custom strategies must checkHasContainer.hasTraversal()before folding into index lookups.Empty-result semantics - When a child traversal produces zero results, behavior depends on predicate type. Collection predicates (
within/without) resolve to an empty collection and delegate toContains.test()which applies correct set-theoretic logic:within([])is always false,without([])is always true. Scalar predicates (eq/gt/lt/etc.) setresolvedEmpty=trueand steps short-circuit to false since there is no meaningful comparison value. A null result (traversal producesnull) is distinct from an empty result: it resolves normally andnullis used as the comparison value, consistent with existingP.eq(null)semantics.AndP short-circuits, OrP doesn't -
AndP.resolve()overridesConnectiveP.resolve()to stop resolving children at the first scalar predicate that resolves empty. This is sound becauseand(unsatisfiable, X)is always false regardless of X.OrPdoes NOT short-circuit: a non-empty resolution does not guarantee the predicate will passtest(), so later children must already be resolved when their turn comes. Note: short-circuiting means side-effecting child traversals (e.g., ones usingaggregate) in later AndP operands may not execute. This matches standard boolean short-circuit semantics.P.resolve()mutates in place -resolve()overwritesliterals,isCollection, andresolvedEmptyfields on thePinstance. In OLTP this is safe since resolve and test happen sequentially per traverser. In OLAP,HasStep.clone()deep-clones predicates per worker, providing thread isolation. The mutation pattern is a known limitation documented for future improvement (return an immutable resolved value instead). Predicate trees of arbitrary depth (AndP containing OrP containing NotP, etc.) are handled by recursive resolution and recursive cloning.Mutation guard -
ChildTraversalValidatorrejects any child traversal containing a step that implements theMutatinginterface (addV, addE, drop, property). This runs at construction time (DSL methods and P factory methods) and again at strategy time viaChildTraversalVerificationStrategy. Aggregating side-effects (aggregate,store,group) are NOT blocked because they don't modify graph state. Whether they should be restricted is a question for reviewers.Provider contract -
HasContainer.hasTraversal()is the single check point providers must use before folding. Providers that fold HasContainers into index-backed steps without this check will attempt to use an unresolvedPvalue (returnsnullbeforeresolve()is called) as an index key, producing wrong results.TinkerGraphStepStrategydemonstrates the pattern: skip traversal-bearing HasSteps withcontinueso that subsequent literal HasSteps can still be folded. Providers with customGraphStepreplacements must also copy theidTraversalfield during strategy replacement, otherwiseV(traversal)falls through to returning all vertices.choose()restriction -choose(P)andchoose().option(P, ...)reject traversal-bearing predicates at construction time. This is a deliberate scope decision.PredicateTraversal(which evaluates option-key predicates inBranchStep) has access to the traverser and could technically callP.resolve()beforeP.test(), but extending the branching infrastructure is out of scope for this feature. Users needing dynamic branching can use thechoose(Traversal, ...)form where the branch predicate is a full traversal (e.g.,choose(__.is(P.gt(traversal)), trueChoice, falseChoice)). This works becauseIsStephandles resolution normally. Restrictingis()when used insidechoose()'s branch traversal is not feasible sincechoose()receives it as an opaqueTraversal.Adminand does not inspect internal steps.What's NOT in this PR
P.resolve()thread-safety refactoring (future)instanceof DefaultGraphTraversalcleanup (deferred)FilterRankingStrategycost awarenessCompatibility
has(key, traversal)still serializes ashas(key, traversal), verified byGremlinLangTraversalRoundTripTestandGremlinQueryParserTraversalTest.Testing
All
gremlin-coreunit tests pass (9132, 0 failures) and the full TinkerGraph Gherkin suite passes (2172 scenarios, 0 failures) against the currentmaster.HasTraversal.feature,IsTraversal.feature,WhereTraversal.feature,VETraversal.feature,PropertyTraversal.feature,ChildTraversalVerification.featurePTraversalTest,CloneIndependenceTraversalTest,HasContainerTest,ChildTraversalValidatorTest,ChildTraversalVerificationStrategyTestGremlinQueryParserTraversalTest,GremlinLangTraversalRoundTripTestTinkerGraphStepStrategyTraversalTestChecklist
the-traversal.asciidoc)release-4.x.x.asciidoc)mvn clean installpassesVOTE +1