DEV Community

Arvin M Fernanda
Arvin M Fernanda

Posted on

Membuat Kode Go yang Type-Safe dari SQL Menggunakan sqlc (Indonesia)

Di sini kita akan membahas bagaimana menggunakan sqlc dalam sebuah proyek aplikasi back-end bahasa Go dengan database MySQL.

Daftar Isi

Faktor Kebutuhan akan sqlc

Berbeda dari ORM seperti GORM, sqlc tidak menyembunyikan SQL-nya. Justru sebaliknya, kita menulis query SQL sendiri, dan sqlc akan me-generate function-function Go yang sudah aman dari kesalahan tipe dan siap dipanggil.

Kesalahan tipe rentan terjadi baik saat melakukan binding row dari SELECT maupun mengisi parameter untuk INSERT, UPDATE, dan DELETE.

Alur Singkat Penggunaan sqlc

  1. Tulis query dalam file .sql
  2. Jalankan command sqlc generate
  3. Function Go tersedia dalam package yang kita tentukan dan siap digunakan! 🔥

Ayo Praktik!

Dokumentasi resmi dapat diakses di sini ya.

(Juga terdapat untuk database PostgreSQL dan SQLite)


A. Instalasi dan Setup

Struktur Direktori Proyek

Secara garis besar, kita akan membentuk folder proyek kita sebagai berikut.

learn-sqlc/
├── cmd/
│    └── main.go
├── db/
│    ├── migrations/
│    ├── queries/
│    └── sqlc/
└── repository/ 
Enter fullscreen mode Exit fullscreen mode

Instal sqlc

Jalankan command berikut di terminal!

go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest
Enter fullscreen mode Exit fullscreen mode

Oiya, di sini saya mencontohkan schema dalam file-file yang di-generate oleh golang-migrate. Jika mau ikut menggunakannya juga bisa memulai dari sini.

Setup sqlc pada folder proyek

Pada folder root, buat file bernama sqlc.yaml dengan isi seperti berikut.

version: "2"
sql:
  - engine: "mysql"
    queries: "db/queries"
    schema: "db/migrations"
    gen:
      go:
        package: "sqlc"
        out: "db/sqlc"
        sql_package: "database/sql"
        sql_driver: "github.com/go-sql-driver/mysql"
Enter fullscreen mode Exit fullscreen mode

Semisal tidak menggunakan golang-migrate, bisa buat saja nama foldernya seperti db/schema dengan isi sebagai berikut.

db/
├── schema/
│    ├── file1.sql
│    ├── file2.sql
│    └── dst.
Enter fullscreen mode Exit fullscreen mode

Lalu nanti ambil isi file xx.up.sql saja pada contoh-contoh yang saya cantumkan (tidak usah yang "down")

Catatan Penting:

  • value dari key package: akan digunakan sebagai nama package di awal setiap file .go yang di-generate oleh sqlc
  • value dari key out: akan menjadi folder target tempat meletakkan file-file .go yang di-generate oleh sqlc
  • pastikan menambahkan dependency modul driver SQL tersebut, bisa gunakan command berikut go get -u github.com/go-sql-driver/mysql, lalu import di file yang pasti tersentuh saat menjalankan runtime 😉

B. Membuat Queries dan Generate Function Go

1. Membuat schema pada database

Berikut adalah ERD yang akan kita implementasikan.

Gambar ERD

Saya membuatnya di eraser.io/cody

Saya telah membuat file migrasi di dalam folder db/migrations dengan isi seperti berikut.

.up.sql

CREATE TABLE IF NOT EXISTS teams (
    id CHAR(36) NOT NULL PRIMARY KEY,
    name VARCHAR(50) NULL
) ENGINE = InnoDB;

CREATE TABLE IF NOT EXISTS participants (
    id BIGINT AUTO_INCREMENT NOT NULL PRIMARY KEY,
    team_id CHAR(36) NOT NULL,
    name VARCHAR(50) NULL,
    CONSTRAINT fk_participant_team FOREIGN KEY (team_id) REFERENCES teams(id)
) ENGINE = InnoDB;
Enter fullscreen mode Exit fullscreen mode

.down.sql

DROP TABLE IF EXISTS participants;
DROP TABLE IF EXISTS teams;
Enter fullscreen mode Exit fullscreen mode

2. Membuat queries dan mencoba generate

Perlu diketahui 🔈
Pola membuat kueri untuk sqlc adalah:

--name :{Nama Function}: {jenis permintaan}
{kueri}
Enter fullscreen mode Exit fullscreen mode

team.sql

-- name: CreateTeam :execresult
INSERT INTO teams (
    id, name
) VALUES (?, ?);
Enter fullscreen mode Exit fullscreen mode

Sekarang kita akan mencoba melakukan generate function Go. Jalankan command berikut di terminal!

sqlc generate
Enter fullscreen mode Exit fullscreen mode

Jika kita memeriksa folder db/sqlc saat ini, kita akan menemukan file-file dengan format .go .

a. File db.go memuat isi seperti struct Queries dan constructornya. Queries (dengan properti dbnya) ini lah yang akan berlaku sebagai 'database handle' yang melakukan semacam (DB).Exec dan (DB).Query.

b. File model.go berisi struct-struct yang mewakili row berproperti persis setiap tabel yang ada berdasarkan sumber schema: dalam proyek ini adalah file-file dalam folder migrations.

Jika kita melakukan SELECT * FROM . . ., function hasil generate-nya akan di-bind langsung ke struct dari model.go

c. File-file berjumlah sesuai dengan jumlah file dalam folder queries, misalnya team.sql.go , berisi function-function yang di-generate berdasarkan sintaks SQL yang ada di dalam team.sql .
Di sini lah sqlc mendefinisikan function-function Go yang akan kita gunakan nantinya!

Perhatikan di dalam team.sql.go !

team.sql.go

// inisialisasi string createTeam sesuai sintaks SQL kita
const createTeam = `...`

type CreateTeamParams struct {
    ID   string
    Name sql.NullString
}

func (q *Queries) CreateTeam(ctx context.Context, arg CreateTeamParams) (sql.Result, error) {
    return q.db.ExecContext(ctx, createTeam, arg.ID, arg.Name)
}
Enter fullscreen mode Exit fullscreen mode

Kita sudah berhasil membuat satu buah function Go dengan nama CreateTeam() 😮

Perhatikan bahwa function CreateTeam() mengembalikan sql.Result dan error. sql.Result umumnya digunakan untuk mengambil last insert ID ataupun jumlah rows affected saat saat melakukan kueri yang berkarakteristik 'mutation': INSERT, UPDATE, DELETE.

Coba ubah keyword execresult menjadi exec saja lalu jalankan sqlc generate! Periksa bagaimana perbedaan function Go yang terbentuk!

Selain execresult dan exec, juga ada jenis permintaan ini:

  • execrows : langsung mengembalikan jumlah affected rows: (int64, error)
  • execlastid : langsung mengembalikan ID baru yang terbentuk: (int64, error)

Jika ingin langsung mencoba penggunaan function CreateTeam() ini, bisa langsung lompat ke bagian ini dulu ya!

3. Generate queries - naming parameters

Mari tambahkan function lagi pada file team.sql.go !

team.sql

-- name: CreateTeam :execresult
INSERT INTO teams (
    id, name
) VALUES (?, ?);

-- name: GetAllTeams :many
SELECT * FROM teams;

-- name: GetTeamIDByName :one
SELECT id FROM teams
WHERE name = ?;
Enter fullscreen mode Exit fullscreen mode

jangan lupa di-save 😄

Lalu jalankan command sqlc generate!

Sekarang kita menemukan dua function baru di dalam team.sql.go .

sqlc sebenarnya tetap melakukan generate seluruhnya (termasuk CreateTeam())

team.sql.go

func (q *Queries) GetAllTeams(ctx context.Context) ([]Team, error) {
    rows, err := q.db.QueryContext(ctx, getAllTeams)
    if err != nil {
        return nil, err
    }
    defer rows.Close()
    var items []Team
    for rows.Next() {
        var i Team
        if err := rows.Scan(&i.ID, &i.Name); err != nil {
            return nil, err
        }
        items = append(items, i)
    }
    if err := rows.Close(); err != nil {
        return nil, err
    }
    if err := rows.Err(); err != nil {
        return nil, err
    }
    return items, nil
}

func (q *Queries) GetTeamIDByName(ctx context.Context, name sql.NullString) (string, error) {
    row := q.db.QueryRowContext(ctx, getTeamIDByName, name)
    var id string
    err := row.Scan(&id)
    return id, err
}
Enter fullscreen mode Exit fullscreen mode
Mengubah nama parameter

Perhatikan bahwa function GetTeamIDByName() meminta argumen bertipe sql.NullString dengan sebutan name. Secara default, sqlc menggunakan nama kolom dari tabel sebagai nama parameter. Mungkin saja kita merasa lebih nyaman jika nama parameter tersebut lebih spesifik, misalnya teamName. Kita dapat mengkustomisasi nama parameter sebagai berikut.

