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. <marco@zitadel.com>
This commit is contained in:
Tim Möhlmann
2025-10-22 10:40:21 +02:00
committed by GitHub
parent d10be4c09a
commit f230cf0fb5
17 changed files with 128 additions and 32 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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
}

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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")

View File

@@ -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")

View File

@@ -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")

View File

@@ -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 {

View File

@@ -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(

View File

@@ -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")

View File

@@ -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),
),
)

View File

@@ -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()),
)