Tenha controle sobre seu SQL com Golang e SQLC

Wiliam V. Joaquim - Nov 25 '23 - - Dev Community

Introdução

Lidar com o SQL muitas vezes se torna complicado e cansativo, lidar com tantas queries, indexações, performance, segurança, transações e tudo que envolve o uso do SQL, pensando nisso cada vez mais é adotado o uso de ORM, visando facilitar nossas vidas.

O Uso de ORM geralmente é recomendado devido sua a praticidade de uso, agilidade, entrega de queries mais seguras e rápidas (nem sempre), porém nem tudo é perfeito, o uso de ORM acaba nos limitando quando é preciso fazer uma query mais complexa, a grande maioria dos ORM não oferecem uma boa performance com queries complexas ou até mesmo não tem suporte, precisando então usar SQL no próprio ORM.

Em GO, a comunidade desencoraja o uso de ORM, apesar do GORM ser um excelente ORM, o uso dele pode sim fazer sentido em pequenas aplicações que não necessitam de uma query mais complexa.

O usar em Go então?

Podemos usar o GORM, como mencionei, podemos fazer nossas queries e usar apenas o driver do banco de dados, como o pq para PostgreSQL (é a opção mais trabalhosa, porém com controle total), mas como o intuito do post é falar sobre o SQLC, vamos usar ele.

O que é o SQLC?

SQLC é um pacote para facilitar a execução e manipulação das nossas consultas ao banco, ele gera o código automaticamente de acordo com a query SQL que escrevemos.

O SQLC faz a parte mais chata para nós, que é receber os dados do banco e passar para nossa struct por exemplo, e já gera as interfaces para nós.

Pode parecer estranho, vai gerar código GO? Não deve ser bom, eu pensava da mesma forma, até utilizar.

Instalando o SQLC

Precisamos instalar o SQLC na nossa máquina, mas como qualquer pacote em GO, é bem simples, veja como nas docs, lembrando que por enquanto o SQLC tem suporte apenas a PostgreSQL, MySQL e SQLite.

Criando o projeto

Vamos ao código, para isso vamos criar um projeto simples:

Project structure

Vamos apenas ter nosso main.go, que vai iniciar a conexão com o banco PostgreSQL e fazer nossa aplicação rodar:

  package main

  import (
    "database/sql"
    "fmt"
    "log"
    "os"

    "github.com/joho/godotenv"
    _ "github.com/lib/pq"
  )

  func main() {
    // load .env file
    godotenv.Load()

    postgresURI := os.Getenv("DATABASE_URL")
    db, err := sql.Open("postgres", postgresURI)
    if err != nil {
      log.Panic(err)
    }
    err = db.Ping()
    if err != nil {
      db.Close()
      log.Panic(err)
    }

    fmt.Println("Connected to database")

    // keep the program running
    select {}
  }
Enter fullscreen mode Exit fullscreen mode

Na pasta internal/database, no arquivo queries.sql vai ser onde vamos deixar nossas queries.
Na pasta internal/database/migrations, vamos deixar a migration que vai criar as nossas tabelas, já fiz um post de como usar o golang-migrate, veja aqui.

Para rodar as migrations, verifique se o seu banco PostgreSQL está rodando na porta 5432 (vou deixar um arquivo docker-compose no repositório), depois rode a migration com:

  make migrate_up
Enter fullscreen mode Exit fullscreen mode

para desfazer a migration rode:

  make migrate_down
Enter fullscreen mode Exit fullscreen mode

Executando o SQLC

Finalmente, vamos gerar nossa query e rodar o SQLC, mas antes só verifique se está realmente instalando o SQLC na sua máquina com o comando

  sqlc version
Enter fullscreen mode Exit fullscreen mode

Se receber uma versão v1.20.0 por exemplo, está tudo certo (não precisa ser a mesma versão do exemplo).

Precisamos antes criar um arquivo de config do SLQC, chamado sqlc.yaml na raiz do projeto, nele vamos definir o banco que vamos usar, caminho das pastas e outras configs.

version: "2"
sql:
  - schema: "internal/database/migrations"
    queries: "internal/database/queries"
    engine: "postgresql"
    gen:
      go:
        package: "db"
        out: "internal/database/db"
Enter fullscreen mode Exit fullscreen mode
  • schema: onde fica nosso arquivo da estrutura da nossa tabela no banco de dados.
  • queries: Pasta com as queries que vamos criar.
  • out: Aqui é onde o SQLC vai gerar seu código.

Vamos criar uma query chamada user.sql que adiciona um usuário no banco, veja como fica usando o SQLC:

  -- name: CreateOneUser :exec
  INSERT INTO users (id, name, email, password, created_at, updated_at)
  VALUES ($1, $2, $3, $4, $5, $6);

Enter fullscreen mode Exit fullscreen mode

O SQLC utiliza anotações para as queries, veja:

  • --name: Indica o nome da query, será usado como o nome da interface.
  • exec: Indica que queremos executar a query, existem várias, veja aqui nas docs.

Vamos criar os arquivos com o comando do SQLC:

  sqlc generate
Enter fullscreen mode Exit fullscreen mode

Você vai perceber que vai ser gerado uma basta chamada db dentro da pasta database, essa pasta é de uso exclusivo do SQLC e não deve ser alterada.

O SQLC criou 3 arquivos, vamos ver eles:

db.go: Aqui é onde o SQLC lida com a conexão do banco, transactions e as queries.

  type DBTX interface {
    ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
    PrepareContext(context.Context, string) (*sql.Stmt, error)
    QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
    QueryRowContext(context.Context, string, ...interface{}) *sql.Row
  }

  func New(db DBTX) *Queries {
    return &Queries{db: db}
  }

  type Queries struct {
    db DBTX
  }

  func (q *Queries) WithTx(tx *sql.Tx) *Queries {
    return &Queries{
      db: tx,
    }
  }
Enter fullscreen mode Exit fullscreen mode

models.go: Aqui é onde o SQLC cria os nossos models, de acordo com a estrutura do nosso banco.

  type Post struct {
    ID        string
    AuthorID  string
    Title     string
    Body      string
    CreatedAt time.Time
    UpdatedAt time.Time
  }

  type User struct {
    ID        string
    Name      string
    Email     string
    Password  string
    CreatedAt time.Time
    UpdatedAt time.Time
  }
Enter fullscreen mode Exit fullscreen mode

user.sql.go: Aqui é onde o SQLC cria as funções que vamos executar e nossas structs para envio dos parâmetros:

  const createOneUser = `-- name: CreateOneUser :exec
  INSERT INTO users (id, name, email, password, created_at, updated_at)
  VALUES ($1, $2, $3, $4, $5, $6)
  `

  type CreateOneUserParams struct {
    ID        string
    Name      string
    Email     string
    Password  string
    CreatedAt time.Time
    UpdatedAt time.Time
  }

  func (q *Queries) CreateOneUser(ctx context.Context, arg CreateOneUserParams) error {
    _, err := q.db.ExecContext(ctx, createOneUser,
      arg.ID,
      arg.Name,
      arg.Email,
      arg.Password,
      arg.CreatedAt,
      arg.UpdatedAt,
    )
    return err
  }
Enter fullscreen mode Exit fullscreen mode

O SQLC também deixa a query que vai ser executada, no nosso caso em createOneUser.

Criando um registro com SQLC

Vamos salvar um usuário com uso do SQLC, no nosso caso vamos salvar o usuário quando iniciar nossa aplicação, mas em uso real, seria criado quando receber uma requisição POST de um controller por exemplo.

