ORM e Relacionamentos

ORM Manipulação de Objetos

Vimos na seção sobre a definição de tabelas com o ORM como definir classes do Python que podem ser correlacionadas com entidades do SQL por meio do SQLAlchemy. Já fizemos uso, sem explorar muito o assunto, do método relationship() que insere no esquema de uma sessão os relacionamentos entre propriedades dos objetos que serão espelhadas nas tabelas envolvidas.

Recordando, criamos uma classe vazia herdando de DeclarativeBase que será a superclasse para os modelos de tabelas. No nosso exemplo criamos os objetos aluno e endereco

class Base(DeclarativeBase):
    pass

class Aluno(Base):
    __tablename__ = "aluno"
    ...
    enderecos: Mapped[List["Endereco"]] = relationship(back_populates="aluno")
    
class Endereco(Base):
    __tablename__ = "endereco"
    ...
    aluno: Mapped[Aluno] = relationship(back_populates="enderecos")


Vemos que a classe Aluno tem o atributo Aluno.enderecos e a classe Endereco tem o atributo Endereco.aluno, que estão em relacionamento. Vimos também que Mapped informa o tipo do campo. Objetos da classe Endereco se referem a uma tabela com a campo aluno que é uma chave estrangeira (ForeignKeyConstraint) ligada ao campo aluno.enderecos. O método relationship() pode determinar sem ambiguidade que existe um relacionamento de um para muitos: um aluno.enderecos (uma linha de aluno) pode estar ligada a várias linhas na tabela de endereco.

Relacionamentos um-para-muitos correspondem, é claro, a um relacionamento muitos-para-um na direção oposta. Portanto o parâmetro relacionship.back_populates, em ambas as classes, define que esses campos estão em relação complementar entre si.

Persistência: Um objeto dentro de uma sessão pode ter diversos estados, no que se refere à persistência. Um objeto persistente possui uma identidade em relação ao banco de dados, ou seja, possui uma identidade (uma pk ou chave primária) igual àquela da linha que ele modela. Ao ser criado, antes de ser comitado, um objeto está no estado pendente. Ele se torna persistente com um commit, após ser aplicado no BD. Igualmente, um objeto que foi carregado do BD é persistente. Objetos removidos da sessão são denominados destacados (detached ).

Persistência de relacionamentos: Definidos os relacionamentos eles devem ser gravados na tabela e, quando as tabelas já estão definidas, carregados de volta para as classes do ORM. Suponha que inicializamos um objeto aluno com as seguintes propriedades:

aluno1 = Aluno(matricula="976567-123", nome="Mauro", sobrenome="Olivares")
aluno1.enderecos
↳ []
# uma lista vazia


O campo retornado, inicialmente vazio, é uma versão de uma lista no SQLAlchemy (uma Mapped[List]) que pode rastrear e responder às alterações efetuadas sobre o objeto. Ela é inserido automaticamente quando tentamos acessar o atributo, mesmo que não o tenhamos definido na criação do objeto. Isso é semelhante à inserção de ids que não são informados na incialização. Esse comportamento é diferente daqueles das classes usuais do Python que geram uma exeção AttributeError se a propriedade não for definida na inicialização. O objeto aluno1 é transitório e a lista em aluno1.enderecos não sofreu nenhuma alteração.

Para inserir um elemento nessa coleção criamos um endereço e usamos o método list.append(objeto_endereco).

end1 = Endereco(email="olivares@gmail.com")
aluno1.enderecos.append(end1)

# um endereço é anexado ao objeto aluno1
aluno1.enderecos
↳ [Endereco(id=None, email='olivares@gmail.com')]

# o objeto end1 é sincronizado (veja descrição abaixo)
end1.aluno
↳ Aluno(id=None, nome='Mauro', sobrenome='Olivares')

A operação de inserir um Endereco ao objeto Aluno, além de atualizar o próprio campo aluno1.enderecos também realiza a sincronização automática de Endereco.aluno, inserindo uma referência ao aluno dono desse endereço de email. Essa sincronização é o resultado do parâmetro relationship.back_populates entre os objetos relacionados.

Essa sincronização funciona também na outra direção: se criamos outro objeto Endereco com atributo Endereco.aluno referenciando o aluno1 esse novo endereço fará parte da coleção Aluno.enderecos, para o aluno em questão.

# criamos novo endereco, já associado ao aluno1
end2 = Endereco(email="olivar@aol.com", aluno=aluno1)
# o novo endereco se torna parte da coleção
aluno1.enderecos
↳ [Endereco(id=None, email='olivares@gmail.com'), Endereco(id=None, email='olivar@aol.com')]

Esses novos elementos precisam ser inseridos na sessão, o que pode ser feito com o método session.add(). Com a inserção de aluno1 os dois endereços ficam também inseridos.

session.add(aluno1)
# com esse procedimento temos
aluno1 in session
↳ True
end1 in session
↳ True
end2 in session
↳ True

Essas são as chamadas operações de save e update em cascata. Agora os 3 objetos envolvidos estão em estado pendente: nenhum deles tem um id designado, por enquanto. Além disso os objetos end1 e end2 possuem o atributo aluno_id que é a referência à coluna com um ForeignKeyConstraint ligada à aluno.id. Esse atributo também não foi ainda atribuído a uma linha real do banco de dados, portanto aluno.id = None.

print(aluno1.id)
↳ None
print(end1.aluno_id)
↳ None


Quando comitamos as transações os passos ocorrem na ordem correta, gerenciados pelo SQLAlchemy, para gerar as ids e propagar essa informação para os campos relacionados.

session.commit()
[SQL]
INSERT INTO aluno (nome, sobrenome) VALUES (?, ?) ('Mauro', 'Olivares')
INSERT INTO endereco (email, aluno_id) VALUES (?, ?), (?, ?) RETURNING id
('olivares@gmail.com', 6, 'olivar@aol.com', 6)
COMMIT

No último insert estamos supondo que o id de aluno recém inserido seja 6.

Carregando Relacionamentos: Após a emissão de Session.commit() é emitida automaticamemnte um Session.commit.expire_on_commit que faz com que todos os objetos da sessão fiquem expirados. No próximo acesso de um atributo desses objetos um SELECT é emitido para a linha, permitindo a visualização da chave primária recém-gerada.

aluno1.id
↳ 6
[SQL]
SELECT aluno.id AS aluno_id, aluno.nome AS aluno_nome, aluno.sobrenome AS aluno_sobrenome
FROM aluno WHERE aluno.id = ? (6,)

Podemos também acessar a coleção persistente aluno.enderecos de aluno1, que consiste em um conjunto adicional de linhas da tabela de endereços. Quando acessamos essa coleção ocorre uma lazy load (uma carga lenta) emitida para recuperar os objetos:

