Last active
May 22, 2026 08:33
-
-
Save arenagroove/ec9c4d6db38dd1b502dd4864b4cc00a5 to your computer and use it in GitHub Desktop.
WordPress MU plugin for safely managing composite postmeta indexes to improve ACF and meta query performance on large WordPress sites.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| /** | |
| * Plugin Name: LR Postmeta Index Manager | |
| * Description: Manage a composite index on the postmeta table (post_id, meta_key(191)) to speed up ACF and meta lookups. One-click, reversible. | |
| * Version: 1.6.0 | |
| * Requires PHP: 7.4 | |
| * Requires at least: 6.2 | |
| * Author: Luis Martinez | |
| * Author URI: https://www.lessrain.com | |
| * | |
| * Changelog | |
| * ───────────────────────────────────────────────────────────────────────────── | |
| * 1.6.0 2026-05-22 Updated to unified LR admin UI standard. Bug fix: index | |
| * removal now resolves the live index name via SHOW INDEX | |
| * (lr_pmi_get_index_name) instead of assuming the hardcoded | |
| * post_id_meta_key name, aligning drop behaviour with the | |
| * composition-based detection in lr_pmi_index_exists. | |
| * Extracted lr_pmi_get_index_name() to eliminate duplicated | |
| * SHOW INDEX traversal. Remove Index moved to red destructive | |
| * link with confirm gate. | |
| * 1.5.0 — Previous version (no changelog on record). | |
| */ | |
| if ( ! defined( 'ABSPATH' ) ) exit; | |
| /* ============================================================= | |
| Constants | |
| ============================================================= */ | |
| define( 'LR_PMI_VERSION', '1.6.0' ); | |
| define( 'LR_PMI_SLUG', 'lr-postmeta-index' ); | |
| /* ============================================================= | |
| Admin menu | |
| ============================================================= */ | |
| add_action( 'admin_menu', 'lr_pmi_register_admin_menu' ); | |
| function lr_pmi_register_admin_menu() { | |
| add_management_page( | |
| 'LR Postmeta Index', | |
| 'LR Postmeta Index', | |
| 'manage_options', | |
| LR_PMI_SLUG, | |
| 'lr_pmi_render_tools_page' | |
| ); | |
| } | |
| /* ============================================================= | |
| Settings page styles | |
| ============================================================= */ | |
| add_action( 'admin_head', function () { | |
| $screen = get_current_screen(); | |
| if ( ! $screen || $screen->id !== 'tools_page_' . LR_PMI_SLUG ) return; | |
| ?> | |
| <style> | |
| #pmi-wrap { max-width: 880px; } | |
| #pmi-wrap h1 .lr-version { font-size: 12px; font-weight: 400; color: #646970; margin-left: 6px; } | |
| #pmi-wrap h2.title { margin-top: 1.5em; } | |
| #pmi-wrap hr { margin: 2em 0; border: none; border-top: 1px solid #c3c4c7; } | |
| #pmi-wrap .lr-status-off { color: #d63638; } | |
| #pmi-wrap .lr-remove { color: #d63638; } | |
| #pmi-wrap pre { padding: 12px; overflow-x: auto; border: 1px solid #c3c4c7; } | |
| #pmi-wrap .lr-perf-list { list-style: disc; margin-left: 2em; } | |
| </style> | |
| <?php | |
| } ); | |
| /* ============================================================= | |
| Tools page | |
| ============================================================= */ | |
| function lr_pmi_render_tools_page() { | |
| if ( ! current_user_can( 'manage_options' ) ) { | |
| wp_die( __( 'Insufficient permissions.', 'lr-postmeta-index' ) ); | |
| } | |
| global $wpdb; | |
| $table = $wpdb->postmeta; | |
| $has_index = lr_pmi_index_exists(); | |
| $size_mb = null; | |
| if ( isset( $_GET['show_stats'] ) ) { | |
| $size_mb = lr_pmi_get_table_size(); | |
| } | |
| $msg = isset( $_GET['lr_pmi_msg'] ) ? sanitize_text_field( $_GET['lr_pmi_msg'] ) : ''; | |
| $err = isset( $_GET['lr_pmi_err'] ) ? sanitize_text_field( $_GET['lr_pmi_err'] ) : ''; | |
| $created_at = get_option( 'lr_pmi_created_at', '' ); | |
| $when = $created_at ? esc_html( sanitize_text_field( $created_at ) ) : ''; | |
| ?> | |
| <div id="pmi-wrap" class="wrap"> | |
| <h1><?php esc_html_e( 'Postmeta Index Manager', 'lr-postmeta-index' ); ?> <span class="lr-version">v<?php echo esc_html( LR_PMI_VERSION ); ?></span></h1> | |
| <?php if ( $msg ): ?> | |
| <div class="notice notice-success is-dismissible"><p><?php echo esc_html( $msg ); ?></p></div> | |
| <?php endif; ?> | |
| <?php if ( $err ): ?> | |
| <div class="notice notice-error"><p><?php echo esc_html( $err ); ?></p></div> | |
| <?php endif; ?> | |
| <?php /* ─── Status ───────────────────────────────── */ ?> | |
| <h2 class="title"><?php esc_html_e( 'Status', 'lr-postmeta-index' ); ?></h2> | |
| <table class="widefat striped"> | |
| <tbody> | |
| <tr> | |
| <th scope="row"><?php esc_html_e( 'Table', 'lr-postmeta-index' ); ?></th> | |
| <td><code><?php echo esc_html( $table ); ?></code></td> | |
| </tr> | |
| <tr> | |
| <th scope="row"><?php esc_html_e( 'Index', 'lr-postmeta-index' ); ?></th> | |
| <td><code>post_id_meta_key</code> <?php esc_html_e( 'on', 'lr-postmeta-index' ); ?> <code>(post_id, meta_key(191))</code></td> | |
| </tr> | |
| <tr> | |
| <th scope="row"><?php esc_html_e( 'Status', 'lr-postmeta-index' ); ?></th> | |
| <td> | |
| <?php if ( $has_index ): ?> | |
| <?php esc_html_e( 'Active', 'lr-postmeta-index' ); ?> | |
| <?php else: ?> | |
| <span class="lr-status-off"><?php esc_html_e( 'Not created', 'lr-postmeta-index' ); ?></span> | |
| <?php endif; ?> | |
| </td> | |
| </tr> | |
| <?php if ( $when ): ?> | |
| <tr> | |
| <th scope="row"><?php esc_html_e( 'Created', 'lr-postmeta-index' ); ?></th> | |
| <td><?php echo $when; ?></td> | |
| </tr> | |
| <?php endif; ?> | |
| <tr> | |
| <th scope="row"><?php esc_html_e( 'Table Size', 'lr-postmeta-index' ); ?></th> | |
| <td> | |
| <?php if ( $size_mb !== null ): ?> | |
| <?php echo esc_html( $size_mb ); ?> MB | |
| <?php elseif ( ! isset( $_GET['show_stats'] ) ): ?> | |
| <a href="<?php echo esc_url( add_query_arg( [ 'page' => LR_PMI_SLUG, 'show_stats' => '1' ], admin_url( 'tools.php' ) ) ); ?>"> | |
| <?php esc_html_e( 'Show statistics', 'lr-postmeta-index' ); ?> | |
| </a> | |
| <span class="description"><?php esc_html_e( '(may be slow on large databases)', 'lr-postmeta-index' ); ?></span> | |
| <?php endif; ?> | |
| </td> | |
| </tr> | |
| </tbody> | |
| </table> | |
| <form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>"> | |
| <?php wp_nonce_field( 'lr_pmi_action', 'lr_pmi_nonce' ); ?> | |
| <input type="hidden" name="action" value="lr_pmi_handle"> | |
| <?php if ( ! $has_index ): ?> | |
| <input type="hidden" name="lr_pmi_do" value="create"> | |
| <p> | |
| <button type="submit" class="button button-primary"> | |
| <?php esc_html_e( 'Create Index Now', 'lr-postmeta-index' ); ?> | |
| </button> | |
| <span class="description"><?php esc_html_e( 'Run during low-traffic periods.', 'lr-postmeta-index' ); ?></span> | |
| </p> | |
| <?php else: ?> | |
| <input type="hidden" name="lr_pmi_do" value="drop"> | |
| <p> | |
| <a href="#" id="pmi_remove" class="lr-remove"><?php esc_html_e( 'Remove Index', 'lr-postmeta-index' ); ?></a> | |
| </p> | |
| <?php endif; ?> | |
| </form> | |
| <hr> | |
| <?php /* ─── What This Does ──────────────────────── */ ?> | |
| <h2 class="title"><?php esc_html_e( 'What This Does', 'lr-postmeta-index' ); ?></h2> | |
| <p><?php esc_html_e( 'Adds a composite index so MySQL can find meta rows quickly, optimizing queries like:', 'lr-postmeta-index' ); ?></p> | |
| <pre><code>SELECT meta_value FROM <?php echo esc_html( $table ); ?> | |
| WHERE post_id = ? AND meta_key = ?;</code></pre> | |
| <p><?php esc_html_e( 'Safe for ACF, Polylang, and most meta-heavy plugins. Read speedup is usually significant on sites with 10,000+ posts. Fully reversible with one click.', 'lr-postmeta-index' ); ?></p> | |
| <?php if ( $has_index ): ?> | |
| <hr> | |
| <h2 class="title"><?php esc_html_e( 'Performance Impact', 'lr-postmeta-index' ); ?></h2> | |
| <p><strong><?php esc_html_e( 'Expected improvements (on sites with 10,000+ posts):', 'lr-postmeta-index' ); ?></strong></p> | |
| <ul class="lr-perf-list"> | |
| <li><?php esc_html_e( 'ACF field lookups: 50–90% faster', 'lr-postmeta-index' ); ?></li> | |
| <li><?php esc_html_e( 'get_post_meta() calls: 40–80% faster', 'lr-postmeta-index' ); ?></li> | |
| <li><?php esc_html_e( 'WP_Query meta queries: 30–70% faster (varies by query complexity)', 'lr-postmeta-index' ); ?></li> | |
| <li><?php esc_html_e( 'Page load times (meta-heavy pages): 10–30% improvement', 'lr-postmeta-index' ); ?></li> | |
| </ul> | |
| <p class="description"> | |
| <strong><?php esc_html_e( 'Note:', 'lr-postmeta-index' ); ?></strong> | |
| <?php esc_html_e( 'Adds approximately 5–10% overhead to INSERT/UPDATE/DELETE operations on postmeta. Actual results vary based on database size, query patterns, and server configuration.', 'lr-postmeta-index' ); ?> | |
| </p> | |
| <?php endif; ?> | |
| </div> | |
| <?php if ( $has_index ): ?> | |
| <script> | |
| (function () { | |
| var ui = { | |
| remove: document.getElementById('pmi_remove'), | |
| init: function () { | |
| if (!this.remove) return; | |
| this.remove.addEventListener('click', function (e) { | |
| e.preventDefault(); | |
| if (!confirm('<?php echo esc_js( __( 'Remove the postmeta index? This will affect query performance until recreated.', 'lr-postmeta-index' ) ); ?>')) return; | |
| ui.remove.closest('form').submit(); | |
| }); | |
| } | |
| }; | |
| ui.init(); | |
| }()); | |
| </script> | |
| <?php endif; ?> | |
| <?php | |
| } | |
| /* ============================================================= | |
| Index detection helpers | |
| ============================================================= */ | |
| /** | |
| * Return the name of the composite (post_id, meta_key) index on the postmeta | |
| * table, regardless of what it was named. Returns null if no such index exists. | |
| * | |
| * Uses SHOW INDEX to avoid information_schema permission issues on managed/ | |
| * restricted hosts. Detects by column composition and sequence, not by name, | |
| * so externally-created indexes with different names are correctly identified. | |
| * | |
| * @return string|null Index name, or null if not found. | |
| */ | |
| function lr_pmi_get_index_name() { | |
| global $wpdb; | |
| $rows = $wpdb->get_results( "SHOW INDEX FROM `{$wpdb->postmeta}`" ); | |
| if ( ! $rows ) return null; | |
| $indexes = []; | |
| foreach ( $rows as $row ) { | |
| $indexes[ $row->Key_name ][ (int) $row->Seq_in_index ] = $row->Column_name; | |
| } | |
| foreach ( $indexes as $name => $cols ) { | |
| ksort( $cols ); | |
| $cols = array_values( $cols ); | |
| if ( | |
| isset( $cols[0], $cols[1] ) && | |
| $cols[0] === 'post_id' && | |
| $cols[1] === 'meta_key' | |
| ) { | |
| return $name; | |
| } | |
| } | |
| return null; | |
| } | |
| /** | |
| * Check if a composite index on (post_id, meta_key) already exists on the | |
| * postmeta table. Delegates detection to lr_pmi_get_index_name() to avoid | |
| * duplicating the SHOW INDEX traversal. Result is cached in a short-lived | |
| * transient. | |
| * | |
| * @param bool $use_cache Whether to use the cached result. | |
| * @return bool True if a matching composite index exists, false otherwise. | |
| */ | |
| function lr_pmi_index_exists( $use_cache = true ) { | |
| $cache_key = 'lr_pmi_index_exists'; | |
| if ( $use_cache ) { | |
| $cached = get_transient( $cache_key ); | |
| if ( $cached !== false ) { | |
| return $cached === 'yes'; | |
| } | |
| } | |
| $found = lr_pmi_get_index_name() !== null; | |
| set_transient( $cache_key, $found ? 'yes' : 'no', 5 * MINUTE_IN_SECONDS ); | |
| return $found; | |
| } | |
| /** | |
| * Get approximate table size in MB. | |
| * | |
| * @return float|null Size in MB, or null on error. | |
| */ | |
| function lr_pmi_get_table_size() { | |
| $cache_key = 'lr_pmi_table_size'; | |
| $cached = get_transient( $cache_key ); | |
| if ( $cached !== false ) { | |
| return $cached; | |
| } | |
| global $wpdb; | |
| $size = $wpdb->get_var( $wpdb->prepare( | |
| "SELECT ROUND(SUM(data_length + index_length) / 1024 / 1024, 2) AS size_mb | |
| FROM information_schema.tables | |
| WHERE table_schema = DATABASE() | |
| AND table_name = %s", | |
| $wpdb->postmeta | |
| ) ); | |
| if ( $size !== null ) { | |
| set_transient( $cache_key, $size, 5 * MINUTE_IN_SECONDS ); | |
| } | |
| return $size; | |
| } | |
| /** | |
| * Clear all plugin caches. | |
| * | |
| * @return void | |
| */ | |
| function lr_pmi_clear_cache() { | |
| delete_transient( 'lr_pmi_index_exists' ); | |
| delete_transient( 'lr_pmi_table_size' ); | |
| } | |
| /* ============================================================= | |
| Action handler — create / drop | |
| ============================================================= */ | |
| add_action( 'admin_post_lr_pmi_handle', 'lr_pmi_handle_action' ); | |
| function lr_pmi_handle_action() { | |
| if ( ! current_user_can( 'manage_options' ) ) { | |
| wp_die( __( 'Insufficient permissions.', 'lr-postmeta-index' ) ); | |
| } | |
| check_admin_referer( 'lr_pmi_action', 'lr_pmi_nonce' ); | |
| $action = isset( $_POST['lr_pmi_do'] ) ? sanitize_key( $_POST['lr_pmi_do'] ) : ''; | |
| $lock_key = 'lr_pmi_lock'; | |
| if ( get_transient( $lock_key ) ) { | |
| lr_pmi_redirect( | |
| __( 'Another operation is in progress. Please try again in a moment.', 'lr-postmeta-index' ), | |
| true | |
| ); | |
| return; | |
| } | |
| // v1.5: extended from 60s — ALTER TABLE on large postmeta tables can take several minutes | |
| set_transient( $lock_key, 1, 5 * MINUTE_IN_SECONDS ); | |
| $has_index = lr_pmi_index_exists( false ); | |
| try { | |
| if ( $action === 'create' && ! $has_index ) { | |
| lr_pmi_create_index( $lock_key ); | |
| } elseif ( $action === 'drop' && $has_index ) { | |
| lr_pmi_drop_index( $lock_key ); | |
| } else { | |
| delete_transient( $lock_key ); | |
| lr_pmi_redirect( __( 'No changes were made.', 'lr-postmeta-index' ) ); | |
| } | |
| } catch ( Exception $e ) { | |
| delete_transient( $lock_key ); | |
| error_log( 'LR PMI Error: ' . $e->getMessage() ); | |
| lr_pmi_redirect( $e->getMessage(), true ); | |
| } | |
| } | |
| /** | |
| * Create the composite index on the postmeta table. | |
| * | |
| * @param string $lock_key Transient lock key. | |
| * @return void | |
| * @throws Exception If index creation fails. | |
| */ | |
| function lr_pmi_create_index( $lock_key ) { | |
| global $wpdb; | |
| $query = "ALTER TABLE `{$wpdb->postmeta}` ADD INDEX post_id_meta_key (post_id, meta_key(191))"; | |
| $result = $wpdb->query( $query ); | |
| if ( $result === false && lr_pmi_is_key_too_long_error( $wpdb->last_error ) ) { | |
| $query = "ALTER TABLE `{$wpdb->postmeta}` ADD INDEX post_id_meta_key (post_id, meta_key(190))"; | |
| $result = $wpdb->query( $query ); | |
| } | |
| delete_transient( $lock_key ); | |
| if ( $result !== false ) { | |
| update_option( 'lr_pmi_created_at', current_time( 'mysql' ), false ); | |
| lr_pmi_clear_cache(); | |
| lr_pmi_redirect( __( 'Postmeta index created successfully.', 'lr-postmeta-index' ) ); | |
| } else { | |
| $error_msg = $wpdb->last_error | |
| ? sprintf( __( 'Database error: %s', 'lr-postmeta-index' ), $wpdb->last_error ) | |
| : __( 'Unknown error occurred while creating index.', 'lr-postmeta-index' ); | |
| throw new Exception( $error_msg ); | |
| } | |
| } | |
| /** | |
| * Drop the composite index from the postmeta table. | |
| * | |
| * Resolves the live index name via lr_pmi_get_index_name() so that indexes | |
| * created externally under a different name are correctly dropped, consistent | |
| * with how lr_pmi_index_exists() detects them by column composition. | |
| * | |
| * @param string $lock_key Transient lock key. | |
| * @return void | |
| * @throws Exception If index removal fails. | |
| */ | |
| function lr_pmi_drop_index( $lock_key ) { | |
| global $wpdb; | |
| $index_name = lr_pmi_get_index_name(); | |
| if ( ! $index_name ) { | |
| delete_transient( $lock_key ); | |
| lr_pmi_redirect( __( 'No matching index found to remove.', 'lr-postmeta-index' ) ); | |
| return; | |
| } | |
| $query = "ALTER TABLE `{$wpdb->postmeta}` DROP INDEX `" . esc_sql( $index_name ) . "`"; | |
| $result = $wpdb->query( $query ); | |
| delete_transient( $lock_key ); | |
| if ( $result !== false ) { | |
| delete_option( 'lr_pmi_created_at' ); | |
| lr_pmi_clear_cache(); | |
| lr_pmi_redirect( __( 'Postmeta index removed successfully.', 'lr-postmeta-index' ) ); | |
| } else { | |
| $error_msg = $wpdb->last_error | |
| ? sprintf( __( 'Database error: %s', 'lr-postmeta-index' ), $wpdb->last_error ) | |
| : __( 'Unknown error occurred while dropping index.', 'lr-postmeta-index' ); | |
| throw new Exception( $error_msg ); | |
| } | |
| } | |
| /** | |
| * Check if a MySQL error indicates a key length issue. | |
| * | |
| * @param string $error MySQL error message. | |
| * @return bool True if error is related to key length. | |
| */ | |
| function lr_pmi_is_key_too_long_error( $error ) { | |
| return stripos( $error, '1071' ) !== false || stripos( $error, 'key was too long' ) !== false; | |
| } | |
| /** | |
| * Redirect back to the Tools page with a message. | |
| * | |
| * @param string $message Message to display. | |
| * @param bool $is_error Whether this is an error message. | |
| * @return void | |
| */ | |
| function lr_pmi_redirect( $message, $is_error = false ) { | |
| $query_arg = $is_error ? 'lr_pmi_err' : 'lr_pmi_msg'; | |
| // v1.5: removed rawurlencode() — add_query_arg() handles encoding internally; | |
| // double-encoding produced garbled notices (%2520 instead of %20) | |
| $url = add_query_arg( | |
| [ | |
| 'page' => LR_PMI_SLUG, | |
| $query_arg => $message, | |
| ], | |
| admin_url( 'tools.php' ) | |
| ); | |
| wp_safe_redirect( $url ); | |
| exit; | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment