Criei um ORM em Python para minha empresa

Aqui na RPS Capital praticamente 100% do nosso dia a dia é lidando com dados no pandas, e muitos desses dados são implementados no SQL de uma maneira que cada projeto seja independente de models definidos via código (sei que é uma má prática, mas precisamos de muita agilidade e pouca complexidade).

Cada consulta que fazemos aqui dentro é única, mesmo puxando dados das mesmas tabelas, o que torna difícil construir funções padronizadas para consultas.

O Problema (ou meu toc)

Muitos aqui ainda fazem consultas da seguinte maneira no Python

import rps_databases # módulo que usamos internamente

db = rps_databases.connect()
SQL = """
    SELECT column_a, column_b
    FROM myschema.mytable
    WHERE id in %s
"""

ids = [1,2,3]

df = db.fetch(SQL, (ids,))

É um exemplo simples, porém, como a query é um texto, eu poderia escrever ela em infinitas variações, que é o que acontece. Eu escrevi do jeito que eu acredito ser o mais legível, mas mesmo assim, não é um padrão.

A solução

df = db.myschema.mytable.get(columns=["column_a", "column_b"], id=ids)

Foi possível resumir em 1 linha o que foi feito em 6 linhas.

O que aconteceu aqui?

A classe Database tem acesso dinâmico às propriedades dela, se eu tento acessar uma propriedade que não existe, ela automaticamente é considerada como um "schema". Isso foi possível utilizando a função "__getattribute__":

class Database:
    ...
    def __getattribute__(self, attr):
        try:
            return object.__getattribute__(self, attr)
        except AttributeError:
            return Schema(db=self, name=attr)

A mesma coisa acontece com a classe Schema, que retorna uma Table ao acessar qualquer propriedade:

class Schema:
    def __init__(self, db, name: str):
        self.db = db
        self.name = name

    def __getattribute__(self, attr):
        try:
            return object.__getattribute__(self, attr)
        except AttributeError:
            return Table(db=self.db, schema=self, name=attr)

Sério, o schema só serve pra isso.

O resto da mágica acontece na classe Table:

class Table:
    def __init__(self, db: "Database", schema: "Schema", name: str):
        self.schema = schema
        self.name = name
        self.db = db

    def path(self):
        return f"{self.schema.name}.{self.name}"

Como ela conhece o caminho do schema e da tabela, só precisamos de uma função para construir a query baseado em alguns requísitos.

    def get(
        self,
        columns: Optional[list] = None,
        distinct: Optional[list] = None,
        min: Optional[list] = None,
        max: Optional[list] = None,
        sum: Optional[list] = None,
        avg: Optional[list] = None,
        **simple_where,
    ) -> pd.DataFrame:
    ...

Com apenas esses parâmetros, já dá pra fazer muita coisa... Aqui vai uns exemplos:

from rps_databases import operators as o

# Idade média por sobrenome onde os usuários são de curitiba
db.public.users.get(columns=["last_name"], avg=["age"], city="Curitiba")

# Idade média por sobrenome onde os usuários não são de curitiba
db.public.users.get(columns=["last_name"], avg=["age"], city=o.Not("Curitiba"))

# Idade média por sobrenome onde os usuários são de curitiba e são paulo
db.public.users.get(columns=["last_name"], avg=["age"], city=["Curitiba", "São Paulo"])

# Idade média por sobrenome onde os usuários não são de curitiba e são paulo
db.public.users.get(columns=["last_name"], avg=["age"], city=o.Not(["Curitiba", "São Paulo"]))

# Data mais recente em uma tabela
db.public.transactions.get(max=["transaction_date"])

# Data mais recente trocando de nome
db.public.transactions.get(max=[
    ("transaction_date", "most_recent_date") # pega o max de transaction_date e renomeia pra most_recent_date
])

# Consulta todas as colunas filtrando as linhas que o last_name contenha em algum lugar "A"
db.public.users.get(last_name=o.Ilike("A"))

Acho que deu pra pegar a ideia, o objetivo foi deixar consultas menos verbosas e menos complexas sem depender de models/schemas definidos.

Muito interessante. assim mitiga as chances de uma falha, além de ficar mais "fácil" de fazer as consultas no banco de dados. Achei muito legal.

Sim, o código fica mais padronizado. A lib tem algumas limitações do tipo não poder dar JOINS, mas eu até prefiro que tenha essa limitação para continuar com a simplicidade, a ideia é a query ficar bem limpa, e que transformações mais complexas sejam feitas utilizando funções do pandas.

Se deixar publico, deixa como módulo e com material de implementação em português também.

Eu tô doido pra deixar esse projeto público, eu só preciso de um nome... alguma ideia?

Uma dica que dou para este tipo de caso é usar a biblioteca ConnectorX, "Biblioteca mais rápida para carregar dados de banco de dados para DataFrames em Rust e Python" https://github.com/sfu-db/connector-x

Opa, não entendi muito bem, vi meio por cima a lib (só o readme) e é utilizado SQL, o objetivo aqui foi eliminar SQL dentro do python. Mas pelo o que eu vi poderia ser útil para otimizar o tempo da consulta.

Cara, sensacional! mples, direto e mega funcional!

Obrigado! Estou até pensando em tornar o código público.
Eu gostaria de poder contribuir, achei muito interessante o teu projeto.
Seria bem legal... A codebase não tá aquela coisa bonita, não existem testes, to começando a usar type hints só agora. Eu vou pensar em uma forma aqui já que esse é mais um projeto pessoal do que qualquer outra coisa.