aluno1.enderecos
↳ [Endereco(id=4, email='olivares@gmail.com'), Endereco(id=5, email='olivar@aol.com')]
[SQL]
SELECT endereco.id AS endereco_id, endereco.email AS endereco_email,
endereco.aluno_id AS endereco_aluno_id
FROM endereco WHERE endereco.aluno_id = ? (6,)

lazy load, eager load: No ORM uma “carga lenta”, ou lazy load, se refere a um atributo que não contém seu valor imediatamente lido no banco de dados. Geralmente isso ocorre quando o objeto é carregado pela primeira vez. O atributo recebe uma referência na memória que permite que ele leia o valor no banco de dados quando for usado pela primeira vez. Esse padrão busca reduzir o tempo gasto nas buscas de objetos que não precisam ser imediatamente exibidos. Carregamentos que ocorrem no momento da chamada são denominados “carregamentos imediatos ou rápidos”, eager load.

As coleções e atributos relacionados no SQLAlchemy ORM são persistentes na memória. Depois que o valor é atribuído não há mais necessidade de emitir consultas SQL até que a coleção ou atributo expire. Podemos acessar, adicionar ou remover itens em aluno1.enderecos sem que novas consultas SQL sejam executadas.

Esse carregamento lento pode se tornar pesado na memória se não forem tomadas medidas para otimizá-lo. Existe otimização para evitar trabalho redundante: a coleção aluno1.enderecos foi atualizada no mapa de identidade onde todas as referências apontam para as mesmas instâncias Endereco já criadas. Portanto, todos esses objetos já estão carregados.

Consultas com Relacionamentos: O SQLAlchemy admite diversos recursos para a construção consultas SQL que envolvem classes mapeadas coom relacionamentos. Os métodos Select.join() e Select.join_from() são usados para compor cláusulas JOIN nas consultas. Esses métodos inferem a cláusula ON com base na presença de um único e inequívoco objeto ForeignKeyConstraint quando constroem consultas com junções (JOIN), vinculando as tabelas à partir da estrutura dos metadados da sessão. Se desejado, também é possível fornecer explicitamente uma expressão SQL especificando a cláusula ON.

Outro mecanismo também está disponível para estabelecer junções quando usamos entidades ORM, usando os objetos gerados por relationship(), que foram configurados no mapeamento das classes. O atributo da classe que está em relacionamento, definido em relationship(), pode ser passado como argumento para Select.join(), para indicar tanto o lado direito da junção quanto o campo na cláusula ON.

# consulta (1)
print(select(Endereco.email).select_from(Aluno).join(Aluno.enderecos))
# consulta (2)
print(select(Endereco.email).join_from(Aluno, Endereco))
# ambas as consultas geram:
[SQL]
SELECT endereco.email FROM aluno JOIN endereco ON aluno.id = endereco.aluno_id

Consultas com Select.join() ou Select.join_from() não usam o relacionamento estabelecido no mapeamento para inferir a cláusula ON, exceto se isso for explicitamente especificado. Isso significa que, quando fazemos união de Aluno para Endereco sem incluir uma cláusula ON, uma consulta correta é emitida por causa da ForeignKeyConstraint entre os objetos mapeados e não devido à existência de um relationship().


Vale lembrar que Aluno, Endereco (com maiúsculas) se referem às classes do ORM enquanto aluno, endereco são os nomes das tabelas no BD.Consulte o manual do SQLAlchemy: ORM Query Guide, Select Join, Select Join On Clause.

Relacionamentos e WHERE

Existem algumas formas de gerar consultas e filtros com relationship(), tipicamente aplicados com WHERE (no SQL) e Select.where() (no SQLAlchemy).

EXISTS: has() e any(): Vimos na seção Agrupamentos e Subqueries: EXISTS como funciona EXISTS e sua contraparte no SQLAlchemy. O método exists() é usado para gerar a cláusula EXISTS do SQL que é aplicada sobre um conjunto de resultados obtidos com uma subconsulta escalar. A classe construída por relationship() tem métodos auxiliares responsáveis pela geração de algumas formas comuns de uso de EXISTS em consultas sobre colunas ligadas por relacionamentos.

Em um relacionamento um-para-muitos, como é o caso de Aluno.enderecos que se liga a uma coleção de Endereco.aluno, podemos gerar um EXISTS usando PropComparator.any(). Este método aceita um critério WHERE opcional para filtrar as linhas retornadas pela subconsulta.

query = select(Aluno.sobrenome)
             .where(Aluno.enderecos.any(Enderecos.email == "olivares@gmail.com"))
session.execute(query).all()
# é retornado
↳ ['Olivares',)]
[SQL]
SELECT aluno.sobrenome FROM aluno
WHERE EXISTS (SELECT 1 FROM endereco WHERE aluno.id =
              endereco.aluno_id AND aluno.email = ?) ('olivares@gmail.com',)

A subconsulta retorna 1 para cada linha que satisfaz
aluno.id = endereco.aluno_id AND aluno.email = 'olivares@gmail.com' .
Se existir algum valor EXISTS retorna TRUE e o sobrenome é retornado pela consulta externa.

O uso de EXISTS é, em geral, mais eficiente para pesquisas negativas, quando se faz uma busca por elementos que não estão presentes nas linhas. Para isso basta negar um resultado, como ~Aluno.endereco.any(), para selecionar Alunos que não possuem linhas associadadas na tabela endereco.

query = select(Aluno.nome).where(~Aluno.enderecos.any())
session.execute(query).all()

[SQL]
SELECT aluno.nome FROM aluno WHERE NOT (EXISTS 
      (SELECT 1 FROM aluno WHERE aluno.id = endereco.aluno_id)
)

A consulta retorna os nomes dos alunos sem um endereço cadastrado.

O método PropComparator.has() age quase da mesma forma que PropComparator.any(), com a diferença de ser usado em relacionamentos muitos-para-um. Esse seria o caso se quisermos encontrar todos os endereços associados com um aluno determinado.

query = select(Endereco.email).where(Endereco.aluno.has(Aluno.nome == "Mauro"))
session.execute(query).all()

[SQL]
SELECT endereco.email FROM endereco WHERE EXISTS
   (SELECT 1 FROM aluno WHERE aluno.id = endereco.aluno_id AND aluno.nome = ?) ('Mauro',)

↳ [('olivares@gmail.com',), ('olivar@aol.com',)]

As consultas 1-4 abaixo exibem outras propriedades: (1) Uma instância de um objeto pode ser comparada a um relacionamento muitos-para-um para selecionar linhas onde a chave estrangeira no destino corresponde à chave primária do objeto dado. (2) O operador not equals (!=) também pode ser usado. (3) Aluno.enderecos.contains(obj_endereco) testa se o objeto carregado é um dos endereços na coleção. (4) with_parent(obj_aluno, Aluno.enderecos) testa se obj_aluno tem Aluno.enderecos como classe pai.

# (1) 
obj_endereco = session.get(Endereco, 1)
obj_aluno = session.get(aluno, 1)
print(select(Endereco).where(Endereco.aluno == obj_aluno))
[SQL]
SELECT endereco.id, endereco.aluno_id, endereco.email 
FROM endereco WHERE :param_1 = endereco.aluno_id

# (2) 
print(select(Endereco).where(Endereco.aluno != obj_aluno))
[SQL]
SELECT endereco.id, endereco.aluno_id, endereco.email FROM endereco
WHERE endereco.aluno_id != :aluno_id_1 OR endereco.aluno_id IS NULL

# (3) 
print(select(Aluno).where(Aluno.enderecos.contains(obj_endereco)))
[SQL]
SELECT aluno.id, aluno.matricula, aluno.nome, aluno.sobrenome
FROM aluno WHERE aluno.id = :param_1

# (4) 
from sqlalchemy.orm import with_parent
print(select(Endereco).where(with_parent(obj_aluno, Aluno.enderecos)))
[SQL]
SELECT endereco.id, endereco.aluno_id, endereco.email
FROM endereco WHERE :param_1 = endereco.aluno_id

Lembramos aqui que Endereco.aluno está em relacionamento (muitos-para-1) com Aluno.enderecos.

Estratégias de de carregamento

Vimos que, quando acessamos atributos de objetos mapeados que usam relacionamentos, um carregamento lento (ou lazy load) será realizado quando a coleção ainda não estiver preenchida. O carregamento lento é um padrão importante no ORM, embora controverso. Quando temos muitos objetos ORM na memória que fazem referência a muitos atributos não carregados, a manipulação desses objetos pode gerar novas consultas em cascata, causando acúmulo (no que consiste no problema denominado “N mais um”). Para piorar o estado de coisas essas novas consultas são emitidas implicitamente. Elas podem causar erros quando são produzidas após o fechamento das transações com o BD, ou quando se usa gerenciadores de conexões assíncronas, como asyncio.

Apesar disso o carregamento lento é útil, principalmente quando bem ajustado com mecanismos de sincronização. Por isso o SQLAlchemy ORM inclui muitos recursos para controlar e otimizar o comportamento de carregamentos. A etapa principal no uso de carregamento lento consiste em testar o aplicativo ativando a exibição de saídas de consultas para análise do SQL emitido. A presença de muitas instruções SELECT redundantes, que poderiam ser agrupadas com mais eficiência, ou a ocorrência de carregamentos inadequados para objetos que já estão destacados (detached ) da sessão, são indicadores de que se deve usar estratégias de carregamento.

Essas estratégias são representadas por objetos que podem ser associados a uma instrução SELECT através do método Select.options(). A estratégia abaixo permite o acesso aos objetos já carregados de Aluno.enderecos.

enderecos_carregados = session.execute(select(Aluno)
                            .options(selectinload(Aluno.enderecos))).scalars()
for obj_aluno in enderecos_carregados:
    obj_aluno.enderecos

Também é possível tornar o carregamento lento a forma default em relationship(), usando a opção relationship.lazy.

from sqlalchemy.orm import Mapped
from sqlalchemy.orm import relationship

class Aluno(Base):
    __tablename__ = "aluno"
    ...
    enderecos: Mapped[List["Endereco"]] = relationship(back_populates="aluno", lazy="selectin")

Carregamento Selectin: Uma opção de carregamento muito útil é a selectinload() que resolve o problema frequente “N mais um”, citado acima. A opção selectinload() faz com que uma coleção completa de objetos relacionados seja carregada antecipadamente em uma única consulta. Isso é obtido com consultas SELECT aplicadas apenas sobre uma tabela, sem inserir JOINs ou subconsultas, seguida de consultas para os objetos relacionados que ainda não foram carregados.

No exemplo abaixo selectinload() é usado para carregar todos os objetos Alunos e os objetos Enderecos associados. Quando invocamos Session.execute() uma vez, passando um select(), o BD dados é acessado com duas instruções SELECT, sendo a segunda usada para carregar objetos Enderecos associados.

from sqlalchemy.orm import selectinload
query = select(Aluno).options(selectinload(Aluno.enderecos)).order_by(Aluno.id)
for row in session.execute(query):
    print(f"Aluno: {row.Aluno.nome} {row.Aluno.sobrenome}")
    for a in row.Aluno.enderecos:
        print(f"{a.email}")
[SQL]
SELECT aluno.id, aluno.nome, aluno.sobrenome
FROM aluno ORDER BY aluno.id

SELECT endereco.aluno_id AS endereco_aluno_id, endereco.id AS endereco_id, 
endereco.email AS endereco_email FROM endereco
WHERE endereco.aluno_id IN (?, ?, ?, ?, ?, ?) (1, 2, 3, 4, 5, 6)

A consulta retorna nome e sobrenome dos 6 primeiros alunos e seus respectivos emails.

Carregamento com JOIN: joinedload() é usado como estratégia de carregamento imediato (eager load ) que inclui a possibilidade de JOINs em uma instrução SELECT. Esse JOIN pode ser uma junção externa ou interna. Essa é a estratégia adequada para carregar objetos em relacionamentos muitos-para-um pois isso exige apenas o carregamento de colunas adicionais a uma linha da entidade primária. Ele também aceita a opção joinload.innerjoin para que a junção seja considerada interna (e não externa). No exemplo abaixo sabemos que todos os objetos Enderecos estão associados a algum Aluno.

from sqlalchemy.orm import joinedload
query = (
    select(Endereco)
    .options(joinedload(Endereco.aluno, innerjoin=True))
    .order_by(Endereco.id)
)
for row in session.execute(query):
    print(f"Aluno: {row.Endereco.aluno.nome}: email: {row.Endereco.email} ")
    
SELECT endereco.id, endereco.email, endereco.aluno_id,
       aluno_1.id AS id_1, aluno_1.nome, aluno_1.sobrenome
FROM endereco JOIN aluno AS aluno_1 ON aluno_1.id = endereco.aluno_id
       ORDER BY endereco.id

A consulta retorna os nomes e emails de alunos, ordenados pelo id do endereço. Lembrando que Endereco está em relação com aluno vemos que Endereco.aluno.nome fica carregado com o nome desse aluno.

joinload() também funciona para coleções, em relacionamentos um-para-muitos. Esse uso, no entanto, pode multiplicar as linhas linhas retornadas de maneira recursiva, o que exige cuidado nessa opção, e consideração do uso de selectinload().

Importante: os critérios WHERE e ORDER BY, usados para modificar a instrução Select, não agem sobre a tabela afetada por joinload(). Como mostra a consulta SQL acima um aliás é atribuído à tabela aluno para que ela não seja alvo desses filtros.