Ada dua macam "naming parameter" pada sqlc, yaitu sqlc.arg() dan juga sqlc.narg() (untuk parameter yang nullable).

Mari memodifikasi kode file team.sql !

team.sql

-- name: GetTeamIDByName :one
SELECT id FROM teams
WHERE name = sqlc.arg('team_name');
Enter fullscreen mode Exit fullscreen mode

Jalankan sqlc generate!

Dan coba periksa kode function GetTeamIDByName() saat ini!

team.sql.go

func (q *Queries) GetTeamIDByName(ctx context.Context, teamName sql.NullString) (string, error) {
    row := q.db.QueryRowContext(ctx, getTeamIDByName, teamName)
    var id string
    err := row.Scan(&id)
    return id, err
}
Enter fullscreen mode Exit fullscreen mode

Nama parameter telah berubah menjadi teamName! 😉

Informasi tambahan:

  • Saat ada lebih dari satu parameter dalam kueri tertentu, function Go dari sqlc akan meminta parameter kedua (parameter setelah context) berupa satu buah struct yang membalut semua parameter yang diperlukan, contohnya seperti struct CreateTeamParams yang berisi properti ID dan Name.
  • Sedangkan jika hanya ada satu buah parameter dalam kueri SQL tertentu, function Go dari sqlc akan meminta parameter tersebut sebagai parameter kedua pada function, contohnya seperti name atau teamName pada GetTeamIDByName().
  • Begitu juga dengan row! Saat kueri hanya me-SELECT satu buah kolom maka sqlc akan me-return row tanpa membuat struct. Contohnya yaitu seperti kembalian string pada GetTeamIDByName().

4. Generate queries - specified rows

Perhatikan pada contoh sebelumnya return dari function GetAllTeams() adalah objek bertipe struct Team.

func (q *Queries) GetAllTeams(ctx context.Context) ([]Team, error) {
    ...                                               ^^^^                                                
}
Enter fullscreen mode Exit fullscreen mode

Hal ini akan selalu terjadi ketika kita mengambil * FROM {table}, rows akan di-bind ke struct yang terdefinisi dari file model.go .

Kita sebenarnya sudah mencoba membuat kueri dengan mengambil kolom id saja. Namun, apa yang terjadi jika yang kita minta bukan * dan lebih dari satu kolom?

Sekarang, coba buat file participant.sql di dalam folder db/queries dengan isi sebagai berikut!

participant.sql

-- name: GetParticipantByName :one
SELECT
    p.*,    -- atau p.id, p.name, p.team_id
    t.name
FROM participants p
LEFT JOIN teams t ON p.team_id = t.id
WHERE p.name = sqlc.arg(participant_name);
Enter fullscreen mode Exit fullscreen mode

Lalu jalankan command sqlc generate!

Saat ini, di dalam folder db/sqlc telah terbentuk sebuah file baru bernama participant.sql.go . Di dalamnya, sebelum definisi dari function GetParticipantByName(), kita akan menemukan definisi dari struct berikut.

type GetParticipantByNameRow struct {
    ID     int64
    TeamID string
    Name   sql.NullString
    Name_2 sql.NullString
}
Enter fullscreen mode Exit fullscreen mode

Struct ini lah yang digunakan oleh sqlc untuk mem-bind row yang didapatkan dari MySQL untuk kemudian menjadi return dari functionnya. Secara default, sqlc akan mendefinisikan struct tersebut dengan nama berformat {NamaFunction}Row, dengan nama kolom menyesuaikan nama-nama kolom bentukan MySQL dari kueri tersebut.

Menggunakan AS pada nama kolom

Modifikasi isi file participant.sql menjadi seperti berikut!

participant.sql

-- name: GetParticipantByName :one
SELECT
    p.id AS participant_id,
    p.name AS participant_name,
    p.team_id,
    t.name AS team_name
FROM participants p
LEFT JOIN teams t ON p.team_id = t.id
WHERE p.name = sqlc.arg(participant_name);
Enter fullscreen mode Exit fullscreen mode

Lalu generate kembali!

Inilah struct yang akan terdefinisikan dalam participant.sql.go .

type GetParticipantByNameRow struct {
    ParticipantID   int64
    ParticipantName sql.NullString
    TeamID          string
    TeamName        sql.NullString
}
Enter fullscreen mode Exit fullscreen mode

