diff --git a/aulas/04.md b/aulas/04.md index 6e7d86b9..b05aa478 100644 --- a/aulas/04.md +++ b/aulas/04.md @@ -8,12 +8,13 @@ description: Criação de um modelo usando SQLAlchemy e migrações --- Objetivos dessa aula: -- Introdução ao SQLAlchemy e Alembic -- Instalando SQLAlchemy e Alembic -- Configurando e criando o banco de dados -- Criando e localizando tabelas utilizando SQLAlchemy -- Testando a criação de tabelas -- Gerenciando migrações do banco de dados com Alembic +- Introdução ao SQLAlchemy e Alembic +- Instalando SQLAlchemy e Alembic +- Configurando e criando o banco de dados +- Criando e localizando tabelas utilizando SQLAlchemy +- Testando a criação de tabelas +- Eventos do SQLAlchemy +- Gerenciando migrações do banco de dados com Alembic {%set aula = "04" %} {%set link = "_87z5b4szW4" %} @@ -219,7 +220,9 @@ A essa altura, se estivéssemos buscando apenas cobertura, poderíamos simplesme graph A[Aplicativo Python] -- utiliza --> B[SQLAlchemy ORM] B -- fornece --> D[Session] + D -- eventos --> D D -- interage com --> C[Modelos] + C -- eventos --> C C -- mapeados para --> G[Tabelas no Banco de Dados] D -- depende de --> E[Engine] E -- conecta-se com --> F[Banco de Dados] @@ -324,7 +327,165 @@ TOTAL 54 2 96% Neste caso, podemos ver que todos os nossos testes passaram com sucesso. Isso significa que nossa funcionalidade de criação de usuário está funcionando corretamente e que nosso modelo de usuário está sendo corretamente persistido no banco de dados. -Com nossos modelos e testes de banco de dados agora em ordem, estamos prontos para avançar para a próxima fase de configuração de nosso banco de dados e gerenciamento de migrações. +Embora tudo esteja se encaixando bem, esse teste não é muito legal, pois não faz a validação do objeto como um todo. Conseguimos garantir que toda a estrutura do bando de dados funciona, porém, não conseguimos garantir ainda que todos os valores estão corretos. + +## Eventos do ORM + +Embora nossos testes tenham sido executados corretamente, temos um problema se quisermos validar o objeto como um todo, por existirem alguns campos da tabela que fogem do mecanismo da criação do objeto `#!python (init=False)`. + +Um desses casos é o campo `created_at`. Quando configuramos o modelo, deixamos que o banco de dados defina seu horário e data atual para preencher esse campo. Será que existe uma forma de alterar esse comportamento durante os testes? Pra podermos validar quando o objeto foi criado? A resposta é sim. + +O SQLAlchemy tem um sistema de eventos. Eventos são blocos de código que podem ser inseridos ou removidos antes e depois de uma operação. + +```mermaid +flowchart TD + subgraph Operação + direction LR + A[Hook] --> B[Operação] + B --> C[Hook] + end +``` + +Isso nos permite modificar os dados antes ou depois de determinadas operações serem executadas pelo SQLAlchemy. + +Por exemplo, nosso modelo de `User` não permite que sejam enviados os campos `id` e `created_at` no momento em que a instância de `User` é criada. Por conta da restrição `init=False` no `mapped_column`. + +Escrever testes essa restrição pode nos trazer algumas dificuldades no momento das validações (asserts). Então vamos programar um evento para acontecer **antes** que o dado seja inserido no banco de dados. + +```mermaid +flowchart TD + commit --> Z["Inserir registro no banco (operação)"] + subgraph Z["Inserir registro no banco (operação)"] + direction LR + A[Hook - before_insert] --> B[insert] + end +``` + +Um *hook* é basicamente uma função python que registramos como um evento no sqlalchemy. Nesse caso, como queremos um evento de insert, devemos fornecer o modelo que queremos que seja atrelado ao evento: + +```py title="Código de exemplo" linenums="1" hl_lines="4 8" +from sqlalchemy import event + + +def hook(mapper, connection, target): #(1)! + ... + + +event.listen(User, 'before_insert', hook) #(2)! +``` + +1. Qualquer função que for usada como um hook do evento de `before_insert` tem que receber os parâmetros `mapper`, `connextion` e `target`, mesmo que não os use. +2. Nesse exemplo o evento "ouvirá" [*listen*] o modelo `User` e toda vez que o ORM for inserir um registro desse modelo no banco (`before_insert`) ele executará a função `hook`. + +A ideia por trás dos eventos é simplesmente passar algum modelo ou a sessão para que o ORM observe todas às vezes em que uma determinada operação foi executada e se ela tem algum hook sendo "ouvido" para aquela operação. Falando de forma clara, todas às vezes que `User` for inserido na base, antes disso a função `hook` será executada. + +> Você pode buscar por outros eventos de mapeamento na [Documentação do SQLAlchemy](https://docs.sqlalchemy.org/en/20/orm/events.html#mapper-events){:target="_blank"} + +### Evento para manipular o tempo + +Para fazer a validação de todos os campos do objeto durante os testes, podemos criar um evento que será executado durante o teste que faça que com os registros inseridos nesse teste tenham o horário manipulado, facilitando a comparação com um `created_at` fixo: + +```python title="tests/conftest.py" hl_lines="9 16 20" +from contextlib import contextmanager +from datetime import datetime + +# ... +from sqlalchemy import create_engine, event + +# ... + +@contextmanager #(1)! +def _mock_db_time(*, model, time=datetime(2024, 1, 1)): #(2)! + + def fake_time_hook(mapper, connection, target): #(3)! + if hasattr(target, 'created_at'): + target.created_at = time + + event.listen(model, 'before_insert', fake_time_hook) #(4)! + + yield time #(5)! + + event.remove(model, 'before_insert', fake_time_hook) #(6)! +``` + +1. O decorador `#!python @contextmanager` cria um gerenciador de contexto para que a função `_mock_db_time` seja usada com um bloco `#!python with`. Caso você não tenha experiência com gerenciadores de contexto, você pode assistir a [essa Live](https://youtu.be/fR73UVNXb04){:target="_blank"}. +2. Todos os parâmetros após `*` devem ser chamados de forma nomeada, para ficarem explícitos na função. Ou seja `mock_db_time(model=User)`. Os parâmetros não podem ser chamados de forma posicional `_mock_db_time(User)`, isso acarretará em um erro. +3. Função para alterar alterar o método `created_at` do objeto de target. +4. `event.listen` adiciona um evento relação a um `model` que será passado a função. Esse evento é o `before_insert`, ele executará uma função (hook) antes de inserir o registro no banco de dados. O hook é a função `fake_time_handler`. +5. Retorna o datetime na abertura do gerenciamento de contexto. +6. Após o final do gerenciamento de contexto o hook dos eventos é removido. + +A ideia por trás dessa função é ser um gerenciador de contexto (para ser chamado em um bloco `#!python with`). Toda vezes que um registro de `model` for inserido no banco de dados, se ele tiver o campo `created_at`, por padrão, o campo será cadastrado com a sua data pré-fixada '01/01/2024'. Facilitando a manutenção dos testes que precisam da comparação de data, pois será determinística. + +#### Transformando o evento em uma fixture + +Agora que temos a função gerenciadora de contexto, para evitar o sistema de importação durante os testes, podemos criar uma fixture para ele. De forma bem simples, somente retornando a função `_mock_db_time`: + +```python title="tests/conftest.py" +@pytest.fixture +def mock_db_time(): + return _mock_db_time +``` + +Dessa forma podemos fazer a chamada direta no teste. + +### Adicionando o evento ao teste + +Agora que temos uma fixture para tratar o caso da data de criação, podemos fazer a comparação do objeto completo: + +```python title="tests/test_db.py" linenums="1" hl_lines="1 9 18 23" +from dataclasses import asdict + +from sqlalchemy import select + +from fast_zero.models import User + + +def test_create_user(session, mock_db_time): + with mock_db_time(model=User) as time: #(1)! + new_user = User( + username='alice', password='secret', email='teste@test' + ) + session.add(new_user) + session.commit() + + user = session.scalar(select(User).where(User.username == 'alice')) + + assert asdict(user) == { #(2)! + 'id': 1, + 'username': 'alice', + 'password': 'secret', + 'email': 'teste@test', + 'created_at': time, #(3)! + } +``` + +1. Inicia o gerenciador de contexto `mock_db_time` usando o modelo `User` como base. +2. Converte o user em um dicionário para simplificar a validação no teste. +3. Usa o time gerado por `mock_db_time` para validar o campo `created_at`. + +O teste permanece praticamente igual, com a diferença de que todas as operações envolvendo a criação de `User` no banco de dados acontecem no escopo de `mock_db_time`. + +Isso faz com que durante o `commit`, quando os objetos são persistidos da sessão para o banco de dados, o evento de `before_insert` seja executado para cada objeto do modelo passado em `mock_db_time(model=*MODEL*)`. + +Por conta do campo `created_at` agora ser determinístico podemos fazer uma comparação completa dos campos. + +Para simplificar a comparação de todos os campos, como nossos objetos de modelo são dataclasses, a função `dataclass.asdict()`, converte uma dataclass para um dicionário: + +```python title="Estudando a comparação" + assert asdict(user) == { + 'id': 1, + 'username': 'alice', + 'password': 'secret', + 'email': 'teste@test', + 'created_at': time, + } +``` + +Como o tempo agora é determinístico e contido no nosso gerenciador de contexto, podemos fazer a comparação exata entre todos os campos. Inclusive `created_at`. + +Desta forma, nossos modelos e testes de banco de dados agora em ordem, estamos prontos para avançar para a próxima fase de configuração de nosso banco de dados e gerenciamento de migrações. + ## Configuração do ambiente do banco de dados @@ -692,8 +853,9 @@ E pronto! As mudanças que fizemos foram salvas no histórico do Git e agora est ```python mapped_column(onupdate=func.now()) ``` -2. Criar uma nova migração autogerada com alembic -3. Aplicar essa migração ao banco de dados +2. Altere o evento de testes (`mock_db_time`) para ser contemplado no mock o campo `updated_at` na validação do teste. +3. Criar uma nova migração autogerada com alembic +4. Aplicar essa migração ao banco de dados {% include "templates/exercicios.md" %} diff --git a/aulas/exercicios_resolvidos/aula_04.md b/aulas/exercicios_resolvidos/aula_04.md index fed692fe..96b5f289 100644 --- a/aulas/exercicios_resolvidos/aula_04.md +++ b/aulas/exercicios_resolvidos/aula_04.md @@ -33,6 +33,55 @@ class User: ## Exercício 02 +Altere o evento de testes (`mock_db_time`) para ser contemplado no mock o campo `updated_at` na validação do teste. + +### Solução + +A ideia é adicionar mais um campo na verificação do modelo, para que o update também esteja um horário determinístico: + +```python hl_lines="7 8" +@contextmanager +def _mock_db_time(*, model, time=datetime(2024, 1, 1)): + + def fake_time_handler(mapper, connection, target): + if hasattr(target, 'created_at'): + target.created_at = time + if hasattr(target, 'updated_at'): + target.updated_at = time + + event.listen(model, 'before_insert', fake_time_handler) + + yield time + + event.remove(model, 'before_insert', fake_time_handler) +``` + +Com a alteração do modelo, o teste também passará a falhar. Isso pode ser modificado adicionando o campo `updated_at` no dicionário de validação: + +```python hl_lines="17" +def test_create_user(session, mock_db_time): + with mock_db_time(model=User) as time: + new_user = User( + username='alice', password='secret', email='teste@test' + ) + session.add(new_user) + session.commit() + + user = session.scalar(select(User).where(User.username == 'alice')) + + assert asdict(user) == { + 'id': 1, + 'username': 'alice', + 'password': 'secret', + 'email': 'teste@test', + 'created_at': time, + 'updated_at': time, + } +``` + + +## Exercício 03 + Criar uma nova migração autogerada com alembic. ### Solução @@ -90,7 +139,7 @@ def downgrade() -> None: 1. Adiciona a coluna `updated_at` na tabela `users` 2. Remove a coluna `updated_at` na tabela `users` -## Exercício 03 +## Exercício 04 Aplicar essa migração ao banco de dados diff --git a/codigo_das_aulas/04/tests/conftest.py b/codigo_das_aulas/04/tests/conftest.py index 96aa741f..037a178a 100644 --- a/codigo_das_aulas/04/tests/conftest.py +++ b/codigo_das_aulas/04/tests/conftest.py @@ -1,6 +1,9 @@ +from contextlib import contextmanager +from datetime import datetime + import pytest from fastapi.testclient import TestClient -from sqlalchemy import create_engine +from sqlalchemy import create_engine, event from sqlalchemy.orm import Session from fast_zero.app import app @@ -21,3 +24,24 @@ def session(): yield session table_registry.metadata.drop_all(engine) + + +@contextmanager +def _mock_db_time(*, model, time=datetime(2024, 1, 1)): + + def fake_time_handler(mapper, connection, target): + if hasattr(target, 'created_at'): + target.created_at = time + if hasattr(target, 'updated_at'): + target.updated_at = time + + event.listen(model, 'before_insert', fake_time_handler) + + yield time + + event.remove(model, 'before_insert', fake_time_handler) + + +@pytest.fixture +def mock_db_time(): + return _mock_db_time diff --git a/codigo_das_aulas/04/tests/test_db.py b/codigo_das_aulas/04/tests/test_db.py index 794503ac..f6a3a425 100644 --- a/codigo_das_aulas/04/tests/test_db.py +++ b/codigo_das_aulas/04/tests/test_db.py @@ -1,13 +1,25 @@ +from dataclasses import asdict + from sqlalchemy import select from fast_zero.models import User -def test_create_user(session): - new_user = User(username='alice', password='secret', email='teste@test') - session.add(new_user) - session.commit() +def test_create_user(session, mock_db_time): + with mock_db_time(model=User) as time: + new_user = User( + username='alice', password='secret', email='teste@test' + ) + session.add(new_user) + session.commit() user = session.scalar(select(User).where(User.username == 'alice')) - assert user.username == 'alice' + assert asdict(user) == { + 'id': 1, + 'username': 'alice', + 'password': 'secret', + 'email': 'teste@test', + 'created_at': time, + 'updated_at': time, # Exercício + } diff --git a/codigo_das_aulas/05/fast_zero/models.py b/codigo_das_aulas/05/fast_zero/models.py index 4985989b..7a45efd0 100644 --- a/codigo_das_aulas/05/fast_zero/models.py +++ b/codigo_das_aulas/05/fast_zero/models.py @@ -17,3 +17,8 @@ class User: created_at: Mapped[datetime] = mapped_column( init=False, server_default=func.now() ) + + # Exercício + updated_at: Mapped[datetime] = mapped_column( + init=False, server_default=func.now(), onupdate=func.now() + ) diff --git a/codigo_das_aulas/05/tests/conftest.py b/codigo_das_aulas/05/tests/conftest.py index 5bf2b4d4..d58fe83f 100644 --- a/codigo_das_aulas/05/tests/conftest.py +++ b/codigo_das_aulas/05/tests/conftest.py @@ -1,6 +1,9 @@ +from contextlib import contextmanager +from datetime import datetime + import pytest from fastapi.testclient import TestClient -from sqlalchemy import create_engine +from sqlalchemy import create_engine, event from sqlalchemy.orm import Session from sqlalchemy.pool import StaticPool @@ -36,6 +39,27 @@ def session(): table_registry.metadata.drop_all(engine) +@contextmanager +def _mock_db_time(*, model, time=datetime(2024, 1, 1)): + + def fake_time_handler(mapper, connection, target): + if hasattr(target, 'created_at'): + target.created_at = time + if hasattr(target, 'updated_at'): + target.updated_at = time + + event.listen(model, 'before_insert', fake_time_handler) + + yield time + + event.remove(model, 'before_insert', fake_time_handler) + + +@pytest.fixture +def mock_db_time(): + return _mock_db_time + + @pytest.fixture def user(session): user = User(username='Teste', email='teste@test.com', password='testtest') diff --git a/codigo_das_aulas/05/tests/test_db.py b/codigo_das_aulas/05/tests/test_db.py index 794503ac..f6a3a425 100644 --- a/codigo_das_aulas/05/tests/test_db.py +++ b/codigo_das_aulas/05/tests/test_db.py @@ -1,13 +1,25 @@ +from dataclasses import asdict + from sqlalchemy import select from fast_zero.models import User -def test_create_user(session): - new_user = User(username='alice', password='secret', email='teste@test') - session.add(new_user) - session.commit() +def test_create_user(session, mock_db_time): + with mock_db_time(model=User) as time: + new_user = User( + username='alice', password='secret', email='teste@test' + ) + session.add(new_user) + session.commit() user = session.scalar(select(User).where(User.username == 'alice')) - assert user.username == 'alice' + assert asdict(user) == { + 'id': 1, + 'username': 'alice', + 'password': 'secret', + 'email': 'teste@test', + 'created_at': time, + 'updated_at': time, # Exercício + } diff --git a/codigo_das_aulas/06/fast_zero/models.py b/codigo_das_aulas/06/fast_zero/models.py index 4985989b..7a45efd0 100644 --- a/codigo_das_aulas/06/fast_zero/models.py +++ b/codigo_das_aulas/06/fast_zero/models.py @@ -17,3 +17,8 @@ class User: created_at: Mapped[datetime] = mapped_column( init=False, server_default=func.now() ) + + # Exercício + updated_at: Mapped[datetime] = mapped_column( + init=False, server_default=func.now(), onupdate=func.now() + ) diff --git a/codigo_das_aulas/06/tests/conftest.py b/codigo_das_aulas/06/tests/conftest.py index e53d190e..8c80d5aa 100644 --- a/codigo_das_aulas/06/tests/conftest.py +++ b/codigo_das_aulas/06/tests/conftest.py @@ -1,6 +1,9 @@ +from contextlib import contextmanager +from datetime import datetime + import pytest from fastapi.testclient import TestClient -from sqlalchemy import create_engine +from sqlalchemy import create_engine, event from sqlalchemy.orm import Session from sqlalchemy.pool import StaticPool @@ -37,6 +40,27 @@ def session(): table_registry.metadata.drop_all(engine) +@contextmanager +def _mock_db_time(*, model, time=datetime(2024, 1, 1)): + + def fake_time_handler(mapper, connection, target): + if hasattr(target, 'created_at'): + target.created_at = time + if hasattr(target, 'updated_at'): + target.updated_at = time + + event.listen(model, 'before_insert', fake_time_handler) + + yield time + + event.remove(model, 'before_insert', fake_time_handler) + + +@pytest.fixture +def mock_db_time(): + return _mock_db_time + + @pytest.fixture def user(session): user = User( diff --git a/codigo_das_aulas/06/tests/test_db.py b/codigo_das_aulas/06/tests/test_db.py index 794503ac..f6a3a425 100644 --- a/codigo_das_aulas/06/tests/test_db.py +++ b/codigo_das_aulas/06/tests/test_db.py @@ -1,13 +1,25 @@ +from dataclasses import asdict + from sqlalchemy import select from fast_zero.models import User -def test_create_user(session): - new_user = User(username='alice', password='secret', email='teste@test') - session.add(new_user) - session.commit() +def test_create_user(session, mock_db_time): + with mock_db_time(model=User) as time: + new_user = User( + username='alice', password='secret', email='teste@test' + ) + session.add(new_user) + session.commit() user = session.scalar(select(User).where(User.username == 'alice')) - assert user.username == 'alice' + assert asdict(user) == { + 'id': 1, + 'username': 'alice', + 'password': 'secret', + 'email': 'teste@test', + 'created_at': time, + 'updated_at': time, # Exercício + } diff --git a/codigo_das_aulas/07/fast_zero/models.py b/codigo_das_aulas/07/fast_zero/models.py index 4985989b..7a45efd0 100644 --- a/codigo_das_aulas/07/fast_zero/models.py +++ b/codigo_das_aulas/07/fast_zero/models.py @@ -17,3 +17,8 @@ class User: created_at: Mapped[datetime] = mapped_column( init=False, server_default=func.now() ) + + # Exercício + updated_at: Mapped[datetime] = mapped_column( + init=False, server_default=func.now(), onupdate=func.now() + ) diff --git a/codigo_das_aulas/07/tests/conftest.py b/codigo_das_aulas/07/tests/conftest.py index ca546467..d4ccbb22 100644 --- a/codigo_das_aulas/07/tests/conftest.py +++ b/codigo_das_aulas/07/tests/conftest.py @@ -1,6 +1,9 @@ +from contextlib import contextmanager +from datetime import datetime + import pytest from fastapi.testclient import TestClient -from sqlalchemy import create_engine +from sqlalchemy import create_engine, event from sqlalchemy.orm import Session from sqlalchemy.pool import StaticPool @@ -37,6 +40,27 @@ def session(): table_registry.metadata.drop_all(engine) +@contextmanager +def _mock_db_time(*, model, time=datetime(2024, 1, 1)): + + def fake_time_handler(mapper, connection, target): + if hasattr(target, 'created_at'): + target.created_at = time + if hasattr(target, 'updated_at'): + target.updated_at = time + + event.listen(model, 'before_insert', fake_time_handler) + + yield time + + event.remove(model, 'before_insert', fake_time_handler) + + +@pytest.fixture +def mock_db_time(): + return _mock_db_time + + @pytest.fixture def user(session): user = User( diff --git a/codigo_das_aulas/07/tests/test_db.py b/codigo_das_aulas/07/tests/test_db.py index 794503ac..f6a3a425 100644 --- a/codigo_das_aulas/07/tests/test_db.py +++ b/codigo_das_aulas/07/tests/test_db.py @@ -1,13 +1,25 @@ +from dataclasses import asdict + from sqlalchemy import select from fast_zero.models import User -def test_create_user(session): - new_user = User(username='alice', password='secret', email='teste@test') - session.add(new_user) - session.commit() +def test_create_user(session, mock_db_time): + with mock_db_time(model=User) as time: + new_user = User( + username='alice', password='secret', email='teste@test' + ) + session.add(new_user) + session.commit() user = session.scalar(select(User).where(User.username == 'alice')) - assert user.username == 'alice' + assert asdict(user) == { + 'id': 1, + 'username': 'alice', + 'password': 'secret', + 'email': 'teste@test', + 'created_at': time, + 'updated_at': time, # Exercício + } diff --git a/codigo_das_aulas/08/fast_zero/models.py b/codigo_das_aulas/08/fast_zero/models.py index 4985989b..7a45efd0 100644 --- a/codigo_das_aulas/08/fast_zero/models.py +++ b/codigo_das_aulas/08/fast_zero/models.py @@ -17,3 +17,8 @@ class User: created_at: Mapped[datetime] = mapped_column( init=False, server_default=func.now() ) + + # Exercício + updated_at: Mapped[datetime] = mapped_column( + init=False, server_default=func.now(), onupdate=func.now() + ) diff --git a/codigo_das_aulas/08/tests/conftest.py b/codigo_das_aulas/08/tests/conftest.py index 764b3468..38645d93 100644 --- a/codigo_das_aulas/08/tests/conftest.py +++ b/codigo_das_aulas/08/tests/conftest.py @@ -1,7 +1,10 @@ +from contextlib import contextmanager +from datetime import datetime + import factory import pytest from fastapi.testclient import TestClient -from sqlalchemy import create_engine +from sqlalchemy import create_engine, event from sqlalchemy.orm import Session from sqlalchemy.pool import StaticPool @@ -52,6 +55,27 @@ def user(session): return user +@contextmanager +def _mock_db_time(*, model, time=datetime(2024, 1, 1)): + + def fake_time_handler(mapper, connection, target): + if hasattr(target, 'created_at'): + target.created_at = time + if hasattr(target, 'updated_at'): + target.updated_at = time + + event.listen(model, 'before_insert', fake_time_handler) + + yield time + + event.remove(model, 'before_insert', fake_time_handler) + + +@pytest.fixture +def mock_db_time(): + return _mock_db_time + + @pytest.fixture def other_user(session): password = 'testtest' diff --git a/codigo_das_aulas/08/tests/test_db.py b/codigo_das_aulas/08/tests/test_db.py index 794503ac..f6a3a425 100644 --- a/codigo_das_aulas/08/tests/test_db.py +++ b/codigo_das_aulas/08/tests/test_db.py @@ -1,13 +1,25 @@ +from dataclasses import asdict + from sqlalchemy import select from fast_zero.models import User -def test_create_user(session): - new_user = User(username='alice', password='secret', email='teste@test') - session.add(new_user) - session.commit() +def test_create_user(session, mock_db_time): + with mock_db_time(model=User) as time: + new_user = User( + username='alice', password='secret', email='teste@test' + ) + session.add(new_user) + session.commit() user = session.scalar(select(User).where(User.username == 'alice')) - assert user.username == 'alice' + assert asdict(user) == { + 'id': 1, + 'username': 'alice', + 'password': 'secret', + 'email': 'teste@test', + 'created_at': time, + 'updated_at': time, # Exercício + } diff --git a/codigo_das_aulas/09/fast_zero/models.py b/codigo_das_aulas/09/fast_zero/models.py index 30740237..326ddd97 100644 --- a/codigo_das_aulas/09/fast_zero/models.py +++ b/codigo_das_aulas/09/fast_zero/models.py @@ -31,6 +31,11 @@ class User: init=False, back_populates='user', cascade='all, delete-orphan' ) + # Exercício + updated_at: Mapped[datetime] = mapped_column( + init=False, server_default=func.now(), onupdate=func.now() + ) + @table_registry.mapped_as_dataclass class Todo: diff --git a/codigo_das_aulas/09/tests/conftest.py b/codigo_das_aulas/09/tests/conftest.py index 841b90e6..c94b6929 100644 --- a/codigo_das_aulas/09/tests/conftest.py +++ b/codigo_das_aulas/09/tests/conftest.py @@ -1,6 +1,9 @@ +from contextlib import contextmanager +from datetime import datetime + import pytest from fastapi.testclient import TestClient -from sqlalchemy import create_engine +from sqlalchemy import create_engine, event from sqlalchemy.orm import Session from sqlalchemy.pool import StaticPool @@ -38,6 +41,27 @@ def session(): table_registry.metadata.drop_all(engine) +@contextmanager +def _mock_db_time(*, model, time=datetime(2024, 1, 1)): + + def fake_time_handler(mapper, connection, target): + if hasattr(target, 'created_at'): + target.created_at = time + if hasattr(target, 'updated_at'): + target.updated_at = time + + event.listen(model, 'before_insert', fake_time_handler) + + yield time + + event.remove(model, 'before_insert', fake_time_handler) + + +@pytest.fixture +def mock_db_time(): + return _mock_db_time + + @pytest.fixture def user(session): password = 'testtest' diff --git a/codigo_das_aulas/09/tests/test_db.py b/codigo_das_aulas/09/tests/test_db.py index 6bd5e334..713d84f1 100644 --- a/codigo_das_aulas/09/tests/test_db.py +++ b/codigo_das_aulas/09/tests/test_db.py @@ -1,16 +1,29 @@ +from dataclasses import asdict + from sqlalchemy import select from fast_zero.models import Todo, User -def test_create_user(session): - new_user = User(username='alice', password='secret', email='teste@test') - session.add(new_user) - session.commit() +def test_create_user(session, mock_db_time): + with mock_db_time(model=User) as time: + new_user = User( + username='alice', password='secret', email='teste@test' + ) + session.add(new_user) + session.commit() user = session.scalar(select(User).where(User.username == 'alice')) - assert user.username == 'alice' + assert asdict(user) == { + 'id': 1, + 'username': 'alice', + 'password': 'secret', + 'email': 'teste@test', + 'created_at': time, + 'todos': [], + 'updated_at': time, # Exercício + } def test_create_todo(session, user: User): diff --git a/codigo_das_aulas/10/fast_zero/models.py b/codigo_das_aulas/10/fast_zero/models.py index 30740237..326ddd97 100644 --- a/codigo_das_aulas/10/fast_zero/models.py +++ b/codigo_das_aulas/10/fast_zero/models.py @@ -31,6 +31,11 @@ class User: init=False, back_populates='user', cascade='all, delete-orphan' ) + # Exercício + updated_at: Mapped[datetime] = mapped_column( + init=False, server_default=func.now(), onupdate=func.now() + ) + @table_registry.mapped_as_dataclass class Todo: diff --git a/codigo_das_aulas/10/tests/conftest.py b/codigo_das_aulas/10/tests/conftest.py index 794abf67..9ce5f1e6 100644 --- a/codigo_das_aulas/10/tests/conftest.py +++ b/codigo_das_aulas/10/tests/conftest.py @@ -1,6 +1,9 @@ +from contextlib import contextmanager +from datetime import datetime + import pytest from fastapi.testclient import TestClient -from sqlalchemy import create_engine +from sqlalchemy import create_engine, event from sqlalchemy.orm import Session from testcontainers.postgres import PostgresContainer @@ -44,6 +47,27 @@ def session(engine): table_registry.metadata.drop_all(engine) +@contextmanager +def _mock_db_time(*, model, time=datetime(2024, 1, 1)): + + def fake_time_handler(mapper, connection, target): + if hasattr(target, 'created_at'): + target.created_at = time + if hasattr(target, 'updated_at'): + target.updated_at = time + + event.listen(model, 'before_insert', fake_time_handler) + + yield time + + event.remove(model, 'before_insert', fake_time_handler) + + +@pytest.fixture +def mock_db_time(): + return _mock_db_time + + @pytest.fixture def user(session): password = 'testtest' diff --git a/codigo_das_aulas/10/tests/test_db.py b/codigo_das_aulas/10/tests/test_db.py index 6bd5e334..713d84f1 100644 --- a/codigo_das_aulas/10/tests/test_db.py +++ b/codigo_das_aulas/10/tests/test_db.py @@ -1,16 +1,29 @@ +from dataclasses import asdict + from sqlalchemy import select from fast_zero.models import Todo, User -def test_create_user(session): - new_user = User(username='alice', password='secret', email='teste@test') - session.add(new_user) - session.commit() +def test_create_user(session, mock_db_time): + with mock_db_time(model=User) as time: + new_user = User( + username='alice', password='secret', email='teste@test' + ) + session.add(new_user) + session.commit() user = session.scalar(select(User).where(User.username == 'alice')) - assert user.username == 'alice' + assert asdict(user) == { + 'id': 1, + 'username': 'alice', + 'password': 'secret', + 'email': 'teste@test', + 'created_at': time, + 'todos': [], + 'updated_at': time, # Exercício + } def test_create_todo(session, user: User): diff --git a/codigo_das_aulas/11/fast_zero/models.py b/codigo_das_aulas/11/fast_zero/models.py index 30740237..2c7c6ec4 100644 --- a/codigo_das_aulas/11/fast_zero/models.py +++ b/codigo_das_aulas/11/fast_zero/models.py @@ -27,6 +27,11 @@ class User: init=False, server_default=func.now() ) + # Exercício + updated_at: Mapped[datetime] = mapped_column( + init=False, server_default=func.now(), onupdate=func.now() + ) + todos: Mapped[list['Todo']] = relationship( init=False, back_populates='user', cascade='all, delete-orphan' ) diff --git a/codigo_das_aulas/11/tests/conftest.py b/codigo_das_aulas/11/tests/conftest.py index dc48c4a6..9ce5f1e6 100644 --- a/codigo_das_aulas/11/tests/conftest.py +++ b/codigo_das_aulas/11/tests/conftest.py @@ -1,6 +1,9 @@ +from contextlib import contextmanager +from datetime import datetime + import pytest from fastapi.testclient import TestClient -from sqlalchemy import create_engine +from sqlalchemy import create_engine, event from sqlalchemy.orm import Session from testcontainers.postgres import PostgresContainer @@ -14,6 +17,7 @@ @pytest.fixture(scope='session') def engine(): with PostgresContainer('postgres:16', driver='psycopg') as postgres: + _engine = create_engine(postgres.get_connection_url()) with _engine.begin(): @@ -43,6 +47,27 @@ def session(engine): table_registry.metadata.drop_all(engine) +@contextmanager +def _mock_db_time(*, model, time=datetime(2024, 1, 1)): + + def fake_time_handler(mapper, connection, target): + if hasattr(target, 'created_at'): + target.created_at = time + if hasattr(target, 'updated_at'): + target.updated_at = time + + event.listen(model, 'before_insert', fake_time_handler) + + yield time + + event.remove(model, 'before_insert', fake_time_handler) + + +@pytest.fixture +def mock_db_time(): + return _mock_db_time + + @pytest.fixture def user(session): password = 'testtest' diff --git a/codigo_das_aulas/11/tests/test_db.py b/codigo_das_aulas/11/tests/test_db.py index 6bd5e334..713d84f1 100644 --- a/codigo_das_aulas/11/tests/test_db.py +++ b/codigo_das_aulas/11/tests/test_db.py @@ -1,16 +1,29 @@ +from dataclasses import asdict + from sqlalchemy import select from fast_zero.models import Todo, User -def test_create_user(session): - new_user = User(username='alice', password='secret', email='teste@test') - session.add(new_user) - session.commit() +def test_create_user(session, mock_db_time): + with mock_db_time(model=User) as time: + new_user = User( + username='alice', password='secret', email='teste@test' + ) + session.add(new_user) + session.commit() user = session.scalar(select(User).where(User.username == 'alice')) - assert user.username == 'alice' + assert asdict(user) == { + 'id': 1, + 'username': 'alice', + 'password': 'secret', + 'email': 'teste@test', + 'created_at': time, + 'todos': [], + 'updated_at': time, # Exercício + } def test_create_todo(session, user: User): diff --git a/codigo_das_aulas/12/fast_zero/models.py b/codigo_das_aulas/12/fast_zero/models.py index 30740237..2c7c6ec4 100644 --- a/codigo_das_aulas/12/fast_zero/models.py +++ b/codigo_das_aulas/12/fast_zero/models.py @@ -27,6 +27,11 @@ class User: init=False, server_default=func.now() ) + # Exercício + updated_at: Mapped[datetime] = mapped_column( + init=False, server_default=func.now(), onupdate=func.now() + ) + todos: Mapped[list['Todo']] = relationship( init=False, back_populates='user', cascade='all, delete-orphan' ) diff --git a/codigo_das_aulas/12/tests/conftest.py b/codigo_das_aulas/12/tests/conftest.py index 794abf67..9ce5f1e6 100644 --- a/codigo_das_aulas/12/tests/conftest.py +++ b/codigo_das_aulas/12/tests/conftest.py @@ -1,6 +1,9 @@ +from contextlib import contextmanager +from datetime import datetime + import pytest from fastapi.testclient import TestClient -from sqlalchemy import create_engine +from sqlalchemy import create_engine, event from sqlalchemy.orm import Session from testcontainers.postgres import PostgresContainer @@ -44,6 +47,27 @@ def session(engine): table_registry.metadata.drop_all(engine) +@contextmanager +def _mock_db_time(*, model, time=datetime(2024, 1, 1)): + + def fake_time_handler(mapper, connection, target): + if hasattr(target, 'created_at'): + target.created_at = time + if hasattr(target, 'updated_at'): + target.updated_at = time + + event.listen(model, 'before_insert', fake_time_handler) + + yield time + + event.remove(model, 'before_insert', fake_time_handler) + + +@pytest.fixture +def mock_db_time(): + return _mock_db_time + + @pytest.fixture def user(session): password = 'testtest' diff --git a/codigo_das_aulas/12/tests/test_db.py b/codigo_das_aulas/12/tests/test_db.py index 6bd5e334..713d84f1 100644 --- a/codigo_das_aulas/12/tests/test_db.py +++ b/codigo_das_aulas/12/tests/test_db.py @@ -1,16 +1,29 @@ +from dataclasses import asdict + from sqlalchemy import select from fast_zero.models import Todo, User -def test_create_user(session): - new_user = User(username='alice', password='secret', email='teste@test') - session.add(new_user) - session.commit() +def test_create_user(session, mock_db_time): + with mock_db_time(model=User) as time: + new_user = User( + username='alice', password='secret', email='teste@test' + ) + session.add(new_user) + session.commit() user = session.scalar(select(User).where(User.username == 'alice')) - assert user.username == 'alice' + assert asdict(user) == { + 'id': 1, + 'username': 'alice', + 'password': 'secret', + 'email': 'teste@test', + 'created_at': time, + 'todos': [], + 'updated_at': time, # Exercício + } def test_create_todo(session, user: User):