Skip to main content

Cyber AI România

Vineri, 23 octombrie 2026

Tabele custom în WordPress: migrații versionate și capcanele dbDelta()

Pluginul de suport al unei firme trebuie să păstreze tichetele existente când o versiune nouă adaugă statusuri și înlocuiește câmpul pentru adresa solicitantului. Vom construi un plugin care trece controlat prin trei versiuni ale unui tabel custom, memorează ultima migrare reușită și verifică explicit rezultatul produs de dbDelta().

La final, WordPress va avea tabelul ${prefix}car_tickets, opțiunea car_tickets_schema_version cu valoarea 3 și un rând migrat fără pierderea adresei inițiale. Laboratorul este pentru un site WordPress single-site de staging sau dezvoltare, nu pentru execuție directă pe baza de date de producție.

Fixăm laboratorul la WordPress 7.1.3 și trei versiuni de schemă

Configurația de referință este:

  • Ubuntu Server 24.04 LTS;
  • WordPress 7.1.3;
  • PHP 8.3;
  • MariaDB 10.11;
  • WP-CLI 2.12.0;
  • acces SSH la directorul instalării WordPress;
  • minimum 512 MB RAM și aproximativ 50 MB liberi pentru plugin și copiile bazei de date;
  • 30-60 de minute.

Rulează verificările din directorul în care se află wp-config.php:

set -euo pipefail

cat /etc/os-release
php --version
wp --version
wp core version
wp db cli --execute='SELECT VERSION();'
df -h .

Un rezultat orientativ este:

Ubuntu 24.04 LTS
PHP 8.3.x
WP-CLI 2.12.0
7.1.3
10.11.x-MariaDB

Versiunile patch și spațiul disponibil pot fi diferite. Tutorialul nu cere instalarea altor pachete: folosește API-ul WordPress, dbDelta() din WordPress Core și comenzile WP-CLI deja disponibile.

Înainte de laborator, creează un backup al bazei:

mkdir -p "$HOME/wp-migration-backups"
wp db export "$HOME/wp-migration-backups/before-car-tickets.sql"
test -s "$HOME/wp-migration-backups/before-car-tickets.sql"

Ultima comandă nu afișează nimic dacă fișierul există și nu este gol. Nu continua pe un site important fără să fi testat restaurarea backupului. Pentru promovarea controlată a schimbării poți integra aceleași verificări într-un pipeline WordPress cu staging efemer și teste automate.

Două fișiere separă încărcarea pluginului de istoricul bazei de date

Creează directorul proiectului:

mkdir -p wp-content/plugins/car-tickets/src

Structura va fi:

wp-content/plugins/car-tickets/
├── car-tickets.php
└── src/
    └── class-car-tickets-migrator.php

car-tickets.php înregistrează hook-urile pluginului. Clasa din src definește versiunile, SQL-ul aferent și verificările executate după fiecare schimbare.

Separarea contează când pluginul ajunge la mai multe versiuni: versiunea codului și versiunea bazei de date nu sunt același lucru. Prima poate fi citită din antetul pluginului, iar a doua trebuie păstrată separat, aici în opțiunea car_tickets_schema_version.

Pasul 1 — Legăm activarea și încărcarea pluginului de migrator

De ce: hook-ul de activare acoperă instalarea inițială, dar nu este suficient pentru actualizările ulterioare. WordPress nu reactivează automat pluginul după fiecare înlocuire a fișierelor, așa că verificarea versiunii rulează și pe plugins_loaded.

Fișierul wp-content/plugins/car-tickets/car-tickets.php trebuie să conțină integral:

