API completa em Golang - Parte 6

Wiliam V. Joaquim - Jan 23 - - Dev Community

O que vamos fazer?

Na parte 6 do nosso crud vamos finalizar nosso repository, salvando os dados do usuários no banco de dados utilizando o sqlc.

Criando a tabela de endereços

Primeiramente vamos criar uma tabela de endereços, nela vamos salvar os endereços dos nossos usuários, poderíamos salvar esses dados na mesma tabela do usuário, porém decidi separar para utilizarmos relacionamentos e transactions com o postgres.

Vamos criar uma nova migration com o comando make create_migration. Criamos esse comando na parte 2, com a nova migration criada, vamos escrever o sql na migration up:

  CREATE TABLE address (
    id VARCHAR(36) NOT NULL PRIMARY KEY,
    cep VARCHAR(255) NOT NULL,
    ibge VARCHAR(255) NOT NULL,
    uf VARCHAR(255) NOT NULL,
    city VARCHAR(255) NOT NULL,
    complement VARCHAR(255) NULL,
    street VARCHAR(255) NOT NULL,
    created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP(3) NOT NULL,
    user_id VARCHAR(36) UNIQUE NOT NULL,

    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
  );
Enter fullscreen mode Exit fullscreen mode

Criamos os campos que pegamos na api do viacep e adicionamos um relacionamento com o usuário (one to one), onde um usuário tem um endereço e um endereço pertence a um usuário.

Na migration down podemos fazer o sql que desfaz o que fizemos acima:

  DROP TABLE IF EXISTS address;
Enter fullscreen mode Exit fullscreen mode

Agora podemos rodar o comando make migrate_up, não se esqueça de garantir que o banco de dados esteja rodando, se você clonou o repositório, basta usar o comando docker compose up -d que já vai subir um container com o postgres.

Com isso temos nossa tabela relacionanda com o usuário.

Criando as queries

Primeiro vamos criar nossas queries para salvar um usuário, buscar um usuário pelo id, buscar todos os usuários, deletar usuário, atualizar um usuário e atualizar a senha do usuário.

CreateUser

Essa query vai ser responsável por salvar um novo usuário:

  -- name: CreateUser :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

Não se esqueça das queries annotations do sqlc.

FindUserByEmail

Essa query vai ser responsável por buscar um usuário pelo e-mail:

  -- name: FindUserByEmail :one
  SELECT u.id, u.name, u.email FROM users u WHERE u.email = $1;
Enter fullscreen mode Exit fullscreen mode

FindUserByID

Essa query vai ser responsável por buscar um usuário pelo ID e também vamos retornar o endereço fazendo um join:

  -- name: FindUserByID :one
  SELECT u.id, u.name, u.email, u.created_At, u.updated_at, a.cep, a.uf, a.city, a.complement, a.street
  FROM users u
  JOIN address a ON a.user_id = u.id
  WHERE u.id = $1;
Enter fullscreen mode Exit fullscreen mode

UpdateUser

Essa query vai ser responsável por atualizar o usuário, usei os nullable parameters do sqlc, isso ajuda a lidar com dados nulos, como atualizar dados é opcional, caso o usuário deseja atualizar apenas o name por exemplo, o resto dos dados não é anulado. Sem isso precisaríamos fazer algumas validações a mais:

  -- name: UpdateUser :exec
  UPDATE users SET
  name = COALESCE(sqlc.narg('name'), name),
  email = COALESCE(sqlc.narg('email'), email),
  updated_at = $2
  WHERE id = $1;
Enter fullscreen mode Exit fullscreen mode

DeleteUser

Essa query vai ser responsável por deletar um usuário pelo id, consequentemente deleta o endereço, pois criamos o relacionamento com a opção DELETE CASCADE:

  -- name: DeleteUser :exec
  DELETE FROM users WHERE id = $1;
Enter fullscreen mode Exit fullscreen mode

FindManyUsers

Essa query vai ser responsável por buscar todos os usuários com seus endereços, exatamente o que fizemos no FindUserByID mas agora sem o WHERE:

  -- name: FindManyUsers :many
  SELECT u.id, u.name, u.email, u.created_At, u.updated_at, a.cep, a.uf, a.city, a.complement, a.street
  FROM users u
  JOIN address a ON a.user_id = u.id
  ORDER BY u.created_at DESC;