sqlc akan mengikuti kolom yang terbentuk dari kueri SQL kita, baik pada nama kolom maupun urutan kolom.


C. Menggunakan Function Hasil Generate

1. Menggunakan function DML

Apa itu DML dan DQL? Sudah dibahas cukup lengkap oleh @ronnychen dalam artikel ini

Kita akan menggunakan function CreateTeam() yang ada pada team.sql.go .

Pada folder repository, buat file team_repository.go !

team_repository.go

type TeamRepository interface {
    CreateTeam(name string)
}

type teamRepository struct {
    db *sqlc.Queries
}

// constructor
func NewTeamRepository(q *sqlc.Queries) TeamRepository {
    return &teamRepository{db: q}
}

func (r *teamRepository) CreateTeam(name string) {
    params := sqlc.CreateTeamParams{
        ID:     uuid.NewString()
        Name:   sql.NullString{String: name, Valid: true}
    }

    err := r.db.CreateTeam(context.Background(), params)
    if err != nil {
        fmt.Println("Failed to insert new team to database")
        return
    }
}
Enter fullscreen mode Exit fullscreen mode

Lalu kita panggil pada cmd/main.go .

main.go

func main() {
    godotenv.Load()

    db := config.InitDB()
    defer db.Close()

    queries := sqlc.New(db)
    teamRepo := repository.NewTeamRepository(queries)

    teamRepo.CreateTeam("Team A")
    teamRepo.CreateTeam("Team B")
}
Enter fullscreen mode Exit fullscreen mode

Sekarang cek pada database! Lihat isi tabel teams untuk memastikan data team baru sudah benar-benar ter-insert 😎

Menggunakan sql.Result untuk last insert ID

Masih ingat bahwa id pada schema tabel participants tadi kita buat dengan atribut AUTO_INCREMENT?? Yap, id-nya tidak kita masukkan secara manual, tetapi diset oleh MySQL. Jika kita membutuhkan id baru tersebut, kita dapat mengambilnya dari sql.Result.

Untuk mendapatkan return sql.Result jangan lupa menggunakan execresult (atau langsung execlastid) ketimbang exec

participant.sql

-- name: CreateParticipant :execresult
INSERT INTO participants (
    name, team_id
) VALUES (?, ?);
Enter fullscreen mode Exit fullscreen mode

sqlc generate akan menghasilkan function berikut.

participant.sql.go

func (q *Queries) CreateParticipant(ctx context.Context, arg CreateParticipantParams) (sql.Result, error) {
    ...
}
Enter fullscreen mode Exit fullscreen mode

function CreateParticipant() mengembalikan sql.Result dan error

Buat file participant_repository.go !

participant_repository.go

type ParticipantRepository interface {
    CreateParticipant(name, teamID string)
}

type participantRepository struct {
    db *sqlc.Queries
}

// constructor
func NewParticipantRepository(q *sqlc.Queries) ParticipantRepository {
    return &participantRepository{db: q}
}

func (r *participantRepository) CreateParticipant(name, teamID string) {
    params := sqlc.CreateParticipantParams {
        Name:   sql.NullString{String: name, Valid: true},
        TeamID: teamID,
    }

    result, err := r.db.CreateParticipant(context.Background(), params)
    if err != nil {
        fmt.Printf("Failed to insert new participant to database")
        return
    }

    ID, _ := result.LastInsertId()
    fmt.Printf("Successfully inserted new participant with ID: %d\n", ID)
}
Enter fullscreen mode Exit fullscreen mode

Kita akan mencoba memanggil CreateParticipant() ini di main.go . Parameter yang dibutuhkan untuk menambahkan data partisipan ke database adalah nama partisipan dan id grup. Kita akan menyediakan function GetTeamIDByName() terlebih dahulu 😉

team_repository.go

func (r *teamRepository) GetTeamIDByName(name string) string {
    row, err := r.db.GetTeamIDByName(context.Background(), sql.NullString{
        String: name,
        Valid:  true,
    })
    if err != nil {
        panic(errors.New("failed to get team ID"))
    }

    return row
}
Enter fullscreen mode Exit fullscreen mode

Oke, sekarang kita tinggal melengkapi main.go !

Agar method/function bisa diakses, jangan lupa mencantumkan function yang telah ditambahkan ke interface {X}Repository ya!

main.go

participantRepo := repository.NewParticipantRepository(queries)

participantRepo.CreateParticipant("John1", teamRepo.GetTeamIDByName("Team B"))
participantRepo.CreateParticipant("John2", teamRepo.GetTeamIDByName("Team A"))
participantRepo.CreateParticipant("John3", teamRepo.GetTeamIDByName("Team B"))
Enter fullscreen mode Exit fullscreen mode