Vemos assim que joinload() recebe como argumento o campo que deve ser carregado de forma imediata. Nos exemplos abaixo os objetos ORM (com letra maiúscula) refletem tabelas Cliente, com campo (uma coleção) Cliente.pedidos; Pedidos com coleção Pedidos.itens, cada item com a descrição Item.descricao.

# joined-load um campo "pedidos" no objeto ORM Cliente
query(Cliente).options(joinedload(Cliente.pedidos))

# joined-load Pedidos.itens, depois Item.descricao (se Pedidos.itens é uma coleção de objetos Item)
query(Pedidos).options(
    joinedload(Pedidos.itens).joinedload(Item.descricao))

# a mesma consulta, com lazy load
query(Pedidos).options(
    lazyload(Pedidos.items).joinedload(Item.descricao))


Junções explícitas com carregamentos rápidos, contains_eager: Suponha que queremos carregar as linhas de endereço associadas à tabela aluno usando um método como Select.join() para aplicar um JOIN. Esse JOIN para ser aproveitado para uma carga rápida do conteúdo de Endereco.aluno em cada campo endereco retornado. Podemos realizar um carregamento rápido como JOIN, executando esse JOIN manualmente. Isso pode ser obtido com contains_eager(), uma opção semelhante a joinload() que libera o desenvolvedor para configurar o JOIN. Colunas adicionais na cláusula COLUMNS devem ser carregadas em atributos relacionados em cada objeto retornado. Por exemplo:

from sqlalchemy.orm import contains_eager
query = (
    select(Endereco)
    .join(Endereco.aluno)
    .where(Aluno.nome == "Marcos")
    .options(contains_eager(Endereco.aluno))
    .order_by(Endereco.id)
)
for row in session.execute(query):
    print(f"{row.Endereco.aluno.nome}, email: {row.Endereco.email} ")
[SQL]
SELECT aluno.id, aluno.nome, aluno.sobrenome, endereco.id AS id_1, endereco.email, endereco.aluno_id
FROM endereco JOIN aluno ON aluno.id = endereco.aluno_id
WHERE aluno.nome = ? ORDER BY endereco.id ('Marcos',)

Filtramos, na consulta acima, as linhas por aluno.nome e carregamos linhas de aluno no atributo Endereco.aluno. Se tivéssemos aplicado joinedload() seriam geradas partes desnecessárias na consulta SQL, como exibido abaixo.

query = (
    select(Endereco)
    .join(Endereco.aluno)
    .where(Aluno.nome == "Marcos")
    .options(joinedload(Endereco.aluno))
    .order_by(Endereco.id)
)
print(query)
[SQL]
SELECT endereco.id, endereco.email, endereco.aluno_id,
aluno_1.id AS id_1, aluno_1.nome, aluno_1.sobrenome
FROM endereco JOIN aluno ON aluno.id = endereco.aluno_id
LEFT OUTER JOIN aluno AS aluno_1 ON aluno_1.id = endereco.aluno_id
WHERE aluno.nome = :nome_1 ORDER BY endereco.id

Esse exemplo produz a geração desnecessária de clásulas JOIN e LEFT OUTER JOIN junto com SELECT.

Raiseload é outra estratégia de carregamento. Ela é usada para impedir o surgimento do problema N-mais-um, transformando cargas lazy em um lançamento de erro. Usamos a opção raiseload.sql_only para bloquear cargas lentas feitas por consultas SQL ou bloquear todos os carregamentos, incluindo aqueles que apenas precisam consultar a sessão atual. Uma das formas consiste em usar raiseload() para configurar o relacionamento estabelecido em relationship(), ajustando o valor relationship.lazy = "raise_on_sql". Com isso nenhum acesso aos dados tentará emitir uma consulta SQL. Isso pode ser feito na definição dos objetos ORM que refletem as tabelas.

from sqlalchemy.orm import Mapped
from sqlalchemy.orm import relationship

class Aluno(Base):
    __tablename__ = "aluno"
    id: Mapped[int] = mapped_column(primary_key=True)
    enderecos: Mapped[List["Endereco"]] = relationship(back_populates="aluno", lazy="raise_on_sql")

class Endereco(Base):
    __tablename__ = "endereco"
    id: Mapped[int] = mapped_column(primary_key=True)
    aluno_id: Mapped[int] = mapped_column(ForeignKey("aluno.id"))
    aluno: Mapped["Aluno"] = relationship(back_populates="enderecos", lazy="raise_on_sql")

Esse tipo de definição no relacionamento impede a realização de “lazy loads” e obriga a definição de uma estratégia de carregamento para consultas nesses campos.

u1 = session.execute(select(Aluno)).scalars().first()
[SQL]
SELECT aluno.id FROM aluno
# ao tentar acessar a propriedade relacionada
u1.enderecos
# um erro é lançado
sqlalchemy.exc.InvalidRequestError: 'Aluno.enderecos' is not available due to lazy='raise_on_sql'

Essa exceção indica que a coleção devaria ter sido carregada antes do uso.

u1 = (
    session.execute(select(User).options(selectinload(User.addresses)))
    .scalars()
    .first()
)
[SQL]
SELECT aluno.id FROM aluno
[...]
SELECT endereco.aluno_id AS endereco_aluno_id, endereco.id AS endereco_id
FROM endereco WHERE endereco.aluno_id IN (?, ?, ?, ?, ?, ?) (1, 2, 3, 4, 5, 6)

O opção lazy="raise_on_sql" também tenta o carregamneto correto em relacionamentos muitos-para-um. Se o atributo Endereco.aluno não estiver preenchido mas o objeto Aluno já está carregado no sessão atual, então a estratégia raiseload não lança erros.

Bibliografia

Esse texto é baseado primariamente na documentação do SQLAlchemy, disponível em SQLAlchemy 2, Documentation. Outras referências no artigo Python e SQL: SQLAlchemy.

Dataframes: multi-índices e concatenção

Índices Hierárquicos

É possível criar series e dataframes com índices e subíndices. Esse processo de indexação hierárquica é importante para a reformatação (reshaping ), formação de tabelas pivot e outras operações de agrupamento de dados.

» import pandas as pd
» import numpy as np

» # formamos uma series com índices duplos 
» sr = pd.Series([11, 12, 21, 22, 23, 31, 32, 41, 42],
                 index=[['A', 'A', 'B', 'B', 'B', 'C', 'C', 'D', 'D'],
                 [1, 2, 1, 2, 3, 1, 2, 1, 2]])
» sr
↳ A  1    11
     2    12
  B  1    21
     2    22
     3    23
  C  1    31
     2    32
  D  1    41
     2    42

» # essa series possui índices
» sr.index
↳ MultiIndex([('A', 1), ('A', 2),
              ('B', 1), ('B', 2), ('B', 3),
              ('C', 1), ('C', 2),
              ('D', 1), ('D', 2)],)

