Feat(API): Database schema and models defined
- Wrote tests for database migrations and populate with test data
This commit is contained in:
parent
ad5596c61c
commit
c641ddd47d
@ -37,3 +37,10 @@
|
||||
#### Miscs Dev-deps
|
||||
|
||||
- `djhtml`: Generate jinja templates html for emails.
|
||||
|
||||
### Backend Technical Write-up
|
||||
|
||||
#### Difference between database schema and database model
|
||||
|
||||
- A schema defines the structure of data within the database.
|
||||
- A model is a class that can be represented as rows in the database, i.e ID row, age row as class member.
|
||||
|
@ -2,6 +2,7 @@ TODO_BASE_URL="localhost:5050"
|
||||
TODO_DEBUG=true
|
||||
TODO_SECRET_KEY="secret key"
|
||||
TODO_QUART_DB_DATABASE_URL="postgresql://todo:todo@0.0.0.0:5432/todo"
|
||||
TODO_QUART_DB_DATA_PATH="migrations/data.py"
|
||||
|
||||
# disable for when we not using HTTPS
|
||||
TODO_QUART_AUTH_COOKIE_SECURE=false
|
||||
|
@ -53,6 +53,10 @@ addopts = "--showlocals"
|
||||
asyncio_mode = "auto"
|
||||
pythonpath = ["src"]
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module =["h11"]
|
||||
ignore_missing_imports = true
|
||||
|
||||
[tool.pdm.scripts]
|
||||
format-black = "black src/ tests/"
|
||||
format-djhtml = "djhtml src/backend/templates -t 2 --in-place"
|
||||
|
31
backend/src/backend/migrations/0.py
Normal file
31
backend/src/backend/migrations/0.py
Normal file
@ -0,0 +1,31 @@
|
||||
from quart_db import Connection
|
||||
|
||||
|
||||
async def migrate(connection: Connection) -> None:
|
||||
await connection.execute(
|
||||
"""CREATE TABLE members (
|
||||
id INT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
created TIMESTAMP NOT NULL DEFAULT now(),
|
||||
email TEXT NOT NULL,
|
||||
email_verified TIMESTAMP,
|
||||
password_hash TEXT NOT NULL
|
||||
)""",
|
||||
)
|
||||
await connection.execute(
|
||||
"""CREATE UNIQUE INDEX members_unique_email_idx
|
||||
ON members (LOWER(email)
|
||||
)"""
|
||||
)
|
||||
await connection.execute(
|
||||
"""CREATE TABLE todos (
|
||||
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
complete BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
due TIMESTAMPTZ,
|
||||
member_id INT NOT NULL REFERENCES members(id),
|
||||
task TEXT NOT NULL
|
||||
)""",
|
||||
)
|
||||
|
||||
|
||||
async def valid_migration(connection: Connection) -> bool:
|
||||
return True
|
0
backend/src/backend/migrations/__init__.py
Normal file
0
backend/src/backend/migrations/__init__.py
Normal file
14
backend/src/backend/migrations/data.py
Normal file
14
backend/src/backend/migrations/data.py
Normal file
@ -0,0 +1,14 @@
|
||||
from quart_db import Connection
|
||||
|
||||
|
||||
async def execute(connection: Connection) -> None:
|
||||
await connection.execute(
|
||||
"""INSERT INTO members (email, password_hash)
|
||||
VALUES ('member@todo.minhtrannhat.com',
|
||||
'$2b$14$6yXjNza30kPCg3LhzZJfqeCWOLM.zyTiQFD4rdWlFHBTfYzzKJMJe'
|
||||
)"""
|
||||
)
|
||||
await connection.execute(
|
||||
"""INSERT INTO todos (member_id, task)
|
||||
VALUES (1, 'Test Task')"""
|
||||
)
|
0
backend/src/backend/models/__init__.py
Normal file
0
backend/src/backend/models/__init__.py
Normal file
60
backend/src/backend/models/member.py
Normal file
60
backend/src/backend/models/member.py
Normal file
@ -0,0 +1,60 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from quart_db import Connection
|
||||
|
||||
|
||||
@dataclass
|
||||
class Member:
|
||||
id: int
|
||||
email: str
|
||||
password_hash: str
|
||||
created: datetime
|
||||
email_verified: datetime | None
|
||||
|
||||
|
||||
async def select_member_by_email(db: Connection, email: str) -> Member | None:
|
||||
result = await db.fetch_one(
|
||||
"""SELECT id, email, password_hash, created, email_verified
|
||||
FROM members
|
||||
WHERE LOWER(email) = LOWER(:email)""",
|
||||
{"email": email},
|
||||
)
|
||||
return None if result is None else Member(**result)
|
||||
|
||||
|
||||
async def select_member_by_id(db: Connection, id: int) -> Member | None:
|
||||
result = await db.fetch_one(
|
||||
"""SELECT id, email, password_hash, created, email_verified
|
||||
FROM members
|
||||
WHERE id = :id""",
|
||||
{"id": id},
|
||||
)
|
||||
return None if result is None else Member(**result)
|
||||
|
||||
|
||||
async def insert_member(db: Connection, email: str, password_hash: str) -> Member:
|
||||
result = await db.fetch_one(
|
||||
"""INSERT INTO members (email, password_hash)
|
||||
VALUES (:email, :password_hash)
|
||||
RETURNING id, email, password_hash, created,
|
||||
email_verified""",
|
||||
{"email": email, "password_hash": password_hash},
|
||||
)
|
||||
return Member(**result)
|
||||
|
||||
|
||||
async def update_member_password(db: Connection, id: int, password_hash: str) -> None:
|
||||
await db.execute(
|
||||
"""UPDATE members
|
||||
SET password_hash = :password_hash
|
||||
WHERE id = :id""",
|
||||
{"id": id, "password_hash": password_hash},
|
||||
)
|
||||
|
||||
|
||||
async def update_member_email_verified(db: Connection, id: int) -> None:
|
||||
await db.execute(
|
||||
"UPDATE members SET email_verified = now() WHERE id = :id",
|
||||
{"id": id},
|
||||
)
|
102
backend/src/backend/models/todo.py
Normal file
102
backend/src/backend/models/todo.py
Normal file
@ -0,0 +1,102 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import constr
|
||||
from quart_db import Connection
|
||||
|
||||
|
||||
@dataclass
|
||||
class Todo:
|
||||
complete: bool
|
||||
due: datetime | None
|
||||
id: int
|
||||
task: constr(strip_whitespace=True, min_length=1) # type: ignore
|
||||
|
||||
|
||||
async def select_todos(
|
||||
connection: Connection,
|
||||
member_id: int,
|
||||
complete: bool | None = None,
|
||||
) -> list[Todo]:
|
||||
if complete is None:
|
||||
query = """SELECT id, complete, due, task
|
||||
FROM todos
|
||||
WHERE member_id = :member_id"""
|
||||
values = {"member_id": member_id}
|
||||
else:
|
||||
query = """SELECT id, complete, due, task
|
||||
FROM todos
|
||||
WHERE member_id = :member_id
|
||||
AND complete = :complete"""
|
||||
values = {"member_id": member_id, "complete": complete}
|
||||
return [Todo(**row) async for row in connection.iterate(query, values)]
|
||||
|
||||
|
||||
async def select_todo(
|
||||
connection: Connection,
|
||||
id: int,
|
||||
member_id: int,
|
||||
) -> Todo | None:
|
||||
result = await connection.fetch_one(
|
||||
"""SELECT id, complete, due, task
|
||||
FROM todos
|
||||
WHERE id = :id AND member_id = :member_id""",
|
||||
{"id": id, "member_id": member_id},
|
||||
)
|
||||
return None if result is None else Todo(**result)
|
||||
|
||||
|
||||
async def insert_todo(
|
||||
connection: Connection,
|
||||
member_id: int,
|
||||
task: str,
|
||||
complete: bool,
|
||||
due: datetime | None,
|
||||
) -> Todo:
|
||||
result = await connection.fetch_one(
|
||||
"""INSERT INTO todos (complete, due, member_id, task)
|
||||
VALUES (:complete, :due, :member_id, :task)
|
||||
RETURNING id, complete, due, task""",
|
||||
{
|
||||
"member_id": member_id,
|
||||
"task": task,
|
||||
"complete": complete,
|
||||
"due": due,
|
||||
},
|
||||
)
|
||||
return Todo(**result)
|
||||
|
||||
|
||||
async def update_todo(
|
||||
connection: Connection,
|
||||
id: int,
|
||||
member_id: int,
|
||||
task: str,
|
||||
complete: bool,
|
||||
due: datetime | None,
|
||||
) -> Todo | None:
|
||||
result = await connection.fetch_one(
|
||||
"""UPDATE todos
|
||||
SET complete = :complete, due = :due, task = :task
|
||||
WHERE id = :id AND member_id = :member_id
|
||||
RETURNING id, complete, due, task""",
|
||||
{
|
||||
"id": id,
|
||||
"member_id": member_id,
|
||||
"task": task,
|
||||
"complete": complete,
|
||||
"due": due,
|
||||
},
|
||||
)
|
||||
return None if result is None else Todo(**result)
|
||||
|
||||
|
||||
async def delete_todo(
|
||||
connection: Connection,
|
||||
id: int,
|
||||
member_id: int,
|
||||
) -> None:
|
||||
await connection.execute(
|
||||
"DELETE FROM todos WHERE id = :id AND member_id = :member_id",
|
||||
{"id": id, "member_id": member_id},
|
||||
)
|
@ -3,6 +3,7 @@ TODO_DEBUG=true
|
||||
TODO_SECRET_KEY="secret key"
|
||||
TODO_TESTING=true
|
||||
TODO_QUART_DB_DATABASE_URL="postgresql://todo_test:todo_test@0.0.0.0:5432/todo_test"
|
||||
TODO_QUART_DB_DATA_PATH="migrations/data.py"
|
||||
|
||||
# disable for when we not using HTTPS
|
||||
TODO_QUART_AUTH_COOKIE_SECURE=false
|
||||
|
0
backend/tests/models/__init__.py
Normal file
0
backend/tests/models/__init__.py
Normal file
17
backend/tests/models/test_member.py
Normal file
17
backend/tests/models/test_member.py
Normal file
@ -0,0 +1,17 @@
|
||||
import pytest
|
||||
from asyncpg.exceptions import UniqueViolationError # type: ignore
|
||||
from quart_db import Connection
|
||||
|
||||
from backend.models.member import insert_member, select_member_by_email
|
||||
|
||||
|
||||
async def test_insert_member(connection: Connection) -> None:
|
||||
await insert_member(connection, "casing@todo.minhtrannhat.com", "")
|
||||
with pytest.raises(UniqueViolationError):
|
||||
await insert_member(connection, "Casing@todo.minhtrannhat.com", "")
|
||||
|
||||
|
||||
async def test_select_member_by_email(connection: Connection) -> None:
|
||||
await insert_member(connection, "casing@todo.minhtrannhat.com", "")
|
||||
member = await select_member_by_email(connection, "Casing@todo.minhtrannhat.com")
|
||||
assert member is not None
|
31
backend/tests/models/test_todo.py
Normal file
31
backend/tests/models/test_todo.py
Normal file
@ -0,0 +1,31 @@
|
||||
import pytest
|
||||
from quart_db import Connection
|
||||
|
||||
from backend.models.todo import delete_todo, insert_todo, select_todo, update_todo
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"member_id, deleted",
|
||||
[(1, True), (2, False)],
|
||||
)
|
||||
async def test_delete_todo(
|
||||
connection: Connection, member_id: int, deleted: bool
|
||||
) -> None:
|
||||
todo = await insert_todo(connection, 1, "Task", False, None)
|
||||
await delete_todo(connection, todo.id, member_id)
|
||||
new_todo = await select_todo(connection, todo.id, 1)
|
||||
assert (new_todo is None) is deleted
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"member_id, complete",
|
||||
[(1, True), (2, False)],
|
||||
)
|
||||
async def test_update_todo(
|
||||
connection: Connection, member_id: int, complete: bool
|
||||
) -> None:
|
||||
todo = await insert_todo(connection, 1, "Task", False, None)
|
||||
await update_todo(connection, todo.id, member_id, "Task", True, None)
|
||||
new_todo = await select_todo(connection, todo.id, 1)
|
||||
assert new_todo is not None
|
||||
assert new_todo.complete is complete
|
Loading…
x
Reference in New Issue
Block a user