Featured image of post J'écris encore du SQL
photo parNikola Jovanovic

J'écris encore du SQL

La génération de code avec sqlc

Read in English

Préambule

Aujourd’hui, j’aimerais vous parler d’un proof-of-concept que je viens tout récemment d’appliquer dans mon travail. Mais avant ça, j’aimerais raconter comment j’en suis venu à m’intéresser au sujet dont je m’apprête à vous parler.

J’ai toujours aimé le SQL. Ce langage à la fois simple et élégant permettant d’effectuer des requêtes au sein d’une base de données, comme si on lui parlait directement.
Quand j’étais à l’école, c’est peut-être la première fois qu’en informatique j’avais l’impression de parler une autre langue. Une base de données nous fournira toujours les informations que l’on veut, pour peu qu’on sache comment les lui demander.

Je suis alors devenu développeur, et je me suis rappelé que si j’ai appris à communiquer avec une base de données c’était surtout pour l’appliquer au sein même de mes programmes. Inclure des requêtes SQL dans un code source, donc.
Comme tout étudiant j’ai connu la joie d’écrire des SELECT * et INSERT INTO dans des chaînes de caractères concaténées à des valeurs que je passais dans des formulaires, et l’émerveillement de voir s’afficher les données de ma base dans mes pages web.

Comme beaucoup d’étudiants, j’ai mis mes premiers sites web en ligne pour les montrer fièrement à tout le monde.
Puis comme tous ceux qui ont fait la même chose que moi, j’ai découvert la joie de me faire poutrer ma base de données à grands coups d’injections SQL.
Comment ça, concaténer des strings SQL, c’est pas bien ?

J’ai alors découvert comment faire de la préparation de requêtes.
Nous nous sommes tous heurtés aux mêmes problèmes :

  • C’est rébarbatif
  • On peut facilement faire des erreurs
  • La peur de laisser passer une injection est toujours présente
  • Il faut faire le mapping avec nos objets avec attention

Pour ce dernier point, je parlerai “d’objet” dans le cas général et le sens philosophique du terme, bien que je m’apprête à illustrer la problématique avec le langage Go qui n’est pas orienté objet.

Je n’étais pas le seul à avoir remarqué cette nécessité de mapper des structures / classes avec des tables de bases de données, d’autres l’ont remarqué bien avant moi. Ce sont ces gens qui ont créé les ORM (Object-Relational Mapping), permettant de répondre à ce besoin.

Les avantages des ORM sont immenses :

  • On manipule directement des objets
  • On abstrait totalement la couche SQL, même plus besoin d’écrire du SQL
  • Facilite grandement la sérialisation / déserialisation
  • Opérations CRUD simplifiées
  • La gestion des relations est simplifiée
  • Compatibilité entre plusieurs bases de données (PostgreSQL, MySQL, …)

Il y a également des inconvénients incontournables :

  • Perte de performance
  • Contrôle plus limité
  • Debugging parfois plus compliqué
  • Apprentissage d’un DSL

Bien que ces inconvénients existent, les avantages sont bien trop évidents pour passer à côté. Le principe des ORM est largement adopté dans à peu près tous les langages de programmation.

J’adorais le langage SQL, mais je dois me faire une raison : écrire du SQL à notre époque est obsolète. Plus personne ne fait ça. C’est du moins ce que je pensais.
Cela fait des années que je parle avec mes bases de données via des ORM, et je m’y suis accommodé. Mais je dois vous avouer que je suis nostalgique de l’époque où je parlais la langue de mes bases de données.

Récemment au travail, nous avons été confrontés à certains problèmes liés à ce manque de contrôle qu’entraînent parfois les ORM, notamment lorsque nous devions changer le schéma de la base de données.
C’est alors qu’un collègue est venu nous présenter une technologie que je ne connaissais pas encore : sqlc.

sqlc et les ORMs

sqlc est un outil qui génère automatiquement du code à partir de requêtes SQL écrites manuellement.
Il a été créé pour combiner la sécurité et la performance du SQL brut avec la facilité d’utilisation d’un ORM, tout en évitant la surcharge de traitement et la perte de contrôle que peuvent introduire ces derniers.