» # da mesma forma podemos transformar essa series um um dataframe
» df = pd.DataFrame(sr)

» # Os índices do dataframe são os mesmos: df.index

» # o índice B corresponde à 3 linhas
» df.loc['B']
↳        0
  1     21
  2     22
  3     23

» df.loc['B'].loc[2]
↳ 0    22

» # idem para a series
» sr['C']
↳ 1    31
  2    32

» sr['C'][1]
↳ 31

» # podemos listar as linhas de 'A' até 'C'
» sr['A':'C']
↳ A  1    11
     2    12
  B  1    21
     2    22
     3    23
  C  1    31
     2    32

» # ou as linhas correspondentes à 'A' e 'C'
» sr.loc[['A','C']]
↳ A  1    11
     2    12
  C  1    31
     2    32

» # seleção pelo índice interno pode feita diretamente
» sr.loc[:, 2]
↳ A    12
  B    22
  C    32
  D    42

» sr.loc[:, 3]
↳ B    23

stack() e unstack()

Os dados de uma series com índices hierárquicos podem ser rearranjados em um DataFrame com o uso de método Series.unstack(). Os índices internos se tornam nomes das colunas. Valores não existentes, como o correspondende aos índices A, 3, são preenchidos com NaN.

» df = sr.unstack()
» df
↳          1         2       3
  A     11.0     12.0      NaN
  B     21.0     22.0     23.0
  C     31.0     32.0      NaN
  D     41.0     42.0      NaN

» # para retornar à uma series
» df.unstack()
↳ 1  A    11.0
     B    21.0
     C    31.0
     D    41.0
  2  A    12.0
     B    22.0
     C    32.0
     D    42.0
  3  A     NaN
     B    23.0
     C     NaN
     D     NaN

No processo de desempilhar o dataframe (unstack ) os nomes das colunas foram usados como índices primários.

Um dataframe pode ter índices hierarquizados para linhas e colunas.

» clima = np.array([[25,20,30],[20,16,15],[15,25,27],[40,60,78]])
» dfClima = pd.DataFrame(clima,
»                       index=[['Temperatura','Temperatura','Umidade','Umidade'],
»                              ['dia','noite','dia','noite']],
»                       columns=[['Paraná','Paraná','Amazonas'],['Cascavel','Curitiba','Manaus']]
»                       )

» # inserindo nomes para as linhas e colunas
» dfClima.index.names = ['Característica', 'D/N']        # D/N = dia/noite
» dfClima.columns.names = ['Estado', 'Cidade']

» # o resultado é
» dfClima
↳                  Estado                   Paraná     Amazonas
                   Cidade     Cascavel     Curitiba      Manaus
  Característica      D/N             
  Temperatura         dia           25           20          30
                    noite           20           16          15
  Umidade             dia           15           25          27
                    noite           40           60          78

Se o processo de criação de dataframes com os mesmos índices será repetido várias vezes ,pode ser útil definir previamente os objetos multindexes.

» colunas = pd.MultiIndex.from_arrays([['Paraná', 'Paraná', 'Amazonas'],
»                                      ['Cascavel', 'Curitiba', 'Manaus']],
»                                      names=['Estado', 'Cidade'])

» linhas = pd.MultiIndex.from_arrays([['Temperatura','Temperatura','Umidade','Umidade'],
»                                     ['dia','noite','dia','noite']],
»                                     names=['Característica', 'D/N'])

» linhas
↳ MultiIndex([('Temperatura',   'dia'),
              ('Temperatura', 'noite'),
              (    'Umidade',   'dia'),
              (    'Umidade', 'noite')],
             names=['Característica', 'D/N'])

» pd.DataFrame(clima, index=linhas, columns=colunas)
↳                 Estado     Paraná             Amazonas
                  Cidade   Cascavel    Curitiba   Manaus
  Característica     D/N
  Temperatura        dia         25          20       30
                   noite         20          16       15
  Umidade            dia         15          25       27
                   noite         40          60       78

swaplevel() e groupby()

O ordenamento dos níveis nos dataframes pode ser alterado com o método dataframe.swaplevel(indice1, indice2). Índices primários podem ser permutados com índice secundários. Com dataframe.sort_index(level=n) podemos ordenar as linhas do dataframe segundo os nomes dos índices do nível n.

» dfClima.swaplevel('D/N', 'Característica')
↳                 Estado     Paraná               Amazonas
                  Cidade   Cascavel    Curitiba     Manaus
  D/N     Característica
  dia        Temperatura         25          20         30
  noite      Temperatura         20          16         15
  dia            Umidade         15          25         27
  noite          Umidade         40          60         78

» # ordenando as linhas pelos labels do nível 1 (D/N)
» dfClima.sort_index(level=1)

↳                  Estado                    Paraná    Amazonas
                   Cidade     Cascavel     Curitiba      Manaus
  Característica      D/N
  Temperatura         dia           25           20          30
  Umidade             dia           15           25          27
  Temperatura       noite           20           16          15
  Umidade           noite           40           60          78

» # alternativamente podemos inverter a ordem dos níveis e ordenar pelo nivel 0
» dfClima.swaplevel(0, 1).sort_index(level=0)
↳                 Estado                    Paraná    Amazonas
                  Cidade     Cascavel     Curitiba      Manaus
  D/N     Característica
  dia        Temperatura           25           20          30
                 Umidade           15           25          27
  noite      Temperatura           20           16          15
                 Umidade           40           60          78

» # soma dos valores agrupados pelo nível 1 (D/N)
» dfClima.groupby(level=1).sum()
 
↳ Estado                    Paraná    Amazonas
  Cidade     Cascavel     Curitiba      Manaus
  D/N 
  dia              40           45          57
  noite            60           76          93

O método dataframe.groupby(), que veremos mais tarde com maiores detalhes, permite o agrupamento dos dados de um determinado índice (ou nível de índices). Por ex., dataframe.groupby(level=n).sum() faz o agrupamento dos dados segundo o n-ésimo nível de índice e depois soma esses valores. Muitas outras funções estatísticas ficam disponíveis com agrupamentos por groupby.

» # soma dos valores agrupados pelo nível 0
» dfClima.groupby(level='Característica').mean()
↳                  Estado               Paraná    Amazonas
                   Cidade  Cascavel   Curitiba      Manaus
  Característica
  Temperatura                  22.5       18.0        22.5
  Umidade                      27.5       42.5        52.5

» # a média dos valores agrupados pelo índice D/N
» dfClima.groupby(level=0).mean()

↳                   Estado              Paraná   Amazonas
                    Cidade  Cascavel  Curitiba     Manaus
  Característica
  Temperatura                   22.5      18.0       22.5
  Umidade                       27.5      42.5       52.5

