Liquibase, paramétrer facilement des données factices en contexte de développement

Récemment, j’ai abordé une problématique qui, je pense, est récurrente : avoir des données factices sur son environnement de développement en local. Et de manière plus générique, contrôler ses données suivant le contexte d’exécution de l’application.

En effet, je participe au développement d’un projet qui se compose d’une API Spring Boot dialoguant avec un système de gestion de base de données relationnel (SGBDR ci-après - Système de Gestion de Base de Données Relationnel) qui peut être PostgreSQL ou H2 suivant les cas.

D’autres services complètent la stack technique mais ceux-ci ne nous intéressent pas pour le périmètre de cet article.

Vous l’aurez peut-être deviné, cette API Spring Boot gère le schéma et les données de la base de données associée grâce à Liquibase. Néanmoins, les concepts abordés dans cet article ne sont pas liés à Spring Boot.

Les concepts clefs de Liquibase

Si vous ne connaissez pas encore Liquibase, voici la définition (partielle) donnée par son fondateur Nathan Voxland : “Liquibase est une bibliothèque open source pour le suivi, la gestion et l'application des changements de base de données qui peut être utilisée pour toute base de données avec un pilote JDBC.” [Traduction]. En d’autres termes, Liquibase peut être vu comme Git pour les bases de données.

Liquibase est fourni en plusieurs éditions, tous les concepts et techniques abordés ici sont disponibles en version gratuite de Liquibase (Community Edition).

Les principaux concepts clefs sont les suivants :

  • Changelogs : ils représentent l’endroit où les altérations de la base de données vont être définies. Ils peuvent être écrits en XML, YAML, JSON ou encore directement en SQL. Généralement, un changelog va aborder un objectif global comme une User Story par exemple. De plus, un changelog global (couramment appelé changelog-master) référence généralement les différents changelogs de l’application.
  • Changesets : ce sont les unités de travail de Liquibase, elles sont trackées par ID dans une table DATABASECHANGELOG exploitée par Liquibase pour assurer un versionnage correct et éviter de possibles conflits. Les changesets composent un changelog, il peut y avoir plusieurs changesets par changelog.
  • Change Types : ce sont les types d’altération qu’il est possible de réaliser grâce à Liquibase. Ces change types peuvent altérer les différentes entités de la base de données telles que les tables, colonnes, séquences mais également les contraintes telles que les clefs primaires, étrangères, etc et bien d’autres encore. Il y a généralement un seul change type par changeset.
  • Contexts : les contexts sont des expressions qui permettent de restreindre l’exécution des changesets suivant le contexte d’exécution de Liquibase. Ils peuvent être attribués à des changesets ou des changelogs.
Pour plus d’informations, vous pouvez vous référer aux articles How Liquibase Works et Core Concepts du site officiel de Liquibase.

Maintenant que nous avons une bonne introduction de Liquibase, penchons-nous sur notre problématique initiale.

Expression du besoin

Détaillons quelque peu notre besoin. Nous devons premièrement purger intégralement les données de toutes les tables. Ensuite nous devons ajouter les données factices à ces dernières. Et nous souhaitons effectuer ceci en :

  • ayant la dernière version en date de notre schéma de base de données (nous avons déjà un historique de migrations Liquibase).
  • appliquant ce processus uniquement en contexte de développement
  • étant agnostique du RDMBS exploité.

Purge de la base de données

Notre premier objectif est de purger notre base de données. Nous allons réaliser ceci grâce à un changeset qui, rappelons-le, est l’unité de travail de Liquibase.

Nous devons maintenant sélectionner le change type à intégrer au changeset pour arriver à nos fins. Nous avons deux solutions, soit utiliser le change type delete, qui permet de supprimer les données d’une table. Soit utiliser le change type sqlFile, qui permet d’exécuter un script SQL arbitraire.

Si nous utilisons le change type delete, nous devrions réaliser autant d’appels qu’il y a de tables à vider et dans un ordre précis à cause des contraintes de clefs étrangères.

Nous pouvons réaliser ceci de manière plus concise avec un unique script SQL pour chaque SGBD qui va pouvoir être exploité par le change type sqlFile.

Nous créons un fichier truncate_all_data_postgresql.sql pour PostgreSQL :

TRUNCATE TABLE user, address, formation

