DEV Community

Cover image for Fixing Silent Filter Drops in GitLab Work Item Saved Views: Unwrapping GraphQL Status IDs
Manish Kumar
Manish Kumar

Posted on

Fixing Silent Filter Drops in GitLab Work Item Saved Views: Unwrapping GraphQL Status IDs

A deep dive into GraphQL input preparation, GlobalID resolution, Ruby hash normalization, and regression testing in GitLab.

While contributing to GitLab's Work Items Saved Views feature, I investigated a subtle bug where saving a view with a status filter supplied by Global ID silently dropped the filter without returning an error.

The GraphQL mutation completed successfully, the response contained no errors, but the persisted saved view did not retain the requested status filter.

The issue turned out to be an interaction between GraphQL input preparation and the downstream filter normalization service.

In this article, I'll walk through the request flow, identify the root cause, explain the fix, and discuss the regression tests added in GitLab Merge Request !269035.


1. Understanding the Problem

GitLab Work Items Saved Views allow users to save filtered views of work items so they can reuse specific filtering configurations.

One of the supported filters is status.

When creating or updating a saved view through GraphQL, clients can provide a status using its Global ID.

For example:

mutation {
  workItemSavedViewCreate(
    input: {
      name: "In Progress Items"
      filters: {
        status: {
          id: "gid://gitlab/WorkItems::Statuses::Custom::Status/123"
        }
      }
    }
  ) {
    workItemSavedView {
      id
      name
      filters
    }
    errors
  }
}
Enter fullscreen mode Exit fullscreen mode

The expected behavior was straightforward:

  1. GraphQL receives the status Global ID.
  2. The ID is resolved into the corresponding status object.
  3. The saved view normalization service processes the status filter.
  4. The normalized filter is persisted with the saved view.

However, the actual behavior was different.

Observed behavior:

  • The GraphQL mutation completed successfully.
  • The response returned an empty errors array.
  • The status filter was silently omitted from the resulting saved view.

This was particularly difficult to identify because the request itself appeared successful. There was no GraphQL validation error or explicit failure indicating that the filter had been discarded.

The issue affected both saved view creation and updating when status filters were supplied using Global IDs.


2. Tracing the GraphQL Input Pipeline

To understand why the filter disappeared, I traced how the status input moved from the GraphQL mutation into the service responsible for normalizing saved view filters.

The important detail was that GraphQL was not passing the original Global ID string directly to the normalization service.

2.1 GlobalID input preparation

GitLab's StatusFilterInputType uses GraphQL's prepare mechanism on the :id argument.

The purpose of this preparation is to resolve the supplied Global ID into a corresponding WorkItems::Statuses::Status object before the input reaches the downstream application logic.

Conceptually, the transformation looks like this:

Original GraphQL input:

{
  id: "gid://gitlab/WorkItems::Statuses::Custom::Status/123"
}
Enter fullscreen mode Exit fullscreen mode

After GraphQL input preparation:

{
  id: status_object
}
Enter fullscreen mode Exit fullscreen mode

Here, status_object represents the resolved status instance.

This distinction is important.

The downstream service does not necessarily receive the same data structure that the client submitted through GraphQL.

GraphQL input preparation has already transformed the value.

2.2 The normalization boundary

The prepared input is subsequently passed to:

ee/app/services/ee/work_items/saved_views/filter_normalizer_service.rb
Enter fullscreen mode Exit fullscreen mode

The responsibility of FilterNormalizerService is to normalize the incoming saved view filters into the expected representation before persistence.

At this boundary, the service needs to understand the shape of the data produced by GraphQL.

However, the existing normalize_status implementation did not account for the prepared { id: status_object } structure.

That mismatch was the source of the problem.


3. Root Cause: An Unexpected Hash Structure

The existing normalization logic supported two forms of status input:

  1. A bare Status object.
  2. A hash containing :name.

However, GraphQL input preparation introduced a third representation:

{
  id: status_object
}
Enter fullscreen mode Exit fullscreen mode

The normalization method did not unwrap the status object from this hash.

As a result, the prepared input did not match the structures that the normalizer knew how to process.

The important distinction was:

Input representation Existing support
Bare Status object Supported
{ name: "In Progress" } Supported
{ id: status_object } Not handled correctly

The problem was not that GlobalID resolution failed.

In fact, GraphQL had already successfully resolved the identifier.

The problem was that the normalization layer did not recognize the resulting prepared input structure.

This explains why the mutation could succeed without reporting an error while still producing an incomplete saved view.

The failure occurred after successful GraphQL input preparation, at the boundary between prepared input and filter normalization.


4. Implementing the Fix

The fix was intentionally small and targeted.

Instead of changing GraphQL input preparation or introducing another conversion mechanism, I updated the existing normalize_status method to unwrap the prepared status object from the :id hash before continuing with normalization.

File:

ee/app/services/ee/work_items/saved_views/filter_normalizer_service.rb
Enter fullscreen mode Exit fullscreen mode

The relevant change:

def normalize_status
  return unless filters[:status]

  status_filter = filters[:status]

  status_filter = status_filter[:id] if status_filter.is_a?(Hash) && status_filter[:id]

  # Existing normalization logic continues...
end
Enter fullscreen mode Exit fullscreen mode

4.1 Why this fixes the issue

The new handling extracts the resolved status object when the incoming value is a hash containing :id.

For example:

Before unwrapping:

status_filter = {
  id: status_object
}
Enter fullscreen mode Exit fullscreen mode