» # o valor máximo agrupado pelo nível 'Característica'
» dfClima.groupby(level='Característica').max()
↳                  Estado              Paraná    Amazonas
  Cidade                   Cascavel   Curitiba     Manaus
  Característica
  Temperatura                    25         20         30
  Umidade                        40         60         78

Vimos previamente que qualquer coluna pode ser transformada em índice do dataframe. Mais de uma coluna pode também ser usada: para isso usamos dataframe.set_index([coluna1, coluna2]). Por default essa operação coloca coluna1, coluna2 como índices e descarta as colunas usadas. Para alterar esse comportamento (e manter as colunas) usamos o parâmetro drop=False. O método dataframe.reset_index() remove os índices colocando-os como colunas e criando um novo conjunto de índices.

» # criamos um dataframe arbitrário
» dfNums = pd.DataFrame({'a': range(1,6),
»                        'texto-a': ['um','dois','três','quatro','cinco'],
»                        'b': range(5, 0, -1),
»                        'texto-b': ['cinco', 'quatro','três','dois','um']
»                       })

» # dataframe inicial
» dfNums
↳      a     texto-a     b     texto-b
  0    1          um     5       cinco
  1    2        dois     4      quatro
  2    3        três     3        três
  3    4      quatro     2        dois
  4    5       cinco     1          um

» # usamos as colunas 'a' e 'b' como índices
» dfNums2 = dfNums.set_index(['a', 'b'])

» dfNums2
↳          texto-a    texto-b
  a    b         
  1    5        um      cinco
  2    4      dois     quatro
  3    3      três       três
  4    2    quatro       dois
  5    1     cinco         um

» # para descartar os índices (e recuperar as colunas)
» dfNums2.reset_index()

↳       a     b     texto-a     texto-b
  0     1     5     um          cinco
  1     2     4     dois        quatro
  2     3     3     três        três
  3     4     2     quatro      dois
  4     5     1     cinco       um

» # podemos usar as colunas 'a' e 'texto-a' como índices sem descartar essas colunas
» dfNums.set_index(['a', 'texto-a'], drop=False)
↳                 a     texto-a    b    texto-b
  a     texto-a                 
  1     um        1     um         5      cinco
  2     dois      2     dois       4     quatro
  3     três      3     três       3       três
  4     quatro    4     quatro     2       dois
  5     cinco     5     cinco      1         um

Uma exceção é lançada se já existem colunas com os mesmos nomes recuperados por reset_index.

Combinando dataframes

Podemos juntar dataframes de várias formas. pandas.merge() junta dataframes usando um ou mais índices, em operações semelhantes àquelas de bancos de dados relacionais usando-se as operações de join do SQL. pandas.concat() faz a concatenação ou empilhamento dos dataframes ao longo do eixo escolhido. pandas.combine_first() permite a junção de dados que se superpõe (existem em mais de uma tabela), preenchendo valores ausentes um uma tabela com aqueles em outra tabela fornecida.

merge()

df1.merge(df2) retorna outro dataframe que é a junção dos dois dataframes. O método possui a seguinte assinatura:
df1.merge(df2, how='inner', on=None, left_on=None, right_on=None, left_index=False, right_index=False, sort=False, suffixes=('_x', '_y'), copy=True, indicator=False, validate=None)

A junção pode ser feita sobre nomes das colunas ou índices. Uma Series nomeada é tratada como um dataframe de coluna única. São parâmetros:

df1, df2 dataframe ou Series nomeada. Junção de df1 com df2
how tipo de junção: left, right, outer, inner, cross:
inner: usa apenas combinações de chaves existentes em ambas as tabelas preserva ordem das chaves.
outer: usa todas as combinações de chaves em cada uma das tabelas,
left: usa todas as combinações de chaves existentes na tabela à esquerda,
right: usa todas as combinações de chaves existentes na tabela à direita,
cross: cria o produto cartesiano das tabelas, preserva ordem dos índices.
on coluna ou índice para a junção. Deve existir em ambos os dataframes
left_on nome da coluna ou índice (ou lista) em df1.
right_on nome da coluna ou índice (ou lista) em df2.
left_index False/True: use o índice de df1 como chave.
right_index False/True: use o índice de df2 como chave.
sort False/True: Ordena os índices no resultado.
suffixes lista: default = (“_x”, “_y”). Sufixos para índices de mesmo nome
copy False/True: Se False evita a cópia, se possível
indicator False/True ou str: Se True acrescenta coluna “_merge” com informações sobre as linhas.
validate str, opcional. Se especificada verifica se a junção é do tipo:
one_to_one ou 1:1 : se chave da fusão é única nos dois dataframes,
one_to_many ou 1:m : se chave da fusão é única em df1 (lado esquerdo),
many_to_one ou m:1 : se chave da fusão é única em df2 (lado direito),
many_to_many ou m:m : embora permitida não resulta em nenhuma verificação.

Comparação de how='' com comandos SQL: (Pandas e SQL comparados).

how= similar ao SQL
left left outer join. Preserva ordem das chaves.
right right outer join. Preserva ordem das chaves.
outer full outer join. Ordena por nomes das chaves.
inner inner join. Preserva ordem das chaves à esquerda.
» # criando dataframes 
» df1 = pd.DataFrame({'chave': ['a', 'a', 'a', 'b', 'b', 'b', 'c'], 'data1': range(7)})
» df2 = pd.DataFrame({'chave': ['a', 'b', 'd'], 'data2': range(3)})

» # exibindo df1, df2 e sua junção com merge
» display(df1, df2, pd.merge(df1, df2))

↳    chave   data1
  0      a       0
  1      a       1
  2      a       2
  3      b       3
  4      b       4
  5      b       5
  6      c       6

↳    chave   data2
  0      a      0
  1      b      1
  2      d      2

↳    chave   data1   data2
  0      a       0       0
  1      a       1       0
  2      a       2       0
  3      b       3       1
  4      b       4       1
  5      b       5       1


Como os dois dataframes possuem uma coluna com nome comum a junção foi feita com base nos valores da coluna com esse nome. Essa informação pode ser explicitada com pd.merge(df1, df2, on='chave').

Se os nomes das colunas de cada dataframe for diferente eles devem ser definidos com os parâmetros left_on, right_on.

» df3 = pd.DataFrame({'chave1': ['a', 'a', 'a', 'b', 'b', 'c', 'd'], 'data1': range(7)})
» df4 = pd.DataFrame({'chave2': ['a', 'b', 'd'], 'data2': range(3)})

» display(df3, df4, pd.merge(df3, df4, left_on='chave1', right_on='chave2'))
↳    chave1     data1
  0       a     0
  1       a     1
  2       a     2
  3       b     3
  4       b     4
  5       c     5
  6       d     6

