Skip to content

Commit aea2f0e

Browse files
committed
✨ Add (module): Database CRUD operations and SA model representation
1 parent fc39573 commit aea2f0e

File tree

6 files changed

+302
-5
lines changed

6 files changed

+302
-5
lines changed

app/config/exceptions.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
"""
2+
This module defines custom exception classes for the Core
3+
"""
4+
5+
from sqlalchemy.exc import SQLAlchemyError
6+
7+
8+
class DatabaseException(SQLAlchemyError): # type: ignore
9+
"""
10+
Database Exception class
11+
"""
12+
13+
def __init__(self, message: str, note: str | None = None):
14+
super().__init__(message)
15+
if note:
16+
self.add_note(note)

app/crud/user.py

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
"""
2+
This script handles CRUD (Create, Read, Update, Delete) operations for
3+
User objects in the database.
4+
"""
5+
6+
import logging
7+
from datetime import UTC, datetime
8+
from typing import Any, Sequence
9+
10+
from pydantic import NonNegativeInt, PositiveInt
11+
from sqlalchemy import Row, RowMapping, select
12+
from sqlalchemy.engine import ScalarResult
13+
from sqlalchemy.exc import SQLAlchemyError
14+
from sqlalchemy.orm import Session
15+
from sqlalchemy.sql import Select
16+
17+
from app.config.exceptions import DatabaseException
18+
from app.db.session import get_session
19+
from app.models.user import User
20+
from app.schemas.user import UserCreate, UserUpdate
21+
22+
logger: logging.Logger = logging.getLogger(__name__)
23+
24+
25+
class UserRepository:
26+
"""
27+
This class handles all operations (CRUD) related to a User in the
28+
database.
29+
"""
30+
31+
def __init__(
32+
self,
33+
session: Session,
34+
):
35+
self.session: Session = session
36+
37+
def read_by_id(self, _id: PositiveInt) -> User | None:
38+
"""
39+
Retrieve a user from the database by its id
40+
:param _id: The id of the user
41+
:type _id: IdSpecification
42+
:return: The user with the specified id, or None if no such
43+
user exists
44+
:rtype: Optional[User]
45+
"""
46+
with self.session as session:
47+
stmt: Select[Any]
48+
stmt = select(User).where(User.id == _id)
49+
try:
50+
db_obj: Row[Any] | RowMapping = (session.scalars(stmt)).one()
51+
if not isinstance(db_obj, User):
52+
raise ValueError("Retrieved object is not a User instance")
53+
except SQLAlchemyError as sa_exc:
54+
logger.error(sa_exc)
55+
logger.info("Retrieving row with id: %s", _id)
56+
raise DatabaseException(str(sa_exc)) from sa_exc
57+
return User(db_obj)
58+
59+
def read_users(
60+
self,
61+
offset: NonNegativeInt,
62+
limit: PositiveInt,
63+
) -> list[User]:
64+
"""
65+
Retrieve a list of users from the database, with pagination
66+
:param offset: The number of users to skip before starting to
67+
return users
68+
:type offset: NonNegativeInt
69+
:param limit: The maximum number of users to return
70+
:type limit: PositiveInt
71+
:return: A list of users
72+
:rtype: list[User]
73+
"""
74+
stmt: Select[tuple[User]] = select(User).offset(offset).limit(limit)
75+
with self.session as session:
76+
try:
77+
scalar_result: ScalarResult[User] = session.scalars(stmt)
78+
all_results: Sequence[Row[User] | RowMapping | Any] = (
79+
scalar_result.all()
80+
)
81+
users: list[User] = [User(result) for result in all_results]
82+
except SQLAlchemyError as sa_exc:
83+
logger.error(sa_exc)
84+
raise DatabaseException(str(sa_exc)) from sa_exc
85+
return users
86+
87+
def create_user(
88+
self,
89+
user: UserCreate,
90+
) -> User:
91+
"""
92+
Create a new user in the database.
93+
:param user: An object containing the information of the user
94+
to create
95+
:type user: UserCreate
96+
:return: The created user object
97+
:rtype: User
98+
"""
99+
user_data: dict[str, Any] = user.model_dump()
100+
user_create: User = User(**user_data)
101+
with self.session as session:
102+
try:
103+
session.add(user_create)
104+
session.commit()
105+
except SQLAlchemyError as sa_exc:
106+
logger.error(sa_exc)
107+
session.rollback()
108+
raise DatabaseException(str(sa_exc)) from sa_exc
109+
if created_user := self.read_by_id(user_create.id):
110+
return created_user
111+
else:
112+
raise DatabaseException("User could not be created")
113+
114+
def update_user(self, _id: PositiveInt, user: UserUpdate) -> User | None:
115+
"""
116+
Update the information of a user in the database
117+
:param _id: The id of the user to update
118+
:type _id: PositiveInt
119+
:param user: An object containing the new information of the
120+
user
121+
:type user: UserUpdate
122+
:return: The updated user, or None if no such user exists
123+
:rtype: Optional[User]
124+
"""
125+
with self.session as session:
126+
try:
127+
found_user: User | None = self.read_by_id(_id)
128+
except DatabaseException as db_exc:
129+
logger.error(db_exc)
130+
raise DatabaseException(str(db_exc)) from db_exc
131+
if not found_user:
132+
raise DatabaseException(
133+
f"User with ID: {_id} could not be updated"
134+
)
135+
update_data: dict[str, Any] = user.model_dump(exclude_unset=True)
136+
for field, value in update_data.items():
137+
if value is not None:
138+
setattr(found_user, field, value)
139+
found_user.updated_at = datetime.now(UTC)
140+
session.add(found_user)
141+
session.commit()
142+
try:
143+
updated_user: User | None = self.read_by_id(_id)
144+
except DatabaseException as db_exc:
145+
logger.error(db_exc)
146+
raise DatabaseException(str(db_exc)) from db_exc
147+
return updated_user
148+
149+
def delete_user(self, _id: PositiveInt) -> bool:
150+
"""
151+
Delete a user from the database
152+
:param _id: The id of the user to delete
153+
:type _id: PositiveInt
154+
:return: True if the user is deleted; otherwise False
155+
:rtype: bool
156+
"""
157+
with self.session as session:
158+
try:
159+
found_user: User | None = self.read_by_id(_id)
160+
except DatabaseException as db_exc:
161+
raise DatabaseException(str(db_exc)) from db_exc
162+
if not found_user:
163+
raise DatabaseException(
164+
f"User with ID: {_id} could not be deleted"
165+
)
166+
try:
167+
session.delete(found_user)
168+
session.commit()
169+
except SQLAlchemyError as sa_exc:
170+
logger.error(sa_exc)
171+
session.rollback()
172+
return False
173+
return True
174+
175+
176+
def get_user_repository() -> UserRepository:
177+
"""
178+
Create a UserRepository with a database session, an index
179+
filter, and a unique filter.
180+
:return: A UserRepository instance
181+
:rtype: UserRepository
182+
"""
183+
return UserRepository(
184+
get_session(),
185+
)