After unwrapping:

status_filter = status_object
Enter fullscreen mode Exit fullscreen mode

The existing normalization logic can now process the resolved object using the same path it already uses for bare status objects.

4.2 Preserving backward compatibility

An important part of this change was preserving the existing :name-based filtering behavior.

The additional condition:

status_filter.is_a?(Hash) && status_filter[:id]
Enter fullscreen mode Exit fullscreen mode

ensures that only hashes containing an ID value are unwrapped.

Other supported input representations continue through the existing normalization logic.

This avoids unnecessarily changing the established behavior of the service.

4.3 Supporting system-defined and custom statuses

The fix also preserves ID-based filtering for both categories of statuses:

  • System-defined statuses.
  • Custom statuses.

Both are resolved through the GraphQL GlobalID input preparation mechanism and can now be correctly handled by the normalization service.

The key engineering decision was to correct the mismatch at the normalization boundary rather than modify the upstream GraphQL resolution behavior.


5. Regression Tests

A fix at the service boundary is not sufficient without verifying that the behavior works through the actual GraphQL mutation flow.

I added regression coverage at two levels.

5.1 Service-level unit tests

Location:

ee/spec/services/ee/work_items/saved_views/filter_normalizer_service_spec.rb
Enter fullscreen mode Exit fullscreen mode

The service-level tests cover:

  • Custom statuses supplied through { id: status }.
  • System-defined statuses supplied through { id: status }.
  • Correct normalization of the prepared status object.
  • The unresolved numeric ID test case, which was updated as part of the change.

These tests verify that the normalizer correctly handles the prepared input structure independently of the GraphQL request layer.

5.2 GraphQL mutation request specs

I also added request-level regression tests for both saved view mutations.

Create mutation:

ee/spec/requests/api/graphql/mutations/work_items/saved_views/create_spec.rb
Enter fullscreen mode Exit fullscreen mode

Update mutation:

ee/spec/requests/api/graphql/mutations/work_items/saved_views/update_spec.rb
Enter fullscreen mode Exit fullscreen mode

The tests exercise:

  • workItemSavedViewCreate with system-defined status Global IDs.
  • workItemSavedViewCreate with custom status Global IDs.
  • workItemSavedViewUpdate with system-defined status Global IDs.
  • workItemSavedViewUpdate with custom status Global IDs.

These tests are important because they validate the complete input transformation path:

GraphQL Mutation
       |
       v
StatusFilterInputType
       |
       v
GlobalID Resolution
       |
       v
Prepared Status Object
       |
       v
FilterNormalizerService
       |
       v
Status Filter Normalization
       |
       v
Saved View
Enter fullscreen mode Exit fullscreen mode

Testing at both levels provides coverage for the underlying normalization behavior as well as the integration between GraphQL and the service layer.


6. Why This Bug Matters

This issue highlights an important characteristic of GraphQL-based application architectures.

Input preparation can transform client-provided values before they reach application services.

A service that assumes it will always receive the original input structure can therefore behave incorrectly when an upstream layer resolves or transforms that input.

In this case, the difference between:

{ id: "gid://..." }
Enter fullscreen mode Exit fullscreen mode

and:

{ id: status_object }
Enter fullscreen mode Exit fullscreen mode

was enough to cause a silent loss of filtering behavior.

There are several engineering lessons worth taking away.

Understand the complete input lifecycle

When debugging GraphQL mutations, inspect the data after input preparation rather than assuming the service receives the original client payload.

Treat service boundaries as explicit contracts

The normalization service must understand the actual input representation produced by its callers.

Avoid unnecessary changes to upstream layers

The GlobalID resolution was already working correctly. Changing it would have introduced unnecessary complexity.

Fixing the mismatch where it occurred resulted in a smaller and more focused change.

Test both unit behavior and integration behavior

Service-level tests verify normalization logic, while GraphQL request specs verify that the complete mutation pipeline preserves the filter.

Both perspectives are necessary for catching regressions across application boundaries.


7. Conclusion

The issue in GitLab Work Item Saved Views was a good example of how a small mismatch between two application layers can produce a subtle user-facing bug.

GraphQL successfully resolved the status Global ID, but the downstream filter normalizer did not recognize the prepared { id: status_object } structure.

By unwrapping the prepared object inside normalize_status, I fixed the issue while preserving existing name-based filtering behavior.

The regression tests cover both custom and system-defined statuses across the create and update GraphQL mutations.

The broader takeaway is simple:

When an input passes through multiple transformation layers, correctness depends on every layer agreeing on the shape and semantics of the data being passed forward.

This contribution gave me an opportunity to work deeper into GitLab's GraphQL input pipeline, Ruby service-layer design, and regression testing within a large production Rails application.


References

  • Issue: gitlab-org/gitlab#638187
  • Merge Request: gitlab-org/gitlab!269035
  • Service: ee/app/services/ee/work_items/saved_views/filter_normalizer_service.rb
  • Service Specs: ee/spec/services/ee/work_items/saved_views/filter_normalizer_service_spec.rb
  • GraphQL Create Specs: ee/spec/requests/api/graphql/mutations/work_items/saved_views/create_spec.rb
  • GraphQL Update Specs: ee/spec/requests/api/graphql/mutations/work_items/saved_views/update_spec.rb

Author: Manish Kumar

GitLab: @manishpatel00

Contributing to GitLab's Work Items and GraphQL infrastructure.

Top comments (0)