Un appel unique de la forme TRUNCATE TABLE table_1, table2, etc permet de vider toutes les tables en faisant abstraction des contraintes de clefs étrangères ( TRUNCATE - PostegreSQL documentation).

Nous créons un fichier truncate_all_data_h2.sql pour H2 :

SET REFERENTIAL_INTEGRITY = FALSE;

TRUNCATE TABLE user;
TRUNCATE TABLE address;
TRUNCATE TABLE formation;

SET REFERENTIAL_INTEGRITY = TRUE;

Pour H2, il n’est pas possible de passer une liste de tables dans l’appel TRUNCATE. Pour s’absoudre des contraintes de clefs étrangères,  il faut d'abord paramétrer la REFERENTIAL_INTEGRITY à false, puis vider les tables une par une avec un appel TRUNCATE à chaque table et enfin re paramétrer la REFERENTIAL_INTEGRITY à true (H2 - How to truncate all tables?).

Nous créons donc un fichier changelog (apply-dummy-data.xml par exemple), avec ses directives de schéma propre au format XML, auquel nous allons ajouter tous nos changesets.

<?xml version="1.0" encoding="utf-8"?>
<databaseChangeLog
  xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.5.xsd">

    <!-- Nous allons insérer nos changesets ici -->
</databaseChangeLog>

Puis nous déclarons un changeset (Ajouter un sous-tag <comment> aux changesets, est une des bonnes pratiques Liquibase).

<?xml version="1.0" encoding="utf-8"?>
<databaseChangeLog
  xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.5.xsd">

  <changeSet id="apply_dummy_data_truncate_data" author="cprezelin">
      <comment>Clear any data in all the tables</comment>

       <!-- Nous allons insérer nos change types ici -->
  </changeSet>
</databaseChangeLog>

Nous devons être agnostiques du SGBD et nous avons vu que PostgreSQL et H2 gèrent différemment les contraintes de clefs étrangères et les suppressions en cascade. Nous devons trouver une solution pour exécuter nos scripts en fonction du SGBD exploité.

