Fix LeadingJoin Error in Work Item Hierarchy Queries

Problem

Premium customers encounter an ActiveRecord::ConfigurationError when querying Epics with child Issues through GraphQL:

ActiveRecord::ConfigurationError: Arel::Nodes::LeadingJoin is not supported for visitor_for

Ultimate customers querying identical Epic→Issue structures do not experience this error.

Affected versions: GitLab 18.10+, Premium-tier groups only

Root Cause

The bug is in the EE override of work_item_children_keyset_order (ee/app/models/ee/work_item.rb), which fires only for Premium groups (those without the :subepics license).

The original code builds the query through an association proxy:

non_epic_children = work_item.work_item_children.where.not(work_item_type_id: epic_type_id)
keyset_order.apply_cursor_conditions(non_epic_children.includes(:parent_link)).reorder(keyset_order)

work_item.work_item_children is a has_many :through :child_links association. When ActiveRecord constructs its scope via AssociationScope, it internally injects Arel::Nodes::LeadingJoin nodes into joins_values to join the through-table to the source table. These Arel nodes then bleed into the joins_values of the returned relation.

When the GraphQL preloader later attempts to preload work_item_children_by_relative_position (itself a has_many :through with a lambda scope), ThroughAssociation#through_scope reads those joins_values and passes them into JoinDependency#walk_tree. walk_tree only handles Symbol, String, Array, and Hash — it raises ConfigurationError when it encounters the LeadingJoin Arel node.

The CE path avoids this entirely because it calls self.joins(:parent_link) at the class level (WorkItem.joins(...)) — no association proxy, no LeadingJoin contamination.

The Ultimate path avoids this because the EE override returns super when :subepics is licensed, falling through to the safe CE code.

Solution

Rewrite the EE override to build the query at the class level, matching the CE pattern:

epic_type_id = ::WorkItems::TypesFramework::Provider.new(work_item.namespace).find_by_base_type(:epic).id
keyset_order = ::WorkItem.work_item_children_keyset_order_config

keyset_order.apply_cursor_conditions(
  joins(:parent_link).where.not(work_item_type_id: epic_type_id)
).reorder(keyset_order)

This uses joins(:parent_link) at the class scope (same as CE) and adds the WHERE NOT filter as a simple condition. The has_many :through :child_links association already constrains results to children of the given work item, so the epic-type exclusion is all that's needed.

Why this works

  • self.joins(:parent_link) operates on the WorkItem class, not an association proxy — no LeadingJoin Arel nodes are introduced
  • The query is compatible with ActiveRecord's preloader for has_many :through associations
  • Works identically for both Premium and Ultimate tiers
  • Maintains correct keyset ordering and epic-type filtering

Verification

Premium group: Epic→Issue query succeeds without error
Ultimate group: Continues to work
Ordering maintained: Proper ORDER BY with parent_link columns
No N+1 queries: Database query count appropriate for data loaded

Test query:

query {
  workItemsByReference(contextNamespacePath: "premium-group", refs: ["premium-group&1"]) {
    nodes {
      widgets {
        ... on WorkItemWidgetHierarchy {
          children { 
            count
            nodes { id title }
          }
        }
      }
    }
  }
}

How to Test Locally

Prerequisites: Premium-licensed group (NOT Ultimate) Setup:

# Rails console - create Epic with child Issues
group = Group.find_by(path: 'your-premium-group')
epic = group.work_items.create!(work_item_type: WorkItems::Type.default_by_type(:epic), title: 'Test Epic')
issue = group.projects.first.issues.create!(title: 'Child Issue')
WorkItems::ParentLink.create!(work_item_parent: epic, work_item: issue)

Test on master (should fail):

query {
  workItemsByReference(contextNamespacePath: "your-premium-group", refs: ["your-premium-group&EPIC_IID"]) {
    nodes {
      widgets {
        ... on WorkItemWidgetHierarchy {
          children { count }
        }
      }
    }
  }
}

Expected: ActiveRecord::ConfigurationError

Test on this branch (should succeed):
Same query - returns children count without error.

Relates to https://gitlab.com/gitlab-com/request-for-help/-/work_items/4588

Edited by Gosia Ksionek

Merge request reports

Loading