WordPress’te Özel Tablo Oluşturma ve Veritabanı Yönetimi
WordPress ile eklenti geliştirirken en çok göz ardı edilen konulardan biri veritabanı yönetimidir. Çoğu geliştirici post meta’ya ya da options tablosuna bir şeyler tıkıştırıp geçer. Ama ciddi bir eklenti yazıyorsanız, yani gerçek anlamda veri yönetimi yapan bir şey, o zaman özel tablolar kaçınılmaz hale gelir. Bu yazıda WordPress’in $wpdb sınıfını kullanarak nasıl düzgün, güvenli ve ölçeklenebilir özel tablolar oluşturacağınızı anlatacağım. Sadece teorik değil, gerçek projelerde karşılaştığım sorunları ve çözümlerini de aktaracağım.
Neden Özel Tablo?
Bu soruyu sormadan geçmek olmaz. WordPress’in mevcut yapısı pek çok iş için yeterlidir. Ama bazı durumlar vardır ki, özel tablo olmadan işin içinden çıkamazsınız:
- Yüksek hacimli veri: Binlerce kayıt tutuyorsanız ve bu kayıtlar üzerinde karmaşık sorgular çalıştırıyorsanız,
wp_postmetatablosu sizi boğar. - İlişkisel veri: Birden fazla varlık arasında çok-çoka ilişkiler kurmanız gerekiyorsa, mevcut tablolar bu işe gelmez.
- Performans: Özel tablo demek özel indeks demektir. Doğru indekslenmiş bir tablo,
wp_postmetaüzerindeki karmaşık sorgulardan kat kat hızlıdır. - Veri bütünlüğü: Foreign key benzeri mantık uygulamak istiyorsanız, kendi tablonuzu kontrol etmeniz gerekir.
Ben bir e-ticaret entegrasyon eklentisi üzerinde çalışırken bu gerçekle yüz yüze geldim. Sipariş loglarını wp_postmeta‘ya yazıyorduk. Altı ay sonra tablo 2 milyon satırı geçti ve her sorgu site genelini yavaşlatmaya başladı. Özel tabloya geçiş günlerim oldu o dönem.
dbDelta Fonksiyonu: WordPress’in Gizli Silahı
WordPress’te özel tablo oluşturmanın standart yolu dbDelta() fonksiyonunu kullanmaktır. Bu fonksiyon, verdiğiniz SQL şemasını mevcut tablo yapısıyla karşılaştırır ve sadece gerekli değişiklikleri uygular. Yani eklentinizin farklı sürümlerinde şemayı güncellemek için de kullanılır.
Ama dbDelta() seçici bir hanımdır. Bazı kurallara uymak zorundaysınız, aksi halde sessizce hata verir ve siz neden tablo oluşmadı diye saçınızı yolarsınız.
dbDelta() için kritik kurallar:
- Her alan tanımı ayrı satırda olmalıdır. Virgülden sonra yeni satır.
- PRIMARY KEY tanımı büyük harf olmalıdır.
- Anahtar sözcüklerin sonuna iki boşluk bırakın:
CREATE TABLEifadesinden sonra tablo adı bir boşlukla başlar, ama sütun tiplerinden önce iki boşluk bırakmanız gerekir. Pek çok kaynak bunu yanlış anlatır. - INDEX yerine KEY kullanın.
function my_plugin_create_tables() {
global $wpdb;
$charset_collate = $wpdb->get_charset_collate();
$sql = "CREATE TABLE {$wpdb->prefix}my_plugin_logs (
id bigint(20) NOT NULL AUTO_INCREMENT,
user_id bigint(20) NOT NULL,
action varchar(100) NOT NULL,
data longtext NOT NULL,
created_at datetime DEFAULT '0000-00-00 00:00:00' NOT NULL,
PRIMARY KEY (id),
KEY user_id (user_id),
KEY action (action)
) $charset_collate;";
require_once( ABSPATH . 'wp-admin/includes/upgrade.php' );
dbDelta( $sql );
}
Dikkat ettiniz mi? PRIMARY KEY dan sonra iki boşluk var. Bu kasıtlı. dbDelta() bunu böyle bekliyor.
Aktivasyon Hook’una Bağlamak
Tablo oluşturma kodunu eklenti aktivasyonuna bağlamak en yaygın yaklaşımdır. Ama bunu yaparken dikkatli olmanız gereken bir nokta var: Multisite kurulumlar.
register_activation_hook( __FILE__, 'my_plugin_activate' );
function my_plugin_activate( $network_wide ) {
if ( is_multisite() && $network_wide ) {
// Tüm siteleri döngüye al
$sites = get_sites( array( 'fields' => 'ids' ) );
foreach ( $sites as $site_id ) {
switch_to_blog( $site_id );
my_plugin_create_tables();
restore_current_blog();
}
} else {
my_plugin_create_tables();
}
}
Bunu ilk kez yazmadığımda, bir müşterinin multisite kurulumunda 47 siteden sadece birinde tablo oluştu. Saatlerce debug yaptım. O günden beri aktivasyon koduna her zaman multisite kontrolü ekliyorum.
Sürüm Kontrolü ile Şema Güncellemeleri
Eklentinizin ilk sürümünde tablonuzu oluşturdunuz. Diyelim ki 3 ay sonra yeni bir alan eklemeniz gerekiyor. Kullanıcılar eklentinizi zaten kurmuş durumda. Bu noktada dbDelta() devreye giriyor.
Doğru yaklaşım, bir versiyon numarası tutmak ve her güncellemede bunu kontrol etmektir:
define( 'MY_PLUGIN_DB_VERSION', '1.3' );
function my_plugin_check_db_version() {
$installed_version = get_option( 'my_plugin_db_version' );
if ( $installed_version !== MY_PLUGIN_DB_VERSION ) {
my_plugin_create_tables();
update_option( 'my_plugin_db_version', MY_PLUGIN_DB_VERSION );
}
}
add_action( 'plugins_loaded', 'my_plugin_check_db_version' );
Bu yaklaşımın güzel yanı şu: dbDelta() mevcut tabloyu analiz eder ve sadece eksik sütunları veya değişen sütun tanımlarını uygular. Var olan verinizi silmez. Yani güvenle yeni sütun ekleyebilirsiniz.
Ama bir uyarı: dbDelta() sütun silmez. Bir sütunu kaldırmak istiyorsanız, bunu manuel olarak yapmanız gerekir.
Güvenli Sorgular: $wpdb Hazır İfadeler
Özel tablonuzu oluşturdunuz. Şimdi veri yazma ve okuma zamanı. Burada en kritik konu SQL enjeksiyonu güvenliğidir. WordPress’in $wpdb->prepare() metodu tam olarak bunun için var.
function my_plugin_insert_log( $user_id, $action, $data ) {
global $wpdb;
$table_name = $wpdb->prefix . 'my_plugin_logs';
$result = $wpdb->insert(
$table_name,
array(
'user_id' => $user_id,
'action' => $action,
'data' => maybe_serialize( $data ),
'created_at' => current_time( 'mysql' ),
),
array( '%d', '%s', '%s', '%s' )
);
if ( false === $result ) {
// $wpdb->last_error ile hatayı logla
error_log( 'MY_PLUGIN DB Error: ' . $wpdb->last_error );
return false;
}
return $wpdb->insert_id;
}
Format dizisi önemlidir. %d integer için, %s string için, %f float için kullanılır. Bunu atlamak veya yanlış kullanmak, güvenlik açığına neden olabilir.
Okuma işlemleri için prepare():
function my_plugin_get_user_logs( $user_id, $limit = 50 ) {
global $wpdb;
$table_name = $wpdb->prefix . 'my_plugin_logs';
$query = $wpdb->prepare(
"SELECT * FROM {$table_name}
WHERE user_id = %d
ORDER BY created_at DESC
LIMIT %d",
$user_id,
$limit
);
return $wpdb->get_results( $query );
}
Asla şöyle bir şey yazmayın:
// BU YANLIS - YAPMAYIN
$query = "SELECT * FROM {$table_name} WHERE user_id = " . $_GET['user_id'];
$wpdb->get_results( $query );
Bu kadar basit bir hata, sitenizi tamamen ele geçirilmeye açık hale getirir.
İlişkisel Tablo Yapısı
Gerçek dünya senaryolarında tek bir tablo yetmez. Diyelim ki bir anket eklentisi yazıyorsunuz. Anketler, sorular ve cevaplar için ayrı tablolara ihtiyacınız var.
function survey_plugin_create_tables() {
global $wpdb;
$charset_collate = $wpdb->get_charset_collate();
// Anketler tablosu
$sql_surveys = "CREATE TABLE {$wpdb->prefix}surveys (
id bigint(20) NOT NULL AUTO_INCREMENT,
title varchar(255) NOT NULL,
description text,
created_by bigint(20) NOT NULL,
status varchar(20) DEFAULT 'draft' NOT NULL,
created_at datetime DEFAULT '0000-00-00 00:00:00' NOT NULL,
updated_at datetime DEFAULT '0000-00-00 00:00:00' NOT NULL,
PRIMARY KEY (id),
KEY created_by (created_by),
KEY status (status)
) $charset_collate;";
// Sorular tablosu
$sql_questions = "CREATE TABLE {$wpdb->prefix}survey_questions (
id bigint(20) NOT NULL AUTO_INCREMENT,
survey_id bigint(20) NOT NULL,
question_text text NOT NULL,
question_type varchar(50) NOT NULL,
options longtext,
sort_order int(11) DEFAULT 0 NOT NULL,
PRIMARY KEY (id),
KEY survey_id (survey_id)
) $charset_collate;";
// Cevaplar tablosu
$sql_answers = "CREATE TABLE {$wpdb->prefix}survey_answers (
id bigint(20) NOT NULL AUTO_INCREMENT,
survey_id bigint(20) NOT NULL,
question_id bigint(20) NOT NULL,
user_id bigint(20),
session_id varchar(100),
answer_value text NOT NULL,
submitted_at datetime DEFAULT '0000-00-00 00:00:00' NOT NULL,
PRIMARY KEY (id),
KEY survey_id (survey_id),
KEY question_id (question_id),
KEY user_id (user_id)
) $charset_collate;";
require_once( ABSPATH . 'wp-admin/includes/upgrade.php' );
dbDelta( $sql_surveys );
dbDelta( $sql_questions );
dbDelta( $sql_answers );
}
Bu yapıda dikkat edin: WordPress, standart MySQL foreign key constraint’lerini desteklemez çünkü bazı hosting ortamları InnoDB yerine MyISAM kullanır. Bu yüzden referans bütünlüğünü PHP katmanında sağlamanız gerekir.
JOIN Sorguları ile Veri Çekme
İlişkisel tablolarınızı oluşturduktan sonra, birden fazla tablodan veri çekmek için JOIN kullanmanız gerekecek:
function get_survey_with_questions( $survey_id ) {
global $wpdb;
$surveys_table = $wpdb->prefix . 'surveys';
$questions_table = $wpdb->prefix . 'survey_questions';
// Anket bilgisi
$survey = $wpdb->get_row(
$wpdb->prepare(
"SELECT s.*, u.display_name as author_name
FROM {$surveys_table} s
LEFT JOIN {$wpdb->users} u ON s.created_by = u.ID
WHERE s.id = %d",
$survey_id
)
);
if ( ! $survey ) {
return false;
}
// Sorular
$survey->questions = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$questions_table}
WHERE survey_id = %d
ORDER BY sort_order ASC",
$survey_id
)
);
return $survey;
}
Bu yaklaşımda $wpdb->users gibi WordPress’in kendi tablo referanslarını kullanabilirsiniz. $wpdb sınıfı tüm WordPress tablolarını otomatik prefix ile property olarak tutar.
Temizleme: Deaktivasyon ve Kaldırma
Birçok geliştirici bunu es geçer. Eklenti kaldırıldığında tabloların silinmesi şart değildir, ama iyi pratiktir. Önemli olan şu: bunu deaktivasyon hook’unda değil, uninstall.php dosyasında yapın. Deaktivasyon geçici bir işlemdir, kaldırma kalıcıdır.
// uninstall.php dosyası - eklentin kök dizininde olmalı
if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
die;
}
global $wpdb;
// Tabloları sil
$tables = array(
$wpdb->prefix . 'survey_answers',
$wpdb->prefix . 'survey_questions',
$wpdb->prefix . 'surveys',
);
foreach ( $tables as $table ) {
$wpdb->query( "DROP TABLE IF EXISTS {$table}" );
}
// İlgili option'ları da temizle
delete_option( 'my_plugin_db_version' );
delete_option( 'my_plugin_settings' );
Bağımlı tabloları silme sırası önemlidir. Alt tablolar (cevaplar, sorular) önce silinmelidir. Tersi durumda veri bütünlüğü sorunları yaşayabilirsiniz, özellikle foreign key kullanıyorsanız.
Performans: İndeks Stratejisi
Tablo oluştururken indeksleri doğru belirlemek, ilerleyen süreçte size çok zaman kazandırır. Temel kurallar şunlardır:
- WHERE koşulunda sık kullandığınız sütunlara indeks ekleyin
- ORDER BY için kullandığınız sütunları indeksleyin
- JOIN koşullarında kullandığınız sütunlar mutlaka indekslenmeli
- Çok sütunlu indeksler (composite index) sorgularınızın tipine göre belirlenmelidir
// Performanslı tablo örneği - e-ticaret log tablosu
$sql = "CREATE TABLE {$wpdb->prefix}order_events (
id bigint(20) NOT NULL AUTO_INCREMENT,
order_id bigint(20) NOT NULL,
event_type varchar(50) NOT NULL,
event_data longtext,
user_id bigint(20),
ip_address varchar(45),
created_at datetime DEFAULT '0000-00-00 00:00:00' NOT NULL,
PRIMARY KEY (id),
KEY order_id (order_id),
KEY event_type (event_type),
KEY user_id (user_id),
KEY created_at (created_at),
KEY order_event_compound (order_id, event_type)
) $charset_collate;";
Son satırdaki order_event_compound bileşik indeks şu sorgu için biçilmiş kaftan:
SELECT * FROM wp_order_events
WHERE order_id = 1234 AND event_type = 'payment_failed'
ORDER BY created_at DESC
Bu tür sorguyu indekssiz çalıştırırsanız, 100.000 kayıtlık tabloda full table scan yaşarsınız. İndeksle milisaniyeler içinde sonuç alırsınız.
Hata Ayıklama: $wpdb->last_error ve show_errors
Geliştirme ortamında $wpdb‘nin hata çıktılarını açmak hayat kurtarır:
// Sadece geliştirme ortamında kullanın
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
$wpdb->show_errors();
}
// Belirli bir sorgudan sonra hatayı kontrol etmek
$wpdb->query( $some_query );
if ( $wpdb->last_error ) {
error_log( sprintf(
'[MY_PLUGIN] DB Error: %s | Query: %s',
$wpdb->last_error,
$wpdb->last_query
) );
}
$wpdb->show_errors() metodunu production’da asla açmayın. SQL hataları ve sorguları doğrudan ekrana basılır, bu ciddi bir güvenlik açığıdır.
Gerçek Proje Deneyimi: Toplu İşlem Sorunları
Büyük veri setleriyle çalışırken toplu insert işlemleri kritik önem kazanır. Tek tek insert yapmak yerine, çoklu insert kullanın:
function my_plugin_bulk_insert( $rows ) {
global $wpdb;
if ( empty( $rows ) ) {
return 0;
}
$table_name = $wpdb->prefix . 'my_plugin_logs';
$values = array();
$place_holders = array();
foreach ( $rows as $row ) {
array_push( $values, $row['user_id'], $row['action'], $row['data'], current_time( 'mysql' ) );
$place_holders[] = "(%d, %s, %s, %s)";
}
$query = "INSERT INTO {$table_name} (user_id, action, data, created_at) VALUES ";
$query .= implode( ', ', $place_holders );
$prepared = $wpdb->prepare( $query, $values );
return $wpdb->query( $prepared );
}
Bu yaklaşımla 1000 satırlık bir veri setini tek sorguda insert edebilirsiniz. 1000 ayrı insert yerine tek bir toplu insert, özellikle transaction içinde kullanıldığında 10-20 kat daha hızlı çalışır.
Büyük import işlemleri için transaction kullanmak da önemlidir:
function my_plugin_import_with_transaction( $data_rows ) {
global $wpdb;
$wpdb->query( 'START TRANSACTION' );
$success = true;
foreach ( $data_rows as $row ) {
$result = my_plugin_insert_log( $row['user_id'], $row['action'], $row['data'] );
if ( false === $result ) {
$success = false;
break;
}
}
if ( $success ) {
$wpdb->query( 'COMMIT' );
return true;
} else {
$wpdb->query( 'ROLLBACK' );
return false;
}
}
Sonuç
WordPress’te özel tablo yönetimi, dikkat edilmesi gereken pek çok detayı barındırıyor. Ama temel prensipler sizi çoğu sorundan korur: dbDelta() kurallarına uymak, her zaman prepare() kullanmak, indeksleri baştan doğru belirlemek, sürüm kontrolünü ihmal etmemek ve multisite desteğini unutmamak.
Özel tablo oluşturmak gerçekten gerektiğinde kullanılması gereken bir araçtır. Her şeyi özel tabloya koymak da çözüm değildir. Ama veri hacminiz büyüdükçe, ilişkisel yapınız karmaşıklaştıkça, wp_postmeta veya wp_options‘ın sınırlarına çarptıkça, bu yazıda anlattıklarım tam olarak ihtiyacınız olan şeyler olacak.
Production’a çıkmadan önce mutlaka farklı MySQL sürümlerinde test edin. WordPress 6.x ile birlikte bazı tablo oluşturma davranışları değişti ve eski yaklaşımlar bazen beklenmedik sonuçlar doğurabiliyor. dbDelta() çıktısını loglayın, neyin değiştiğini neyin atlandığını görün. Bu sizi ileride çok daha az baş ağrısından kurtarır.