app/db/session.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
engine: Engine = create_engine(url, pool_pre_ping=True, future=True, echo=True)
1212

1313

14-
async def get_session() -> Session:
14+
def get_session() -> Session:
1515
"""
1616
Get an asynchronous session to the database
1717
:return session: Session for database connection

app/models/user.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"""
2+
A module for user in the app models package.
3+
"""
4+
5+
from datetime import datetime
6+
7+
from pydantic import EmailStr, PastDate, PositiveInt
8+
from pydantic_extra_types.phone_numbers import PhoneNumber
9+
from sqlalchemy import CheckConstraint, Date, Integer, String, text
10+
from sqlalchemy.dialects.postgresql import TIMESTAMP
11+
from sqlalchemy.orm import Mapped, mapped_column
12+
13+
from app.config.settings import setting
14+
from app.db.base import Base
15+
16+
17+
class User(Base): # type: ignore
18+
"""
19+
User model class representing the "users" table
20+
"""
21+
22+
__tablename__ = "users"
23+
24+
id: Mapped[PositiveInt] = mapped_column(
25+
Integer,
26+
nullable=False,
27+
primary_key=True,
28+
autoincrement=True,
29+
index=True,
30+
unique=True,
31+
comment="ID of the User",
32+
)
33+
username: Mapped[str] = mapped_column(
34+
String(15),
35+
index=True,
36+
unique=True,
37+
nullable=False,
38+
comment="Username to identify the user",
39+
)
40+
email: Mapped[EmailStr] = mapped_column(
41+
String(320),
42+
index=True,
43+
unique=True,
44+
nullable=False,
45+
comment="Preferred e-mail address of the User",
46+
)
47+
password: Mapped[str] = mapped_column(
48+
String(60), nullable=False, comment="Hashed password of the User"
49+
)
50+
birthdate: Mapped[PastDate] = mapped_column(
51+
Date, nullable=True, comment="Birthday of the User"
52+
)
53+
phone_number: Mapped[PhoneNumber] = mapped_column(
54+
String(20),
55+
nullable=True,
56+
comment="Preferred telephone number of the User",
57+
)
58+
created_at: Mapped[datetime] = mapped_column(
59+
TIMESTAMP(timezone=True, precision=setting.TIMESTAMP_PRECISION),
60+
default=datetime.now(),
61+
nullable=False,
62+
server_default=text("now()"),
63+
comment="Time the User was created",
64+
)
65+
updated_at: Mapped[datetime] = mapped_column(
66+
TIMESTAMP(timezone=True, precision=setting.TIMESTAMP_PRECISION),
67+
nullable=True,
68+
onupdate=text("now()"),
69+
comment="Time the User was updated",
70+
)
71+
72+
__table_args__ = (
73+
CheckConstraint(
74+
"char_length(username) >= 4", name="users_username_length"
75+
),
76+
CheckConstraint("char_length(email) >= 3", name="users_email_length"),
77+
CheckConstraint(setting.DB_EMAIL_CONSTRAINT, name="users_email_format"),
78+
CheckConstraint("LENGTH(password) = 60", name="users_password_length"),
79+
CheckConstraint(
80+
setting.DB_PHONE_NUMBER_CONSTRAINT,
81+
name="users_phone_number_format",
82+
),
83+
)

app/schemas/user.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
"""
2+
A module for user in the app-schemas package.
3+
"""
4+
5+
from pydantic import BaseModel
6+
7+
8+
class UserCreate(BaseModel):
9+
pass
10+
11+
12+
class UserUpdate(BaseModel):
13+
pass

poetry.lock

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)