fix: increase performance of ListUser by login name ignore case (#12350)

# Which Problems Are Solved

Login v2 uses the `users.v2.ListUsers`-endpoint to get a user by login
name. This query had an inefficient `WHERE`-clause.

# How the Problems Are Solved

Update the view and use a specific clause for this query.

# Additional information

Added in https://github.com/zitadel/zitadel/pull/10475

---------

Co-authored-by: Marco A. <[email protected]>
Co-authored-by: Livio Spring <[email protected]>
This commit is contained in:
Silvan
2026-06-30 11:20:21 +00:00
committed by GitHub
co-authored by Marco A. Livio Spring
parent 561da54ff7
commit f9995ee39c
11 changed files with 137 additions and 27 deletions
+5 -2
View File
@@ -587,6 +587,8 @@ export type ListUsersCommand = WithServiceConfig<{
organizationId?: string;
}>;
const userLookupQuery = { limit: 2 };
export async function listUsers({ serviceConfig, loginName, userName, phone, email, organizationId }: ListUsersCommand) {
const queries: SearchQuery[] = [];
@@ -672,7 +674,7 @@ export async function listUsers({ serviceConfig, loginName, userName, phone, ema
const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceConfig);
return userService.listUsers({ queries });
return userService.listUsers({ query: userLookupQuery, queries });
}
export type SearchUsersCommand = WithServiceConfig<{
@@ -755,7 +757,7 @@ export async function searchUsers({
const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceConfig);
const loginNameResult = await userService.listUsers({ queries });
const loginNameResult = await userService.listUsers({ query: userLookupQuery, queries });
if (!loginNameResult || !loginNameResult.details) {
return { error: t("errors.errorOccured") };
@@ -817,6 +819,7 @@ export async function searchUsers({
}
const emailOrPhoneResult = await userService.listUsers({
query: userLookupQuery,
queries: emailAndPhoneQueries,
});
+27
View File
@@ -0,0 +1,27 @@
package setup
import (
"context"
_ "embed"
"github.com/zitadel/zitadel/internal/database"
"github.com/zitadel/zitadel/internal/eventstore"
)
var (
//go:embed 72.sql
addColumnsToLoginNamesView string
)
type AddColumnsToLoginNamesView struct {
dbClient *database.DB
}
func (mig *AddColumnsToLoginNamesView) Execute(ctx context.Context, _ eventstore.Event) error {
_, err := mig.dbClient.ExecContext(ctx, addColumnsToLoginNamesView)
return err
}
func (mig *AddColumnsToLoginNamesView) String() string {
return "72_add_columns_to_login_names_view"
}
+42
View File
@@ -0,0 +1,42 @@
CREATE OR REPLACE VIEW projections.login_names3 AS (
SELECT
u.id AS user_id
, CASE
WHEN p.must_be_domain THEN CONCAT(u.user_name, '@', d.name)
ELSE u.user_name
END AS login_name
, COALESCE(d.is_primary, TRUE) AS is_primary
, u.instance_id
, u.resource_owner
, CASE
WHEN p.must_be_domain THEN CONCAT(u.user_name_lower, '@', d.name_lower)
ELSE u.user_name_lower
END AS login_name_lower
FROM
projections.login_names3_users AS u
LEFT JOIN LATERAL (
SELECT
must_be_domain
, is_default
FROM
projections.login_names3_policies AS p
WHERE
(
p.instance_id = u.instance_id
AND NOT p.is_default
AND p.resource_owner = u.resource_owner
) OR (
p.instance_id = u.instance_id
AND p.is_default
)
ORDER BY
p.is_default -- custom first
LIMIT 1
) AS p ON TRUE
LEFT JOIN
projections.login_names3_domains d
ON
p.must_be_domain
AND u.resource_owner = d.resource_owner
AND u.instance_id = d.instance_id
);
+1
View File
@@ -191,6 +191,7 @@ type Steps struct {
s69CacheTablesLogged *CacheTablesLogged
s70AddEventStoreCommandEnforceOwner *AddEventStoreCommandEnforceOwnerColumn
s71JWTProvideAddAudienceColumn *JWTProvideAddAudienceColumn
s72AddColumnsToLoginNamesView *AddColumnsToLoginNamesView
RelationalTables *TransactionalTables
}
+2
View File
@@ -254,6 +254,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string)
steps.s69CacheTablesLogged = &CacheTablesLogged{dbClient: dbClient}
steps.s70AddEventStoreCommandEnforceOwner = &AddEventStoreCommandEnforceOwnerColumn{dbClient: dbClient}
steps.s71JWTProvideAddAudienceColumn = &JWTProvideAddAudienceColumn{dbClient: dbClient}
steps.s72AddColumnsToLoginNamesView = &AddColumnsToLoginNamesView{dbClient: dbClient}
err = projection.Create(ctx, dbClient, eventstoreClient, config.Projections, nil, nil, nil)
if err != nil {
@@ -378,6 +379,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string)
steps.s66SessionRecoveryCodeCheckedAt,
steps.s68TargetAddPayloadTypeColumn,
steps.s71JWTProvideAddAudienceColumn,
steps.s72AddColumnsToLoginNamesView,
} {
setupErr = executeMigration(ctx, eventstoreClient, step, "migration failed")
if setupErr != nil {
+15
View File
@@ -70,6 +70,8 @@ type InitColumn struct {
nullable bool
defaultValue interface{}
deleteCascade string
generated string
isStored bool
}
type ColumnOption func(*InitColumn)
@@ -105,6 +107,13 @@ func DeleteCascade(column string) ColumnOption {
}
}
func Generated(stmt string, isStored bool) ColumnOption {
return func(c *InitColumn) {
c.generated = stmt
c.isStored = isStored
}
}
type PrimaryKey []string
func NewPrimaryKey(columnNames ...string) PrimaryKey {
@@ -381,6 +390,12 @@ func createColumnsStatement(cols []*InitColumn, tableName string) string {
if col.defaultValue != nil {
column += " DEFAULT " + defaultValue(col.defaultValue)
}
if col.generated != "" {
column += " GENERATED ALWAYS AS (" + col.generated + ")"
if col.isStored {
column += " STORED"
}
}
if len(col.deleteCascade) != 0 {
column += fmt.Sprintf(" REFERENCES %s (%s) ON DELETE CASCADE", tableName, col.deleteCascade)
}
+8
View File
@@ -15,6 +15,10 @@ var (
name: projection.LoginNameCol,
table: loginNameTable,
}
LoginNameNameLowerCol = Column{
name: projection.LoginNameLowerCol,
table: loginNameTable,
}
LoginNameIsPrimaryCol = Column{
name: projection.LoginNameIsPrimaryCol,
table: loginNameTable,
@@ -23,4 +27,8 @@ var (
name: projection.LoginNameInstanceIDCol,
table: loginNameTable,
}
LoginNameResourceOwnerCol = Column{
name: projection.LoginNameResourceOwnerCol,
table: loginNameTable,
}
)
+17 -19
View File
@@ -26,6 +26,8 @@ const (
LoginNameUserCol = "user_id"
LoginNameIsPrimaryCol = "is_primary"
LoginNameInstanceIDCol = "instance_id"
LoginNameLowerCol = "login_name_lower"
LoginNameResourceOwnerCol = "resource_owner"
usersAlias = "users"
policyCustomAlias = "policy_custom"
@@ -76,8 +78,7 @@ func (*loginNameProjection) Init() *old_handler.Check {
[]*handler.InitColumn{
handler.NewColumn(LoginNameUserIDCol, handler.ColumnTypeText),
handler.NewColumn(LoginNameUserUserNameCol, handler.ColumnTypeText),
// TODO: implement computed columns
// handler.NewComputedColumn(loginNameUserUserNameLowerCol, handler.ColumnTypeText),
handler.NewColumn(loginNameUserUserNameLowerCol, handler.ColumnTypeText, handler.Generated("lower("+LoginNameUserUserNameCol+")", true)),
handler.NewColumn(LoginNameUserResourceOwnerCol, handler.ColumnTypeText),
handler.NewColumn(LoginNameUserInstanceIDCol, handler.ColumnTypeText),
},
@@ -93,33 +94,30 @@ func (*loginNameProjection) Init() *old_handler.Check {
),
),
),
// TODO: uncomment the following line when login_names4 will be created
// handler.WithIndex(
// handler.NewIndex("search", []string{LoginNameUserInstanceIDCol, loginNameUserUserNameLowerCol},
// handler.WithInclude(LoginNameUserResourceOwnerCol),
// ),
// ),
handler.WithIndex(
handler.NewIndex("search", []string{LoginNameUserInstanceIDCol, loginNameUserUserNameLowerCol},
handler.WithInclude(LoginNameUserResourceOwnerCol),
),
),
),
handler.NewSuffixedTable(
[]*handler.InitColumn{
handler.NewColumn(LoginNameDomainNameCol, handler.ColumnTypeText),
// TODO: implement computed columns
// handler.NewComputedColumn(loginNameDomainNameLowerCol, handler.ColumnTypeText),
handler.NewColumn(loginNameDomainNameLowerCol, handler.ColumnTypeText, handler.Generated("lower("+LoginNameDomainNameCol+")", true)),
handler.NewColumn(LoginNameDomainIsPrimaryCol, handler.ColumnTypeBool, handler.Default(false)),
handler.NewColumn(LoginNameDomainResourceOwnerCol, handler.ColumnTypeText),
handler.NewColumn(LoginNameDomainInstanceIDCol, handler.ColumnTypeText),
},
handler.NewPrimaryKey(LoginNameDomainInstanceIDCol, LoginNameDomainResourceOwnerCol, LoginNameDomainNameCol),
loginNameDomainSuffix,
// TODO: uncomment the following line when login_names4 will be created
// handler.WithIndex(
// handler.NewIndex("search", []string{LoginNameDomainInstanceIDCol, LoginNameDomainResourceOwnerCol, loginNameDomainNameLowerCol}),
// ),
// handler.WithIndex(
// handler.NewIndex("search_result", []string{LoginNameDomainInstanceIDCol, LoginNameDomainResourceOwnerCol},
// handler.WithInclude(LoginNameDomainIsPrimaryCol),
// ),
// ),
handler.WithIndex(
handler.NewIndex("search", []string{LoginNameDomainInstanceIDCol, LoginNameDomainResourceOwnerCol, loginNameDomainNameLowerCol}),
),
handler.WithIndex(
handler.NewIndex("search_result", []string{LoginNameDomainInstanceIDCol, LoginNameDomainResourceOwnerCol},
handler.WithInclude(LoginNameDomainIsPrimaryCol),
),
),
),
handler.NewSuffixedTable(
[]*handler.InitColumn{
@@ -6,6 +6,11 @@ SELECT
END AS login_name
, COALESCE(d.is_primary, TRUE) AS is_primary
, u.instance_id
, u.resource_owner
, CASE
WHEN p.must_be_domain THEN CONCAT(u.user_name_lower, '@', d.name_lower)
ELSE u.user_name_lower
END AS login_name_lower
FROM
projections.login_names3_users AS u
LEFT JOIN LATERAL (
+1
View File
@@ -221,6 +221,7 @@ func Projections() []projection {
func Init(ctx context.Context) error {
for _, p := range projections {
if err := p.Init(ctx); err != nil {
logging.WithError(err).WithField("projection", p.ProjectionName()).Error("failed to initialize projection")
return err
}
}
+10 -2
View File
@@ -803,13 +803,21 @@ func NewUserLoginNameExistsQuery(value string, comparison TextComparison) (Searc
if err != nil {
return nil, err
}
resourceOwnerQuery, err := NewColumnComparisonQuery(LoginNameResourceOwnerCol, UserResourceOwnerCol, ColumnEquals)
if err != nil {
return nil, err
}
// text query to select data from the linked sub select
loginNameQuery, err := NewTextQuery(LoginNameNameCol, value, comparison)
var loginNameQuery SearchQuery
loginNameQuery, err = NewTextQuery(LoginNameNameCol, value, comparison)
if comparison == TextEqualsIgnoreCase {
loginNameQuery, err = NewTextQuery(LoginNameNameLowerCol, strings.ToLower(value), TextEquals)
}
if err != nil {
return nil, err
}
// full definition of the sub select
subSelect, err := NewSubSelect(LoginNameUserIDCol, []SearchQuery{instanceQuery, userIDQuery, loginNameQuery})
subSelect, err := NewSubSelect(LoginNameUserIDCol, []SearchQuery{instanceQuery, userIDQuery, resourceOwnerQuery, loginNameQuery})
if err != nil {
return nil, err
}