Enter fullscreen mode Exit fullscreen mode

UpdatePassword

Essa query vai ser responsável por atualizar apenas a senha do usuário:

  -- name: UpdatePassword :exec
  UPDATE users SET password = $2, updated_at = $3 WHERE id = $1;
Enter fullscreen mode Exit fullscreen mode

GetUserPassword

Essa query vai ser responsável por buscar apenas a senha do usuário:

  -- name: GetUserPassword :one
  SELECT u.password FROM users u WHERE u.id = $1;
Enter fullscreen mode Exit fullscreen mode

CreateUserAddress

Essa query vai ser responsável por criar o endereço do usuário na tabelas address:

  -- name: CreateUserAddress :exec
  INSERT INTO address (id, user_id, cep, ibge, uf, city, complement, street, created_at, updated_at)
  VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10);
Enter fullscreen mode Exit fullscreen mode

CreateUserAddress

Por fim, essa query vai atualizar o endereço do usuário, usando o nullable parameters do sqlc:

  -- name: UpdateUserAddress :exec
  UPDATE address SET
  cep = COALESCE(sqlc.narg('cep'), cep),
  ibge = COALESCE(sqlc.narg('ibge'), ibge),
  uf = COALESCE(sqlc.narg('uf'), uf),
  city = COALESCE(sqlc.narg('city'), city),
  complement = COALESCE(sqlc.narg('complement'), complement),
  street = COALESCE(sqlc.narg('street'), street),
  updated_at = $2
  WHERE user_id = $1;
Enter fullscreen mode Exit fullscreen mode

Gerando o código sqlc

Com as queries escritas vamos rodar o sqlc para fazer sua mágica e gerar o código, vamos rodar com o comando:

  sqlc generate
Enter fullscreen mode Exit fullscreen mode

Se notar na pasta internal/database/sqlc vai perceber que foi gerado bastante código, lembrando que não é preciso alterar nada da pasta sqlc.

Finalizando o repository

Agora vamos ajustar nosso repository, até o momento apenas implementamos, agora vamos chamar o sqlc para fazer as execuções no nosso banco.

CreateUser

  func (r *repository) CreateUser(ctx context.Context, u *entity.UserEntity) error {
    err := r.queries.CreateUser(ctx, sqlc.CreateUserParams{
      ID:        u.ID,
      Name:      u.Name,
      Email:     u.Email,
      Password:  u.Password,
      CreatedAt: u.CreatedAt,
      UpdatedAt: u.UpdatedAt,
    })
    if err != nil {
      return err
    }
    err = r.queries.CreateUserAddress(ctx, sqlc.CreateUserAddressParams{
      ID:         uuid.New().String(),
      UserID:     u.ID,
      Cep:        u.Address.CEP,
      Ibge:       u.Address.IBGE,
      Uf:         u.Address.UF,
      City:       u.Address.City,
      Complement: sql.NullString{String: u.Address.Complement, Valid: u.Address.Complement != ""},
      Street:     u.Address.Street,
      CreatedAt:  time.Now(),
      UpdatedAt:  time.Now(),
    })
    if err != nil {
      return err
    }
    return nil
  }
Enter fullscreen mode Exit fullscreen mode

No código acima, chamamos q nossa queries que iniciamos lá no main.go (queries := sqlc.New(dbConnection)), chamamos o CreateUser que definimos na annotation (-- name: CreateUser :exec), por fim o sqlc criou uma struct CreateUserParams dentro da pasta sqlc, nela vamos passar nossos dados. Depois de criar o usuário e se não houver erro, criamos o endereço com CreateUserAddress, nosso repository será bastante simples já que a única responsabilidade dele é chamar o sqlc fazer as tarefas no banco e retornar dados ou um erro, nada mais que isso.

Reparou em um problema? no caso do método CreateUser realiza duas tarefas no nosso banco, imagine que se ao salvar o endereço ocorra um erro? Nosso usuário vai ficar sem endereço e isso começa a degradar nosso banco de dados, isso é um problema grave! A forma correta de tratar esse problema seria salvar esses dados de forma atômica, vamos fazer isso com transactions, mas antes vamos finalizar o repository.

FindUserByEmail

  func (r *repository) FindUserByEmail(ctx context.Context, email string) (*entity.UserEntity, error) {
    user, err := r.queries.FindUserByEmail(ctx, email)
    if err != nil {
      return nil, err
    }
    userEntity := entity.UserEntity{
      ID:    user.ID,
      Name:  user.Name,
      Email: user.Email,
    }
    return &userEntity, nil
  }
Enter fullscreen mode Exit fullscreen mode

Buscar nosso usuário pelo e-mail é bem simples, passamos o contexto e o e-mail e depois tratamos para retornar o UserEntity, nesse caso o FindUserByEmail poderia retornar apenas um boolean, já utilizar apenas para saber se um usuário existe ou não, mas vamos manter assim por enquanto.

FindUserByID

  func (r *repository) FindUserByID(ctx context.Context, id string) (*entity.UserEntity, error) {
    user, err := r.queries.FindUserByID(ctx, id)
    if err != nil {
      return nil, err
    }
    userEntity := entity.UserEntity{
      ID:    user.ID,
      Name:  user.Name,
      Email: user.Email,
      Address: entity.UserAddress{
        CEP:        user.Cep,
        UF:         user.Uf,
        City:       user.City,
        Complement: user.Complement.String,
        Street:     user.Street,
      },
      CreatedAt: user.CreatedAt,
      UpdatedAt: user.UpdatedAt,
    }
    return &userEntity, nil
  }
Enter fullscreen mode Exit fullscreen mode

Muito semelhante ao FindUserByEmail, porém agora nosso critério de busca é pelo ID e retornamos o endereço.

UpdateUser

  func (r *repository) UpdateUser(ctx context.Context, u *entity.UserEntity) error {
    err := r.queries.UpdateUser(ctx, sqlc.UpdateUserParams{
      ID:        u.ID,
      Name:      sql.NullString{String: u.Name, Valid: u.Name != ""},
      Email:     sql.NullString{String: u.Email, Valid: u.Email != ""},
      UpdatedAt: u.UpdatedAt,
    })
    if err != nil {
      return err
    }
    err = r.queries.UpdateUserAddress(ctx, sqlc.UpdateUserAddressParams{
      UserID:     u.ID,
      Cep:        sql.NullString{String: u.Address.CEP, Valid: u.Address.CEP != ""},
      Ibge:       sql.NullString{String: u.Address.IBGE, Valid: u.Address.IBGE != ""},
      Uf:         sql.NullString{String: u.Address.UF, Valid: u.Address.UF != ""},
      City:       sql.NullString{String: u.Address.City, Valid: u.Address.City != ""},
      Complement: sql.NullString{String: u.Address.Complement, Valid: u.Address.Complement != ""},
      Street:     sql.NullString{String: u.Address.Street, Valid: u.Address.Street != ""},
      UpdatedAt:  time.Now(),
    })
    if err != nil {
      return err
    }
    return nil
  }
Enter fullscreen mode Exit fullscreen mode

Para atualizar um usuário também é muito simples, a única diferença que temos do CreateUser é que precisamos tratar os dados que são opcionais, como utilizamos o nullable parameters do sqlc, foi gerado a struct dessa forma:

  type UpdateUserParams struct {
    ID        string
    UpdatedAt time.Time
    Name      sql.NullString
    Email     sql.NullString
  }
Enter fullscreen mode Exit fullscreen mode

Por isso usamos o sql.NullString caso o dado seja do tipo não null no contexto que estamos, então devemos atualizar, caso contrário não alteramos nada no banco.

Novamente o método UpdateUser atualiza o usuário e endereço, temos o mesmo problema do CreateUser e vamos resolver com transactions.

DeleteUser

  func (r *repository) DeleteUser(ctx context.Context, id string) error {
    err := r.queries.DeleteUser(ctx, id)
    if err != nil {
      return err
    }
    return nil
  }
Enter fullscreen mode Exit fullscreen mode

Esse deve ser o mais simples, apenas deletamos o usuário, sem retorar nada.

