Add exclude_ids argument to BaseGroupsResolver

What does this MR do and why?

Expose an exclude_ids argument on BaseGroupsResolver so that consumers of the Groups GraphQL API can exclude specific groups by GitLab Global ID directly in their query.

This is needed for the Organizations Universal Onboarding Workflow to query top-level groups (TLGs) while excluding the group currently being onboarded. Without this argument, callers must filter client-side or make additional round-trips, which is inefficient and error-prone.

The argument uses GlobalIDType[Group] for type-safe Global ID coercion (e.g. gid://gitlab/Group/123) and passes the coerced integer IDs to GroupsFinder as exclude_group_ids, which already supports this parameter via groups.id_not_in(params[:exclude_group_ids]).

Closes #607395 (closed)

References

  • Issue: #607395 (closed)
  • GroupsFinder already supports exclude_group_ids: app/finders/groups_finder.rb

Database review

Scoped to owned TLGs

SQL

SELECT
    "namespaces"."id",
    "namespaces"."name",
    "namespaces"."path",
    "namespaces"."owner_id",
    "namespaces"."created_at",
    "namespaces"."updated_at",
    "namespaces"."type",
    "namespaces"."avatar",
    "namespaces"."membership_lock",
    "namespaces"."share_with_group_lock",
    "namespaces"."visibility_level",
    "namespaces"."request_access_enabled",
    "namespaces"."ldap_sync_status",
    "namespaces"."ldap_sync_error",
    "namespaces"."ldap_sync_last_update_at",
    "namespaces"."ldap_sync_last_successful_update_at",
    "namespaces"."ldap_sync_last_sync_at",
    "namespaces"."lfs_enabled",
    "namespaces"."parent_id",
    "namespaces"."shared_runners_minutes_limit",
    "namespaces"."repository_size_limit",
    "namespaces"."require_two_factor_authentication",
    "namespaces"."two_factor_grace_period",
    "namespaces"."project_creation_level",
    "namespaces"."runners_token",
    "namespaces"."saml_discovery_token",
    "namespaces"."runners_token_encrypted",
    "namespaces"."auto_devops_enabled",
    "namespaces"."extra_shared_runners_minutes_limit",
    "namespaces"."last_ci_minutes_notification_at",
    "namespaces"."last_ci_minutes_usage_notification_level",
    "namespaces"."subgroup_creation_level",
    "namespaces"."max_pages_size",
    "namespaces"."max_artifacts_size",
    "namespaces"."mentions_disabled",
    "namespaces"."default_branch_protection",
    "namespaces"."max_personal_access_token_lifetime",
    "namespaces"."shared_runners_enabled",
    "namespaces"."allow_descendants_override_disabled_shared_runners",
    "namespaces"."traversal_ids",
    "namespaces"."organization_id",
    "namespaces"."state"
FROM
    "namespaces"
    INNER JOIN "members" ON "namespaces"."id" = "members"."source_id"
WHERE
    "members"."type" = 'GroupMember'
    AND "members"."source_type" = 'Namespace'
    AND "namespaces"."type" = 'Group'
    AND "members"."user_id" = 5413811
    AND "members"."requested_at" IS NULL
    AND (access_level >= 10)
    AND "members"."access_level" = 50
    AND "namespaces"."id" NOT IN (92221560, 95280580)
    AND "namespaces"."parent_id" IS NULL
ORDER BY
    LOWER("namespaces"."name") ASC,
    "namespaces"."id" DESC
LIMIT 101

Query plan

 Limit  (cost=35.12..35.12 rows=1 width=390) (actual time=122.640..122.643 rows=5 loops=1)
   Buffers: shared hit=43 read=76 dirtied=1
   WAL: records=1 fpi=1 bytes=8097
   I/O Timings: read=121.537 write=0.000
   ->  Sort  (cost=35.12..35.12 rows=1 width=390) (actual time=122.638..122.640 rows=5 loops=1)
         Sort Key: (lower((namespaces.name)::text)), namespaces.id DESC
         Sort Method: quicksort  Memory: 26kB
         Buffers: shared hit=43 read=76 dirtied=1
         WAL: records=1 fpi=1 bytes=8097
         I/O Timings: read=121.537 write=0.000
         ->  Nested Loop  (cost=1.13..35.11 rows=1 width=390) (actual time=19.143..122.573 rows=5 loops=1)
               Buffers: shared hit=37 read=76 dirtied=1
               WAL: records=1 fpi=1 bytes=8097
               I/O Timings: read=121.537 write=0.000
               ->  Index Scan Backward using idx_members_on_user_and_source_and_source_type_and_member_role on public.members  (cost=0.57..27.94 rows=2 width=4) (actual time=7.985..63.167 rows=18 loops=1)
                     Index Cond: ((members.user_id = 5413811) AND ((members.source_type)::text = 'Namespace'::text))
                     Filter: ((members.requested_at IS NULL) AND (members.access_level >= 10) AND ((members.type)::text = 'GroupMember'::text) AND (members.access_level = 50))
                     Rows Removed by Filter: 9
                     Buffers: shared hit=2 read=32 dirtied=1
                     WAL: records=1 fpi=1 bytes=8097
                     I/O Timings: read=62.778 write=0.000
               ->  Index Scan using index_groups_on_parent_id_id on public.namespaces  (cost=0.56..3.58 rows=1 width=358) (actual time=3.291..3.291 rows=0 loops=18)
                     Index Cond: ((namespaces.parent_id IS NULL) AND (namespaces.id = members.source_id))
                     Filter: (namespaces.id <> ALL ('{92221560,95280580}'::integer[]))
                     Buffers: shared hit=35 read=44
                     I/O Timings: read=58.759 write=0.000
Settings: jit = 'off', random_page_cost = '1.5', work_mem = '230MB', seq_page_cost = '4', effective_cache_size = '472585MB'
Query ID: -4011964592960925909
Time: 127.156 ms  
  - planning: 4.419 ms  
  - execution: 122.737 ms  
    - I/O read: 121.537 ms  
    - I/O write: 0.000 ms  
  
Shared buffers:  
  - hits: 43 (~344.00 KiB) from the buffer pool  
  - reads: 76 (~608.00 KiB) from the OS file cache, including disk I/O  
  - dirtied: 1 (~8.00 KiB)  
  - writes: 0  

https://postgres.ai/console/gitlab/gitlab-production-main/sessions/54154/commands/156652

Unscoped

SQL

SELECT
    "namespaces"."id",
    "namespaces"."name",
    "namespaces"."path",
    "namespaces"."owner_id",
    "namespaces"."created_at",
    "namespaces"."updated_at",
    "namespaces"."type",
    "namespaces"."avatar",
    "namespaces"."membership_lock",
    "namespaces"."share_with_group_lock",
    "namespaces"."visibility_level",
    "namespaces"."request_access_enabled",
    "namespaces"."ldap_sync_status",
    "namespaces"."ldap_sync_error",
    "namespaces"."ldap_sync_last_update_at",
    "namespaces"."ldap_sync_last_successful_update_at",
    "namespaces"."ldap_sync_last_sync_at",
    "namespaces"."lfs_enabled",
    "namespaces"."parent_id",
    "namespaces"."shared_runners_minutes_limit",
    "namespaces"."repository_size_limit",
    "namespaces"."require_two_factor_authentication",
    "namespaces"."two_factor_grace_period",
    "namespaces"."project_creation_level",
    "namespaces"."runners_token",
    "namespaces"."saml_discovery_token",
    "namespaces"."runners_token_encrypted",
    "namespaces"."auto_devops_enabled",
    "namespaces"."extra_shared_runners_minutes_limit",
    "namespaces"."last_ci_minutes_notification_at",
    "namespaces"."last_ci_minutes_usage_notification_level",
    "namespaces"."subgroup_creation_level",
    "namespaces"."max_pages_size",
    "namespaces"."max_artifacts_size",
    "namespaces"."mentions_disabled",
    "namespaces"."default_branch_protection",
    "namespaces"."max_personal_access_token_lifetime",
    "namespaces"."shared_runners_enabled",
    "namespaces"."allow_descendants_override_disabled_shared_runners",
    "namespaces"."traversal_ids",
    "namespaces"."organization_id",
    "namespaces"."state"
FROM
    "namespaces"
WHERE
    "namespaces"."type" = 'Group'
    AND "namespaces"."id" NOT IN (92221560, 95280580)
ORDER BY
    LOWER("namespaces"."name") ASC,
    "namespaces"."id" DESC
LIMIT 101

Query plan

 Limit  (cost=4.81..58.40 rows=101 width=391) (actual time=378.396..820.796 rows=101 loops=1)
   Buffers: shared hit=6 read=579 dirtied=2
   WAL: records=2 fpi=2 bytes=15950
   I/O Timings: read=810.720 write=0.000
   ->  Incremental Sort  (cost=4.81..5627128.21 rows=10604968 width=391) (actual time=378.393..820.780 rows=101 loops=1)
         Sort Key: (lower((namespaces.name)::text)), namespaces.id DESC
         Presorted Key: (lower((namespaces.name)::text))
         Full-sort Groups: 4  Sort Method: quicksort  Average Memory: 34kB  Peak Memory: 34kB
         Buffers: shared hit=6 read=579 dirtied=2
         WAL: records=2 fpi=2 bytes=15950
         I/O Timings: read=810.720 write=0.000
         ->  Index Scan using index_on_namespaces_lower_name on public.namespaces  (cost=0.57..5307626.48 rows=10604968 width=391) (actual time=24.157..820.065 rows=102 loops=1)
               Filter: ((namespaces.id <> ALL ('{92221560,95280580}'::integer[])) AND ((namespaces.type)::text = 'Group'::text))
               Rows Removed by Filter: 471
               Buffers: shared read=579 dirtied=2
               WAL: records=2 fpi=2 bytes=15950
               I/O Timings: read=810.720 write=0.000
Settings: random_page_cost = '1.5', work_mem = '230MB', seq_page_cost = '4', effective_cache_size = '472585MB', jit = 'off'
Query ID: -830973834980066604
Time: 824.576 ms  
  - planning: 3.652 ms  
  - execution: 820.924 ms  
    - I/O read: 810.720 ms  
    - I/O write: 0.000 ms  
  
Shared buffers:  
  - hits: 6 (~48.00 KiB) from the buffer pool  
  - reads: 579 (~4.50 MiB) from the OS file cache, including disk I/O  
  - dirtied: 2 (~16.00 KiB)  
  - writes: 0  

https://postgres.ai/console/gitlab/gitlab-production-main/sessions/54231/commands/156799

Screenshots or screen recordings

Without excludeIds With excludeIds
Screenshot_2026-07-29_at_9.32.37_PM Screenshot_2026-07-29_at_9.33.23_PM

How to set up and validate locally

  1. In a Rails console or via GraphQL explorer, run:
    query {
      groups(excludeIds: ["gid://gitlab/Group/123"]) {
        nodes { id name }
      }
    }
  2. Verify the group with ID 123 is excluded from results.

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.

Edited by Peter Hegman

Merge request reports

Loading
Loading