robotstxt-mlp2-mlp5/includes/class-robotstxt-mlp2-mlp5-logger.php
2026-03-02 11:22:59 +00:00

290 lines
8.7 KiB
PHP

<?php
/**
* Migration log manager.
*
* Handles creation, insertion, retrieval, truncation, and CSV export
* of the migration log table.
*
* @package Robotstxt_Mlp2_Mlp5
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Class Robotstxt_Mlp2_Mlp5_Logger
*
* All queries in this class are intentionally direct — the log table is
* write-once per operation and must never be read from an object cache.
* Per-line DirectDatabaseQuery suppressions are applied throughout.
*/
class Robotstxt_Mlp2_Mlp5_Logger {
/**
* Sitemeta key used to track the installed DB schema version.
*
* @var string
*/
const DB_VERSION_KEY = 'mlp_migrator_db_version';
/**
* Return the fully qualified log table name.
*
* @return string
*/
public static function table_name(): string {
global $wpdb;
return $wpdb->base_prefix . 'mlp_migrator_log';
}
/**
* Create (or update) the log table via dbDelta().
*
* Safe to call multiple times — dbDelta() is idempotent and only alters
* the table when the schema has changed. The DB upgrader is responsible
* for calling this method and for updating the stored schema version.
*/
public static function create_table(): void {
global $wpdb;
$table = self::table_name();
$charset_collate = $wpdb->get_charset_collate();
// Note: dbDelta() requires two spaces before PRIMARY KEY.
$sql = "CREATE TABLE {$table} (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
created_at datetime NOT NULL,
type varchar(20) NOT NULL,
context varchar(50) NOT NULL,
source_id bigint(20) unsigned DEFAULT NULL,
target_id bigint(20) unsigned DEFAULT NULL,
message text NOT NULL,
metadata longtext DEFAULT NULL,
PRIMARY KEY (id),
KEY type (type),
KEY context (context)
) {$charset_collate};";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta( $sql );
}
/**
* Drop the log table and remove the schema version option.
*
* Only called from uninstall.php when the user has explicitly opted in
* to data deletion via the plugin settings.
*/
public static function drop_table(): void {
global $wpdb;
$table = self::table_name();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$wpdb->query( "DROP TABLE IF EXISTS {$table}" );
delete_site_option( self::DB_VERSION_KEY );
}
/**
* Insert a log entry.
*
* @param string $type Entry type: 'success', 'skipped', or 'error'.
* @param string $context Operation context: 'site_relation', 'post_relation', 'term_relation', or 'cleanup'.
* @param int|null $source_id Source content or site ID, or null if not applicable.
* @param int|null $target_id Target content or site ID, or null if not applicable.
* @param string $message Human-readable description of the operation result.
* @param array $metadata Optional additional structured data (stored as JSON).
*
* @return int Inserted row ID, or 0 on failure.
*/
public static function log(
string $type,
string $context,
?int $source_id,
?int $target_id,
string $message,
array $metadata = array()
): int {
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
$inserted = $wpdb->insert(
self::table_name(),
array(
'created_at' => current_time( 'mysql', true ),
'type' => $type,
'context' => $context,
'source_id' => $source_id,
'target_id' => $target_id,
'message' => $message,
'metadata' => empty( $metadata ) ? null : wp_json_encode( $metadata ),
),
array( '%s', '%s', '%s', '%d', '%d', '%s', '%s' )
);
return $inserted ? (int) $wpdb->insert_id : 0;
}
/**
* Retrieve log entries.
*
* @param array $args {
* Optional. Query arguments.
*
* @type string $type Filter by type: 'success', 'skipped', or 'error'.
* @type string $context Filter by context: 'site_relation', 'post_relation', 'term_relation', or 'cleanup'.
* @type int $limit Maximum number of rows to return. Default 50.
* @type int $offset Row offset for pagination. Default 0.
* @type string $order Sort direction: 'ASC' or 'DESC'. Default 'DESC'.
* }
*
* @return array Array of row objects, or an empty array.
*/
public static function get_entries( array $args = array() ): array {
global $wpdb;
$args = wp_parse_args(
$args,
array(
'type' => '',
'context' => '',
'limit' => 50,
'offset' => 0,
'order' => 'DESC',
)
);
$table = self::table_name();
$order = 'ASC' === strtoupper( (string) $args['order'] ) ? 'ASC' : 'DESC';
$where = '';
$values = array();
if ( ! empty( $args['type'] ) ) {
$where .= ' AND type = %s';
$values[] = $args['type'];
}
if ( ! empty( $args['context'] ) ) {
$where .= ' AND context = %s';
$values[] = $args['context'];
}
$values[] = (int) $args['limit'];
$values[] = (int) $args['offset'];
// Table name and ORDER BY direction are hardcoded — not from user input.
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
return (array) $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$table} WHERE 1=1{$where} ORDER BY id {$order} LIMIT %d OFFSET %d",
$values
)
);
// phpcs:enable
}
/**
* Count log entries.
*
* Accepts the same filter arguments as get_entries() except limit, offset, and order.
*
* @param array $args Optional. 'type' and/or 'context' filter keys.
*
* @return int Total count of matching entries.
*/
public static function count( array $args = array() ): int {
global $wpdb;
$table = self::table_name();
$where = '';
$values = array();
if ( ! empty( $args['type'] ) ) {
$where .= ' AND type = %s';
$values[] = $args['type'];
}
if ( ! empty( $args['context'] ) ) {
$where .= ' AND context = %s';
$values[] = $args['context'];
}
if ( empty( $values ) ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" );
}
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
return (int) $wpdb->get_var(
$wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE 1=1{$where}", $values )
);
// phpcs:enable
}
/**
* Truncate the log table.
*
* Removes all rows without dropping the table structure. Used when
* restarting a migration from scratch.
*/
public static function clear(): void {
global $wpdb;
$table = self::table_name();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$wpdb->query( "TRUNCATE TABLE {$table}" );
}
/**
* Export the full log as a CSV string.
*
* Columns: id, created_at, type, context, source_id, target_id, message, metadata.
* Rows are ordered by id ASC.
*
* @return string CSV content with a header row, or an empty string on failure.
*/
public static function export_csv(): string {
global $wpdb;
$table = self::table_name();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$rows = $wpdb->get_results( "SELECT * FROM {$table} ORDER BY id ASC", ARRAY_A );
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
$buffer = fopen( 'php://temp', 'r+' );
if ( false === $buffer ) {
return '';
}
fputcsv( $buffer, array( 'id', 'created_at', 'type', 'context', 'source_id', 'target_id', 'message', 'metadata' ) );
if ( ! empty( $rows ) ) {
foreach ( $rows as $row ) {
fputcsv(
$buffer,
array(
$row['id'],
$row['created_at'],
$row['type'],
$row['context'],
$row['source_id'] ?? '',
$row['target_id'] ?? '',
$row['message'],
$row['metadata'] ?? '',
)
);
}
}
rewind( $buffer );
$csv = stream_get_contents( $buffer );
fclose( $buffer ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
return is_string( $csv ) ? $csv : '';
}
}