Skip to content

Commit

Permalink
Merge pull request #252 from dunossauro/251_sqlalchemy_event_datetime
Browse files Browse the repository at this point in the history
Adicionando evento do sqlalchemy para testes de datetime
  • Loading branch information
dunossauro authored Oct 5, 2024
2 parents 64e64e8 + e0ba080 commit d658755
Show file tree
Hide file tree
Showing 28 changed files with 644 additions and 64 deletions.
180 changes: 171 additions & 9 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.

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

Expand Down Expand Up @@ -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" %}

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

0 comments on commit d658755

Please sign in to comment.