Files
wpdev 59f3b19ce6 fix(zone-cold): JSON_MERGE_PATCH 原子寫入 + JSON_REMOVE 原子刪除(B2)
set()/set_many() 原本 get_blob_raw()→array_merge→save_blob() 三步,並發
寫入會互相覆蓋整個 blob;remove() 同樣是 read-modify-write。改為:
- 新增 private save_patch():INSERT ... ON DUPLICATE KEY UPDATE
  data = JSON_MERGE_PATCH(COALESCE(data,'{}'), patch)
- remove():UPDATE ... SET data = JSON_REMOVE(COALESCE(data,'{}'), '$.key')
- save_blob() 改用 TMDO_DB::upsert()
- SQLite 保留 read-modify-write fallback
- ZoneColdTest stub 補 JSON_REMOVE / JSON_MERGE_PATCH / upsert 三種 SQL 模擬

對應 A v3.3.3 P1-21。unit 379 / integration 398 GREEN

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
2026-07-31 05:34:27 +08:00

269 lines
10 KiB
PHP

<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Tests for WPDO_Zone_Cold — JSON blob storage with Object Cache integration.
*/
class ZoneColdTest extends TestCase {
/** In-memory "database" store: post_id => json string */
public static array $db_store = [];
/** Control whether get_var returns the "id" existence check */
public static bool $row_exists = false;
protected function setUp(): void {
self::$db_store = [];
self::$row_exists = false;
$GLOBALS['_wp_cache'] = [];
$this->setup_wpdb_mock();
}
private function setup_wpdb_mock(): void {
global $wpdb;
$wpdb = new class {
public string $prefix = 'wp_';
public function prepare( string $sql, ...$args ): string {
$i = 0;
return preg_replace_callback( '/%([sd])/', function ( $m ) use ( &$i, $args ) {
$val = $args[ $i++ ] ?? '';
return $m[1] === 'd' ? (string) (int) $val : "'" . addslashes( (string) $val ) . "'";
}, $sql );
}
/**
* get_var is used for two things:
* 1. SELECT data ... → return JSON blob
* 2. SELECT id ... → return '1' if exists, else null
*/
public function get_var( string $sql ): ?string {
$flat = preg_replace( '/\s+/', ' ', $sql );
// Existence check (save_blob path).
if ( stripos( $flat, 'SELECT id' ) !== false ) {
if ( preg_match( "/post_id = (\d+)/", $flat, $m ) ) {
return isset( ZoneColdTest::$db_store[ (int) $m[1] ] ) ? '1' : null;
}
return null;
}
// Data fetch.
if ( preg_match( "/post_id = (\d+)/", $flat, $m ) ) {
return ZoneColdTest::$db_store[ (int) $m[1] ] ?? null;
}
return null;
}
public function get_row( string $sql, $output = OBJECT ) {
return null;
}
public function get_results( string $sql, $output = OBJECT ): array {
return [];
}
public function insert( string $table, array $data, $format = null ): int|false {
if ( isset( $data['post_id'], $data['data'] ) ) {
ZoneColdTest::$db_store[ (int) $data['post_id'] ] = $data['data'];
}
return 1;
}
public function update( string $table, array $data, array $where, $format = null, $where_format = null ): int|false {
if ( isset( $where['post_id'], $data['data'] ) ) {
ZoneColdTest::$db_store[ (int) $where['post_id'] ] = $data['data'];
}
return 1;
}
public function delete( string $table, array $where, $format = null ): int|false {
if ( isset( $where['post_id'] ) ) {
unset( ZoneColdTest::$db_store[ (int) $where['post_id'] ] );
}
return 1;
}
public function query( string $sql ): int|bool {
$flat = preg_replace( '/\s+/', ' ', $sql );
// JSON_REMOVE UPDATE — from TMDO_Zone_Cold::remove (MySQL path).
// SQL: UPDATE `{table}` SET data = JSON_REMOVE(..., '$.key'), ... WHERE post_id = N
if ( stripos( $flat, 'JSON_REMOVE' ) !== false ) {
if ( preg_match( "/'\\\$\\.([^']+)'/", $flat, $km )
&& preg_match( '/WHERE post_id = (\d+)/i', $flat, $wm ) ) {
$post_id = (int) $wm[1];
$key = $km[1];
$data = json_decode( ZoneColdTest::$db_store[ $post_id ] ?? '{}', true );
$data = is_array( $data ) ? $data : array();
unset( $data[ $key ] );
ZoneColdTest::$db_store[ $post_id ] = (string) wp_json_encode( $data );
}
return 1;
}
// INSERT...ON DUPLICATE KEY with JSON_MERGE_PATCH — from save_patch (MySQL path).
// Merges the patch into the existing blob, preserving untouched keys.
if ( stripos( $flat, 'ON DUPLICATE KEY' ) !== false && stripos( $flat, 'JSON_MERGE_PATCH' ) !== false ) {
if ( preg_match( "/VALUES \((\d+), '((?:[^'\\\\]|\\\\.)*)'/i", $flat, $m ) ) {
$post_id = (int) $m[1];
$patch = json_decode( stripslashes( $m[2] ), true );
$patch = is_array( $patch ) ? $patch : array();
$existing = json_decode( ZoneColdTest::$db_store[ $post_id ] ?? '{}', true );
$existing = is_array( $existing ) ? $existing : array();
ZoneColdTest::$db_store[ $post_id ] = (string) wp_json_encode( array_merge( $existing, $patch ) );
}
return 1;
}
// INSERT...ON DUPLICATE KEY UPDATE (from TMDO_DB::upsert via save_blob).
// Pattern: VALUES (post_id, 'json_data', 'datetime') ON DUPLICATE KEY UPDATE col = VALUES(col)
if ( stripos( $flat, 'ON DUPLICATE KEY' ) !== false ) {
if ( preg_match( "/VALUES \((\d+), '((?:[^'\\\\]|\\\\.)*)'/i", $flat, $m ) ) {
ZoneColdTest::$db_store[ (int) $m[1] ] = stripslashes( $m[2] );
}
}
return 1;
}
};
}
// ── table() ──────────────────────────────────────────────────────────────
public function test_table_returns_prefixed_name(): void {
$this->assertSame( 'wp_wpdo_cold_hp_listing', WPDO_Zone_Cold::table( 'hp_listing' ) );
}
// ── get() ─────────────────────────────────────────────────────────────────
public function test_get_returns_null_for_missing_key(): void {
// Cache miss + no DB row → blob is empty array → key missing → null.
$result = WPDO_Zone_Cold::get( 99, 'hp_listing', 'hp_description' );
$this->assertNull( $result );
}
public function test_get_reads_from_cache_on_hit(): void {
// Pre-populate cache so DB should NOT be hit.
$group = 'wpdo_cold_hp_listing';
$cache_key = 'cold_1';
$GLOBALS['_wp_cache'][ $group ][ $cache_key ] = [ 'hp_bio' => 'cached value' ];
$result = WPDO_Zone_Cold::get( 1, 'hp_listing', 'hp_bio' );
$this->assertSame( 'cached value', $result );
// DB store should remain empty (DB was not queried for data).
$this->assertEmpty( self::$db_store );
}
// ── get_blob() ───────────────────────────────────────────────────────────
public function test_get_blob_queries_db_on_cache_miss(): void {
self::$db_store[5] = json_encode( [ 'hp_description' => 'Hello World', 'hp_location' => 'Paris' ] );
$blob = WPDO_Zone_Cold::get_blob( 5, 'hp_listing' );
$this->assertSame( 'Hello World', $blob['hp_description'] );
$this->assertSame( 'Paris', $blob['hp_location'] );
}
// ── set() ─────────────────────────────────────────────────────────────────
public function test_set_merges_new_key_into_blob(): void {
// Seed an existing blob.
self::$db_store[10] = json_encode( [ 'a' => 1 ] );
WPDO_Zone_Cold::set( 10, 'hp_listing', 'b', 2 );
$stored = json_decode( self::$db_store[10], true );
$this->assertArrayHasKey( 'a', $stored );
$this->assertArrayHasKey( 'b', $stored );
$this->assertSame( 1, $stored['a'] );
$this->assertSame( 2, $stored['b'] );
}
// ── set_many() ───────────────────────────────────────────────────────────
public function test_set_many_merges_multiple_keys(): void {
WPDO_Zone_Cold::set_many( 20, 'hp_listing', [
'key1' => 'v1',
'key2' => 'v2',
'key3' => 'v3',
] );
$stored = json_decode( self::$db_store[20], true );
$this->assertSame( 'v1', $stored['key1'] );
$this->assertSame( 'v2', $stored['key2'] );
$this->assertSame( 'v3', $stored['key3'] );
}
// ── remove() ─────────────────────────────────────────────────────────────
public function test_remove_deletes_key_from_blob(): void {
self::$db_store[30] = json_encode( [ 'keep' => 'yes', 'drop' => 'no' ] );
WPDO_Zone_Cold::remove( 30, 'hp_listing', 'drop' );
$stored = json_decode( self::$db_store[30], true );
$this->assertArrayHasKey( 'keep', $stored );
$this->assertArrayNotHasKey( 'drop', $stored );
}
// ── delete() ─────────────────────────────────────────────────────────────
public function test_delete_clears_cache(): void {
$group = 'wpdo_cold_hp_listing';
$cache_key = 'cold_1';
// Pre-populate cache.
$GLOBALS['_wp_cache'][ $group ][ $cache_key ] = [ 'some' => 'data' ];
WPDO_Zone_Cold::delete( 1, 'hp_listing' );
// Cache entry must be gone.
$this->assertFalse( isset( $GLOBALS['_wp_cache'][ $group ][ $cache_key ] ) );
}
// ── Additional edge-case tests ────────────────────────────────────────────
public function test_set_invalidates_object_cache(): void {
$group = 'wpdo_cold_hp_listing';
$cache_key = 'cold_50';
// Pre-populate cache with stale data.
$GLOBALS['_wp_cache'][ $group ][ $cache_key ] = [ 'stale' => 'old_value' ];
WPDO_Zone_Cold::set( 50, 'hp_listing', 'fresh', 'new_value' );
// Cache must be invalidated after write.
$this->assertFalse( isset( $GLOBALS['_wp_cache'][ $group ][ $cache_key ] ) );
}
public function test_get_blob_populates_cache_on_db_hit(): void {
// Seed the DB store so get_blob has something to fetch.
self::$db_store[60] = json_encode( [ 'cached_key' => 'cached_val' ] );
WPDO_Zone_Cold::get_blob( 60, 'hp_listing' );
// Cache must now contain the fetched data.
$group = 'wpdo_cold_hp_listing';
$cached = $GLOBALS['_wp_cache'][ $group ]['cold_60'] ?? false;
$this->assertIsArray( $cached );
$this->assertSame( 'cached_val', $cached['cached_key'] );
}
public function test_remove_nonexistent_key_is_safe(): void {
// Store an existing blob.
self::$db_store[70] = json_encode( [ 'keep' => 'this' ] );
// Remove a key that doesn't exist — should not throw.
WPDO_Zone_Cold::remove( 70, 'hp_listing', 'nonexistent_key' );
$stored = json_decode( self::$db_store[70], true );
$this->assertArrayHasKey( 'keep', $stored );
$this->assertArrayNotHasKey( 'nonexistent_key', $stored );
}
}