↳    chave2     data2
  0       a     0
  1       b     1
  2       d     2

↳    chave1     data1     chave2     data2
  0       a     0         a          0
  1       a     1         a          0
  2       a     2         a          0
  3       b     3         b          1
  4       b     4         b          1
  5       d     6         d          2

Vemos na concatenação acima que o método usado reune apenas valores existentes nas duas tabelas. Isso é equivalente a passar o parâmetro how=’inner’ (um inner join ). Outra opção consiste em fazer o ligamento externo.

» # para conseguir um outer join    
» pd.merge(df1, df2, how='outer')

↳     chave   data1   data2
  0     a       0.0     0.0
  1     a       1.0     0.0
  2     a       2.0     0.0
  3     b       3.0     1.0
  4     b       4.0     1.0
  5     b       5.0     1.0
  6     c       6.0     NaN
  7     d       NaN     2.0

» dd.merge(df1, df2, how='left')

↳   chave   data1   data2
  0     a       0     0.0
  1     a       1     0.0
  2     a       2     0.0
  3     b       3     1.0
  4     b       4     1.0
  5     b       5     1.0
  6     c       6     NaN

» pd.merge(df1, df2, how='right')

↳   chave     data1   data2
  0     a       0.0       0
  1     a       1.0       0
  2     a       2.0       0
  3     b       3.0       1
  4     b       4.0       1
  5     b       5.0       1
  6     d       NaN       2

Tabelas podem ser ligadas por mais de uma chave, quando os dataframes possuem índices hierarquizados. As chaves são usadas como se fossem uma única chave concatenada.

» df1 = pd.DataFrame({'chave_1': ['rato', 'rato', 'gato'],
»                      'chave_2': ['Jones', 'Jerry', 'Tom'],
»                      'valor_A': [10, 20, 30]})
» df2 = pd.DataFrame({'chave_1': ['rato', 'rato', 'gato', 'gato'],
»                       'chave_2': ['Jones', 'Jerry', 'Tom', 'Tim'],
»                       'valor_B': [40, 50, 60, 70]})
                      
» # exibindo os dataframes e a junção externa em duas chaves
» display(df1, df2, pd.merge(df1, df2, on=['chave_1','chave_2'], how='outer'))

↳     chave_1     chave_2    valor_A
  0      rato       Jones         10
  1      rato       Jerry         20
  2      gato         Tom         30

↳    chave_1     chave_2   valor_B
  0     rato       Jones        40
  1     rato       Jerry        50
  2     gato         Tom        60
  3     gato         Tim        70

↳     chave_1    chave_2    valor_A    valor_B
  0     rato       Jones       10.0         40
  1     rato       Jerry       20.0         50
  2     gato        Tom        30.0         60
  3     gato        Tim         NaN         70

» # a junção interna em duas chaves
» pd.merge(df1, df2, on=['chave_1','chave_2'], how='inner')

↳     chave_1   chave_2    valor_A    valor_B
  0      rato     Jones         10         40
  1      rato     Jerry         20         50
  2      gato      Tom          30         60

Se a junção for feita sobre campos (nomes de colunas) com o mesmo nome estes serão alterados para continuar a representar suas colunas de origem. No caso do exemplo as colunas com nome valor foram renomeadas para valor_x e valor_y.

» df1 = pd.DataFrame({'chave': ['a', 'b', 'c'], 'valor': [1,2,3]})
» df2 = pd.DataFrame({'chave': ['a', 'b', 'c'], 'valor': [10,20,30]})

» mrg = pd.merge(df1, df2, on='chave')

» display(df1, df2, mrg)

↳    chave  valor
  0      a      1
  1      b      2
  2      c      3

↳    chave  valor
  0      a     10
  1      b     20
  2      c     30

↳    chave  valor_x  valor_y
  0      a        1       10
  1      b        2       20
  2      c        3       30

A chave usada na fusão (merge) pode estar no índice de um ou ambas as tabelas. No exemplo usamos pd.merge(esquerda, direita, left_on='chave', right_index=True) que faz a junção de esquerda.chave com direita.index

» esquerda = pd.DataFrame({'chave': ['a1', 'a1', 'a2', 'a1', 'a2', 'a3'], 'valor_1': range(6)})
» direita = pd.DataFrame({'valor_2': [50, 70]}, index=['a1', 'a2'])

» mrg = pd.merge(esquerda, direita, left_on='chave', right_index=True)

» # exibindo dataframes e sua junção, usando o índice da tabela à direita
» display(esquerda, direita, mrg)

↳    chave   valor_1
  0     a1         0
  1     a1         1
  2     a2         2
  3     a1         3
  4     a2         4
  5     a3         5

↳    valor_2
  a1      50
  a2      70

↳    chave   valor_1    valor_2
  0     a1         0         50
  1     a1         1         50
  3     a1         3         50
  2     a2         2         70
  4     a2         4         70

» # se os dataframes forem invertidos conseguiríamos o
» # mesmo resultado, exceto pela ordem das colunas, usando:
» # pd.merge(direita, esquerda, right_on='chave', left_index=True)

Junções com join()

Junções podem ser feitas com dataframe.join(dfOutro) que, por default, faz a união outer join usando o índice como chave. Esse método tem a seguinte assinatura, onde os parâmetros são
dataframe.join(dfOutro, on, how, lsuffix, rsuffix, sort),
Todos os parâmetros são opcionais exceto dfOutro. Os defaults estão em negrito.

dfOutro DataFrame, Series ou lista de DataFrames.
on string, especifica em que chave(s) fazer a junção
how strings: left, right, outer, inner. Especifica o tipo de junção.
lsuffix/rsuffix Default = ”. String a concatenar à esquerda/direita em colunas com mesmo nome.
sort False/True. Se True ordena o dataframe pela chave de junção.
» # dataframe join
» df1 = pd.DataFrame({'nome': ['Paulo', 'Maria', 'Julio','Marta'],
                       'idade': [35, 43, 31, 56]})
» df2 = pd.DataFrame({'profissao': ['médico', 'engenheiro', 'advogado']})

» df1
↳      nome      idade
  0    Paulo     35
  1    Maria     43
  2    Julio     31
  3    Marta     56

» df2
↳      profissao
  0    médico
  1    engenheiro
  2    advogado

» df1.join(df2, on=df1.index,  lsuffix='_1', rsuffix='_2') # , how = 'left' (default)
↳      nome   idade_1    profissao    idade_2
  0   Paulo        35       médico       35.0
  1   Maria        43   engenheiro       40.0
  2   Julio        31     advogado       31.0
  3   Marta        56          NaN        NaN

» # um inner join
» df1.join(df2, lsuffix='_', how='inner')
↳      nome    idade_    profissao   idade
  0   Paulo       35        médico      35
  1   Maria       43    engenheiro      40
  2   Julio       31      advogado      31

