From f230cf0fb5d1faf60302dc6e8cbaef7ddf3e0f64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Wed, 22 Oct 2025 10:40:21 +0200 Subject: [PATCH] refactor(rt): add primary key columns to repo interfaces (#10843) # Which Problems Are Solved Relational table's primary keys are part of the SQL table definition. However, by abstraction through the repository interfaces callers (service layer) have no clear understanding of the primary key definion. This might lead to confusion during implementation: - The repository makes a sanity check on required conditions before query execution and returns an error if the condition was not met. - When a Get method is executed, an error is returned if not exactly one row is found. Get replaces the legacy GetXxxByID methods we have on projections. However, a list of flexible conditions can be passed. - Operations like UPDATE and DELETE should often only affect a single row, identified by the primary key. If the primary key conditions are missing an error is returned. Because there is no "type safety" enforced here, the errors are returned during runtime. When all possible combinations of API arguments are not properly tested such errors are actually bugs in the experience of the end-user. (The API should do it's own input validation, but a programmer's error is easily made). # How the Problems Are Solved Add `PrimaryKeyColumns` and `PrimaryKeyCondition` to existing repository intefaces. # Additional Changes - Where reducers already used repositories, the PrimaryKeyConditions are used where possible. # Additional Context - Part of https://github.com/zitadel/zitadel/wiki/Decision-Log#expose-primary-key-definition-in-repository-interfaces --------- Co-authored-by: Marco A. --- backend/v3/domain/id_provider.go | 4 +++ backend/v3/domain/instance.go | 4 +++ backend/v3/domain/instance_domain.go | 4 +++ backend/v3/domain/organization.go | 6 ++++- backend/v3/domain/organization_domain.go | 5 ++++ backend/v3/domain/project.go | 4 +-- backend/v3/domain/repository.go | 9 +++++++ backend/v3/domain/user.go | 4 +++ .../database/repository/id_provider.go | 15 +++++++++++ .../storage/database/repository/instance.go | 9 +++++++ .../database/repository/instance_domain.go | 11 ++++++++ backend/v3/storage/database/repository/org.go | 15 +++++++++++ .../storage/database/repository/org_domain.go | 17 +++++++++++++ .../storage/database/repository/repository.go | 7 ++---- .../v3/storage/database/repository/user.go | 15 +++++++++++ .../projection/instance_domain_relational.go | 6 ++--- .../query/projection/org_domain_relational.go | 25 +++---------------- 17 files changed, 128 insertions(+), 32 deletions(-) create mode 100644 backend/v3/domain/repository.go diff --git a/backend/v3/domain/id_provider.go b/backend/v3/domain/id_provider.go index 6f2bab1e3a1..dbcfbbc88cd 100644 --- a/backend/v3/domain/id_provider.go +++ b/backend/v3/domain/id_provider.go @@ -296,6 +296,8 @@ type idProviderColumns interface { } type idProviderConditions interface { + // PrimaryKeyCondition returns a filter on the primary key fields. + PrimaryKeyCondition(instanceID, idpID string) database.Condition InstanceIDCondition(id string) database.Condition OrgIDCondition(id *string) database.Condition IDCondition(id string) IDPIdentifierCondition @@ -327,6 +329,8 @@ type idProviderChanges interface { } type IDProviderRepository interface { + Repository + idProviderColumns idProviderConditions idProviderChanges diff --git a/backend/v3/domain/instance.go b/backend/v3/domain/instance.go index 446331a320e..60c6381ba92 100644 --- a/backend/v3/domain/instance.go +++ b/backend/v3/domain/instance.go @@ -65,6 +65,8 @@ type instanceColumns interface { // instanceConditions define all the conditions for the instance table. type instanceConditions interface { + // PrimaryKeyCondition returns a filter on the primary key fields. + PrimaryKeyCondition(instanceID string) database.Condition // IDCondition returns an equal filter on the id field. IDCondition(instanceID string) database.Condition // NameCondition returns a filter on the name field. @@ -93,6 +95,8 @@ type instanceChanges interface { // InstanceRepository is the interface for the instance repository. type InstanceRepository interface { + Repository + instanceColumns instanceConditions instanceChanges diff --git a/backend/v3/domain/instance_domain.go b/backend/v3/domain/instance_domain.go index 786be287ee7..5a9499f33c0 100644 --- a/backend/v3/domain/instance_domain.go +++ b/backend/v3/domain/instance_domain.go @@ -47,6 +47,8 @@ type instanceDomainColumns interface { type instanceDomainConditions interface { domainConditions + // PrimaryKeyCondition returns a filter on the primary key fields. + PrimaryKeyCondition(domain string) database.Condition // TypeCondition returns a filter for the type field. TypeCondition(typ DomainType) database.Condition } @@ -58,6 +60,8 @@ type instanceDomainChanges interface { } type InstanceDomainRepository interface { + Repository + instanceDomainColumns instanceDomainConditions instanceDomainChanges diff --git a/backend/v3/domain/organization.go b/backend/v3/domain/organization.go index cc99bebc988..d9b839c4e4b 100644 --- a/backend/v3/domain/organization.go +++ b/backend/v3/domain/organization.go @@ -51,8 +51,10 @@ type organizationColumns interface { // organizationConditions define all the conditions for the instance table. type organizationConditions interface { + // PrimaryKeyCondition returns a filter on the primary key fields. + PrimaryKeyCondition(instanceID, organizationID string) database.Condition // IDCondition returns an equal filter on the id field. - IDCondition(instanceID string) database.Condition + IDCondition(orgID string) database.Condition // NameCondition returns a filter on the name field. NameCondition(op database.TextOperation, name string) database.Condition // InstanceIDCondition returns a filter on the instance id field. @@ -77,6 +79,8 @@ type organizationChanges interface { // OrganizationRepository is the interface for the instance repository. type OrganizationRepository interface { + Repository + organizationColumns organizationConditions organizationChanges diff --git a/backend/v3/domain/organization_domain.go b/backend/v3/domain/organization_domain.go index 157e2c89070..3d61914cc34 100644 --- a/backend/v3/domain/organization_domain.go +++ b/backend/v3/domain/organization_domain.go @@ -47,6 +47,9 @@ type organizationDomainColumns interface { type organizationDomainConditions interface { domainConditions + + // PrimaryKeyCondition returns a filter on the primary key fields. + PrimaryKeyCondition(instanceID, orgID, domain string) database.Condition // OrgIDCondition returns a filter on the org id field. OrgIDCondition(orgID string) database.Condition // IsVerifiedCondition returns a filter on the is verified field. @@ -65,6 +68,8 @@ type organizationDomainChanges interface { //go:generate mockgen -typed -package domainmock -destination ./mock/org_domain.mock.go . OrganizationDomainRepository type OrganizationDomainRepository interface { + Repository + organizationDomainColumns organizationDomainConditions organizationDomainChanges diff --git a/backend/v3/domain/project.go b/backend/v3/domain/project.go index f0d9de39d8f..dad50242336 100644 --- a/backend/v3/domain/project.go +++ b/backend/v3/domain/project.go @@ -30,8 +30,6 @@ type Project struct { } type projectColumns interface { - // PrimaryKeyColumns returns the columns for the primary key fields - PrimaryKeyColumns() []database.Column // InstanceIDColumn returns the column for the instance id field InstanceIDColumn() database.Column // OrganizationIDColumn returns the column for the organization id field @@ -94,6 +92,8 @@ type projectChanges interface { // //go:generate mockgen -typed -package domainmock -destination ./mock/project.mock.go . ProjectRepository type ProjectRepository interface { + Repository + projectColumns projectConditions projectChanges diff --git a/backend/v3/domain/repository.go b/backend/v3/domain/repository.go new file mode 100644 index 00000000000..3ed7b720073 --- /dev/null +++ b/backend/v3/domain/repository.go @@ -0,0 +1,9 @@ +package domain + +import "github.com/zitadel/zitadel/backend/v3/storage/database" + +// Repository is the base interface for all repositories. +type Repository interface { + // PrimaryKeyColumns returns the columns for the primary key fields + PrimaryKeyColumns() []database.Column +} diff --git a/backend/v3/domain/user.go b/backend/v3/domain/user.go index 333b5c96fbb..cb652757757 100644 --- a/backend/v3/domain/user.go +++ b/backend/v3/domain/user.go @@ -27,6 +27,8 @@ type userColumns interface { // userConditions define all the conditions for the user table. type userConditions interface { + // PrimaryKeyCondition returns a filter on the primary key fields. + PrimaryKeyCondition(instanceID, userID string) database.Condition // InstanceIDCondition returns an equal filter on the instance id field. InstanceIDCondition(instanceID string) database.Condition // OrgIDCondition returns an equal filter on the org id field. @@ -53,6 +55,8 @@ type userChanges interface { // UserRepository is the interface for the user repository. type UserRepository interface { + Repository + userColumns userConditions userChanges diff --git a/backend/v3/storage/database/repository/id_provider.go b/backend/v3/storage/database/repository/id_provider.go index 9f92de35ec5..54decf427f2 100644 --- a/backend/v3/storage/database/repository/id_provider.go +++ b/backend/v3/storage/database/repository/id_provider.go @@ -431,6 +431,14 @@ func (i *idProvider) GetSAML(ctx context.Context, client database.QueryExecutor, // columns // ------------------------------------------------------------- +// PrimaryKeyColumns implements [domain.Repository]. +func (i idProvider) PrimaryKeyColumns() []database.Column { + return []database.Column{ + i.InstanceIDColumn(), + i.IDColumn(), + } +} + func (idProvider) InstanceIDColumn() database.Column { return database.NewColumn("identity_providers", "instance_id") } @@ -499,6 +507,13 @@ func (idProvider) UpdatedAtColumn() database.Column { // conditions // ------------------------------------------------------------- +func (i idProvider) PrimaryKeyCondition(instanceID, id string) database.Condition { + return database.And( + i.InstanceIDCondition(instanceID), + i.IDCondition(id), + ) +} + func (i idProvider) InstanceIDCondition(id string) database.Condition { return database.NewTextCondition(i.InstanceIDColumn(), database.TextOperationEqual, id) } diff --git a/backend/v3/storage/database/repository/instance.go b/backend/v3/storage/database/repository/instance.go index 2d03bdc5e0e..ff314e29e1d 100644 --- a/backend/v3/storage/database/repository/instance.go +++ b/backend/v3/storage/database/repository/instance.go @@ -166,6 +166,10 @@ func (i instance) SetConsoleAppID(id string) database.Change { // conditions // ------------------------------------------------------------- +func (i instance) PrimaryKeyCondition(instanceID string) database.Condition { + return i.IDCondition(instanceID) +} + // IDCondition implements [domain.instanceConditions]. func (i instance) IDCondition(id string) database.Condition { return database.NewTextCondition(i.IDColumn(), database.TextOperationEqual, id) @@ -204,6 +208,11 @@ func (i instance) ExistsDomain(cond database.Condition) database.Condition { // columns // ------------------------------------------------------------- +// PrimaryKeyColumns implements [domain.Repository]. +func (i instance) PrimaryKeyColumns() []database.Column { + return []database.Column{i.IDColumn()} +} + // IDColumn implements [domain.instanceColumns]. func (i instance) IDColumn() database.Column { return database.NewColumn(i.unqualifiedTableName(), "id") diff --git a/backend/v3/storage/database/repository/instance_domain.go b/backend/v3/storage/database/repository/instance_domain.go index 9cb4d9e127a..5edaf0af6e3 100644 --- a/backend/v3/storage/database/repository/instance_domain.go +++ b/backend/v3/storage/database/repository/instance_domain.go @@ -136,6 +136,10 @@ func (i instanceDomain) SetType(typ domain.DomainType) database.Change { // conditions // ------------------------------------------------------------- +func (i instanceDomain) PrimaryKeyCondition(domain string) database.Condition { + return i.DomainCondition(database.TextOperationEqual, domain) +} + // DomainCondition implements [domain.InstanceDomainRepository]. func (i instanceDomain) DomainCondition(op database.TextOperation, domain string) database.Condition { return database.NewTextCondition(i.DomainColumn(), op, domain) @@ -160,6 +164,13 @@ func (i instanceDomain) TypeCondition(typ domain.DomainType) database.Condition // columns // ------------------------------------------------------------- +// PrimaryKeyColumns implements [domain.Repository]. +func (i instanceDomain) PrimaryKeyColumns() []database.Column { + return []database.Column{ + i.DomainColumn(), + } +} + // CreatedAtColumn implements [domain.InstanceDomainRepository]. func (i instanceDomain) CreatedAtColumn() database.Column { return database.NewColumn(i.unqualifiedTableName(), "created_at") diff --git a/backend/v3/storage/database/repository/org.go b/backend/v3/storage/database/repository/org.go index 5f07a9d6b32..d06ac87d64c 100644 --- a/backend/v3/storage/database/repository/org.go +++ b/backend/v3/storage/database/repository/org.go @@ -148,6 +148,13 @@ func (o org) SetState(state domain.OrgState) database.Change { // conditions // ------------------------------------------------------------- +func (o org) PrimaryKeyCondition(instanceID, orgID string) database.Condition { + return database.And( + o.InstanceIDCondition(instanceID), + o.IDCondition(orgID), + ) +} + // IDCondition implements [domain.organizationConditions]. func (o org) IDCondition(id string) database.Condition { return database.NewTextCondition(o.IDColumn(), database.TextOperationEqual, id) @@ -221,6 +228,14 @@ func (o org) ExistsMetadata(cond database.Condition) database.Condition { // columns // ------------------------------------------------------------- +// PrimaryKeyColumns implements [domain.Repository]. +func (o org) PrimaryKeyColumns() []database.Column { + return []database.Column{ + o.InstanceIDColumn(), + o.IDColumn(), + } +} + // IDColumn implements [domain.organizationColumns]. func (o org) IDColumn() database.Column { return database.NewColumn(o.unqualifiedTableName(), "id") diff --git a/backend/v3/storage/database/repository/org_domain.go b/backend/v3/storage/database/repository/org_domain.go index 4d73647edf2..2ecaf09617e 100644 --- a/backend/v3/storage/database/repository/org_domain.go +++ b/backend/v3/storage/database/repository/org_domain.go @@ -157,6 +157,14 @@ func (o orgDomain) SetUpdatedAt(updatedAt time.Time) database.Change { // conditions // ------------------------------------------------------------- +func (o orgDomain) PrimaryKeyCondition(instanceID, orgID, domain string) database.Condition { + return database.And( + o.InstanceIDCondition(instanceID), + o.OrgIDCondition(orgID), + o.DomainCondition(database.TextOperationEqual, domain), + ) +} + // DomainCondition implements [domain.OrganizationDomainRepository]. func (o orgDomain) DomainCondition(op database.TextOperation, domain string) database.Condition { return database.NewTextCondition(o.DomainColumn(), op, domain) @@ -187,6 +195,15 @@ func (o orgDomain) OrgIDCondition(orgID string) database.Condition { // columns // ------------------------------------------------------------- +// PrimaryKeyColumns implements [domain.Repository]. +func (o orgDomain) PrimaryKeyColumns() []database.Column { + return []database.Column{ + o.InstanceIDColumn(), + o.OrgIDColumn(), + o.DomainColumn(), + } +} + // CreatedAtColumn implements [domain.OrganizationDomainRepository]. // Subtle: this method shadows the method ([domain.OrganizationRepository]).CreatedAtColumn of orgDomain.org. func (o orgDomain) CreatedAtColumn() database.Column { diff --git a/backend/v3/storage/database/repository/repository.go b/backend/v3/storage/database/repository/repository.go index 95a27c2b69e..065a9fcc8a7 100644 --- a/backend/v3/storage/database/repository/repository.go +++ b/backend/v3/storage/database/repository/repository.go @@ -3,6 +3,7 @@ package repository import ( "context" + "github.com/zitadel/zitadel/backend/v3/domain" "github.com/zitadel/zitadel/backend/v3/storage/database" ) @@ -29,14 +30,10 @@ func checkRestrictingColumns( return nil } -type pkRepository interface { - PrimaryKeyColumns() []database.Column -} - // checkPKCondition checks if the Primary Key columns are part of the condition. // This can ensure only a single row is affected by updates and deletes. func checkPKCondition( - repo pkRepository, + repo domain.Repository, condition database.Condition, ) error { return checkRestrictingColumns( diff --git a/backend/v3/storage/database/repository/user.go b/backend/v3/storage/database/repository/user.go index 8527767c443..c6e15a72c30 100644 --- a/backend/v3/storage/database/repository/user.go +++ b/backend/v3/storage/database/repository/user.go @@ -135,6 +135,13 @@ func (u user) SetUsername(username string) database.Change { // conditions // ------------------------------------------------------------- +func (u user) PrimaryKeyCondition(instanceID, userID string) database.Condition { + return database.And( + u.InstanceIDCondition(instanceID), + u.IDCondition(userID), + ) +} + // InstanceIDCondition implements [domain.userConditions]. func (u user) InstanceIDCondition(instanceID string) database.Condition { return database.NewTextCondition(u.InstanceIDColumn(), database.TextOperationEqual, instanceID) @@ -182,6 +189,14 @@ func (u user) DeletedAtCondition(op database.NumberOperation, deletedAt time.Tim // columns // ------------------------------------------------------------- +// PrimaryKeyColumns implements [domain.Repository]. +func (u user) PrimaryKeyColumns() []database.Column { + return []database.Column{ + u.InstanceIDColumn(), + u.IDColumn(), + } +} + // InstanceIDColumn implements [domain.userColumns]. func (user) InstanceIDColumn() database.Column { return database.NewColumn("users", "instance_id") diff --git a/internal/query/projection/instance_domain_relational.go b/internal/query/projection/instance_domain_relational.go index 80606fda677..1c688f6e0f0 100644 --- a/internal/query/projection/instance_domain_relational.go +++ b/internal/query/projection/instance_domain_relational.go @@ -92,8 +92,8 @@ func (p *instanceDomainRelationalProjection) reduceDomainPrimarySet(event events _, err := domainRepo.Update(ctx, v3_sql.SQLTx(tx), database.And( + domainRepo.PrimaryKeyCondition(e.Domain), domainRepo.InstanceIDCondition(e.Aggregate().InstanceID), - domainRepo.DomainCondition(database.TextOperationEqual, e.Domain), domainRepo.TypeCondition(domain.DomainTypeCustom), ), domainRepo.SetPrimary(), @@ -116,8 +116,8 @@ func (p *instanceDomainRelationalProjection) reduceCustomDomainRemoved(event eve domainRepo := repository.InstanceDomainRepository() _, err := domainRepo.Remove(ctx, v3_sql.SQLTx(tx), database.And( + domainRepo.PrimaryKeyCondition(e.Domain), domainRepo.InstanceIDCondition(e.Aggregate().InstanceID), - domainRepo.DomainCondition(database.TextOperationEqual, e.Domain), domainRepo.TypeCondition(domain.DomainTypeCustom), ), ) @@ -158,8 +158,8 @@ func (p *instanceDomainRelationalProjection) reduceTrustedDomainRemoved(event ev domainRepo := repository.InstanceDomainRepository() _, err := domainRepo.Remove(ctx, v3_sql.SQLTx(tx), database.And( + domainRepo.PrimaryKeyCondition(e.Domain), domainRepo.InstanceIDCondition(e.Aggregate().InstanceID), - domainRepo.DomainCondition(database.TextOperationEqual, e.Domain), domainRepo.TypeCondition(domain.DomainTypeTrusted), ), ) diff --git a/internal/query/projection/org_domain_relational.go b/internal/query/projection/org_domain_relational.go index 2ac3dc44931..754969f05b3 100644 --- a/internal/query/projection/org_domain_relational.go +++ b/internal/query/projection/org_domain_relational.go @@ -5,7 +5,6 @@ import ( "database/sql" "github.com/zitadel/zitadel/backend/v3/domain" - "github.com/zitadel/zitadel/backend/v3/storage/database" v3_sql "github.com/zitadel/zitadel/backend/v3/storage/database/dialect/sql" "github.com/zitadel/zitadel/backend/v3/storage/database/repository" old_domain "github.com/zitadel/zitadel/internal/domain" @@ -87,11 +86,7 @@ func (p *orgDomainRelationalProjection) reducePrimarySet(event eventstore.Event) } domainRepo := repository.OrganizationDomainRepository() _, err := domainRepo.Update(ctx, v3_sql.SQLTx(tx), - database.And( - domainRepo.InstanceIDCondition(e.Aggregate().InstanceID), - domainRepo.OrgIDCondition(e.Aggregate().ResourceOwner), - domainRepo.DomainCondition(database.TextOperationEqual, e.Domain), - ), + domainRepo.PrimaryKeyCondition(e.Aggregate().InstanceID, e.Aggregate().ResourceOwner, e.Domain), domainRepo.SetPrimary(), domainRepo.SetUpdatedAt(e.CreationDate()), ) @@ -111,11 +106,7 @@ func (p *orgDomainRelationalProjection) reduceRemoved(event eventstore.Event) (* } domainRepo := repository.OrganizationDomainRepository() _, err := domainRepo.Remove(ctx, v3_sql.SQLTx(tx), - database.And( - domainRepo.InstanceIDCondition(e.Aggregate().InstanceID), - domainRepo.OrgIDCondition(e.Aggregate().ResourceOwner), - domainRepo.DomainCondition(database.TextOperationEqual, e.Domain), - ), + domainRepo.PrimaryKeyCondition(e.Aggregate().InstanceID, e.Aggregate().ResourceOwner, e.Domain), ) return err }), nil @@ -143,11 +134,7 @@ func (p *orgDomainRelationalProjection) reduceVerificationAdded(event eventstore domainRepo := repository.OrganizationDomainRepository() _, err := domainRepo.Update(ctx, v3_sql.SQLTx(tx), - database.And( - domainRepo.InstanceIDCondition(e.Aggregate().InstanceID), - domainRepo.OrgIDCondition(e.Aggregate().ResourceOwner), - domainRepo.DomainCondition(database.TextOperationEqual, e.Domain), - ), + domainRepo.PrimaryKeyCondition(e.Aggregate().InstanceID, e.Aggregate().ResourceOwner, e.Domain), domainRepo.SetValidationType(validationType), domainRepo.SetUpdatedAt(e.CreationDate()), ) @@ -168,11 +155,7 @@ func (p *orgDomainRelationalProjection) reduceVerified(event eventstore.Event) ( domainRepo := repository.OrganizationDomainRepository() _, err := domainRepo.Update(ctx, v3_sql.SQLTx(tx), - database.And( - domainRepo.InstanceIDCondition(e.Aggregate().InstanceID), - domainRepo.OrgIDCondition(e.Aggregate().ResourceOwner), - domainRepo.DomainCondition(database.TextOperationEqual, e.Domain), - ), + domainRepo.PrimaryKeyCondition(e.Aggregate().InstanceID, e.Aggregate().ResourceOwner, e.Domain), domainRepo.SetVerified(), domainRepo.SetUpdatedAt(e.CreationDate()), )