Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 38 additions & 11 deletions aaa-option-optimizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,19 @@
register_deactivation_hook( __FILE__, 'aaa_option_optimizer_deactivation' );

/**
* Activation hooked function to store start stats.
* Activation hooked function to store start stats and create table.
*
* @return void
*/
function aaa_option_optimizer_activation() {
global $wpdb;

// Create the custom table.
Emilia\OptionOptimizer\Database::create_table();
Comment thread
ilicfilip marked this conversation as resolved.
Outdated

// Migrate existing data if present.
Emilia\OptionOptimizer\Database::maybe_migrate();

$autoload_values = \wp_autoload_values_to_autoload();
$placeholders = implode( ',', array_fill( 0, count( $autoload_values ), '%s' ) );

Expand All @@ -42,16 +49,19 @@ function aaa_option_optimizer_activation() {
);
// phpcs:enable WordPress.DB

update_option(
'option_optimizer',
[
'starting_point_kb' => ( $result->autoload_size / 1024 ),
'starting_point_num' => $result->count,
'starting_point_date' => current_time( 'mysql' ),
'used_options' => [],
],
false
);
// Only set starting point if not already set (preserve existing data).
$existing = get_option( 'option_optimizer' );
if ( empty( $existing['starting_point_date'] ) ) {
update_option(
'option_optimizer',
[
'starting_point_kb' => ( $result->autoload_size / 1024 ),
'starting_point_num' => $result->count,
'starting_point_date' => current_time( 'mysql' ),
],
false
);
}
}

/**
Expand All @@ -64,6 +74,23 @@ function aaa_option_optimizer_deactivation() {
update_option( 'option_optimizer', $aaa_option_value, false );
}

/**
* Ensure database table exists and migrate data if needed.
* Runs on plugins_loaded to handle existing installs that don't trigger activation.
*
* @return void
*/
function aaa_option_optimizer_maybe_upgrade() {
// Check if table exists, create if not.
if ( ! Emilia\OptionOptimizer\Database::table_exists() ) {
Emilia\OptionOptimizer\Database::create_table();
}

// Migrate existing data if present.
Emilia\OptionOptimizer\Database::maybe_migrate();
}
add_action( 'plugins_loaded', 'aaa_option_optimizer_maybe_upgrade' );

/**
* Initializes the plugin.
*
Expand Down
200 changes: 200 additions & 0 deletions src/class-database.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
<?php
/**
* Database functionality for AAA Option Optimizer.
*
* @package Emilia\OptionOptimizer
*/

namespace Emilia\OptionOptimizer;

/**
* Handles custom database table for tracking options.
*/
class Database {

/**
* The database table name (without prefix).
*
* @var string
*/
const TABLE_NAME = 'option_optimizer_tracked';

/**
* Get the full table name with prefix.
*
* @return string
*/
public static function get_table_name() {
global $wpdb;
return $wpdb->prefix . self::TABLE_NAME;
}

/**
* Create the custom table.
*
* @return void
*/
public static function create_table() {
global $wpdb;

$table_name = self::get_table_name();
$charset_collate = $wpdb->get_charset_collate();

$sql = "CREATE TABLE {$table_name} (
option_name VARCHAR(191) NOT NULL,
access_count BIGINT UNSIGNED DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (option_name)
) {$charset_collate};";

require_once ABSPATH . 'wp-admin/includes/upgrade.php';
\dbDelta( $sql );
}

/**
* Drop the custom table.
*
* @return void
*/
public static function drop_table() {
global $wpdb;

$table_name = self::get_table_name();

// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe (from constant).
$wpdb->query( "DROP TABLE IF EXISTS {$table_name}" );
}

/**
* Check if the table exists.
*
* @return bool
*/
public static function table_exists() {
global $wpdb;

$table_name = self::get_table_name();

// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
return $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table_name ) ) === $table_name;
}

/**
* Migrate data from the old option format to the custom table.
*
* @return void
*/
public static function maybe_migrate() {
$option_data = \get_option( 'option_optimizer' );

// No data or already migrated (no used_options key).
if ( ! \is_array( $option_data ) || ! isset( $option_data['used_options'] ) ) {
return;
}

// Ensure table exists.
if ( ! self::table_exists() ) {
self::create_table();
}

// Batch insert old data to custom table.
if ( ! empty( $option_data['used_options'] ) ) {
self::batch_insert( $option_data['used_options'] );
}

// Remove used_options from the option, keep metadata.
unset( $option_data['used_options'] );
\update_option( 'option_optimizer', $option_data, false );
}

