Skip to content

Commit 9a7f277

Browse files
authored
refactor: migrate to entity slugs (#384)
Moves the `project_slugs` table to `entity_slugs` and `project_slugs_old` to `entity_slugs_old`. This is done to prepare for more entity types to be namespaced. /deploy renku=release-0.57.0 Note: the sequence migration has been tested: 1. Force migration revision to be `9058bf0a1a12` (before this). 2. Deploy with image tag `renku/renku-data-service:sha-345a1bb` (current `main`) and run `alembic check` and `alembic heads` to verify the DB is before this change. 3. Create some projects. 4. Re-deploy this PR and check that migration revision is `a11752a5afba` (after this). 5. Create more projects.
1 parent 345a1bb commit 9a7f277

File tree

4 files changed

+179
-49
lines changed

4 files changed

+179
-49
lines changed
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
"""migrate to entity slugs
2+
3+
Revision ID: a11752a5afba
4+
Revises: 9058bf0a1a12
5+
Create Date: 2024-09-03 11:18:46.025525
6+
7+
"""
8+
9+
import sqlalchemy as sa
10+
from alembic import op
11+
12+
# revision identifiers, used by Alembic.
13+
revision = "a11752a5afba"
14+
down_revision = "9058bf0a1a12"
15+
branch_labels = None
16+
depends_on = None
17+
18+
19+
def upgrade() -> None:
20+
connection = op.get_bind()
21+
22+
op.execute("ALTER TABLE projects.project_slugs SET SCHEMA common")
23+
op.rename_table("project_slugs", "entity_slugs", schema="common")
24+
op.execute("ALTER INDEX common.project_slugs_unique_slugs RENAME TO entity_slugs_unique_slugs")
25+
op.execute(
26+
"ALTER INDEX common.ix_projects_project_slugs_namespace_id RENAME TO ix_common_entity_slugs_namespace_id"
27+
)
28+
op.execute("ALTER INDEX common.ix_projects_project_slugs_project_id RENAME TO ix_common_entity_slugs_project_id")
29+
op.execute("ALTER INDEX common.ix_projects_project_slugs_slug RENAME TO ix_common_entity_slugs_slug")
30+
op.execute("ALTER SEQUENCE common.project_slugs_id_seq RENAME TO entity_slugs_id_seq")
31+
op.drop_constraint("project_slugs_project_id_fk", "entity_slugs", schema="common", type_="foreignkey")
32+
op.create_foreign_key(
33+
"entity_slugs_project_id_fk",
34+
"entity_slugs",
35+
"projects",
36+
["project_id"],
37+
["id"],
38+
source_schema="common",
39+
referent_schema="projects",
40+
ondelete="CASCADE",
41+
)
42+
43+
op.execute("ALTER TABLE projects.project_slugs_old SET SCHEMA common")
44+
op.rename_table("project_slugs_old", "entity_slugs_old", schema="common")
45+
op.execute(
46+
"ALTER INDEX common.ix_projects_project_slugs_old_created_at RENAME TO ix_common_entity_slugs_old_created_at"
47+
)
48+
op.execute(
49+
"ALTER INDEX common.ix_projects_project_slugs_old_latest_slug_id RENAME TO ix_common_entity_slugs_old_latest_slug_id"
50+
)
51+
op.execute("ALTER INDEX common.ix_projects_project_slugs_old_slug RENAME TO ix_common_entity_slugs_old_slug")
52+
op.execute("ALTER SEQUENCE common.project_slugs_old_id_seq RENAME TO entity_slugs_old_id_seq")
53+
54+
tables = ["entity_slugs", "entity_slugs_old"]
55+
inspector = sa.inspect(op.get_bind())
56+
found_sequences = inspector.get_sequence_names("common")
57+
for table in tables:
58+
seq = f"{table}_id_seq"
59+
if seq not in found_sequences:
60+
continue
61+
last_id_stmt = sa.select(sa.func.max(sa.column("id", type_=sa.INT))).select_from(
62+
sa.table(table, schema="common")
63+
)
64+
last_id = connection.scalars(last_id_stmt).one_or_none()
65+
if last_id is None or last_id <= 0:
66+
continue
67+
op.execute(sa.text(f"ALTER SEQUENCE common.{seq} RESTART WITH {last_id + 1}"))
68+
69+
70+
def downgrade() -> None:
71+
connection = op.get_bind()
72+
73+
op.drop_constraint("entity_slugs_project_id_fk", "entity_slugs", schema="common", type_="foreignkey")
74+
op.create_foreign_key(
75+
"project_slugs_project_id_fk",
76+
"entity_slugs",
77+
"projects",
78+
["project_id"],
79+
["id"],
80+
source_schema="common",
81+
referent_schema="projects",
82+
ondelete="CASCADE",
83+
)
84+
op.execute("ALTER SEQUENCE common.entity_slugs_id_seq RENAME TO project_slugs_id_seq")
85+
op.execute("ALTER INDEX common.ix_common_entity_slugs_slug RENAME TO ix_projects_project_slugs_slug")
86+
op.execute("ALTER INDEX common.ix_common_entity_slugs_project_id RENAME TO ix_projects_project_slugs_project_id")
87+
op.execute(
88+
"ALTER INDEX common.ix_common_entity_slugs_namespace_id RENAME TO ix_projects_project_slugs_namespace_id"
89+
)
90+
op.execute("ALTER INDEX common.entity_slugs_unique_slugs RENAME TO project_slugs_unique_slugs")
91+
op.rename_table("entity_slugs", "project_slugs", schema="common")
92+
op.execute("ALTER TABLE common.project_slugs SET SCHEMA projects")
93+
94+
op.execute("ALTER SEQUENCE common.entity_slugs_old_id_seq RENAME TO project_slugs_old_id_seq")
95+
op.execute("ALTER INDEX common.ix_common_entity_slugs_old_slug RENAME TO ix_projects_project_slugs_old_slug")
96+
op.execute(
97+
"ALTER INDEX common.ix_common_entity_slugs_old_latest_slug_id RENAME TO ix_projects_project_slugs_old_latest_slug_id"
98+
)
99+
op.execute(
100+
"ALTER INDEX common.ix_common_entity_slugs_old_created_at RENAME TO ix_projects_project_slugs_old_created_at"
101+
)
102+
op.rename_table("entity_slugs_old", "project_slugs_old", schema="common")
103+
op.execute("ALTER TABLE common.project_slugs_old SET SCHEMA projects")
104+
105+
tables = ["project_slugs", "project_slugs_old"]
106+
inspector = sa.inspect(op.get_bind())
107+
found_sequences = inspector.get_sequence_names("projects")
108+
for table in tables:
109+
seq = f"{table}_id_seq"
110+
if seq not in found_sequences:
111+
continue
112+
last_id_stmt = sa.select(sa.func.max(sa.column("id", type_=sa.INT))).select_from(
113+
sa.table(table, schema="projects")
114+
)
115+
last_id = connection.scalars(last_id_stmt).one_or_none()
116+
if last_id is None or last_id <= 0:
117+
continue
118+
op.execute(sa.text(f"ALTER SEQUENCE projects.{seq} RESTART WITH {last_id + 1}"))

components/renku_data_services/namespace/orm.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,15 @@
33
from datetime import datetime
44
from typing import Optional
55

6-
from sqlalchemy import CheckConstraint, DateTime, MetaData, String, func
6+
from sqlalchemy import CheckConstraint, DateTime, Index, MetaData, String, func
77
from sqlalchemy.orm import DeclarativeBase, Mapped, MappedAsDataclass, mapped_column, relationship
88
from sqlalchemy.schema import ForeignKey
99
from ulid import ULID
1010

1111
from renku_data_services.base_orm.registry import COMMON_ORM_REGISTRY
1212
from renku_data_services.errors import errors
1313
from renku_data_services.namespace import models
14+
from renku_data_services.project.orm import ProjectORM
1415
from renku_data_services.users.models import UserInfo, UserWithNamespace
1516
from renku_data_services.users.orm import UserORM
1617
from renku_data_services.utils.sqlalchemy import ULIDType
@@ -160,3 +161,40 @@ def dump(self) -> models.Namespace:
160161
underlying_resource_id=self.latest_slug.user_id,
161162
name=name,
162163
)
164+
165+
166+
class EntitySlugORM(BaseORM):
167+
"""Project and namespace slugs."""
168+
169+
__tablename__ = "entity_slugs"
170+
__table_args__ = (Index("entity_slugs_unique_slugs", "namespace_id", "slug", unique=True),)
171+
172+
id: Mapped[int] = mapped_column(primary_key=True, init=False)
173+
slug: Mapped[str] = mapped_column(String(99), index=True, nullable=False)
174+
project_id: Mapped[ULID] = mapped_column(
175+
ForeignKey(ProjectORM.id, ondelete="CASCADE", name="entity_slugs_project_id_fk"), index=True
176+
)
177+
project: Mapped[ProjectORM] = relationship(lazy="joined", init=False, repr=False, viewonly=True)
178+
namespace_id: Mapped[ULID] = mapped_column(
179+
ForeignKey(NamespaceORM.id, ondelete="CASCADE", name="entity_slugs_namespace_id_fk"), index=True
180+
)
181+
namespace: Mapped[NamespaceORM] = relationship(lazy="joined", init=False, repr=False, viewonly=True)
182+
183+
184+
class EntitySlugOldORM(BaseORM):
185+
"""Project slugs history."""
186+
187+
__tablename__ = "entity_slugs_old"
188+
189+
id: Mapped[int] = mapped_column(primary_key=True, init=False)
190+
slug: Mapped[str] = mapped_column(String(99), index=True, nullable=False)
191+
created_at: Mapped[datetime] = mapped_column(
192+
DateTime(timezone=True), nullable=False, index=True, init=False, server_default=func.now()
193+
)
194+
latest_slug_id: Mapped[int] = mapped_column(
195+
ForeignKey(EntitySlugORM.id, ondelete="CASCADE"),
196+
nullable=False,
197+
init=False,
198+
index=True,
199+
)
200+
latest_slug: Mapped[EntitySlugORM] = relationship(lazy="joined", repr=False, viewonly=True)

