diff --git a/2meet-data-optimizer.php b/2meet-data-optimizer.php index 07aca6d..0707023 100644 --- a/2meet-data-optimizer.php +++ b/2meet-data-optimizer.php @@ -136,6 +136,8 @@ require_once TMDO_PATH . 'includes/interceptors/class-tmdo-interceptor-base.php' require_once TMDO_PATH . 'includes/interceptors/class-tmdo-sync-bridge.php'; // ── Zone handlers ───────────────────────────────────────────────────────── +require_once TMDO_PATH . 'includes/zones/class-tmdo-zone-router.php'; +require_once TMDO_PATH . 'includes/class-tmdo-routing-predicate.php'; require_once TMDO_PATH . 'includes/zones/class-tmdo-zone-hot.php'; require_once TMDO_PATH . 'includes/zones/class-tmdo-zone-warm.php'; require_once TMDO_PATH . 'includes/zones/class-tmdo-zone-cold.php'; diff --git a/includes/class-tmdo-back-compat.php b/includes/class-tmdo-back-compat.php index a2353ac..17f2b3d 100644 --- a/includes/class-tmdo-back-compat.php +++ b/includes/class-tmdo-back-compat.php @@ -111,6 +111,8 @@ $tmdo_class_aliases = array( 'TMDO_Zone_Hot' => 'WPDO_Zone_Hot', 'TMDO_Zone_Warm' => 'WPDO_Zone_Warm', 'TMDO_Zone_Cold' => 'WPDO_Zone_Cold', + 'TMDO_Zone_Router' => 'WPDO_Zone_Router', + 'TMDO_Routing_Predicate' => 'WPDO_Routing_Predicate', 'TMDO_Zone_Archive' => 'WPDO_Zone_Archive', 'TMDO_Interceptor_Base' => 'WPDO_Interceptor_Base', 'TMDO_Query_Interceptor_Base' => 'WPDO_Query_Interceptor_Base', diff --git a/includes/class-tmdo-rest-api.php b/includes/class-tmdo-rest-api.php index 08c6803..416bc94 100644 --- a/includes/class-tmdo-rest-api.php +++ b/includes/class-tmdo-rest-api.php @@ -537,7 +537,7 @@ class TMDO_REST_API { $order = strtoupper( (string) ( $request->get_param( 'order' ) ?? 'DESC' ) ); $offset = ( $page - 1 ) * $per_page; - $module = 'hot_' . sanitize_key( $post_type ); + $module = TMDO_Zone_Router::module_name( 'hot', $post_type ); if ( TMDO_Feature_Flags::is_read_custom( $module ) ) { return $this->listings_from_zone_a( $post_type, $per_page, $offset, $orderby, $order, $request ); @@ -571,7 +571,7 @@ class TMDO_REST_API { ); // Zone A. - $hot_module = 'hot_' . sanitize_key( $post_type ); + $hot_module = TMDO_Zone_Router::module_name( 'hot', $post_type ); if ( TMDO_Feature_Flags::is_read_custom( $hot_module ) ) { $hot = TMDO_Zone_Hot::get_row( $post_id, $post_type ); if ( $hot ) { @@ -585,7 +585,7 @@ class TMDO_REST_API { } // Zone C. - $cold_module = 'cold_' . sanitize_key( $post_type ); + $cold_module = TMDO_Zone_Router::module_name( 'cold', $post_type ); if ( TMDO_Feature_Flags::is_read_custom( $cold_module ) ) { $cold = TMDO_Zone_Cold::get_blob( $post_id, $post_type ); $data = array_merge( $data, $cold ); diff --git a/includes/class-tmdo-routing-predicate.php b/includes/class-tmdo-routing-predicate.php new file mode 100644 index 0000000..fb45dbe --- /dev/null +++ b/includes/class-tmdo-routing-predicate.php @@ -0,0 +1,110 @@ + + */ + private static array $entity_bridge_cache = array(); + + /** + * Returns true when the Entity Bridge (Hook Bus) owns writes for $meta_key on + * the 'post' entity type — Zone Sync_Bridge must not also write to a Zone table. + * + * Replaces the former TMDO_Sync_Bridge::is_owned_by_entity_bridge(). + * + * @param string $meta_key Meta key being written. + * @return bool + */ + public static function entity_bridge_owns( string $meta_key ): bool { + if ( isset( self::$entity_bridge_cache[ $meta_key ] ) ) { + return self::$entity_bridge_cache[ $meta_key ]; + } + + $result = false; + if ( class_exists( 'TMDO_Mode_Manager' ) + && class_exists( 'TMDO_Entity_Registry' ) + && TMDO_Mode_Manager::writes_to_flat( 'post' ) + && null !== TMDO_Entity_Registry::get_field( 'post', $meta_key ) ) { + $result = true; + } + + self::$entity_bridge_cache[ $meta_key ] = $result; + return $result; + } + + /** + * Returns true when Zone dual-write is active for the given post_type + zone. + * + * Replaces the repeated inline pattern: + * $module = TMDO_Zone_Router::module_name( $zone, $post_type ); + * TMDO_Feature_Flags::is_write_active( $module ); + * + * @param string $post_type Post type slug. + * @param string $zone Zone identifier: 'hot', 'cold', 'warm', 'archive'. + * @return bool + */ + public static function should_write_to_zone( string $post_type, string $zone ): bool { + return TMDO_Feature_Flags::is_write_active( + TMDO_Zone_Router::module_name( $zone, $post_type ) + ); + } + + /** + * Returns true when Zone read-from-custom is active for the given post_type + zone. + * + * @param string $post_type Post type slug. + * @param string $zone Zone identifier. + * @return bool + */ + public static function should_read_from_zone( string $post_type, string $zone ): bool { + return TMDO_Feature_Flags::is_read_custom( + TMDO_Zone_Router::module_name( $zone, $post_type ) + ); + } + + /** + * Returns true when Zone query-rewrite is active for the given post_type + zone. + * + * @param string $post_type Post type slug. + * @param string $zone Zone identifier. + * @return bool + */ + public static function should_query_from_zone( string $post_type, string $zone ): bool { + return TMDO_Feature_Flags::is_query_active( + TMDO_Zone_Router::module_name( $zone, $post_type ) + ); + } + + /** + * Flush the request-level entity bridge cache. + * Call this in test setUp to isolate test cases. + */ + public static function flush_cache(): void { + self::$entity_bridge_cache = array(); + } +} diff --git a/includes/interceptors/class-tmdo-sync-bridge.php b/includes/interceptors/class-tmdo-sync-bridge.php index 0e5d722..49c8adb 100644 --- a/includes/interceptors/class-tmdo-sync-bridge.php +++ b/includes/interceptors/class-tmdo-sync-bridge.php @@ -2,7 +2,7 @@ /** * Zone-aware dual-write dispatcher for WordPress metadata API. * - * @package WP_Data_Optimizer + * @package TMDO */ if ( ! defined( 'ABSPATH' ) ) { @@ -40,6 +40,11 @@ class TMDO_Sync_Bridge { /** * Register all metadata hooks. + * + * Write hooks (update/add/delete) are always registered because Sync Bridge + * handles zone-registered keys that may not be tracked by the Entity Bridge. + * The per-call is_owned_by_entity_bridge() guard (cached) prevents double-writes + * for keys that the Hook Bus owns when post mode is dual_write or higher. */ public function register_hooks(): void { add_filter( 'get_post_metadata', array( $this, 'intercept_get' ), 10, 5 ); @@ -75,17 +80,16 @@ class TMDO_Sync_Bridge { } // P1-24: defer to Hook Bus for reads when post entity is dual_write or higher. - if ( self::is_owned_by_entity_bridge( $meta_key ) ) { + if ( TMDO_Routing_Predicate::entity_bridge_owns( $meta_key ) ) { return $value; } - $module = $this->get_zone_module( $field['zone'], $post_type ); - if ( ! TMDO_Feature_Flags::is_read_custom( $module ) ) { + if ( ! TMDO_Routing_Predicate::should_read_from_zone( $post_type, $field['zone'] ) ) { return $value; } try { - $zone_value = $this->read_from_zone( $field, $post_id, $post_type, $meta_key ); + $zone_value = TMDO_Zone_Router::read( $field, $post_id, $post_type, $meta_key ); if ( null === $zone_value ) { return $value; @@ -96,7 +100,7 @@ class TMDO_Sync_Bridge { return array( $zone_value ); } catch ( \Throwable $e ) { - TMDO_Logger::error( $module, 'get_post_metadata', $e->getMessage() ); + TMDO_Logger::error( TMDO_Zone_Router::module_name( $field['zone'], $post_type ), 'get_post_metadata', $e->getMessage() ); return $value; } } @@ -121,11 +125,9 @@ class TMDO_Sync_Bridge { return $check; } - // v2.9.2 Entity Bridge guard: when post mode is dual_write or higher - // AND the key is registered in the new Entity Registry, the unified - // Hook Bus is the source of truth — Sync_Bridge must not also write - // to the legacy zone table to avoid duplicate flat writes. - if ( self::is_owned_by_entity_bridge( $meta_key ) ) { + // v2.9.2 Entity Bridge guard — see ADR-001. Hook Bus owns this key when post + // mode is dual_write or higher AND the key is in Entity Registry. + if ( TMDO_Routing_Predicate::entity_bridge_owns( $meta_key ) ) { return $check; } @@ -134,16 +136,15 @@ class TMDO_Sync_Bridge { return $check; } - $module = $this->get_zone_module( $field['zone'], $post_type ); - if ( ! TMDO_Feature_Flags::is_write_active( $module ) ) { + if ( ! TMDO_Routing_Predicate::should_write_to_zone( $post_type, $field['zone'] ) ) { return $check; } // Write to zone table (non-fatal on failure). try { - $this->write_to_zone( $field, $post_id, $post_type, $meta_key, $meta_value ); + TMDO_Zone_Router::write( $field, $post_id, $post_type, $meta_key, $meta_value ); } catch ( \Throwable $e ) { - TMDO_Logger::error( $module, 'update_post_metadata', $e->getMessage() ); + TMDO_Logger::error( TMDO_Zone_Router::module_name( $field['zone'], $post_type ), 'update_post_metadata', $e->getMessage() ); } // Return null — let WordPress proceed with native postmeta write. @@ -152,34 +153,6 @@ class TMDO_Sync_Bridge { return $check; } - /** - * Whether the given post meta_key is now owned by the Entity Bridge, - * meaning Sync_Bridge should defer to the unified Hook Bus and skip its - * zone write to avoid duplicate flat writes. - * - * Returns true only when ALL of: - * - TMDO_Mode_Manager and TMDO_Entity_Registry classes exist - * - post mode is dual_write or higher (writes_to_flat returns true) - * - the key is registered for entity_type=post - * - * Default post mode is `disabled`, so this returns false in all - * environments that have not opted in to Entity Bridge — keeping the - * legacy Sync_Bridge → zone path unchanged. - * - * @param string $meta_key Meta key being written. - * @return bool - * @since 2.9.2 - */ - private static function is_owned_by_entity_bridge( string $meta_key ): bool { - if ( ! class_exists( 'TMDO_Mode_Manager' ) || ! class_exists( 'TMDO_Entity_Registry' ) ) { - return false; - } - if ( ! TMDO_Mode_Manager::writes_to_flat( 'post' ) ) { - return false; - } - return null !== TMDO_Entity_Registry::get_field( 'post', $meta_key ); - } - /** * Intercept add_post_meta — dual-write to zone table. * @@ -200,8 +173,8 @@ class TMDO_Sync_Bridge { return $check; } - // v2.9.2 Entity Bridge guard — see is_owned_by_entity_bridge() docblock. - if ( self::is_owned_by_entity_bridge( $meta_key ) ) { + // Entity Bridge guard — see ADR-001. + if ( TMDO_Routing_Predicate::entity_bridge_owns( $meta_key ) ) { return $check; } @@ -210,15 +183,14 @@ class TMDO_Sync_Bridge { return $check; } - $module = $this->get_zone_module( $field['zone'], $post_type ); - if ( ! TMDO_Feature_Flags::is_write_active( $module ) ) { + if ( ! TMDO_Routing_Predicate::should_write_to_zone( $post_type, $field['zone'] ) ) { return $check; } try { - $this->write_to_zone( $field, $post_id, $post_type, $meta_key, $meta_value ); + TMDO_Zone_Router::write( $field, $post_id, $post_type, $meta_key, $meta_value ); } catch ( \Throwable $e ) { - TMDO_Logger::error( $module, 'add_post_metadata', $e->getMessage() ); + TMDO_Logger::error( TMDO_Zone_Router::module_name( $field['zone'], $post_type ), 'add_post_metadata', $e->getMessage() ); } return $check; @@ -251,19 +223,18 @@ class TMDO_Sync_Bridge { } // P1-24: skip zone cleanup when Hook Bus owns this key (aeav_only blocks native delete). - if ( self::is_owned_by_entity_bridge( $meta_key ) ) { + if ( TMDO_Routing_Predicate::entity_bridge_owns( $meta_key ) ) { return; } - $module = $this->get_zone_module( $field['zone'], $post_type ); - if ( ! TMDO_Feature_Flags::is_write_active( $module ) ) { + if ( ! TMDO_Routing_Predicate::should_write_to_zone( $post_type, $field['zone'] ) ) { return; } try { - $this->delete_from_zone( $field, $post_id, $post_type, $meta_key ); + TMDO_Zone_Router::delete_field( $field, $post_id, $post_type, $meta_key ); } catch ( \Throwable $e ) { - TMDO_Logger::error( $module, 'deleted_post_meta', $e->getMessage() ); + TMDO_Logger::error( TMDO_Zone_Router::module_name( $field['zone'], $post_type ), 'deleted_post_meta', $e->getMessage() ); } } @@ -278,44 +249,9 @@ class TMDO_Sync_Bridge { if ( ! $post_type ) { return; } - - $registry = TMDO_Schema_Registry::instance(); - - // Clean Hot zone. - if ( ! empty( $registry->get_hot_columns( $post_type ) ) ) { - try { - TMDO_Zone_Hot::delete( $post_id, $post_type ); - } catch ( \Throwable $e ) { - TMDO_Logger::error( 'hot_' . $post_type, 'before_delete_post', $e->getMessage() ); - } - } - - // Clean Cold zone. - if ( ! empty( $registry->get_cold_meta_keys( $post_type ) ) ) { - try { - TMDO_Zone_Cold::delete( $post_id, $post_type ); - } catch ( \Throwable $e ) { - TMDO_Logger::error( 'cold_' . $post_type, 'before_delete_post', $e->getMessage() ); - } - } - - // Clean Warm zone. - try { - TMDO_Zone_Warm::delete_all( $post_id ); - } catch ( \Throwable $e ) { - TMDO_Logger::error( 'warm', 'before_delete_post', $e->getMessage() ); - } - - // Clean Archive zone. - try { - TMDO_Zone_Archive::delete( $post_id ); - } catch ( \Throwable $e ) { - TMDO_Logger::error( 'archive', 'before_delete_post', $e->getMessage() ); - } + TMDO_Zone_Router::delete_post( $post_id, $post_type ); } - // ── Private helpers ─────────────────────────────────────────────────── - /** * Get a registered field with a request-level static cache. * @@ -330,81 +266,4 @@ class TMDO_Sync_Bridge { } return self::$field_cache[ $cache_key ]; } - - /** - * Derive module name from zone + post_type for feature flag lookups. - * - * @param string $zone Zone identifier (hot, cold, warm, archive). - * @param string $post_type Post type. - * @return string Module name. - */ - private function get_zone_module( string $zone, string $post_type ): string { - return match ( $zone ) { - 'hot' => 'hot_' . sanitize_key( $post_type ), - 'cold' => 'cold_' . sanitize_key( $post_type ), - 'warm' => 'warm', - 'archive' => 'archive', - default => 'unknown', - }; - } - - /** - * Read a value from the appropriate zone. - * - * @param array $field Field definition from Schema Registry. - * @param int $post_id Post ID. - * @param string $post_type Post type. - * @param string $meta_key Meta key. - * @return mixed Value from zone or null. - */ - private function read_from_zone( array $field, int $post_id, string $post_type, string $meta_key ): mixed { - return match ( $field['zone'] ) { - 'hot' => TMDO_Zone_Hot::get( $post_id, $post_type, $field['column'] ), - 'cold' => TMDO_Zone_Cold::get( $post_id, $post_type, $meta_key ), - 'warm' => TMDO_Zone_Warm::get( $post_id, $meta_key ), - default => null, - }; - } - - /** - * Write a value to the appropriate zone. - * - * @param array $field Field definition from Schema Registry. - * @param int $post_id Post ID. - * @param string $post_type Post type. - * @param string $meta_key Meta key. - * @param mixed $value Value to write. - * @return void - */ - private function write_to_zone( array $field, int $post_id, string $post_type, string $meta_key, mixed $value ): void { - match ( $field['zone'] ) { - 'hot' => TMDO_Zone_Hot::set( $post_id, $post_type, $field['column'], $value ), - 'cold' => TMDO_Zone_Cold::set( $post_id, $post_type, $meta_key, $value ), - 'warm' => TMDO_Zone_Warm::set( - $post_id, - $meta_key, - is_string( $value ) ? $value : wp_json_encode( $value ), - $field['ttl'] ?? null - ), - default => null, - }; - } - - /** - * Delete a value from the appropriate zone. - * - * @param array $field Field definition from Schema Registry. - * @param int $post_id Post ID. - * @param string $post_type Post type. - * @param string $meta_key Meta key. - * @return void - */ - private function delete_from_zone( array $field, int $post_id, string $post_type, string $meta_key ): void { - match ( $field['zone'] ) { - 'hot' => TMDO_Zone_Hot::set( $post_id, $post_type, $field['column'], null ), - 'cold' => TMDO_Zone_Cold::remove( $post_id, $post_type, $meta_key ), - 'warm' => TMDO_Zone_Warm::delete( $post_id, $meta_key ), - default => null, - }; - } } diff --git a/includes/migration/class-tmdo-cold-migration.php b/includes/migration/class-tmdo-cold-migration.php index 7512ae6..cdcddcb 100644 --- a/includes/migration/class-tmdo-cold-migration.php +++ b/includes/migration/class-tmdo-cold-migration.php @@ -39,7 +39,7 @@ class TMDO_Cold_Migration extends TMDO_Migration_Base { * @return string Module name. */ public function get_module(): string { - return 'cold_' . sanitize_key( $this->post_type ); + return TMDO_Zone_Router::module_name( 'cold', $this->post_type ); } /** diff --git a/includes/migration/class-tmdo-hot-migration.php b/includes/migration/class-tmdo-hot-migration.php index dc4ec0e..168cf20 100644 --- a/includes/migration/class-tmdo-hot-migration.php +++ b/includes/migration/class-tmdo-hot-migration.php @@ -39,7 +39,7 @@ class TMDO_Hot_Migration extends TMDO_Migration_Base { * @return string Module name. */ public function get_module(): string { - return 'hot_' . sanitize_key( $this->post_type ); + return TMDO_Zone_Router::module_name( 'hot', $this->post_type ); } /** diff --git a/includes/query/class-tmdo-query-router.php b/includes/query/class-tmdo-query-router.php index e775e00..666f901 100644 --- a/includes/query/class-tmdo-query-router.php +++ b/includes/query/class-tmdo-query-router.php @@ -2,7 +2,7 @@ /** * Zone A Query Router for flat-column hot table rewrites. * - * @package WP_Data_Optimizer + * @package TMDO */ if ( ! defined( 'ABSPATH' ) ) { @@ -47,6 +47,53 @@ class TMDO_Query_Router { /** * Extract meta_query clauses that can be served by hot tables. * + * Separated from the hook callback so it can be called and asserted directly + * in unit tests without triggering a full WP_Query lifecycle. + * + * @param array $raw_meta_query Raw meta_query array from WP_Query. + * @param string[] $post_types Post types being queried. + * @return array{hot_clauses: array>, remaining: array} Extracted and leftover clauses. + */ + public static function extract_hot_clauses( array $raw_meta_query, array $post_types ): array { + $registry = TMDO_Schema_Registry::instance(); + $hot_clauses = array(); + $remaining = array(); + + foreach ( $raw_meta_query as $k => $clause ) { + if ( 'relation' === $k || ! is_array( $clause ) || ! isset( $clause['key'] ) ) { + $remaining[ $k ] = $clause; + continue; + } + + $matched = false; + foreach ( $post_types as $pt ) { + $field = $registry->get_field( $pt, $clause['key'] ); + if ( $field && 'hot' === $field['zone'] && TMDO_Routing_Predicate::should_query_from_zone( $pt, 'hot' ) ) { + $hot_clauses[ $pt ][] = array( + 'column' => $field['column'], + 'value' => $clause['value'] ?? '', + 'compare' => strtoupper( trim( $clause['compare'] ?? '=' ) ), + 'type' => strtoupper( trim( $clause['type'] ?? 'CHAR' ) ), + ); + $matched = true; + break; + } + } + + if ( ! $matched ) { + $remaining[ $k ] = $clause; + } + } + + return array( + 'hot_clauses' => $hot_clauses, + 'remaining' => $remaining, + ); + } + + /** + * Detect hot-table clauses in meta_query and stash them for later hooks. + * * @param \WP_Query $query The WP_Query object. * @return void */ @@ -65,53 +112,19 @@ class TMDO_Query_Router { return; } - $registry = TMDO_Schema_Registry::instance(); - $hot_clauses = array(); // Keyed by post_type. - $remaining = array(); + $result = self::extract_hot_clauses( $raw_meta_query, $post_types ); - foreach ( $raw_meta_query as $k => $clause ) { - if ( 'relation' === $k || ! is_array( $clause ) || ! isset( $clause['key'] ) ) { - $remaining[ $k ] = $clause; - continue; - } - - $matched = false; - foreach ( $post_types as $pt ) { - $field = $registry->get_field( $pt, $clause['key'] ); - if ( $field && 'hot' === $field['zone'] ) { - // Check module is query-active. - $module = 'hot_' . sanitize_key( $pt ); - if ( ! TMDO_Feature_Flags::is_query_active( $module ) ) { - continue; - } - - $hot_clauses[ $pt ][] = array( - 'column' => $field['column'], - 'value' => $clause['value'] ?? '', - 'compare' => strtoupper( trim( $clause['compare'] ?? '=' ) ), - 'type' => strtoupper( trim( $clause['type'] ?? 'CHAR' ) ), - ); - $matched = true; - break; - } - } - - if ( ! $matched ) { - $remaining[ $k ] = $clause; - } - } - - if ( empty( $hot_clauses ) ) { + if ( empty( $result['hot_clauses'] ) ) { return; } - // Preserve relation if there are remaining clauses. + $remaining = $result['remaining']; if ( isset( $raw_meta_query['relation'] ) && ! isset( $remaining['relation'] ) ) { $remaining['relation'] = $raw_meta_query['relation']; } $query->set( 'meta_query', $remaining ); - $query->set( self::QUERY_VAR, $hot_clauses ); + $query->set( self::QUERY_VAR, $result['hot_clauses'] ); } /** diff --git a/includes/zones/class-tmdo-zone-router.php b/includes/zones/class-tmdo-zone-router.php new file mode 100644 index 0000000..e778b5b --- /dev/null +++ b/includes/zones/class-tmdo-zone-router.php @@ -0,0 +1,142 @@ + 'hot_' . sanitize_key( $post_type ), + 'cold' => 'cold_' . sanitize_key( $post_type ), + 'warm' => 'warm', + 'archive' => 'archive', + default => 'unknown', + }; + } + + /** + * Read a field value from the appropriate zone. + * + * @param array $field Field definition from Schema Registry. + * @param int $post_id Post ID. + * @param string $post_type Post type. + * @param string $meta_key Meta key. + * @return mixed Value from zone, or null if not found / zone not supported. + */ + public static function read( array $field, int $post_id, string $post_type, string $meta_key ): mixed { + return match ( $field['zone'] ) { + 'hot' => TMDO_Zone_Hot::get( $post_id, $post_type, $field['column'] ), + 'cold' => TMDO_Zone_Cold::get( $post_id, $post_type, $meta_key ), + 'warm' => TMDO_Zone_Warm::get( $post_id, $meta_key ), + default => null, + }; + } + + /** + * Write a field value to the appropriate zone. + * + * @param array $field Field definition from Schema Registry. + * @param int $post_id Post ID. + * @param string $post_type Post type. + * @param string $meta_key Meta key. + * @param mixed $value Value to write. + * @return void + */ + public static function write( array $field, int $post_id, string $post_type, string $meta_key, mixed $value ): void { + match ( $field['zone'] ) { + 'hot' => TMDO_Zone_Hot::set( $post_id, $post_type, $field['column'], $value ), + 'cold' => TMDO_Zone_Cold::set( $post_id, $post_type, $meta_key, $value ), + 'warm' => TMDO_Zone_Warm::set( + $post_id, + $meta_key, + is_string( $value ) ? $value : wp_json_encode( $value ), + $field['ttl'] ?? null + ), + default => null, + }; + } + + /** + * Delete a single field value from the appropriate zone. + * + * @param array $field Field definition from Schema Registry. + * @param int $post_id Post ID. + * @param string $post_type Post type. + * @param string $meta_key Meta key. + * @return void + */ + public static function delete_field( array $field, int $post_id, string $post_type, string $meta_key ): void { + match ( $field['zone'] ) { + 'hot' => TMDO_Zone_Hot::set( $post_id, $post_type, $field['column'], null ), + 'cold' => TMDO_Zone_Cold::remove( $post_id, $post_type, $meta_key ), + 'warm' => TMDO_Zone_Warm::delete( $post_id, $meta_key ), + default => null, + }; + } + + /** + * Delete all Zone data for a post being permanently removed. + * + * Callers only need post_id and post_type; zone enumeration and per-zone + * error isolation are handled here. Hot and Cold are skipped when the + * post_type has no registered fields in that zone (table may not exist). + * + * @param int $post_id Post ID being deleted. + * @param string $post_type Post type slug. + * @return void + */ + public static function delete_post( int $post_id, string $post_type ): void { + $registry = TMDO_Schema_Registry::instance(); + + if ( ! empty( $registry->get_hot_columns( $post_type ) ) ) { + try { + TMDO_Zone_Hot::delete( $post_id, $post_type ); + } catch ( \Throwable $e ) { + TMDO_Logger::error( 'hot_' . $post_type, 'delete_post', $e->getMessage() ); + } + } + + if ( ! empty( $registry->get_cold_meta_keys( $post_type ) ) ) { + try { + TMDO_Zone_Cold::delete( $post_id, $post_type ); + } catch ( \Throwable $e ) { + TMDO_Logger::error( 'cold_' . $post_type, 'delete_post', $e->getMessage() ); + } + } + + try { + TMDO_Zone_Warm::delete_all( $post_id ); + } catch ( \Throwable $e ) { + TMDO_Logger::error( 'warm', 'delete_post', $e->getMessage() ); + } + + try { + TMDO_Zone_Archive::delete( $post_id ); + } catch ( \Throwable $e ) { + TMDO_Logger::error( 'archive', 'delete_post', $e->getMessage() ); + } + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index b4b47db..f2810e4 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -508,6 +508,8 @@ require_once TMDO_PATH . 'includes/interceptors/class-tmdo-interceptor-base.php' require_once TMDO_PATH . 'includes/interceptors/class-tmdo-sync-bridge.php'; // Zones. +require_once TMDO_PATH . 'includes/zones/class-tmdo-zone-router.php'; +require_once TMDO_PATH . 'includes/class-tmdo-routing-predicate.php'; require_once TMDO_PATH . 'includes/zones/class-tmdo-zone-hot.php'; require_once TMDO_PATH . 'includes/zones/class-tmdo-zone-warm.php'; require_once TMDO_PATH . 'includes/zones/class-tmdo-zone-cold.php'; diff --git a/tests/integration/SyncBridgeEntityGuardTest.php b/tests/integration/SyncBridgeEntityGuardTest.php index c0d6b2d..573fea4 100644 --- a/tests/integration/SyncBridgeEntityGuardTest.php +++ b/tests/integration/SyncBridgeEntityGuardTest.php @@ -114,6 +114,9 @@ class SyncBridgeEntityGuardTest extends TestCase { $wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' ); $wpdb->query( 'DROP TABLE IF EXISTS `wp_itest_wpdo_errors`' ); + // Prevent this class's entity_bridge_cache entries leaking into later classes. + TMDO_Routing_Predicate::flush_cache(); + // Reset Mode_Manager cache to prevent post=dual_write leaking into // later tests that share the same PHP process (e.g. SyncBridgeIntegrationTest // which uses 'hp_price' as a generic test field — that key is in the post @@ -132,6 +135,10 @@ class SyncBridgeEntityGuardTest extends TestCase { global $wpdb; $wpdb->query( 'TRUNCATE TABLE `' . self::TABLE . '`' ); + // Routing_Predicate memoises entity_bridge_owns() per request — clear it so + // each case sees the mode/registry state it just set up. + TMDO_Routing_Predicate::flush_cache(); + $GLOBALS['_wp_options'] = array(); WPDO_Feature_Flags::set( self::MODULE, 'dual_write' ); diff --git a/tests/integration/bootstrap.php b/tests/integration/bootstrap.php index 6828c3b..d3b4d72 100644 --- a/tests/integration/bootstrap.php +++ b/tests/integration/bootstrap.php @@ -522,6 +522,8 @@ require_once TMDO_PATH . 'includes/class-tmdo-conflict-monitor.php'; require_once TMDO_PATH . 'includes/class-tmdo-compatibility.php'; require_once TMDO_PATH . 'includes/interceptors/class-tmdo-interceptor-base.php'; require_once TMDO_PATH . 'includes/interceptors/class-tmdo-sync-bridge.php'; +require_once TMDO_PATH . 'includes/zones/class-tmdo-zone-router.php'; +require_once TMDO_PATH . 'includes/class-tmdo-routing-predicate.php'; require_once TMDO_PATH . 'includes/zones/class-tmdo-zone-hot.php'; require_once TMDO_PATH . 'includes/zones/class-tmdo-zone-warm.php'; require_once TMDO_PATH . 'includes/zones/class-tmdo-zone-cold.php'; diff --git a/tests/unit/RoutingPredicateTest.php b/tests/unit/RoutingPredicateTest.php new file mode 100644 index 0000000..8e9c1aa --- /dev/null +++ b/tests/unit/RoutingPredicateTest.php @@ -0,0 +1,85 @@ +getProperty( 'cache' )->setValue( null, null ); + + // Reset Schema Registry singleton. + $sr = new ReflectionClass( WPDO_Schema_Registry::class ); + $sr->getProperty( 'instance' )->setValue( null, null ); + + $GLOBALS['_wp_options'] = array(); + } + + // ── entity_bridge_owns ──────────────────────────────────────────────────── + + public function test_entity_bridge_owns_returns_false_when_mode_manager_absent(): void { + // WPDO_Mode_Manager is not defined in unit test bootstrap → returns false. + $this->assertFalse( WPDO_Routing_Predicate::entity_bridge_owns( 'hp_price' ) ); + } + + public function test_entity_bridge_owns_caches_result(): void { + $first = WPDO_Routing_Predicate::entity_bridge_owns( 'hp_featured' ); + $second = WPDO_Routing_Predicate::entity_bridge_owns( 'hp_featured' ); + $this->assertSame( $first, $second ); + } + + public function test_flush_cache_clears_entity_bridge_cache(): void { + WPDO_Routing_Predicate::entity_bridge_owns( 'some_key' ); + WPDO_Routing_Predicate::flush_cache(); + + $ref = new ReflectionClass( WPDO_Routing_Predicate::class ); + $cache = $ref->getProperty( 'entity_bridge_cache' )->getValue( null ); + $this->assertEmpty( $cache ); + } + + // ── should_write_to_zone ────────────────────────────────────────────────── + + public function test_should_write_to_zone_returns_false_when_module_idle(): void { + $this->assertFalse( WPDO_Routing_Predicate::should_write_to_zone( 'hp_listing', 'hot' ) ); + } + + public function test_should_write_to_zone_returns_true_when_dual_write(): void { + WPDO_Feature_Flags::set( 'hot_hp_listing', 'dual_write' ); + $this->assertTrue( WPDO_Routing_Predicate::should_write_to_zone( 'hp_listing', 'hot' ) ); + } + + public function test_should_write_to_zone_returns_false_when_only_cold_active(): void { + WPDO_Feature_Flags::set( 'cold_hp_listing', 'dual_write' ); + $this->assertFalse( WPDO_Routing_Predicate::should_write_to_zone( 'hp_listing', 'hot' ) ); + } + + // ── should_read_from_zone ───────────────────────────────────────────────── + + public function test_should_read_from_zone_returns_false_when_not_cutover(): void { + WPDO_Feature_Flags::set( 'hot_hp_listing', 'dual_write' ); + $this->assertFalse( WPDO_Routing_Predicate::should_read_from_zone( 'hp_listing', 'hot' ) ); + } + + public function test_should_read_from_zone_returns_true_when_cutover(): void { + WPDO_Feature_Flags::set( 'hot_hp_listing', 'cutover' ); + $this->assertTrue( WPDO_Routing_Predicate::should_read_from_zone( 'hp_listing', 'hot' ) ); + } + + // ── should_query_from_zone ──────────────────────────────────────────────── + + public function test_should_query_from_zone_returns_false_when_idle(): void { + $this->assertFalse( WPDO_Routing_Predicate::should_query_from_zone( 'hp_listing', 'hot' ) ); + } + + public function test_should_query_from_zone_returns_true_when_cutover(): void { + WPDO_Feature_Flags::set( 'hot_hp_listing', 'cutover' ); + $this->assertTrue( WPDO_Routing_Predicate::should_query_from_zone( 'hp_listing', 'hot' ) ); + } +} diff --git a/tests/unit/ZoneRouterTest.php b/tests/unit/ZoneRouterTest.php new file mode 100644 index 0000000..5d8e9e8 --- /dev/null +++ b/tests/unit/ZoneRouterTest.php @@ -0,0 +1,250 @@ +query(). */ + public static string $last_query = ''; + + /** Last $wpdb->delete() call args: [table, where, formats]. */ + public static array $last_delete = []; + + /** Stub return for $wpdb->get_var(). */ + public static mixed $get_var_return = null; + + protected function setUp(): void { + self::$last_query = ''; + self::$last_delete = []; + self::$get_var_return = null; + $this->setup_wpdb_mock(); + } + + private function setup_wpdb_mock(): void { + global $wpdb; + $wpdb = new class { + public string $prefix = 'wp_'; + public function prepare( string $sql, mixed ...$args ): string { + $i = 0; + return preg_replace_callback( '/%([sd])/', static function ( $m ) use ( &$i, $args ) { + $val = $args[ $i++ ] ?? ''; + return $m[1] === 'd' ? (string) (int) $val : "'" . addslashes( (string) $val ) . "'"; + }, $sql ); + } + public function query( string $sql ): int { + ZoneRouterTest::$last_query = $sql; + return 1; + } + public function get_var( string $sql ): mixed { + ZoneRouterTest::$last_query = $sql; + return ZoneRouterTest::$get_var_return; + } + public function insert( string $table, array $data, mixed $formats = null ): int { + ZoneRouterTest::$last_query = "INSERT INTO `{$table}`"; + return 1; + } + public function delete( string $table, array $where, array $formats ): void { + ZoneRouterTest::$last_delete = [ $table, $where, $formats ]; + } + public function get_row( string $sql, string $output = OBJECT ): mixed { + ZoneRouterTest::$last_query = $sql; + return null; + } + }; + } + + // ── module_name ─────────────────────────────────────────────────────────── + + public function test_module_name_hot_prepends_zone(): void { + $this->assertSame( 'hot_hp_listing', WPDO_Zone_Router::module_name( 'hot', 'hp_listing' ) ); + } + + public function test_module_name_cold_prepends_zone(): void { + $this->assertSame( 'cold_hp_listing', WPDO_Zone_Router::module_name( 'cold', 'hp_listing' ) ); + } + + public function test_module_name_warm_ignores_post_type(): void { + $this->assertSame( 'warm', WPDO_Zone_Router::module_name( 'warm', 'hp_listing' ) ); + } + + public function test_module_name_archive_ignores_post_type(): void { + $this->assertSame( 'archive', WPDO_Zone_Router::module_name( 'archive', 'hp_listing' ) ); + } + + public function test_module_name_unknown_zone_returns_unknown(): void { + $this->assertSame( 'unknown', WPDO_Zone_Router::module_name( 'other', 'hp_listing' ) ); + } + + public function test_module_name_sanitizes_post_type(): void { + // sanitize_key strips spaces and lowercases; 'HP Listing' → 'hplisting'. + $this->assertSame( 'hot_hplisting', WPDO_Zone_Router::module_name( 'hot', 'HP Listing' ) ); + } + + // ── read ───────────────────────────────────────────────────────────────── + + public function test_read_hot_calls_zone_hot_get(): void { + $field = array( 'zone' => 'hot', 'column' => 'hp_price' ); + WPDO_Zone_Router::read( $field, 42, 'hp_listing', 'hp_price' ); + $this->assertStringContainsString( 'wpdo_hot_hp_listing', self::$last_query ); + $this->assertStringContainsString( 'hp_price', self::$last_query ); + $this->assertStringContainsString( '42', self::$last_query ); + } + + public function test_read_warm_calls_zone_warm_get(): void { + $field = array( 'zone' => 'warm' ); + WPDO_Zone_Router::read( $field, 7, 'hp_listing', 'views' ); + $this->assertStringContainsString( 'wpdo_warm', self::$last_query ); + } + + public function test_read_cold_calls_zone_cold_get(): void { + $field = array( 'zone' => 'cold' ); + WPDO_Zone_Router::read( $field, 5, 'hp_listing', 'hp_desc' ); + $this->assertStringContainsString( 'wpdo_cold_hp_listing', self::$last_query ); + } + + public function test_read_unknown_zone_returns_null(): void { + $field = array( 'zone' => 'other' ); + $result = WPDO_Zone_Router::read( $field, 1, 'hp_listing', 'key' ); + $this->assertNull( $result ); + } + + // ── write ───────────────────────────────────────────────────────────────── + + public function test_write_hot_calls_zone_hot_set(): void { + $field = array( 'zone' => 'hot', 'column' => 'hp_price' ); + WPDO_Zone_Router::write( $field, 42, 'hp_listing', 'hp_price', '100' ); + $this->assertStringContainsString( 'wpdo_hot_hp_listing', self::$last_query ); + $this->assertStringContainsString( 'hp_price', self::$last_query ); + } + + public function test_write_warm_calls_zone_warm_set(): void { + $field = array( 'zone' => 'warm', 'ttl' => null ); + WPDO_Zone_Router::write( $field, 7, 'hp_listing', 'views', '5' ); + $this->assertStringContainsString( 'wpdo_warm', self::$last_query ); + } + + public function test_write_warm_json_encodes_non_string_value(): void { + $field = array( 'zone' => 'warm', 'ttl' => null ); + WPDO_Zone_Router::write( $field, 7, 'hp_listing', 'data', array( 'a' => 1 ) ); + $this->assertStringContainsString( 'wpdo_warm', self::$last_query ); + } + + public function test_write_unknown_zone_is_noop(): void { + $field = array( 'zone' => 'other' ); + WPDO_Zone_Router::write( $field, 1, 'hp_listing', 'key', 'val' ); + $this->assertSame( '', self::$last_query ); + } + + // ── delete_field ────────────────────────────────────────────────────────── + + public function test_delete_field_hot_nulls_column(): void { + $field = array( 'zone' => 'hot', 'column' => 'hp_price' ); + WPDO_Zone_Router::delete_field( $field, 42, 'hp_listing', 'hp_price' ); + $this->assertStringContainsString( 'wpdo_hot_hp_listing', self::$last_query ); + // Hot delete sets the column to NULL via an UPDATE. + $this->assertStringContainsString( 'NULL', self::$last_query ); + } + + public function test_delete_field_warm_calls_delete(): void { + $field = array( 'zone' => 'warm' ); + WPDO_Zone_Router::delete_field( $field, 7, 'hp_listing', 'views' ); + // Table name includes $wpdb->prefix ('wp_') + 'wpdo_warm'. + $this->assertSame( 'wp_wpdo_warm', self::$last_delete[0] ?? '' ); + } + + public function test_delete_field_unknown_zone_is_noop(): void { + $field = array( 'zone' => 'other' ); + WPDO_Zone_Router::delete_field( $field, 1, 'hp_listing', 'key' ); + $this->assertSame( '', self::$last_query ); + $this->assertSame( [], self::$last_delete ); + } + + // ── delete_post ─────────────────────────────────────────────────────────── + + public function test_delete_post_skips_hot_when_no_hot_columns(): void { + $sr = new ReflectionClass( WPDO_Schema_Registry::class ); + $sr->getProperty( 'instance' )->setValue( null, null ); + + $captured = array(); + global $wpdb; + $wpdb = new class( $captured ) { + public string $prefix = 'wp_'; + public array $log; + public function __construct( array &$ref ) { $this->log = &$ref; } + public function prepare( string $s, mixed ...$a ): string { return $s; } + public function query( string $s ): int { return 1; } + public function get_var( string $s ): mixed { return null; } + public function insert( string $t, array $d, mixed $f = null ): int { return 1; } + public function delete( string $table, array $where, array $formats ): void { + $this->log[] = $table; + } + }; + + WPDO_Zone_Router::delete_post( 1, 'hp_listing' ); + + $this->assertNotContains( 'wp_wpdo_hot_hp_listing', $captured, 'Hot should be skipped when no columns registered' ); + $this->assertContains( 'wp_wpdo_warm', $captured ); + $this->assertContains( 'wp_wpdo_archive', $captured ); + } + + public function test_delete_post_deletes_hot_when_columns_registered(): void { + $sr = new ReflectionClass( WPDO_Schema_Registry::class ); + $sr->getProperty( 'instance' )->setValue( null, null ); + + WPDO_Schema_Registry::instance()->register( 'test', array( + 'post_type' => 'hp_listing', + 'meta_key' => 'hp_price', + 'zone' => 'hot', + 'column' => 'hp_price', + 'type' => 'decimal', + ) ); + + $captured = array(); + global $wpdb; + $wpdb = new class( $captured ) { + public string $prefix = 'wp_'; + public array $log; + public function __construct( array &$ref ) { $this->log = &$ref; } + public function prepare( string $s, mixed ...$a ): string { return $s; } + public function query( string $s ): int { return 1; } + public function get_var( string $s ): mixed { return null; } + public function insert( string $t, array $d, mixed $f = null ): int { return 1; } + public function delete( string $table, array $where, array $formats ): void { + $this->log[] = $table; + } + }; + + WPDO_Zone_Router::delete_post( 10, 'hp_listing' ); + + $this->assertContains( 'wp_wpdo_hot_hp_listing', $captured ); + } + + public function test_delete_post_always_clears_warm_and_archive(): void { + $sr = new ReflectionClass( WPDO_Schema_Registry::class ); + $sr->getProperty( 'instance' )->setValue( null, null ); + + $captured = array(); + global $wpdb; + $wpdb = new class( $captured ) { + public string $prefix = 'wp_'; + public array $log; + public function __construct( array &$ref ) { $this->log = &$ref; } + public function prepare( string $s, mixed ...$a ): string { return $s; } + public function query( string $s ): int { return 1; } + public function get_var( string $s ): mixed { return null; } + public function insert( string $t, array $d, mixed $f = null ): int { return 1; } + public function delete( string $table, array $where, array $formats ): void { + $this->log[] = $table; + } + }; + + WPDO_Zone_Router::delete_post( 5, 'hp_listing' ); + + $this->assertContains( 'wp_wpdo_warm', $captured ); + $this->assertContains( 'wp_wpdo_archive', $captured ); + } +}