From 81abe8a147ed383598d68763cf4d46ca578d4c59 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 21:11:43 +0000 Subject: [PATCH 1/6] Load terms, meta and comments for batches of posts Exporting a post ran separate queries for its terms, its meta, its comments and each comment's meta. Load all of them for the batch of posts that is already fetched together instead, which cuts the number of queries per batch of 100 posts from several hundred to about six. The output is unchanged: terms, meta and comments are ordered the same way as the per-post queries order them, `_edit_lock` and the `wxr_export_skip_postmeta` filter are still respected, and spam comments are still skipped. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01D26yjkN2BiqCXT6p6o1WqS --- features/export.feature | 64 ++++++++++++++++++ src/WP_Export_Query.php | 140 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 201 insertions(+), 3 deletions(-) diff --git a/features/export.feature b/features/export.feature index c1d21a3e..d58e9197 100644 --- a/features/export.feature +++ b/features/export.feature @@ -1365,3 +1365,67 @@ Feature: Export content. """ """ + + Scenario: Export attaches terms, meta and comments to the right posts across batches + Given a WP install + And I run `wp site empty --yes` + And I run `wp post generate --count=150` + And I run `wp post create --post_title='Last post' --post_status=publish --tags_input='alpha,beta' --porcelain` + And save STDOUT as {LAST_POST_ID} + And I run `wp post meta add {LAST_POST_ID} my_meta_key my_meta_value` + And I run `wp post meta add {LAST_POST_ID} _edit_lock 123:1` + And I run `wp comment create --comment_post_ID={LAST_POST_ID} --comment_content='Approved comment' --comment_approved=1 --porcelain` + And save STDOUT as {COMMENT_ID} + And I run `wp comment meta add {COMMENT_ID} my_comment_meta_key my_comment_meta_value` + And I run `wp comment create --comment_post_ID={LAST_POST_ID} --comment_content='Spam comment' --comment_approved=spam` + And a check-export.php file: + """ + channel->item as $item ) { + $wp = $item->children( 'http://wordpress.org/export/1.2/' ); + $tags = array(); + foreach ( $item->category as $category ) { + if ( 'post_tag' === (string) $category['domain'] ) { + $tags[] = (string) $category['nicename']; + } + } + $meta = array(); + foreach ( $wp->postmeta as $postmeta ) { + if ( in_array( (string) $postmeta->meta_key, array( '_pingme', '_encloseme' ), true ) ) { + continue; + } + $meta[] = (string) $postmeta->meta_key . '=' . (string) $postmeta->meta_value; + } + $comments = array(); + foreach ( $wp->comment as $comment ) { + $comment_meta = array(); + foreach ( $comment->commentmeta as $commentmeta ) { + $comment_meta[] = (string) $commentmeta->meta_key . '=' . (string) $commentmeta->meta_value; + } + $comments[] = (string) $comment->comment_content . ' [' . implode( ',', $comment_meta ) . ']'; + } + if ( (string) $wp->post_id === '{LAST_POST_ID}' ) { + ++$found; + echo 'tags: ' . implode( ',', $tags ) . "\n"; + echo 'meta: ' . implode( ',', $meta ) . "\n"; + echo 'comments: ' . implode( ',', $comments ) . "\n"; + } elseif ( $tags || $meta || $comments ) { + echo 'unexpected data on post ' . $wp->post_id . "\n"; + } + } + echo "found: $found\n"; + """ + + When I run `wp export --post_type=post` + Then save STDOUT 'Writing to file %s' as {EXPORT_FILE} + + When I run `wp eval-file check-export.php {EXPORT_FILE}` + Then STDOUT should be: + """ + tags: alpha,beta + meta: my_meta_key=my_meta_value + comments: Approved comment [my_comment_meta_key=my_comment_meta_value] + found: 1 + """ diff --git a/src/WP_Export_Query.php b/src/WP_Export_Query.php index 0d9c7925..26072f74 100644 --- a/src/WP_Export_Query.php +++ b/src/WP_Export_Query.php @@ -34,6 +34,20 @@ class WP_Export_Query { public $missing_parents = false; + /** + * Terms, meta and comments of the posts in the current batch, keyed by post ID. + * + * @var array, meta: array, comments: array}> + */ + private $batch_data = []; + + /** + * Positions of the post IDs in $post_ids, to find the batch a post belongs to. + * + * @var array|null + */ + private $post_id_positions; + public function __construct( $filters = [] ) { $this->filters = wp_parse_args( $filters, self::$defaults ); @@ -156,9 +170,21 @@ public function exportify_post( $post ) { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Calling native WordPress hook. $post->post_excerpt = apply_filters( 'the_excerpt_export', $post->post_excerpt ); $post->is_sticky = is_sticky( $post->ID ) ? 1 : 0; - $post->terms = self::get_terms_for_post( $post ); - $post->meta = self::get_meta_for_post( $post ); - $post->comments = $this->get_comments_for_post( $post ); + + if ( ! isset( $this->batch_data[ $post->ID ] ) ) { + $this->load_batch_data( $post->ID ); + } + + if ( isset( $this->batch_data[ $post->ID ] ) ) { + $post->terms = $this->batch_data[ $post->ID ]['terms']; + $post->meta = $this->batch_data[ $post->ID ]['meta']; + $post->comments = $this->batch_data[ $post->ID ]['comments']; + unset( $this->batch_data[ $post->ID ] ); + } else { + $post->terms = self::get_terms_for_post( $post ); + $post->meta = self::get_meta_for_post( $post ); + $post->comments = $this->get_comments_for_post( $post ); + } // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Revert back to original. $GLOBALS['post'] = $previous_global_post; return $post; @@ -413,6 +439,114 @@ private function process_orphaned_terms( $terms ) { return $terms; } + /** + * Load the terms, meta and comments of the batch of posts starting with the given one. + * + * Querying them for a whole batch at once avoids several queries per exported post. + * The data is the same as what get_terms_for_post(), get_meta_for_post() and + * get_comments_for_post() return for each post. + * + * @param int $post_id ID of the first post of the batch. + */ + private function load_batch_data( $post_id ) { + global $wpdb; + + if ( null === $this->post_id_positions ) { + $this->post_id_positions = array_flip( array_map( 'intval', $this->post_ids ) ); + } + + $this->batch_data = []; + if ( ! isset( $this->post_id_positions[ $post_id ] ) ) { + return; + } + + $post_ids = array_map( 'intval', array_slice( $this->post_ids, $this->post_id_positions[ $post_id ], self::QUERY_CHUNK ) ); + $in_ids = implode( ',', $post_ids ); + + $terms_by_post = array_fill_keys( $post_ids, [] ); + $meta_by_post = array_fill_keys( $post_ids, [] ); + $comments_by_post = array_fill_keys( $post_ids, [] ); + + // Terms, queried per post type as the taxonomies depend on it. + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- integers only + $post_types = $wpdb->get_results( "SELECT ID, post_type FROM $wpdb->posts WHERE ID IN ($in_ids)" ); + $ids_by_type = []; + foreach ( $post_types as $row ) { + $ids_by_type[ $row->post_type ][] = (int) $row->ID; + } + foreach ( $ids_by_type as $post_type => $ids ) { + $taxonomies = get_object_taxonomies( $post_type ); + if ( empty( $taxonomies ) ) { + continue; + } + $terms = wp_get_object_terms( $ids, $taxonomies, [ 'fields' => 'all_with_object_id' ] ); + if ( is_wp_error( $terms ) ) { + continue; + } + foreach ( $terms as $term ) { + if ( ! $term instanceof \WP_Term ) { + continue; + } + // Set by wp_get_object_terms() for 'all_with_object_id'. + $object_id = (int) $term->object_id; // @phpstan-ignore property.notFound + if ( isset( $terms_by_post[ $object_id ] ) ) { + $terms_by_post[ $object_id ][] = $term; + } + } + } + + // Terms are ordered by name. Order terms with the same name, e.g. a category and a tag, + // by their term_taxonomy_id like the query for a single post returns them. + foreach ( $terms_by_post as &$terms ) { + usort( + $terms, + static function ( $a, $b ) { + return [ $a->name, $a->term_taxonomy_id ] <=> [ $b->name, $b->term_taxonomy_id ]; + } + ); + } + unset( $terms ); + + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- integers only + $meta_from_db = $wpdb->get_results( "SELECT * FROM $wpdb->postmeta WHERE post_id IN ($in_ids) ORDER BY post_id, meta_id" ); + foreach ( $meta_from_db as $meta ) { + if ( '_edit_lock' === $meta->meta_key ) { + continue; + } + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Calling native WordPress hook. + if ( apply_filters( 'wxr_export_skip_postmeta', false, $meta->meta_key, $meta ) ) { + continue; + } + $meta_by_post[ (int) $meta->post_id ][] = $meta; + } + + if ( ! isset( $this->filters['skip_comments'] ) ) { + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- integers only + $comments = $wpdb->get_results( "SELECT * FROM $wpdb->comments WHERE comment_post_ID IN ($in_ids) AND comment_approved <> 'spam' ORDER BY comment_post_ID, comment_ID" ); + if ( ! empty( $comments ) ) { + $comment_meta = []; + $comment_ids = implode( ',', array_map( 'intval', wp_list_pluck( $comments, 'comment_ID' ) ) ); + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- integers only + foreach ( $wpdb->get_results( "SELECT * FROM $wpdb->commentmeta WHERE comment_id IN ($comment_ids) ORDER BY comment_id, meta_id" ) as $meta ) { + $comment_meta[ (int) $meta->comment_id ][] = $meta; + } + + foreach ( $comments as $comment ) { + $comment->meta = isset( $comment_meta[ (int) $comment->comment_ID ] ) ? $comment_meta[ (int) $comment->comment_ID ] : []; + $comments_by_post[ (int) $comment->comment_post_ID ][] = $comment; + } + } + } + + foreach ( $post_ids as $id ) { + $this->batch_data[ $id ] = [ + 'terms' => $terms_by_post[ $id ], + 'meta' => $meta_by_post[ $id ], + 'comments' => $comments_by_post[ $id ], + ]; + } + } + private static function get_terms_for_post( $post ) { $taxonomies = get_object_taxonomies( $post->post_type ); if ( empty( $taxonomies ) ) { From fdd9b15dd94b18b6649892d14476687989abbd00 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 06:22:11 +0000 Subject: [PATCH 2/6] Use the already loaded comment meta in the WXR formatter Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01D26yjkN2BiqCXT6p6o1WqS --- src/WP_Export_WXR_Formatter.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/WP_Export_WXR_Formatter.php b/src/WP_Export_WXR_Formatter.php index 870eeb74..0a6779d8 100644 --- a/src/WP_Export_WXR_Formatter.php +++ b/src/WP_Export_WXR_Formatter.php @@ -288,7 +288,10 @@ protected function terms( $terms ) { protected function comment_meta( $comment ) { global $wpdb; - $metas = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM $wpdb->commentmeta WHERE comment_id = %d", $comment->comment_ID ) ); + // The export query already loads the comment meta, see WP_Export_Query::exportify_post(). + $metas = isset( $comment->meta ) && is_array( $comment->meta ) + ? $comment->meta + : $wpdb->get_results( $wpdb->prepare( "SELECT * FROM $wpdb->commentmeta WHERE comment_id = %d", $comment->comment_ID ) ); if ( ! $metas ) { return new Oxymel(); } From 3c784165083a3bfac71e025e310edf0c8010c0ec Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 06:34:28 +0000 Subject: [PATCH 3/6] Filter post meta while its post is the global post Callbacks of the `wxr_export_skip_postmeta` filter can rely on the global post, which is only set to the post the meta belongs to in exportify_post(). Load the meta for the whole batch, but filter it there instead of while loading the batch. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01D26yjkN2BiqCXT6p6o1WqS --- features/export.feature | 13 +++++++++++++ src/WP_Export_Query.php | 28 ++++++++++++++++++---------- 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/features/export.feature b/features/export.feature index d58e9197..83238a36 100644 --- a/features/export.feature +++ b/features/export.feature @@ -1378,6 +1378,19 @@ Feature: Export content. And save STDOUT as {COMMENT_ID} And I run `wp comment meta add {COMMENT_ID} my_comment_meta_key my_comment_meta_value` And I run `wp comment create --comment_post_ID={LAST_POST_ID} --comment_content='Spam comment' --comment_approved=spam` + And a wp-content/mu-plugins/skip-meta.php file: + """ + post_id !== get_the_ID(); + }, + 10, + 3 + ); + """ And a check-export.php file: """ , meta: array, comments: array}> + * @var array, meta: array<\stdClass>, comments: array<\stdClass>}> */ private $batch_data = []; @@ -177,7 +177,7 @@ public function exportify_post( $post ) { if ( isset( $this->batch_data[ $post->ID ] ) ) { $post->terms = $this->batch_data[ $post->ID ]['terms']; - $post->meta = $this->batch_data[ $post->ID ]['meta']; + $post->meta = self::filter_meta_for_export( $this->batch_data[ $post->ID ]['meta'] ); $post->comments = $this->batch_data[ $post->ID ]['comments']; unset( $this->batch_data[ $post->ID ] ); } else { @@ -444,7 +444,7 @@ private function process_orphaned_terms( $terms ) { * * Querying them for a whole batch at once avoids several queries per exported post. * The data is the same as what get_terms_for_post(), get_meta_for_post() and - * get_comments_for_post() return for each post. + * get_comments_for_post() return for each post, except that the meta is not filtered yet. * * @param int $post_id ID of the first post of the batch. */ @@ -509,14 +509,8 @@ static function ( $a, $b ) { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- integers only $meta_from_db = $wpdb->get_results( "SELECT * FROM $wpdb->postmeta WHERE post_id IN ($in_ids) ORDER BY post_id, meta_id" ); + // The meta is filtered in exportify_post(), while the post it belongs to is the global post. foreach ( $meta_from_db as $meta ) { - if ( '_edit_lock' === $meta->meta_key ) { - continue; - } - // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Calling native WordPress hook. - if ( apply_filters( 'wxr_export_skip_postmeta', false, $meta->meta_key, $meta ) ) { - continue; - } $meta_by_post[ (int) $meta->post_id ][] = $meta; } @@ -559,6 +553,20 @@ private static function get_meta_for_post( $post ) { global $wpdb; $meta_for_export = []; $meta_from_db = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM $wpdb->postmeta WHERE post_id = %d", $post->ID ) ); + return self::filter_meta_for_export( $meta_from_db ); + } + + /** + * Remove the meta that should not be exported. + * + * Called while the post the meta belongs to is the global post, as callbacks + * of the `wxr_export_skip_postmeta` filter may rely on it. + * + * @param array<\stdClass> $meta_from_db Meta rows of a post. + * @return array<\stdClass> + */ + private static function filter_meta_for_export( $meta_from_db ) { + $meta_for_export = []; foreach ( $meta_from_db as $meta ) { if ( '_edit_lock' === $meta->meta_key ) { continue; From 600421d69706b083d81c383dbe6143fd3bd5be58 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 07:51:10 +0000 Subject: [PATCH 4/6] Align batches with the posts iterator and keep the database term order The posts iterator queries chunks of post IDs without an ORDER BY, so the first post it returns is not necessarily the first ID of its chunk. Start the batch at the chunk boundary, as otherwise e.g. an export of `--post__in` IDs in descending order loaded a batch for every post. Terms come back from the database ordered by name in its collation, so keep that order and only order terms with the same name by their term_taxonomy_id, instead of sorting them by name in PHP. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01D26yjkN2BiqCXT6p6o1WqS --- features/export.feature | 9 +++++++-- src/WP_Export_Query.php | 45 ++++++++++++++++++++++++++++++++--------- 2 files changed, 43 insertions(+), 11 deletions(-) diff --git a/features/export.feature b/features/export.feature index 83238a36..6422db8e 100644 --- a/features/export.feature +++ b/features/export.feature @@ -1370,7 +1370,7 @@ Feature: Export content. Given a WP install And I run `wp site empty --yes` And I run `wp post generate --count=150` - And I run `wp post create --post_title='Last post' --post_status=publish --tags_input='alpha,beta' --porcelain` + And I run `wp post create --post_title='Last post' --post_status=publish --tags_input='Zoo,apple,beta' --porcelain` And save STDOUT as {LAST_POST_ID} And I run `wp post meta add {LAST_POST_ID} my_meta_key my_meta_value` And I run `wp post meta add {LAST_POST_ID} _edit_lock 123:1` @@ -1421,6 +1421,10 @@ Feature: Export content. } if ( (string) $wp->post_id === '{LAST_POST_ID}' ) { ++$found; + // The terms should be in the same order as the database returns them for the post. + $expected = wp_list_pluck( wp_get_object_terms( (int) $wp->post_id, 'post_tag' ), 'slug' ); + echo 'tags in database order: ' . ( $tags === $expected ? 'yes' : 'no' ) . "\n"; + sort( $tags ); echo 'tags: ' . implode( ',', $tags ) . "\n"; echo 'meta: ' . implode( ',', $meta ) . "\n"; echo 'comments: ' . implode( ',', $comments ) . "\n"; @@ -1437,7 +1441,8 @@ Feature: Export content. When I run `wp eval-file check-export.php {EXPORT_FILE}` Then STDOUT should be: """ - tags: alpha,beta + tags in database order: yes + tags: apple,beta,zoo meta: my_meta_key=my_meta_value comments: Approved comment [my_comment_meta_key=my_comment_meta_value] found: 1 diff --git a/src/WP_Export_Query.php b/src/WP_Export_Query.php index 5ec45dcf..fbd2f20f 100644 --- a/src/WP_Export_Query.php +++ b/src/WP_Export_Query.php @@ -460,7 +460,9 @@ private function load_batch_data( $post_id ) { return; } - $post_ids = array_map( 'intval', array_slice( $this->post_ids, $this->post_id_positions[ $post_id ], self::QUERY_CHUNK ) ); + // Load the same chunk of posts that the posts iterator queries, which returns them in no particular order. + $start = intdiv( $this->post_id_positions[ $post_id ], self::QUERY_CHUNK ) * self::QUERY_CHUNK; + $post_ids = array_map( 'intval', array_slice( $this->post_ids, $start, self::QUERY_CHUNK ) ); $in_ids = implode( ',', $post_ids ); $terms_by_post = array_fill_keys( $post_ids, [] ); @@ -495,15 +497,10 @@ private function load_batch_data( $post_id ) { } } - // Terms are ordered by name. Order terms with the same name, e.g. a category and a tag, - // by their term_taxonomy_id like the query for a single post returns them. + // The terms are ordered by name in the database's collation. Order terms with the same name, + // e.g. a category and a tag, by their term_taxonomy_id like the query for a single post returns them. foreach ( $terms_by_post as &$terms ) { - usort( - $terms, - static function ( $a, $b ) { - return [ $a->name, $a->term_taxonomy_id ] <=> [ $b->name, $b->term_taxonomy_id ]; - } - ); + $terms = self::order_terms_with_same_name( $terms ); } unset( $terms ); @@ -541,6 +538,36 @@ static function ( $a, $b ) { } } + /** + * Order runs of terms with the same name by their term_taxonomy_id, keeping the order of the runs. + * + * @param array<\WP_Term> $terms Terms ordered by name. + * @return array<\WP_Term> + */ + private static function order_terms_with_same_name( $terms ) { + $ordered = []; + $count = count( $terms ); + $i = 0; + while ( $i < $count ) { + $run = [ $terms[ $i ] ]; + ++$i; + while ( $i < $count && $terms[ $i ]->name === $run[0]->name ) { + $run[] = $terms[ $i ]; + ++$i; + } + if ( count( $run ) > 1 ) { + usort( + $run, + static function ( $a, $b ) { + return $a->term_taxonomy_id <=> $b->term_taxonomy_id; + } + ); + } + array_push( $ordered, ...$run ); + } + return $ordered; + } + private static function get_terms_for_post( $post ) { $taxonomies = get_object_taxonomies( $post->post_type ); if ( empty( $taxonomies ) ) { From 6057e09e08c48fd089deb8e798ad16c9f1fa24c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 07:59:37 +0000 Subject: [PATCH 5/6] Test the order of terms with the same name across batches Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01D26yjkN2BiqCXT6p6o1WqS --- features/export.feature | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/features/export.feature b/features/export.feature index 6422db8e..adedae09 100644 --- a/features/export.feature +++ b/features/export.feature @@ -1372,6 +1372,8 @@ Feature: Export content. And I run `wp post generate --count=150` And I run `wp post create --post_title='Last post' --post_status=publish --tags_input='Zoo,apple,beta' --porcelain` And save STDOUT as {LAST_POST_ID} + And I run `wp term create category Zoo --slug=zoo-category` + And I run `wp post term add {LAST_POST_ID} category zoo-category` And I run `wp post meta add {LAST_POST_ID} my_meta_key my_meta_value` And I run `wp post meta add {LAST_POST_ID} _edit_lock 123:1` And I run `wp comment create --comment_post_ID={LAST_POST_ID} --comment_content='Approved comment' --comment_approved=1 --porcelain` @@ -1399,10 +1401,12 @@ Feature: Export content. foreach ( $xml->channel->item as $item ) { $wp = $item->children( 'http://wordpress.org/export/1.2/' ); $tags = array(); + $terms = array(); foreach ( $item->category as $category ) { if ( 'post_tag' === (string) $category['domain'] ) { $tags[] = (string) $category['nicename']; } + $terms[] = (string) $category['nicename']; } $meta = array(); foreach ( $wp->postmeta as $postmeta ) { @@ -1422,13 +1426,13 @@ Feature: Export content. if ( (string) $wp->post_id === '{LAST_POST_ID}' ) { ++$found; // The terms should be in the same order as the database returns them for the post. - $expected = wp_list_pluck( wp_get_object_terms( (int) $wp->post_id, 'post_tag' ), 'slug' ); - echo 'tags in database order: ' . ( $tags === $expected ? 'yes' : 'no' ) . "\n"; + $expected = wp_list_pluck( wp_get_object_terms( (int) $wp->post_id, get_object_taxonomies( 'post' ) ), 'slug' ); + echo 'terms in database order: ' . ( $terms === $expected ? 'yes' : 'no' ) . "\n"; sort( $tags ); echo 'tags: ' . implode( ',', $tags ) . "\n"; echo 'meta: ' . implode( ',', $meta ) . "\n"; echo 'comments: ' . implode( ',', $comments ) . "\n"; - } elseif ( $tags || $meta || $comments ) { + } elseif ( $tags || $meta || $comments || array( 'uncategorized' ) !== $terms ) { echo 'unexpected data on post ' . $wp->post_id . "\n"; } } @@ -1441,7 +1445,7 @@ Feature: Export content. When I run `wp eval-file check-export.php {EXPORT_FILE}` Then STDOUT should be: """ - tags in database order: yes + terms in database order: yes tags: apple,beta,zoo meta: my_meta_key=my_meta_value comments: Approved comment [my_comment_meta_key=my_comment_meta_value] From d7ebb20fd02178324ceaa7a7ceb60f5c4ab00529 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 08:20:14 +0000 Subject: [PATCH 6/6] Find the batch of a post by its position in the list of post IDs `--post__in` IDs are deduplicated with array_unique() and array_filter(), which keep the original keys. Flipping that array mapped IDs to keys rather than positions, so with duplicate IDs batches started at the wrong offset or past the end of the list, which queried `IN ()`. Also compare term names rather than slugs in the multi-batch scenario, as the database can return terms with the same name in any order. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01D26yjkN2BiqCXT6p6o1WqS --- features/export.feature | 56 ++++++++++++++++++++++++++++++++++++++--- src/WP_Export_Query.php | 7 ++++-- 2 files changed, 57 insertions(+), 6 deletions(-) diff --git a/features/export.feature b/features/export.feature index adedae09..ebdde6d9 100644 --- a/features/export.feature +++ b/features/export.feature @@ -1406,7 +1406,7 @@ Feature: Export content. if ( 'post_tag' === (string) $category['domain'] ) { $tags[] = (string) $category['nicename']; } - $terms[] = (string) $category['nicename']; + $terms[] = (string) $category; } $meta = array(); foreach ( $wp->postmeta as $postmeta ) { @@ -1425,14 +1425,15 @@ Feature: Export content. } if ( (string) $wp->post_id === '{LAST_POST_ID}' ) { ++$found; - // The terms should be in the same order as the database returns them for the post. - $expected = wp_list_pluck( wp_get_object_terms( (int) $wp->post_id, get_object_taxonomies( 'post' ) ), 'slug' ); + // The terms should be in the same order as the database returns them for the post. Compare + // their names, as the database can return terms with the same name in any order. + $expected = wp_list_pluck( wp_get_object_terms( (int) $wp->post_id, get_object_taxonomies( 'post' ) ), 'name' ); echo 'terms in database order: ' . ( $terms === $expected ? 'yes' : 'no' ) . "\n"; sort( $tags ); echo 'tags: ' . implode( ',', $tags ) . "\n"; echo 'meta: ' . implode( ',', $meta ) . "\n"; echo 'comments: ' . implode( ',', $comments ) . "\n"; - } elseif ( $tags || $meta || $comments || array( 'uncategorized' ) !== $terms ) { + } elseif ( $tags || $meta || $comments || array( 'Uncategorized' ) !== $terms ) { echo 'unexpected data on post ' . $wp->post_id . "\n"; } } @@ -1451,3 +1452,50 @@ Feature: Export content. comments: Approved comment [my_comment_meta_key=my_comment_meta_value] found: 1 """ + + # Duplicate IDs in --post__in leave gaps in the keys of the list of post IDs. + When I run `wp post list --post_type=post --orderby=ID --order=ASC --format=ids` + Then save STDOUT as {POST_IDS} + + When I run `wp eval 'echo implode( ",", array_merge( ...array_map( function ( $id ) { return array( $id, $id ); }, explode( " ", "{POST_IDS}" ) ) ) );'` + Then save STDOUT as {DUPLICATED_POST_IDS} + + Given a wp-content/mu-plugins/count-batches.php file: + """ + post_id_positions ) { - $this->post_id_positions = array_flip( array_map( 'intval', $this->post_ids ) ); + $this->post_id_positions = array_flip( array_map( 'intval', array_values( $this->post_ids ) ) ); } $this->batch_data = []; @@ -463,7 +463,10 @@ private function load_batch_data( $post_id ) { // Load the same chunk of posts that the posts iterator queries, which returns them in no particular order. $start = intdiv( $this->post_id_positions[ $post_id ], self::QUERY_CHUNK ) * self::QUERY_CHUNK; $post_ids = array_map( 'intval', array_slice( $this->post_ids, $start, self::QUERY_CHUNK ) ); - $in_ids = implode( ',', $post_ids ); + if ( ! $post_ids ) { + return; + } + $in_ids = implode( ',', $post_ids ); $terms_by_post = array_fill_keys( $post_ids, [] ); $meta_by_post = array_fill_keys( $post_ids, [] );