Modifier le fichier

Migration de colonnes texte avec AbstractTextColumnsProcessor


Vue d’ensemble fonctionnelle

AbstractTextColumnsProcessor est une base de migration Flyway (Java) dédiée aux colonnes texte des tables applicatives. Elle permet de:

  • Parcourir automatiquement toutes les tables (sauf exclusions configurées) et détecter les colonnes “texte”.
  • Pour chaque ligne, transformer le contenu des colonnes texte:
    • Si la valeur est un JSON “wrappeur” (string commençant par { et finissant par }), extraire les valeurs texte et leur appliquer une transformation HTML, puis ré-encoder le JSON.
    • Sinon, considérer la valeur comme du HTML et l’envoyer à une transformation HTML spécialisée.
  • Vérifier l’intégrité “fonctionnelle” du contenu après transformation (pas de perte de texte visible) et, en cas d’échec, conserver la valeur d’origine.
  • Mettre à jour en base uniquement les lignes modifiées, avec logs détaillés.

Les classes concrètes définissent:

  • La logique de transformation HTML (processHtmlContent).
  • Le prédicat SQL ciblant uniquement les lignes susceptibles d’être impactées (buildColumnPredicat).

Des exemples existants:

  • V7_3_1_6__migration_grilles: migration des grilles CKEditor -> nouveau layout.
  • V7_3_1_10__migration_accordeons: migration d’anciens accordéons <dl/dt/dd> vers <details>.
  • V7_3_1_7__migration_styles, V7_3_1_8__migration_styles_v2, V7_3_1_9__migration_onglets: mêmes patterns pour d’autres structures.

Architecture et flux d’exécution (technique)

  1. Initialisation Flyway

    • La classe étend BaseJavaMigration et implémente migrate(Context). Flyway l’exécute au moment des migrations (au démarrage).
  2. Sélection des tables à traiter

    • listAllTables(Connection): récupère l’ensemble des tables via les métadonnées JDBC.
    • Exclusions: la propriété text_column_processor.excluded_tables (chargée via PropertyHelper) peut contenir une liste de tables à ignorer, séparées par des virgules.
      • Exemple de configuration (fichier env.properties de l’environnement):
        text_column_processor.excluded_tables=HISTORIQUE_LOGS,TMP_IMPORT
        
  3. Détection des colonnes texte

    • listTextColumns(Connection, String table):
      • Tente un SELECT * FROM {table} LIMIT 1 et lit le ResultSetMetaData.
      • Est considérée comme “texte” une colonne dont le type JDBC est LONGVARCHAR, ou de type VARCHAR d’une taille supérieure à un seuil minimal, ou dont le type contient TEXT.
      • Seuil configurable au niveau de la classe via le champ protégé minimumColumnSize (valeur par défaut: 1024). Une classe fille peut l’ajuster si nécessaire.
      • Exclut systématiquement la colonne identifiant ID_{TABLE}.
  4. Construction des requêtes SQL

    • buildQuerySelect(table, columns): construit le SELECT avec ID_{table} + colonnes texte, et y ajoute une clause WHERE construite par buildColumnPredicat(table, columns) (spécifique à la classe fille).
    • buildQueryUpdate(table, columns): construit un UPDATE avec paramètres, du style UPDATE {table} SET col1=?, col2=?, ... WHERE ID_{table}=?.
    • buildColumnPredicat(Set<String> columns, String needle): utilitaire générique pour produire une clause WHERE (col LIKE '%needle%' OR ...), avec échappement des ' dans needle.
  5. Traitement ligne par ligne

    • Pour chaque ligne du SELECT:
      • On lit l’ID (getIdValue) et chaque colonne texte.
      • processColumn(col, oldVal) décide de la voie:
        • JSON wrappeur ? (isJsonWrapped(value): valeur non vide, trim, commence par { et finit par }): processJsonWrappedContent(col, value)
          • Parse via CodecJSon.decodeStringJSonToClass en Map<String, Object>.
          • Pour chaque valeur de type String, applique processHtmlContent(html).
          • Re-encode le JSON via CodecJSon.encodeObjectToJSonInString.
        • Sinon: processHtmlContent(value) directement.
      • Si la valeur a changé et que l’intégrité est validée, on UPDATE la ligne.
  6. Vérification d’intégrité

    • verifyContentIntegrity(originalValue, processedValue):
      • Convertit HTML -> texte via Jsoup.parse(...).text() de part et d’autre.
      • Normalise (suppression \n, \t, espaces; tri des caractères) puis compare.
      • Si l’intégrité échoue et que applyIntegrityVerification est à true (par défaut), la migration pour cette ligne est annulée (on écrit la valeur d’origine), avec un WARN.
      • Une classe fille peut désactiver ce garde-fou en réglant applyIntegrityVerification=false si elle doit effectuer des transformations modifiant intentionnellement le texte visible.

Points d’attention et bonnes pratiques

  • Prédicats SQL efficaces

    • Évitez un scan complet des tables: construisez un WHERE qui restreint aux contenus suspects (via buildColumnPredicat(columns, "motif")).
    • Double gestion « non échappé / échappé » si vos motifs peuvent se trouver dans du HTML direct ou du HTML sérialisé en JSON string (voir SEARCH_ESCAPED).
  • Intégrité du texte visible

    • Conservez applyIntegrityVerification=true tant que possible. Ne désactivez que si vos transformations changent volontairement le texte (ex. suppression de décorations textuelles devenues obsolètes).