Initialement écrit en Go pour du langage Go, sqlc supporte aujourd’hui plusieurs autres langages.
Ici, nous en parlerons essentiellement en Go. Pour bien situer sqlc dans l’écosystème actuel, nous utiliserons à titre comparatif l’ORM GORM (bien qu’il existe d’autres alternatives populaires comme Ent).

Prenons un schéma de base de données très simple :

1
2
3
4
5
6
7
8
+---------------------+          +------------------+
|         book        |          |      author      |
+---------------------+          +------------------+
| ID         (PK)     |     +--> | ID      (PK)     |
| Title               |     |    | Email            |
| PublicationDate     |     |    | Bio              |
| AuthorID   (FK)     | ----+    |                  |
+---------------------+          +------------------+

À première vue ça ressemble un peu à un retour en arrière : on utilise de nouveau des requêtes préparées écrites directement dans notre code source.

Dans le cas des ORM, le SQL est totalement invisible.
Ajouter une ligne dans une base de données prend littéralement une ligne de code :

1
2
3
func CreateAuthor(db *gorm.DB, author *models.Author) error {
    return db.Create(author).Error
}

Cependant, comme dit plus haut, nous voulons nous affranchir des limitations des ORM. Si manipuler des bases de données avec un ORM est aisé avec des exemples triviaux, qu’en est-il lorsque nous voulons exécuter des requêtes complexes ?
Ce n’est pas forcément impossible, mais nous oblige parfois à faire des concessions (découpage des requêtes pour déléguer une partie des calculs au langage de programmation, création de vues, etc.)
Au final, les inconvénients induits par les requêtes préparées manuellement, n’y a-t-il pas un autre moyen de les gérer ?

sqlc prend le problème dans l’autre sens : pas de dépendance dans le code, mais un outil en local qui génère du code compatible librairie standard.
À noter que de la génération de code vers d’autres librairies est également possible, mais ici c’est le fait de ne pas avoir de dépendances autres que la librairie standard qui nous intéressera.
Le résultat :

  • On écrit à nouveau du code SQL
  • On obtient du code 100% compatible librairie standard prêt à l’emploi

Mais avant d’écrire le moindre SQL, sqlc a besoin de savoir quelques petites choses : quel moteur de base de données on utilise, où trouver nos requêtes et notre schéma, et où générer le code. Tout ça se définit dans un fichier de configuration, sqlc.yaml (ou sqlc.json, si vous préférez), placé à la racine du projet.

Voici celui que j’ai utilisé :

1
2
3
4
5
6
7
8
9
version: "2"
sql:
  - engine: "postgresql"
    queries: "sqlc/database/*.sql"
    schema: "sqlc/database/migrations"
    gen:
      go:
        package: "database"
        out: "sqlc/database"

