mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-18 05:52:51 +00:00
<!-- CURSOR_AGENT_PR_BODY_BEGIN --> # Which Problems Are Solved Login V2 `ListUsers` lookups of the form username OR email (and email OR phone) hash-join the full `users14` / `users14_humans` tables, then filter. That cost grows with instance size (~40ms at 10k users, ~650ms at 200k). Top-level login-name equals already used an indexed join, but the same login-name filter inside an `OrQuery` fell back to the slow view-based exists query. # How the Problems Are Solved **TL;DR:** equality lookups on username, email, phone, or login name become `INNER JOIN (UNION of indexed ID seeks) AS matches`. Mixed / contains / name searches keep the old WHERE shape. ### Reviewer map | Read first | Why | | --- | --- | | `internal/query/user_login_equality.go` | Typed filters + extractor + UNION join | | `internal/query/user.go` (`prepareUsersQuery`, constructors) | Wiring; username/email/phone equals return `lowerEqualsFilter` | | `cmd/setup/76.go` + `cmd/setup/76/*.sql` | Concurrent indexes for existing deployments; drop invalid leftovers | | `internal/query/projection/user.go` | Same indexes in `Init()` for new databases | | `internal/query/user_login_name_exists_test.go` | SQL-shape coverage | | `internal/api/grpc/user/v2/integration_test/query_test.go` | Nested username AND/OR user results | ### Query rewrite - Extract one equals leaf, a flat `OrQuery` of those leaves, or the same nested under `AndQuery`. Remaining AND conjuncts stay as WHERE. Equality nested under `OrQuery` / `NotQuery` is left as WHERE. - Login-name equals is a UNION arm that reuses `login_names3_users` SQL, so it works inside OR as well as at the top level. - EQUALS seeks use `LOWER(col)` for the index, then recheck `col = value` so case-sensitive searches do not match extra rows. EQUALS_IGNORE_CASE is unchanged. - Empty OR arms are not skipped (that would change OR meaning); mixed empty-OR falls back to WHERE. Login UI already omits empty arms. ### Indexes - Setup 76: `CREATE INDEX CONCURRENTLY IF NOT EXISTS` on `users14_username_lower_idx` `(instance_id, LOWER(username))` and `users14_humans_phone_lower_idx` `(instance_id, LOWER(phone))`. Not wrapped in a transaction. - Before create, drop any leftover `pg_index.indisvalid = false` indexes of those names (failed/cancelled `CREATE INDEX CONCURRENTLY`). `IF NOT EXISTS` would otherwise skip rebuild. - `Init()` creates the same indexes without `CONCURRENTLY` (empty tables). Existing `users14_username` and email lower indexes are unchanged. # Additional Changes - Unit tests assert UNION SQL for username OR email, email OR phone, login name OR email, username OR username, nested `AndQuery(loginName, org)`, and nested `AndQuery(Or(...), org)`; that contains / display-name / mixed OR / nested `OrQuery(AndQuery(...), ...)` do not UNION; that email EQUALS rechecks the raw column; and that empty-OR is not extracted. - Setup 76 sqlmock coverage: missing `users14` skips work; valid/absent indexes only create; invalid leftover username index is dropped then both creates run. - `TestServer_ListUsers_UsernameAndOr` checks live results: org AND (alice OR bob) returns both users, sibling username AND is empty, nested `AndQuery(Or(...), org)` returns both. # Additional Context - Base branch: `main` - Extends the login-name equals rewrite from #12460 to username/email/phone OR trees used by Login V2 search. <!-- CURSOR_AGENT_PR_BODY_END --> <div><a href="https://cursor.com/agents/bc-1f043b1c-6168-45ca-9e9e-c18451994207?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-web-light.png"><img alt="Open in Web" width="114" height="28" src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a> <a href="https://cursor.com/background-agent?bcId=bc-1f043b1c-6168-45ca-9e9e-c18451994207&cursor_ref=pr_footer&cursor_cta=open_in_cursor"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img alt="Open in Cursor" width="131" height="28" src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a> </div> --------- Co-authored-by: Cursor Agent <[email protected]> Co-authored-by: Silvan <[email protected]>
228 lines
6.1 KiB
Go
228 lines
6.1 KiB
Go
package query
|
|
|
|
import (
|
|
"strings"
|
|
|
|
sq "github.com/Masterminds/squirrel"
|
|
)
|
|
|
|
type loginEqualitySeek struct {
|
|
sql string
|
|
args []interface{}
|
|
}
|
|
|
|
// loginNameEqualsFilter marks login-name equality for an indexed ID-seek in
|
|
// prepareUsersQuery. Unextracted markers fall back to the view-based exists query.
|
|
type loginNameEqualsFilter struct {
|
|
username string
|
|
domain string
|
|
loginName string
|
|
ignoreCase bool
|
|
}
|
|
|
|
func newLoginNameEqualsFilter(value string, ignoreCase bool) (*loginNameEqualsFilter, error) {
|
|
if ignoreCase {
|
|
value = strings.ToLower(value)
|
|
}
|
|
username := value
|
|
domainIndex := strings.LastIndex(value, "@")
|
|
var domainSuffix string
|
|
// split between the last @ (so ignore it if the login name ends with it)
|
|
if domainIndex > 0 && domainIndex != len(value)-1 {
|
|
domainSuffix = value[domainIndex+1:]
|
|
username = value[:domainIndex]
|
|
}
|
|
return &loginNameEqualsFilter{
|
|
username: username,
|
|
domain: domainSuffix,
|
|
loginName: value,
|
|
ignoreCase: ignoreCase,
|
|
}, nil
|
|
}
|
|
|
|
func (q *loginNameEqualsFilter) comparison() TextComparison {
|
|
if q.ignoreCase {
|
|
return TextEqualsIgnoreCase
|
|
}
|
|
return TextEquals
|
|
}
|
|
|
|
func (q *loginNameEqualsFilter) fallback() SearchQuery {
|
|
fallback, _ := newLoginNameExistsViewQuery(q.loginName, q.comparison())
|
|
return fallback
|
|
}
|
|
|
|
func (q *loginNameEqualsFilter) toQuery(query sq.SelectBuilder) sq.SelectBuilder {
|
|
return q.fallback().toQuery(query)
|
|
}
|
|
|
|
func (q *loginNameEqualsFilter) Col() Column {
|
|
return UserIDCol
|
|
}
|
|
|
|
func (q *loginNameEqualsFilter) comp() sq.Sqlizer {
|
|
return q.fallback().comp()
|
|
}
|
|
|
|
func (q *loginNameEqualsFilter) matchesArgs(instanceID string) []interface{} {
|
|
return []interface{}{
|
|
instanceID,
|
|
instanceID,
|
|
q.domain,
|
|
instanceID,
|
|
q.username,
|
|
q.loginName,
|
|
q.username,
|
|
q.domain,
|
|
q.loginName,
|
|
}
|
|
}
|
|
|
|
func (q *loginNameEqualsFilter) idSeek(instanceID string) loginEqualitySeek {
|
|
// userLoginNameMatchesQuery uses *_lower columns; the _case_sensitive embed uses raw columns.
|
|
subQuery := userLoginNameMatchesQuery
|
|
if !q.ignoreCase {
|
|
subQuery = userLoginNameMatchesCaseSensitiveQuery
|
|
}
|
|
return loginEqualitySeek{
|
|
sql: "SELECT user_id AS id FROM (" + subQuery + ") AS login_name_matches",
|
|
args: q.matchesArgs(instanceID),
|
|
}
|
|
}
|
|
|
|
// lowerEqualsFilter is username, email, or phone EQUALS / EQUALS_IGNORE_CASE.
|
|
// Unextracted filters compile to the same WHERE clause as textQuery.
|
|
type lowerEqualsFilter struct {
|
|
textQuery
|
|
tbl table
|
|
idCol Column
|
|
}
|
|
|
|
func newLowerEqualsSearchQuery(valueCol Column, tbl table, idCol Column, value string, comparison TextComparison) (SearchQuery, error) {
|
|
if comparison != TextEquals && comparison != TextEqualsIgnoreCase {
|
|
return NewTextQuery(valueCol, value, comparison)
|
|
}
|
|
tq, err := NewTextQuery(valueCol, value, comparison)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &lowerEqualsFilter{textQuery: *tq, tbl: tbl, idCol: idCol}, nil
|
|
}
|
|
|
|
func (q *lowerEqualsFilter) idSeek(instanceID string) loginEqualitySeek {
|
|
sql := "SELECT " + q.idCol.identifier() + " AS id FROM " + q.tbl.identifier() +
|
|
" WHERE " + q.tbl.InstanceIDIdentifier() + " = ? AND LOWER(" + q.Column.identifier() + ") = "
|
|
args := []interface{}{instanceID}
|
|
if q.Compare == TextEquals {
|
|
sql += "LOWER(?) AND " + q.Column.identifier() + " = ?"
|
|
args = append(args, q.Text, q.Text)
|
|
} else {
|
|
sql += "?"
|
|
args = append(args, strings.ToLower(q.Text))
|
|
}
|
|
return loginEqualitySeek{sql: sql, args: args}
|
|
}
|
|
|
|
func loginEqualitySeekFromLeaf(instanceID string, qry SearchQuery) (loginEqualitySeek, bool) {
|
|
switch v := qry.(type) {
|
|
case *loginNameEqualsFilter:
|
|
if v.loginName == "" {
|
|
return loginEqualitySeek{}, false
|
|
}
|
|
return v.idSeek(instanceID), true
|
|
case *lowerEqualsFilter:
|
|
if v.Text == "" {
|
|
return loginEqualitySeek{}, false
|
|
}
|
|
return v.idSeek(instanceID), true
|
|
default:
|
|
return loginEqualitySeek{}, false
|
|
}
|
|
}
|
|
|
|
func loginEqualitySeeksFromQuery(instanceID string, qry SearchQuery) ([]loginEqualitySeek, bool) {
|
|
if seek, ok := loginEqualitySeekFromLeaf(instanceID, qry); ok {
|
|
return []loginEqualitySeek{seek}, true
|
|
}
|
|
or, ok := qry.(*OrQuery)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
seeks := make([]loginEqualitySeek, 0, len(or.queries))
|
|
for _, inner := range or.queries {
|
|
seek, ok := loginEqualitySeekFromLeaf(instanceID, inner)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
seeks = append(seeks, seek)
|
|
}
|
|
if len(seeks) == 0 {
|
|
return nil, false
|
|
}
|
|
return seeks, true
|
|
}
|
|
|
|
// extractLoginEqualitySeeks pulls one login-equality filter from queries.
|
|
// A match may be a top-level equals leaf, a flat OrQuery of those leaves, or
|
|
// the same nested under AndQuery. Remaining AND conjuncts stay as WHERE.
|
|
// Equality nested under OrQuery or NotQuery is not extracted.
|
|
func extractLoginEqualitySeeks(instanceID string, queries []SearchQuery) ([]loginEqualitySeek, []SearchQuery, bool) {
|
|
for i, qry := range queries {
|
|
if and, ok := qry.(*AndQuery); ok {
|
|
seeks, andRest, ok := extractLoginEqualitySeeks(instanceID, and.queries)
|
|
if !ok {
|
|
continue
|
|
}
|
|
return seeks, spliceRemainingQuery(queries, i, remainingAndQuery(andRest)), true
|
|
}
|
|
seeks, ok := loginEqualitySeeksFromQuery(instanceID, qry)
|
|
if !ok {
|
|
continue
|
|
}
|
|
return seeks, spliceRemainingQuery(queries, i, nil), true
|
|
}
|
|
return nil, queries, false
|
|
}
|
|
|
|
func remainingAndQuery(rest []SearchQuery) SearchQuery {
|
|
switch len(rest) {
|
|
case 0:
|
|
return nil
|
|
case 1:
|
|
return rest[0]
|
|
default:
|
|
and, err := NewAndQuery(rest...)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return and
|
|
}
|
|
}
|
|
|
|
func spliceRemainingQuery(queries []SearchQuery, i int, replacement SearchQuery) []SearchQuery {
|
|
n := len(queries) - 1
|
|
if replacement != nil {
|
|
n++
|
|
}
|
|
remaining := make([]SearchQuery, 0, n)
|
|
remaining = append(remaining, queries[:i]...)
|
|
if replacement != nil {
|
|
remaining = append(remaining, replacement)
|
|
}
|
|
remaining = append(remaining, queries[i+1:]...)
|
|
return remaining
|
|
}
|
|
|
|
func joinLoginEqualitySeeks(seeks []loginEqualitySeek) sq.Sqlizer {
|
|
parts := make([]string, len(seeks))
|
|
args := make([]interface{}, 0, len(seeks)*2)
|
|
for i, seek := range seeks {
|
|
parts[i] = seek.sql
|
|
args = append(args, seek.args...)
|
|
}
|
|
return sq.Expr(
|
|
"INNER JOIN ("+strings.Join(parts, " UNION ")+") AS matches ON "+UserIDCol.identifier()+" = matches.id",
|
|
args...,
|
|
)
|
|
}
|