Avançando com Go e Postgresql

Recentemente me surpreendi com o fato de que alguns usuários da comunidade “Go Brasil” no Telegram estavam usando um artigo meu como referência e decidi ampliar essa colaboração criando este conteúdo que segue.

Pois bem, neste artigo gostaria dar exemplos de funcionalidades do postgresql em conjunto com o driver que uso para Go (pgx).

Como muitos devem saber, o postgresql tem alguns tipos de dados que não são comuns em todas as soluções de bancos relacionais. Sendo assim, não faz muito sentido que um ORM vá se preocupar em dar suporte a tais tipos. Vejam o seguinte exemplo:

Num cenário em que tenhamos um cadastro de usuários e que estes possam assumir vários papéis num sistema, poderíamos modelar as tabelas assim (omitirei campos que não estejam diretamente relacionados ao exemplo):

    CREATE TABLE users (
        id SERIAL PRIMARY KEY,
        username VARCHAR NOT NULL UNIQUE
    );
    CREATE TABLE roles (
        id SERIAL PRIMARY KEY,
        rolename VARCHAR NOT NULL UNIQUE
    );
    CREATE TABLE users_roles (
        user_id INTEGER NOT NULL REFERENCES users(id),
        role_id INTEGER NOT NULL REFERENCES roles(id),
        UNIQUE (user_id, role_id)
    );

    INSERT INTO users (username) VALUES
        ('admin'),
        ('hitalos')
    ;
    INSERT INTO roles (rolename) VALUES
        ('administrador'),
        ('editor'),
        ('revisor'),
        ('leitor')
    ;
    INSERT INTO users_roles (user_id, role_id)
        SELECT (SELECT id FROM users WHERE username = 'admin'), id FROM roles;
    INSERT INTO users_roles (user_id, role_id)
        SELECT (SELECT id FROM users WHERE username = 'hitalos'), id FROM roles WHERE rolename = 'leitor';

Vejam que o usuário admin recebeu todos os papéis enquanto o usuário hitalos recebeu apenas o papel de ’leitor’.

Nessa abordagem, para listar todos os usuários e seus papéis e atribuí-los a um slice de structs, faríamos os seguintes tipos:

type User struct {
    ID       uint64 `json:"id"`
    Username string `json:"username"`
    Roles    []Role `json:"roles"`
}

type Role struct {
    ID       uint64 `json:"id"`
    Rolename string `json:"rolename"`
}

Então executaríamos uma query para consultar todos os usuários, depois todos os papéis e ainda uma consulta na terceira tabela para “cruzar as informações”. Ou seja, três consultas e mais um bocado de processamento do lado da nossa aplicação. Segue um exemplo:

func listUsers(ctx context.Context, conn *pgx.Conn) ([]User, error) {
    rows, err := conn.Query(ctx, "SELECT id, username FROM users")
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    users := []User{}
    for rows.Next() {
        u := new(User)
        if err := rows.Scan(&u.ID, &u.Username); err != nil {
            return nil, err
        }
        users = append(users, *u)
    }

    rows, err = conn.Query(ctx, "SELECT id, rolename FROM roles")
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    roles := []Role{}
    for rows.Next() {
        r := new(Role)
        if err := rows.Scan(&r.ID, &r.Rolename); err != nil {
            return nil, err
        }
        roles = append(roles, *r)
    }

    rows, err = conn.Query(ctx, "SELECT user_id, role_id FROM users_roles")
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    for rows.Next() {
        var userID, roleID uint64
        if err := rows.Scan(&userID, &roleID); err != nil {
            return nil, err
        }
        for i := range users {
            if users[i].ID == userID {
                for j := range roles {
                    if roles[j].ID == roleID {
                        users[i].Roles = append(users[i].Roles, roles[j])
                    }
                }
            }
        }
    }

    return users, nil
}

Vejam que ainda precisei fazer 3 loops aninhados pra cruzar tudo!

Agora vejam uma versão usando recursos do próprio banco:

func listUsersOptimized(ctx context.Context, conn *pgx.Conn) ([]User, error) {
    rows, err := conn.Query(ctx, `
        SELECT id, username, (
            SELECT JSONB_AGG(r.*)
            FROM (
                SELECT id, rolename
                FROM roles INNER JOIN users_roles AS ur ON id = ur.role_id AND u.id = ur.user_id
            ) AS r
        ) AS roles
        FROM users AS u
        GROUP BY id, username`)
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    return pgx.CollectRows(rows, pgx.RowToStructByPos[User])
}

Bem mais simples, não acham? Jogamos a complexidade para o banco tratar, afinal ele que está com os dados e vai nos devolver só o que interessa e já devidamente “arrumado”.

Aproveitei para usar a nova função ColletRows que faz uso de generics (sugestão do usuário “Douglas Zuqueto” lá do grupo do Telegram que citei). Nem precisamos de loops!

Link do código: main.go

Exemplo de saída convertendo para JSON:

[
  {
    "id": 1,
    "username": "admin",
    "roles": [
      {
        "id": 1,
        "rolename": "administrador"
      },
      {
        "id": 2,
        "rolename": "editor"
      },
      {
        "id": 3,
        "rolename": "revisor"
      },
      {
        "id": 4,
        "rolename": "leitor"
      }
    ]
  },
  {
    "id": 2,
    "username": "hitalos",
    "roles": [
      {
        "id": 4,
        "rolename": "leitor"
      }
    ]
  }
]

Conclusão

Nos 2 casos, temos como resultado o mesmo slice. Resta saber se há impacto no tempo total de processamento. Acredito que com uma quantidade razoável de dados, a segunda função deva se sair melhor. No próximo exemplo, tentarei trazer situações mais complexas ou inusitadas. Espero que não leve tanto tempo.