Créer un nouveau script de migration: guide pas-à-pas

  1. Étendre AbstractTextColumnsProcessor

    • Implémenter:
      • protected String processHtmlContent(String htmlValue).
      • protected String buildColumnPredicat(String tableName, Set<String> columns).
  2. Construire des constantes de recherche

    • Exemple:
      private static final String SEARCH = "<div class=\"old-widget\">";
      private static final String SEARCH_ESCAPED = "<div class=\\\\\"old-widget\\\\\">"; // pour JSON string
      
  3. Implémenter buildColumnPredicat

  4. Implémenter processHtmlContent

    • Pattern recommandé:
      @Override
      protected String processHtmlContent(final String value) {
          if (!value.contains("old-widget")) {
              return value; // court-circuit
          }
          final Document doc = Jsoup.parseBodyFragment(value);
          setXmlSettings(doc);
          boolean changed = false;
          // Sélectionner précisément ce que vous transformez
          final Elements widgets = doc.select("div.old-widget");
          for (final Element w : widgets) {
              final Element replacement = transformWidget(w);
              if (replacement != null) {
                  w.replaceWith(replacement);
                  changed = true;
              }
          }
          return changed ? doc.body().html() : value;
      }
      
      private Element transformWidget(final Element old) {
          // Construire le nouveau DOM (balises, classes, attributs) en réutilisant le inner HTML si besoin
          final Element container = new Element("div").addClass("new-widget");
          final Element inner = new Element("div");
          inner.html(old.html());
          container.appendChild(inner);
          return container;
      }
      
  5. Ajuster les garde-fous si nécessaire

    • Si la transformation modifie le texte visible (suppression de libellés), envisagez this.applyIntegrityVerification = false; (dans un bloc d’initialisation ou un constructeur) avec prudence et justification.
    • Vous pouvez aussi abaisser/augmenter minimumColumnSize si votre migration doit couvrir des VARCHAR plus petits/grands.

Modèle minimal de classe de migration

package com.kosmos.core.migration;

import java.util.Set;
import org.apache.commons.lang3.StringUtils;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.kosmos.migration.core.executor.AbstractTextColumnsProcessor;

public class V7_3_1_X__migration_mon_widget extends AbstractTextColumnsProcessor {

    private static final Logger LOG = LoggerFactory.getLogger(V7_3_1_X__migration_mon_widget.class);

    // Motifs pour ciblage SQL (HTML direct vs HTML encodé dans JSON)
    private static final String SEARCH = "<div class=\"old-widget\">";
    private static final String SEARCH_ESCAPED = "<div class=\\\\\"old-widget\\\\\">";

    @Override
    public Integer getChecksum() {
        return 1234567890; // fige le checksum Flyway de cette migration Java
    }

    @Override
    protected String processHtmlContent(final String value) {
        if (!value.contains("old-widget")) {
            return value;
        }
        final Document doc = Jsoup.parseBodyFragment(value);
        setXmlSettings(doc);
        boolean changed = false;
        final Elements olds = doc.select("div.old-widget");
        for (final Element old : olds) {
            final Element neo = transformOldToNew(old);
            if (neo != null) {
                old.replaceWith(neo);
                changed = true;
            }
        }
        return changed ? doc.body().html() : value;
    }

    private Element transformOldToNew(final Element old) {
        final Element container = new Element("div").addClass("new-widget");
        final Element inner = new Element("div");
        inner.html(old.html());
        container.appendChild(inner);
        return container;
    }

    @Override
    protected String buildColumnPredicat(final String tableName, final Set<String> columns) {
        final String p1 = buildColumnPredicat(columns, SEARCH);
        final String p2 = buildColumnPredicat(columns, SEARCH_ESCAPED);
        if (StringUtils.isEmpty(p1) && StringUtils.isEmpty(p2)) return StringUtils.EMPTY;
        if (StringUtils.isEmpty(p1)) return p2;
        if (StringUtils.isEmpty(p2)) return p1;
        return p1 + p2.replace(" WHERE ", " OR ");
    }
}

FAQ / points spécifiques

  • Et si mon contenu JSON n’est pas un objet au niveau racine ?

    • isJsonWrapped ne reconnaît que les strings démarrant/finissant par {/}. Si vous avez du JSON tableau ou un wrapper différent, ajustez isJsonWrapped dans une sous-classe (ou surchargez processColumn).
  • Puis-je désactiver l’intégrité uniquement pour une colonne ?

    • Le garde-fou est global à l’instance. Pour un contrôle plus fin, surchargez processColumn pour décider au cas par cas si l’intégrité doit bloquer la migration et retournez la valeur d’origine si besoin.
  • Et si ID_{TABLE} n’existe pas ?

    • getIdValue tente ID_{TABLE} puis, en fallback, la première colonne du ResultSet. Si aucune n’est accessible, la ligne est ignorée (pas d’UPDATE possible).