Fix status filter dropping matching items
What does this MR do and why?
Fixes a bug where filtering work items by a custom status name or id could return no results even though matching items exist and display that status correctly. On a board, a card dropped into a status list shows on drop, then disappears on refresh when the list re-queries.
Root cause: ee/app/finders/work_items/status_filter.rb, in apply_mapping_exclusion_condition, used where.not(column: value) to exclude items that a status mapping would move away from the filtered status. The current status of a work item lives in one of two nullable columns on work_item_current_statuses: custom_status_id or system_defined_status_id. When the exclusion negated the column the item does not use (which is NULL), SQL three-valued logic caused the predicate to evaluate to NULL, and WHERE drops NULL rows. Items whose status lived in the other column were wrongly excluded whenever their work item type matched a conflicting mapping.
This only surfaces in namespaces with a specific mapping history (a status converted from a system-defined status that maps into the filtered status for one work item type and away from it for another), which is why it could not be reproduced in a fresh namespace.
Fix: Replace the NULL-unsafe NOT (work_item_type_id = X AND column = value) with an inline, NULL-safe condition (work_item_type_id != X OR column IS NULL OR column != value) evaluated on the work_item_current_statuses row the query already joins. Rows whose status column is NULL are kept, so matching items are no longer dropped. Genuinely mapped-away items are still excluded, and the change adds no subqueries and no new index.
Database
This MR only changes the part of the status filter that excludes items a mapping moves away from the filtered status.
What changed
- The old condition could drop items that really match the status, because
NOT (... AND column = value)becomesNULLin SQL when that column is empty, andWHEREthrowsNULLrows away. The new condition checks forNULLso those items stay. - It is still one query, with no subqueries, and needs no new index. The check runs on the
work_item_current_statusesrow the query already joins.
Query plans
I can't share Database Lab plans for this one. Work item statuses only exist on root groups, and this case needs a specific setup (several lifecycles with custom statuses and mappings across work item types). I don't have Maintainer access to a production group where I could set that up and run the scenario. The change only tweaks a condition on already-joined data, so it adds no subqueries or indexes.
Before
Click to expand
SELECT issues.*
FROM issues
INNER JOIN projects ON projects.id = issues.project_id
LEFT OUTER JOIN work_item_current_statuses ON work_item_current_statuses.work_item_id = issues.id
LEFT JOIN project_features ON projects.id = project_features.project_id
WHERE (
EXISTS (
SELECT 1 FROM project_authorizations
WHERE project_authorizations.user_id = 1
AND project_authorizations.project_id = projects.id
)
OR projects.visibility_level IN (0, 10, 20)
)
AND (project_features.issues_access_level > 0 OR project_features.issues_access_level IS NULL)
AND projects.id = 27
AND issues.work_item_type_id IN (2, 1, 5, 9)
AND (
work_item_current_statuses.custom_status_id IS NOT NULL AND work_item_current_statuses.custom_status_id = 39
OR issues.work_item_type_id = 1001 AND (
work_item_current_statuses.custom_status_id IS NOT NULL AND work_item_current_statuses.custom_status_id = 35
OR work_item_current_statuses.system_defined_status_id IS NOT NULL AND work_item_current_statuses.system_defined_status_id = 2
)
OR issues.work_item_type_id = 1 AND (
work_item_current_statuses.custom_status_id IS NOT NULL AND work_item_current_statuses.custom_status_id = 35
OR work_item_current_statuses.system_defined_status_id IS NOT NULL AND work_item_current_statuses.system_defined_status_id = 2
)
)
-- NULL-unsafe exclusion (this pair is emitted 4 times); drops rows where the column is NULL:
AND NOT (issues.work_item_type_id = 5 AND work_item_current_statuses.custom_status_id = 35)
AND NOT (issues.work_item_type_id = 5 AND work_item_current_statuses.system_defined_status_id = 2)
ORDER BY issues.created_at DESC, issues.id DESC
LIMIT 21After
Click to expand
SELECT issues.*
FROM issues
INNER JOIN projects ON projects.id = issues.project_id
LEFT OUTER JOIN work_item_current_statuses ON work_item_current_statuses.work_item_id = issues.id
LEFT JOIN project_features ON projects.id = project_features.project_id
WHERE (
EXISTS (
SELECT 1 FROM project_authorizations
WHERE project_authorizations.user_id = 1
AND project_authorizations.project_id = projects.id
)
OR projects.visibility_level IN (0, 10, 20)
)
AND (project_features.issues_access_level > 0 OR project_features.issues_access_level IS NULL)
AND projects.id = 27
AND issues.work_item_type_id IN (2, 1, 5, 9)
AND (
work_item_current_statuses.custom_status_id IS NOT NULL AND work_item_current_statuses.custom_status_id = 39
OR issues.work_item_type_id = 1001 AND (
work_item_current_statuses.custom_status_id IS NOT NULL AND work_item_current_statuses.custom_status_id = 35
OR work_item_current_statuses.system_defined_status_id IS NOT NULL AND work_item_current_statuses.system_defined_status_id = 2
)
OR issues.work_item_type_id = 1 AND (
work_item_current_statuses.custom_status_id IS NOT NULL AND work_item_current_statuses.custom_status_id = 35
OR work_item_current_statuses.system_defined_status_id IS NOT NULL AND work_item_current_statuses.system_defined_status_id = 2
)
)
-- NULL-safe exclusion (this pair is emitted 4 times, unchanged from master):
AND (
(issues.work_item_type_id != 5 OR work_item_current_statuses.custom_status_id IS NULL)
OR work_item_current_statuses.custom_status_id != 35
)
AND (
(issues.work_item_type_id != 5 OR work_item_current_statuses.system_defined_status_id IS NULL)
OR work_item_current_statuses.system_defined_status_id != 2
)
ORDER BY issues.created_at DESC, issues.id DESC
LIMIT 21References
Screenshots or screen recordings
| Scenario | Before | After |
|---|---|---|
| Work items list filtering | ![]() |
![]() |
| Board filtering | ![]() |
![]() |
How to set up and validate locally
Click to expand
Work items list
- Create a top-level group, a subgroup, and a project under the subgroup. The work items live in the project.
- On the top-level group, go to
Settings > Work items(custom statuses and lifecycles live on the root group). - On the
Defaultlifecycle,Edit statuses, add a status namedTriageunder the Triage category, and save. This converts the group to custom statuses (In progressnow carriesconverted_from_system_defined_status_identifier = 2). Create lifecycleand moveTaskonto it viaChange lifecycleusing the identity mapping. NowDefaultis used by Issue andTask lifecycleby Task. Both containTriage,To do, andIn progress.- On the
Defaultlifecycle,Edit statuses, removeIn progress, remap toTriage. This createsIn progress -> Triagefor Issue. - On the
Task lifecycle,Edit statuses, removeIn progress, remap toTo do. This createsIn progress -> To dofor Task. (Both targets are open statuses, which the app requires.) - In the project, create
Item 1(a Task) andItem 2(an Issue), both set toTriage. - Open the project's work items list and filter by
Status = Triage.- Before the fix: only
Item 2(Issue) shows,Item 1(Task) is missing. - After the fix: both
Item 1andItem 2show.
- Before the fix: only
Board
- Enable the board tasks flag:
Feature.enable(:work_item_tasks_on_boards). - On a Task board with a
Triagelist, dragItem 1into it. Before the fix the card appears on drop then disappears on refresh. After the fix it stays.
MR acceptance checklist
Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.



