Feat(API): Database schema and models defined

- Wrote tests for database migrations and populate with test data
This commit is contained in:
minhtrannhat 2022-12-23 19:57:21 -05:00
parent ad5596c61c
commit c641ddd47d
No known key found for this signature in database
GPG Key ID: 894C6A5801E01CA9
14 changed files with 269 additions and 1 deletions

View File

@ -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.

View File

@ -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

View File

@ -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"

View 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

View 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')"""
)

View File

View 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},
)

View 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},
)

View File

@ -1,7 +1,7 @@
from quart import Quart, ResponseReturnValue
import os
from subprocess import call # no sec
from subprocess import call # nosec
from urllib.parse import urlparse
# Each blueprint is a logical collection of features in our web app

View File

@ -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

View File

View 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

View 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