Skip to content

Keep Coalesce(...) with a single sub-query from changing what that sub-query returns - #2474

Merged
danielcweber merged 3 commits into
14.xfrom
optimize/single-traversal-coalesce
Sep 30, 2026
Merged

danielcweber merged 3 commits into
14.xfrom
optimize/single-traversal-coalesce

Conversation

@danielcweber

@danielcweber danielcweber commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Coalesce(...) with a single sub-query no longer changes what that sub-query returns. Until now the steps of a single sub-query were always written directly into the surrounding query, so g.V<Person>().Coalesce(__ => __.Out<WorksFor>().Limit(1)) was emitted as g.V().hasLabel('Person').out('WorksFor').limit(1) and returned one vertex in total, instead of up to one per person. It is now emitted as g.V().hasLabel('Person').flatMap(__.out('WorksFor').limit(1)): with nothing to fall back to, a coalesce of a single traversal is a flatMap. The same applies to every sub-query containing a step that acts on the whole stream once it is inlined: Limit, Range, Skip, Tail, Dedup, Order, Fold, Count and the other aggregations.

A sub-query that gives the same result either way is emitted exactly as before, without any step around it: Coalesce(__ => __.Out<WorksFor>()) still becomes out('WorksFor'), filters still merge with the steps in front of them, and Coalesce(__ => __.Identity()) still adds nothing to the query.

Changes

  • Core: the Coalesce(...) query method adds the steps of a single sub-query to the query only if the new internal Traversal.IsLocal() says that they behave the same per element and as part of the surrounding query. Otherwise it adds a FlatMapStep with that sub-query. Either way the query carries the projection of the sub-query. Coalesce(...) with two or more sub-queries is unchanged.
  • Core: IsLocal() is a conservative allow-list of steps: filters, navigation, one-to-one maps, string and date functions, steps whose own traversals are evaluated per element (map, flatMap, local, coalesce, project), and the scoped steps (limit, range, tail, skip, dedup, count, order, min, max, mean, sum) in local scope only. Everything else, and every traversal that writes, is not local.
  • Core.Tests: the first commit records what Coalesce(...) with a single sub-query was written as before, so the fix shows as changes to those snapshots. A test over all step classes makes a new step fail until it has been decided whether it is local.

Notes

  • Closes Coalesce() with a single sub-query inlines steps that change meaning outside of coalesce #2471.
  • Emit coalesce() with a single child traversal without the coalesce step #2462 asked for the same distinction at the step or serialization level. The rule lives in the operator instead, where Gremlinq's other optimizations are; a CoalesceStep pushed directly via ConfigureSteps is written as pushed.
  • A query that relied on the old behaviour, e.g. on Coalesce(__ => __.Out().Limit(1)) limiting the whole result, now gets per-element results. Moving the step out of the Coalesce(...) restores the previous query.
  • A single sub-query that writes to the graph or contains a step that is not on the list gets the flatMap(...) as well, even where inlining would have been harmless: Coalesce(__ => __.Drop()) is now emitted as flatMap(__.drop()) instead of drop(). The result is the same, the query is a little longer.
  • The equivalences rest on the semantics of the TinkerPop steps, not on a run against a server. The new tests are serialization tests only.
  • No public API change. No existing snapshot changes: the shared query tests only use a single sub-query with Out() and with Identity().

🤖 Generated with Claude Code

@codecov

codecov Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.54%. Comparing base (404b1a8) to head (0fba749).

Additional details and impacted files
@@            Coverage Diff             @@
##             14.x    #2474      +/-   ##
==========================================
+ Coverage   93.24%   93.54%   +0.29%     
==========================================
  Files         279      279              
  Lines        7819     7871      +52     
  Branches      878      882       +4     
==========================================
+ Hits         7291     7363      +72     
+ Misses        333      313      -20     
  Partials      195      195              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

danielcweber and others added 3 commits September 30, 2026 11:23
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… its parent

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ves the same inlined

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@danielcweber
danielcweber force-pushed the optimize/single-traversal-coalesce branch from ecaf218 to 0fba749 Compare September 30, 2026 09:52
@danielcweber
danielcweber merged commit 0fba749 into 14.x Sep 30, 2026
5 checks passed
@danielcweber
danielcweber deleted the optimize/single-traversal-coalesce branch September 30, 2026 10:12
@danielcweber danielcweber changed the title Emit coalesce() with a single child traversal without the coalesce step Keep Coalesce(...) with a single sub-query from changing what that sub-query returns Sep 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Coalesce() with a single sub-query inlines steps that change meaning outside of coalesce

1 participant