Vários dataframes podem ser concatenados de uma vez. Para isso eles devem ter dimensões compatíveis.

» # Vários dataframes podem ser concatenados
» df1 = pd.DataFrame([[23, 83], [93, 10], [73, 89], [68, 90]],
»                    index=['a', 'b', 'e', 'f'],
»                    columns=['A', 'B'])

» df2 = pd.DataFrame([[2, 8], [9, 1], [7, 8], [6, 9]],
»                    index=['a', 'b', 'c', 'd'],
»                    columns=['C', 'D'])

» df3 = pd.DataFrame([[3, 3], [3, 0], [3, 9], [8, 0]],
»                    index=['a', 'c', 'd', 'e'],
»                    columns=['E', 'F'])

» # exibe os 3 dataframes
» display(df1, df2, df3)

↳ A    B
  a    23    83
  b    93    10
  e    73    89
  f    68    90

↳ C    D
  a    2    8
  b    9    1
  c    7    8
  d    6    9
  
↳ E    F
  a    3    3
  c    3    0
  d    3    9
  e    8    0

» # exibe a junção dos dataframes
» df1.join([df2, df3])

↳         A       B      C      D      E      F
  a    23.0    83.0    2.0    8.0    3.0    3.0
  b    93.0    10.0    9.0    1.0    NaN    NaN
  e    73.0    89.0    NaN    NaN    8.0    0.0
  f    68.0    90.0    NaN    NaN    NaN    NaN

Como sempre, campos não fornecidos são preenchidos por NaN. Por ex.: df1.join([df2, df3]).loc['f', 'F'] = NaN.

concatenate()

Podemos concatenar numpy.arrays, Series e dataframes ao longo do eixo desejado.

» # Concatenando um array ao longo de um eixo
» # criamos 2 arrays
» arr1 = np.arange(6).reshape((3, 2))

» arr1
↳ array([[0, 1],
         [2, 3],
         [4, 5]])

» # concatenando arr1 consigo mesmo, ao longo de colunas
» np.concatenate([arr1, arr1], axis=1)
↳ array([[0, 1, 0, 1],
         [2, 3, 2, 3],
         [4, 5, 4, 5]])

» # concatenando arr1 consigo mesmo, ao longo de linhas
» np.concatenate([arr1, arr1], axis=0)
↳ array([[0, 1],
         [2, 3],
         [4, 5],
         [0, 1],
         [2, 3],
         [4, 5]])

» # defina outro array, com shape (3, 1)
» arr2 = np.array([[0], [1], [2]])

» arr2
↳ array([[0],
         [1],
         [2]])

» # concatenando arr1 2 arr2 pelas colunas
» np.concatenate([arr1, arr2], axis=1)

↳ array([[0, 1, 0],
         [2, 3, 1],
         [4, 5, 2]])

» # (tentando) concatenar arr1 2 arr2 pelas linhas
» np.concatenate([arr1, arr2], axis=0)
↳ ValueError: all the input array dimensions for the concatenation axis must match exactly,
  but along dimension 1, the array at index 0 has size 2 and the array at index 1 has size 1


Vemos que podemos concatenar uma matriz coluna (3 × 1) com outra matriz (3 × 2) pelas colunas, mas não pelas linhas pois as dimensãos são incompatíveis.

combine() e combine_first()

O método df1.combine(df2, func, fill_value=None, overwrite=True) combina df1 e df2, coluna a coluna, aplicando func para decidir qual valor será usado.

Podemos criar uma função que receba duas colunas e realize alguma operação entre elas, retornando outra coluna. No ex., a função f faz a soma dos elementos de duas colunas e retorna aquela com menor soma. A função g seleciona, a cada linha, qual é o maior elemento. Quando o parâmetro fill_value=r é usado todos os valores NaN são substituídos por r antes de serem submetidos à função func, exceto se ambos os valores forem nulos, quando não existirá substituição.

» df1 = pd.DataFrame({'A': [0, 3], 'B': [7, 2]})
» df2 = pd.DataFrame({'A': [2, 6], 'B': [1, 3]})

» df1
↳      A    B
  0    0    7
  1    3    2

» df2
↳      A    B
  0    2    1
  1    6    3

» # a função de comparação pode ser
» def f(x,y):
»     if x.sum() < y.sum():
»         return x
»     else:
»         return y

» # a combinação, usando essa função
» df1.combine(df2, f)
↳      A    B
  0    0    1
  1    3    3

» # O mesmo resultado pode ser obtido com uma função lambda
» df1.combine(df2, lambda x, y: x if x.sum() < y.sum() else y)

» # funções mais complexas podem ser usadas
» df1.combine(df2, lambda x, y: (x+y)*(y-x))
↳       A     B
  0     4   -48
  1    27     5

» # outro exemplo, selecionar o maior elemento de cada df
» def g(x,y):
»     a = x[0] if x[0] > y[0] else y[0]
»     b = x[1] if x[1] > y[1] else y[1]
»     return pd.Series([a,b])

» df1.combine(df2,g)
↳      A    B
  0    2    7
  1    6    3

» # o mesmo poderia ser feito com uma funlão lambda
» maior = lambda x,y: pd.Series([x[0] if x[0] > y[0] else y[0],
                                x[1] if x[1] > y[1] else y[1]])
» df1.combine(df2,maior) # mesmo output
 
» # uso de fill_value
» df1 = pd.DataFrame({'A': [0, 0], 'B': [np.NaN, 4]})
» df2 = pd.DataFrame({'A': [1, 1], 'B': [3, 3]})

» df1.combine(df2, maior, fill_value=6)
↳      A      B
  0    1    6.0
  1    1    4.0

Já o método dataframe.combine_first(dfOutro) substitui os valores NaN no dataframe com os valores de dfOutro, quando esses valores existirem.

» df1 = pd.DataFrame({'a': [1, np.nan, 5, np.nan],
»                     'b': [np.nan, 2, np.nan, 6],
»                     'c': range(2, 18, 4)})
» df2 = pd.DataFrame({'a': [5, 4, np.nan, 3, 7],
»                     'b': [np.nan, 3, 4, 6, 8]})
» display(df1, df2)
↳        a      b     c
  0    1.0    NaN     2
  1    NaN    2.0     6
  2    5.0    NaN    10
  3    NaN    6.0    14
  
↳        a      b
  0    5.0    NaN
  1    4.0    3.0
  2    NaN    4.0
  3    3.0    6.0

» df1.combine_first(df2)
↳        a      b      c
  0    1.0    NaN    2.0
  1    4.0    2.0    6.0
  2    5.0    4.0   10.0
  3    3.0    6.0   14.0
  4    7.0    8.0    NaN

Bibliografia

Consulte bibliografia completa em Pandas, Introdução neste site.

Nesse site: