2026年7月11日

2026年7月11日

WordPressのデータベースクエリエラーを解決する方法

はじめに

WordPressでWP_Queryで期待した投稿が取得できない・$wpdb->query()でSQLを実行しているが結果がfalseになる・$wpdb->prepare()の使い方が分からずSQLインジェクションのリスクがある・カスタムクエリがタイムアウトするほど遅いといった問題は、WordPressのデータベースAPIを正しく使い、適切なインデックスとクエリ最適化を行うことで解決できます。

症状・原因

  • $wpdb->prepare()を使わず文字列連結でSQLを組み立てており、SQLインジェクションの脆弱性がある
  • WP_Querymeta_querytax_queryが複雑になりすぎてN+1問題が発生している
  • $wpdb->last_errorを確認せずクエリの失敗原因が不明なままになっている
  • カスタムテーブルにインデックスがなく全テーブルスキャンが発生している

解決手順

ステップ1:データベースクエリの状態を診断する

# データベース接続を確認
wp db check

# 遅いクエリを確認(SAVEQUERIES を有効化)
wp eval "
define('SAVEQUERIES', true);
global \$wpdb;
// クエリ実行後...
foreach (\$wpdb->queries as \$q) {
    if (\$q[1] > 0.01) {  // 10ms以上かかったクエリ
        echo round(\$q[1] * 1000) . 'ms: ' . \$q[0] . PHP_EOL;
    }
}
"

# 最後のクエリエラーを確認
wp eval "global \$wpdb; echo \$wpdb->last_error;"

# テーブルの状態を確認
wp db query "SHOW TABLE STATUS LIKE 'wp_posts';"
wp db query "EXPLAIN SELECT * FROM wp_posts WHERE post_status='publish' LIMIT 10;"

ステップ2:$wpdbを正しく使用する

global $wpdb;

// ✅ 正しい: $wpdb->prepare() でSQLインジェクション対策
$user_id  = 5;
$status   = 'publish';
$results  = $wpdb->get_results(
    $wpdb->prepare(
        "SELECT ID, post_title FROM {$wpdb->posts}
         WHERE post_author = %d
         AND post_status = %s
         AND post_type = 'post'
         ORDER BY post_date DESC
         LIMIT %d",
        $user_id,
        $status,
        10
    )
);

// ❌ 悪い例: SQLインジェクション脆弱性あり
// $results = $wpdb->get_results("SELECT * FROM {$wpdb->posts} WHERE ID = " . $_GET['id']);

// エラーチェック
if ($wpdb->last_error) {
    error_log('DB Error: ' . $wpdb->last_error);
    return [];
}

// INSERT / UPDATE / DELETE
$wpdb->insert(
    $wpdb->prefix . 'my_table',
    ['user_id' => $user_id, 'data' => 'value', 'created' => current_time('mysql')],
    ['%d', '%s', '%s']
);
$inserted_id = $wpdb->insert_id;

$wpdb->update(
    $wpdb->prefix . 'my_table',
    ['data' => 'new_value'],
    ['user_id' => $user_id],
    ['%s'],
    ['%d']
);

ステップ3:WP_Queryを最適化する

// パフォーマンスを意識したWP_Query
$query = new WP_Query([
    'post_type'      => 'product',
    'post_status'    => 'publish',
    'posts_per_page' => 20,
    'no_found_rows'  => true,    // ページネーション不要時はtrueでCOUNTクエリを省略
    'update_post_meta_cache' => true,  // メタデータを一括取得
    'update_post_term_cache' => true,  // タームデータを一括取得
    'fields'         => 'ids',   // IDのみ必要な場合

    // meta_query はインデックスが有効な列のみ使用
    'meta_query'     => [
        [
            'key'     => '_price',
            'value'   => [100, 10000],
            'type'    => 'NUMERIC',
            'compare' => 'BETWEEN',
        ],
    ],
    // tax_query
    'tax_query'      => [
        [
            'taxonomy' => 'product_category',
            'field'    => 'slug',
            'terms'    => ['electronics'],
        ],
    ],
]);