Vamos criar dentro da pasta internal, uma pasta chamada handler e o arquivo chamado user.go:

  package handler

  import (
    "context"
    "time"

    "github.com/google/uuid"
    "github.com/wiliamvj/golang-sqlc/internal/database/db"
  )

  type UserHandler struct {
    Queries *db.Queries
  }

  func CreateUser(ctx context.Context, h *UserHandler) error {
    err := h.Queries.CreateOneUser(ctx, db.CreateOneUserParams{
      ID:        uuid.New().String(),
      Name:      "John Doe",
      Email:     "john.doe@email.com",
      Password:  "123456",
      CreatedAt: time.Now(),
      UpdatedAt: time.Now(),
    })
    if err != nil {
      fmt.Println("Error creating user", err)
      return err
    }
    fmt.Println("User created")
    return nil
  }
Enter fullscreen mode Exit fullscreen mode

Criamos uma struct, que vai receber um ponteiro das queries criadas pelo SQLC, depois usamos a função CreateOneUser criada pelo SQLC e passamos os parâmetros esperados, isso é o suficiente para salvar no banco, vamos agora chamar o CreateUser no main.go, simulando uma requisição.

   // start slqc queries
  slqcQueries := db.New(dbConnection)
  q := handler.UserHandler{
    Queries: slqcQueries,
  }
  handler.CreateUser(context.Background(), &q)
Enter fullscreen mode Exit fullscreen mode

Iniciamos no SQLC passando para o db.New a conexão com o banco, depois iniciamos a struct UserHandler e por fim chamamos a função CreateUser.

main.go completo:

  func main() {
    // load .env file
    godotenv.Load()

    postgresURI := os.Getenv("DATABASE_URL")
    dbConnection, err := sql.Open("postgres", postgresURI)
    if err != nil {
      log.Panic(err)
    }
    err = dbConnection.Ping()
    if err != nil {
      dbConnection.Close()
      log.Panic(err)
    }

    fmt.Println("Connected to database")

    // start slqc queries
    slqcQueries := db.New(dbConnection)
    q := handler.UserHandler{
      Queries: slqcQueries,
    }
    handler.CreateUser(context.Background(), &q)

    // keep the program running
    select {}
  }
Enter fullscreen mode Exit fullscreen mode

Agora se iniciar a aplicação, vamos receber o log User created:

  Connected to database
  User created
Enter fullscreen mode Exit fullscreen mode

Se tentar rodar novamente, vamos receber um erro, isso porque nosso e-mail é único no banco, e já existe, o correto é antes de salvar no banco, verificar se já existe um registro com o mesmo e-mail, mas não vamos fazer isso por enquanto.

  Connected to database
  Error creating user pq: duplicate key value violates unique constraint "users_email_key"
Enter fullscreen mode Exit fullscreen mode

Agora, vamos salvar nosso post, vamos criar o usuário e logo em seguida criar o post, vamos fazer isso para exemplificar um problema que vamos enfrentar:

Criando query post.sqlna pasta queries para salvar o post:

  -- name: CreateOnePost :exec
  INSERT INTO posts (id, title, body, author_id, created_at, updated_at)
  VALUES ($1, $2, $3, $4, $5, $6);
Enter fullscreen mode Exit fullscreen mode

Vamos rodar novamente o comando do sqlc para atualizar os arquivos:

  sqlc generate
Enter fullscreen mode Exit fullscreen mode

Com os arquivos atualizados, vamos criar o post dentro do user.go

  type UserHandler struct {
    Queries *db.Queries
  }

  func CreateUser(ctx context.Context, h *UserHandler) error {
    userID := uuid.New().String()

    err := h.Queries.CreateOneUser(ctx, db.CreateOneUserParams{
      ID:        userID,
      Name:      "John Doe",
      Email:     "john.doe@email.com",
      Password:  "123456",
      CreatedAt: time.Now(),
      UpdatedAt: time.Now(),
    })
    if err != nil {
      fmt.Println("Error creating user", err)
      return err
    }
    fmt.Println("User created")

    // create post
    err = h.Queries.CreateOnePost(ctx, db.CreateOnePostParams{
      ID:        uuid.New().String(),
      Title:     "SLQC with Golang",
      Body:      "This is a post about SLQC with Golang",
      AuthorID:  userID,
      CreatedAt: time.Now(),
      UpdatedAt: time.Now(),
    })
    if err != nil {
      fmt.Println("Error creating post", err)
      return err
    }
    fmt.Println("Post created")
    return nil
  }
