Files
coder/coderd/database/queries/groupmembers.sql
T
Wyatt Fry a005e5cd22 feat: add username and email user search filters (#27922)
## Summary

User search can now resolve exact `email:` and `username:` terms through
`GET /api/v2/users` instead of only supporting fuzzy free-text matches.
The database query already had exact email and username filters; this
wires the public search parser and API handler to those filters so
clients can ask for a single user by email without fetching every user
or depending on substring matching.

This is the API half of coder/terraform-provider-coderd#403: that
provider PR adds `data.coderd_user.email`, and this PR gives it an
efficient exact lookup path.

## Testing

- `go test ./coderd/searchquery -run '^TestSearchUsers$' -count=1`
- `go test ./coderd -run '^TestGetUsersFilter$' -count=1`
- Live API test:
  - Built local enterprise Coder from this branch.
- Started Coder on `http://127.0.0.1:39991` against a clean Postgres
database.
  - Created `lookup-target@example.com`.
- Verified `GET /api/v2/users?q=email:LOOKUP-TARGET@EXAMPLE.COM&limit=2`
returned exactly one user:

```json
{
  "count": 1,
  "users": [
    {
      "id": "efc6f909-ce0a-4731-bd2f-6e4df417aaa7",
      "username": "lookup-target",
      "email": "lookup-target@example.com"
    }
  ]
}
```

---

![flow.ai](https://img.shields.io/badge/Built_with-flow.ai-6366f1)
![Codex](https://img.shields.io/badge/GPT--5-000000)

---------

Co-authored-by: Ethan Dickson <ethanndickson@gmail.com>
2026-08-16 18:18:02 +05:00

215 lines
5.6 KiB
SQL

-- name: GetGroupMembers :many
SELECT * FROM group_members_expanded
WHERE CASE
WHEN @include_system::bool THEN TRUE
ELSE
user_is_system = false
END;
-- name: GetGroupMembersByGroupID :many
SELECT *
FROM group_members_expanded
WHERE group_id = @group_id
-- Filter by system type
AND CASE
WHEN @include_system::bool THEN TRUE
ELSE
user_is_system = false
END;
-- name: GetGroupMembersByGroupIDPaginated :many
SELECT
*, COUNT(*) OVER() AS count
FROM
group_members_expanded
WHERE
group_members_expanded.group_id = @group_id
AND CASE
-- This allows using the last element on a page as effectively a cursor.
-- This is an important option for scripts that need to paginate without
-- duplicating or missing data.
WHEN @after_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN (
-- The pagination cursor is the last ID of the previous page.
-- The query is ordered by the username field, so select all
-- rows after the cursor.
(LOWER(user_username)) > (
SELECT
LOWER(user_username)
FROM
group_members_expanded
WHERE
group_id = @group_id
AND user_id = @after_id
)
)
ELSE true
END
-- Start filters
-- Filter by email, username, or name (display name)
AND CASE
WHEN @search :: text != '' THEN (
user_email ILIKE concat('%', @search, '%')
OR user_username ILIKE concat('%', @search, '%')
OR user_name ILIKE concat('%', @search, '%')
)
ELSE true
END
-- Filter by name (display name)
AND CASE
WHEN @name :: text != '' THEN
user_name ILIKE concat('%', @name, '%')
ELSE true
END
-- Filter by exact username
AND CASE
WHEN @exact_username :: text != '' THEN
lower(user_username) = lower(@exact_username)
ELSE true
END
-- Filter by exact email
AND CASE
WHEN @exact_email :: text != '' THEN
lower(user_email) = lower(@exact_email)
ELSE true
END
-- Filter by status
AND CASE
-- @status needs to be a text because it can be empty, If it was
-- user_status enum, it would not.
WHEN cardinality(@status :: user_status[]) > 0 THEN
user_status = ANY(@status :: user_status[])
ELSE true
END
-- Filter by rbac_roles
AND CASE
-- @rbac_role allows filtering by rbac roles. If 'member' is included, show everyone, as
-- everyone is a member.
WHEN cardinality(@rbac_role :: text[]) > 0 AND 'member' != ANY(@rbac_role :: text[]) THEN
user_rbac_roles && @rbac_role :: text[]
ELSE true
END
-- Filter by last_seen
AND CASE
WHEN @last_seen_before :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
user_last_seen_at <= @last_seen_before
ELSE true
END
AND CASE
WHEN @last_seen_after :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
user_last_seen_at >= @last_seen_after
ELSE true
END
-- Filter by created_at
AND CASE
WHEN @created_before :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
user_created_at <= @created_before
ELSE true
END
AND CASE
WHEN @created_after :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
user_created_at >= @created_after
ELSE true
END
-- Filter by system type
AND CASE
WHEN @include_system::bool THEN TRUE
ELSE user_is_system = false
END
-- Filter by github.com user ID
AND CASE
WHEN @github_com_user_id :: bigint != 0 THEN
user_github_com_user_id = @github_com_user_id
ELSE true
END
-- Filter by login_type
AND CASE
WHEN cardinality(@login_type :: login_type[]) > 0 THEN
user_login_type = ANY(@login_type :: login_type[])
ELSE true
END
-- Filter by service account.
AND CASE
WHEN sqlc.narg('is_service_account') :: boolean IS NOT NULL THEN
user_is_service_account = sqlc.narg('is_service_account') :: boolean
ELSE true
END
-- End of filters
ORDER BY
-- Deterministic and consistent ordering of all users. This is to ensure consistent pagination.
LOWER(user_username) ASC OFFSET @offset_opt
LIMIT
-- A null limit means "no limit", so 0 means return all
NULLIF(@limit_opt :: int, 0);
-- name: GetGroupMembersCountByGroupID :one
-- Returns the total count of members in a group. Shows the total
-- count even if the caller does not have read access to ResourceGroupMember.
-- They only need ResourceGroup read access.
SELECT COUNT(*)
FROM group_members_expanded
WHERE group_id = @group_id
-- Filter by system type
AND CASE
WHEN @include_system::bool THEN TRUE
ELSE
user_is_system = false
END;
-- name: GetGroupMembersCountByGroupIDs :many
-- Returns the total member count for each of the given group IDs in a
-- single query. Used to avoid N+1 lookups when listing many groups. Like
-- GetGroupMembersCountByGroupID, the count is returned even when the
-- caller does not have read access to individual group members.
SELECT
group_id,
COUNT(*) AS member_count
FROM group_members_expanded
WHERE group_id = ANY(@group_ids :: uuid[])
AND CASE
WHEN @include_system::bool THEN TRUE
ELSE user_is_system = false
END
GROUP BY group_id;
-- InsertUserGroupsByID adds a user to all provided groups, if they exist.
-- name: InsertUserGroupsByID :many
WITH groups AS (
SELECT
id
FROM
groups
WHERE
groups.id = ANY(@group_ids :: uuid [])
)
INSERT INTO
group_members (user_id, group_id)
SELECT
@user_id,
groups.id
FROM
groups
-- If there is a conflict, the user is already a member
ON CONFLICT DO NOTHING
RETURNING group_id;
-- name: RemoveUserFromGroups :many
DELETE FROM
group_members
WHERE
user_id = @user_id AND
group_id = ANY(@group_ids :: uuid [])
RETURNING group_id;
-- name: InsertGroupMember :exec
INSERT INTO
group_members (user_id, group_id)
VALUES
($1, $2);
-- name: DeleteGroupMemberFromGroup :exec
DELETE FROM
group_members
WHERE
user_id = $1 AND
group_id = $2;