Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adicionando evento do sqlalchemy para testes de datetime #252

Merged
merged 5 commits into from
Oct 5, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 169 additions & 7 deletions aulas/04.md
Original file line number Diff line number Diff line change
Expand Up @@ -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" %}
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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.

## [REV] Eventos do ORM

> TODO: Revisar toda a grafia desse tópico!

Embora nossos testes tenham sido executados de forma correta, temos um problema se quisermos validar o objeto como um todo, pois existem algumas coisas que fogem do mecanismo da criação do objeto.

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 que possamos 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 as 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 as 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áiro 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 na data '01/01/2024'. Facilitando a manutenção dos testes para validar a criação do objeto com a sua data.

#### 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, 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 determinística de todos os campos. Inclusive do `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

Expand Down Expand Up @@ -692,6 +853,7 @@ E pronto! As mudanças que fizemos foram salvas no histórico do Git e agora est
```python
mapped_column(onupdate=func.now())
```
2. Altere o evento de testes (`mock_db_time`) para ser contemplado no mock o campo `updated_at` na validação do teste.
dunossauro marked this conversation as resolved.
Show resolved Hide resolved
2. Criar uma nova migração autogerada com alembic
3. Aplicar essa migração ao banco de dados

Expand Down
51 changes: 50 additions & 1 deletion aulas/exercicios_resolvidos/aula_04.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
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
Expand Down Expand Up @@ -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

Expand Down
26 changes: 25 additions & 1 deletion codigo_das_aulas/04/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
22 changes: 17 additions & 5 deletions codigo_das_aulas/04/tests/test_db.py
Original file line number Diff line number Diff line change
@@ -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
}
5 changes: 5 additions & 0 deletions codigo_das_aulas/05/fast_zero/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
)
26 changes: 25 additions & 1 deletion codigo_das_aulas/05/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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='[email protected]', password='testtest')
Expand Down
Loading