diff --git a/.cursor/rules/go-pg-constraint-check.mdc b/.cursor/rules/go-pg-constraint-check.mdc index 5fb593f6d..43c374bfb 100644 --- a/.cursor/rules/go-pg-constraint-check.mdc +++ b/.cursor/rules/go-pg-constraint-check.mdc @@ -38,3 +38,21 @@ if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok { This applies to all PostgreSQL error codes mapped to sentinel errors: - `"23505"` (unique violation) → `ErrResourceAlreadyExists` - `"23503"` (foreign key violation) → `ErrResourceInUse` + +# Primary key constraint handling + +**Do not** add a 23505 check for a single-column GID primary key (`id TEXT PRIMARY KEY`). GIDs are generated and cannot realistically collide; such a check is dead code. + +**Do** check the composite primary key on junction tables where the PK represents a business uniqueness constraint (e.g. a link between two entities). + +```go +// GOOD — composite PK on a junction table (real business constraint) +if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok && pgErr.Code == "23505" && pgErr.ConstraintName == "risk_assessment_scenario_threats_pkey" { + return ErrResourceAlreadyExists +} + +// BAD — single GID PK (can never collide, dead code) +if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok && pgErr.Code == "23505" && pgErr.ConstraintName == "risk_assessments_pkey" { + return ErrResourceAlreadyExists +} +``` diff --git a/contrib/claude/coredata.md b/contrib/claude/coredata.md index a11976768..ade2e7d2f 100644 --- a/contrib/claude/coredata.md +++ b/contrib/claude/coredata.md @@ -249,6 +249,20 @@ if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok { The same applies to foreign key violations (`"23503"`) mapped to `ErrResourceInUse` — always verify the constraint name. +**Primary key handling:** Do not add a 23505 check for a single-column GID primary key (`id TEXT PRIMARY KEY`). GIDs are generated and cannot realistically collide — such a check is dead code. Only check the primary key constraint on **composite-PK junction tables** where the PK represents a business uniqueness constraint (e.g. linking a scenario to a threat). + +```go +// Good — composite PK on junction table (real business constraint) +if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok && pgErr.Code == "23505" && pgErr.ConstraintName == "risk_assessment_scenario_threats_pkey" { + return ErrResourceAlreadyExists +} + +// Bad — single GID PK (cannot collide, dead code) +if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok && pgErr.Code == "23505" && pgErr.ConstraintName == "risk_assessments_pkey" { + return ErrResourceAlreadyExists +} +``` + ## Filters Filters implement `SQLFragment() string` and `SQLArguments() pgx.NamedArgs`. Use double pointers for three-state filtering: `nil` = no filter, `*nil` = IS NULL, `*val` = equals. diff --git a/pkg/coredata/membership_profile.go b/pkg/coredata/membership_profile.go index 84fd86e04..0745368d5 100644 --- a/pkg/coredata/membership_profile.go +++ b/pkg/coredata/membership_profile.go @@ -1219,8 +1219,13 @@ VALUES ( _, err := conn.Exec(ctx, q, args) if err != nil { - if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok && pgErr.Code == "23505" && pgErr.ConstraintName == "idx_profiles_identity_id_organization_id" { - return ErrResourceAlreadyExists + if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok && pgErr.Code == "23505" { + switch pgErr.ConstraintName { + case "idx_profiles_identity_id_organization_id", + "idx_profiles_external_id_organization_id", + "idx_profiles_user_name_organization_id": + return ErrResourceAlreadyExists + } } return fmt.Errorf("cannot insert profile: %w", err) diff --git a/pkg/coredata/oauth2_consent.go b/pkg/coredata/oauth2_consent.go index bbd98a12c..f4b7e9061 100644 --- a/pkg/coredata/oauth2_consent.go +++ b/pkg/coredata/oauth2_consent.go @@ -22,7 +22,6 @@ import ( "time" "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgconn" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/page" @@ -367,10 +366,6 @@ INSERT INTO iam_oauth2_consents ( _, err := conn.Exec(ctx, q, args) if err != nil { - if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok && pgErr.Code == "23505" && pgErr.ConstraintName == "iam_oauth2_consents_pkey" { - return ErrResourceAlreadyExists - } - return fmt.Errorf("cannot insert oauth2_consent: %w", err) } diff --git a/pkg/coredata/risk_assessment.go b/pkg/coredata/risk_assessment.go index 4a869a1c7..976b70911 100644 --- a/pkg/coredata/risk_assessment.go +++ b/pkg/coredata/risk_assessment.go @@ -22,7 +22,6 @@ import ( "time" "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgconn" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/page" @@ -191,9 +190,6 @@ VALUES (@id, @tenant_id, @organization_id, @name, @description, @created_at, @up _, err := conn.Exec(ctx, q, args) if err != nil { - if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok && pgErr.Code == "23505" && pgErr.ConstraintName == "risk_assessments_pkey" { - return ErrResourceAlreadyExists - } return fmt.Errorf("cannot insert risk assessment: %w", err) } return nil diff --git a/pkg/coredata/risk_assessment_scenario.go b/pkg/coredata/risk_assessment_scenario.go index b2483f5ba..503af0066 100644 --- a/pkg/coredata/risk_assessment_scenario.go +++ b/pkg/coredata/risk_assessment_scenario.go @@ -22,7 +22,6 @@ import ( "time" "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgconn" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/page" @@ -351,9 +350,6 @@ INSERT INTO risk_assessment_scenarios ( } _, err := conn.Exec(ctx, q, args) if err != nil { - if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok && pgErr.Code == "23505" && pgErr.ConstraintName == "risk_assessment_scenarios_pkey" { - return ErrResourceAlreadyExists - } return fmt.Errorf("cannot insert risk scenario: %w", err) } return nil diff --git a/pkg/coredata/risk_assessment_scope.go b/pkg/coredata/risk_assessment_scope.go index c9e224ea7..ca1137036 100644 --- a/pkg/coredata/risk_assessment_scope.go +++ b/pkg/coredata/risk_assessment_scope.go @@ -22,7 +22,6 @@ import ( "time" "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgconn" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/page" @@ -196,9 +195,6 @@ INSERT INTO risk_assessment_scopes ( } _, err := conn.Exec(ctx, q, args) if err != nil { - if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok && pgErr.Code == "23505" && pgErr.ConstraintName == "risk_assessment_scopes_pkey" { - return ErrResourceAlreadyExists - } return fmt.Errorf("cannot insert risk assessment scope: %w", err) } return nil diff --git a/pkg/coredata/statement_of_applicability.go b/pkg/coredata/statement_of_applicability.go index 6cd0915eb..a7f414dfa 100644 --- a/pkg/coredata/statement_of_applicability.go +++ b/pkg/coredata/statement_of_applicability.go @@ -227,8 +227,10 @@ VALUES ( _, err := conn.Exec(ctx, q, args) if err != nil { - if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok { - if pgErr.Code == "23505" && pgErr.ConstraintName == "statements_of_applicability_document_id_key" { + if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok && pgErr.Code == "23505" { + switch pgErr.ConstraintName { + case "statements_of_applicability_document_id_key", + "states_of_applicability_name_organization_id_uniq": return ErrResourceAlreadyExists } } @@ -267,8 +269,10 @@ WHERE result, err := conn.Exec(ctx, q, args) if err != nil { - if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok { - if pgErr.Code == "23505" && pgErr.ConstraintName == "statements_of_applicability_document_id_key" { + if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok && pgErr.Code == "23505" { + switch pgErr.ConstraintName { + case "statements_of_applicability_document_id_key", + "states_of_applicability_name_organization_id_uniq": return ErrResourceAlreadyExists } }