Periksa di database bahwasanya data partisipan telah ditambahkan!

Menggunakan sql.Result untuk rows affected

Selain mengambil ID terakhir, sql.Result juga dapat memuat jumlah baris yang terpengaruh oleh eksekusi kueri, misalnya pada kueri UPDATE dan DELETE.

team.sql

-- name: UpdateTeamName :execresult
UPDATE teams SET name = sqlc.arg('new_name')
WHERE name LIKE sqlc.arg('old_name');
Enter fullscreen mode Exit fullscreen mode

Generate, lalu buat function untuk TeamRepository!

team_repository.go

func (r *teamRepository) ChangeTeamName(old, new string) {
    params := sqlc.UpdateTeamParams {
        OldName: sql.NullString{String: old, Valid: true},
        NewName: sql.NullString{String: new, Valid: true},
    }

    result, err := r.db.UpdateTeamName(context.Background(), params)
    if err != nil {
        fmt.Printf("There were some issues when updating the team: %v\n", err)
        return
    }

    affected, _ := result.RowsAffected()
    fmt.Printf("Successfully updated the name of %d team(s)", affected)
}
Enter fullscreen mode Exit fullscreen mode

Coba di main.go dengan argumen sebagai berikut.

main.go

teamRepo.UpdateTeamName("Team %", "Unused Team from Prev. Chapter")
Enter fullscreen mode Exit fullscreen mode

Lalu perhatikan log yang tercetak di konsol untuk memeriksa nilai affected yang kita dapatkan!

2. Menggunakan function DQL

Kita akan menggunakan function GetAllTeams() untuk kemudian mencetak data tim pada konsol.
Lengkapi file team_repository.go !

team_repository.go

func printTeam(row sqlc.Team, num int) {
    fmt.Printf("\nTeam #%d\n", num)
    fmt.Printf("ID\t: %s\n", row.ID)
    fmt.Printf("Name\t: %s\n", row.Name.String)
}

func (r *teamRepository) GetAllTeams() {
    rows, err := r.db.GetAllTeams(context.Background())
    if err != nil {
        fmt.Printf("Failed to fetch teams from database: %v", err)
        return
    }

    for i, row := range rows {
        printTeam(row, i)
    }
}
Enter fullscreen mode Exit fullscreen mode

Kita mengambil id tim dengan row.ID dan nama tim dengan row.Name.String (yaitu value properti String pada sql.NullString).

Coba panggil di main.go !

Percobaan terakhir

Untuk yang terakhir, kita akan mencetak data partisipan beserta timnya.

Lengkapi participant.sql !

participant.sql

-- name: GetParticipantByName :one
SELECT
    p.id AS participant_id,
    p.name AS participant_name,
    p.team_id,
    t.name AS team_name
FROM participants p
LEFT JOIN teams t ON p.team_id = t.id
WHERE p.name = sqlc.arg(participant_name);
Enter fullscreen mode Exit fullscreen mode

Generate, lalu lengkapi participant_repository.go !

Perhatikan bahwasanya kembalian dari function yang dibuat oleh sqlc bertipe struct GetParticipantByNameRow dengan properti:

  • ParticipantID, bertipe int64
  • ParticipantName, bertipe sql.NullString
  • TeamID, bertipe string
  • TeamName, bertipe sql.NullString

participant_repository.go

func printParticipant(row sqlc.GetParticipantByNameRow) {
    fmt.Println("\nPrinting participant data . . .")
    fmt.Printf("ID\t\t: %d\n", row.ParticipantID)
    fmt.Printf("Name\t\t: %s\n", row.ParticipantName.String)
    fmt.Printf("Team ID\t\t: %s\n", row.TeamID)
    fmt.Printf("Team Name\t: %s\n\n", row.TeamName.String)
}

func (r *participantRepository) GetParticipantByName(name string) {
    row, err := r.db.GetParticipantByName(context.Background(), sql.NullString{
        String: name,
        Valid:  true,
    })
    if err != nil {
        fmt.Println("Failed to fetch participant from database")
        return
    }

    printParticipant(row)
}
Enter fullscreen mode Exit fullscreen mode

Sekarang kita panggil di main.go .

main.go

participantRepo.GetParticipantByName("John3")
Enter fullscreen mode Exit fullscreen mode

Begini contoh hasil yang didapatkan.


Selesai 😃

Top comments (0)