From 0232e45c658b82cabf0cb1c4f0f1d26585deba97 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Fri, 11 Sep 2026 10:18:14 -0400 Subject: [PATCH] fix: write child posts to their canonical hierarchical path The native runtime built its shared storage without a post resolver. Every other backend wires one. resolve_parent_dir() needs it to walk a post's ancestor chain, so without it the writer returns the post-type root immediately and a child lands flat while its row still records the parent. The canonical path then disagrees with the hierarchy the row declares, and a reader that derives the path from the parent chain cannot find the file. Resolve through WP_Markdown_Storage::read_post(), which reads the post out of the corpus. get_post() would work under WordPress and fatal anywhere else, and the runtime has no WordPress to depend on. A residual predicate is also matched in the executor after the provider read, but the provider projection was built only from the select list, the scalar and boolean predicates, and the subqueries. A provider that resolves a column lazily returns it empty when it is absent from the projection, so a body predicate matched against an empty string and selected nothing. Even LIKE '%' returned no rows. The regression writes a child under a parent, asserts the file lands in the parent directory, and asserts the child's body is searchable there. It fails on the prior resolver. --- ...lass-wp-markdown-native-query-executor.php | 5 ++++ ...class-wp-markdown-native-query-runtime.php | 9 +++++- tests/smoke-native-markdown-post-write.php | 29 +++++++++++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/inc/native/class-wp-markdown-native-query-executor.php b/inc/native/class-wp-markdown-native-query-executor.php index 688164f..3a13dbc 100644 --- a/inc/native/class-wp-markdown-native-query-executor.php +++ b/inc/native/class-wp-markdown-native-query-executor.php @@ -564,6 +564,11 @@ private function execute_query_plan( WP_Markdown_Native_Query_Plan $plan, bool $ ? array() : array_values( array_filter( $predicates, static fn( WP_Markdown_Native_Query_Predicate $predicate ): bool => $predicate !== $pushdown ) ); $provider_projection = $plan->counts_all() ? array() : array_merge( $projection, $scalar_columns ); + // A residual predicate is matched here, after the provider read, so the + // provider has to return the columns it reads. A provider that resolves + // a column lazily returns it empty when it is absent from the + // projection, and the residual then matches against that empty value. + foreach ( $residual as $predicate ) { $provider_projection = array_merge( $provider_projection, $predicate->columns() ); } foreach ( $scalar_predicates as $predicate ) { $provider_projection = array_merge( $provider_projection, $predicate->columns() ); } if ( null !== $boolean_predicate ) { $provider_projection = array_merge( $provider_projection, $boolean_predicate->columns() ); } foreach ( array_merge( $plan->subqueries(), $this->boolean_subqueries( $boolean_predicate ) ) as $subquery ) { diff --git a/inc/native/class-wp-markdown-native-query-runtime.php b/inc/native/class-wp-markdown-native-query-runtime.php index 9544f92..94c0fd7 100644 --- a/inc/native/class-wp-markdown-native-query-runtime.php +++ b/inc/native/class-wp-markdown-native-query-runtime.php @@ -408,7 +408,14 @@ private static function shared_storage( string $content_root, bool $network_root $key = ( $network_root ? 'network:' : 'site:' ) . rtrim( $content_root, '/\\' ); if ( ! isset( self::$storages[ $key ] ) ) { // The network root owns sites/{blog_id}; it is not a post-type tree. - self::$storages[ $key ] = new WP_Markdown_Storage( $content_root, $network_root ? array( 'sites' ) : array() ); + $storage = new WP_Markdown_Storage( $content_root, $network_root ? array( 'sites' ) : array() ); + // A post with a parent belongs inside its parent's directory, and + // the writer walks the ancestor chain to find that directory. Without + // a resolver it cannot read an ancestor, so it writes the child flat + // at the post-type root while the row still records the parent. The + // path then disagrees with the hierarchy the row declares. + $storage->set_post_resolver( static fn( int $post_id ): ?object => $storage->read_post( $post_id ) ); + self::$storages[ $key ] = $storage; } return self::$storages[ $key ]; } diff --git a/tests/smoke-native-markdown-post-write.php b/tests/smoke-native-markdown-post-write.php index a42c72b..fd13dd0 100644 --- a/tests/smoke-native-markdown-post-write.php +++ b/tests/smoke-native-markdown-post-write.php @@ -50,6 +50,31 @@ $wp_id = (int) $wp_insert->wpdb_state()['insert_id']; $wp_read = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT post_title, comment_count FROM wp_posts WHERE ID = ' . $wp_id, 'wp_' ) ); +// A child post belongs inside its parent's directory. The writer walks the +// ancestor chain through the runtime's post resolver to find that directory, +// so a runtime without one writes the child flat while the row still records +// the parent, and the canonical path then disagrees with the hierarchy. +$parent_insert = $runtime->execute( + new WP_Markdown_Query_Request( + "INSERT INTO wp_posts (post_author, post_date, post_date_gmt, post_content, post_title, post_excerpt, post_status, comment_status, ping_status, post_password, post_name, to_ping, pinged, post_modified, post_modified_gmt, post_content_filtered, post_parent, guid, menu_order, post_type, post_mime_type, comment_count) VALUES (1, '2026-08-27 12:00:00', '2026-08-27 12:00:00', 'Parent body', 'Parent', '', 'publish', 'open', 'open', '', 'parent-slug', '', '', '2026-08-27 12:00:00', '2026-08-27 12:00:00', '', 0, 'http://localhost/parent-slug/', 0, 'post', '', 0)", + 'wp_' + ) +); +$parent_id = (int) ( $parent_insert->wpdb_state()['insert_id'] ?? 0 ); +$child_insert = $runtime->execute( + new WP_Markdown_Query_Request( + "INSERT INTO wp_posts (post_author, post_date, post_date_gmt, post_content, post_title, post_excerpt, post_status, comment_status, ping_status, post_password, post_name, to_ping, pinged, post_modified, post_modified_gmt, post_content_filtered, post_parent, guid, menu_order, post_type, post_mime_type, comment_count) VALUES (1, '2026-08-27 12:00:00', '2026-08-27 12:00:00', 'Child body needle', 'Child', '', 'publish', 'open', 'open', '', 'child-slug', '', '', '2026-08-27 12:00:00', '2026-08-27 12:00:00', '', {$parent_id}, 'http://localhost/child-slug/', 0, 'post', '', 0)", + 'wp_' + ) +); +$child_id = (int) ( $child_insert->wpdb_state()['insert_id'] ?? 0 ); +$child_files = array_values( array_filter( $markdown_files( $content ), static fn( string $path ): bool => str_ends_with( $path, 'child-slug.md' ) ) ); +$child_nested = array() !== $child_files && str_ends_with( $child_files[0], '/post/parent-slug/child-slug.md' ); +// A body predicate is matched after the provider read, so a child whose file +// the provider cannot resolve returns empty content and matches nothing. +$child_body = $runtime->execute( new WP_Markdown_Query_Request( "SELECT ID FROM wp_posts WHERE post_content LIKE '%needle%'", 'wp_' ) ); +$child_body_ids = array_map( static fn( object $row ): int => (int) $row->ID, $child_body->wpdb_state()['last_result'] ?? array() ); + $checks = array( 'an INSERT assigns an identity and writes markdown' => 1 === $insert->return_value() && 1 === $insert->wpdb_state()['insert_id'] @@ -62,6 +87,8 @@ 'a DELETE removes the canonical file' => 1 === $delete->return_value() && 0 === $after_delete->return_value() && array() === $files_after_delete, + 'a child post is written inside its parent directory' => $parent_id > 0 && $child_id > 0 && $child_nested, + 'a child post body is searchable at its canonical path' => in_array( $child_id, $child_body_ids, true ), 'a WordPress wp_insert_post row fills integer defaults' => 1 === $wp_insert->return_value() && $wp_id > 0 && 'Native Save Probe' === ( $wp_read->wpdb_state()['last_result'][0]->post_title ?? null ) @@ -74,6 +101,8 @@ $failed = $failed || ! $passed; } +array_map( 'unlink', glob( $content . '/post/parent-slug/*' ) ?: array() ); +@rmdir( $content . '/post/parent-slug' ); array_map( 'unlink', glob( $content . '/post/*' ) ?: array() ); @rmdir( $content . '/post' ); @rmdir( $content );