L’inizializzazione di un database può sembrare un’attività semplice: verificare la versione corrente, eseguire gli script mancanti e aggiornare il numero di versione.

In un sistema reale, però, un servizio di database initialization deve gestire molti scenari:

  • il database potrebbe essere vuoto o già esistente;
  • la versione installata potrebbe essere precedente a quella richiesta;
  • gli script potrebbero avere un ordine di esecuzione obbligatorio;
  • alcuni script potrebbero essere associati a una migrazione principale;
  • le migrazioni potrebbero essere incluse nell’immagine container o fornite da una directory esterna;
  • un errore a metà processo non deve lasciare il database in uno stato ambiguo;
  • i log devono essere utili senza esporre password o credenziali;
  • il comportamento deve essere verificabile sia senza dipendenze esterne sia contro un vero SQL Server.

Per rispondere a queste esigenze abbiamo sviluppato un servizio .NET dedicato, DatabaseInitializer.Service.InitDb, basato su DbUp.

L’obiettivo non era soltanto eseguire file SQL, ma costruire un processo prevedibile, osservabile e adatto all’esecuzione automatizzata tramite CI/CD e container.

Il problema: una migrazione non è semplicemente un file SQL

La prima decisione progettuale è stata definire una convenzione affidabile per gli script.

Gli script standard seguono questo formato:

NNNNNN_VVV.sql

Esempi:

000001_110.sql
000002_111.sql
000003_112.sql

Il prefisso numerico a sei cifre rappresenta l’ordine di esecuzione, mentre la parte successiva rappresenta la versione compatta del database:

110  -> 1.1.0
111  -> 1.1.1
112  -> 1.1.2

Questa convenzione separa due concetti importanti:

  1. la sequenza tecnica con cui eseguire le migrazioni;
  2. la versione funzionale raggiunta dal database.

Sono inoltre supportati script aggiuntivi associati a una migrazione standard:

000001_110_additional.sql

Lo script aggiuntivo viene eseguito immediatamente dopo il relativo script standard.

Infine, 999999_Data.sql rappresenta uno script finale da eseguire dopo tutte le migrazioni applicabili.

Queste regole non possono essere lasciate al comportamento implicito del filesystem o a un semplice ordinamento alfabetico. Devono essere espresse in modo esplicito, centralizzato e testabile.

Separare la pianificazione dall’esecuzione

La decisione architetturale più importante è stata separare la pianificazione delle migrazioni dall’esecuzione tramite DbUp.

Il componente MigrationScriptPlanner riceve:

  • l’elenco dei file disponibili;
  • la versione corrente del database;
  • la versione target richiesta.

Restituisce l’elenco ordinato degli script da eseguire.

La regola di selezione è:

currentVersion < migrationVersion <= targetVersion

In questo modo:

  • le migrazioni già applicate vengono ignorate;
  • vengono incluse solo le migrazioni fino alla versione target;
  • una versione target senza script standard viene rifiutata;
  • gli script aggiuntivi senza una migrazione standard corrispondente vengono rifiutati;
  • due script standard non possono puntare alla stessa versione.

Gli altri file vengono ignorati se non rispettano la convenzione prevista.

Un esempio semplificato della logica è il seguente:

public static IReadOnlyList<string> Plan(
    IEnumerable<string> availableFiles,
    DatabaseVersion currentVersion,
    DatabaseVersion targetVersion)
{
    var scripts = ParseAvailableScripts(availableFiles);

    EnsureUniqueVersions(scripts.Standard);

    EnsureAdditionalScriptsMatchStandardMigrations(
        scripts.Standard,
        scripts.Additional);

    if (!scripts.Standard.Any(x => x.Version == targetVersion))
    {
        throw new InvalidOperationException(
            $"Target version '{targetVersion}' has no migration.");
    }

    var plan = new List<string>();

    foreach (var migration in scripts.Standard
        .Where(x => x.Version > currentVersion &&
                    x.Version <= targetVersion)
        .OrderBy(x => x.Sequence))
    {
        plan.Add(migration.FileName);

        plan.AddRange(scripts.Additional
            .Where(x => x.Sequence == migration.Sequence &&
                        x.Version == migration.Version)
            .Select(x => x.FileName));
    }

    if (plan.Count > 0 && scripts.FinalScript is not null)
    {
        plan.Add(scripts.FinalScript);
    }

    return plan;
}

La separazione tra planning ed execution offre due vantaggi principali.

Il primo è la testabilità: le regole di pianificazione possono essere verificate senza aprire una connessione SQL.

Il secondo è la trasparenza operativa: prima di eseguire gli script, il servizio può registrare il piano calcolato nei log.


Gestire la versione del database

La versione corrente viene letta dalla tabella:

dbo.DatabaseVersion

La tabella deve contenere esattamente una riga.

Una tabella vuota o contenente più righe indica che il database non si trova nello stato atteso. In queste condizioni il servizio interrompe l’esecuzione invece di tentare di indovinare quale versione utilizzare.

Il modello DatabaseVersion converte il valore compatto in una versione semantica:

var version = DatabaseVersion.FromCompactValue(110);

Console.WriteLine(version);
// 1.1.0

La classe centralizza inoltre la validazione e il confronto delle versioni:

public readonly record struct DatabaseVersion
    : IComparable<DatabaseVersion>
{
    public int ToCompactValue() =>
        (Major * 100) + (Minor * 10) + Patch;

    public int CompareTo(DatabaseVersion other) =>
        ToCompactValue().CompareTo(other.ToCompactValue());
}

Questo evita confronti manuali tra stringhe o interi sparsi nel codice. La versione diventa un valore tipizzato, con regole coerenti in tutto il servizio.


Script embedded e directory esterna

Il servizio supporta due sorgenti per gli script SQL.

Embedded resources

Per l’esecuzione all’interno del container, gli script vengono inclusi come embedded resources nel progetto .NET:

<ItemGroup>
  <EmbeddedResource Include="Migrations\**\*.sql" />
</ItemGroup>

Questa modalità rende l’immagine container autonoma. Non è necessario montare un volume o distribuire separatamente gli script.

Directory esterna

Per test, override o distribuzioni particolari è possibile fornire una directory tramite:

--migrationsScriptsPath

La scelta della sorgente è esclusiva. Quando viene indicata una directory esterna, il servizio utilizza soltanto quella directory e non combina gli script esterni con quelli embedded.

Questa scelta evita ambiguità difficili da diagnosticare. Un file esterno con lo stesso nome di uno embedded non può sostituire o duplicare silenziosamente una migrazione.


Eseguire le migrazioni con DbUp

Dopo aver costruito il piano, il servizio crea un motore DbUp configurato con:

  • database SQL Server;
  • journal personalizzato;
  • transazione per script;
  • logging tramite Microsoft.Extensions.Logging;
  • elenco esplicito degli script pianificati.

La configurazione principale è simile alla seguente:

private UpgradeEngine CreateUpgradeEngine(
    IEnumerable<MigrationScript> scripts)
{
    return DeployChanges.To
        .SqlDatabase(Options.ConnectionString)
        .WithTransactionPerScript()
        .WithVariablesDisabled()
        .JournalToSqlTable(
            Options.MigrationScriptsTableSchema,
            Options.MigrationScriptsTableName)
        .WithScripts(
            scripts.Select(script =>
                new SqlScript(script.Name, script.Contents)))
        .LogTo(new DbUpToMicrosoftLoggerAdapter<
            DbUpDatabaseUpdateService>(_logger))
        .Build();
}

L’uso di una transazione per script limita l’impatto di un errore. Se una migrazione fallisce, DbUp non considera completato quello script.

Il journal DbUp registra il nome dello script eseguito. Di conseguenza, gli script embedded e quelli caricati da una directory esterna producono una cronologia compatibile quando utilizzano gli stessi nomi file.


Perché 999999_Data.sql è condizionale

Lo script finale non viene eseguito sempre.

Se il database è già alla versione target, il piano è vuoto e 999999_Data.sql deve essere ignorato. Uno script dati potrebbe infatti:

  • modificare dati già aggiornati;
  • eseguire operazioni costose;
  • non essere idempotente;
  • produrre effetti indesiderati durante un semplice controllo dello stato.

Il flusso effettivo è:

if (migrationScripts.Length == 0)
{
    logger.LogInformation(
        "No migrations are required for the requested target version.");

    return;
}

PerformUpgrade(
    CreateUpgradeEngine(migrationScripts),
    "migration scripts");

RunFinalScriptIfRequired(finalScript);

UpdateDatabaseVersion(targetVersion);

L’aggiornamento della tabella DatabaseVersion avviene soltanto dopo il completamento delle migrazioni e dello script finale.

In questo modo la versione registrata rappresenta realmente lo stato raggiunto dal database.


Logging e sicurezza

Un database initializer deve essere facile da diagnosticare, soprattutto quando viene eseguito in una pipeline o come processo container.

Il servizio registra:

  • inizio e fine dell’operazione;
  • versione corrente e target;
  • elenco degli script pianificati;
  • risultato della connessione;
  • durata dell’aggiornamento;
  • errori DbUp;
  • identificativo univoco dell’esecuzione.

La connection string, però, non viene scritta nei log in forma originale.

