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 : ''; } }