Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE roles ADD CONSTRAINT roles_org_id_name_key UNIQUE (org_id, name);
DROP INDEX IF EXISTS uq_roles_org_id_name_live;
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
CREATE UNIQUE INDEX IF NOT EXISTS uq_roles_org_id_name_live ON roles (org_id, name) WHERE deleted_at IS NULL;
ALTER TABLE roles DROP CONSTRAINT IF EXISTS roles_org_id_name_key;
8 changes: 3 additions & 5 deletions internal/store/postgres/postgres.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,9 @@ func fromLive(table string) *goqu.SelectDataset {
return dialect.From(table).Where(live(table))
}

// liveConflictTarget builds the ON CONFLICT target for a unique index that
// covers only live rows. For example "urn" renders as
// ON CONFLICT (urn) WHERE (deleted_at IS NULL). Postgres uses such an index only
// when the clause names its condition. goqu wraps the target in parentheses as
// is, which is why the string ends open.
// liveConflictTarget is the ON CONFLICT target for a unique index over live
// rows. Postgres matches such an index only when the target repeats its WHERE
// clause. goqu wraps the target in parentheses as is, so the string ends open.
func liveConflictTarget(columns string) string {
return columns + ") WHERE (deleted_at IS NULL"
}
Expand Down
3 changes: 2 additions & 1 deletion internal/store/postgres/role_repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,12 +132,13 @@ func (r RoleRepository) Upsert(ctx context.Context, rl role.Role) (role.Role, er
"state": rl.State,
"metadata": marshaledMetadata,
"scopes": pq.Array(rl.Scopes),
}).OnConflict(goqu.DoUpdate("org_id, name", goqu.Record{
}).OnConflict(goqu.DoUpdate(liveConflictTarget("org_id, name"), goqu.Record{
"title": rl.Title,
"permissions": marshaledPermissions,
"state": rl.State,
"metadata": marshaledMetadata,
"scopes": pq.Array(rl.Scopes),
"updated_at": goqu.L("now()"),
})).Returning(&Role{}).ToSQL()
if err != nil {
return role.Role{}, fmt.Errorf("%w: %s", errQuery, err)
Expand Down
62 changes: 62 additions & 0 deletions internal/store/postgres/role_repository_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,68 @@ func (s *RoleRepositoryTestSuite) TestCreate() {
}
})
}

s.Run("should create a new row when the name belongs to a soft-deleted role", func() {
deleted := s.roles[3]
if _, err := s.client.ExecContext(s.ctx, "UPDATE roles SET deleted_at = now() WHERE id = $1", deleted.ID); err != nil {
s.T().Fatal(err)
}

got, err := s.repository.Upsert(s.ctx, role.Role{
Name: deleted.Name,
Title: "Test Title",
Permissions: []string{"user"},
OrgID: s.orgID,
Metadata: metadata.Metadata{},
})
s.Assert().NoError(err)
if got.ID == deleted.ID {
s.T().Fatalf("got the deleted row %s back, expected a new row", deleted.ID)
}

var rows int
if err := s.client.QueryRowxContext(s.ctx, "SELECT count(*) FROM roles WHERE org_id = $1 AND name = $2", s.orgID, deleted.Name).Scan(&rows); err != nil {
s.T().Fatal(err)
}
if rows != 2 {
s.T().Fatalf("got %d rows named %s, expected the deleted row and the new one", rows, deleted.Name)
}
})

s.Run("should return conflict when the id belongs to a soft-deleted role", func() {
deleted := s.roles[4]
if _, err := s.client.ExecContext(s.ctx, "UPDATE roles SET deleted_at = now() WHERE id = $1", deleted.ID); err != nil {
s.T().Fatal(err)
}

_, err := s.repository.Upsert(s.ctx, role.Role{
ID: deleted.ID,
Name: "role with a reused id",
OrgID: s.orgID,
Metadata: metadata.Metadata{},
})
s.Assert().ErrorIs(err, role.ErrConflict)
})

s.Run("should update the live role and move updated_at when the name is taken", func() {
before, err := s.repository.Get(s.ctx, s.roles[2].ID)
if err != nil {
s.T().Fatal(err)
}

got, err := s.repository.Upsert(s.ctx, role.Role{
Name: before.Name,
Title: "changed",
Permissions: before.Permissions,
Scopes: before.Scopes,
OrgID: s.orgID,
Metadata: metadata.Metadata{},
})
s.Assert().NoError(err)
s.Assert().Equal(before.ID, got.ID)
s.Assert().Equal("changed", got.Title)
s.Assert().True(got.UpdatedAt.After(before.UpdatedAt))
})
}

func (s *RoleRepositoryTestSuite) TestList() {
Expand Down
Loading