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 );