Proseguendo nel nostro progetto con cui, ripetiamo, vogliamo che Claude Code scriva una migration SQL da zero, possiamo sottolineare che il secondo requisito era più delicato del primo perché destinato ad interagire con dati esistenti. La tabella ticket_assignments aveva un indice composito su (ticket_id, user_id) che garantiva l'unicità dell'assegnazione. Il requisito prevedeva di aggiungere una colonna assigned_at con il timestamp dell'assegnazione e includerla nell'indice per ottimizzare le query che filtravano per data di assegnazione in un certo intervallo.
Claude code genera la migration SQL
La migration generata da Claude Code era tecnicamente valida:
// database/migrations/2024_01_15_000002_update_ticket_assignments_table.php
public function up(): void
{
Schema::table('ticket_assignments', function (Blueprint $table) {
$table->timestamp('assigned_at')->nullable()->after('user_id');
});
// Rimozione indice esistente e creazione del nuovo indice composito
Schema::table('ticket_assignments', function (Blueprint $table) {
$table->dropUnique(['ticket_id', 'user_id']);
$table->index(['ticket_id', 'user_id', 'assigned_at'], 'idx_ticket_assignments_composite');
});
}
public function down(): void
{
Schema::table('ticket_assignments', function (Blueprint $table) {
$table->dropIndex('idx_ticket_assignments_composite');
$table->unique(['ticket_id', 'user_id']);
$table->dropColumn('assigned_at');
});
}
Il codice è sintatticamente corretto e la logica di rollback è presente e coerente. Ma ci sono due problemi che uno sviluppatore con esperienza su PostgreSQL in produzione riconoscerebbe immediatamente e che Claude Code ha ignorato completamente.
Primo errore di Claude Code
Il primo è che la colonna assigned_at viene aggiunta come nullable, il che è sensato per non rompere i record esistenti. Ma l'agente non ha considerato che includere una colonna nullable in un indice composito su PostgreSQL produce un comportamento specifico. Le righe con assigned_at a NULL vengono comunque indicizzate, il che significa che tutti i record esistenti parteciperanno all'indice con un valore NULL, rendendo l'indice potenzialmente meno selettivo di quanto ci si aspetti per le query che filtrano su intervalli di date.
La soluzione corretta sarebbe stata usare un indice parziale
WHERE assigned_at IS NOT NULL
per escludere i record legacy, ma questo richiede SQL raw che Laravel non espone direttamente tramite Blueprint.
Il secondo problema è ancora più grave
Per quanto riguarda il secondo errore, rimuovere l'indice UNIQUE su (ticket_id, user_id) e sostituirlo con un indice non-unique su (ticket_id, user_id, assigned_at) significa che il constraint di unicità sull'assegnazione, un ticket non può essere assegnato due volte allo stesso utente, viene silenziosamente eliminato.
L'indice non-unique non garantisce l'unicità. Se il requisito era aggiungere assigned_at all'indice per le performance di query mantenendo il constraint di unicità, la migration corretta avrebbe dovuto creare un indice UNIQUE su (ticket_id, user_id) separato dall'indice di performance su (ticket_id, user_id, assigned_at).
Claude Code ha ottimizzato per la performance senza accorgersi di aver rimosso un'invariante del sistema.
La colonna JSONB con constraint di validazione
Il terzo requisito era il più interessante tecnicamente: aggiungere una colonna metadata di tipo JSONB sulla tabella tickets, con un valore di default che rappresentasse una struttura JSON minima, un oggetto con alcune chiavi obbligatorie e valori predefiniti, e un check constraint che verificasse che il JSON inserito rispettasse almeno la presenza delle chiavi obbligatorie.
Questo è un territorio in cui Laravel non arriva con le sue astrazioni standard. I check constraint su colonne JSONB richiedono SQL raw, e la migration generata da Claude Code lo riflette correttamente:
// database/migrations/2024_01_15_000003_add_metadata_to_tickets_table.php
public function up(): void
{
Schema::table('tickets', function (Blueprint $table) {
$table->jsonb('metadata')->default(DB::raw("'{
\"source\": \"manual\",
\"external_id\": null,
\"integration\": null,
\"custom_fields\": {}
}'::jsonb"));
});
// Check constraint per validare la struttura del JSON
DB::statement("
ALTER TABLE tickets
ADD CONSTRAINT chk_tickets_metadata_structure
CHECK (
metadata ? 'source'
AND metadata ? 'external_id'
AND metadata ? 'integration'
AND metadata ? 'custom_fields'
AND jsonb_typeof(metadata->'custom_fields') = 'object'
)
");
}
public function down(): void
{
DB::statement("
ALTER TABLE tickets
DROP CONSTRAINT IF EXISTS chk_tickets_metadata_structure
");
Schema::table('tickets', function (Blueprint $table) {
$table->dropColumn('metadata');
});
}
Questa migration è notevolmente buona. L'uso dell'operatore ? di PostgreSQL per verificare la presenza delle chiavi, la verifica del tipo del campo custom_fields tramite jsonb_typeof, il cast esplicito ::jsonb nel default: sono tutti dettagli corretti che dimostrano una conoscenza genuina delle funzionalità JSONB di PostgreSQL.
L'agente ha anche gestito correttamente il rollback con
DROP CONSTRAINT IF EXISTS
invece di un DROP semplice, il che rende la migration reversibile anche se il constraint non esiste per qualche ragione.
Se vuoi aggiornamenti su Quando Claude Code sbaglia inserisci la tua email nel box qui sotto: