77e338f9c3
- dual_write / shadow_read no longer write flat in the update_/add_/
delete_{type}_metadata pre-filters, before WP decides whether native
changes at all. added_/updated_/deleted_{type}_meta (priority 1) call
TMDO_Hook_Bus::sync_from_native(), which copies the entity's first native
row (plain SQL, NULL when gone) into flat. Flat now follows native when WP
skips (same value), refuses (non-matching prev_value, add $unique on an
existing key) or deletes (also delete_all: touched IDs are collected from
the meta IDs in delete_{type}_meta), and a flat value left stale by a
direct SQL write heals on the next write.
- update_metadata_by_mid key changes: the old key is remembered in
update_{type}_metadata_by_mid and re-synced too.
- wpdo_after_write / wpdo_after_delete fire from the sync with the same
arguments; aeav_only keeps the pre-filter short-circuit unchanged.
- $internal_ops lock key includes the entity type (user 5 ≠ post 5).
- Live suite resync: 4 entities × 2 modes × 3 scenarios (old code: 16 of
24 FAIL); integration: pre-filter leaves flat to the sync, delete_all
re-syncs every touched user.
- Verify: live resync + shadow_write + serialized + zero_side_effect 33/33
PASS; unit 687 OK, integration 485 OK, phpcs (phpcs.xml) 0, stan OK
- PR: PR-057
244 lines
7.8 KiB
PHP
244 lines
7.8 KiB
PHP
<?php
|
|
/**
|
|
* Live-suite fixtures: in-process mode overrides, throwaway entities, and
|
|
* direct reads of native meta / flat tables (bypassing every meta filter).
|
|
*
|
|
* Nothing here persists a mode change: modes are overridden through the
|
|
* `pre_option_wpdo_bridge_modes` filter for the current process only.
|
|
*
|
|
* @package WP_Data_Optimizer
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
/**
|
|
* Fixture helpers, reset after every test by TMDO_Live::run().
|
|
*/
|
|
final class TMDO_Live_Fixtures {
|
|
|
|
/**
|
|
* Entities created by the current test, deleted on teardown.
|
|
*
|
|
* @var array<int, array{0:string, 1:int, 2?:string}>
|
|
*/
|
|
private static array $created = array();
|
|
|
|
/**
|
|
* Active pre_option callback, if any.
|
|
*
|
|
* @var callable|null
|
|
*/
|
|
private static $mode_filter = null;
|
|
|
|
/**
|
|
* Override entity modes for this process and make sure the Hook Bus is hooked.
|
|
*
|
|
* @param array<string, string> $modes e.g. array( 'user' => 'shadow_read' ).
|
|
*/
|
|
public static function use_modes( array $modes ): void {
|
|
self::clear_mode_filter();
|
|
|
|
$stored = get_option( 'wpdo_bridge_modes', array() );
|
|
$merged = array_merge( is_array( $stored ) ? $stored : array(), $modes );
|
|
self::$mode_filter = static function () use ( $merged ) {
|
|
return $merged;
|
|
};
|
|
add_filter( 'pre_option_wpdo_bridge_modes', self::$mode_filter );
|
|
self::reset_caches();
|
|
|
|
// The Hook Bus only boots when wpdo_hook_bus_enabled is on; the suite
|
|
// must not depend on (or change) that site option.
|
|
if ( false === has_filter( 'update_user_metadata', array( 'TMDO_Hook_Bus', 'intercept_update' ) ) ) {
|
|
TMDO_Hook_Bus::init();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a throwaway user.
|
|
*/
|
|
public static function user(): int {
|
|
$login = 'tmdo_live_' . wp_generate_password( 6, false, false );
|
|
$id = wp_insert_user(
|
|
array(
|
|
'user_login' => $login,
|
|
'user_email' => $login . '@example.test',
|
|
'user_pass' => wp_generate_password(),
|
|
)
|
|
);
|
|
if ( is_wp_error( $id ) ) {
|
|
throw new TMDO_Live_Failure( 'fixture user: ' . $id->get_error_message() );
|
|
}
|
|
self::$created[] = array( 'user', (int) $id );
|
|
return (int) $id;
|
|
}
|
|
|
|
/**
|
|
* Create a throwaway term.
|
|
*
|
|
* @param string $taxonomy Taxonomy.
|
|
*/
|
|
public static function term( string $taxonomy ): int {
|
|
if ( ! taxonomy_exists( $taxonomy ) ) {
|
|
throw new TMDO_Live_Skip( "taxonomy {$taxonomy} not registered on this site" );
|
|
}
|
|
$t = wp_insert_term( 'tmdo-live-' . wp_generate_password( 6, false, false ), $taxonomy );
|
|
if ( is_wp_error( $t ) ) {
|
|
throw new TMDO_Live_Failure( 'fixture term: ' . $t->get_error_message() );
|
|
}
|
|
self::$created[] = array( 'term', (int) $t['term_id'], $taxonomy );
|
|
return (int) $t['term_id'];
|
|
}
|
|
|
|
/**
|
|
* Create a throwaway post.
|
|
*
|
|
* @param string $post_type Post type.
|
|
*/
|
|
public static function post( string $post_type ): int {
|
|
if ( ! post_type_exists( $post_type ) ) {
|
|
throw new TMDO_Live_Skip( "post type {$post_type} not registered on this site" );
|
|
}
|
|
$id = wp_insert_post(
|
|
array(
|
|
'post_type' => $post_type,
|
|
'post_title' => 'tmdo-live-' . wp_generate_password( 6, false, false ),
|
|
'post_status' => 'draft',
|
|
),
|
|
true
|
|
);
|
|
if ( is_wp_error( $id ) ) {
|
|
throw new TMDO_Live_Failure( 'fixture post: ' . $id->get_error_message() );
|
|
}
|
|
self::$created[] = array( 'post', (int) $id );
|
|
return (int) $id;
|
|
}
|
|
|
|
/**
|
|
* Create a throwaway comment (on a throwaway post of $post_type).
|
|
*
|
|
* @param string $post_type Post type of the parent post.
|
|
* @param string $comment_type Comment type.
|
|
*/
|
|
public static function comment( string $post_type, string $comment_type = 'comment' ): int {
|
|
$post_id = self::post( $post_type );
|
|
$id = wp_insert_comment(
|
|
array(
|
|
'comment_post_ID' => $post_id,
|
|
'comment_content' => 'tmdo-live',
|
|
'comment_type' => $comment_type,
|
|
'comment_approved' => 1,
|
|
)
|
|
);
|
|
if ( ! $id ) {
|
|
throw new TMDO_Live_Failure( 'fixture comment could not be created' );
|
|
}
|
|
self::$created[] = array( 'comment', (int) $id );
|
|
return (int) $id;
|
|
}
|
|
|
|
/**
|
|
* Native meta values for a key, read with SQL (no filters, no cache).
|
|
*
|
|
* @param string $type Entity type (post|user|term|comment).
|
|
* @param int $id Entity ID.
|
|
* @param string $key Meta key.
|
|
* @return string[]
|
|
*/
|
|
public static function native( string $type, int $id, string $key ): array {
|
|
global $wpdb;
|
|
$table = _get_meta_table( $type );
|
|
$id_col = $type . '_id';
|
|
$pk = 'user' === $type ? 'umeta_id' : 'meta_id';
|
|
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table/column names are WP internals.
|
|
return $wpdb->get_col( $wpdb->prepare( "SELECT meta_value FROM {$table} WHERE {$id_col} = %d AND meta_key = %s ORDER BY {$pk}", $id, $key ) );
|
|
}
|
|
|
|
/**
|
|
* Raw flat-table value for a key; 'NO-ROW' when the entity has no row.
|
|
*
|
|
* @param string $type Entity type.
|
|
* @param int $id Entity ID.
|
|
* @param string $key Meta key.
|
|
* @param string $post_type For posts: the post type whose group owns the key.
|
|
* @return string|null
|
|
*/
|
|
public static function flat( string $type, int $id, string $key, string $post_type = '' ) {
|
|
global $wpdb;
|
|
$field = self::field( $type, $key, $post_type );
|
|
$table = TMDO_Schema_Manager::get_table_name( $type, $field['group'] );
|
|
$col = TMDO_Schema_Manager::sanitize_column_name( $key );
|
|
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table/column come from the registry.
|
|
$row = $wpdb->get_row( $wpdb->prepare( "SELECT `{$col}` AS v FROM `{$table}` WHERE {$type}_id = %d", $id ), ARRAY_A );
|
|
return null === $row ? 'NO-ROW' : $row['v'];
|
|
}
|
|
|
|
/**
|
|
* Registry field for a key, or skip the test when the site does not register it.
|
|
*
|
|
* A key can live in several post groups (hp_rating in listing and vendor
|
|
* groups); pass the post type to pick the group scoped to it.
|
|
*
|
|
* @param string $type Entity type.
|
|
* @param string $key Meta key.
|
|
* @param string $post_type For posts: the post type whose group owns the key.
|
|
*/
|
|
public static function field( string $type, string $key, string $post_type = '' ): array {
|
|
$field = TMDO_Entity_Registry::get_field( $type, $key );
|
|
if ( '' !== $post_type ) {
|
|
foreach ( TMDO_Entity_Registry::get_groups_for_type( $type ) as $group ) {
|
|
if ( in_array( $post_type, TMDO_Entity_Registry::get_group_post_types( $type, $group ), true )
|
|
&& in_array( $key, TMDO_Entity_Registry::get_group_keys( $type, $group ), true ) ) {
|
|
$field = array( 'group' => $group ) + (array) $field;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if ( ! $field ) {
|
|
throw new TMDO_Live_Skip( "{$type} key {$key} is not registered on this site" );
|
|
}
|
|
if ( ! TMDO_Schema_Manager::table_exists( TMDO_Schema_Manager::get_table_name( $type, $field['group'] ) ) ) {
|
|
throw new TMDO_Live_Skip( "flat table for {$type}/{$field['group']} does not exist" );
|
|
}
|
|
return $field;
|
|
}
|
|
|
|
/**
|
|
* Delete fixtures and drop the mode override.
|
|
*/
|
|
public static function teardown(): void {
|
|
require_once ABSPATH . 'wp-admin/includes/user.php';
|
|
foreach ( array_reverse( self::$created ) as $c ) {
|
|
if ( 'user' === $c[0] ) {
|
|
wp_delete_user( $c[1] );
|
|
} elseif ( 'term' === $c[0] ) {
|
|
wp_delete_term( $c[1], $c[2] );
|
|
} elseif ( 'post' === $c[0] ) {
|
|
wp_delete_post( $c[1], true );
|
|
} elseif ( 'comment' === $c[0] ) {
|
|
wp_delete_comment( $c[1], true );
|
|
}
|
|
}
|
|
self::$created = array();
|
|
self::clear_mode_filter();
|
|
self::reset_caches();
|
|
}
|
|
|
|
/**
|
|
* Remove the pre_option override, if any.
|
|
*/
|
|
private static function clear_mode_filter(): void {
|
|
if ( null !== self::$mode_filter ) {
|
|
remove_filter( 'pre_option_wpdo_bridge_modes', self::$mode_filter );
|
|
self::$mode_filter = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reset request-level caches that memoise modes or routing decisions.
|
|
*/
|
|
private static function reset_caches(): void {
|
|
TMDO_Mode_Manager::reset_cache();
|
|
TMDO_Routing_Predicate::flush_cache();
|
|
}
|
|
}
|