Enter fullscreen mode Exit fullscreen mode

Fizemos o mesmo processo ao salvar o usuário, mas perceba que separei o id do usuário en uma variável chamada userID, pois precisamos do id para salvar o post, uma vez que temos um relacionamento one-to-many, onde um usuário pode ter muitos posts e um post tem apenas 1 usuário, é apenas um exemplo.

Ao rodar o projeto novamente, vamos ter sucesso ao salvar o usuário e o post, mas antes lembre-se de apagar todos os registros, como não validamos se já existe registros de usuário com mesmo e-mail, vai dar erro.

  Connected to database
  User created
  Post created
Enter fullscreen mode Exit fullscreen mode

Nosso usuário e nosso post foram criados com sucesso, porém quando criamos registros em "cascata", se um deles falhar por algum motivo os demais ainda podem ser criados, vamos ver como resolver isso com transactions.

Transactions

As transactions são um conjunto de operações que são tratadas como uma única operação, no nosso exemplo ao criar um usuário em seguida já criamos um post, as duas operações depende uma da outra, então usamos as transações.

Uma transação só pode ter sucesso ou falhar, se tiver sucesso, salvamos todos os registros, em nosso exemplo será criado um usuário e um post, mas se falhar, não será criado nenhum registros, caso a criação do post falhe, será feito um rollback nas operações anteriores, ou seja o usuário não será salvo. Isso garante que todas as operações realizadas sejam escritas com sucesso, mas se falhar nenhuma operação é salva.

O SQLC nos permite trabalhar com isso, vamos a um exemplo, imagine que sempre que criar um usuário seja obrigatório criar um post, mas e se a criação do post falhar? Vamos alterar o user.go, forçando um erro, vamos sempre tentar salvar um post com o mesmo id, na segunda tentativa já vai falar, pois teremos no banco um registro com o mesmo id.

  func CreateUser(ctx context.Context, h *UserHandler) error {
    userID := uuid.New().String()
    userEmail := fmt.Sprintf("john.doe-%v@email.com", time.Now().Unix())

    err := h.Queries.CreateOneUser(ctx, db.CreateOneUserParams{
      ID:        userID,
      Name:      "John Doe",
      Email:     userEmail,
      Password:  "123456",
      CreatedAt: time.Now(),
      UpdatedAt: time.Now(),
    })
    if err != nil {
      fmt.Println("Error creating user", err)
      return err
    }
    fmt.Println("User created")

    // create post
    err = h.Queries.CreateOnePost(ctx, db.CreateOnePostParams{
      ID:        "093eb8c7-d09d-464d-aa55-99ee7c1b7488",
      Title:     "SLQC with Golang",
      Body:      "This is a post about SLQC with Golang",
      AuthorID:  userID,
      CreatedAt: time.Now(),
      UpdatedAt: time.Now(),
    })
    if err != nil {
      fmt.Println("Error creating post", err)
      return err
    }
    fmt.Println("Post created")
    return nil
  }
Enter fullscreen mode Exit fullscreen mode

Deixamos o id do post sempre o mesmo, também coloquei o userEmail para gerar um e-mail único sempre que iniciar nossa aplicação, recebemos o erro na segunda tentativa:

  Connected to database
  User created
  Error creating post pq: duplicate key value violates unique constraint "posts_pkey"
Enter fullscreen mode Exit fullscreen mode

Mas se você perceber na tabela users o usuário é sempre criado, vamos então usar transactions.

Utilizando transactions com SQLC

