From 7015d347e1652a2f2553248b238418925596f820 Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Thu, 14 Dec 2023 13:17:35 +0200 Subject: [PATCH] fix(query): user performance (#7081) * fix(query): user performance (#6537) * start user by id * ignore debug bin * use new user by id * new sql * fix(sql): replace STRING with text for psql compatabilit * some changes * fix: correct user queries * fix tests * unify sql statements * use specific get user methods * search login name case insensitive * refactor: optimise user statements * add index * fix queries * fix: correct domain segregation * return all login names * fix queries * improve readability * query should be correct now * cleanup statements * fix username / loginname handling * fix: psql doesn't support create view if not exists * fix: create pre-release * ignore release comments * add lower fields * fix: always to lower * update to latest projection --------- Co-authored-by: Livio Spring (cherry picked from commit ddbea119f1c49845a3e364fa6dc565ab3180989a) * update projection versions * trigger workflow --------- Co-authored-by: Silvan --- cmd/setup/18.go | 26 + cmd/setup/18.sql | 6 + cmd/setup/config.go | 1 + cmd/setup/setup.go | 5 + internal/api/grpc/management/user.go | 14 +- internal/api/grpc/session/v2/session.go | 10 +- internal/api/grpc/session/v2/session_test.go | 8 +- internal/api/oidc/client.go | 6 +- internal/api/saml/storage.go | 6 +- .../api/ui/login/init_password_handler.go | 8 +- .../api/ui/login/password_reset_handler.go | 8 +- .../repository/eventsourcing/view/user.go | 23 +- .../handlers/mock/queries.mock.go | 13 +- internal/notification/handlers/queries.go | 2 +- internal/query/projection/login_name.go | 33 +- internal/query/user.go | 464 ++++++++++-------- internal/query/user_by_id.sql | 81 +++ internal/query/user_by_login_name.sql | 115 +++++ internal/query/user_notify_by_id.sql | 79 +++ internal/query/user_notify_by_login_name.sql | 112 +++++ 20 files changed, 753 insertions(+), 267 deletions(-) create mode 100644 cmd/setup/18.go create mode 100644 cmd/setup/18.sql create mode 100644 internal/query/user_by_id.sql create mode 100644 internal/query/user_by_login_name.sql create mode 100644 internal/query/user_notify_by_id.sql create mode 100644 internal/query/user_notify_by_login_name.sql diff --git a/cmd/setup/18.go b/cmd/setup/18.go new file mode 100644 index 0000000000..99837d81e0 --- /dev/null +++ b/cmd/setup/18.go @@ -0,0 +1,26 @@ +package setup + +import ( + "context" + _ "embed" + + "github.com/zitadel/zitadel/internal/database" +) + +var ( + //go:embed 18.sql + addLowerFieldsToLoginNames string +) + +type AddLowerFieldsToLoginNames struct { + dbClient *database.DB +} + +func (mig *AddLowerFieldsToLoginNames) Execute(ctx context.Context) error { + _, err := mig.dbClient.ExecContext(ctx, addLowerFieldsToLoginNames) + return err +} + +func (mig *AddLowerFieldsToLoginNames) String() string { + return "18_add_lower_fields_to_login_names" +} diff --git a/cmd/setup/18.sql b/cmd/setup/18.sql new file mode 100644 index 0000000000..675ac993e6 --- /dev/null +++ b/cmd/setup/18.sql @@ -0,0 +1,6 @@ +ALTER TABLE IF EXISTS projections.login_names3_users ADD COLUMN IF NOT EXISTS user_name_lower TEXT GENERATED ALWAYS AS (lower(user_name)) STORED; +CREATE INDEX IF NOT EXISTS login_names3_users_search ON projections.login_names3_users (instance_id, user_name_lower) INCLUDE (resource_owner); + +ALTER TABLE IF EXISTS projections.login_names3_domains ADD COLUMN IF NOT EXISTS name_lower TEXT GENERATED ALWAYS AS (lower(name)) STORED; +CREATE INDEX IF NOT EXISTS login_names3_domain_search ON projections.login_names3_domains (instance_id, resource_owner, name_lower); +CREATE INDEX IF NOT EXISTS login_names3_domain_search_result ON projections.login_names3_domains (instance_id, resource_owner) INCLUDE (is_primary); \ No newline at end of file diff --git a/cmd/setup/config.go b/cmd/setup/config.go index 668537eaf6..92ca30c3b1 100644 --- a/cmd/setup/config.go +++ b/cmd/setup/config.go @@ -75,6 +75,7 @@ type Steps struct { s15CurrentStates *CurrentProjectionState s16UniqueConstraintsLower *UniqueConstraintToLower s17AddOffsetToUniqueConstraints *AddOffsetToCurrentStates + s18AddLowerFieldsToLoginNames *AddLowerFieldsToLoginNames } type encryptionKeyConfig struct { diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index a31c5e9ae2..f21ca046a9 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -102,6 +102,7 @@ func Setup(config *Config, steps *Steps, masterKey string) { steps.s15CurrentStates = &CurrentProjectionState{dbClient: zitadelDBClient} steps.s16UniqueConstraintsLower = &UniqueConstraintToLower{dbClient: zitadelDBClient} steps.s17AddOffsetToUniqueConstraints = &AddOffsetToCurrentStates{dbClient: zitadelDBClient} + steps.s18AddLowerFieldsToLoginNames = &AddLowerFieldsToLoginNames{dbClient: zitadelDBClient} err = projection.Create(ctx, zitadelDBClient, eventstoreClient, config.Projections, nil, nil, nil) logging.OnError(err).Fatal("unable to start projections") @@ -151,6 +152,10 @@ func Setup(config *Config, steps *Steps, masterKey string) { err = migration.Migrate(ctx, eventstoreClient, repeatableStep) logging.OnError(err).Fatalf("unable to migrate repeatable step: %s", repeatableStep.String()) } + + // This step is executed after the repeatable steps because it adds fields to the login_names3 projection + err = migration.Migrate(ctx, eventstoreClient, steps.s18AddLowerFieldsToLoginNames) + logging.WithFields("name", steps.s18AddLowerFieldsToLoginNames.String()).OnError(err).Fatal("migration failed") } func readStmt(fs embed.FS, folder, typ, filename string) (string, error) { diff --git a/internal/api/grpc/management/user.go b/internal/api/grpc/management/user.go index 754b02755a..5dc33bb459 100644 --- a/internal/api/grpc/management/user.go +++ b/internal/api/grpc/management/user.go @@ -20,6 +20,7 @@ import ( "github.com/zitadel/zitadel/internal/api/ui/login" "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/errors" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/repository/user" @@ -27,13 +28,12 @@ import ( ) func (s *Server) getUserByID(ctx context.Context, id string) (*query.User, error) { - owner, err := query.NewUserResourceOwnerSearchQuery(authz.GetCtxData(ctx).OrgID, query.TextEquals) + user, err := s.query.GetUserByID(ctx, true, id) if err != nil { return nil, err } - user, err := s.query.GetUserByID(ctx, true, id, owner) - if err != nil { - return nil, err + if user.ResourceOwner != authz.GetCtxData(ctx).OrgID { + return nil, errors.ThrowNotFound(nil, "MANAG-fpo4B", "Errors.User.NotFound") } return user, nil } @@ -49,11 +49,7 @@ func (s *Server) GetUserByID(ctx context.Context, req *mgmt_pb.GetUserByIDReques } func (s *Server) GetUserByLoginNameGlobal(ctx context.Context, req *mgmt_pb.GetUserByLoginNameGlobalRequest) (*mgmt_pb.GetUserByLoginNameGlobalResponse, error) { - loginName, err := query.NewUserPreferredLoginNameSearchQuery(req.LoginName, query.TextEquals) - if err != nil { - return nil, err - } - user, err := s.query.GetUser(ctx, true, loginName) + user, err := s.query.GetUserByLoginName(ctx, true, req.LoginName) if err != nil { return nil, err } diff --git a/internal/api/grpc/session/v2/session.go b/internal/api/grpc/session/v2/session.go index ff99c1ae5f..7b1f27ef82 100644 --- a/internal/api/grpc/session/v2/session.go +++ b/internal/api/grpc/session/v2/session.go @@ -474,11 +474,7 @@ func userByID(userID string) userSearch { } func userByLoginName(loginName string) (userSearch, error) { - loginNameQuery, err := query.NewUserLoginNamesSearchQuery(loginName) - if err != nil { - return nil, err - } - return userSearchByLoginName{loginNameQuery}, nil + return userSearchByLoginName{loginName}, nil } type userSearchByID struct { @@ -490,9 +486,9 @@ func (u userSearchByID) search(ctx context.Context, q *query.Queries) (*query.Us } type userSearchByLoginName struct { - loginNameQuery query.SearchQuery + loginName string } func (u userSearchByLoginName) search(ctx context.Context, q *query.Queries) (*query.User, error) { - return q.GetUser(ctx, true, u.loginNameQuery) + return q.GetUserByLoginName(ctx, true, u.loginName) } diff --git a/internal/api/grpc/session/v2/session_test.go b/internal/api/grpc/session/v2/session_test.go index 18f2cc4e67..8ec831a504 100644 --- a/internal/api/grpc/session/v2/session_test.go +++ b/internal/api/grpc/session/v2/session_test.go @@ -586,12 +586,6 @@ func Test_sessionQueryToQuery(t *testing.T) { } } -func mustUserLoginNamesSearchQuery(t testing.TB, value string) query.SearchQuery { - loginNameQuery, err := query.NewUserLoginNamesSearchQuery("bar") - require.NoError(t, err) - return loginNameQuery -} - func Test_userCheck(t *testing.T) { type args struct { user *session.CheckUser @@ -623,7 +617,7 @@ func Test_userCheck(t *testing.T) { LoginName: "bar", }, }}, - want: userSearchByLoginName{mustUserLoginNamesSearchQuery(t, "bar")}, + want: userSearchByLoginName{"bar"}, }, { name: "unimplemented error", diff --git a/internal/api/oidc/client.go b/internal/api/oidc/client.go index 7035f3db56..891bf28f87 100644 --- a/internal/api/oidc/client.go +++ b/internal/api/oidc/client.go @@ -215,11 +215,7 @@ func (o *OPStorage) SetIntrospectionFromToken(ctx context.Context, introspection } func (o *OPStorage) ClientCredentialsTokenRequest(ctx context.Context, clientID string, scope []string) (op.TokenRequest, error) { - loginname, err := query.NewUserLoginNamesSearchQuery(clientID) - if err != nil { - return nil, err - } - user, err := o.query.GetUser(ctx, false, loginname) + user, err := o.query.GetUserByLoginName(ctx, false, clientID) if err != nil { return nil, err } diff --git a/internal/api/saml/storage.go b/internal/api/saml/storage.go index cad83d86a5..afcc06d984 100644 --- a/internal/api/saml/storage.go +++ b/internal/api/saml/storage.go @@ -159,11 +159,7 @@ func (p *Storage) SetUserinfoWithLoginName(ctx context.Context, userinfo models. ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - loginNameSQ, err := query.NewUserLoginNamesSearchQuery(loginName) - if err != nil { - return err - } - user, err := p.query.GetUser(ctx, true, loginNameSQ) + user, err := p.query.GetUserByLoginName(ctx, true, loginName) if err != nil { return err } diff --git a/internal/api/ui/login/init_password_handler.go b/internal/api/ui/login/init_password_handler.go index e6939e0c09..b0d9c686bc 100644 --- a/internal/api/ui/login/init_password_handler.go +++ b/internal/api/ui/login/init_password_handler.go @@ -7,7 +7,6 @@ import ( http_mw "github.com/zitadel/zitadel/internal/api/http/middleware" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/errors" - "github.com/zitadel/zitadel/internal/query" ) const ( @@ -91,7 +90,7 @@ func (l *Login) resendPasswordSet(w http.ResponseWriter, r *http.Request, authRe if authReq != nil { userOrg = authReq.UserOrgID } - loginName, err := query.NewUserLoginNamesSearchQuery(authReq.LoginName) + user, err := l.query.GetUserByLoginName(setContext(r.Context(), userOrg), false, authReq.LoginName) if err != nil { l.renderInitPassword(w, r, authReq, authReq.UserID, "", err) return @@ -101,11 +100,6 @@ func (l *Login) resendPasswordSet(w http.ResponseWriter, r *http.Request, authRe l.renderInitPassword(w, r, authReq, authReq.UserID, "", err) return } - user, err := l.query.GetUser(setContext(r.Context(), userOrg), false, loginName) - if err != nil { - l.renderInitPassword(w, r, authReq, authReq.UserID, "", err) - return - } _, err = l.command.RequestSetPassword(setContext(r.Context(), userOrg), user.ID, user.ResourceOwner, domain.NotificationTypeEmail, passwordCodeGenerator) l.renderInitPassword(w, r, authReq, authReq.UserID, "", err) } diff --git a/internal/api/ui/login/password_reset_handler.go b/internal/api/ui/login/password_reset_handler.go index ea8cd43321..b6e401fe63 100644 --- a/internal/api/ui/login/password_reset_handler.go +++ b/internal/api/ui/login/password_reset_handler.go @@ -5,7 +5,6 @@ import ( "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/errors" - "github.com/zitadel/zitadel/internal/query" ) const ( @@ -18,12 +17,7 @@ func (l *Login) handlePasswordReset(w http.ResponseWriter, r *http.Request) { l.renderError(w, r, authReq, err) return } - loginName, err := query.NewUserLoginNamesSearchQuery(authReq.LoginName) - if err != nil { - l.renderInitPassword(w, r, authReq, authReq.UserID, "", err) - return - } - user, err := l.query.GetUser(setContext(r.Context(), authReq.UserOrgID), true, loginName) + user, err := l.query.GetUserByLoginName(setContext(r.Context(), authReq.UserOrgID), true, authReq.LoginName) if err != nil { if authReq.LoginPolicy.IgnoreUnknownUsernames && errors.IsNotFound(err) { err = nil diff --git a/internal/auth/repository/eventsourcing/view/user.go b/internal/auth/repository/eventsourcing/view/user.go index 42643c6912..0f15991e33 100644 --- a/internal/auth/repository/eventsourcing/view/user.go +++ b/internal/auth/repository/eventsourcing/view/user.go @@ -4,6 +4,7 @@ import ( "context" "github.com/zitadel/logging" + "github.com/zitadel/zitadel/internal/errors" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/query" @@ -21,25 +22,31 @@ func (v *View) UserByID(userID, instanceID string) (*model.UserView, error) { } func (v *View) UserByLoginName(ctx context.Context, loginName, instanceID string) (*model.UserView, error) { - loginNameQuery, err := query.NewUserLoginNamesSearchQuery(loginName) + queriedUser, err := v.query.GetNotifyUserByLoginName(ctx, true, loginName) if err != nil { return nil, err } - return v.userByID(ctx, instanceID, loginNameQuery) + //nolint: contextcheck // no lint was added because refactor would change too much code + return view.UserByID(v.Db, userTable, queriedUser.ID, instanceID) } func (v *View) UserByLoginNameAndResourceOwner(ctx context.Context, loginName, resourceOwner, instanceID string) (*model.UserView, error) { - loginNameQuery, err := query.NewUserLoginNamesSearchQuery(loginName) - if err != nil { - return nil, err - } - resourceOwnerQuery, err := query.NewUserResourceOwnerSearchQuery(resourceOwner, query.TextEquals) + queriedUser, err := v.query.GetNotifyUserByLoginName(ctx, true, loginName) if err != nil { return nil, err } - return v.userByID(ctx, instanceID, loginNameQuery, resourceOwnerQuery) + //nolint: contextcheck // no lint was added because refactor would change too much code + user, err := view.UserByID(v.Db, userTable, queriedUser.ID, instanceID) + if err != nil { + return nil, err + } + if user.ResourceOwner != resourceOwner { + return nil, errors.ThrowNotFound(nil, "VIEW-qScmi", "Errors.User.NotFound") + } + + return user, nil } func (v *View) UserByEmail(ctx context.Context, email, instanceID string) (*model.UserView, error) { diff --git a/internal/notification/handlers/mock/queries.mock.go b/internal/notification/handlers/mock/queries.mock.go index 4d4c5516ac..7c9186760b 100644 --- a/internal/notification/handlers/mock/queries.mock.go +++ b/internal/notification/handlers/mock/queries.mock.go @@ -86,23 +86,18 @@ func (mr *MockQueriesMockRecorder) GetDefaultLanguage(arg0 any) *gomock.Call { } // GetNotifyUserByID mocks base method. -func (m *MockQueries) GetNotifyUserByID(arg0 context.Context, arg1 bool, arg2 string, arg3 ...query.SearchQuery) (*query.NotifyUser, error) { +func (m *MockQueries) GetNotifyUserByID(arg0 context.Context, arg1 bool, arg2 string) (*query.NotifyUser, error) { m.ctrl.T.Helper() - varargs := []any{arg0, arg1, arg2} - for _, a := range arg3 { - varargs = append(varargs, a) - } - ret := m.ctrl.Call(m, "GetNotifyUserByID", varargs...) + ret := m.ctrl.Call(m, "GetNotifyUserByID", arg0, arg1, arg2) ret0, _ := ret[0].(*query.NotifyUser) ret1, _ := ret[1].(error) return ret0, ret1 } // GetNotifyUserByID indicates an expected call of GetNotifyUserByID. -func (mr *MockQueriesMockRecorder) GetNotifyUserByID(arg0, arg1, arg2 any, arg3 ...any) *gomock.Call { +func (mr *MockQueriesMockRecorder) GetNotifyUserByID(arg0, arg1, arg2 any) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]any{arg0, arg1, arg2}, arg3...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNotifyUserByID", reflect.TypeOf((*MockQueries)(nil).GetNotifyUserByID), varargs...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNotifyUserByID", reflect.TypeOf((*MockQueries)(nil).GetNotifyUserByID), arg0, arg1, arg2) } // MailTemplateByOrg mocks base method. diff --git a/internal/notification/handlers/queries.go b/internal/notification/handlers/queries.go index cd50a26b41..8a852eb7b1 100644 --- a/internal/notification/handlers/queries.go +++ b/internal/notification/handlers/queries.go @@ -15,7 +15,7 @@ import ( type Queries interface { ActiveLabelPolicyByOrg(ctx context.Context, orgID string, withOwnerRemoved bool) (*query.LabelPolicy, error) MailTemplateByOrg(ctx context.Context, orgID string, withOwnerRemoved bool) (*query.MailTemplate, error) - GetNotifyUserByID(ctx context.Context, shouldTriggered bool, userID string, queries ...query.SearchQuery) (*query.NotifyUser, error) + GetNotifyUserByID(ctx context.Context, shouldTriggered bool, userID string) (*query.NotifyUser, error) CustomTextListByTemplate(ctx context.Context, aggregateID, template string, withOwnerRemoved bool) (*query.CustomTexts, error) SearchInstanceDomains(ctx context.Context, queries *query.InstanceDomainSearchQueries) (*query.InstanceDomains, error) SessionByID(ctx context.Context, shouldTriggerBulk bool, id, sessionToken string) (*query.Session, error) diff --git a/internal/query/projection/login_name.go b/internal/query/projection/login_name.go index 3eea01afd6..44058e5871 100644 --- a/internal/query/projection/login_name.go +++ b/internal/query/projection/login_name.go @@ -17,6 +17,7 @@ import ( ) const ( + // if the table name of the users or domains table is changed please update setup step 18 LoginNameTableAlias = "login_names3" LoginNameProjectionTable = "projections." + LoginNameTableAlias LoginNameUserProjectionTable = LoginNameProjectionTable + "_" + loginNameUserSuffix @@ -35,14 +36,17 @@ const ( domainsAlias = "domains" domainAlias = "domain" - loginNameUserSuffix = "users" - LoginNameUserIDCol = "id" - LoginNameUserUserNameCol = "user_name" + loginNameUserSuffix = "users" + LoginNameUserIDCol = "id" + LoginNameUserUserNameCol = "user_name" + // internal fields for faster search + loginNameUserUserNameLowerCol = "user_name_lower" LoginNameUserResourceOwnerCol = "resource_owner" LoginNameUserInstanceIDCol = "instance_id" loginNameDomainSuffix = "domains" LoginNameDomainNameCol = "name" + loginNameDomainNameLowerCol = "name_lower" LoginNameDomainIsPrimaryCol = "is_primary" LoginNameDomainResourceOwnerCol = "resource_owner" LoginNameDomainInstanceIDCol = "instance_id" @@ -171,12 +175,16 @@ 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(LoginNameUserResourceOwnerCol, handler.ColumnTypeText), handler.NewColumn(LoginNameUserInstanceIDCol, handler.ColumnTypeText), }, handler.NewPrimaryKey(LoginNameUserInstanceIDCol, LoginNameUserIDCol), loginNameUserSuffix, - handler.WithIndex(handler.NewIndex("resource_owner", []string{LoginNameUserResourceOwnerCol})), + handler.WithIndex(handler.NewIndex("instance_user_name", []string{LoginNameUserInstanceIDCol, LoginNameUserUserNameCol}, + handler.WithInclude(LoginNameUserResourceOwnerCol), + )), handler.WithIndex( handler.NewIndex("lnu_instance_ro_id", []string{LoginNameUserInstanceIDCol, LoginNameUserResourceOwnerCol, LoginNameUserIDCol}, handler.WithInclude( @@ -184,16 +192,33 @@ 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.NewSuffixedTable( []*handler.InitColumn{ handler.NewColumn(LoginNameDomainNameCol, handler.ColumnTypeText), + // TODO: implement computed columns + // handler.NewComputedColumn(loginNameDomainNameLowerCol, handler.ColumnTypeText), 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.NewSuffixedTable( []*handler.InitColumn{ diff --git a/internal/query/user.go b/internal/query/user.go index 144efec1dc..40546effe5 100644 --- a/internal/query/user.go +++ b/internal/query/user.go @@ -3,6 +3,7 @@ package query import ( "context" "database/sql" + _ "embed" errs "errors" "strings" "time" @@ -313,7 +314,10 @@ var ( } ) -func (q *Queries) GetUserByID(ctx context.Context, shouldTriggerBulk bool, userID string, queries ...SearchQuery) (user *User, err error) { +//go:embed user_by_id.sql +var userByIDQuery string + +func (q *Queries) GetUserByID(ctx context.Context, shouldTriggerBulk bool, userID string) (user *User, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -321,26 +325,55 @@ func (q *Queries) GetUserByID(ctx context.Context, shouldTriggerBulk bool, userI triggerUserProjections(ctx) } - query, scan := prepareUserQuery(ctx, q.client) - for _, q := range queries { - query = q.toQuery(query) - } - eq := sq.Eq{ - UserIDCol.identifier(): userID, - UserInstanceIDCol.identifier(): authz.GetInstance(ctx).InstanceID(), - } - stmt, args, err := query.Where(eq).ToSql() - if err != nil { - return nil, errors.ThrowInternal(err, "QUERY-FBg21", "Errors.Query.SQLStatment") - } - - err = q.client.QueryRowContext(ctx, func(row *sql.Row) error { - user, err = scan(row) - return err - }, stmt, args...) + err = q.client.QueryRowContext(ctx, + func(row *sql.Row) error { + user, err = scanUser(row) + return err + }, + userByIDQuery, + userID, + authz.GetInstance(ctx).InstanceID(), + ) return user, err } +//go:embed user_by_login_name.sql +var userByLoginNameQuery string + +func (q *Queries) GetUserByLoginName(ctx context.Context, shouldTriggered bool, loginName string) (user *User, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + if shouldTriggered { + triggerUserProjections(ctx) + } + + loginName = strings.ToLower(loginName) + + username := loginName + domainIndex := strings.LastIndex(loginName, "@") + var domainSuffix string + // split between the last @ (so ignore it if the login name ends with it) + if domainIndex > 0 && domainIndex != len(loginName)-1 { + domainSuffix = loginName[domainIndex+1:] + username = loginName[:domainIndex] + } + + err = q.client.QueryRowContext(ctx, + func(row *sql.Row) error { + user, err = scanUser(row) + return err + }, + userByLoginNameQuery, + username, + domainSuffix, + loginName, + authz.GetInstance(ctx).InstanceID(), + ) + return user, err +} + +// Deprecated: use either GetUserByID or GetUserByLoginName func (q *Queries) GetUser(ctx context.Context, shouldTriggerBulk bool, queries ...SearchQuery) (user *User, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -440,7 +473,10 @@ func (q *Queries) GetHumanPhone(ctx context.Context, userID string, queries ...S return phone, err } -func (q *Queries) GetNotifyUserByID(ctx context.Context, shouldTriggered bool, userID string, queries ...SearchQuery) (user *NotifyUser, err error) { +//go:embed user_notify_by_id.sql +var notifyUserByIDQuery string + +func (q *Queries) GetNotifyUserByID(ctx context.Context, shouldTriggered bool, userID string) (user *NotifyUser, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -448,23 +484,51 @@ func (q *Queries) GetNotifyUserByID(ctx context.Context, shouldTriggered bool, u triggerUserProjections(ctx) } - query, scan := prepareNotifyUserQuery(ctx, q.client) - for _, q := range queries { - query = q.toQuery(query) - } - eq := sq.Eq{ - UserIDCol.identifier(): userID, - UserInstanceIDCol.identifier(): authz.GetInstance(ctx).InstanceID(), - } - stmt, args, err := query.Where(eq).ToSql() - if err != nil { - return nil, errors.ThrowInternal(err, "QUERY-Err3g", "Errors.Query.SQLStatment") + err = q.client.QueryRowContext(ctx, + func(row *sql.Row) error { + user, err = scanNotifyUser(row) + return err + }, + notifyUserByIDQuery, + userID, + authz.GetInstance(ctx).InstanceID(), + ) + return user, err +} + +//go:embed user_notify_by_login_name.sql +var notifyUserByLoginNameQuery string + +func (q *Queries) GetNotifyUserByLoginName(ctx context.Context, shouldTriggered bool, loginName string) (user *NotifyUser, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + if shouldTriggered { + triggerUserProjections(ctx) } - err = q.client.QueryRowContext(ctx, func(row *sql.Row) error { - user, err = scan(row) - return err - }, stmt, args...) + loginName = strings.ToLower(loginName) + + username := loginName + domainIndex := strings.LastIndex(loginName, "@") + var domainSuffix string + // split between the last @ (so ignore it if the login name ends with it) + if domainIndex > 0 && domainIndex != len(loginName)-1 { + domainSuffix = loginName[domainIndex+1:] + username = loginName[:domainIndex] + } + + err = q.client.QueryRowContext(ctx, + func(row *sql.Row) error { + user, err = scanNotifyUser(row) + return err + }, + notifyUserByLoginNameQuery, + username, + domainSuffix, + loginName, + authz.GetInstance(ctx).InstanceID(), + ) return user, err } @@ -705,6 +769,95 @@ func preparePreferredLoginNamesQuery() (string, []interface{}, error) { ).ToSql() } +func scanUser(row *sql.Row) (*User, error) { + u := new(User) + var count int + preferredLoginName := sql.NullString{} + + humanID := sql.NullString{} + firstName := sql.NullString{} + lastName := sql.NullString{} + nickName := sql.NullString{} + displayName := sql.NullString{} + preferredLanguage := sql.NullString{} + gender := sql.NullInt32{} + avatarKey := sql.NullString{} + email := sql.NullString{} + isEmailVerified := sql.NullBool{} + phone := sql.NullString{} + isPhoneVerified := sql.NullBool{} + + machineID := sql.NullString{} + name := sql.NullString{} + description := sql.NullString{} + hasSecret := sql.NullBool{} + accessTokenType := sql.NullInt32{} + + err := row.Scan( + &u.ID, + &u.CreationDate, + &u.ChangeDate, + &u.ResourceOwner, + &u.Sequence, + &u.State, + &u.Type, + &u.Username, + &u.LoginNames, + &preferredLoginName, + &humanID, + &firstName, + &lastName, + &nickName, + &displayName, + &preferredLanguage, + &gender, + &avatarKey, + &email, + &isEmailVerified, + &phone, + &isPhoneVerified, + &machineID, + &name, + &description, + &hasSecret, + &accessTokenType, + &count, + ) + + if err != nil || count != 1 { + if errs.Is(err, sql.ErrNoRows) || count != 1 { + return nil, errors.ThrowNotFound(err, "QUERY-Dfbg2", "Errors.User.NotFound") + } + return nil, errors.ThrowInternal(err, "QUERY-Bgah2", "Errors.Internal") + } + + u.PreferredLoginName = preferredLoginName.String + + if humanID.Valid { + u.Human = &Human{ + FirstName: firstName.String, + LastName: lastName.String, + NickName: nickName.String, + DisplayName: displayName.String, + AvatarKey: avatarKey.String, + PreferredLanguage: language.Make(preferredLanguage.String), + Gender: domain.Gender(gender.Int32), + Email: domain.EmailAddress(email.String), + IsEmailVerified: isEmailVerified.Bool, + Phone: domain.PhoneNumber(phone.String), + IsPhoneVerified: isPhoneVerified.Bool, + } + } else if machineID.Valid { + u.Machine = &Machine{ + Name: name.String, + Description: description.String, + HasSecret: hasSecret.Bool, + AccessTokenType: domain.OIDCTokenType(accessTokenType.Int32), + } + } + return u, nil +} + func prepareUserQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, func(*sql.Row) (*User, error)) { loginNamesQuery, loginNamesArgs, err := prepareLoginNamesQuery() if err != nil { @@ -756,94 +909,7 @@ func prepareUserQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuilder userPreferredLoginNameInstanceIDCol.identifier()+" = "+UserInstanceIDCol.identifier()+db.Timetravel(call.Took(ctx)), preferredLoginNameArgs...). PlaceholderFormat(sq.Dollar), - func(row *sql.Row) (*User, error) { - u := new(User) - var count int - preferredLoginName := sql.NullString{} - - humanID := sql.NullString{} - firstName := sql.NullString{} - lastName := sql.NullString{} - nickName := sql.NullString{} - displayName := sql.NullString{} - preferredLanguage := sql.NullString{} - gender := sql.NullInt32{} - avatarKey := sql.NullString{} - email := sql.NullString{} - isEmailVerified := sql.NullBool{} - phone := sql.NullString{} - isPhoneVerified := sql.NullBool{} - - machineID := sql.NullString{} - name := sql.NullString{} - description := sql.NullString{} - hasSecret := sql.NullBool{} - accessTokenType := sql.NullInt32{} - - err := row.Scan( - &u.ID, - &u.CreationDate, - &u.ChangeDate, - &u.ResourceOwner, - &u.Sequence, - &u.State, - &u.Type, - &u.Username, - &u.LoginNames, - &preferredLoginName, - &humanID, - &firstName, - &lastName, - &nickName, - &displayName, - &preferredLanguage, - &gender, - &avatarKey, - &email, - &isEmailVerified, - &phone, - &isPhoneVerified, - &machineID, - &name, - &description, - &hasSecret, - &accessTokenType, - &count, - ) - - if err != nil || count != 1 { - if errs.Is(err, sql.ErrNoRows) || count != 1 { - return nil, errors.ThrowNotFound(err, "QUERY-Dfbg2", "Errors.User.NotFound") - } - return nil, errors.ThrowInternal(err, "QUERY-Bgah2", "Errors.Internal") - } - - u.PreferredLoginName = preferredLoginName.String - - if humanID.Valid { - u.Human = &Human{ - FirstName: firstName.String, - LastName: lastName.String, - NickName: nickName.String, - DisplayName: displayName.String, - AvatarKey: avatarKey.String, - PreferredLanguage: language.Make(preferredLanguage.String), - Gender: domain.Gender(gender.Int32), - Email: domain.EmailAddress(email.String), - IsEmailVerified: isEmailVerified.Bool, - Phone: domain.PhoneNumber(phone.String), - IsPhoneVerified: isPhoneVerified.Bool, - } - } else if machineID.Valid { - u.Machine = &Machine{ - Name: name.String, - Description: description.String, - HasSecret: hasSecret.Bool, - AccessTokenType: domain.OIDCTokenType(accessTokenType.Int32), - } - } - return u, nil - } + scanUser } func prepareProfileQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, func(*sql.Row) (*Profile, error)) { @@ -1054,88 +1120,90 @@ func prepareNotifyUserQuery(ctx context.Context, db prepareDatabase) (sq.SelectB userPreferredLoginNameInstanceIDCol.identifier()+" = "+UserInstanceIDCol.identifier()+db.Timetravel(call.Took(ctx)), preferredLoginNameArgs...). PlaceholderFormat(sq.Dollar), - func(row *sql.Row) (*NotifyUser, error) { - u := new(NotifyUser) - var count int - loginNames := database.TextArray[string]{} - preferredLoginName := sql.NullString{} + scanNotifyUser +} - humanID := sql.NullString{} - firstName := sql.NullString{} - lastName := sql.NullString{} - nickName := sql.NullString{} - displayName := sql.NullString{} - preferredLanguage := sql.NullString{} - gender := sql.NullInt32{} - avatarKey := sql.NullString{} +func scanNotifyUser(row *sql.Row) (*NotifyUser, error) { + u := new(NotifyUser) + var count int + loginNames := database.TextArray[string]{} + preferredLoginName := sql.NullString{} - notifyUserID := sql.NullString{} - notifyEmail := sql.NullString{} - notifyVerifiedEmail := sql.NullString{} - notifyPhone := sql.NullString{} - notifyVerifiedPhone := sql.NullString{} - notifyPasswordSet := sql.NullBool{} + humanID := sql.NullString{} + firstName := sql.NullString{} + lastName := sql.NullString{} + nickName := sql.NullString{} + displayName := sql.NullString{} + preferredLanguage := sql.NullString{} + gender := sql.NullInt32{} + avatarKey := sql.NullString{} - err := row.Scan( - &u.ID, - &u.CreationDate, - &u.ChangeDate, - &u.ResourceOwner, - &u.Sequence, - &u.State, - &u.Type, - &u.Username, - &loginNames, - &preferredLoginName, - &humanID, - &firstName, - &lastName, - &nickName, - &displayName, - &preferredLanguage, - &gender, - &avatarKey, - ¬ifyUserID, - ¬ifyEmail, - ¬ifyVerifiedEmail, - ¬ifyPhone, - ¬ifyVerifiedPhone, - ¬ifyPasswordSet, - &count, - ) + notifyUserID := sql.NullString{} + notifyEmail := sql.NullString{} + notifyVerifiedEmail := sql.NullString{} + notifyPhone := sql.NullString{} + notifyVerifiedPhone := sql.NullString{} + notifyPasswordSet := sql.NullBool{} - if err != nil || count != 1 { - if errs.Is(err, sql.ErrNoRows) || count != 1 { - return nil, errors.ThrowNotFound(err, "QUERY-Dgqd2", "Errors.User.NotFound") - } - return nil, errors.ThrowInternal(err, "QUERY-Dbwsg", "Errors.Internal") - } + err := row.Scan( + &u.ID, + &u.CreationDate, + &u.ChangeDate, + &u.ResourceOwner, + &u.Sequence, + &u.State, + &u.Type, + &u.Username, + &loginNames, + &preferredLoginName, + &humanID, + &firstName, + &lastName, + &nickName, + &displayName, + &preferredLanguage, + &gender, + &avatarKey, + ¬ifyUserID, + ¬ifyEmail, + ¬ifyVerifiedEmail, + ¬ifyPhone, + ¬ifyVerifiedPhone, + ¬ifyPasswordSet, + &count, + ) - if !notifyUserID.Valid { - return nil, errors.ThrowPreconditionFailed(nil, "QUERY-Sfw3f", "Errors.User.NotFound") - } - - u.LoginNames = loginNames - if preferredLoginName.Valid { - u.PreferredLoginName = preferredLoginName.String - } - if humanID.Valid { - u.FirstName = firstName.String - u.LastName = lastName.String - u.NickName = nickName.String - u.DisplayName = displayName.String - u.AvatarKey = avatarKey.String - u.PreferredLanguage = language.Make(preferredLanguage.String) - u.Gender = domain.Gender(gender.Int32) - } - u.LastEmail = notifyEmail.String - u.VerifiedEmail = notifyVerifiedEmail.String - u.LastPhone = notifyPhone.String - u.VerifiedPhone = notifyVerifiedPhone.String - u.PasswordSet = notifyPasswordSet.Bool - - return u, nil + if err != nil || count != 1 { + if errs.Is(err, sql.ErrNoRows) || count != 1 { + return nil, errors.ThrowNotFound(err, "QUERY-Dgqd2", "Errors.User.NotFound") } + return nil, errors.ThrowInternal(err, "QUERY-Dbwsg", "Errors.Internal") + } + + if !notifyUserID.Valid { + return nil, errors.ThrowPreconditionFailed(nil, "QUERY-Sfw3f", "Errors.User.NotFound") + } + + u.LoginNames = loginNames + if preferredLoginName.Valid { + u.PreferredLoginName = preferredLoginName.String + } + if humanID.Valid { + u.FirstName = firstName.String + u.LastName = lastName.String + u.NickName = nickName.String + u.DisplayName = displayName.String + u.AvatarKey = avatarKey.String + u.PreferredLanguage = language.Make(preferredLanguage.String) + u.Gender = domain.Gender(gender.Int32) + } + u.LastEmail = notifyEmail.String + u.VerifiedEmail = notifyVerifiedEmail.String + u.LastPhone = notifyPhone.String + u.VerifiedPhone = notifyVerifiedPhone.String + u.PasswordSet = notifyPasswordSet.Bool + + return u, nil } func prepareUserUniqueQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, func(*sql.Row) (bool, error)) { diff --git a/internal/query/user_by_id.sql b/internal/query/user_by_id.sql new file mode 100644 index 0000000000..5e6508026a --- /dev/null +++ b/internal/query/user_by_id.sql @@ -0,0 +1,81 @@ +WITH login_names AS (SELECT + u.id user_id + , u.instance_id + , u.resource_owner + , u.user_name + , d.name domain_name + , d.is_primary + , p.must_be_domain + , CASE WHEN p.must_be_domain + THEN concat(u.user_name, '@', d.name) + ELSE u.user_name + END login_name + FROM + projections.login_names3_users u + JOIN lateral ( + SELECT + p.must_be_domain + FROM + projections.login_names3_policies p + WHERE + u.instance_id = p.instance_id + AND ( + (p.is_default IS TRUE AND p.instance_id = $2) + OR (p.instance_id = $2 AND p.resource_owner = u.resource_owner) + ) + AND + u.id = $1 + ORDER BY is_default + LIMIT 1 + ) p ON TRUE + JOIN + projections.login_names3_domains d + ON + u.instance_id = d.instance_id + AND u.resource_owner = d.resource_owner +) +SELECT + u.id + , u.creation_date + , u.change_date + , u.resource_owner + , u.sequence + , u.state + , u.type + , u.username + , (SELECT array_agg(ln.login_name)::TEXT[] login_names FROM login_names ln GROUP BY ln.user_id, ln.instance_id) login_names + , (SELECT ln.login_name login_names_lower FROM login_names ln WHERE ln.is_primary IS TRUE) preferred_login_name + , h.user_id + , h.first_name + , h.last_name + , h.nick_name + , h.display_name + , h.preferred_language + , h.gender + , h.avatar_key + , h.email + , h.is_email_verified + , h.phone + , h.is_phone_verified + , m.user_id + , m.name + , m.description + , m.has_secret + , m.access_token_type + , count(*) OVER () +FROM projections.users9 u +LEFT JOIN + projections.users9_humans h + ON + u.id = h.user_id + AND u.instance_id = h.instance_id +LEFT JOIN + projections.users9_machines m + ON + u.id = m.user_id + AND u.instance_id = m.instance_id +WHERE + u.id = $1 + AND u.instance_id = $2 +LIMIT 1 +; \ No newline at end of file diff --git a/internal/query/user_by_login_name.sql b/internal/query/user_by_login_name.sql new file mode 100644 index 0000000000..933a5d0ae7 --- /dev/null +++ b/internal/query/user_by_login_name.sql @@ -0,0 +1,115 @@ +WITH found_users AS ( + SELECT DISTINCT + u.id + , u.instance_id + , u.resource_owner + , u.user_name + FROM + projections.login_names3_users u + JOIN lateral ( + SELECT + p.must_be_domain + FROM + projections.login_names3_policies p + WHERE + u.instance_id = p.instance_id + AND ( + (p.is_default IS TRUE AND p.instance_id = $4) + OR (p.instance_id = $4 AND p.resource_owner = u.resource_owner) + ) + AND ( + (p.must_be_domain IS TRUE AND user_name_lower = $1) + OR (p.must_be_domain IS FALSE AND user_name_lower = $3) + ) + ORDER BY is_default + LIMIT 1 + ) p ON TRUE + JOIN + projections.login_names3_domains d + ON + u.instance_id = d.instance_id + AND u.resource_owner = d.resource_owner + AND CASE WHEN p.must_be_domain THEN d.name_lower = $2 ELSE TRUE END +), +login_names AS (SELECT + fu.id user_id + , fu.instance_id + , fu.resource_owner + , fu.user_name + , d.name domain_name + , d.is_primary + , p.must_be_domain + , CASE WHEN p.must_be_domain + THEN concat(fu.user_name, '@', d.name) + ELSE fu.user_name + END login_name + FROM + found_users fu + JOIN lateral ( + SELECT + p.must_be_domain + FROM + projections.login_names3_policies p + WHERE + fu.instance_id = p.instance_id + AND ( + (p.is_default IS TRUE AND p.instance_id = $4) + OR (p.instance_id = $4 AND p.resource_owner = fu.resource_owner) + ) + ORDER BY is_default + LIMIT 1 + ) p ON TRUE + JOIN + projections.login_names3_domains d + ON + fu.instance_id = d.instance_id + AND fu.resource_owner = d.resource_owner +) +SELECT + u.id + , u.creation_date + , u.change_date + , u.resource_owner + , u.sequence + , u.state + , u.type + , u.username + , (SELECT array_agg(ln.login_name)::TEXT[] login_names FROM login_names ln WHERE fu.id = ln.user_id GROUP BY ln.user_id, ln.instance_id) login_names + , (SELECT ln.login_name login_names_lower FROM login_names ln WHERE fu.id = ln.user_id AND ln.is_primary IS TRUE) preferred_login_name + , h.user_id + , h.first_name + , h.last_name + , h.nick_name + , h.display_name + , h.preferred_language + , h.gender + , h.avatar_key + , h.email + , h.is_email_verified + , h.phone + , h.is_phone_verified + , m.user_id + , m.name + , m.description + , m.has_secret + , m.access_token_type + , count(*) OVER () +FROM found_users fu +JOIN + projections.users9 u + ON + fu.id = u.id + AND fu.instance_id = u.instance_id +LEFT JOIN + projections.users9_humans h + ON + fu.id = h.user_id + AND fu.instance_id = h.instance_id +LEFT JOIN + projections.users9_machines m + ON + fu.id = m.user_id + AND fu.instance_id = m.instance_id +WHERE + u.instance_id = $4 +; \ No newline at end of file diff --git a/internal/query/user_notify_by_id.sql b/internal/query/user_notify_by_id.sql new file mode 100644 index 0000000000..d383650c7f --- /dev/null +++ b/internal/query/user_notify_by_id.sql @@ -0,0 +1,79 @@ +WITH login_names AS ( + SELECT + u.id user_id + , u.instance_id + , u.resource_owner + , u.user_name + , d.name domain_name + , d.is_primary + , p.must_be_domain + , CASE WHEN p.must_be_domain + THEN concat(u.user_name, '@', d.name) + ELSE u.user_name + END login_name + FROM + projections.login_names3_users u + JOIN lateral ( + SELECT + p.must_be_domain + FROM + projections.login_names3_policies p + WHERE + u.instance_id = p.instance_id + AND ( + (p.is_default IS TRUE AND p.instance_id = $2) + OR (p.instance_id = $2 AND p.resource_owner = u.resource_owner) + ) + AND + u.id = $1 + ORDER BY is_default + LIMIT 1 + ) p ON TRUE + JOIN + projections.login_names3_domains d + ON + u.instance_id = d.instance_id + AND u.resource_owner = d.resource_owner +) +SELECT + u.id + , u.creation_date + , u.change_date + , u.resource_owner + , u.sequence + , u.state + , u.type + , u.username + , (SELECT array_agg(ln.login_name)::TEXT[] login_names FROM login_names ln GROUP BY ln.user_id, ln.instance_id) login_names + , (SELECT ln.login_name login_names_lower FROM login_names ln WHERE ln.is_primary IS TRUE) preferred_login_name + , h.user_id + , h.first_name + , h.last_name + , h.nick_name + , h.display_name + , h.preferred_language + , h.gender + , h.avatar_key + , n.user_id + , n.last_email + , n.verified_email + , n.last_phone + , n.verified_phone + , n.password_set + , count(*) OVER () +FROM projections.users9 u +LEFT JOIN + projections.users9_humans h + ON + u.id = h.user_id + AND u.instance_id = h.instance_id +LEFT JOIN + projections.users9_notifications n + ON + u.id = n.user_id + AND u.instance_id = n.instance_id +WHERE + u.id = $1 + AND u.instance_id = $2 +LIMIT 1 +; \ No newline at end of file diff --git a/internal/query/user_notify_by_login_name.sql b/internal/query/user_notify_by_login_name.sql new file mode 100644 index 0000000000..c375708894 --- /dev/null +++ b/internal/query/user_notify_by_login_name.sql @@ -0,0 +1,112 @@ +WITH found_users AS ( + SELECT DISTINCT + u.id + , u.instance_id + , u.resource_owner + , u.user_name + FROM + projections.login_names3_users u + JOIN lateral ( + SELECT + p.must_be_domain + FROM + projections.login_names3_policies p + WHERE + u.instance_id = p.instance_id + AND ( + (p.is_default IS TRUE AND p.instance_id = $4) + OR (p.instance_id = $4 AND p.resource_owner = u.resource_owner) + ) + AND ( + (p.must_be_domain IS TRUE AND u.user_name_lower = $1) + OR (p.must_be_domain IS FALSE AND u.user_name_lower = $3) + ) + ORDER BY is_default + LIMIT 1 + ) p ON TRUE + JOIN + projections.login_names3_domains d + ON + u.instance_id = d.instance_id + AND u.resource_owner = d.resource_owner + AND CASE WHEN p.must_be_domain THEN d.name_lower = $2 ELSE TRUE END +), +login_names AS (SELECT + fu.id user_id + , fu.instance_id + , fu.resource_owner + , fu.user_name + , d.name domain_name + , d.is_primary + , p.must_be_domain + , CASE WHEN p.must_be_domain + THEN concat(fu.user_name, '@', d.name) + ELSE fu.user_name + END login_name + FROM + found_users fu + JOIN lateral ( + SELECT + p.must_be_domain + FROM + projections.login_names3_policies p + WHERE + fu.instance_id = p.instance_id + AND ( + (p.is_default IS TRUE AND p.instance_id = $4) + OR (p.instance_id = $4 AND p.resource_owner = fu.resource_owner) + ) + ORDER BY is_default + LIMIT 1 + ) p ON TRUE + JOIN + projections.login_names3_domains d + ON + fu.instance_id = d.instance_id + AND fu.resource_owner = d.resource_owner +) +SELECT + u.id + , u.creation_date + , u.change_date + , u.resource_owner + , u.sequence + , u.state + , u.type + , u.username + , (SELECT array_agg(ln.login_name)::TEXT[] login_names FROM login_names ln WHERE fu.id = ln.user_id GROUP BY ln.user_id, ln.instance_id) login_names + , (SELECT ln.login_name login_names_lower FROM login_names ln WHERE fu.id = ln.user_id AND ln.is_primary IS TRUE) preferred_login_name + , h.user_id + , h.first_name + , h.last_name + , h.nick_name + , h.display_name + , h.preferred_language + , h.gender + , h.avatar_key + , n.user_id + , n.last_email + , n.verified_email + , n.last_phone + , n.verified_phone + , n.password_set + , count(*) OVER () +FROM found_users fu +JOIN + projections.users9 u + ON + fu.id = u.id + AND fu.instance_id = u.instance_id +LEFT JOIN + projections.users9_humans h + ON + fu.id = h.user_id + AND fu.instance_id = h.instance_id +LEFT JOIN + projections.users9_notifications n + ON + fu.id = n.user_id + AND fu.instance_id = n.instance_id +WHERE + u.instance_id = $4 +; \ No newline at end of file