// N+1問題を避ける: メタデータは一括取得
if ($query->have_posts()) {
    $post_ids = wp_list_pluck($query->posts, 'ID');
    // update_meta_cache で一括取得
    update_postmeta_cache($post_ids);
}

ステップ4:カスタムテーブルを作成・管理する

// カスタムテーブルをdbDelta()で作成・更新
function create_my_custom_table(): void {
    global $wpdb;
    $table_name = $wpdb->prefix . 'my_logs';
    $charset    = $wpdb->get_charset_collate();

    $sql = "CREATE TABLE {$table_name} (
        id         bigint(20)   NOT NULL AUTO_INCREMENT,
        user_id    bigint(20)   NOT NULL,
        action     varchar(50)  NOT NULL,
        data       longtext,
        created_at datetime     NOT NULL DEFAULT CURRENT_TIMESTAMP,
        PRIMARY KEY (id),
        KEY user_id (user_id),
        KEY action  (action),
        KEY created_at (created_at)
    ) {$charset};";

    require_once ABSPATH . 'wp-admin/includes/upgrade.php';
    dbDelta($sql);
}
register_activation_hook(__FILE__, 'create_my_custom_table');

// テーブルバージョン管理
$current_version = get_option('my_plugin_db_version', '0');
if (version_compare($current_version, '1.1', '<')) {
    create_my_custom_table();
    update_option('my_plugin_db_version', '1.1');
}

ステップ5:クエリキャッシュで高速化する

// wp_cache_get/set でクエリ結果をキャッシュ
function get_top_products(int $limit = 10): array {
    $cache_key   = "top_products_{$limit}";
    $cache_group = 'my_plugin';
    $cached      = wp_cache_get($cache_key, $cache_group);
    if ($cached !== false) {
        return $cached;
    }

    global $wpdb;
    $results = $wpdb->get_results(
        $wpdb->prepare(
            "SELECT p.ID, p.post_title, pm.meta_value as price
             FROM {$wpdb->posts} p
             INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id
             WHERE p.post_type = 'product'
             AND p.post_status = 'publish'
             AND pm.meta_key = '_price'
             ORDER BY CAST(pm.meta_value AS DECIMAL) DESC
             LIMIT %d",
            $limit
        )
    );

    wp_cache_set($cache_key, $results, $cache_group, HOUR_IN_SECONDS);
    return $results ?: [];
}

注意事項

  • $wpdb->prepare()を使わずに変数をSQL文字列に直接埋め込むことは絶対に避けてください。WordPressは$wpdb->last_errorにエラー詳細を格納しますが、本番環境ではWP_DEBUGを無効にしてエラーをユーザーに表示しないようにしてください
  • カスタムテーブルのスキーマ変更には必ずdbDelta()を使用してください。直接ALTER TABLEを実行すると、プラグインのバージョンアップ時に既存データが失われる可能性があります

まとめ

WordPress データベースクエリ問題の解決は①wp db checkでDB接続確認・SAVEQUERIESで遅いクエリを特定・$wpdb->last_errorでエラー確認・EXPLAINでクエリ実行計画を分析、②$wpdb->prepare()%d/%s/%fプレースホルダーを使用・変数の直接埋め込み禁止・insert/update/deleteメソッドでデータ型を指定、③no_found_rows=trueでCOUNTクエリ省略・fields='ids'でIDのみ取得・update_post_meta_cacheでN+1問題解消・meta_queryのインデックス活用、④dbDelta()でカスタムテーブル作成・PRIMARY KEY/インデックスを適切に設定・バージョン管理で安全なスキーマ更新、⑤wp_cache_get/setでクエリ結果をキャッシュ・JOINクエリでN+1を防止・HOUR_IN_SECONDSで有効期限管理の手順で解決します。

お気軽にご相談ください

お見積りへ お問い合わせへ