FindManyUsers

  func (r *repository) FindManyUsers(ctx context.Context) ([]entity.UserEntity, error) {
    users, err := r.queries.FindManyUsers(ctx)
    if err != nil {
      return nil, err
    }
    var usersEntity []entity.UserEntity
    for _, user := range users {
      userEntity := entity.UserEntity{
        ID:    user.ID,
        Name:  user.Name,
        Email: user.Email,
        Address: entity.UserAddress{
          CEP:        user.Cep,
          UF:         user.Uf,
          City:       user.City,
          Street:     user.Street,
          Complement: user.Complement.String,
        },
        CreatedAt: user.CreatedAt,
        UpdatedAt: user.UpdatedAt,
      }
      usersEntity = append(usersEntity, userEntity)
    }
    return usersEntity, nil
  }
Enter fullscreen mode Exit fullscreen mode

Para buscar todos os usuário também não tem segredo, a única coisa diferente que fazemos é um for para mapear os dados para um slice de entity.UserEntity.

UpdatePassword

  func (r *repository) UpdatePassword(ctx context.Context, pass, id string) error {
    err := r.queries.UpdatePassword(ctx, sqlc.UpdatePasswordParams{
      ID:        id,
      Password:  pass,
      UpdatedAt: time.Now(),
    })
    if err != nil {
      return err
    }
    return nil
  }
Enter fullscreen mode Exit fullscreen mode

Para atualizar a senha do usuário, apenas passamos a nova senha, id e um nova data para informar sua atualização.

GetUserPassword

  func (r *repository) GetUserPassword(ctx context.Context, id string) (string, error) {
    pass, err := r.queries.GetUserPassword(ctx, id)
    if err != nil {
      return "", err
    }
    return pass, nil
  }
Enter fullscreen mode Exit fullscreen mode

Por fim, esse método usamos apenas para buscar a senha do usuário, alterei para retornar apenas uma string nos últimos posts ele retornava um ponteiro do entity.UserEntity, não se esqueça de alterar na interface user_interface_repository.

Nosso repository está quase pronto, como pode perceber é bastante simples e com pouca responsabilidade no nosso projeto, antes de partir para as transactions vamos fazer alguns ajustes na nossa api.

Ajustando o service

Vamos precisar ajustar algumas coisas no service, alguns tratamentos que acabei esquecendo de passar.

No método CreateUser do service, precisamos tratar ao verificar se o usuário existe pelo e-mail se o repository retornar um erro e esse erro for diferente de um erro do tipo sql.ErrNoRows, então temos um erro que devemos retornar, isso garante que seja retornado apenas um erro que não esperamos, quando o postgres do pacote pg não encontra um registro no banco é retornado um sql.ErrNoRows que nada mais é que um errors.New("sql: no rows in result set").

  userExists, err := s.repo.FindUserByEmail(ctx, u.Email)
  if err != nil {
    if err != sql.ErrNoRows { // adicione esse if
      slog.Error("error to search user by email", "err", err, slog.String("package", "userservice"))
      return err
    }
  }
Enter fullscreen mode Exit fullscreen mode

Precisamos ajustar também no método UpdateUser, caso o erro seja do tipo sql.ErrNoRows significa que não encontrou o usuário.

  _, err := s.repo.FindUserByID(ctx, id)
  if err != nil {
    if err == sql.ErrNoRows {
      slog.Error("user not found", slog.String("package", "userservice"))
      return errors.New("user not found")
    }
    slog.Error("error to search user by id", "err", err, slog.String("package", "userservice"))
    return err
  }
Enter fullscreen mode Exit fullscreen mode

Caso o usuário informe um e-mail para atualização, precisamos validar também:

  if u.Email != "" {
    userExists, err := s.repo.FindUserByEmail(ctx, u.Email)
    if err != nil {
      if err != sql.ErrNoRows {
        slog.Error("error to search user by email", "err", err, slog.String("package", "userservice"))
        return errors.New("error to search user by email")
      }
    }
    if userExists != nil {
      slog.Error("user already exists", slog.String("package", "userservice"))
      return errors.New("user already exists")
    }
    updateUser.Email = u.Email
  }
Enter fullscreen mode Exit fullscreen mode

Alterando o response

Precisamos adicionar o address ao response, vamos alterar o user_response.go:

  type UserResponse struct {
    ID        string      `json:"id"`
    Name      string      `json:"name"`
    Email     string      `json:"email"`
    Address   UserAddress `json:"address"`
    CreatedAt time.Time   `json:"created_at"`
    UpdatedAt time.Time   `json:"updated_at"`
  }

  type UserAddress struct {
    CEP        string `json:"cep"`
    UF         string `json:"uf"`
    City       string `json:"city"`
    Complement string `json:"complement,omitempty"`
    Street     string `json:"street"`
  }
Enter fullscreen mode Exit fullscreen mode

Alterando o auth

Vamos modificar o auth_service.go não vamos mais retornar a senha no método FindUserByEmail, para isso vamos buscar a senha do usuários com o método GetUserPassword:

  pass, err := s.repo.GetUserPassword(ctx, user.ID)
  if err != nil {
    slog.Error("error to search user password", "err", err, slog.String("package", "userservice"))
    return nil, errors.New("error to search user password")
  }
Enter fullscreen mode Exit fullscreen mode

Agora para comparar as senhas usamos o pass:

  err = bcrypt.CompareHashAndPassword([]byte(pass), []byte(u.Password))
Enter fullscreen mode Exit fullscreen mode

o mesmo no método UpdateUserPassword do user_service.go:

  oldPass, err := s.repo.GetUserPassword(ctx, id)
  if err != nil {
    slog.Error("error to get user password", "err", err, slog.String("package", "userservice"))
    return err
  }
Enter fullscreen mode Exit fullscreen mode

Agora usamos o oldPass para comparar:

 // compare passwords
  err = bcrypt.CompareHashAndPassword([]byte(oldPass), []byte(u.OldPassword))
  if err != nil {
    slog.Error("invalid password", slog.String("package", "userservice"))
    return errors.New("invalid password")
  }
  // compare new password with password in database
  err = bcrypt.CompareHashAndPassword([]byte(oldPass), []byte(u.Password))
  if err == nil {
    slog.Error("new password is equal to old password", slog.String("package", "userservice"))
    return errors.New("new password is equal to old password")
  }
Enter fullscreen mode Exit fullscreen mode

Melhorando o arquivo http

Nosso arquivo http_client.http precisamos definir o token de autenticação de forma manual após fazer login, vamos usar um "trick" do rest client para deixar dinâmico:

# @name login
POST http://localhost:8080/auth/login HTTP/1.1
content-type: application/json

{
  "email": "john.doe3@email.com",
  "password": "12345678@"
}
@token = { {login.response.body.access_token} }
Enter fullscreen mode Exit fullscreen mode

Definimos um nome para esse endpoint com o @name login, depois pegamos o token da resposta e colocamos no @token, agora podemos usar o token nos demais endpoints, dessa forma:

GET http://localhost:8080/user/list-all HTTP/1.1
content-type: application/json
Authorization: Bearer { {token} }
Enter fullscreen mode Exit fullscreen mode

Agora não precisamos mais ficar copiando o token e colocando em cada endpoint 😎.

Se quiser saber mais sobre esse truques veja aqui nas docs.

Trabalhando com transactions

Temos um problema para salvar e editar um usuário, como são dois comandos executados um após o outro (salvar usuário e salvar endereço) e não queremos um usuário sem endereço e um endereço sem usuário, caso ocorra um erro ao salvar qualquer um dos dados, o outro já aconteceu, por exemplo:

func (r *repository) CreateUser(ctx context.Context, u *entity.UserEntity) error {
    err := r.queries.CreateUser(ctx, sqlc.CreateUserParams{
      ID:        u.ID,
      Name:      u.Name,
      Email:     u.Email,
      Password:  u.Password,
      CreatedAt: u.CreatedAt,
      UpdatedAt: u.UpdatedAt,
    })
    if err != nil {
      return err
    }
    err = r.queries.CreateUserAddress(ctx, sqlc.CreateUserAddressParams{
      ID:         uuid.New().String(),
      UserID:     u.ID,
      Cep:        u.Address.CEP,
      Ibge:       u.Address.IBGE,
      Uf:         u.Address.UF,
      City:       u.Address.City,
      Complement: sql.NullString{String: u.Address.Complement, Valid: u.Address.Complement != ""},
      Street:     u.Address.Street,
      CreatedAt:  time.Now(),
      UpdatedAt:  time.Now(),
    })
    if err != nil {
      return err
    }
    return nil
  }