/**
* Batch insert or update option counts.
*
* @param array<string, int> $options Array of option_name => count.
*
* @return void
*/
public static function batch_insert( $options ) {
global $wpdb;

if ( empty( $options ) ) {
return;
}

$table_name = self::get_table_name();
$values = [];
$placeholders = [];

foreach ( $options as $option_name => $count ) {
$placeholders[] = '(%s, %d, NOW())';
$values[] = $option_name;
$values[] = (int) $count;
}

$sql = "INSERT INTO {$table_name} (option_name, access_count, created_at)
VALUES " . implode( ', ', $placeholders ) . '
ON DUPLICATE KEY UPDATE access_count = access_count + VALUES(access_count)';

// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
Comment thread
ilicfilip marked this conversation as resolved.
Outdated
$wpdb->query( $wpdb->prepare( $sql, ...$values ) );
}

/**
* Get all tracked options as an associative array.
*
* @return array<string, int> Array of option_name => access_count.
*/
public static function get_tracked_options() {
global $wpdb;

$table_name = self::get_table_name();

// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe (from constant).
$results = $wpdb->get_results( "SELECT option_name, access_count FROM {$table_name}", ARRAY_A );

if ( empty( $results ) ) {
return [];
}

$options = [];
foreach ( $results as $row ) {
$options[ $row['option_name'] ] = (int) $row['access_count'];
}

return $options;
}

/**
* Get tracked option names as a keyed array for efficient lookups.
*
* @return array<string, bool> Array of option_name => true.
*/
public static function get_tracked_option_keys() {
global $wpdb;

$table_name = self::get_table_name();

// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe (from constant).
$option_names = $wpdb->get_col( "SELECT option_name FROM {$table_name}" );

if ( empty( $option_names ) ) {
return [];
}

return array_fill_keys( $option_names, true );
}

/**
* Clear all tracked options from the table.
*
* @return void
*/
public static function clear_tracked_options() {
global $wpdb;

$table_name = self::get_table_name();

// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe (from constant).
$wpdb->query( "TRUNCATE TABLE {$table_name}" );
}
}
72 changes: 62 additions & 10 deletions src/class-plugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,6 @@ public static function get_instance() {
* @return void
*/
public function register_hooks() {
$this->accessed_options = \get_option( 'option_optimizer', [ 'used_options' => [] ] )['used_options'];

// Hook into all actions and filters to monitor option accesses.
// @phpstan-ignore-next-line -- The 'all' hook does not need a return.
\add_filter( 'all', [ $this, 'monitor_option_accesses' ] );
Expand Down Expand Up @@ -123,7 +121,10 @@ protected function add_option_usage( $option_name ) {
}

/**
* Update the 'option_optimizer' option with the list of used options at the end of the page load.
* Update the tracked options at the end of the page load.
*
* Uses transient batching to reduce database writes - only flushes to the custom table
* every 5 minutes instead of on every request.
*
* @return void
*/
Expand All @@ -132,16 +133,67 @@ public function update_tracked_options() {
if ( isset( $_GET['page'] ) && $_GET['page'] === 'aaa-option-optimizer' ) {
return;
}
// Retrieve the existing option_optimizer data.
$option_optimizer = get_option( 'option_optimizer', [ 'used_options' => [] ] );

$option_optimizer['used_options'] = $this->accessed_options;

// Handle reset: clear batch and custom table.
if ( $this->should_reset ) {
$option_optimizer['used_options'] = [];
\delete_transient( 'option_optimizer_batch' );
Database::clear_tracked_options();
return;
}

// Get the batch data.
$batch_data = $this->get_batch_data();

// Add current request's options to the batch.
foreach ( $this->accessed_options as $option_name => $count ) {
if ( ! isset( $batch_data['options'][ $option_name ] ) ) {
$batch_data['options'][ $option_name ] = 0;
}
$batch_data['options'][ $option_name ] += $count;
}

// Check if it's time to flush the batch.
$should_flush = ( \time() - $batch_data['last_flush'] ) >= $this->get_flush_interval();

// Flush batch to custom table every 5 minutes.
if ( ! empty( $batch_data['options'] ) && $should_flush ) {
Database::batch_insert( $batch_data['options'] );

// Reset the batch data.
$batch_data = [
'options' => [],
'last_flush' => \time(),
];
}

// Update the 'option_optimizer' option with the new list.
update_option( 'option_optimizer', $option_optimizer, false );
// No expiry - batch is explicitly deleted on flush, expiry would only cause data loss.
\set_transient( 'option_optimizer_batch', $batch_data, 0 );
}

/**
* Get the batch data.
*
* @return array<string, int>
*/
protected function get_batch_data() {
// Get existing batch (stores both data and flush timestamp in one transient).
$batch_data = \get_transient( 'option_optimizer_batch' );
if ( ! \is_array( $batch_data ) || ! isset( $batch_data['options'], $batch_data['last_flush'] ) ) {
$batch_data = [
'options' => [],
'last_flush' => \time(),
];
}

return $batch_data;
}

/**
* Get the flush interval.
*
* @return int
*/
protected function get_flush_interval() {
return (int) \apply_filters( 'aaa_option_optimizer_flush_interval', 5 * MINUTE_IN_SECONDS );
}
}
Loading