C’est assez simple : j’indique à sqlc que j’utilise PostgreSQL, que mes requêtes SQL se trouvent dans sqlc/database/*.sql, que le schéma de la base de données est défini par les scripts de migration dans sqlc/database/migrations, et que le code Go généré doit aller dans le package sqlc/database.
Une fois en place, une simple commande sqlc generate suffit pour générer le code.

Passons maintenant au SQL.
Il suffit juste d’ajouter un commentaire au-dessus des requêtes afin de spécifier le nom de la fonction désirée :

author.sql

1
2
3
4
5
6
7
8
9
-- name: createAuthor :one
INSERT INTO author (email, bio)
VALUES ($1, $2)
RETURNING id;

-- name: getAuthor :one
SELECT *
FROM author
WHERE id = $1;  

author.sql.go (fichier généré)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import (
    "context"
    "database/sql"
)

const createAuthor = `-- name: createAuthor :one
INSERT INTO author (email, bio)
VALUES ($1, $2)
RETURNING id
`

type createAuthorParams struct {
    Email string
    Bio   sql.NullString
}

func (q *Queries) createAuthor(ctx context.Context, arg createAuthorParams) (int32, error) {
    row := q.db.QueryRowContext(ctx, createAuthor, arg.Email, arg.Bio)
    var id int32
    err := row.Scan(&id)
    return id, err
}

const getAuthor = `-- name: getAuthor :one
SELECT id, email, bio
FROM author
WHERE id = $1
`

func (q *Queries) getAuthor(ctx context.Context, id int32) (Author, error) {
    row := q.db.QueryRowContext(ctx, getAuthor, id)
    var i Author
    err := row.Scan(&i.ID, &i.Email, &i.Bio)
    return i, err
}

C’est une autre façon d’appréhender le problème : fini d’écrire du boilerplate, plus à craindre de potentielles injections dues à des erreurs humaines.
Des avantages très proches de ceux fournis par les ORM en somme, mais avec un contrôle direct sur les requêtes et surtout sans surcoût à l’exécution. En effet, contrairement à certains ORM qui s’appuient fortement sur la réflexion au runtime, le code généré par sqlc s’exécute tel quel, sans perte de performances.
Vous pouvez trouver un exemple plus complet sur le sqlc Playground.

Écrire du SQL, c’est cool.

Vous noterez que le SELECT * a été transformé en SELECT id, email, bio dans le code généré.
En effet, sqlc remplace SELECT * par une liste explicite de colonnes pour garantir la stabilité du code généré, optimiser les performances et éviter les erreurs liées aux changements de schéma.

Vous remarquerez ensuite que la comparaison entre l’utilisation des fonctions fournies par un ORM et celle des fonctions générées par sqlc est assez similaire.

GORM

1
2
3
4
5
6
7
func GetAuthor(db *gorm.DB, id int64) (*models.Author, error) {
    var author models.Author
    if err := db.First(&author, id).Error; err != nil {
        return nil, err
    }
    return &author, nil
}

sqlc

1
2
3
4
5
6
7
func (q *Queries) GetAuthor(ctx context.Context, id int32) (*Author, error) {
    author, err := q.getAuthor(ctx, id)
    if err != nil {
        return nil, err
    }
    return &author, nil
}

Dans le cas de sqlc, on utilise une structure de type *Queries, qui est une encapsulation de DBTX, une interface qui définit un contrat pour exécuter les requêtes SQL. Tous deux sont générés par sqlc.

Libre à nous de les utiliser comme on veut. Pour ma part, j’ai choisi de rendre les fonctions générées internes (d’où le fait qu’elles commencent par une lowercase), afin de les encapsuler par des fonctions exportées du même nom que je définis comme méthode de *Queries.

Et c’est tout ! Tout aussi simple à utiliser qu’avant, mais avec bien des avantages.

D’ailleurs, des avantages, il y en a d’autres. J’aimerais justement attirer l’attention sur deux d’entre eux que j’apprécie beaucoup.
Premièrement, les modèles sont eux aussi générés par des scripts SQL.

Génération de modèles

Voici les modèles Go générés par mes scripts SQL :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
type Author struct {
    ID    int32
    Email string
    Bio   sql.NullString
}

type Book struct {
    ID              int32
    Title           string
    PublicationDate time.Time
    AuthorID        int32
}

Vous commencez à comprendre le concept de sqlc, ces modèles ont bien évidemment été écrits par des scripts SQL.
Voici donc un script SQL qui permettrait la génération de ces modèles :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
CREATE TABLE author (
    id SERIAL PRIMARY KEY,
    email TEXT NOT NULL UNIQUE,
    bio TEXT
);

CREATE TABLE book (
    id SERIAL PRIMARY KEY,
    title TEXT NOT NULL,
    publication_date DATE NOT NULL,
    author_id INT NOT NULL,
    FOREIGN KEY (author_id) REFERENCES author(id) ON DELETE CASCADE
);

Ça fonctionne, mais en entreprise les tables de nos bases de données sont rarement figées, elles évoluent. C’est pour cela que l’on trouvera en général plutôt des scripts de migration.
C’est là que ça devient intéressant : si sqlc est capable de générer nos modèles via du SQL, il peut du coup le faire via des scripts de migration directement.

Voici les scripts que j’ai réellement utilisés pour obtenir le même résultat :

v0001.sql

1
2
3
4
5
6
7
8
9
-- +migrate Up
CREATE TABLE author (
    id SERIAL PRIMARY KEY,
    email TEXT NOT NULL UNIQUE,
    bio TEXT
);

-- +migrate Down
DROP TABLE author;

v0002.sql

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
-- +migrate Up
CREATE TABLE book (
    id SERIAL PRIMARY KEY,
    title TEXT NOT NULL,
    publication_date DATE NOT NULL,
    author_id INT NOT NULL,
    FOREIGN KEY (author_id) REFERENCES author(id)
);

-- +migrate Down
DROP TABLE book;

v0003.sql

1
2
3
4
5
6
7
-- +migrate Up
ALTER TABLE book DROP CONSTRAINT book_author_id_fkey;
ALTER TABLE book ADD CONSTRAINT book_author_id_fkey FOREIGN KEY (author_id) REFERENCES author(id) ON DELETE CASCADE;

-- +migrate Down
ALTER TABLE book DROP CONSTRAINT book_author_id_fkey;
ALTER TABLE book ADD CONSTRAINT book_author_id_fkey FOREIGN KEY (author_id) REFERENCES author(id);

Chez nous, la gestion des bases de données est effectuée par une équipe dédiée. Ils ont leurs propres repos Git, et nous faisons des PR chez eux pour faire évoluer nos schémas.
Grâce à sqlc, nous pouvons maintenant ajouter directement ces repos via des Git submodules afin de faire en sorte que sqlc aille directement chercher les scripts de ces repos afin de générer nos modèles.
Avant, nous étions obligés de reproduire les modèles de données au sein de notre code, avec les erreurs humaines que ça implique.

Cette méthode permet d’obtenir un lien réel et fiable entre le code de nos microservices et celui des migrations de base de données.
L’équipe en charge des bases de données devient ainsi la réelle source de vérité.

À titre d’exemple, j’ai reproduit ce proof-of-concept à la maison : https://github.com/sebferrer/poc-sqlc ;
et j’ai stocké mes scripts de migration dans un repo dédié : https://github.com/sebferrer/poc-sqlc-db

Transactions

Un point important à savoir : sqlc ne génère pas de transactions automatiquement. Si on veut exécuter plusieurs requêtes au sein d’une même transaction, il faut le gérer soi-même.

Mais ce n’est pas bien compliqué, car sqlc fournit un mécanisme bien pratique pour ça. Le code généré inclut une méthode WithTx sur la structure *Queries, qui permet d’associer nos requêtes à une transaction existante :

1
2
3
4
5
func (q *Queries) WithTx(tx *sql.Tx) *Queries {
    return &Queries{
        db: tx,
    }
}

L’idée est simple : on démarre une transaction via la librairie standard, puis on crée une nouvelle instance de *Queries liée à cette transaction grâce à WithTx. À partir de là, toutes les requêtes exécutées sur cette instance feront partie de la même transaction.

Voici un exemple concret : imaginons qu’on veuille créer un auteur et lui assigner un livre dans la foulée, le tout au sein d’une même transaction.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func createAuthorWithBook(ctx context.Context, db *sql.DB, queries *Queries, authorParams createAuthorParams, bookTitle string, pubDate time.Time) error {
    tx, err := db.Begin()
    if err != nil {
        return err
    }
    defer tx.Rollback()

    qtx := queries.WithTx(tx)

    authorID, err := qtx.createAuthor(ctx, authorParams)
    if err != nil {
        return err
    }

    if err := qtx.createBook(ctx, createBookParams{
        Title:           bookTitle,
        PublicationDate: pubDate,
        AuthorID:        authorID,
    }); err != nil {
        return err
    }

    return tx.Commit()
}

Si l’une des opérations échoue, le defer tx.Rollback() se charge de tout annuler. Si tout se passe bien, tx.Commit() commit la transaction.

Pour finir, si vous voulez en savoir un peu plus, je vous laisse sur quelques dernières notes comprenant certaines subtilités bonnes à connaître que j’ai remarquées sur sqlc.

Quelques subtilités

Structures de paramètres

sqlc génère des structures Go pour encapsuler les paramètres des requêtes SQL, mais uniquement lorsqu’il y a plus d’un paramètre. Si la requête SQL n’a qu’un seul paramètre, sqlc n’utilise pas de structure et passe directement la valeur correspondante à la fonction.

On a pu remarquer dans l’exemple plus haut qu’une structure a été générée pour pouvoir passer les paramètres nécessaires, afin de pouvoir la passer à la fonction elle aussi générée :

1
2
3
4
5
6
type createAuthorParams struct {
    Email string
    Bio   sql.NullString
}

func (q *Queries) createAuthor(ctx context.Context, arg createAuthorParams) (int32, error)

Ça aurait été la même chose pour la requête SELECT si elle avait eu besoin de plus d’un seul paramètre.
En revanche, si un seul paramètre est nécessaire à la requête, sqlc ne s’embêtera pas à créer une structure pour rien :

1
2
3
-- name: deleteAuthor :exec
DELETE FROM author
WHERE id = $1
1
2
3
4
func (q *Queries) deleteAuthor(ctx context.Context, id int32) error {
    _, err := q.db.ExecContext(ctx, deleteAuthor, id)
    return err
}

Structure de retour

On peut également se demander, comment sqlc gère le type de retour pour renvoyer les données sélectionnées ? C’est assez simple.

Si le SELECT comporte toutes les colonnes de la table, comme un SELECT *, il utilisera directement le type généré pour définir le modèle de données.

1
2
3
4
-- name: getAuthor :one
SELECT *
FROM author
WHERE id = $1
1
func (q *Queries) getAuthor(ctx context.Context, id int32) (Author, error)

Sinon, il génère une structure intermédiaire, nous laissant le libre choix de comment la gérer.

1
2
3
4
-- name: getAuthor :one
SELECT id, email
FROM author
WHERE id = $1;
1
2
3
4
5
6
type getAuthorRow struct {
	ID    int32
	Email string
}

func (q *Queries) getAuthor(ctx context.Context, id int32) (getAuthorRow, error)

Embedding avec sqlc.embed

Quand on écrit des requêtes avec des JOINs, la structure résultante générée contient habituellement une liste à plat de toutes les colonnes sélectionnées. Ça peut vite devenir pénible à manipuler.

Par exemple, la requête suivante récupère des livres avec leurs auteurs :

1
2
3
4
5
-- name: getBookWithAuthor :many
SELECT book.*, author.*
FROM book
JOIN author ON author.id = book.author_id
WHERE book.id = $1;

sqlc génèrerait une structure à plat comme celle-ci :

1
2
3
4
5
6
7
8
9
type GetBookWithAuthorRow struct {
    ID              int32
    Title           string
    PublicationDate time.Time
    AuthorID        int32
    ID_2            int32
    Email           string
    Bio             sql.NullString
}

Avec sqlc.embed, on peut indiquer à sqlc de réutiliser les structures de modèles existantes :

1
2
3
4
5
-- name: getBookWithAuthor :many
SELECT sqlc.embed(book), sqlc.embed(author)
FROM book
JOIN author ON author.id = book.author_id
WHERE book.id = $1;

La structure générée sera alors bien plus propre :

1
2
3
4
type GetBookWithAuthorRow struct {
    Book   Book
    Author Author
}

Le code généré reste ainsi bien organisé, et on n’a pas besoin d’extraire manuellement chaque champ d’une ligne à plat.

Paramètres nommés avec sqlc.arg

sqlc essaie de générer des noms pertinents pour les paramètres positionnels, mais parfois il manque de contexte. Par exemple :

1
2
3
4
-- name: UpsertAuthorName :one
UPDATE author
SET name = CASE WHEN $1::bool THEN $2::text ELSE name END
RETURNING *;
1
2
3
4
type UpsertAuthorNameParams struct {
    Column1   bool
    Column2 string
}

Pas très lisible. Avec sqlc.arg, on peut nommer explicitement les paramètres :

1
2
3
4
-- name: UpsertAuthorName :one
UPDATE author
SET name = CASE WHEN sqlc.arg(set_name)::bool THEN sqlc.arg(name)::text ELSE name END
RETURNING *;
1
2
3
4
type UpsertAuthorNameParams struct {
    SetName bool   `json:"set_name"`
    Name    string `json:"name"`
}

Si la syntaxe vous paraît trop verbeuse, vous pouvez aussi utiliser le raccourci @ :

1
2
3
4
-- name: UpsertAuthorName :one
UPDATE author
SET name = CASE WHEN @set_name::bool THEN @name::text ELSE name END
RETURNING *;

Note : le raccourci @ n’est pas supporté avec MySQL.

Paramètres nullables avec sqlc.narg

sqlc infère la nullabilité des paramètres, et généralement ça fait ce qu’on veut. Toutefois, si on veut nous-même forcer la nullabilité d’un paramètre qui n’aurait pas été inféré comme tel (parce que la colonne est NOT NULL), sqlc propose également sqlc.narg (nullable arg), qui permet justement de le faire.
C’est particulièrement utile pour les mises à jour partielles, où l’on veut qu’un champ reste inchangé si aucune nouvelle valeur n’est fournie.

Voici un exemple qui permet de mettre à jour l’email, la bio d’un auteur, ou les deux :

1
2
3
4
5
6
-- name: UpdateAuthor :one
UPDATE author
SET email = coalesce(sqlc.narg('email'), email),
    bio = coalesce(sqlc.narg('bio'), bio)
WHERE id = sqlc.arg('id')
RETURNING *;
1
2
3
4
5
type UpdateAuthorParams struct {
    Email sql.NullString
    Bio   sql.NullString
    ID    int32
}

Ici, sans sqlc.narg, le paramètre email aurait été généré en tant que string (non-nullable) puisque la colonne est NOT NULL. En utilisant sqlc.narg, on le force à être sql.NullString, ce qui nous permet de passer NULL pour signifier « ne change pas ce champ ».

Le revers de la médaille : les requêtes dynamiques

Tout n’est pas rose pour autant. Là où un ORM comme GORM brille par sa capacité à construire des requêtes dynamiques (ajouter un Where() seulement si une variable est définie par exemple), sqlc montre ses limites. Comme tout est figé à la compilation, gérer une barre de recherche avec dix filtres optionnels devient vite un casse-tête.

On pourrait être tenté de ruser avec du SQL un peu « gras » du type WHERE (column = $1 OR $1 IS NULL), mais soyons honnêtes : c’est rarement une solution envisageable sur des projets sérieux. C’est verbeux, difficile à maintenir et souvent catastrophique pour les performances dès que la table prend du volume.
Dans ce genre de scénario, il faut savoir rester pragmatique : la meilleure option selon moi est de renoncer à sqlc pour ces cas précis et utiliser un Query Builder (comme Squirrel) ou du SQL brut, uniquement pour ces requêtes complexes.
C’est le prix à payer pour la sécurité du typage : on gagne en rigueur sur 95 % du projet, mais on accepte de reprendre la main manuellement sur les 5 % restants.

Autres subtilités en vrac

  • Comme vous avez pu le voir, les champs NULL en SQL sont représentés avec sql.NullXxx en Go : ça permet de gérer les valeurs NULL sans erreur d’exécution (type-safe).
  • Les requêtes INSERT ... RETURNING permettent d’obtenir l’ID d’un nouvel enregistrement directement : sqlc les traduit en QueryRowContext (au lieu d’un simple ExecContext) et utilise Scan pour récupérer la valeur retournée.

À noter également que sqlc ne propose pas que de la génération de code utilisant la librairie standard database/sql mais propose également pgx/v4 et pgx/v5.

Bonus : faire du Mock

J’aimerais parler d’un autre avantage qui m’a marqué. Le fait de générer son code via sqlc simplifie grandement le mock, et ce pour une bonne raison : c’est nous qui avons écrit le script SQL, donc nous savons exactement à quoi nous attendre.

Cela peut paraître anodin, mais rappelez-vous que si via un ORM nous n’avons aucun contrôle sur le code SQL qui est réellement généré derrière, ça peut rendre les mocks particulièrement fastidieux à écrire.
Je veux mocker la requête SELECT générée derrière mon Get, je dois m’attendre à quoi du coup ? Quelle est la requête exacte que je dois intercepter ?

Prenons l’exemple de GORM :

1
mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "author" WHERE "author"."id" = $1 ORDER BY "author"."id" LIMIT $2`))

Ça fonctionne, c’est cool. Mais il a fallu que je comprenne un minimum comment fonctionne GORM pour savoir que c’est spécifiquement cette requête qu’on doit attendre si je veux faire un mock de ma fonction Get.

Est-ce que mon ORM génère les mots-clés en lowercase ou uppercase ? Est-ce qu’il utilise des quotes, double-quotes ? Est-ce qu’il génère systématiquement le nom de la table devant la colonne associée ? Etc…
Répondre à ces questions implique de bien lire la documentation, voire de carrément aller dans le code source de l’ORM. On peut également se servir des messages d’erreurs de la librairie de mock SQL qu’on utilise, mais il faut reconnaître que ça demande du bricolage.
Et ce, pour chacune des requêtes SQL impliquées dans vos mocks.

Une fois passé à sqlc, le mock ressemblera à ça :

1
2
3
mock.ExpectQuery(regexp.QuoteMeta(`INSERT INTO author (email, bio)
                                   VALUES ($1, $2)
                                   RETURNING id`))

C’est-à-dire exactement la requête que j’ai moi-même écrite à la base (ou presque, cf. l’exemple du SELECT *, mais la requête générée est directement accessible).

Quand on doit mocker toute une batterie de requêtes SQL pour nos tests unitaires, j’ai découvert à quel point c’est plus pratique de n’avoir qu’un simple copié-collé de mes requêtes à faire.
Selon moi, une solution comme sqlc donne une réelle valeur ajoutée à l’utilisation de librairies de mock SQL.

Conclusion

Je pense avoir à peu près fait le tour de ce que je voulais vous présenter.
J’espère avoir pu transmettre la raison pour laquelle je me suis intéressé à cette approche un peu différente de ce que beaucoup d’entre nous utilisent couramment.

En conclusion, les ORM comme GORM offrent une abstraction robuste, facilitent les migrations et proposent une interface CRUD intuitive, mais nécessitent un investissement en apprentissage (bien que léger) et peuvent introduire une surcharge de performance, notamment avec de grandes bases de données et des opérations complexes.

Le SQL pur, de son côté, offre plus de contrôle sur les requêtes et peut être plus efficace pour les grandes bases de données, mais il demande une bonne maîtrise du SQL, implique une implémentation plus longue dans le code Go et devient vite complexe à gérer dès que l’on a besoin de requêtes aux paramètres très dynamiques.

Je dirais donc : si la simplicité et la rapidité de développement sont prioritaires, un ORM comme GORM reste une excellente option. Si l’optimisation, le contrôle précis des requêtes et la sécurité du typage sont essentiels, alors je préconiserais l’approche sqlc, tout en gardant le SQL pur en réserve pour les cas les plus complexes.

L’idée n’est pas de dire qu’une façon de faire est mieux qu’une autre.
J’adore les ORM et je continue d’en utiliser dans beaucoup de mes projets, mais j’avais envie de vous faire découvrir ce principe qui a été l’un de mes coups de coeur de l’année.

Enfin, sqlc s’affirme de plus en plus comme un outil pérenne : initialement très centré sur l’écosystème Go, il s’ouvre désormais à d’autres horizons avec le support officiel (actuellement en beta) de langages comme Python, Kotlin, ou TypeScript via un système de plugins.

Liens utiles

sqlc

GORM

Comparaison / Benchmark

Mon proof-of-concept

Un Grand merci à Gab qui m’a fait découvrir cette techno et qui m’a donné envie de m’y intéresser !

Généré avec Hugo
Thème Stack conçu par Jimmy