f48df1a33f
- WooCommerce reads product meta directly and writes stock by SQL, so a flat copy drifted (R-2.1 V1 T4); product meta now stays in native only (plan decision 2). TMDO_Post_Fields registers wp_core, attachment and nav_menu_item. Existing wp_wpdo_post_wc_product tables are left in place and still dropped on uninstall; the orphan restore registers nothing for them while native meta holds the keys; `wp tmdo bridge-restore-native post --group=wc_product --retire` archives them. - Tests that route products register the former group themselves (tmdo_test_register_wc_product_group(), same name and table); live router Q06 runs on wp_core's _wp_page_template instead of _sku. The post stress tester already skips flat tables that do not exist. - Verify: PostFieldsRegistrationTest (RED before); dev36: wc_product absent from the registry and not restored as an orphan, a CLI order took stock 452 → 450 in native with no DB error (reverted), drift post 10 groups 0. - PR: PR-077
416 lines
13 KiB
PHP
416 lines
13 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
/**
|
|
* Integration test: WPDO_Post_Query_Router (v2.10.1).
|
|
*
|
|
* Verifies meta_query rewriting when post mode is reads_from_flat (i.e.
|
|
* shadow_read or aeav_only). The router strips clauses targeting registered
|
|
* Entity Registry keys, JOINs the corresponding flat table, and appends
|
|
* WHERE conditions in SQL.
|
|
*
|
|
* 🔒 Frozen contract:
|
|
* - mode=disabled → router pass-through (zero modification)
|
|
* - mode=dual_write → router pass-through (wp_postmeta still source-of-truth)
|
|
* - mode=shadow_read → router rewrites (flat is read-replica candidate)
|
|
* - mode=aeav_only → router rewrites (flat is source-of-truth)
|
|
*/
|
|
class PostQueryRouterTest extends TestCase {
|
|
|
|
public static function setUpBeforeClass(): void {
|
|
$plugin_dir = WPDO_PLUGIN_DIR;
|
|
foreach ( array(
|
|
'includes/adapters/interface-entity-adapter.php',
|
|
'includes/engine/class-tmdo-entity-registry.php',
|
|
'includes/engine/class-tmdo-mode-manager.php',
|
|
'includes/engine/class-tmdo-schema-manager.php',
|
|
'includes/adapters/class-tmdo-adapter-post.php',
|
|
'includes/integrations/class-tmdo-post-fields.php',
|
|
'includes/query/class-tmdo-post-query-router.php',
|
|
) as $rel ) {
|
|
$file = $plugin_dir . $rel;
|
|
if ( file_exists( $file ) ) {
|
|
require_once $file;
|
|
}
|
|
}
|
|
|
|
WPDO_Entity_Registry::init();
|
|
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
|
|
WPDO_Post_Fields::register_entity_fields();
|
|
tmdo_test_register_wc_product_group();
|
|
}
|
|
|
|
public static function tearDownAfterClass(): void {
|
|
$ref = new ReflectionClass( WPDO_Mode_Manager::class );
|
|
$cache = $ref->getProperty( 'cache' );
|
|
$cache->setAccessible( true );
|
|
$cache->setValue( null, null );
|
|
WPDO_Entity_Registry::init();
|
|
}
|
|
|
|
private function set_post_mode( string $mode ): void {
|
|
$ref = new ReflectionClass( WPDO_Mode_Manager::class );
|
|
$cache = $ref->getProperty( 'cache' );
|
|
$cache->setAccessible( true );
|
|
$cache->setValue( null, array(
|
|
'post' => $mode,
|
|
'user' => 'aeav_only',
|
|
'term' => 'dual_write',
|
|
'comment' => 'dual_write',
|
|
) );
|
|
}
|
|
|
|
private function make_query( array $vars ): WP_Query {
|
|
$q = new WP_Query();
|
|
foreach ( $vars as $k => $v ) {
|
|
$q->set( $k, $v );
|
|
}
|
|
return $q;
|
|
}
|
|
|
|
// ── pre_get_posts gate (mode-aware) ───────────────────────────────────────
|
|
|
|
public function test_pass_through_when_mode_disabled(): void {
|
|
$this->set_post_mode( 'disabled' );
|
|
|
|
$router = new WPDO_Post_Query_Router();
|
|
$query = $this->make_query( array(
|
|
'post_type' => 'product',
|
|
'meta_query' => array(
|
|
array( 'key' => '_price', 'value' => '50', 'compare' => '>=', 'type' => 'NUMERIC' ),
|
|
),
|
|
) );
|
|
$original = $query->get( 'meta_query' );
|
|
|
|
$router->pre_get_posts( $query );
|
|
|
|
$this->assertSame(
|
|
$original,
|
|
$query->get( 'meta_query' ),
|
|
'mode=disabled: meta_query must be untouched.'
|
|
);
|
|
$this->assertSame( '', $query->get( 'wpdo_post_clauses' ) );
|
|
}
|
|
|
|
public function test_pass_through_when_mode_dual_write(): void {
|
|
$this->set_post_mode( 'dual_write' );
|
|
|
|
$router = new WPDO_Post_Query_Router();
|
|
$query = $this->make_query( array(
|
|
'post_type' => 'product',
|
|
'meta_query' => array(
|
|
array( 'key' => '_price', 'value' => '50', 'compare' => '>=', 'type' => 'NUMERIC' ),
|
|
),
|
|
) );
|
|
$original = $query->get( 'meta_query' );
|
|
|
|
$router->pre_get_posts( $query );
|
|
|
|
$this->assertSame(
|
|
$original,
|
|
$query->get( 'meta_query' ),
|
|
'mode=dual_write: wp_postmeta is still source-of-truth, no rewrite.'
|
|
);
|
|
}
|
|
|
|
// ── pre_get_posts rewrite (mode=aeav_only) ───────────────────────────────
|
|
|
|
public function test_rewrites_meta_query_when_mode_aeav_only(): void {
|
|
$this->set_post_mode( 'aeav_only' );
|
|
|
|
$router = new WPDO_Post_Query_Router();
|
|
$query = $this->make_query( array(
|
|
'post_type' => 'product',
|
|
'meta_query' => array(
|
|
array( 'key' => '_price', 'value' => '50', 'compare' => '>=', 'type' => 'NUMERIC' ),
|
|
),
|
|
) );
|
|
|
|
$router->pre_get_posts( $query );
|
|
|
|
// Original meta_query stripped of registered keys.
|
|
$remaining = $query->get( 'meta_query' );
|
|
$this->assertEmpty(
|
|
$remaining,
|
|
'aeav_only: registered keys removed from meta_query.'
|
|
);
|
|
|
|
// Routed clauses captured under wpdo_post_clauses query var.
|
|
$routed = $query->get( 'wpdo_post_clauses' );
|
|
$this->assertIsArray( $routed );
|
|
$this->assertNotEmpty( $routed );
|
|
}
|
|
|
|
public function test_keeps_unmanaged_keys_in_meta_query(): void {
|
|
$this->set_post_mode( 'aeav_only' );
|
|
|
|
$router = new WPDO_Post_Query_Router();
|
|
$query = $this->make_query( array(
|
|
'post_type' => 'product',
|
|
'meta_query' => array(
|
|
array( 'key' => '_price', 'value' => '50', 'compare' => '>=', 'type' => 'NUMERIC' ),
|
|
array( 'key' => 'unmanaged_attr', 'value' => 'x' ),
|
|
),
|
|
) );
|
|
|
|
$router->pre_get_posts( $query );
|
|
|
|
$remaining = $query->get( 'meta_query' );
|
|
$this->assertCount( 1, $remaining );
|
|
// Original index preserved (k=1 since k=0 was the routed _price clause).
|
|
$first_clause = reset( $remaining );
|
|
$this->assertSame(
|
|
'unmanaged_attr',
|
|
$first_clause['key'],
|
|
'Unmanaged key remains in meta_query (Hook Bus pass-through).'
|
|
);
|
|
}
|
|
|
|
public function test_skips_admin_requests(): void {
|
|
$this->set_post_mode( 'aeav_only' );
|
|
|
|
$prev_admin = $GLOBALS['_wp_is_admin'] ?? false;
|
|
$GLOBALS['_wp_is_admin'] = true;
|
|
|
|
$router = new WPDO_Post_Query_Router();
|
|
$query = $this->make_query( array(
|
|
'post_type' => 'product',
|
|
'meta_query' => array(
|
|
array( 'key' => '_price', 'value' => '50', 'compare' => '>=', 'type' => 'NUMERIC' ),
|
|
),
|
|
) );
|
|
$original = $query->get( 'meta_query' );
|
|
|
|
$router->pre_get_posts( $query );
|
|
|
|
$this->assertSame(
|
|
$original,
|
|
$query->get( 'meta_query' ),
|
|
'is_admin requests should not be rewritten.'
|
|
);
|
|
|
|
$GLOBALS['_wp_is_admin'] = $prev_admin;
|
|
}
|
|
|
|
// ── posts_join / posts_where (SQL emission) ──────────────────────────────
|
|
|
|
public function test_posts_join_emits_left_join_for_each_routed_post_type(): void {
|
|
$this->set_post_mode( 'aeav_only' );
|
|
|
|
$router = new WPDO_Post_Query_Router();
|
|
$query = $this->make_query( array(
|
|
'post_type' => 'product',
|
|
'meta_query' => array(
|
|
array( 'key' => '_price', 'value' => '50', 'type' => 'NUMERIC' ),
|
|
),
|
|
'wpdo_post_clauses' => array(),
|
|
) );
|
|
|
|
$router->pre_get_posts( $query );
|
|
$join = $router->posts_join( '', $query );
|
|
|
|
$this->assertStringContainsString( 'LEFT JOIN', $join );
|
|
$this->assertStringContainsString( 'wpdo_post_wc_product', $join );
|
|
}
|
|
|
|
public function test_posts_join_joins_every_routed_group(): void {
|
|
$this->set_post_mode( 'aeav_only' );
|
|
|
|
$router = new WPDO_Post_Query_Router();
|
|
$query = $this->make_query( array(
|
|
'post_type' => 'product',
|
|
'meta_query' => array(
|
|
array( 'key' => '_price', 'value' => '50', 'compare' => '>=', 'type' => 'NUMERIC' ),
|
|
array( 'key' => '_thumbnail_id', 'compare' => 'EXISTS' ),
|
|
),
|
|
) );
|
|
|
|
$router->pre_get_posts( $query );
|
|
$join = $router->posts_join( '', $query );
|
|
$where = $router->posts_where( '', $query );
|
|
|
|
$this->assertStringContainsString( 'wpdo_post_wc_product', $join );
|
|
$this->assertStringContainsString( 'wpdo_post_wp_core', $join );
|
|
$this->assertStringContainsString( '`wpdoflat_product_wp_core`.`_thumbnail_id` IS NOT NULL', $where );
|
|
}
|
|
|
|
public function test_no_group_by_is_added_for_one_row_per_post_joins(): void {
|
|
unset( $GLOBALS['_wp_filter_callbacks']['posts_groupby'] );
|
|
( new WPDO_Post_Query_Router() )->register_hooks();
|
|
|
|
$this->assertEmpty( $GLOBALS['_wp_filter_callbacks']['posts_groupby'] ?? array() );
|
|
}
|
|
|
|
public function test_posts_where_appends_condition_for_routed_clause(): void {
|
|
$this->set_post_mode( 'aeav_only' );
|
|
|
|
$router = new WPDO_Post_Query_Router();
|
|
$query = $this->make_query( array(
|
|
'post_type' => 'product',
|
|
'meta_query' => array(
|
|
array( 'key' => '_price', 'value' => '99', 'compare' => '=', 'type' => 'NUMERIC' ),
|
|
),
|
|
) );
|
|
|
|
$router->pre_get_posts( $query );
|
|
$where = $router->posts_where( '', $query );
|
|
|
|
$this->assertStringContainsString( '`_price`', $where );
|
|
$this->assertStringContainsString( "'99'", $where );
|
|
}
|
|
|
|
public function test_pass_through_when_no_meta_query(): void {
|
|
$this->set_post_mode( 'aeav_only' );
|
|
|
|
$router = new WPDO_Post_Query_Router();
|
|
$query = $this->make_query( array( 'post_type' => 'product' ) );
|
|
|
|
$router->pre_get_posts( $query );
|
|
|
|
$this->assertSame( '', $query->get( 'wpdo_post_clauses' ) );
|
|
}
|
|
|
|
// ── all-or-nothing: native unless every registered clause is routable ────
|
|
|
|
/**
|
|
* Run pre_get_posts in aeav_only and assert the query was left to WordPress.
|
|
*
|
|
* @param array<string, mixed> $vars Query vars.
|
|
*/
|
|
private function assert_not_routed( array $vars, string $why ): void {
|
|
$this->set_post_mode( 'aeav_only' );
|
|
$query = $this->make_query( $vars );
|
|
( new WPDO_Post_Query_Router() )->pre_get_posts( $query );
|
|
|
|
$this->assertSame( $vars['meta_query'], $query->get( 'meta_query' ), $why . ': meta_query untouched.' );
|
|
$this->assertSame( '', $query->get( 'wpdo_post_clauses' ), $why . ': nothing routed.' );
|
|
}
|
|
|
|
public function test_or_relation_is_not_routed(): void {
|
|
$this->assert_not_routed(
|
|
array(
|
|
'post_type' => 'product',
|
|
'meta_query' => array(
|
|
'relation' => 'OR',
|
|
array( 'key' => '_stock', 'value' => '1' ),
|
|
array( 'key' => '_sku', 'value' => 'x' ),
|
|
),
|
|
),
|
|
'OR'
|
|
);
|
|
}
|
|
|
|
public function test_several_post_types_are_not_routed(): void {
|
|
$this->assert_not_routed(
|
|
array(
|
|
'post_type' => array( 'product', 'post' ),
|
|
'meta_query' => array( array( 'key' => '_price', 'value' => '5', 'compare' => '>=', 'type' => 'NUMERIC' ) ),
|
|
),
|
|
'several post types'
|
|
);
|
|
}
|
|
|
|
public function test_untyped_range_on_a_numeric_column_is_not_routed(): void {
|
|
$this->assert_not_routed(
|
|
array(
|
|
'post_type' => 'product',
|
|
'meta_query' => array( array( 'key' => '_price', 'value' => '9000', 'compare' => '>' ) ),
|
|
),
|
|
'CHAR > on DECIMAL'
|
|
);
|
|
}
|
|
|
|
public function test_untyped_equality_on_a_decimal_column_is_not_routed(): void {
|
|
$this->assert_not_routed(
|
|
array(
|
|
'post_type' => 'product',
|
|
'meta_query' => array( array( 'key' => '_price', 'value' => '10' ) ),
|
|
),
|
|
'CHAR = on DECIMAL (native "10.00" != "10")'
|
|
);
|
|
}
|
|
|
|
public function test_numeric_type_on_a_text_column_is_not_routed(): void {
|
|
$this->assert_not_routed(
|
|
array(
|
|
'post_type' => 'product',
|
|
'meta_query' => array( array( 'key' => '_sku', 'value' => '5', 'compare' => '>', 'type' => 'NUMERIC' ) ),
|
|
),
|
|
'NUMERIC on VARCHAR'
|
|
);
|
|
}
|
|
|
|
public function test_unknown_compare_is_not_routed(): void {
|
|
$this->assert_not_routed(
|
|
array(
|
|
'post_type' => 'product',
|
|
'meta_query' => array( array( 'key' => '_sku', 'value' => '^a', 'compare' => 'REGEXP' ) ),
|
|
),
|
|
'REGEXP'
|
|
);
|
|
}
|
|
|
|
public function test_one_unroutable_registered_clause_keeps_the_whole_query_native(): void {
|
|
$this->assert_not_routed(
|
|
array(
|
|
'post_type' => 'product',
|
|
'meta_query' => array(
|
|
array( 'key' => '_stock', 'value' => '3', 'compare' => '>=', 'type' => 'NUMERIC' ),
|
|
array( 'key' => '_price', 'value' => '9000', 'compare' => '>' ),
|
|
),
|
|
),
|
|
'one unroutable clause'
|
|
);
|
|
}
|
|
|
|
public function test_nested_group_with_a_registered_key_is_not_routed(): void {
|
|
$this->assert_not_routed(
|
|
array(
|
|
'post_type' => 'product',
|
|
'meta_query' => array(
|
|
array( 'key' => '_stock', 'value' => '3', 'compare' => '>=', 'type' => 'NUMERIC' ),
|
|
array(
|
|
'relation' => 'OR',
|
|
array( 'key' => '_price', 'compare' => 'NOT EXISTS' ),
|
|
array( 'key' => '_price', 'compare' => 'EXISTS' ),
|
|
),
|
|
),
|
|
),
|
|
'nested group'
|
|
);
|
|
}
|
|
|
|
public function test_untyped_integer_equality_on_an_integer_column_is_routed(): void {
|
|
$this->set_post_mode( 'aeav_only' );
|
|
$query = $this->make_query(
|
|
array(
|
|
'post_type' => 'product',
|
|
'meta_query' => array(
|
|
array( 'key' => '_stock', 'value' => '3' ),
|
|
array( 'key' => 'unmanaged_attr', 'value' => 'x' ),
|
|
),
|
|
)
|
|
);
|
|
( new WPDO_Post_Query_Router() )->pre_get_posts( $query );
|
|
|
|
$this->assertNotEmpty( $query->get( 'wpdo_post_clauses' ) );
|
|
$this->assertCount( 1, $query->get( 'meta_query' ), 'unregistered AND clause stays native' );
|
|
}
|
|
|
|
public function test_like_matches_an_escaped_substring(): void {
|
|
$this->set_post_mode( 'aeav_only' );
|
|
$router = new WPDO_Post_Query_Router();
|
|
$query = $this->make_query(
|
|
array(
|
|
'post_type' => 'product',
|
|
'meta_query' => array( array( 'key' => '_sku', 'value' => 'B1_1%', 'compare' => 'LIKE' ) ),
|
|
)
|
|
);
|
|
$router->pre_get_posts( $query );
|
|
|
|
$this->assertStringContainsString( "LIKE '%B1\\\\_1\\\\%%'", $router->posts_where( '', $query ) );
|
|
}
|
|
}
|