Heureusement, les change types possèdent un attribut dbms (l'équivalent anglophone de SGBD) qui peut spécifier dans quel cas exécuter le change type. Nous appliquons donc nos deux scripts, chacun prenant en compte les particularités de leur SGBD, et nous nous retrouvons avec notre premier changeset qui ressemble à ceci :

<?xml version="1.0" encoding="utf-8"?>
<databaseChangeLog
  xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.5.xsd">

  <changeSet id="apply_dummy_data_truncate_data" author="cprezelin">
      <comment>Clear any data in all the tables</comment>

      <sqlFile dbms="postgresql" path="config/liquibase/scripts/truncate_all_data_postgresql.sql"/>
      <sqlFile dbms="h2" path="config/liquibase/scripts/truncate_all_data_h2.sql"/>
  </changeSet>
</databaseChangeLog>

Ajout des données factices

Notre deuxième objectif est de remplir les tables avec nos données contrôlées. Ici, nous allons une fois de plus créer un changeset spécifique pour cette action.

Nous allons utiliser le change type loadData qui prend en paramètre un fichier CSV contenant les données à ajouter (c’est le seul format accepté à ce jour) et également la table ciblée.

Attention, ici nous devons respecter les contraintes de notre schéma de base de données, nous devons donc charger nos données dans un ordre spécifique de tables, et ainsi éviter d’avoir des valeurs de clefs étrangères non-renseignées.

Voici notre changeset complété :

<?xml version="1.0" encoding="utf-8"?>
<databaseChangeLog
    xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.5.xsd">

    <changeSet id="apply_dummy_data_truncate_data" author="cprezelin"

    …

    </changeSet>


    <changeSet id="apply_dummy_data_populate_data" author="cprezelin">
        <comment>Populate the database with dummy data</comment>

        <!-- Order is important in order to respect database integrity -->
        <loadData file="config/liquibase/dummy-data/user.csv"
                  tableName="user"/>
        <loadData file="config/liquibase/dummy-data/address.csv"
                  tableName="address"/>
        <loadData file="config/liquibase/dummy-data/formation.csv"
                  tableName="formation"/>

        <!-- And so on… -->
    </changeSet>
</databaseChangeLog>

loadData, comme quasiment tous les change types, se comporte de la même manière suivant les SGBD, aucun besoin de développer des change types spécifiques suivant la SGBD. Chaque change type possède une documentation détaillée abordant les compatibilités avec les différentes SGBD supportées.

Contexte d’exécution

Maintenant que nos changesets sont effectifs, il s’agit de les exécuter dans le bon environnement d’exécution. Pour ceci, vous l’aurez peut-être deviné, nous allons exploiter les contexts de Liquibase.

Deux solutions s’offrent à nous. Soit restreindre le contexte d’utilisation au niveau du changeset, en lui appliquant un attribut context, soit le restreindre au niveau du changelog.

En effet, il existe un tag Liquibase s’appelant include qui permet d’importer un changelog dans un autre changelog (c’est notamment ce qui est fait dans le changelog-master qui référence tous les changelogs à appliquer par Liquibase). C’est à ce tag que nous pouvons appliquer l’attribut context.

C’est bien cette dernière option que nous allons choisir, nous ajoutons dans le fichier changelog-master notre changelog contenant nos changesets comme ceci :

<?xml version="1.0" encoding="utf-8"?>
<databaseChangeLog
  xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.5.xsd">

    <include file="config/liquibase/changelog/add-user-constraints.xml"/>

    <include file="config/liquibase/changelog/update-address-schema.xml"/>

    ...
  <!-- Des tags include incluant les précédentes migrations -->

  <include file="config/liquibase/changelog/apply-dummy-data.xml" context="dev"/>

</databaseChangeLog>

Nous avons bien l’attribut context du tag include qui a pour valeur "dev" ici.

Ensuite, la dernière chose à faire est de configurer convenablement le contexte d'exécution de Liquibase avec la valeur "dev".

Attention !

Il existe un bug qui ne référence pas les contexts du changeset en question dans la table DATABASECHANGELOG quand celui-ci est paramétré dans un tag include, alors qu’ils sont bien référencés lorsque l’on paramètre l’attribut context au changeset directement.
C’est une information à connaître si nous souhaitons exploiter les métadonnées des changesets fournies par la table DATABASECHANGELOG.

Ordre d’exécution des changesets

Un dernier point mais pas des moindres et l’ordre d’exécution de ces changesets. Dans la vie du projet, plusieurs migrations ont ou vont être ajoutées pour faire évoluer le schéma de base de données, modifier des données existantes en production, etc. Chaque migration a son importance et nous voulons être sûr d’avoir la dernière version du schéma de base de données lorsque nous voulons ajouter nos données factices.

Pour ce faire, la seule solution est d’appliquer nos changesets après toutes les migrations déjà existantes.

Liquibase gère l’ordre d’exécution des migrations grâce au fichier changelog-master. Il exécute les changelogs dans l’ordre d’apparition de ceux-ci dans le fichier.

La première option est d’ajouter notre tag include à la fin du changelog-master. C’est une solution qui marche mais lorsqu'une nouvelle migration, destinée à la production et non pas à des fins de développement est ajoutée. Il faut faire attention à l’ajouter avant notre tag include. C’est une opération qui peut facilement être oubliée et peu élégante.

La deuxième option consiste à dire directement à Liquibase que nos changesets doivent être appliqués après toutes les migrations existantes. Un attribut de changeset aborde précisément cette problématique, c’est l’attribut runOrder introduit dans Liquibase 3.5, qui peut prendre en valeur "first" et "last". Il suffit donc d’ajouter l’attribut runOrder avec pour valeur "last" aux changesets concernés et le tour est joué. Nous n’avons plus besoin d’avoir explicitement notre changelog ajouté à la fin de notre fichier changelog-master.

Revoyons notre changelog avec la mise à jour :

<?xml version="1.0" encoding="utf-8"?>
<databaseChangeLog
    xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.5.xsd">
    

    <changeSet id="apply_dummy_data_truncate_data"

               author="cprezelin"

               runOrder="last">…</changeSet>

    <changeSet id="apply_dummy_data_populate_data"

               author="cprezelin"

               runOrder="last">…</changeSet>
</databaseChangeLog>

Et voilà, vous avez des données contrôlées suivant un contexte d’exécution de Liquibase, agnostique de votre SGBD et complètement versionnées et contrôlées.

J’espère que cet article vous apportera le soutien espéré dans vos prochaines problématiques gravitant autour de Liquibase !