♻️ Edit refactor db models to use UUID's instead of integer ID's (#1259)
Co-authored-by: Sebastián Ramírez <tiangolo@gmail.com>
This commit is contained in:
@@ -0,0 +1,90 @@
|
|||||||
|
"""Edit replace id integers in all models to use UUID instead
|
||||||
|
|
||||||
|
Revision ID: d98dd8ec85a3
|
||||||
|
Revises: 9c0a54914c78
|
||||||
|
Create Date: 2024-07-19 04:08:04.000976
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlmodel.sql.sqltypes
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'd98dd8ec85a3'
|
||||||
|
down_revision = '9c0a54914c78'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# Ensure uuid-ossp extension is available
|
||||||
|
op.execute('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"')
|
||||||
|
|
||||||
|
# Create a new UUID column with a default UUID value
|
||||||
|
op.add_column('user', sa.Column('new_id', postgresql.UUID(as_uuid=True), default=sa.text('uuid_generate_v4()')))
|
||||||
|
op.add_column('item', sa.Column('new_id', postgresql.UUID(as_uuid=True), default=sa.text('uuid_generate_v4()')))
|
||||||
|
op.add_column('item', sa.Column('new_owner_id', postgresql.UUID(as_uuid=True), nullable=True))
|
||||||
|
|
||||||
|
# Populate the new columns with UUIDs
|
||||||
|
op.execute('UPDATE "user" SET new_id = uuid_generate_v4()')
|
||||||
|
op.execute('UPDATE item SET new_id = uuid_generate_v4()')
|
||||||
|
op.execute('UPDATE item SET new_owner_id = (SELECT new_id FROM "user" WHERE "user".id = item.owner_id)')
|
||||||
|
|
||||||
|
# Set the new_id as not nullable
|
||||||
|
op.alter_column('user', 'new_id', nullable=False)
|
||||||
|
op.alter_column('item', 'new_id', nullable=False)
|
||||||
|
|
||||||
|
# Drop old columns and rename new columns
|
||||||
|
op.drop_constraint('item_owner_id_fkey', 'item', type_='foreignkey')
|
||||||
|
op.drop_column('item', 'owner_id')
|
||||||
|
op.alter_column('item', 'new_owner_id', new_column_name='owner_id')
|
||||||
|
|
||||||
|
op.drop_column('user', 'id')
|
||||||
|
op.alter_column('user', 'new_id', new_column_name='id')
|
||||||
|
|
||||||
|
op.drop_column('item', 'id')
|
||||||
|
op.alter_column('item', 'new_id', new_column_name='id')
|
||||||
|
|
||||||
|
# Create primary key constraint
|
||||||
|
op.create_primary_key('user_pkey', 'user', ['id'])
|
||||||
|
op.create_primary_key('item_pkey', 'item', ['id'])
|
||||||
|
|
||||||
|
# Recreate foreign key constraint
|
||||||
|
op.create_foreign_key('item_owner_id_fkey', 'item', 'user', ['owner_id'], ['id'])
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# Reverse the upgrade process
|
||||||
|
op.add_column('user', sa.Column('old_id', sa.Integer, autoincrement=True))
|
||||||
|
op.add_column('item', sa.Column('old_id', sa.Integer, autoincrement=True))
|
||||||
|
op.add_column('item', sa.Column('old_owner_id', sa.Integer, nullable=True))
|
||||||
|
|
||||||
|
# Populate the old columns with default values
|
||||||
|
# Generate sequences for the integer IDs if not exist
|
||||||
|
op.execute('CREATE SEQUENCE IF NOT EXISTS user_id_seq AS INTEGER OWNED BY "user".old_id')
|
||||||
|
op.execute('CREATE SEQUENCE IF NOT EXISTS item_id_seq AS INTEGER OWNED BY item.old_id')
|
||||||
|
|
||||||
|
op.execute('SELECT setval(\'user_id_seq\', COALESCE((SELECT MAX(old_id) + 1 FROM "user"), 1), false)')
|
||||||
|
op.execute('SELECT setval(\'item_id_seq\', COALESCE((SELECT MAX(old_id) + 1 FROM item), 1), false)')
|
||||||
|
|
||||||
|
op.execute('UPDATE "user" SET old_id = nextval(\'user_id_seq\')')
|
||||||
|
op.execute('UPDATE item SET old_id = nextval(\'item_id_seq\'), old_owner_id = (SELECT old_id FROM "user" WHERE "user".id = item.owner_id)')
|
||||||
|
|
||||||
|
# Drop new columns and rename old columns back
|
||||||
|
op.drop_constraint('item_owner_id_fkey', 'item', type_='foreignkey')
|
||||||
|
op.drop_column('item', 'owner_id')
|
||||||
|
op.alter_column('item', 'old_owner_id', new_column_name='owner_id')
|
||||||
|
|
||||||
|
op.drop_column('user', 'id')
|
||||||
|
op.alter_column('user', 'old_id', new_column_name='id')
|
||||||
|
|
||||||
|
op.drop_column('item', 'id')
|
||||||
|
op.alter_column('item', 'old_id', new_column_name='id')
|
||||||
|
|
||||||
|
# Create primary key constraint
|
||||||
|
op.create_primary_key('user_pkey', 'user', ['id'])
|
||||||
|
op.create_primary_key('item_pkey', 'item', ['id'])
|
||||||
|
|
||||||
|
# Recreate foreign key constraint
|
||||||
|
op.create_foreign_key('item_owner_id_fkey', 'item', 'user', ['owner_id'], ['id'])
|
@@ -1,3 +1,4 @@
|
|||||||
|
import uuid
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
@@ -41,7 +42,7 @@ def read_items(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/{id}", response_model=ItemPublic)
|
@router.get("/{id}", response_model=ItemPublic)
|
||||||
def read_item(session: SessionDep, current_user: CurrentUser, id: int) -> Any:
|
def read_item(session: SessionDep, current_user: CurrentUser, id: uuid.UUID) -> Any:
|
||||||
"""
|
"""
|
||||||
Get item by ID.
|
Get item by ID.
|
||||||
"""
|
"""
|
||||||
@@ -69,7 +70,11 @@ def create_item(
|
|||||||
|
|
||||||
@router.put("/{id}", response_model=ItemPublic)
|
@router.put("/{id}", response_model=ItemPublic)
|
||||||
def update_item(
|
def update_item(
|
||||||
*, session: SessionDep, current_user: CurrentUser, id: int, item_in: ItemUpdate
|
*,
|
||||||
|
session: SessionDep,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
id: uuid.UUID,
|
||||||
|
item_in: ItemUpdate,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
Update an item.
|
Update an item.
|
||||||
@@ -88,7 +93,9 @@ def update_item(
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{id}")
|
@router.delete("/{id}")
|
||||||
def delete_item(session: SessionDep, current_user: CurrentUser, id: int) -> Message:
|
def delete_item(
|
||||||
|
session: SessionDep, current_user: CurrentUser, id: uuid.UUID
|
||||||
|
) -> Message:
|
||||||
"""
|
"""
|
||||||
Delete an item.
|
Delete an item.
|
||||||
"""
|
"""
|
||||||
|
@@ -1,3 +1,4 @@
|
|||||||
|
import uuid
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
@@ -163,7 +164,7 @@ def register_user(session: SessionDep, user_in: UserRegister) -> Any:
|
|||||||
|
|
||||||
@router.get("/{user_id}", response_model=UserPublic)
|
@router.get("/{user_id}", response_model=UserPublic)
|
||||||
def read_user_by_id(
|
def read_user_by_id(
|
||||||
user_id: int, session: SessionDep, current_user: CurrentUser
|
user_id: uuid.UUID, session: SessionDep, current_user: CurrentUser
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
Get a specific user by id.
|
Get a specific user by id.
|
||||||
@@ -187,7 +188,7 @@ def read_user_by_id(
|
|||||||
def update_user(
|
def update_user(
|
||||||
*,
|
*,
|
||||||
session: SessionDep,
|
session: SessionDep,
|
||||||
user_id: int,
|
user_id: uuid.UUID,
|
||||||
user_in: UserUpdate,
|
user_in: UserUpdate,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
@@ -213,7 +214,7 @@ def update_user(
|
|||||||
|
|
||||||
@router.delete("/{user_id}", dependencies=[Depends(get_current_active_superuser)])
|
@router.delete("/{user_id}", dependencies=[Depends(get_current_active_superuser)])
|
||||||
def delete_user(
|
def delete_user(
|
||||||
session: SessionDep, current_user: CurrentUser, user_id: int
|
session: SessionDep, current_user: CurrentUser, user_id: uuid.UUID
|
||||||
) -> Message:
|
) -> Message:
|
||||||
"""
|
"""
|
||||||
Delete a user.
|
Delete a user.
|
||||||
|
@@ -1,3 +1,4 @@
|
|||||||
|
import uuid
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
@@ -45,7 +46,7 @@ def authenticate(*, session: Session, email: str, password: str) -> User | None:
|
|||||||
return db_user
|
return db_user
|
||||||
|
|
||||||
|
|
||||||
def create_item(*, session: Session, item_in: ItemCreate, owner_id: int) -> Item:
|
def create_item(*, session: Session, item_in: ItemCreate, owner_id: uuid.UUID) -> Item:
|
||||||
db_item = Item.model_validate(item_in, update={"owner_id": owner_id})
|
db_item = Item.model_validate(item_in, update={"owner_id": owner_id})
|
||||||
session.add(db_item)
|
session.add(db_item)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
@@ -1,3 +1,5 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
from pydantic import EmailStr
|
from pydantic import EmailStr
|
||||||
from sqlmodel import Field, Relationship, SQLModel
|
from sqlmodel import Field, Relationship, SQLModel
|
||||||
|
|
||||||
@@ -39,14 +41,14 @@ class UpdatePassword(SQLModel):
|
|||||||
|
|
||||||
# Database model, database table inferred from class name
|
# Database model, database table inferred from class name
|
||||||
class User(UserBase, table=True):
|
class User(UserBase, table=True):
|
||||||
id: int | None = Field(default=None, primary_key=True)
|
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||||
hashed_password: str
|
hashed_password: str
|
||||||
items: list["Item"] = Relationship(back_populates="owner")
|
items: list["Item"] = Relationship(back_populates="owner")
|
||||||
|
|
||||||
|
|
||||||
# Properties to return via API, id is always required
|
# Properties to return via API, id is always required
|
||||||
class UserPublic(UserBase):
|
class UserPublic(UserBase):
|
||||||
id: int
|
id: uuid.UUID
|
||||||
|
|
||||||
|
|
||||||
class UsersPublic(SQLModel):
|
class UsersPublic(SQLModel):
|
||||||
@@ -72,16 +74,16 @@ class ItemUpdate(ItemBase):
|
|||||||
|
|
||||||
# Database model, database table inferred from class name
|
# Database model, database table inferred from class name
|
||||||
class Item(ItemBase, table=True):
|
class Item(ItemBase, table=True):
|
||||||
id: int | None = Field(default=None, primary_key=True)
|
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||||
title: str = Field(max_length=255)
|
title: str = Field(max_length=255)
|
||||||
owner_id: int | None = Field(default=None, foreign_key="user.id", nullable=False)
|
owner_id: uuid.UUID = Field(foreign_key="user.id", nullable=False)
|
||||||
owner: User | None = Relationship(back_populates="items")
|
owner: User | None = Relationship(back_populates="items")
|
||||||
|
|
||||||
|
|
||||||
# Properties to return via API, id is always required
|
# Properties to return via API, id is always required
|
||||||
class ItemPublic(ItemBase):
|
class ItemPublic(ItemBase):
|
||||||
id: int
|
id: uuid.UUID
|
||||||
owner_id: int
|
owner_id: uuid.UUID
|
||||||
|
|
||||||
|
|
||||||
class ItemsPublic(SQLModel):
|
class ItemsPublic(SQLModel):
|
||||||
@@ -102,7 +104,7 @@ class Token(SQLModel):
|
|||||||
|
|
||||||
# Contents of JWT token
|
# Contents of JWT token
|
||||||
class TokenPayload(SQLModel):
|
class TokenPayload(SQLModel):
|
||||||
sub: int | None = None
|
sub: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class NewPassword(SQLModel):
|
class NewPassword(SQLModel):
|
||||||
|
@@ -1,3 +1,5 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
|
||||||
@@ -34,15 +36,15 @@ def test_read_item(
|
|||||||
content = response.json()
|
content = response.json()
|
||||||
assert content["title"] == item.title
|
assert content["title"] == item.title
|
||||||
assert content["description"] == item.description
|
assert content["description"] == item.description
|
||||||
assert content["id"] == item.id
|
assert content["id"] == str(item.id)
|
||||||
assert content["owner_id"] == item.owner_id
|
assert content["owner_id"] == str(item.owner_id)
|
||||||
|
|
||||||
|
|
||||||
def test_read_item_not_found(
|
def test_read_item_not_found(
|
||||||
client: TestClient, superuser_token_headers: dict[str, str]
|
client: TestClient, superuser_token_headers: dict[str, str]
|
||||||
) -> None:
|
) -> None:
|
||||||
response = client.get(
|
response = client.get(
|
||||||
f"{settings.API_V1_STR}/items/999",
|
f"{settings.API_V1_STR}/items/{uuid.uuid4()}",
|
||||||
headers=superuser_token_headers,
|
headers=superuser_token_headers,
|
||||||
)
|
)
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
@@ -91,8 +93,8 @@ def test_update_item(
|
|||||||
content = response.json()
|
content = response.json()
|
||||||
assert content["title"] == data["title"]
|
assert content["title"] == data["title"]
|
||||||
assert content["description"] == data["description"]
|
assert content["description"] == data["description"]
|
||||||
assert content["id"] == item.id
|
assert content["id"] == str(item.id)
|
||||||
assert content["owner_id"] == item.owner_id
|
assert content["owner_id"] == str(item.owner_id)
|
||||||
|
|
||||||
|
|
||||||
def test_update_item_not_found(
|
def test_update_item_not_found(
|
||||||
@@ -100,7 +102,7 @@ def test_update_item_not_found(
|
|||||||
) -> None:
|
) -> None:
|
||||||
data = {"title": "Updated title", "description": "Updated description"}
|
data = {"title": "Updated title", "description": "Updated description"}
|
||||||
response = client.put(
|
response = client.put(
|
||||||
f"{settings.API_V1_STR}/items/999",
|
f"{settings.API_V1_STR}/items/{uuid.uuid4()}",
|
||||||
headers=superuser_token_headers,
|
headers=superuser_token_headers,
|
||||||
json=data,
|
json=data,
|
||||||
)
|
)
|
||||||
@@ -141,7 +143,7 @@ def test_delete_item_not_found(
|
|||||||
client: TestClient, superuser_token_headers: dict[str, str]
|
client: TestClient, superuser_token_headers: dict[str, str]
|
||||||
) -> None:
|
) -> None:
|
||||||
response = client.delete(
|
response = client.delete(
|
||||||
f"{settings.API_V1_STR}/items/999",
|
f"{settings.API_V1_STR}/items/{uuid.uuid4()}",
|
||||||
headers=superuser_token_headers,
|
headers=superuser_token_headers,
|
||||||
)
|
)
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
@@ -1,3 +1,4 @@
|
|||||||
|
import uuid
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
@@ -105,7 +106,7 @@ def test_get_existing_user_permissions_error(
|
|||||||
client: TestClient, normal_user_token_headers: dict[str, str]
|
client: TestClient, normal_user_token_headers: dict[str, str]
|
||||||
) -> None:
|
) -> None:
|
||||||
r = client.get(
|
r = client.get(
|
||||||
f"{settings.API_V1_STR}/users/999999",
|
f"{settings.API_V1_STR}/users/{uuid.uuid4()}",
|
||||||
headers=normal_user_token_headers,
|
headers=normal_user_token_headers,
|
||||||
)
|
)
|
||||||
assert r.status_code == 403
|
assert r.status_code == 403
|
||||||
@@ -371,7 +372,7 @@ def test_update_user_not_exists(
|
|||||||
) -> None:
|
) -> None:
|
||||||
data = {"full_name": "Updated_full_name"}
|
data = {"full_name": "Updated_full_name"}
|
||||||
r = client.patch(
|
r = client.patch(
|
||||||
f"{settings.API_V1_STR}/users/99999999",
|
f"{settings.API_V1_STR}/users/{uuid.uuid4()}",
|
||||||
headers=superuser_token_headers,
|
headers=superuser_token_headers,
|
||||||
json=data,
|
json=data,
|
||||||
)
|
)
|
||||||
@@ -468,7 +469,7 @@ def test_delete_user_not_found(
|
|||||||
client: TestClient, superuser_token_headers: dict[str, str]
|
client: TestClient, superuser_token_headers: dict[str, str]
|
||||||
) -> None:
|
) -> None:
|
||||||
r = client.delete(
|
r = client.delete(
|
||||||
f"{settings.API_V1_STR}/users/99999999",
|
f"{settings.API_V1_STR}/users/{uuid.uuid4()}",
|
||||||
headers=superuser_token_headers,
|
headers=superuser_token_headers,
|
||||||
)
|
)
|
||||||
assert r.status_code == 404
|
assert r.status_code == 404
|
||||||
|
1153
backend/poetry.lock
generated
1153
backend/poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -20,7 +20,7 @@ jinja2 = "^3.1.4"
|
|||||||
alembic = "^1.12.1"
|
alembic = "^1.12.1"
|
||||||
httpx = "^0.25.1"
|
httpx = "^0.25.1"
|
||||||
psycopg = {extras = ["binary"], version = "^3.1.13"}
|
psycopg = {extras = ["binary"], version = "^3.1.13"}
|
||||||
sqlmodel = "^0.0.19"
|
sqlmodel = "^0.0.20"
|
||||||
# Pin bcrypt until passlib supports the latest
|
# Pin bcrypt until passlib supports the latest
|
||||||
bcrypt = "4.0.1"
|
bcrypt = "4.0.1"
|
||||||
pydantic-settings = "^2.2.1"
|
pydantic-settings = "^2.2.1"
|
||||||
|
Reference in New Issue
Block a user