Para utilizar transactions, vamos injetar uma função no SQLX que executa nossa queries, ficando assim:

  func runWithTX(ctx context.Context, c *sql.DB, fn func(*db.Queries) error) error {
    tx, err := c.BeginTx(ctx, nil)
    if err != nil {
      return err
    }
    q := db.New(tx)
    err = fn(q)
    if err != nil {
      if errRb := tx.Rollback(); errRb != nil {
        return fmt.Errorf("error on rollback: %v, original error: %w", errRb, err)
      }
      return err
    }
    return tx.Commit()
  }
Enter fullscreen mode Exit fullscreen mode

Essa função vai ser a que vamos usar para executar as queries com transactions, passamos a conexão com o banco, iniciamos a conexão com transaction com BeginTx, passando o contexto e nil, o nil é referente isolation level (não vamos abordar no momento, mas veja aqui do que se trata, iniciamos o db.New(tx), conforme já vimos, mas agora usando transactions, depois executamos nossa query, se der erro fazemos tx.Rollback(), se não houver erro tx.Commit() finalizando tudo.

Para isso funcionar, precisamos atualizar a função CreateUser:

  func CreateUser(ctx context.Context, h *UserHandler) error {
    userID := uuid.New().String()
    userEmail := fmt.Sprintf("john.doe-%v@email.com", time.Now().Unix())

    err := runWithTX(ctx, h.Db, func(q *db.Queries) error {
      var err error
      err = q.CreateOneUser(ctx, db.CreateOneUserParams{
        ID:        userID,
        Name:      "John Doe",
        Email:     userEmail,
        Password:  "123456",
        CreatedAt: time.Now(),
        UpdatedAt: time.Now(),
      })
      if err != nil {
        fmt.Println("Error creating user", err)
        return err
      }
      fmt.Println("User created")

      // create post
      err = q.CreateOnePost(ctx, db.CreateOnePostParams{
        ID:        "193eb8c7-d09d-464d-aa55-99ee7c1b7488",
        Title:     "SLQC with Golang",
        Body:      "This is a post about SLQC with Golang",
        AuthorID:  userID,
        CreatedAt: time.Now(),
        UpdatedAt: time.Now(),
      })
      if err != nil {
        fmt.Println("Error creating post", err)
        return err
      }
      fmt.Println("Post created")
      return nil
    })
    if err != nil {
      fmt.Println("Error creating user and post, roll back applied", err)
      return err
    }
    return nil
  }
Enter fullscreen mode Exit fullscreen mode

Agora utilizamos o runWithTX, e ao usar as queries do sqlc, não usamos mais o h.Queries, usamos o q, que vem da função que injetamos, assim executando utilizando transactions, agora se rodas a aplicação, veja o erro:

  Connected to database
  User created
  Error creating post pq: duplicate key value violates unique constraint "posts_pkey"
  Error creating user and post, roll back applied pq: duplicate key value violates unique constraint "posts_pkey"
Enter fullscreen mode Exit fullscreen mode

No log creating user and post, roll back applied, pode perceber que a transactions rodou, se olhar no banco, nenhum usuário agora é salvo devido ao erro na criação do post, se remover o id do post que está fixo, e colocar novamente o
uuid.New().String(), e rodar a aplicação, tudo funcionará normalmente.

Abordamos apenas um exemplo simples sobre transactions, é um assunto mais complexo, vou deixar a documentação do sqlc sobre transactions.

Considerações finais

Como podem perceber o uso do sqlc nos possibilita controle total sobre nosso SQL, o sqlc também é bastante simples de utilizar. Se você não quer usar ORM a utilização do sqlc pode ser uma opção, remove o trabalho mais braçal que é ficar transformando dados conhecido como "hidratação de dados".

O sqlc tem vários "truques" que podem facilitar a manipulação de dados como Overriding types, Naming parameters e muitos outros, vale a pena olhar as docs.

Link do repositório

repositório do projeto

link do projeto no meu blog

Gopher credits

. . . . . . . . . . . . . . . . . . . . . .