Prima di essere registrata viene elaborata da ConnectionStringRedactor, che sostituisce la password con un valore mascherato:

public static string Redact(string connectionString)
{
    try
    {
        var builder = new SqlConnectionStringBuilder(
            connectionString)
        {
            Password = "***"
        };

        return builder.ConnectionString;
    }
    catch (Exception)
    {
        return "***REDACTED (unparsable connection string)***";
    }
}

Questo dettaglio è importante perché i log vengono spesso raccolti da sistemi centralizzati, allegati a ticket o condivisi durante il troubleshooting.

Sono inoltre supportati diversi formati di log, tra cui JSON e testo, configurabili tramite variabili d’ambiente:

DB_LOG_FORMAT=json
DB_LOG_FORMAT=text

Il formato JSON è utile per i sistemi di log aggregation, mentre quello testuale è più leggibile durante l’esecuzione locale.


Testing: logica pura e integrazione reale

La strategia di test è divisa in due livelli.

Test L0

I test L0 verificano la logica autonoma del planner:

  • ordinamento per sequenza;
  • associazione degli script aggiuntivi;
  • comportamento quando la versione è già aggiornata;
  • rifiuto di versioni target senza script;
  • rifiuto di versioni duplicate;
  • rifiuto di script aggiuntivi orfani.

Questi test non richiedono SQL Server e possono essere eseguiti rapidamente in CI.

Test L1

I test L1 verificano invece il comportamento completo contro un database SQL Server reale.

Il test:

  1. prepara la tabella DatabaseVersion;
  2. imposta una versione precedente;
  3. esegue il servizio;
  4. controlla il journal DbUp;
  5. verifica la nuova versione del database.

Esempio:

var options = new DbUpDatabaseUpdateServiceOptions
{
    ConnectionString = connectionString,
    MigrationScriptsFolderPath = migrationsFolder,
    TargetVersion = new DatabaseVersion(1, 1, 0),
    MigrationScriptsTableName = "SchemaVersions",
    MigrationScriptsTableSchema = "dbo"
};

IDatabaseUpdateService service =
    new DbUpDatabaseUpdateService(options);

service.Update();

Assert.AreEqual(
    110,
    GetDatabaseVersion(connectionString));

Questa distinzione consente di validare velocemente le regole senza dipendenze esterne, mantenendo comunque test end-to-end per il percorso reale di aggiornamento.


Packaging e pipeline CI/CD

Il servizio viene pubblicato come container tramite il target container del .NET SDK e non richiede un Dockerfile dedicato.

La pipeline esegue questi passaggi:

  1. recupera gli script SQL della release;
  2. li copia nella directory Migrations;
  3. ripristina le dipendenze .NET;
  4. compila la soluzione;
  5. esegue i test L0;
  6. pubblica l’immagine container;
  7. applica i tag previsti, incluso il build ID e la versione della suite.

Questa fase di staging separa il codice dell’initializer dal bundle SQL della release.

Il codice stabilisce come pianificare ed eseguire le migrazioni; la pipeline fornisce invece gli script corretti per la versione che deve essere distribuita.


Utilizzo da container

Una volta pubblicata l’immagine, il servizio può essere eseguito indicando la connection string e la versione target:

docker run --rm `
    -e DB_LOG_FORMAT=text `
  datadbinit:local `
  --connectionString "$connectionString" `
  --targetVersion 1.1.0

Per utilizzare una directory esterna:

docker run --rm `
  -v "${PWD}\Migrations:/app/migrations:ro" `
  datadbinit:local `
  --connectionString "$connectionString" `
  --migrationsScriptsPath /app/migrations `
  --targetVersion 1.1.0

Quando il database SQL Server viene eseguito sull’host locale e il container gira tramite Docker Desktop, la connection string deve generalmente utilizzare:

host.docker.internal

al posto di:

localhost

Conclusione

La parte più complessa del progetto non era chiamare DbUp, ma definire con precisione il comportamento attorno a DbUp.

Un database initializer affidabile deve sapere:

  • quali script sono validi;
  • in quale ordine devono essere eseguiti;
  • quali versioni sono applicabili;
  • quando uno script finale deve essere escluso;
  • come gestire uno stato database incoerente;
  • come supportare container e override locali;
  • come produrre log utili senza esporre segreti;
  • come dimostrare il comportamento tramite test automatici.

Separando pianificazione, caricamento, esecuzione, versioning e osservabilità, il servizio diventa più semplice da testare e più prevedibile da operare.

Il risultato non è soltanto un processo che porta il database alla versione successiva, ma un componente ripetibile e verificabile, integrabile in una pipeline di distribuzione automatica.