Enter fullscreen mode Exit fullscreen mode

Perceba que criamos primeiro o usuário, esse comando já foi efetuado no banco, caso ocorra um erro ao salvar o endereço o usuário criado no primeiro comando o usuário vai ficar sem endereço, isso gera um problema sério para a nossa aplicação, precisamos garantir que seja salvo todos os dados ou nada seja salvo em caso de erro, por isso precisamos fazer isso de forma atômica.

Como assim? Utilizando transactions o comando de salvar o usuário e o endereço entram em uma única transação, que por sua vez só pode ter dois resultados possíveis ou salvamos todos os dados corretamente ou nada é salvo. Digamos que aconteça um erro ao salvar o endereço, mas o usuário já foi salvo, utilizando transaction o usuário seria removido do banco (roll back), pois o comando de salvar endereço falhou, se tudo ocorrer com sucesso tudo é salvo (commit). Uma transação pode ter várias operações, no nosso exemplo vamos utilizar apenas duas que são salvar o usuário e seu endereço ou é tudo salvo com sucesso ou nada é salvo.

Transactions são um assunto um pouco mais complexo, não vou me aprofundar, quero apenas mostrar a forma correta de executar vários comandos no banco de dados sem perder dados e degradar o banco.

Vou mostrar um exemplo do problema:

func (r *repository) CreateUser(ctx context.Context, u *entity.UserEntity) error {
    err := r.queries.CreateUser(ctx, sqlc.CreateUserParams{
      ID:        u.ID,
      Name:      u.Name,
      Email:     u.Email,
      Password:  u.Password,
      CreatedAt: u.CreatedAt,
      UpdatedAt: u.UpdatedAt,
    })
    if err != nil {
      return err
    }
    return errors.New("error")
    err = r.queries.CreateUserAddress(ctx, sqlc.CreateUserAddressParams{
      ID:         uuid.New().String(),
      UserID:     u.ID,
      Cep:        u.Address.CEP,
      Ibge:       u.Address.IBGE,
      Uf:         u.Address.UF,
      City:       u.Address.City,
      Complement: sql.NullString{String: u.Address.Complement, Valid: u.Address.Complement != ""},
      Street:     u.Address.Street,
      CreatedAt:  time.Now(),
      UpdatedAt:  time.Now(),
    })
    if err != nil {
      return err
    }
    return nil
  }
Enter fullscreen mode Exit fullscreen mode

Adicionei um return return errors.New("error") logo após salvar o usuário, vamos testar e chamar o endpoint de criação de usuário:

Dbeaver

O usuário foi criado com sucesso.

Dbeaver

Porém o endereço não foi salvo, devido ao return que adicionamos para simular um erro.

Utilizando transactions

O sqlc já oferece suporte a transactions, veja nas docs.

Vamos criar uma pasta chamada transaction dentro da pasta repository e um arquivo chamado run_transaction.go:

  func Run(ctx context.Context, c *sql.DB, fn func(*sqlc.Queries) error) error {
    tx, err := c.BeginTx(ctx, nil)
    if err != nil {
      return err
    }
    q := sqlc.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

Esse código vamos utilizar para injetar durante a execução do sqlc, ele vai ser responsável por dar rollBack e caso de erro e fazer o commit em caso de sucesso.

Perceba que é bem semelhante ao que fazemos no main iniciando o sqlc com sqlc.New(tx), o BeginTx pode ser um pouco mais complexo de entender, ele vai recebe um contexto o nil se trata do isolate level, podemos definir níveis de isolamento para a nossa transação, mas como disse é um assunto mais complexo, vamos deixar o isolamento padrão. Veja mais sobre isolate level nesse post.

Bom, com a nossa função pronta, podemos alterar o método CreateUser do nosso repository, ficando assim:

  func (r *repository) CreateUser(ctx context.Context, u *entity.UserEntity) error {
    err := transaction.Run(ctx, r.db, func(q *sqlc.Queries) error {
      var err error
      err = q.CreateUser(ctx, sqlc.CreateUserParams{
        ID:        u.ID,
        Name:      u.Name,
        Email:     u.Email,
        Password:  u.Password,
        CreatedAt: u.CreatedAt,
        UpdatedAt: u.UpdatedAt,
      })
      if err != nil {
        return err
      }
      err = q.CreateUserAddress(ctx, sqlc.CreateUserAddressParams{
        ID:         uuid.New().String(),
        UserID:     u.ID,
        Cep:        u.Address.CEP,
        Ibge:       u.Address.IBGE,
        Uf:         u.Address.UF,
        City:       u.Address.City,
        Complement: sql.NullString{String: u.Address.Complement, Valid: u.Address.Complement != ""},
        Street:     u.Address.Street,
        CreatedAt:  time.Now(),
        UpdatedAt:  time.Now(),
      })
      if err != nil {
        return err
      }
      return nil
    })
    if err != nil {
      slog.Error("error to create user and address, roll back applied", "err", err)
      return err
    }
    return nil
  }
Enter fullscreen mode Exit fullscreen mode

Agora usamos a função Run que criamos, passamos o contexto, conexão com o banco e o ponteiro das nossas queries do sqlc, a partir de agora tudo que acontecer dentro dessa função é uma transaction única. Também precisamos usar as queries do sqlc está no parâmetro q da função anônima, antes usávamos do r.queries, se houver qualquer erro agora nossa transaction vai fazer um rollback, se tudo ocorrer com sucesso vamos ter um commit da transação.

Você pode fazer novamente o teste e colocar o return errors.New("error") antes de salvar o endereço do usuário, depois olhe no banco, vai perceber que o usuário não foi salvo, inclusive nossa api já vai retornar um erro 400 para o cliente.

Implementando no UpdateUser

Vamos alterar também nosso método UpdateUser:

  func (r *repository) UpdateUser(ctx context.Context, u *entity.UserEntity) error {
    err := transaction.Run(ctx, r.db, func(q *sqlc.Queries) error {
      var err error
      err = r.queries.UpdateUser(ctx, sqlc.UpdateUserParams{
        ID:        u.ID,
        Name:      sql.NullString{String: u.Name, Valid: u.Name != ""},
        Email:     sql.NullString{String: u.Email, Valid: u.Email != ""},
        UpdatedAt: u.UpdatedAt,
      })
      if err != nil {
        return err
      }
      err = r.queries.UpdateUserAddress(ctx, sqlc.UpdateUserAddressParams{
        UserID:     u.ID,
        Cep:        sql.NullString{String: u.Address.CEP, Valid: u.Address.CEP != ""},
        Ibge:       sql.NullString{String: u.Address.IBGE, Valid: u.Address.IBGE != ""},
        Uf:         sql.NullString{String: u.Address.UF, Valid: u.Address.UF != ""},
        City:       sql.NullString{String: u.Address.City, Valid: u.Address.City != ""},
        Complement: sql.NullString{String: u.Address.Complement, Valid: u.Address.Complement != ""},
        Street:     sql.NullString{String: u.Address.Street, Valid: u.Address.Street != ""},
        UpdatedAt:  time.Now(),
      })
      if err != nil {
        return err
      }
      return nil
    })
    if err != nil {
      slog.Error("error to update user and address, roll back applied", "err", err)
      return err
    }
    return nil
  }
Enter fullscreen mode Exit fullscreen mode

Agora com a função Run feita, podemos usar facilmente onde precisar efetuar mais de um comando no banco de dados.

Atualizando as docs

Para finalizar vamos gerar novamente as nossas docs, já que modificamos o response, basta rodar o comando:

  swag init -g internal/handler/routes/docs_route.go
Enter fullscreen mode Exit fullscreen mode

Considerações finais

Nesse post conseguimos finalizar a nosso repository, persistindo os dados no banco, também utilizamos transactions, deixando nossa api mais robusta.

Próximos passos

Estamos chegando na reta final da nossa série, espero que esteja curtindo. Na parte 7 vamos implementar a parte de produtos, desde o handler até o repository.

Link do repositório

repositório do projeto

link do projeto no meu blog

Se inscreva e receba um aviso sobre novos posts, participar

Gopher credits

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