components/renku_data_services/project/db.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from renku_data_services.message_queue.db import EventRepository
2323
from renku_data_services.message_queue.interface import IMessageQueue
2424
from renku_data_services.message_queue.redis_queue import dispatch_message
25+
from renku_data_services.namespace import orm as ns_schemas
2526
from renku_data_services.namespace.db import GroupRepository
2627
from renku_data_services.project import apispec as project_apispec
2728
from renku_data_services.project import models
@@ -99,7 +100,7 @@ async def get_project_by_namespace_slug(
99100
async with self.session_maker() as session:
100101
stmt = select(schemas.ProjectORM)
101102
stmt = _filter_by_namespace_slug(stmt, namespace)
102-
stmt = stmt.where(schemas.ProjectSlug.slug == slug.lower())
103+
stmt = stmt.where(ns_schemas.EntitySlugORM.slug == slug.lower())
103104
result = await session.execute(stmt)
104105
project_orm = result.scalars().first()
105106

@@ -135,7 +136,7 @@ async def insert_project(
135136
if not session:
136137
raise errors.ProgrammingError(message="A database session is required")
137138
ns = await session.scalar(
138-
select(schemas.NamespaceORM).where(schemas.NamespaceORM.slug == project.namespace.lower())
139+
select(ns_schemas.NamespaceORM).where(ns_schemas.NamespaceORM.slug == project.namespace.lower())
139140
)
140141
if not ns:
141142
raise errors.MissingResourceError(
@@ -171,11 +172,12 @@ async def insert_project(
171172
creation_date=datetime.now(UTC).replace(microsecond=0),
172173
keywords=project.keywords,
173174
)
174-
project_slug = schemas.ProjectSlug(slug, project_id=project_orm.id, namespace_id=ns.id)
175+
project_slug = ns_schemas.EntitySlugORM(slug, project_id=project_orm.id, namespace_id=ns.id)
175176

176-
session.add(project_slug)
177177
session.add(project_orm)
178178
await session.flush()
179+
session.add(project_slug)
180+
await session.flush()
179181
await session.refresh(project_orm)
180182

181183
return project_orm.dump()
@@ -241,7 +243,9 @@ async def update_project(
241243

242244
if "namespace" in payload:
243245
ns_slug = payload["namespace"]
244-
ns = await session.scalar(select(schemas.NamespaceORM).where(schemas.NamespaceORM.slug == ns_slug.lower()))
246+
ns = await session.scalar(
247+
select(ns_schemas.NamespaceORM).where(ns_schemas.NamespaceORM.slug == ns_slug.lower())
248+
)
245249
if not ns:
246250
raise errors.MissingResourceError(message=f"The namespace with slug {ns_slug} does not exist")
247251
if not ns.group_id and not ns.user_id:
@@ -301,9 +305,9 @@ async def delete_project(
301305
def _filter_by_namespace_slug(statement: Select[tuple[_T]], namespace: str) -> Select[tuple[_T]]:
302306
"""Filters a select query on projects to a given namespace."""
303307
return (
304-
statement.where(schemas.NamespaceORM.slug == namespace.lower())
305-
.where(schemas.ProjectSlug.namespace_id == schemas.NamespaceORM.id)
306-
.where(schemas.ProjectORM.id == schemas.ProjectSlug.project_id)
308+
statement.where(ns_schemas.NamespaceORM.slug == namespace.lower())
309+
.where(ns_schemas.EntitySlugORM.namespace_id == ns_schemas.NamespaceORM.id)
310+
.where(schemas.ProjectORM.id == ns_schemas.EntitySlugORM.project_id)
307311
)
308312

309313

components/renku_data_services/project/orm.py

Lines changed: 10 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,31 @@
11
"""SQLAlchemy's schemas for the projects database."""
22

33
from datetime import datetime
4-
from typing import Optional
4+
from typing import TYPE_CHECKING, Optional
55

6-
from sqlalchemy import DateTime, Index, Integer, MetaData, String, func
6+
from sqlalchemy import DateTime, Integer, MetaData, String, func
77
from sqlalchemy.dialects.postgresql import ARRAY
88
from sqlalchemy.orm import DeclarativeBase, Mapped, MappedAsDataclass, mapped_column, relationship
99
from sqlalchemy.schema import ForeignKey
1010
from ulid import ULID
1111

1212
from renku_data_services.authz import models as authz_models
13-
from renku_data_services.namespace.orm import NamespaceORM
13+
from renku_data_services.base_orm.registry import COMMON_ORM_REGISTRY
1414
from renku_data_services.project import models
1515
from renku_data_services.project.apispec import Visibility
1616
from renku_data_services.utils.sqlalchemy import ULIDType
1717

18+
if TYPE_CHECKING:
19+
from renku_data_services.namespace.orm import EntitySlugORM
20+
1821
metadata_obj = MetaData(schema="projects") # Has to match alembic ini section name
1922

2023

2124
class BaseORM(MappedAsDataclass, DeclarativeBase):
2225
"""Base class for all ORM classes."""
2326

2427
metadata = metadata_obj
28+
registry = COMMON_ORM_REGISTRY
2529

2630

2731
class ProjectORM(BaseORM):
@@ -36,7 +40,9 @@ class ProjectORM(BaseORM):
3640
keywords: Mapped[Optional[list[str]]] = mapped_column("keywords", ARRAY(String(99)), nullable=True)
3741
# NOTE: The project slugs table has a foreign key from the projects table, but there is a stored procedure
3842
# triggered by the deletion of slugs to remove the project used by the slug. See migration 89aa4573cfa9.
39-
slug: Mapped["ProjectSlug"] = relationship(lazy="joined", init=False, repr=False, viewonly=True)
43+
slug: Mapped["EntitySlugORM"] = relationship(
44+
lazy="joined", init=False, repr=False, viewonly=True, back_populates="project"
45+
)
4046
repositories: Mapped[list["ProjectRepositoryORM"]] = relationship(
4147
back_populates="project",
4248
default_factory=list,
@@ -81,39 +87,3 @@ class ProjectRepositoryORM(BaseORM):
8187
ForeignKey("projects.id", ondelete="CASCADE"), default=None, index=True
8288
)
8389
project: Mapped[Optional[ProjectORM]] = relationship(back_populates="repositories", default=None, repr=False)
84-
85-
86-
class ProjectSlug(BaseORM):
87-
"""Project and namespace slugs."""
88-
89-
__tablename__ = "project_slugs"
90-
__table_args__ = (Index("project_slugs_unique_slugs", "namespace_id", "slug", unique=True),)
91-
92-
id: Mapped[int] = mapped_column(primary_key=True, init=False)
93-
slug: Mapped[str] = mapped_column(String(99), index=True, nullable=False)
94-
project_id: Mapped[ULID] = mapped_column(
95-
ForeignKey(ProjectORM.id, ondelete="CASCADE", name="project_slugs_project_id_fk"), index=True
96-
)
97-
namespace_id: Mapped[ULID] = mapped_column(
98-
ForeignKey(NamespaceORM.id, ondelete="CASCADE", name="project_slugs_namespace_id_fk"), index=True
99-
)
100-
namespace: Mapped[NamespaceORM] = relationship(lazy="joined", init=False, repr=False, viewonly=True)
101-
102-
103-
class ProjectSlugOld(BaseORM):
104-
"""Project slugs history."""
105-
106-
__tablename__ = "project_slugs_old"
107-
108-
id: Mapped[int] = mapped_column(primary_key=True, init=False)
109-
slug: Mapped[str] = mapped_column(String(99), index=True, nullable=False)
110-
created_at: Mapped[datetime] = mapped_column(
111-
DateTime(timezone=True), nullable=False, index=True, init=False, server_default=func.now()
112-
)
113-
latest_slug_id: Mapped[int] = mapped_column(
114-
ForeignKey(ProjectSlug.id, ondelete="CASCADE"),
115-
nullable=False,
116-
init=False,
117-
index=True,
118-
)
119-
latest_slug: Mapped[ProjectSlug] = relationship(lazy="joined", repr=False, viewonly=True)

0 commit comments

Comments
 (0)