diff --git a/tests/bench/class-tmdo-bench-seeder.php b/tests/bench/class-tmdo-bench-seeder.php new file mode 100644 index 0000000..3ea707a --- /dev/null +++ b/tests/bench/class-tmdo-bench-seeder.php @@ -0,0 +1,372 @@ + 'decimal(12,2) NOT NULL DEFAULT 0', + 'hp_featured' => 'tinyint(1) NOT NULL DEFAULT 0', + 'hp_verified' => 'tinyint(1) NOT NULL DEFAULT 0', + 'hp_vendor' => 'bigint(20) NOT NULL DEFAULT 0', + 'hp_expired_time' => 'bigint(20) NOT NULL DEFAULT 0', + ); + + private ?mysqli $db; + private string $prefix; + private int $vendors; + private int $listings; + private int $seed; + private int $batch; + + /** @var array Pending multi-row INSERTs per table. */ + private array $buffers = array(); + + /** + * Refuse to run against anything that is not a dedicated bench database. + * + * @throws InvalidArgumentException When the name does not end in `_bench`. + */ + public static function assert_bench_db( string $db_name ): void { + if ( ! preg_match( '/^[A-Za-z0-9_]+_bench$/', $db_name ) ) { + throw new InvalidArgumentException( "Refusing to seed '{$db_name}': database name must end in _bench." ); + } + } + + /** + * @throws InvalidArgumentException When a count is out of range. + */ + public static function assert_count( string $label, int $value ): void { + if ( $value < 1 || $value > self::MAX_ROWS ) { + throw new InvalidArgumentException( "--{$label} must be between 1 and " . self::MAX_ROWS . ", got {$value}." ); + } + } + + public function __construct( ?mysqli $db, string $prefix, int $vendors, int $listings, int $seed = 42, int $batch = 2000 ) { + $this->db = $db; + $this->prefix = $prefix; + $this->vendors = $vendors; + $this->listings = $listings; + $this->seed = $seed; + $this->batch = max( 1, $batch ); + } + + // ── Deterministic generators (pure — unit tested) ─────────────────────── + + private function det( int $i, string $key, int $mod ): int { + return crc32( "{$this->seed}:{$i}:{$key}" ) % $mod; + } + + /** 1-based vendor index (1..vendors) that owns listing $i. */ + public function listing_vendor( int $i ): int { + return $this->det( $i, 'vendor', $this->vendors ) + 1; + } + + /** 90% publish, 5% draft, 3% pending, 2% private. */ + public function listing_status( int $i ): string { + $r = $this->det( $i, 'status', 100 ); + if ( $r < 90 ) { + return 'publish'; + } + if ( $r < 95 ) { + return 'draft'; + } + return $r < 98 ? 'pending' : 'private'; + } + + /** + * Meta a HivePress listing carries. Keys absent from the array are not stored natively. + * + * @return array + */ + public function listing_meta( int $i, int $vendor_post_id ): array { + $featured = $this->det( $i, 'featured', 100 ) < 5 ? 1 : 0; + $meta = array( + 'hp_price' => sprintf( '%d.00', 100 + $this->det( $i, 'price', 99901 ) ), + 'hp_status' => $this->listing_status( $i ), + 'hp_featured' => $featured, + 'hp_verified' => $this->det( $i, 'verified', 100 ) < 30 ? 1 : 0, + 'hp_vendor' => $vendor_post_id, + 'hp_expired_time' => self::BASE_TS + $this->det( $i, 'expire', 365 ) * 86400, + 'hp_view_count' => $this->det( $i, 'views', 5000 ), + 'hp_rating' => sprintf( '%.2f', 1 + $this->det( $i, 'rating', 401 ) / 100 ), + 'hp_rating_count' => $this->det( $i, 'rating_count', 200 ), + ); + if ( $featured ) { + $meta['hp_featured_time'] = self::BASE_TS + $this->det( $i, 'featured_until', 90 ) * 86400; + } + return $meta; + } + + /** @return array */ + public function vendor_meta( int $v ): array { + return array( + 'hp_user' => $v, + 'hp_verified' => $this->det( $v, 'v_verified', 100 ) < 40 ? 1 : 0, + 'hp_hourly_rate' => sprintf( '%d.00', 20 + $this->det( $v, 'rate', 281 ) ), + 'hp_rating' => sprintf( '%.2f', 1 + $this->det( $v, 'v_rating', 401 ) / 100 ), + 'hp_rating_count' => $this->det( $v, 'v_rating_count', 500 ), + ); + } + + /** @return array */ + public function user_meta( int $v ): array { + return array( + 'first_name' => 'Vendor', + 'last_name' => (string) $v, + 'nickname' => "vendor_{$v}", + 'description' => "Bench vendor {$v}", + 'hp_verified' => $this->det( $v, 'u_verified', 100 ) < 40 ? 1 : 0, + 'hp_image' => (string) ( 100000 + $v ), + ); + } + + // ── Seeding ───────────────────────────────────────────────────────────── + + /** + * Drop and rebuild every bench table, then load the dataset. + * + * @return array Row counts per table. + */ + public function run(): array { + $this->exec( 'SET SESSION unique_checks = 0' ); + $this->exec( 'SET SESSION foreign_key_checks = 0' ); + + $this->create_core_tables(); + $this->create_flat_tables(); + + $this->seed_vendors(); + $this->seed_listings(); + + $counts = array(); + foreach ( array_keys( $this->table_names() ) as $table ) { + $counts[ $table ] = (int) $this->db->query( "SELECT COUNT(*) FROM `{$table}`" )->fetch_row()[0]; + } + return $counts; + } + + /** @return array Every table this seeder owns. */ + private function table_names(): array { + $p = $this->prefix; + return array_fill_keys( + array( + "{$p}users", + "{$p}usermeta", + "{$p}posts", + "{$p}postmeta", + "{$p}wpdo_user_hp_user", + "{$p}wpdo_post_hp_vendor_core", + "{$p}wpdo_post_hp_listing_core", + "{$p}wpdo_hot_hp_listing", + ), + true + ); + } + + /** + * Core tables with the columns and indexes WordPress 6.x uses for these queries. + */ + private function create_core_tables(): void { + $p = $this->prefix; + foreach ( array( 'users', 'usermeta', 'posts', 'postmeta' ) as $t ) { + $this->exec( "DROP TABLE IF EXISTS `{$p}{$t}`" ); + } + $this->exec( + "CREATE TABLE `{$p}users` ( + ID bigint(20) unsigned NOT NULL AUTO_INCREMENT, + user_login varchar(60) NOT NULL DEFAULT '', + user_nicename varchar(50) NOT NULL DEFAULT '', + user_email varchar(100) NOT NULL DEFAULT '', + user_registered datetime NOT NULL DEFAULT '0000-00-00 00:00:00', + display_name varchar(250) NOT NULL DEFAULT '', + PRIMARY KEY (ID), + KEY user_login_key (user_login), + KEY user_nicename (user_nicename), + KEY user_email (user_email) + ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci" + ); + $this->exec( + "CREATE TABLE `{$p}usermeta` ( + umeta_id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + user_id bigint(20) unsigned NOT NULL DEFAULT 0, + meta_key varchar(255) DEFAULT NULL, + meta_value longtext, + PRIMARY KEY (umeta_id), + KEY user_id (user_id), + KEY meta_key (meta_key(191)) + ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci" + ); + $this->exec( + "CREATE TABLE `{$p}posts` ( + ID bigint(20) unsigned NOT NULL AUTO_INCREMENT, + post_author bigint(20) unsigned NOT NULL DEFAULT 0, + post_date datetime NOT NULL DEFAULT '0000-00-00 00:00:00', + post_title text NOT NULL, + post_status varchar(20) NOT NULL DEFAULT 'publish', + post_name varchar(200) NOT NULL DEFAULT '', + post_parent bigint(20) unsigned NOT NULL DEFAULT 0, + post_type varchar(20) NOT NULL DEFAULT 'post', + PRIMARY KEY (ID), + KEY post_name (post_name(191)), + KEY type_status_date (post_type,post_status,post_date,ID), + KEY post_parent (post_parent), + KEY post_author (post_author) + ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci" + ); + $this->exec( + "CREATE TABLE `{$p}postmeta` ( + meta_id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + post_id bigint(20) unsigned NOT NULL DEFAULT 0, + meta_key varchar(255) DEFAULT NULL, + meta_value longtext, + PRIMARY KEY (meta_id), + KEY post_id (post_id), + KEY meta_key (meta_key(191)) + ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci" + ); + } + + /** + * Flat tables are created by TMDO's own code so the bench measures the real schema. + */ + private function create_flat_tables(): void { + TMDO_Entity_Registry::init(); + TMDO_Entity_Registry::register_adapter( 'post', new TMDO_Adapter_Post() ); + TMDO_Entity_Registry::register_adapter( 'user', new TMDO_Adapter_User() ); + TMDO_Post_Fields::register_entity_fields(); + TMDO_Member_Fields::register(); + + // Schema_Manager keeps a schema hash per group in wpdo_registry_meta and skips + // creation when it matches, so a rebuilt table needs its hash row cleared too. + TMDO_Installer::install_v2_tables(); + $registry_meta = $this->prefix . TMDO_TABLE_PREFIX . 'registry_meta'; + + foreach ( array( array( 'post', 'hp_listing_core' ), array( 'post', 'hp_vendor_core' ), array( 'user', 'hp_user' ) ) as [ $type, $group ] ) { + $this->exec( 'DROP TABLE IF EXISTS `' . TMDO_Schema_Manager::get_table_name( $type, $group ) . '`' ); + $this->exec( "DELETE FROM `{$registry_meta}` WHERE entity_type = '{$type}' AND group_name = '{$group}'" ); + if ( ! TMDO_Schema_Manager::create_or_upgrade_table( $type, $group, TMDO_Entity_Registry::get_group_fields( $type, $group ) ) ) { + throw new RuntimeException( "Failed to create entity table {$type}/{$group}." ); + } + } + + $this->exec( "DROP TABLE IF EXISTS `{$this->prefix}wpdo_hot_hp_listing`" ); + TMDO_Installer::create_hot_table( 'hp_listing', self::HOT_COLUMNS ); + } + + private function seed_vendors(): void { + $p = $this->prefix; + for ( $v = 1; $v <= $this->vendors; $v++ ) { + $this->push( "{$p}users", array( 'ID', 'user_login', 'user_nicename', 'user_email', 'user_registered', 'display_name' ), array( $v, "vendor_{$v}", "vendor-{$v}", "vendor{$v}@bench.invalid", '2026-01-01 00:00:00', "Vendor {$v}" ) ); + $user_meta = $this->user_meta( $v ); + foreach ( $user_meta as $key => $value ) { + $this->push( "{$p}usermeta", array( 'user_id', 'meta_key', 'meta_value' ), array( $v, $key, $value ) ); + } + $this->push_entity( 'user', 'hp_user', $v, $user_meta ); + + // Vendor post IDs are 1..vendors; listing post IDs follow. + $this->push( "{$p}posts", array( 'ID', 'post_author', 'post_date', 'post_title', 'post_status', 'post_name', 'post_parent', 'post_type' ), array( $v, $v, '2026-01-01 00:00:00', "Vendor {$v}", 'publish', "vendor-{$v}", 0, 'hp_vendor' ) ); + $vendor_meta = $this->vendor_meta( $v ); + foreach ( $vendor_meta as $key => $value ) { + $this->push( "{$p}postmeta", array( 'post_id', 'meta_key', 'meta_value' ), array( $v, $key, $value ) ); + } + $this->push_entity( 'post', 'hp_vendor_core', $v, $vendor_meta ); + } + $this->flush_all(); + } + + private function seed_listings(): void { + $p = $this->prefix; + for ( $i = 1; $i <= $this->listings; $i++ ) { + $post_id = $this->vendors + $i; + $vendor_id = $this->listing_vendor( $i ); + $meta = $this->listing_meta( $i, $vendor_id ); + $date = gmdate( 'Y-m-d H:i:s', self::BASE_TS + $this->det( $i, 'date', 365 * 86400 ) ); + + $this->push( "{$p}posts", array( 'ID', 'post_author', 'post_date', 'post_title', 'post_status', 'post_name', 'post_parent', 'post_type' ), array( $post_id, $vendor_id, $date, "Listing {$i}", $meta['hp_status'], "listing-{$i}", $vendor_id, 'hp_listing' ) ); + foreach ( $meta as $key => $value ) { + $this->push( "{$p}postmeta", array( 'post_id', 'meta_key', 'meta_value' ), array( $post_id, $key, $value ) ); + } + $this->push_entity( 'post', 'hp_listing_core', $post_id, $meta ); + + $hot = array( 'post_id' => $post_id ); + foreach ( array_keys( self::HOT_COLUMNS ) as $col ) { + $hot[ $col ] = $meta[ $col ] ?? 0; + } + $this->push( "{$p}wpdo_hot_hp_listing", array_keys( $hot ), array_values( $hot ) ); + } + $this->flush_all(); + } + + /** + * One entity-table row per entity; registry keys without a value are stored as NULL. + * + * @param array $meta + */ + private function push_entity( string $type, string $group, int $entity_id, array $meta ): void { + $id_col = TMDO_Entity_Registry::get_adapter( $type )->get_entity_id_column(); + $cols = array( $id_col ); + $vals = array( $entity_id ); + foreach ( TMDO_Entity_Registry::get_group_keys( $type, $group ) as $key ) { + $cols[] = TMDO_Schema_Manager::sanitize_column_name( $key ); + $vals[] = $meta[ $key ] ?? null; + } + $this->push( TMDO_Schema_Manager::get_table_name( $type, $group ), $cols, $vals ); + } + + /** + * @param string[] $cols + * @param array $vals + */ + private function push( string $table, array $cols, array $vals ): void { + $escaped = array_map( + fn( $v ) => null === $v ? 'NULL' : ( is_int( $v ) ? (string) $v : "'" . $this->db->real_escape_string( (string) $v ) . "'" ), + $vals + ); + $this->buffers[ $table ]['cols'] = $cols; + $this->buffers[ $table ]['rows'][] = '(' . implode( ',', $escaped ) . ')'; + if ( count( $this->buffers[ $table ]['rows'] ) >= $this->batch ) { + $this->flush( $table ); + } + } + + private function flush( string $table ): void { + if ( empty( $this->buffers[ $table ]['rows'] ) ) { + return; + } + $cols = '`' . implode( '`,`', $this->buffers[ $table ]['cols'] ) . '`'; + $this->exec( "INSERT INTO `{$table}` ({$cols}) VALUES " . implode( ',', $this->buffers[ $table ]['rows'] ) ); + $this->buffers[ $table ]['rows'] = array(); + } + + private function flush_all(): void { + foreach ( array_keys( $this->buffers ) as $table ) { + $this->flush( $table ); + } + } + + private function exec( string $sql ): void { + if ( ! $this->db->query( $sql ) ) { + throw new RuntimeException( 'Bench seed query failed: ' . $this->db->error . ' — ' . substr( $sql, 0, 200 ) ); + } + } +} diff --git a/tests/bench/seed.php b/tests/bench/seed.php new file mode 100644 index 0000000..a00bfa0 --- /dev/null +++ b/tests/bench/seed.php @@ -0,0 +1,51 @@ +getMessage() . PHP_EOL ); + exit( 2 ); +} + +// Schema_Manager / Installer require ABSPATH . 'wp-admin/includes/upgrade.php'. +define( 'ABSPATH', __DIR__ . '/wp-stub/' ); +require dirname( __DIR__ ) . '/integration/bootstrap.php'; + +$started = microtime( true ); +$seeder = new TMDO_Bench_Seeder( $_mysqli, $wpdb->prefix, $vendors, $listings, (int) ( $opts['seed'] ?? 42 ), (int) ( $opts['batch'] ?? 2000 ) ); +$counts = $seeder->run(); + +printf( "Seeded %s: %d vendors, %d listings in %.1fs\n", $db_name, $vendors, $listings, microtime( true ) - $started ); +foreach ( $counts as $table => $rows ) { + printf( " %-40s %12d\n", $table, $rows ); +} diff --git a/tests/bench/wp-stub/wp-admin/includes/upgrade.php b/tests/bench/wp-stub/wp-admin/includes/upgrade.php new file mode 100644 index 0000000..774e3c4 --- /dev/null +++ b/tests/bench/wp-stub/wp-admin/includes/upgrade.php @@ -0,0 +1,9 @@ +expectException( InvalidArgumentException::class ); + TMDO_Bench_Seeder::assert_bench_db( $name ); + } + + public static function rejected_db_names(): array { + return array( + 'production site' => array( 'wp_2meet_pro_dev21' ), + 'integration db' => array( 'wp_wpdo_test' ), + 'bench not suffix' => array( 'wp_bench_prod' ), + 'empty' => array( '' ), + 'injection' => array( 'x_bench`; DROP DATABASE y; -- _bench' ), + ); + } + + public function test_accepts_bench_database(): void { + TMDO_Bench_Seeder::assert_bench_db( 'wp_wpdo_bench' ); + $this->addToAssertionCount( 1 ); + } + + public function test_count_bounds(): void { + TMDO_Bench_Seeder::assert_count( 'listings', TMDO_Bench_Seeder::MAX_ROWS ); + $this->addToAssertionCount( 1 ); + + foreach ( array( 0, -5, TMDO_Bench_Seeder::MAX_ROWS + 1 ) as $bad ) { + try { + TMDO_Bench_Seeder::assert_count( 'listings', $bad ); + $this->fail( "Accepted out-of-range count {$bad}" ); + } catch ( InvalidArgumentException $e ) { + $this->addToAssertionCount( 1 ); + } + } + } + + public function test_generators_are_deterministic_per_seed(): void { + $a = new TMDO_Bench_Seeder( null, 'wp_itest_', 100, 1000, 42 ); + $b = new TMDO_Bench_Seeder( null, 'wp_itest_', 100, 1000, 42 ); + $c = new TMDO_Bench_Seeder( null, 'wp_itest_', 100, 1000, 7 ); + + $this->assertSame( $a->listing_meta( 123, 5 ), $b->listing_meta( 123, 5 ) ); + $this->assertSame( $a->vendor_meta( 9 ), $b->vendor_meta( 9 ) ); + $this->assertNotSame( $a->listing_meta( 123, 5 ), $c->listing_meta( 123, 5 ) ); + } + + public function test_listing_distribution_matches_spec(): void { + $seeder = new TMDO_Bench_Seeder( null, 'wp_itest_', 1000, 10000 ); + $statuses = array(); + $featured = 0; + + for ( $i = 1; $i <= 10000; $i++ ) { + $vendor = $seeder->listing_vendor( $i ); + $this->assertGreaterThanOrEqual( 1, $vendor ); + $this->assertLessThanOrEqual( 1000, $vendor ); + + $meta = $seeder->listing_meta( $i, $vendor ); + $statuses[ $meta['hp_status'] ] = ( $statuses[ $meta['hp_status'] ] ?? 0 ) + 1; + $featured += $meta['hp_featured']; + $this->assertSame( (bool) $meta['hp_featured'], isset( $meta['hp_featured_time'] ) ); + } + + ksort( $statuses ); + $this->assertSame( array( 'draft', 'pending', 'private', 'publish' ), array_keys( $statuses ) ); + $this->assertEqualsWithDelta( 9000, $statuses['publish'], 300 ); + $this->assertEqualsWithDelta( 500, $featured, 100 ); + } +}