Adds work_item_type_ids filter to GraphQL
Add workItemTypeIds filter to GraphQL API
Problem
The current types filter on work item and issue queries uses the IssueTypeEnum enum, which is a static set of system-defined type names. This cannot support custom work item types because enum values can't be dynamically generated per-namespace. See #582561.
Solution
Add a new workItemTypeIds argument that accepts an array of WorkItems::Type GlobalIDs. This is additive — the existing types enum filter remains fully functional. The two filters are mutually exclusive, enforced via validates mutually_exclusive:.
What this MR does
Model — Two new scopes on Issue: with_work_item_type_ids and without_work_item_type_ids for direct integer-based filtering on work_item_type_id.
Finder — Issues::IssueTypesFilter gains by_work_item_type_ids. We don't pass type ids through the provider for authorization/validation because the DB query will kind of do this anyway for us. It will either match items of existing types or not. This way we keep the filteirng logic super slim.
Only downside is when you filter by a single type which is not part of your namespace, you'll get 0 results.
EE Finder — The EE override for by_work_item_type_ids gates access to the epic work item type based on license availability, matching the existing behaviour of by_issue_types. When parent is nil, epic type is stripped since license status cannot be verified.
GraphQL — workItemTypeIds argument added to:
-
WorkItems::SharedFilterArguments(work item list queries) -
Issues::BaseResolver(issue queries) -
Boards::BoardIssueInputBaseType(board list issues) -
WorkItems::NegatedWorkItemFilterInputType(negated filtering)
Each argument uses a prepare lambda to extract model_id from the GID before the finder receives it. Mutual exclusivity with types is validated at the GraphQL layer.
Scopes
This MR adds two new scopes which work with ids exclusively. They produce the same query in the end.
See scopes here as an overview
# Existing
scope :with_issue_type, ->(types) {
type_ids = WorkItems::TypesFramework::Provider.new.ids_by_base_types(types)
where(work_item_type_id: type_ids)
}
scope :without_issue_type, ->(types) {
type_ids = WorkItems::TypesFramework::Provider.new.ids_by_base_types(types)
where.not(work_item_type_id: type_ids)
}
# New
scope :with_work_item_type_ids, ->(ids) { where(work_item_type_id: ids) }
scope :without_work_item_type_ids, ->(ids) { where.not(work_item_type_id: ids) }
DB queries
The new scopes and existing scopes produce the same queries. They use work_item_type_id which is indexed (composite index using general access patterns across namespace, state_id, work_item_type_id`). So we're actually not reviewing the performance of this query because it's the same than today. The DB review is mostly about the finder per se and the scope
This is a query that was generated when filtering for open issues (using GitLab project id)
SELECT "issues"."id",
"issues"."title",
"issues"."author_id",
"issues"."project_id",
"issues"."created_at",
"issues"."updated_at",
"issues"."description",
"issues"."milestone_id",
"issues"."iid",
"issues"."updated_by_id",
"issues"."weight",
"issues"."confidential",
"issues"."due_date",
"issues"."moved_to_id",
"issues"."lock_version",
"issues"."title_html",
"issues"."description_html",
"issues"."time_estimate",
"issues"."relative_position",
"issues"."service_desk_reply_to",
"issues"."cached_markdown_version",
"issues"."last_edited_at",
"issues"."last_edited_by_id",
"issues"."discussion_locked",
"issues"."closed_at",
"issues"."closed_by_id",
"issues"."state_id",
"issues"."duplicated_to_id",
"issues"."promoted_to_epic_id",
"issues"."health_status",
"issues"."sprint_id",
"issues"."blocking_issues_count",
"issues"."upvotes_count",
"issues"."work_item_type_id",
"issues"."namespace_id",
"issues"."start_date",
"issues"."imported_from",
"issues"."namespace_traversal_ids"
FROM "issues"
INNER JOIN "projects"
ON "projects"."id" = "issues"."project_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" = 278964
AND "issues"."state_id" = 1
AND "issues"."work_item_type_id" = 1
ORDER BY "issues"."created_at" DESC, "issues"."id" DESC LIMIT 101
https://explain-depesz.postgres.ai/s/U8
Limit (cost=2.84..121.52 rows=101 width=999) (actual time=95.859..219.140 rows=101 loops=1)
Buffers: shared hit=17 read=160 dirtied=26
WAL: records=26 fpi=26 bytes=171514
-> Incremental Sort (cost=2.84..142141.17 rows=120965 width=999) (actual time=95.858..219.128 rows=101 loops=1)
Sort Key: issues.created_at DESC, issues.id DESC
Buffers: shared hit=17 read=160 dirtied=26
WAL: records=26 fpi=26 bytes=171514
-> Nested Loop (cost=1.70..136697.74 rows=120965 width=999) (actual time=29.396..218.623 rows=102 loops=1)
Buffers: shared hit=8 read=160 dirtied=26
WAL: records=26 fpi=26 bytes=171514
-> Index Scan Backward using index_issues_on_work_item_type_id_project_id_created_at_state on public.issues (cost=0.57..135148.46 rows=123080 width=999) (actual time=7.503..196.394 rows=102 loops=1)
Index Cond: ((issues.work_item_type_id = 1) AND (issues.project_id = 278964) AND (issues.state_id = 1))
Buffers: shared hit=7 read=143 dirtied=25
WAL: records=25 fpi=25 bytes=166977
-> Materialize (cost=1.13..10.79 rows=1 width=4) (actual time=0.215..0.216 rows=1 loops=102)
Buffers: shared hit=1 read=17 dirtied=1
WAL: records=1 fpi=1 bytes=4537
-> Nested Loop Left Join (cost=1.13..10.78 rows=1 width=4) (actual time=21.881..21.908 rows=1 loops=1)
Filter: ((project_features.issues_access_level > 0) OR (project_features.issues_access_level IS NULL))
Rows Removed by Filter: 0
Buffers: shared hit=1 read=17 dirtied=1
WAL: records=1 fpi=1 bytes=4537
-> Index Scan using idx_projects_on_repository_storage_last_repository_updated_at on public.projects (cost=0.56..7.18 rows=1 width=4) (actual time=16.609..16.634 rows=1 loops=1)
Index Cond: (projects.id = 278964)
Filter: (EXISTS(SubPlan 1) OR (projects.visibility_level = ANY ('{0,10,20}'::integer[])))
Rows Removed by Filter: 0
Buffers: shared hit=1 read=12 dirtied=1
WAL: records=1 fpi=1 bytes=4537
SubPlan 1
-> Index Only Scan using index_project_authorizations_on_project_user_access_level on public.project_authorizations (cost=0.58..3.60 rows=1 width=0) (actual time=6.357..6.357 rows=1 loops=1)
Index Cond: ((project_authorizations.project_id = projects.id) AND (project_authorizations.user_id = 1))
Heap Fetches: 0
Buffers: shared read=5
-> Index Scan using index_project_features_on_project_id on public.project_features (cost=0.57..3.58 rows=1 width=8) (actual time=5.267..5.267 rows=1 loops=1)
Index Cond: (project_features.project_id = 278964)
Buffers: shared read=5
Settings: work_mem = '230MB', seq_page_cost = '4', effective_cache_size = '472585MB', jit = 'off', random_page_cost = '1.5'
How to verify
Filter by work item type GIDs
query {
project(fullPath: "my/project") {
workItems(workItemTypeIds: ["gid://gitlab/WorkItems::Type/9"]) {
nodes { id title
workItemType {
id
}
}
}
}
}
Negated filtering
query {
project(fullPath: "my/project") {
workItems(not: { workItemTypeIds: ["gid://gitlab/WorkItems::Type/9"] }) {
nodes { id title
workItemType {
id
}
}
}
}
}
Multiple types (OR)
query {
project(fullPath: "gitlab-org/gitlab") {
workItems(workItemTypeIds:
["gid://gitlab/WorkItems::Type/9", "gid://gitlab/WorkItems::Type/5"]
) {
nodes { id title
workItemType {
id
}
}
}
}
}
Also works for issues endpoint
Also single, multiple and negated.
query {
project(fullPath: "gitlab-org/gitlab") {
id
issues(workItemTypeIds: ["gid://gitlab/WorkItems::Type/9"]) {
nodes {
id
title
type
}
}
}
}
Also works for board lists endpoint
Example uses negated types, but single type and array work. Same usage as work item and issue.
query {
project(fullPath: "gitlab-org/gitlab") {
board(id: "gid://gitlab/Board/3") {
lists {
nodes {
issues(filters: { not: { workItemTypeIds: ["gid://gitlab/WorkItems::Type/9"] } }) {
nodes {
id
title
type
}
}
}
}
}
}
}
Related
- Part 1 of 3: This MR (Finder + Model + GraphQL)
- Part 2: REST API (adds
work_item_type_idsparam to Issues and Work Items REST endpoints) - Part 3: Elasticsearch (adds
work_item_type_idstoALLOWED_ES_FILTERS) - Closes #582561 (after all 3 MRs)
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.