<?php
/**
 * Plugin Name: CAR Tickets
 * Description: Demonstrație de migrații versionate pentru un tabel custom.
 * Version: 3.0.0
 * Requires PHP: 8.1
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

require_once __DIR__ . '/src/class-car-tickets-migrator.php';

register_activation_hook(
	__FILE__,
	array( 'CAR_Tickets_Migrator', 'maybe_migrate' )
);

add_action(
	'plugins_loaded',
	array( 'CAR_Tickets_Migrator', 'maybe_migrate' )
);

register_activation_hook() pornește instalarea pe un site nou. Hook-ul plugins_loaded compară versiunea salvată cu ținta la încărcările următoare. Constanta ABSPATH împiedică executarea directă a fișierului în afara WordPress.

Ce trebuie să vezi: verificarea de sintaxă trebuie să reușească fără output:

php -l wp-content/plugins/car-tickets/car-tickets.php

Exemplu:

No syntax errors detected in wp-content/plugins/car-tickets/car-tickets.php

Punct de control: dacă PHP raportează o linie cu eroare, corectează fișierul înainte de activare; nu testa un plugin cu sintaxă invalidă în wp-admin.

Pasul 2 — Aplicăm versiunile în ordine și salvăm doar etapele confirmate

De ce: o instalare poate sări direct de la versiunea 1 la versiunea 3. Migratorul trebuie să execute succesiv 1 → 2 → 3, nu doar schema finală, deoarece etapa a treia copiază date dintr-o coloană creată anterior.

Fișierul wp-content/plugins/car-tickets/src/class-car-tickets-migrator.php trebuie să conțină integral:

<?php

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

final class CAR_Tickets_Migrator {
	private const VERSION_OPTION = 'car_tickets_schema_version';
	private const LOCK_OPTION    = 'car_tickets_migration_lock';
	private const LATEST_VERSION = 3;

	public static function maybe_migrate(): void {
		$current = (int) get_option( self::VERSION_OPTION, 0 );
		$target  = self::target_version();

		if ( $current >= $target ) {
			return;
		}

		if ( ! self::acquire_lock() ) {
			return;
		}

		try {
			for ( $version = $current + 1; $version <= $target; $version++ ) {
				$method = 'migrate_to_' . $version;

				if ( ! is_callable( array( self::class, $method ) ) ) {
					throw new RuntimeException(
						'Lipșește migrarea pentru versiunea ' . $version
					);
				}

				call_user_func( array( self::class, $method ) );
				update_option( self::VERSION_OPTION, $version, false );
			}
		} finally {
			delete_option( self::LOCK_OPTION );
		}
	}

	private static function target_version(): int {
		if ( ! defined( 'CAR_TICKETS_SCHEMA_TARGET' ) ) {
			return self::LATEST_VERSION;
		}

		$target = (int) CAR_TICKETS_SCHEMA_TARGET;

		if ( $target < 1 || $target > self::LATEST_VERSION ) {
			throw new RuntimeException(
				'CAR_TICKETS_SCHEMA_TARGET trebuie să fie între 1 și 3.'
			);
		}

		return $target;
	}

	private static function acquire_lock(): bool {
		if ( add_option( self::LOCK_OPTION, time(), '', false ) ) {
			return true;
		}

		$created_at = (int) get_option( self::LOCK_OPTION, 0 );

		if ( $created_at > 0 && time() - $created_at > 300 ) {
			delete_option( self::LOCK_OPTION );
			return add_option( self::LOCK_OPTION, time(), '', false );
		}

		return false;
	}

	private static function migrate_to_1(): void {
		self::apply_schema( 1 );
		self::assert_columns(
			array( 'id', 'email', 'subject', 'created_at' )
		);
	}

	private static function migrate_to_2(): void {
		self::apply_schema( 2 );
		self::assert_columns(
			array( 'id', 'email', 'subject', 'status', 'created_at' )
		);
	}

	private static function migrate_to_3(): void {
		global $wpdb;

		self::apply_schema( 3 );

		$table   = self::table_name();
		$updated = $wpdb->query(
			"UPDATE {$table}
			SET requester_email = email
			WHERE requester_email = ''"
		);

		if ( false === $updated ) {
			throw new RuntimeException(
				'Migrarea adreselor a eșuat: ' . $wpdb->last_error
			);
		}

		self::assert_columns(
			array(
				'id',
				'email',
				'requester_email',
				'subject',
				'status',
				'created_at',
			)
		);

		$empty = (int) $wpdb->get_var(
			"SELECT COUNT(*)
			FROM {$table}
			WHERE requester_email = ''"
		);

		if ( 0 !== $empty ) {
			throw new RuntimeException(
				'Au rămas rânduri fără requester_email.'
			);
		}
	}

	private static function apply_schema( int $version ): void {
		global $wpdb;

		require_once ABSPATH . 'wp-admin/includes/upgrade.php';

		$table            = self::table_name();
		$charset_collate  = $wpdb->get_charset_collate();
		$status_column    = '';
		$requester_column = '';
		$status_index     = '';

		if ( $version >= 2 ) {
			$status_column = "
status varchar(20) NOT NULL DEFAULT 'open',";
			$status_index  = ",
KEY status_created (status, created_at)";
		}

		if ( $version >= 3 ) {
			$requester_column = "
requester_email varchar(190) NOT NULL DEFAULT '',";
		}

		$sql = "CREATE TABLE {$table} (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
email varchar(190) NOT NULL,{$requester_column}
subject varchar(255) NOT NULL,{$status_column}
created_at datetime NOT NULL,
PRIMARY KEY  (id),
KEY created_at (created_at){$status_index}
) {$charset_collate};";

		dbDelta( $sql );

		if ( '' !== $wpdb->last_error ) {
			throw new RuntimeException(
				'dbDelta a raportat: ' . $wpdb->last_error
			);
		}
	}

	private static function assert_columns( array $required ): void {
		global $wpdb;

		$table   = self::table_name();
		$columns = $wpdb->get_col( "DESC {$table}", 0 );

		if ( ! is_array( $columns ) ) {
			throw new RuntimeException(
				'Tabelul nu poate fi inspectat: ' . $wpdb->last_error
			);
		}

		$missing = array_diff( $required, $columns );

		if ( array() !== $missing ) {
			throw new RuntimeException(
				'Lipsesc coloanele: ' . implode( ', ', $missing )
			);
		}
	}

	private static function table_name(): string {
		global $wpdb;
		return $wpdb->prefix . 'car_tickets';
	}
}

LATEST_VERSION reprezintă schema cerută de codul curent. Opțiunea este actualizată numai după ce etapa s-a terminat și coloanele au fost inspectate.

Blocarea prin add_option() reduce riscul ca două cereri să pornească aceeași migrare simultan. După 300 de secunde, un lock abandonat poate fi înlocuit. Într-o desfășurare serioasă, migrarea se rulează înainte de a direcționa traficul către noul cod, nu se lasă prima cerere publică să facă modificări mari.

Verifică sintaxa:

php -l wp-content/plugins/car-tickets/src/class-car-tickets-migrator.php

Punct de control: dacă apare No syntax errors detected, codul poate fi încărcat; această verificare nu confirmă încă structura SQL.

Pasul 3 — Reproducem instalarea inițială fără să sărim direct la versiunea 3

Avertisment: comenzile următoare șterg exclusiv tabelul și opțiunile laboratorului. Confirmă prefixul înainte de DROP TABLE.

set -euo pipefail

PREFIX="$(wp db prefix)"
printf 'Tabelul laboratorului: %scar_ticketsn' "$PREFIX"

wp plugin deactivate car-tickets 2>/dev/null || true
wp db query "DROP TABLE IF EXISTS ${PREFIX}car_tickets"
wp option delete car_tickets_schema_version 2>/dev/null || true
wp option delete car_tickets_migration_lock 2>/dev/null || true

wp --exec='define("CAR_TICKETS_SCHEMA_TARGET", 1);' 
	plugin activate car-tickets

wp db query "SHOW COLUMNS FROM ${PREFIX}car_tickets"
wp option get car_tickets_schema_version

--exec definește temporar ținta înainte ca WordPress să încarce pluginul. Nu modifică wp-config.php. În prima etapă trebuie să existe coloanele id, email, subject și created_at, iar ultima comandă trebuie să afișeze:

1

Adaugă un tichet care va traversa următoarele migrații:

wp db query "
INSERT INTO ${PREFIX}car_tickets
	(email, subject, created_at)
VALUES
	('ana@example.com', 'Export facturi', UTC_TIMESTAMP())
"

Adresa și subiectul sunt date fictive. Pe un mediu real de testare folosește date sintetice, nu o copie neanonimizată a datelor clienților.

Punct de control: dacă tabelul nu apare, verifică wp plugin status car-tickets și jurnalul PHP înainte de a continua.

Pasul 4 — Adăugăm statusul și indexul compus fără a recrea tabelul

De ce: versiunea a doua arată operațiile pentru care dbDelta() este potrivit: adăugarea unei coloane și a unui index descrise în schema completă.

wp --exec='define("CAR_TICKETS_SCHEMA_TARGET", 2);' 
	option get car_tickets_schema_version

PREFIX="$(wp db prefix)"

wp db query "
SHOW COLUMNS FROM ${PREFIX}car_tickets
"

wp db query "
SHOW INDEX FROM ${PREFIX}car_tickets
WHERE Key_name = 'status_created'
"

Exemplu de rezultat relevant:

2
status  varchar(20)  NO  MUL  open
status_created  status
status_created  created_at

În SQL-ul transmis funcției, fiecare câmp este pe linie separată, între PRIMARY KEY și definiție sunt două spații, iar indexurile folosesc KEY, nu sinonimul INDEX. Aceste reguli provin din documentația oficială și evită diferențe de parsare.

Nu trata însă lista returnată de dbDelta() drept singura dovadă a succesului. Codul verifică structura prin DESC, iar testul de capăt inspectează separat indexurile și datele.

Punct de control: dacă opțiunea rămâne 1, caută excepția în jurnalul PHP; nu seta manual versiunea la 2, deoarece ai marca o migrare nereușită drept finalizată.

Pasul 5 — Copiem adresele într-o coloană nouă și păstrăm revenirea posibilă

De ce: dbDelta() poate adăuga requester_email, dar nu trebuie folosit ca mecanism implicit pentru redenumiri sau eliminări distructive. Versiunea a treia adaugă coloana, copiază datele și păstrează temporar email.

wp --exec='define("CAR_TICKETS_SCHEMA_TARGET", 3);' 
	option get car_tickets_schema_version

PREFIX="$(wp db prefix)"

wp db query "
SELECT
	id,
	email,
	requester_email,
	status,
	subject
FROM ${PREFIX}car_tickets
ORDER BY id
"

Exemplul de rezultat este:

3
1  ana@example.com  ana@example.com  open  Export facturi

Câmpul vechi rămâne intenționat. Codul aplicației poate fi lansat mai întâi într-o versiune care scrie ambele coloane, apoi într-o versiune care citește numai requester_email. Eliminarea lui email trebuie făcută într-o migrare ulterioară, după verificarea utilizării și după expirarea ferestrei de rollback.

Această abordare evită trei capcane:

  • o „redenumire” descrisă doar prin schimbarea SQL-ului poate deveni, în practică, o coloană nouă și una veche rămasă în tabel;
  • eliminarea unei coloane din șirul CREATE TABLE nu este o strategie sigură pentru ștergerea ei;
  • actualizarea opțiunii înaintea copierii datelor împiedică reluarea corectă după o eroare.

Înainte de a distribui arhiva pluginului către producție, este util să semnezi și să verifici artefactul pluginului, astfel încât fișierele testate să fie aceleași cu cele instalate.

Testul de capăt confirmă versiunea, coloanele, indexul și datele

Rulează toate verificările într-un singur bloc:

set -euo pipefail

PREFIX="$(wp db prefix)"
VERSION="$(wp option get car_tickets_schema_version)"
EMPTY_EMAILS="$(
	wp db query "
	SELECT COUNT(*)
	FROM ${PREFIX}car_tickets
	WHERE requester_email = ''
	" --skip-column-names
)"
INDEX_ROWS="$(
	wp db query "
	SELECT COUNT(*)
	FROM information_schema.statistics
	WHERE table_schema = DATABASE()
	  AND table_name = '${PREFIX}car_tickets'
	  AND index_name = 'status_created'
	" --skip-column-names
)"

test "$VERSION" = "3"
test "$EMPTY_EMAILS" = "0"
test "$INDEX_ROWS" = "2"

wp db query "
SELECT id, requester_email, status, subject
FROM ${PREFIX}car_tickets
ORDER BY id
"

Pentru indexul compus sunt așteptate două rânduri în information_schema.statistics, câte unul pentru fiecare coloană indexată. Valorile variabile sunt ID-ul și ora inserării; rezultatul funcțional trebuie să păstreze adresa, statusul open și subiectul.

Verifică și repetabilitatea:

wp eval 'CAR_Tickets_Migrator::maybe_migrate();'
wp option get car_tickets_schema_version

Outputul așteptat este:

3

A doua execuție nu trebuie să creeze coloane duplicate și nici să modifice rândul existent.

Cinci erori care localizează defectul în această migrare

Error: The 'car-tickets' plugin is not installed.

WP-CLI nu găsește directorul sau antetul pluginului. Verifică:

wp plugin list
ls -la wp-content/plugins/car-tickets
php -l wp-content/plugins/car-tickets/car-tickets.php

ERROR 1146 (42S02): Table '...car_tickets' doesn't exist

Activarea nu a ajuns la prima migrare sau ai folosit alt prefix. Obține prefixul real și starea pluginului:

wp db prefix
wp plugin status car-tickets

Error: Could not get 'car_tickets_schema_version' option. Does it exist?

Nicio etapă nu a fost confirmată. Nu crea opțiunea manual; reactivează pluginul după ce verifici jurnalul:

wp plugin deactivate car-tickets
wp plugin activate car-tickets

Unknown column 'requester_email' in 'field list'

Codul care citește schema 3 a fost lansat înaintea migrării. Revino la artefactul anterior sau rulează migratorul în staging înainte de promovare:

wp eval 'CAR_Tickets_Migrator::maybe_migrate();'
wp option get car_tickets_schema_version

CAR_TICKETS_SCHEMA_TARGET trebuie să fie între 1 și 3.

Valoarea dată prin --exec nu corespunde unei versiuni implementate. Folosește numai 1, 2 sau 3 în acest laborator:

wp --exec='define("CAR_TICKETS_SCHEMA_TARGET", 3);' 
	option get car_tickets_schema_version

Ștergem laboratorul sau restaurăm copia fără să atingem alte tabele

Avertisment: comenzile șterg tabelul și toate tichetele lui. Rulează-le numai dacă ai terminat laboratorul și ai confirmat prefixul.

set -euo pipefail

PREFIX="$(wp db prefix)"
printf 'Se șterge: %scar_ticketsn' "$PREFIX"

wp plugin deactivate car-tickets
wp db query "DROP TABLE IF EXISTS ${PREFIX}car_tickets"
wp option delete car_tickets_schema_version 2>/dev/null || true
wp option delete car_tickets_migration_lock 2>/dev/null || true
rm -rf wp-content/plugins/car-tickets

Pentru restaurarea întregii baze din copia inițială:

wp db import "$HOME/wp-migration-backups/before-car-tickets.sql"

Importul readuce întreaga bază la starea backupului, nu doar tabelul laboratorului. De aceea este potrivit pentru mediul izolat, nu ca metodă selectivă pe un site unde între timp au apărut comenzi sau formulare noi.

Lista de control înainte de livrarea versiunii 3

  • ☐ car_tickets_schema_version are valoarea 3.
  • ☐ Migrarea rulează succesiv pentru o instalare pornită de la versiunea 1.
  • ☐ Tabelul folosește prefixul returnat de $wpdb->prefix.
  • ☐ Coloana requester_email conține valorile copiate din email.
  • ☐ Indexul status_created include status și created_at.
  • ☐ Reexecutarea migratorului nu schimbă datele.
  • ☐ Versiunea opțiunii este salvată numai după verificarea etapei.
  • ☐ Backupul bazei există și a fost verificat ca fișier nenul.
  • ☐ Artefactul testat este același cu cel pregătit pentru instalare.

Următorul pas practic este un test automat care instalează succesiv trei artefacte ale pluginului într-un staging efemer. Pentru tabele mari, backfill-ul trebuie mutat din cererea web într-o comandă WP-CLI care lucrează în loturi și raportează progresul. Pe WordPress Multisite, migratorul trebuie extins pentru fiecare site din rețea și pentru site-urile create ulterior; exemplul de aici acoperă intenționat un singur site.

Întrebări frecvente

De ce nu este suficient hook-ul de activare?

Actualizarea fișierelor unui plugin nu garantează relansarea hook-ului de activare. Verificarea de pe plugins_loaded compară versiunea salvată cu ținta și poate aplica etapele lipsă.

Poate dbDelta() să redenumească sigur coloana email?

Laboratorul nu se bazează pe o redenumire implicită. Adaugă requester_email, copiază datele și păstrează coloana veche pentru o revenire controlată.

De ce rămâne coloana email după versiunea 3?

Eliminarea imediată ar face rollbackul mai dificil și ar putea afecta încă o versiune veche a codului. Ștergerea se programează într-o migrare ulterioară, după ce aplicația nu mai citește și nu mai scrie acel câmp.

Tutorialul funcționează direct pe WordPress Multisite?

Nu. Exemplul este pentru un site single-site; într-o rețea, migrarea trebuie aplicată separat fiecărui site și integrată cu procesul de creare a site-urilor noi.

Surse

Facebook
X
WhatsApp
Tabele custom în WordPress: migrații versionate și capcanele dbDelta() după mai multe versiuni ale pluginului

Te-ar putea interesa si: