diff --git a/includes/query/class-tmdo-post-query-router.php b/includes/query/class-tmdo-post-query-router.php index c2f1074..88914b7 100644 --- a/includes/query/class-tmdo-post-query-router.php +++ b/includes/query/class-tmdo-post-query-router.php @@ -64,6 +64,9 @@ class TMDO_Post_Query_Router { /** Object cache group for found-rows counts. */ private const COUNT_CACHE_GROUP = 'tmdo_found_rows'; + /** Comparison operators the router can express on a flat column. */ + private const COMPARES = array( '=', '!=', '<>', '<', '<=', '>', '>=', 'IN', 'NOT IN', 'LIKE', 'NOT LIKE', 'BETWEEN', 'NOT BETWEEN', 'EXISTS', 'NOT EXISTS' ); + /** * Per-request answer to "are this group's copies usable?". * @@ -77,7 +80,9 @@ class TMDO_Post_Query_Router { * @return void */ public function register_hooks(): void { - add_action( 'pre_get_posts', array( $this, 'pre_get_posts' ), 10, 1 ); + // Late, so meta clauses other plugins add at the default priority + // (e.g. HivePress search filters and sorting) are already in the query. + add_action( 'pre_get_posts', array( $this, 'pre_get_posts' ), 1000, 1 ); add_filter( 'posts_join', array( $this, 'posts_join' ), 10, 2 ); add_filter( 'posts_where', array( $this, 'posts_where' ), 10, 2 ); add_filter( 'posts_groupby', array( $this, 'posts_groupby' ), 10, 2 ); @@ -103,55 +108,52 @@ class TMDO_Post_Query_Router { return; } + // One post type only: with several, a clause may belong to one type's + // group and the others must still match through wp_postmeta. $post_types = (array) $query->get( 'post_type' ); - if ( empty( $post_types ) ) { + if ( 1 !== count( $post_types ) ) { return; } + $pt = (string) reset( $post_types ); $raw_meta_query = (array) $query->get( 'meta_query' ); if ( empty( $raw_meta_query ) ) { return; } + // All or nothing: routed clauses are ANDed into the WHERE, so the + // query is routed only when every registered clause can be, and + // the remaining (unregistered) clauses are ANDed too. + $relation = strtoupper( (string) ( $raw_meta_query['relation'] ?? 'AND' ) ); + $orderby = $query->get( 'orderby' ); + $named = is_array( $orderby ) ? array_keys( $orderby ) : preg_split( '/[\s,]+/', (string) $orderby ); $routed = array(); // Keyed by post_type. $remaining = array(); foreach ( $raw_meta_query as $k => $clause ) { - if ( 'relation' === $k || ! is_array( $clause ) || ! isset( $clause['key'] ) ) { + if ( 'relation' === $k || ! is_array( $clause ) ) { + $remaining[ $k ] = $clause; + continue; + } + if ( ! isset( $clause['key'] ) ) { + if ( self::has_registered_key( $clause, $pt ) ) { + return; // Nested group. + } $remaining[ $k ] = $clause; continue; } - $matched = false; - foreach ( $post_types as $pt ) { - $field = TMDO_Entity_Registry::get_field( 'post', $clause['key'], (string) $pt ); - if ( ! $field ) { - continue; - } - // Confirm the field's group is appropriate for this post_type: - // either cross-cutting wp_core (any) or the post_type-specific group. - $group = $field['group'] ?? ''; - $is_appropriate = ( 'wp_core' === $group ) || in_array( $group, TMDO_Post_Fields::groups_for_post_type( (string) $pt ), true ); - if ( ! $is_appropriate ) { - continue; - } - - $column = TMDO_Schema_Manager::sanitize_column_name( $clause['key'] ); - - $routed[ $pt ][] = array( - 'group' => $group, - 'column' => $column, - 'value' => $clause['value'] ?? '', - 'compare' => strtoupper( trim( (string) ( $clause['compare'] ?? '=' ) ) ), - 'type' => strtoupper( trim( (string) ( $clause['type'] ?? 'CHAR' ) ) ), - ); - $matched = true; - break; - } - - if ( ! $matched ) { + $field = self::field_for( $clause, $pt ); + if ( ! $field ) { $remaining[ $k ] = $clause; + continue; } + // Unroutable, under OR, or named by orderby (the name would be lost). + $route = self::route_clause( $clause, $field ); + if ( ! $route || 'AND' !== $relation || ( is_string( $k ) && in_array( $k, $named, true ) ) ) { + return; + } + $routed[ $pt ][] = $route; } if ( empty( $routed ) ) { @@ -411,6 +413,95 @@ class TMDO_Post_Query_Router { return self::$copies_usable[ $group ]; } + /** + * The registered field a clause targets for this post type, or null. + * + * @param array $clause Meta query clause. + * @param string $post_type Post type slug. + * @return array|null + */ + private static function field_for( array $clause, string $post_type ): ?array { + if ( ! is_string( $clause['key'] ?? null ) || ! in_array( $clause['compare_key'] ?? '=', array( '=', '' ), true ) ) { + return null; + } + $field = TMDO_Entity_Registry::get_field( 'post', $clause['key'], $post_type ); + if ( ! $field ) { + return null; + } + // Either cross-cutting wp_core or one of the post type's own groups. + $group = $field['group'] ?? ''; + return ( 'wp_core' === $group || in_array( $group, TMDO_Post_Fields::groups_for_post_type( $post_type ), true ) ) ? $field : null; + } + + /** + * Whether a nested meta query group references a registered key. + * + * @param array $group Nested group. + * @param string $post_type Post type slug. + * @return bool + */ + private static function has_registered_key( array $group, string $post_type ): bool { + foreach ( $group as $clause ) { + if ( ! is_array( $clause ) ) { + continue; + } + if ( isset( $clause['key'] ) ? null !== self::field_for( $clause, $post_type ) : self::has_registered_key( $clause, $post_type ) ) { + return true; + } + } + return false; + } + + /** + * The routed form of a clause, or null when the flat column would not + * compare the way WordPress compares the meta_value string. + * + * Native casts meta_value by the clause type (CHAR when absent); flat + * columns are typed. Routed: EXISTS / NOT EXISTS; string columns with + * CHAR; numeric columns with a numeric type (no LIKE); integer columns + * with CHAR =, !=, IN, NOT IN on plain integers ("10" = "10" either way). + * + * @param array $clause Meta query clause. + * @param array $field Registered field. + * @return array{group:string, column:string, value:mixed, compare:string, type:string}|null + */ + private static function route_clause( array $clause, array $field ): ?array { + $compare = strtoupper( trim( (string) ( $clause['compare'] ?? '=' ) ) ); + $type = strtoupper( trim( (string) ( $clause['type'] ?? '' ) ) ); + $type = '' === $type ? 'CHAR' : $type; + $value = $clause['value'] ?? ''; + $kind = $field['type'] ?? 'text'; + $multi = in_array( $compare, array( 'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN' ), true ); + + if ( ! in_array( $compare, self::COMPARES, true ) || ( ! $multi && is_array( $value ) ) ) { + return null; + } + + $numeric_type = 1 === preg_match( '/^(NUMERIC|SIGNED|UNSIGNED|DECIMAL)(\(\d+(,\s?\d+)?\))?$/', $type ); + if ( in_array( $compare, array( 'EXISTS', 'NOT EXISTS' ), true ) ) { + $ok = in_array( $kind, array( 'text', 'textarea', 'enum', 'integer', 'decimal', 'boolean' ), true ); + } elseif ( in_array( $kind, array( 'text', 'textarea', 'enum' ), true ) ) { + $ok = 'CHAR' === $type; + } elseif ( in_array( $kind, array( 'integer', 'decimal', 'boolean' ), true ) && $numeric_type ) { + $ok = ! in_array( $compare, array( 'LIKE', 'NOT LIKE' ), true ); + } else { + $ok = in_array( $kind, array( 'integer', 'boolean' ), true ) && 'CHAR' === $type + && in_array( $compare, array( '=', '!=', '<>', 'IN', 'NOT IN' ), true ) + && array() === array_filter( (array) $value, static fn( $v ) => ! is_scalar( $v ) || (string) (int) $v !== (string) $v ); + } + if ( ! $ok ) { + return null; + } + + return array( + 'group' => (string) $field['group'], + 'column' => TMDO_Schema_Manager::sanitize_column_name( $clause['key'] ), + 'value' => $value, + 'compare' => $compare, + 'type' => $type, + ); + } + /** * Generate a deterministic JOIN alias for a (post_type, group) pair. * @@ -429,9 +520,8 @@ class TMDO_Post_Query_Router { * @return string Sanitized operator (defaults to '='). */ private static function sanitize_compare( string $compare ): string { - $allowed = array( '=', '!=', '<>', '<', '<=', '>', '>=', 'IN', 'NOT IN', 'LIKE', 'NOT LIKE', 'BETWEEN', 'NOT BETWEEN', 'EXISTS', 'NOT EXISTS' ); $compare = strtoupper( trim( $compare ) ); - return in_array( $compare, $allowed, true ) ? $compare : '='; + return in_array( $compare, self::COMPARES, true ) ? $compare : '='; } /** @@ -468,6 +558,10 @@ class TMDO_Post_Query_Router { return ' AND ' . $col_sql . ' ' . $op; } + if ( in_array( $compare, array( 'LIKE', 'NOT LIKE' ), true ) ) { + $value = '%' . $wpdb->esc_like( (string) $value ) . '%'; + } + return ' AND ' . $col_sql . ' ' . $compare . ' ' . $wpdb->prepare( '%s', (string) $value ); } } diff --git a/tests/integration/PostQueryRouterCopyTest.php b/tests/integration/PostQueryRouterCopyTest.php index e0695f6..823d0ff 100644 --- a/tests/integration/PostQueryRouterCopyTest.php +++ b/tests/integration/PostQueryRouterCopyTest.php @@ -65,7 +65,7 @@ class PostQueryRouterCopyTest extends TestCase { array( 'post_type' => 'rc_item', 'post_status' => 'publish', - 'meta_query' => array( array( 'key' => 'rc_price', 'value' => array( 5, 20 ), 'compare' => 'BETWEEN' ) ), + 'meta_query' => array( array( 'key' => 'rc_price', 'value' => array( 5, 20 ), 'compare' => 'BETWEEN', 'type' => 'NUMERIC' ) ), ), $vars ) as $k => $v ) { diff --git a/tests/integration/PostQueryRouterFoundRowsTest.php b/tests/integration/PostQueryRouterFoundRowsTest.php index 825f55d..df26cae 100644 --- a/tests/integration/PostQueryRouterFoundRowsTest.php +++ b/tests/integration/PostQueryRouterFoundRowsTest.php @@ -80,7 +80,7 @@ class PostQueryRouterFoundRowsTest extends TestCase { $query = new WP_Query(); $query->set( 'post_type', 'fr_item' ); $query->set( 'post_status', 'publish' ); - $query->set( 'meta_query', array( array( 'key' => 'fr_price', 'value' => 15, 'compare' => '>=' ) ) ); + $query->set( 'meta_query', array( array( 'key' => 'fr_price', 'value' => 15, 'compare' => '>=', 'type' => 'NUMERIC' ) ) ); $router = new TMDO_Post_Query_Router(); $router->pre_get_posts( $query ); diff --git a/tests/integration/PostQueryRouterTest.php b/tests/integration/PostQueryRouterTest.php index 762c2ef..163e039 100644 --- a/tests/integration/PostQueryRouterTest.php +++ b/tests/integration/PostQueryRouterTest.php @@ -78,7 +78,7 @@ class PostQueryRouterTest extends TestCase { $query = $this->make_query( array( 'post_type' => 'product', 'meta_query' => array( - array( 'key' => '_price', 'value' => '50', 'compare' => '>=' ), + array( 'key' => '_price', 'value' => '50', 'compare' => '>=', 'type' => 'NUMERIC' ), ), ) ); $original = $query->get( 'meta_query' ); @@ -100,7 +100,7 @@ class PostQueryRouterTest extends TestCase { $query = $this->make_query( array( 'post_type' => 'product', 'meta_query' => array( - array( 'key' => '_price', 'value' => '50', 'compare' => '>=' ), + array( 'key' => '_price', 'value' => '50', 'compare' => '>=', 'type' => 'NUMERIC' ), ), ) ); $original = $query->get( 'meta_query' ); @@ -123,7 +123,7 @@ class PostQueryRouterTest extends TestCase { $query = $this->make_query( array( 'post_type' => 'product', 'meta_query' => array( - array( 'key' => '_price', 'value' => '50', 'compare' => '>=' ), + array( 'key' => '_price', 'value' => '50', 'compare' => '>=', 'type' => 'NUMERIC' ), ), ) ); @@ -149,7 +149,7 @@ class PostQueryRouterTest extends TestCase { $query = $this->make_query( array( 'post_type' => 'product', 'meta_query' => array( - array( 'key' => '_price', 'value' => '50', 'compare' => '>=' ), + array( 'key' => '_price', 'value' => '50', 'compare' => '>=', 'type' => 'NUMERIC' ), array( 'key' => 'unmanaged_attr', 'value' => 'x' ), ), ) ); @@ -177,7 +177,7 @@ class PostQueryRouterTest extends TestCase { $query = $this->make_query( array( 'post_type' => 'product', 'meta_query' => array( - array( 'key' => '_price', 'value' => '50', 'compare' => '>=' ), + array( 'key' => '_price', 'value' => '50', 'compare' => '>=', 'type' => 'NUMERIC' ), ), ) ); $original = $query->get( 'meta_query' ); @@ -202,7 +202,7 @@ class PostQueryRouterTest extends TestCase { $query = $this->make_query( array( 'post_type' => 'product', 'meta_query' => array( - array( 'key' => '_price', 'value' => '50' ), + array( 'key' => '_price', 'value' => '50', 'type' => 'NUMERIC' ), ), 'wpdo_post_clauses' => array(), ) ); @@ -221,7 +221,7 @@ class PostQueryRouterTest extends TestCase { $query = $this->make_query( array( 'post_type' => 'product', 'meta_query' => array( - array( 'key' => '_price', 'value' => '99', 'compare' => '=' ), + array( 'key' => '_price', 'value' => '99', 'compare' => '=', 'type' => 'NUMERIC' ), ), ) ); @@ -242,4 +242,156 @@ class PostQueryRouterTest extends TestCase { $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 $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_orderby_on_a_named_registered_clause_is_not_routed(): void { + $this->assert_not_routed( + array( + 'post_type' => 'product', + 'meta_query' => array( 'price' => array( 'key' => '_price', 'compare' => 'EXISTS', 'type' => 'NUMERIC' ) ), + 'orderby' => array( 'price' => 'ASC' ), + ), + 'orderby names a routed clause' + ); + } + + 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 ) ); + } } diff --git a/tests/live/README.md b/tests/live/README.md index 0025e07..8b8d1f8 100644 --- a/tests/live/README.md +++ b/tests/live/README.md @@ -39,6 +39,7 @@ Exit code 0 when every test passed, 1 otherwise. Output per test: | `shadow_write` | writes in shadow_read reach native meta (user, term, HivePress listing) | | `serialized` | array meta values are stored serialized in flat tables, never "Array" | | `resync` | dual_write / shadow_read: flat follows native after every write (4 entities × 2 modes: update, same value, delete, refused writes, stale flat, key rename by meta_id) | +| `router` | a routed WP_Query returns the same ids, in order, as native (OR, several post types, untyped numeric compares, named orderby, LIKE) | | `zero_side_effect` | zone tables that were never created are never queried on save / delete | ## Tools (`tools/`) diff --git a/tests/live/suites/router.php b/tests/live/suites/router.php new file mode 100644 index 0000000..cf63536 --- /dev/null +++ b/tests/live/suites/router.php @@ -0,0 +1,248 @@ + $args Query args (meta_query, post_type, orderby …). + * @param int[] $ids Fixture posts to search within. + * @param bool $routed Hook a TMDO_Post_Query_Router for this query. + * @return array{ids:int[], routed:bool} + */ +function tmdo_live_router_query( array $args, array $ids, bool $routed ): array { + global $wpdb; + $router = new TMDO_Post_Query_Router(); + if ( $routed ) { + $router->register_hooks(); + } + try { + $wpdb->last_error = ''; + $q = new WP_Query( + $args + array( + 'post__in' => $ids, + 'post_status' => 'any', + 'posts_per_page' => -1, + 'orderby' => 'ID', + 'order' => 'ASC', + 'fields' => 'ids', + 'no_found_rows' => true, + 'cache_results' => false, + 'suppress_filters' => false, + ) + ); + } finally { + foreach ( array( 'pre_get_posts', 'posts_join', 'posts_where', 'posts_groupby', 'posts_orderby', 'posts_request', 'found_posts_query' ) as $hook ) { + $priority = has_filter( $hook, array( $router, $hook ) ); + if ( false !== $priority ) { + remove_filter( $hook, array( $router, $hook ), $priority ); + } + } + } + tmdo_live_assert_same( '', $wpdb->last_error, 'SQL error' ); + return array( + 'ids' => array_map( 'intval', $q->posts ), + 'routed' => ! empty( $q->get( 'wpdo_post_clauses' ) ), + ); +} + +/** + * Create posts of $post_type with the given meta, in post mode shadow_read + * (so the Hook Bus keeps the flat rows in step). + * + * @param string $post_type Post type. + * @param array> $posts Label => meta. + * @return array Label => post id. + */ +function tmdo_live_router_posts( string $post_type, array $posts ): array { + $ids = array(); + foreach ( $posts as $label => $meta ) { + $ids[ $label ] = TMDO_Live_Fixtures::post( $post_type ); + foreach ( $meta as $key => $value ) { + update_post_meta( $ids[ $label ], $key, $value ); + } + } + return $ids; +} + +/** + * Listing fixtures shared by the hp_listing cases. + * + * @return array + */ +function tmdo_live_router_listings(): array { + foreach ( array( 'hp_price', 'hp_verified', 'hp_featured', 'hp_hourly_rate' ) as $key ) { + TMDO_Live_Fixtures::field( 'post', $key, 'hp_listing' ); + } + TMDO_Live_Fixtures::use_modes( array( 'post' => 'shadow_read' ) ); + return tmdo_live_router_posts( + 'hp_listing', + array( + 'L1' => array( 'hp_price' => '10000', 'hp_featured' => '1', 'hp_verified' => '0', 'hp_hourly_rate' => '40' ), + 'L2' => array( 'hp_price' => '9500', 'hp_verified' => '1', 'hp_hourly_rate' => '30' ), + 'L3' => array( 'hp_price' => '500', 'hp_featured' => '0', 'hp_hourly_rate' => '45' ), + 'L4' => array( 'hp_price' => '20000', 'tmdo_live_unregistered' => '0' ), + 'L5' => array(), + ) + ); +} + +/** + * Assert the routed query equals native; optionally that it was routed at all. + * + * @param array $args Query args. + * @param int[] $ids Fixture ids. + * @param bool|null $expect_routed true / false to assert, null to ignore. + */ +function tmdo_live_router_assert_native( array $args, array $ids, ?bool $expect_routed = null ): void { + $native = tmdo_live_router_query( $args, $ids, false ); + $routed = tmdo_live_router_query( $args, $ids, true ); + tmdo_live_assert_same( $native['ids'], $routed['ids'], 'routed ids (in order) vs native' ); + if ( null !== $expect_routed ) { + tmdo_live_assert_same( $expect_routed, $routed['routed'], 'query routed to the flat table' ); + } +} + +TMDO_Live::test( + 'Q04 OR across registered keys returns the native posts', + static function () { + $l = tmdo_live_router_listings(); + tmdo_live_router_assert_native( + array( + 'post_type' => 'hp_listing', + 'meta_query' => array( + 'relation' => 'OR', + array( 'key' => 'hp_featured', 'value' => '1' ), + array( 'key' => 'hp_verified', 'value' => '1' ), + ), + ), + array_values( $l ) + ); + } +); + +TMDO_Live::test( + 'Q16 OR mixing a registered and an unregistered key returns the native posts', + static function () { + $l = tmdo_live_router_listings(); + tmdo_live_router_assert_native( + array( + 'post_type' => 'hp_listing', + 'meta_query' => array( + 'relation' => 'OR', + array( 'key' => 'hp_price', 'value' => 1000, 'compare' => '<', 'type' => 'NUMERIC' ), + array( 'key' => 'tmdo_live_unregistered', 'value' => '0' ), + ), + ), + array_values( $l ) + ); + } +); + +TMDO_Live::test( + 'Q14 untyped comparison on a numeric column keeps string semantics', + static function () { + $l = tmdo_live_router_listings(); + tmdo_live_router_assert_native( + array( + 'post_type' => 'hp_listing', + 'meta_query' => array( array( 'key' => 'hp_price', 'value' => '9000', 'compare' => '>' ) ), + ), + array_values( $l ) + ); + } +); + +TMDO_Live::test( + 'Q15 orderby on a named clause keeps the native order', + static function () { + $l = tmdo_live_router_listings(); + tmdo_live_router_assert_native( + array( + 'post_type' => 'hp_listing', + 'meta_query' => array( + 'rate' => array( 'key' => 'hp_hourly_rate', 'value' => 50, 'compare' => '<', 'type' => 'NUMERIC' ), + 'price' => array( 'key' => 'hp_price', 'compare' => 'EXISTS', 'type' => 'NUMERIC' ), + ), + 'orderby' => 'price', + 'order' => 'ASC', + ), + array_values( $l ) + ); + } +); + +TMDO_Live::test( + 'Q08 several post types return the native posts', + static function () { + $l = tmdo_live_router_listings(); + TMDO_Live_Fixtures::field( 'post', 'hp_budget', 'hp_request' ); + $r = tmdo_live_router_posts( 'hp_request', array( 'R1' => array( 'hp_budget' => '90000' ) ) ); + update_post_meta( $l['L2'], 'hp_budget', '85000' ); + tmdo_live_router_assert_native( + array( + 'post_type' => array( 'hp_listing', 'hp_request' ), + 'meta_query' => array( array( 'key' => 'hp_budget', 'value' => 80000, 'compare' => '>=', 'type' => 'NUMERIC' ) ), + ), + array_merge( array_values( $l ), array_values( $r ) ) + ); + } +); + +TMDO_Live::test( + 'typed AND on registered keys is routed and returns the native posts', + static function () { + $l = tmdo_live_router_listings(); + tmdo_live_router_assert_native( + array( + 'post_type' => 'hp_listing', + 'meta_query' => array( + array( 'key' => 'hp_price', 'value' => array( 9000, 15000 ), 'compare' => 'BETWEEN', 'type' => 'NUMERIC' ), + array( 'key' => 'hp_verified', 'value' => '1' ), + ), + ), + array_values( $l ), + true + ); + } +); + +TMDO_Live::test( + 'Q06 LIKE matches substrings and escapes wildcards, routed', + static function () { + TMDO_Live_Fixtures::field( 'post', '_sku', 'product' ); + TMDO_Live_Fixtures::use_modes( array( 'post' => 'shadow_read' ) ); + $p = tmdo_live_router_posts( + 'product', + array( + 'P1' => array( '_sku' => 'tmdo-live-B1-1' ), + 'P2' => array( '_sku' => 'tmdo-live-B1-10' ), + 'P3' => array( '_sku' => 'tmdo-live-B2-1' ), + ) + ); + foreach ( array( 'B1-1', 'B1_1' ) as $needle ) { + tmdo_live_router_assert_native( + array( + 'post_type' => 'product', + 'meta_query' => array( array( 'key' => '_sku', 'value' => $needle, 'compare' => 'LIKE' ) ), + ), + array_values( $p ), + true + ); + } + } +);