Skip to content
This repository was archived by the owner on Sep 9, 2026. It is now read-only.
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
14 changes: 7 additions & 7 deletions internal/database/dao/dao_immutability_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ var _ = Describe("Immutable columns", func() {

// Create a test object:
_, err = conn.Exec(ctx, `
insert into organizations (
insert into tenants (
id,
name,
tenant,
Expand All @@ -67,7 +67,7 @@ var _ = Describe("Immutable columns", func() {
Describe("Database trigger", func() {
It("Rejects update that changes the one immutable column", func() {
_, err := conn.Exec(ctx, `
update organizations set
update tenants set
name = 'your-tenant'
where
id = 'my-tenant'
Expand All @@ -80,13 +80,13 @@ var _ = Describe("Immutable columns", func() {
"name"
]`))
Expect(pgErr.Message).To(Equal(
`column 'name' of table 'organizations' is immutable`,
`column 'name' of table 'tenants' is immutable`,
))
})

It("Rejects update that changes two immutable columns", func() {
_, err := conn.Exec(ctx, `
update organizations set
update tenants set
name = 'your-name',
tenant = 'your-tenant'
where
Expand All @@ -100,12 +100,12 @@ var _ = Describe("Immutable columns", func() {
"name",
"tenant"
]`))
Expect(pgErr.Message).To(Equal(`columns 'name' and 'tenant' of table 'organizations' are immutable`))
Expect(pgErr.Message).To(Equal(`columns 'name' and 'tenant' of table 'tenants' are immutable`))
})

It("Allows update that includes but doesn't change an i the immutable column", func() {
_, err := conn.Exec(ctx, `
update organizations set
update tenants set
name = 'my-tenant'
where
id = 'my-tenant'
Expand All @@ -115,7 +115,7 @@ var _ = Describe("Immutable columns", func() {

It("Allows update of other columns", func() {
_, err := conn.Exec(ctx, `
update organizations set
update tenants set
labels = '{"my-label": "my-value"}'::jsonb
where
id = 'my-tenant'
Expand Down
20 changes: 19 additions & 1 deletion internal/database/dao/generic_dao.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ type Object interface {
// GenericDAOBuilder is a builder for creating generic data access objects.
type GenericDAOBuilder[O Object] struct {
logger *slog.Logger
table string
defaultLimit int32
maxLimit int32
eventCallbacks []EventCallback
Expand Down Expand Up @@ -177,6 +178,14 @@ func (b *GenericDAOBuilder[O]) SetMetricsRegisterer(value prometheus.Registerer)
return b
}

// SetTableName overrides the database table name. By default the table name is derived from the
// protobuf message type name (e.g. Cluster becomes clusters). Use this when the table name does not
// match the type name.
func (b *GenericDAOBuilder[O]) SetTableName(value string) *GenericDAOBuilder[O] {
b.table = value
return b
}

// Build creates a new generic DAO using the configuration stored in the builder.
func (b *GenericDAOBuilder[O]) Build() (result *GenericDAO[O], err error) {
// Check parameters:
Expand Down Expand Up @@ -280,7 +289,7 @@ func (b *GenericDAOBuilder[O]) Build() (result *GenericDAO[O], err error) {
// Create and populate the object:
result = &GenericDAO[O]{
logger: b.logger,
table: b.tableName(),
table: b.resolveTableName(),
defaultLimit: b.defaultLimit,
maxLimit: b.maxLimit,
timestampDesc: timestampDesc,
Expand All @@ -298,6 +307,15 @@ func (b *GenericDAOBuilder[O]) Build() (result *GenericDAO[O], err error) {
return
}

// resolveTableName returns the explicit table name if one was set via SetTableName, otherwise it
// derives the name from the protobuf message type.
func (b *GenericDAOBuilder[O]) resolveTableName() string {
if b.table != "" {
return b.table
}
return b.tableName()
}

// tableName calculates the table name from the protobuf message type name. It converts the CamelCase type
// name to snake_case and pluralizes it. For example, `Cluster` becomes `clusters` and `ComputeInstance` becomes
// `compute_instances`.
Expand Down
1 change: 1 addition & 0 deletions internal/database/dao/generic_dao_events_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ var _ = Describe("Generic DAO events", func() {
// Create the tenant used in the tests:
tenantsDao, err := NewGenericDAO[*privatev1.Organization]().
SetLogger(logger).
SetTableName("tenants").
SetTenancyLogic(tenancy).
Build()
Expect(err).ToNot(HaveOccurred())
Expand Down
1 change: 1 addition & 0 deletions internal/database/dao/generic_dao_immutability_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ var _ = Describe("Immutable fields", func() {
// Create the DAO:
generic, err = NewGenericDAO[*privatev1.Organization]().
SetLogger(logger).
SetTableName("tenants").
SetTenancyLogic(tenancy).
Build()
Expect(err).ToNot(HaveOccurred())
Expand Down
2 changes: 1 addition & 1 deletion internal/database/dao/generic_dao_integrity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ var _ = Describe("Referential integrity", func() {
ctrl = gomock.NewController(GinkgoT())
DeferCleanup(ctrl.Finish)

// Prepare the database pool (all migrations are applied, so the clusters and organizations
// Prepare the database pool (all migrations are applied, so the clusters and tenants
// tables exist with the tenant FK constraint already in place):
db, err := server.NewInstance().Build()
Expect(err).ToNot(HaveOccurred())
Expand Down
4 changes: 2 additions & 2 deletions internal/database/database_tool.go
Original file line number Diff line number Diff line change
Expand Up @@ -719,7 +719,7 @@ func (t *tool) checkTableExists(ctx context.Context, pool *pgxpool.Pool, table s
}

// checkTenantForeignKey verifies that the given table has a foreign key constraint on the 'tenant' column referencing
// the 'id' column of the 'organizations' table. Returns the number of issues found.
// the 'id' column of the 'tenants' table. Returns the number of issues found.
func (t *tool) checkTenantForeignKey(ctx context.Context, pool *pgxpool.Pool, table string) int {
constraint := table + "_tenant_fk"
var count int
Expand Down Expand Up @@ -749,7 +749,7 @@ func (t *tool) checkTenantForeignKey(ctx context.Context, pool *pgxpool.Pool, ta
con.contype = 'f' and
a.attname = 'tenant' and
fn.nspname = 'public' and
fc.relname = 'organizations' and
fc.relname = 'tenants' and
fa.attname = 'id'`,
table,
constraint,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
--
-- Copyright (c) 2026 Red Hat Inc.
--
-- Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
-- the License. You may obtain a copy of the License at
--
-- http://www.apache.org/licenses/LICENSE-2.0
--
-- Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
-- an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
-- specific language governing permissions and limitations under the License.
--

-- Rename the organizations tables to tenants. The immutable column trigger and all foreign key constraints that
-- reference the table must be dropped and re-created because they are bound to the table name.

-- Drop the immutable column trigger (its error messages reference the table name):
drop trigger check_immutable_columns on organizations;

-- Drop all foreign key constraints that reference organizations(id):
do $$
declare
r record;
begin
for r in
select
con.conname as constraint_name,
c.relname as table_name
from
pg_catalog.pg_constraint con
join
pg_catalog.pg_class c on c.oid = con.conrelid
join
pg_catalog.pg_class fc on fc.oid = con.confrelid
join
pg_catalog.pg_namespace fn on fn.oid = fc.relnamespace
where
con.contype = 'f' and
fn.nspname = 'public' and
fc.relname = 'organizations'
loop
execute format('alter table %I drop constraint %I', r.table_name, r.constraint_name);
end loop;
end;
$$;

-- Rename the tables:
alter table organizations rename to tenants;
alter table archived_organizations rename to archived_tenants;

-- Rename the indexes:
alter index organizations_by_name rename to tenants_by_name;
alter index organizations_by_owner rename to tenants_by_owner;
alter index organizations_by_tenant rename to tenants_by_tenant;
alter index organizations_by_label rename to tenants_by_label;

-- Re-create the immutable column trigger on the renamed table:
create trigger check_immutable_columns
before update on tenants
for each row
execute function check_immutable_columns('name', 'tenant');

-- Re-create all tenant foreign key constraints referencing tenants(id):
do $$
declare
t text;
begin
for t in
select
c.table_name
from
information_schema.columns c
join
information_schema.tables tb on
tb.table_schema = c.table_schema and
tb.table_name = c.table_name
where
c.table_schema = 'public' and
c.column_name = 'tenant' and
tb.table_type = 'BASE TABLE' and
c.table_name not like 'archived_%' and
c.table_name not in ('notifications', 'schema_migrations')
order by
c.table_name
loop
execute format(
'alter table %I add constraint %I foreign key (tenant) references tenants (id)',
t, t || '_tenant_fk'
);
end loop;
end;
$$;
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/*
Copyright (c) 2026 Red Hat Inc.

Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the
License. You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific
language governing permissions and limitations under the License.
*/

package migrations

import (
"context"

. "github.com/onsi/ginkgo/v2/dsl/core"
. "github.com/onsi/gomega"
)

var _ = DescribeMigration("Rename organizations to tenants", func() {
It("Renames the organizations table to tenants", func(ctx context.Context) {
err := tool.Migrate(ctx, 58)
Expect(err).ToNot(HaveOccurred())

_, err = conn.Exec(ctx, `
insert into tenants (id, name, tenant, data)
values ('my-tenant', 'my-tenant', 'my-tenant', '{}')
`)
Expect(err).ToNot(HaveOccurred())
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

It("Removes the organizations table", func(ctx context.Context) {
err := tool.Migrate(ctx, 58)
Expect(err).ToNot(HaveOccurred())

var count int
err = conn.QueryRow(ctx, `
select count(*) from pg_catalog.pg_class c
join pg_catalog.pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public' and c.relkind = 'r' and c.relname = 'organizations'
`).Scan(&count)
Expect(err).ToNot(HaveOccurred())
Expect(count).To(Equal(0))
})

It("Renames the archived table", func(ctx context.Context) {
err := tool.Migrate(ctx, 58)
Expect(err).ToNot(HaveOccurred())

var count int
err = conn.QueryRow(ctx, `
select count(*) from pg_catalog.pg_class c
join pg_catalog.pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public' and c.relkind = 'r' and c.relname = 'archived_tenants'
`).Scan(&count)
Expect(err).ToNot(HaveOccurred())
Expect(count).To(Equal(1))
})

It("Preserves tenant foreign key constraints", func(ctx context.Context) {
err := tool.Migrate(ctx, 58)
Expect(err).ToNot(HaveOccurred())

// Insert a valid tenant first:
_, err = conn.Exec(ctx, `
insert into tenants (id, name, tenant, data)
values ('my-tenant', 'my-tenant', 'my-tenant', '{}')
`)
Expect(err).ToNot(HaveOccurred())

// Inserting a cluster with a non-existent tenant should fail:
_, err = conn.Exec(ctx, `
insert into clusters (id, name, tenant, data)
values ('123', 'my-cluster', 'no-such-tenant', '{}')
`)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("tenant_fk"))
})

It("Preserves the immutable column trigger", func(ctx context.Context) {
err := tool.Migrate(ctx, 58)
Expect(err).ToNot(HaveOccurred())

// Insert a tenant:
_, err = conn.Exec(ctx, `
insert into tenants (id, name, tenant, data)
values ('my-tenant', 'my-tenant', 'my-tenant', '{}')
`)
Expect(err).ToNot(HaveOccurred())

// Updating the name should be rejected:
_, err = conn.Exec(ctx, `
update tenants set name = 'new-name' where id = 'my-tenant'
`)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("immutable"))
})
})
11 changes: 11 additions & 0 deletions internal/servers/generic_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import (
type GenericServerBuilder[O dao.Object] struct {
logger *slog.Logger
service string
table string
ignoredFields []any
notifier events.Notifier
attributionLogic auth.AttributionLogic
Expand Down Expand Up @@ -107,6 +108,13 @@ func (b *GenericServerBuilder[O]) SetService(value string) *GenericServerBuilder
return b
}

// SetTableName overrides the database table name. By default the table name is derived from the
// protobuf message type name. Use this when the table name does not match the type name.
func (b *GenericServerBuilder[O]) SetTableName(value string) *GenericServerBuilder[O] {
b.table = value
return b
}

// AddIgnoredFields adds a set of fields to be omitted when mapping objects. The values passed can be of the following
// types:
//
Expand Down Expand Up @@ -193,6 +201,9 @@ func (b *GenericServerBuilder[O]) Build() (result *GenericServer[O], err error)
// Create the DAO:
daoBuilder := dao.NewGenericDAO[O]()
daoBuilder.SetLogger(b.logger)
if b.table != "" {
daoBuilder.SetTableName(b.table)
}
daoBuilder.SetTenancyLogic(b.tenancyLogic)
if b.notifier != nil {
daoBuilder.AddEventCallback(s.notifyEvent)
Expand Down
2 changes: 2 additions & 0 deletions internal/servers/private_organizations_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ func (b *PrivateOrganizationsServerBuilder) Build() (result *PrivateOrganization
generic, err := NewGenericServer[*privatev1.Organization]().
SetLogger(b.logger).
SetService(privatev1.Organizations_ServiceDesc.ServiceName).
SetTableName("tenants").
SetNotifier(b.notifier).
SetAttributionLogic(b.attributionLogic).
SetTenancyLogic(b.tenancyLogic).
Expand All @@ -103,6 +104,7 @@ func (b *PrivateOrganizationsServerBuilder) Build() (result *PrivateOrganization
// Create the DAO:
dao, err := dao.NewGenericDAO[*privatev1.Organization]().
SetLogger(b.logger).
SetTableName("tenants").
SetTenancyLogic(b.tenancyLogic).
SetMetricsRegisterer(b.metricsRegisterer).
Build()
Expand Down
1 change: 1 addition & 0 deletions internal/servers/private_projects_server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ var _ = Describe("Private projects server", func() {
// Create the tenants used in the tests:
tenantsDao, err := dao.NewGenericDAO[*privatev1.Organization]().
SetLogger(logger).
SetTableName("tenants").
SetTenancyLogic(tenancy).
Build()
Expect(err).ToNot(HaveOccurred())
Expand Down
Loading