From 80d42c3337fae01b56bf3e93330fc12f224cfc22 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 9 Sep 2026 07:40:53 -0400 Subject: [PATCH 01/53] native: support bounded SQL metadata reads --- ...lass-wp-markdown-native-query-executor.php | 53 ++++++- .../class-wp-markdown-native-query-parser.php | 4 - ...p-markdown-native-schema-introspection.php | 136 ++++++++++++++++++ tests/smoke-native-aggregates.php | 5 + tests/smoke-native-plugin-schema-query.php | 10 ++ tests/smoke-native-query-parser.php | 10 +- tests/smoke-native-residual-equality.php | 5 + tests/smoke-native-scalar-clauses.php | 8 ++ 8 files changed, 220 insertions(+), 11 deletions(-) diff --git a/inc/native/class-wp-markdown-native-query-executor.php b/inc/native/class-wp-markdown-native-query-executor.php index aa56648..9d176bd 100644 --- a/inc/native/class-wp-markdown-native-query-executor.php +++ b/inc/native/class-wp-markdown-native-query-executor.php @@ -63,6 +63,10 @@ public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query if ( 1 === preg_match( '/^\s*(?:SHOW|DESCRIBE)\b/i', $request->sql() ) ) { return $this->schema_introspection->execute( $request ); } + $information_schema = $this->schema_introspection->select_information_schema( $request ); + if ( null !== $information_schema ) { + return $information_schema; + } // The canonical store is a directory, not a named server database. if ( 1 === preg_match( '/^\s*SELECT\s+DATABASE\s*\(\s*\)\s*;?\s*$/i', $request->sql() ) ) { return WP_Markdown_Query_Result::selected( @@ -70,6 +74,10 @@ public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query array( array( 'name' => 'DATABASE()', 'table' => '', 'type' => 253 ) ) ); } + $json_valid = $this->tableless_json_valid( $request->sql() ); + if ( null !== $json_valid ) { + return $json_valid; + } if ( 1 === preg_match( '/^\s*SELECT\s+(@@(?:SESSION\.)?(IN_TRANSACTION|AUTOCOMMIT))\s*;?\s*$/i', $request->sql(), $match ) ) { $column = $match[1]; $variable = strtolower( $match[2] ); @@ -116,6 +124,41 @@ public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query return $this->execute_plan( $plan ); } + /** Execute the bounded tableless scalar form without treating JSON as a table source. */ + private function tableless_json_valid( string $sql ): ?WP_Markdown_Query_Result { + try { + $tokens = ( new WP_Markdown_Native_SQL_Tokenizer() )->tokenize( rtrim( trim( $sql ), ';' ) ); + } catch ( WP_Markdown_Native_SQL_Parse_Error ) { + return null; + } + if ( 6 !== count( $tokens ) + || 0 !== strcasecmp( 'SELECT', (string) $tokens[0]->value() ) + || 0 !== strcasecmp( 'JSON_VALID', (string) $tokens[1]->value() ) + || WP_Markdown_Native_SQL_Token::LEFT_PAREN !== $tokens[2]->type() + || WP_Markdown_Native_SQL_Token::RIGHT_PAREN !== $tokens[4]->type() + || WP_Markdown_Native_SQL_Token::END !== $tokens[5]->type() + ) { + return null; + } + $value = 0 === strcasecmp( 'NULL', (string) $tokens[3]->value() ) ? null : $tokens[3]->value(); + if ( null !== $value && WP_Markdown_Native_SQL_Token::STRING !== $tokens[3]->type() ) { + return null; + } + $valid = null; + if ( null !== $value ) { + try { + json_decode( (string) $value, true, 512, JSON_THROW_ON_ERROR ); + $valid = '1'; + } catch ( JsonException ) { + $valid = '0'; + } + } + return WP_Markdown_Query_Result::selected( + array( array( 'JSON_VALID(' . $tokens[3]->lexeme() . ')' => $valid ) ), + array( array( 'name' => 'JSON_VALID(' . $tokens[3]->lexeme() . ')', 'table' => '', 'type' => 8 ) ) + ); + } + private function execute_plan( WP_Markdown_Native_Query_Plan $plan, bool $allow_union = true ): WP_Markdown_Query_Result { if ( $allow_union && null !== $plan->union() ) { return $this->execute_union( $plan ); @@ -214,7 +257,7 @@ private function execute_plan( WP_Markdown_Native_Query_Plan $plan, bool $allow_ } } $pushdown = $this->pushdown( $predicates, $schema ); - if ( array() !== $predicates && null === $pushdown && ! $this->allows_residual_scan( $predicates, $schema ) ) { + if ( array() !== $predicates && null === $pushdown && ! $this->allows_residual_scan( $predicates, $schema, PHP_INT_MAX !== $plan->limit() ) ) { return $this->failure( 'unsupported_lookup', 'mdi-native requires one indexable predicate for a filtered query.' ); } foreach ( $plan->order_by() as $item ) { @@ -1631,7 +1674,7 @@ private function derived_source( WP_Markdown_Native_Query_Plan $plan, string $na } /** @param array $predicates */ - private function allows_residual_scan( array $predicates, WP_Markdown_Native_Table_Schema $schema ): bool { + private function allows_residual_scan( array $predicates, WP_Markdown_Native_Table_Schema $schema, bool $bounded = false ): bool { $indexed = $this->indexed_columns( $schema ); foreach ( $predicates as $predicate ) { if ( null !== $predicate->cast() ) { @@ -1667,6 +1710,12 @@ private function allows_residual_scan( array $predicates, WP_Markdown_Native_Tab && $schema->allows_filter( $column, $predicate->operator(), $predicate->values() ) ) { continue; } + // Providers already apply all schema-validated residual filters before + // ORDER/LIMIT. Do not require a separate lookup declaration merely + // because a bounded query combines ordinary text predicates. + if ( $bounded && $schema->allows_filter( $column, $predicate->operator(), $predicate->values() ) ) { + continue; + } return false; } return array() !== $predicates; diff --git a/inc/native/class-wp-markdown-native-query-parser.php b/inc/native/class-wp-markdown-native-query-parser.php index 32d0678..69aa9d5 100644 --- a/inc/native/class-wp-markdown-native-query-parser.php +++ b/inc/native/class-wp-markdown-native-query-parser.php @@ -1235,10 +1235,6 @@ private function match_aggregate(): ?array { $alias = $function . '(' . ( null === $column ? '*' : $column->name() ) . ')'; if ( $this->match_keyword( 'AS' ) ) { $alias = $this->unqualified_identifier()->name(); - } elseif ( 'MIN' !== $function ) { - // Existing native aggregate support requires an explicit result - // name. The calendar derived-source query is the bounded exception. - $this->unsupported( $this->current() ); } return array( 'function' => $function, diff --git a/inc/native/class-wp-markdown-native-schema-introspection.php b/inc/native/class-wp-markdown-native-schema-introspection.php index 90eb1a2..b1c48c8 100644 --- a/inc/native/class-wp-markdown-native-schema-introspection.php +++ b/inc/native/class-wp-markdown-native-schema-introspection.php @@ -250,6 +250,142 @@ public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query : $this->indexes( (string) $query->table(), $definition, $query->predicates() ); } + /** + * Answer bounded information_schema catalog reads from registered native DDL. + * + * This is deliberately separate from physical-table SELECT planning: a native + * directory has no server catalog to scan, so callers must name the requested + * tables before catalog rows are materialized. + */ + public function select_information_schema( WP_Markdown_Query_Request $request ): ?WP_Markdown_Query_Result { + try { + $tokens = ( new WP_Markdown_Native_SQL_Tokenizer() )->tokenize( rtrim( trim( $request->sql() ), ';' ) ); + $position = 0; + $word = static function ( string $expected ) use ( &$tokens, &$position ): bool { + if ( 0 !== strcasecmp( $expected, (string) ( $tokens[ $position ] ?? null )?->value() ) ) { + return false; + } + ++$position; + return true; + }; + $identifier = static function () use ( &$tokens, &$position ): ?string { + $token = $tokens[ $position ] ?? null; + if ( ! $token instanceof WP_Markdown_Native_SQL_Token || ! in_array( $token->type(), array( WP_Markdown_Native_SQL_Token::WORD, WP_Markdown_Native_SQL_Token::KEYWORD, WP_Markdown_Native_SQL_Token::QUOTED_IDENTIFIER ), true ) ) { + return null; + } + ++$position; + return (string) $token->value(); + }; + if ( ! $word( 'SELECT' ) ) { + return null; + } + $projection = array(); + do { + $name = $identifier(); + if ( null === $name ) { + return null; + } + $alias = $name; + if ( $word( 'AS' ) ) { + $alias = $identifier(); + if ( null === $alias ) { + return null; + } + } + $projection[] = array( 'name' => strtoupper( $name ), 'alias' => $alias ); + } while ( WP_Markdown_Native_SQL_Token::COMMA === ( $tokens[ $position ] ?? null )?->type() && ++$position ); + if ( ! $word( 'FROM' ) || 0 !== strcasecmp( 'information_schema', (string) $identifier() ) || WP_Markdown_Native_SQL_Token::DOT !== ( $tokens[ $position ] ?? null )?->type() ) { + return null; + } + ++$position; + $catalog = strtoupper( (string) $identifier() ); + if ( ! in_array( $catalog, array( 'COLUMNS', 'TABLES' ), true ) ) { + return null; + } + if ( ! $word( 'WHERE' ) ) { + return $this->failure( 'unsupported_lookup', 'mdi-native requires a bounded information_schema table lookup.' ); + } + $names = array(); + $schema_match = false; + do { + $column = strtoupper( (string) $identifier() ); + if ( 'TABLE_SCHEMA' === $column && WP_Markdown_Native_SQL_Token::EQUALS === ( $tokens[ $position ] ?? null )?->type() ) { + ++$position; + $schema_match = $word( 'DATABASE' ) && WP_Markdown_Native_SQL_Token::LEFT_PAREN === ( $tokens[ $position ] ?? null )?->type() && WP_Markdown_Native_SQL_Token::RIGHT_PAREN === ( $tokens[ $position + 1 ] ?? null )?->type(); + $position += $schema_match ? 2 : 0; + } elseif ( 'TABLE_NAME' === $column && ( $word( 'IN' ) || WP_Markdown_Native_SQL_Token::EQUALS === ( $tokens[ $position ] ?? null )?->type() ) ) { + if ( WP_Markdown_Native_SQL_Token::EQUALS === ( $tokens[ $position ] ?? null )?->type() ) { + ++$position; + $token = $tokens[ $position++ ] ?? null; + if ( ! $token instanceof WP_Markdown_Native_SQL_Token || WP_Markdown_Native_SQL_Token::STRING !== $token->type() ) { return null; } + $names[] = (string) $token->value(); + } else { + if ( WP_Markdown_Native_SQL_Token::LEFT_PAREN !== ( $tokens[ $position ] ?? null )?->type() ) { return null; } + ++$position; + do { + $token = $tokens[ $position++ ] ?? null; + if ( ! $token instanceof WP_Markdown_Native_SQL_Token || WP_Markdown_Native_SQL_Token::STRING !== $token->type() ) { return null; } + $names[] = (string) $token->value(); + } while ( WP_Markdown_Native_SQL_Token::COMMA === ( $tokens[ $position ] ?? null )?->type() && ++$position ); + if ( WP_Markdown_Native_SQL_Token::RIGHT_PAREN !== ( $tokens[ $position ] ?? null )?->type() ) { return null; } + ++$position; + } + } else { + return null; + } + } while ( $word( 'AND' ) ); + if ( ! $schema_match || array() === $names || WP_Markdown_Native_SQL_Token::END !== ( $tokens[ $position ] ?? null )?->type() ) { + return $this->failure( 'unsupported_lookup', 'mdi-native requires a bounded information_schema table lookup.' ); + } + $rows = array(); + foreach ( array_values( array_unique( $names ) ) as $table ) { + $definition = $this->registry->definition( $table ); + if ( null === $definition || array() === $definition ) { + continue; + } + $catalog_rows = 'COLUMNS' === $catalog ? $this->information_schema_columns( $table, $definition ) : array( $this->information_schema_table( $table ) ); + foreach ( $catalog_rows as $catalog_row ) { + $row = array(); + foreach ( $projection as $column ) { + if ( ! array_key_exists( $column['name'], $catalog_row ) ) { + return $this->failure( 'unsupported_column', 'mdi-native cannot report the requested information_schema column.' ); + } + $row[ $column['alias'] ] = $catalog_row[ $column['name'] ]; + } + $rows[] = $row; + } + } + return WP_Markdown_Query_Result::selected( $rows, array_map( static fn( array $column ): array => array( 'name' => $column['alias'], 'table' => '', 'type' => 253 ), $projection ) ); + } catch ( WP_Markdown_Native_SQL_Parse_Error ) { + return null; + } + } + + /** @param array{columns:array>,indexes:array>} $definition @return array> */ + private function information_schema_columns( string $table, array $definition ): array { + $rows = array(); + foreach ( $definition['columns'] as $position => $column ) { + $rows[] = array( + 'TABLE_SCHEMA' => defined( 'DB_NAME' ) ? (string) DB_NAME : '', + 'TABLE_NAME' => $table, + 'COLUMN_NAME' => $position, + 'ORDINAL_POSITION' => (string) ( count( $rows ) + 1 ), + 'COLUMN_DEFAULT' => $column['default'], + 'IS_NULLABLE' => $column['nullable'] ? 'YES' : 'NO', + 'DATA_TYPE' => strtolower( (string) $column['type'] ), + 'COLUMN_TYPE' => $this->column_type( $column ), + 'COLUMN_KEY' => $this->column_key( $position, $definition['indexes'] ), + 'EXTRA' => $column['auto_increment'] ? 'auto_increment' : '', + ); + } + return $rows; + } + + /** @return array */ + private function information_schema_table( string $table ): array { + return array( 'TABLE_SCHEMA' => defined( 'DB_NAME' ) ? (string) DB_NAME : '', 'TABLE_NAME' => $table, 'ENGINE' => 'InnoDB', 'TABLE_TYPE' => 'BASE TABLE' ); + } + /** * Report the server variables a file-backed engine can answer honestly. * diff --git a/tests/smoke-native-aggregates.php b/tests/smoke-native-aggregates.php index 2236223..ef8afed 100644 --- a/tests/smoke-native-aggregates.php +++ b/tests/smoke-native-aggregates.php @@ -40,6 +40,8 @@ function mdi_aggregate_row( WP_Markdown_Native_Query_Runtime $runtime, string $s $filtered = mdi_aggregate_row( $runtime, "SELECT SUM(score) AS total FROM wp_items WHERE kind = 'a'" ); $empty = mdi_aggregate_row( $runtime, "SELECT SUM(score) AS total, COUNT(score) AS scored FROM wp_items WHERE kind = 'missing'" ); $textual = mdi_aggregate_row( $runtime, 'SELECT SUM(kind) AS total FROM wp_items' ); +$default_names = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT MAX(score), COUNT(score) FROM wp_items', 'wp_' ) ); +$default_empty = $runtime->execute( new WP_Markdown_Query_Request( "SELECT MAX(score), COUNT(score) FROM wp_items WHERE kind = 'missing'", 'wp_' ) ); $checks = array( 'one row reports every ungrouped aggregate' => array( 'total' => '60', 'mean' => '20', 'lowest' => '10', 'highest' => '30' ) === $totals, @@ -48,6 +50,9 @@ function mdi_aggregate_row( WP_Markdown_Native_Query_Runtime $runtime, string $s 'a restriction narrows the aggregate' => array( 'total' => '40' ) === $filtered, 'an aggregate over no rows is NULL, and a count is zero' => array( 'total' => null, 'scored' => '0' ) === $empty, 'summing a text column stays fail-closed' => 'unsupported_aggregate' === ( $textual['unsupported'] ?? null ), + 'unaliased column aggregates retain MySQL result names, values, and metadata' => array( 'MAX(score)' => '30', 'COUNT(score)' => '3' ) === (array) ( $default_names->wpdb_state()['last_result'][0] ?? array() ) + && array( 'MAX(score)', 'COUNT(score)' ) === array_map( static fn( object $column ): string => $column->name, $default_names->wpdb_state()['col_info'] ), + 'unaliased column aggregates retain NULL and empty-set semantics' => array( 'MAX(score)' => null, 'COUNT(score)' => '0' ) === (array) ( $default_empty->wpdb_state()['last_result'][0] ?? array() ), ); $failed = false; diff --git a/tests/smoke-native-plugin-schema-query.php b/tests/smoke-native-plugin-schema-query.php index bebb876..a09a2d0 100644 --- a/tests/smoke-native-plugin-schema-query.php +++ b/tests/smoke-native-plugin-schema-query.php @@ -120,6 +120,9 @@ function mdi_plugin_schema_remove_tree( string $root ): void { $show_full_columns = $runtime->execute( new WP_Markdown_Query_Request( 'SHOW FULL COLUMNS FROM wp_plugin_jobs' ) ); $show_full_missing = $runtime->execute( new WP_Markdown_Query_Request( 'SHOW FULL COLUMNS FROM wp_missing' ) ); $show_indexes = $runtime->execute( new WP_Markdown_Query_Request( 'SHOW INDEX FROM `wp_plugin_jobs`' ) ); +$information_columns = $runtime->execute( new WP_Markdown_Query_Request( "SELECT TABLE_NAME, COLUMN_NAME, ORDINAL_POSITION, COLUMN_DEFAULT, IS_NULLABLE, DATA_TYPE, COLUMN_TYPE, COLUMN_KEY, EXTRA FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN ('wp_plugin_jobs', 'wp_inline_items')" ) ); +$information_tables = $runtime->execute( new WP_Markdown_Query_Request( "SELECT ENGINE AS Engine FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'wp_plugin_jobs'" ) ); +$unbounded_information = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE()' ) ); file_put_contents( $root . '/_tables/plugin_jobs.json', json_encode( @@ -160,6 +163,13 @@ function mdi_plugin_schema_remove_tree( string $root ): void { $show_indexes->wpdb_state()['last_result'] ) && array( '0', '1' ) === array_map( static fn( object $row ): string => $row->Non_unique, $show_indexes->wpdb_state()['last_result'] ), + 'bounded information_schema reads derive column and engine metadata from registered DDL' => array( 'wp_plugin_jobs', 'wp_plugin_jobs', 'wp_plugin_jobs', 'wp_plugin_jobs', 'wp_plugin_jobs', 'wp_plugin_jobs', 'wp_inline_items', 'wp_inline_items' ) === array_map( static fn( object $row ): string => $row->TABLE_NAME, $information_columns->wpdb_state()['last_result'] ) + && array( 'id', 'owner_id', 'status', 'task_url', 'owner_run_ref', 'payload' ) === array_map( static fn( object $row ): string => $row->COLUMN_NAME, array_slice( $information_columns->wpdb_state()['last_result'], 0, 6 ) ) + && 'PRI' === ( $information_columns->wpdb_state()['last_result'][0]->COLUMN_KEY ?? null ) + && 'InnoDB' === ( $information_tables->wpdb_state()['last_result'][0]->Engine ?? null ) + && array( 'Engine' ) === array_map( static fn( object $column ): string => $column->name, $information_tables->wpdb_state()['col_info'] ), + 'information_schema catalog scans remain fail-closed without a bounded table name' => false === $unbounded_information->return_value() + && 'unsupported_lookup' === ( $unbounded_information->diagnostic()['reason'] ?? null ), 'primary and secondary numeric indexes derive bounded lookup capabilities' => array( '1', '2' ) === array_map( static fn( object $row ): string => $row->id, $secondary->wpdb_state()['last_result'] diff --git a/tests/smoke-native-query-parser.php b/tests/smoke-native-query-parser.php index ebe143c..66b5c8e 100644 --- a/tests/smoke-native-query-parser.php +++ b/tests/smoke-native-query-parser.php @@ -208,7 +208,9 @@ && strpos( $unterminated_sql, "'open" ) === ( $unterminated->diagnostic()['sql_offset'] ?? null ) && $malformed_and instanceof WP_Markdown_Query_Result && strpos( $malformed_and_sql, 'BY' ) === ( $malformed_and->diagnostic()['sql_offset'] ?? null ), - 'an aliased column count is an aggregate like any other' => $counted_column instanceof WP_Markdown_Native_Query_Plan + 'column counts retain typed aggregate plans with or without an alias' => $count_column instanceof WP_Markdown_Native_Query_Plan + && 'COUNT(row_id)' === $count_column->aggregates()[0]['alias'] + && $counted_column instanceof WP_Markdown_Native_Query_Plan && 1 === count( $counted_column->aggregates() ) && 'COUNT' === $counted_column->aggregates()[0]['function'] && 'row_id' === $counted_column->aggregates()[0]['column'], @@ -216,9 +218,7 @@ && 'COUNT' === $distinct_count->aggregates()[0]['function'] && 'row_id' === $distinct_count->aggregates()[0]['column'] && true === $distinct_count->aggregates()[0]['distinct'], - 'unsupported aggregate shapes fail closed at exact source positions' => $count_column instanceof WP_Markdown_Query_Result - && strpos( $count_column_sql, 'FROM' ) === ( $count_column->diagnostic()['sql_offset'] ?? null ) - && $mixed_count instanceof WP_Markdown_Query_Result + 'unsupported aggregate shapes fail closed at exact source positions' => $mixed_count instanceof WP_Markdown_Query_Result && strpos( $mixed_count_sql, ',' ) === ( $mixed_count->diagnostic()['sql_offset'] ?? null ) && $aliased_count instanceof WP_Markdown_Query_Result && strpos( $aliased_count_sql, 'AS' ) === ( $aliased_count->diagnostic()['sql_offset'] ?? null ) @@ -227,7 +227,7 @@ && $unsupported_function instanceof WP_Markdown_Query_Result && strpos( $unsupported_function_sql, '*' ) === ( $unsupported_function->diagnostic()['sql_offset'] ?? null ) && array_reduce( - array( $count_column, $mixed_count, $aliased_count, $grouped_count, $unsupported_function ), + array( $mixed_count, $aliased_count, $grouped_count, $unsupported_function ), static fn( bool $valid, WP_Markdown_Query_Result $result ): bool => $valid && 'unsupported_grammar' === ( $result->diagnostic()['reason'] ?? null ), true ), diff --git a/tests/smoke-native-residual-equality.php b/tests/smoke-native-residual-equality.php index ec31af1..fc5d77f 100644 --- a/tests/smoke-native-residual-equality.php +++ b/tests/smoke-native-residual-equality.php @@ -20,6 +20,7 @@ $runtime->execute( new WP_Markdown_Query_Request( "INSERT INTO wp_yoast_indexable (object_id, object_type) VALUES (8, 'post')", 'wp_' ) ); $hit = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT id, object_id FROM wp_yoast_indexable WHERE object_id = 7', 'wp_' ) ); $miss = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT id FROM wp_yoast_indexable WHERE object_id = 404', 'wp_' ) ); +$ordered = $runtime->execute( new WP_Markdown_Query_Request( "SELECT id FROM wp_yoast_indexable WHERE object_type = 'post' ORDER BY id DESC LIMIT 1,1", 'wp_' ) ); $checks = array( 'equality on a non-lookup integer column scans matching rows' => array( '7' ) === array_map( @@ -28,6 +29,10 @@ ), 'a residual equality miss is an empty success' => array() === $miss->wpdb_state()['last_result'] && false !== $miss->return_value(), + 'a bounded ordered text residual scan remains a successful native lookup' => array( '1' ) === array_map( + static fn( object $row ): string => (string) $row->id, + $ordered->wpdb_state()['last_result'] + ), ); $failed = false; diff --git a/tests/smoke-native-scalar-clauses.php b/tests/smoke-native-scalar-clauses.php index 76fd30b..bfc2c81 100644 --- a/tests/smoke-native-scalar-clauses.php +++ b/tests/smoke-native-scalar-clauses.php @@ -32,6 +32,9 @@ $calendar = $runtime->execute( new WP_Markdown_Query_Request( "SELECT DAYOFWEEK('2024-01-14') AS sunday, DAYOFMONTH(published_at) AS day, DAYOFYEAR(published_at) AS ordinal, WEEKDAY(published_at) AS weekday, WEEK(published_at, 1) AS week, SECOND(published_at) AS second, ABS(1 + 2 * 3) AS precedence FROM wp_dates WHERE id = 1", 'wp_' ) ); $formatted = $runtime->execute( new WP_Markdown_Query_Request( "SELECT DATE_FORMAT('2021-01-01 13:02:03.123456', '%a|%W|%b|%M|%c|%D|%d|%e|%f|%H|%h|%I|%i|%j|%k|%l|%m|%p|%r|%S|%s|%T|%U|%u|%V|%v|%w|%X|%x|%Y|%y|%%|%q') AS formatted FROM wp_dates LIMIT 1", 'wp_' ) ); $decimal = $runtime->execute( new WP_Markdown_Query_Request( "SELECT CAST('1.235' AS DECIMAL(5,2)) AS rounded, CAST('-1.235' AS DECIMAL(5,2)) AS negative, CAST('12.9' AS DECIMAL) AS default_decimal, CAST('1e3' AS DECIMAL(10,0)) AS exponent, CAST('0.125' AS DECIMAL(3,3)) AS fractional, CAST('-0.001' AS DECIMAL(3,2)) AS negative_zero, CAST('9999' AS DECIMAL(3,1)) AS overflow, CAST('99.96' AS DECIMAL(3,1)) AS round_overflow, SUBSTRING_INDEX('a,b,c', '', 1) AS empty_delimiter, SUBSTRING_INDEX('a,b,c', ',', 0) AS zero_count, SUBSTRING_INDEX('a,b,c', ',', -2) AS negative_count FROM wp_dates LIMIT 1", 'wp_' ) ); +$json_valid = $runtime->execute( new WP_Markdown_Query_Request( "SELECT JSON_VALID('{\"event\":true}')", 'wp_' ) ); +$json_invalid = $runtime->execute( new WP_Markdown_Query_Request( "SELECT JSON_VALID('{broken}')", 'wp_' ) ); +$json_null = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT JSON_VALID(NULL)', 'wp_' ) ); $checks = array( 'WP date WHERE evaluates DATE_ADD INTERVAL after the bounded read' => array( '2', '3' ) === array_map( static fn( object $row ): string => $row->id, $where->wpdb_state()['last_result'] ), 'DATE_SUB INTERVAL and TIMESTAMPDIFF use the shared WHERE scalar path' => array( '1' ) === array_map( static fn( object $row ): string => $row->id, $subtracted->wpdb_state()['last_result'] ) @@ -60,6 +63,11 @@ 'WP_Date_Query calendar parts and arithmetic precedence match MySQL' => array( 'sunday' => '1', 'day' => '15', 'ordinal' => '15', 'weekday' => '0', 'week' => '3', 'second' => '00', 'precedence' => '7' ) === (array) ( $calendar->wpdb_state()['last_result'][0] ?? array() ), 'DATE_FORMAT handles names, ordinals, 12-hour time, fractions, week modes, escapes, and unknown specifiers' => 'Fri|Friday|Jan|January|1|1st|01|1|123456|13|01|01|02|001|13|1|01|PM|01:02:03 PM|03|03|13:02:03|00|00|52|53|5|2020|2020|2021|21|%|q' === ( $formatted->wpdb_state()['last_result'][0]->formatted ?? null ), 'DECIMAL precision, exponents, saturation, and SUBSTRING_INDEX edge semantics match MariaDB' => array( 'rounded' => '1.24', 'negative' => '-1.24', 'default_decimal' => '13', 'exponent' => '1000', 'fractional' => '0.125', 'negative_zero' => '0.00', 'overflow' => '99.9', 'round_overflow' => '99.9', 'empty_delimiter' => '', 'zero_count' => '', 'negative_count' => 'b,c' ) === (array) ( $decimal->wpdb_state()['last_result'][0] ?? array() ), + 'tableless JSON_VALID preserves valid, invalid, NULL, and column metadata semantics' => '1' === ( $json_valid->wpdb_state()['last_result'][0]->{'JSON_VALID(\'{"event":true}\')'} ?? null ) + && '0' === ( $json_invalid->wpdb_state()['last_result'][0]->{'JSON_VALID(\'{broken}\')'} ?? null ) + && null === ( $json_null->wpdb_state()['last_result'][0]->{'JSON_VALID(NULL)'} ?? null ) + && 'JSON_VALID(\'{"event":true}\')' === ( $json_valid->wpdb_state()['col_info'][0]->name ?? null ) + && 8 === ( $json_valid->wpdb_state()['col_info'][0]->type ?? null ), ); $failed = false; foreach ( $checks as $label => $passed ) { echo ( $passed ? 'PASS: ' : 'FAIL: ' ) . $label . "\n"; $failed = $failed || ! $passed; } From 42081b899f9520def6f9ef038389174ec59ff3c0 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 9 Sep 2026 07:45:39 -0400 Subject: [PATCH 02/53] native: match bounded shadow SQL forms --- ...class-wp-markdown-native-query-executor.php | 16 ++++++++++++---- .../class-wp-markdown-native-query-runtime.php | 2 +- ...wp-markdown-native-schema-introspection.php | 18 ++++++++++++++++++ tests/smoke-native-plugin-schema-query.php | 6 +++--- tests/smoke-native-scalar-clauses.php | 4 +++- 5 files changed, 37 insertions(+), 9 deletions(-) diff --git a/inc/native/class-wp-markdown-native-query-executor.php b/inc/native/class-wp-markdown-native-query-executor.php index 9d176bd..2775512 100644 --- a/inc/native/class-wp-markdown-native-query-executor.php +++ b/inc/native/class-wp-markdown-native-query-executor.php @@ -131,15 +131,23 @@ private function tableless_json_valid( string $sql ): ?WP_Markdown_Query_Result } catch ( WP_Markdown_Native_SQL_Parse_Error ) { return null; } - if ( 6 !== count( $tokens ) + $end = count( $tokens ) - 1; + if ( $end < 5 || 0 !== strcasecmp( 'SELECT', (string) $tokens[0]->value() ) || 0 !== strcasecmp( 'JSON_VALID', (string) $tokens[1]->value() ) || WP_Markdown_Native_SQL_Token::LEFT_PAREN !== $tokens[2]->type() || WP_Markdown_Native_SQL_Token::RIGHT_PAREN !== $tokens[4]->type() - || WP_Markdown_Native_SQL_Token::END !== $tokens[5]->type() + || WP_Markdown_Native_SQL_Token::END !== $tokens[ $end ]->type() ) { return null; } + $column = 'JSON_VALID(' . $tokens[3]->lexeme() . ')'; + if ( 5 < $end ) { + if ( 7 !== $end || 0 !== strcasecmp( 'AS', (string) $tokens[5]->value() ) || ! in_array( $tokens[6]->type(), array( WP_Markdown_Native_SQL_Token::WORD, WP_Markdown_Native_SQL_Token::KEYWORD, WP_Markdown_Native_SQL_Token::QUOTED_IDENTIFIER ), true ) ) { + return null; + } + $column = (string) $tokens[6]->value(); + } $value = 0 === strcasecmp( 'NULL', (string) $tokens[3]->value() ) ? null : $tokens[3]->value(); if ( null !== $value && WP_Markdown_Native_SQL_Token::STRING !== $tokens[3]->type() ) { return null; @@ -154,8 +162,8 @@ private function tableless_json_valid( string $sql ): ?WP_Markdown_Query_Result } } return WP_Markdown_Query_Result::selected( - array( array( 'JSON_VALID(' . $tokens[3]->lexeme() . ')' => $valid ) ), - array( array( 'name' => 'JSON_VALID(' . $tokens[3]->lexeme() . ')', 'table' => '', 'type' => 8 ) ) + array( array( $column => $valid ) ), + array( array( 'name' => $column, 'table' => '', 'type' => 8 ) ) ); } diff --git a/inc/native/class-wp-markdown-native-query-runtime.php b/inc/native/class-wp-markdown-native-query-runtime.php index f64c09c..8ed520b 100644 --- a/inc/native/class-wp-markdown-native-query-runtime.php +++ b/inc/native/class-wp-markdown-native-query-runtime.php @@ -111,7 +111,7 @@ public static function posts_schema(): WP_Markdown_Native_Table_Schema { 'lookup_validator' => static fn( array $values ): bool => self::all_ascii_strings( $values ), ), ), - 'order_columns' => array( 'post_date', 'menu_order', 'post_title' ), + 'order_columns' => array( 'post_date', 'post_date_gmt', 'menu_order', 'post_title' ), ) ); } diff --git a/inc/native/class-wp-markdown-native-schema-introspection.php b/inc/native/class-wp-markdown-native-schema-introspection.php index b1c48c8..ade2529 100644 --- a/inc/native/class-wp-markdown-native-schema-introspection.php +++ b/inc/native/class-wp-markdown-native-schema-introspection.php @@ -306,6 +306,7 @@ public function select_information_schema( WP_Markdown_Query_Request $request ): return $this->failure( 'unsupported_lookup', 'mdi-native requires a bounded information_schema table lookup.' ); } $names = array(); + $column_names = array(); $schema_match = false; do { $column = strtoupper( (string) $identifier() ); @@ -313,6 +314,10 @@ public function select_information_schema( WP_Markdown_Query_Request $request ): ++$position; $schema_match = $word( 'DATABASE' ) && WP_Markdown_Native_SQL_Token::LEFT_PAREN === ( $tokens[ $position ] ?? null )?->type() && WP_Markdown_Native_SQL_Token::RIGHT_PAREN === ( $tokens[ $position + 1 ] ?? null )?->type(); $position += $schema_match ? 2 : 0; + if ( ! $schema_match && WP_Markdown_Native_SQL_Token::STRING === ( $tokens[ $position ] ?? null )?->type() ) { + $schema_match = true; + ++$position; + } } elseif ( 'TABLE_NAME' === $column && ( $word( 'IN' ) || WP_Markdown_Native_SQL_Token::EQUALS === ( $tokens[ $position ] ?? null )?->type() ) ) { if ( WP_Markdown_Native_SQL_Token::EQUALS === ( $tokens[ $position ] ?? null )?->type() ) { ++$position; @@ -330,6 +335,16 @@ public function select_information_schema( WP_Markdown_Query_Request $request ): if ( WP_Markdown_Native_SQL_Token::RIGHT_PAREN !== ( $tokens[ $position ] ?? null )?->type() ) { return null; } ++$position; } + } elseif ( 'COLUMN_NAME' === $column && $word( 'IN' ) ) { + if ( WP_Markdown_Native_SQL_Token::LEFT_PAREN !== ( $tokens[ $position ] ?? null )?->type() ) { return null; } + ++$position; + do { + $token = $tokens[ $position++ ] ?? null; + if ( ! $token instanceof WP_Markdown_Native_SQL_Token || WP_Markdown_Native_SQL_Token::STRING !== $token->type() ) { return null; } + $column_names[] = (string) $token->value(); + } while ( WP_Markdown_Native_SQL_Token::COMMA === ( $tokens[ $position ] ?? null )?->type() && ++$position ); + if ( WP_Markdown_Native_SQL_Token::RIGHT_PAREN !== ( $tokens[ $position ] ?? null )?->type() ) { return null; } + ++$position; } else { return null; } @@ -345,6 +360,9 @@ public function select_information_schema( WP_Markdown_Query_Request $request ): } $catalog_rows = 'COLUMNS' === $catalog ? $this->information_schema_columns( $table, $definition ) : array( $this->information_schema_table( $table ) ); foreach ( $catalog_rows as $catalog_row ) { + if ( array() !== $column_names && ! in_array( $catalog_row['COLUMN_NAME'] ?? null, $column_names, true ) ) { + continue; + } $row = array(); foreach ( $projection as $column ) { if ( ! array_key_exists( $column['name'], $catalog_row ) ) { diff --git a/tests/smoke-native-plugin-schema-query.php b/tests/smoke-native-plugin-schema-query.php index a09a2d0..dba5d60 100644 --- a/tests/smoke-native-plugin-schema-query.php +++ b/tests/smoke-native-plugin-schema-query.php @@ -120,7 +120,7 @@ function mdi_plugin_schema_remove_tree( string $root ): void { $show_full_columns = $runtime->execute( new WP_Markdown_Query_Request( 'SHOW FULL COLUMNS FROM wp_plugin_jobs' ) ); $show_full_missing = $runtime->execute( new WP_Markdown_Query_Request( 'SHOW FULL COLUMNS FROM wp_missing' ) ); $show_indexes = $runtime->execute( new WP_Markdown_Query_Request( 'SHOW INDEX FROM `wp_plugin_jobs`' ) ); -$information_columns = $runtime->execute( new WP_Markdown_Query_Request( "SELECT TABLE_NAME, COLUMN_NAME, ORDINAL_POSITION, COLUMN_DEFAULT, IS_NULLABLE, DATA_TYPE, COLUMN_TYPE, COLUMN_KEY, EXTRA FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN ('wp_plugin_jobs', 'wp_inline_items')" ) ); +$information_columns = $runtime->execute( new WP_Markdown_Query_Request( "SELECT TABLE_NAME, COLUMN_NAME, ORDINAL_POSITION, COLUMN_DEFAULT, IS_NULLABLE, DATA_TYPE, COLUMN_TYPE, COLUMN_KEY, EXTRA FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'wordpress' AND TABLE_NAME IN ('wp_plugin_jobs', 'wp_inline_items') AND COLUMN_NAME IN ('id', 'owner_id', 'value')" ) ); $information_tables = $runtime->execute( new WP_Markdown_Query_Request( "SELECT ENGINE AS Engine FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'wp_plugin_jobs'" ) ); $unbounded_information = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE()' ) ); file_put_contents( @@ -163,8 +163,8 @@ function mdi_plugin_schema_remove_tree( string $root ): void { $show_indexes->wpdb_state()['last_result'] ) && array( '0', '1' ) === array_map( static fn( object $row ): string => $row->Non_unique, $show_indexes->wpdb_state()['last_result'] ), - 'bounded information_schema reads derive column and engine metadata from registered DDL' => array( 'wp_plugin_jobs', 'wp_plugin_jobs', 'wp_plugin_jobs', 'wp_plugin_jobs', 'wp_plugin_jobs', 'wp_plugin_jobs', 'wp_inline_items', 'wp_inline_items' ) === array_map( static fn( object $row ): string => $row->TABLE_NAME, $information_columns->wpdb_state()['last_result'] ) - && array( 'id', 'owner_id', 'status', 'task_url', 'owner_run_ref', 'payload' ) === array_map( static fn( object $row ): string => $row->COLUMN_NAME, array_slice( $information_columns->wpdb_state()['last_result'], 0, 6 ) ) + 'bounded information_schema reads derive column and engine metadata from registered DDL' => array( 'wp_plugin_jobs', 'wp_plugin_jobs', 'wp_inline_items', 'wp_inline_items' ) === array_map( static fn( object $row ): string => $row->TABLE_NAME, $information_columns->wpdb_state()['last_result'] ) + && array( 'id', 'owner_id', 'id', 'value' ) === array_map( static fn( object $row ): string => $row->COLUMN_NAME, $information_columns->wpdb_state()['last_result'] ) && 'PRI' === ( $information_columns->wpdb_state()['last_result'][0]->COLUMN_KEY ?? null ) && 'InnoDB' === ( $information_tables->wpdb_state()['last_result'][0]->Engine ?? null ) && array( 'Engine' ) === array_map( static fn( object $column ): string => $column->name, $information_tables->wpdb_state()['col_info'] ), diff --git a/tests/smoke-native-scalar-clauses.php b/tests/smoke-native-scalar-clauses.php index bfc2c81..ceb2f9b 100644 --- a/tests/smoke-native-scalar-clauses.php +++ b/tests/smoke-native-scalar-clauses.php @@ -35,6 +35,7 @@ $json_valid = $runtime->execute( new WP_Markdown_Query_Request( "SELECT JSON_VALID('{\"event\":true}')", 'wp_' ) ); $json_invalid = $runtime->execute( new WP_Markdown_Query_Request( "SELECT JSON_VALID('{broken}')", 'wp_' ) ); $json_null = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT JSON_VALID(NULL)', 'wp_' ) ); +$json_alias = $runtime->execute( new WP_Markdown_Query_Request( "SELECT JSON_VALID('[]') AS valid_json", 'wp_' ) ); $checks = array( 'WP date WHERE evaluates DATE_ADD INTERVAL after the bounded read' => array( '2', '3' ) === array_map( static fn( object $row ): string => $row->id, $where->wpdb_state()['last_result'] ), 'DATE_SUB INTERVAL and TIMESTAMPDIFF use the shared WHERE scalar path' => array( '1' ) === array_map( static fn( object $row ): string => $row->id, $subtracted->wpdb_state()['last_result'] ) @@ -67,7 +68,8 @@ && '0' === ( $json_invalid->wpdb_state()['last_result'][0]->{'JSON_VALID(\'{broken}\')'} ?? null ) && null === ( $json_null->wpdb_state()['last_result'][0]->{'JSON_VALID(NULL)'} ?? null ) && 'JSON_VALID(\'{"event":true}\')' === ( $json_valid->wpdb_state()['col_info'][0]->name ?? null ) - && 8 === ( $json_valid->wpdb_state()['col_info'][0]->type ?? null ), + && 8 === ( $json_valid->wpdb_state()['col_info'][0]->type ?? null ) + && '1' === ( $json_alias->wpdb_state()['last_result'][0]->valid_json ?? null ), ); $failed = false; foreach ( $checks as $label => $passed ) { echo ( $passed ? 'PASS: ' : 'FAIL: ' ) . $label . "\n"; $failed = $failed || ! $passed; } From 8f5d577c95a46b149bab61683f4a49cf030008bb Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 9 Sep 2026 08:04:21 -0400 Subject: [PATCH 03/53] native: support tableless scalar projections --- ...lass-wp-markdown-native-query-executor.php | 75 +++++++++++++------ .../class-wp-markdown-native-query-parser.php | 55 +++++++++++++- ...ass-wp-markdown-native-shadow-verifier.php | 3 +- tests/smoke-native-scalar-clauses.php | 6 +- tests/smoke-native-shadow-sql-snapshot.php | 9 ++- 5 files changed, 116 insertions(+), 32 deletions(-) diff --git a/inc/native/class-wp-markdown-native-query-executor.php b/inc/native/class-wp-markdown-native-query-executor.php index 2775512..8e64b90 100644 --- a/inc/native/class-wp-markdown-native-query-executor.php +++ b/inc/native/class-wp-markdown-native-query-executor.php @@ -74,9 +74,9 @@ public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query array( array( 'name' => 'DATABASE()', 'table' => '', 'type' => 253 ) ) ); } - $json_valid = $this->tableless_json_valid( $request->sql() ); - if ( null !== $json_valid ) { - return $json_valid; + $tableless = $this->tableless_scalar_projection( $request->sql() ); + if ( null !== $tableless ) { + return $tableless; } if ( 1 === preg_match( '/^\s*SELECT\s+(@@(?:SESSION\.)?(IN_TRANSACTION|AUTOCOMMIT))\s*;?\s*$/i', $request->sql(), $match ) ) { $column = $match[1]; @@ -124,7 +124,29 @@ public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query return $this->execute_plan( $plan ); } - /** Execute the bounded tableless scalar form without treating JSON as a table source. */ + /** Execute source-free typed scalar expressions as the one-row SQL result. */ + private function tableless_scalar_projection( string $sql ): ?WP_Markdown_Query_Result { + $projection = $this->parser->parse_tableless_scalar_projection( $sql ); + if ( $projection instanceof WP_Markdown_Query_Result ) { + return $this->tableless_json_valid( $sql ); + } + // The evaluator accepts a schema for CASE predicates; this sentinel is + // unreachable because tableless expressions have no column references. + $schema = new WP_Markdown_Native_Table_Schema( + array( '__mdi_native_tableless' => new WP_Markdown_Native_Column( 3, false ) ), + '__mdi_native_tableless' + ); + $row = array(); + $columns = array(); + foreach ( $projection as $scalar ) { + $value = $this->evaluate_scalar( $scalar['expression'], array(), $schema ); + $row[ $scalar['alias'] ] = $this->string_scalar( $value ); + $columns[] = array( 'name' => $scalar['alias'], 'table' => '', 'type' => $this->tableless_scalar_type( $scalar['expression'], $value ) ); + } + return WP_Markdown_Query_Result::selected( array( $row ), $columns ); + } + + /** Preserve the legacy unaliased JSON column label while typed aliases use the shared evaluator. */ private function tableless_json_valid( string $sql ): ?WP_Markdown_Query_Result { try { $tokens = ( new WP_Markdown_Native_SQL_Tokenizer() )->tokenize( rtrim( trim( $sql ), ';' ) ); @@ -132,41 +154,36 @@ private function tableless_json_valid( string $sql ): ?WP_Markdown_Query_Result return null; } $end = count( $tokens ) - 1; - if ( $end < 5 + if ( $end !== 5 || 0 !== strcasecmp( 'SELECT', (string) $tokens[0]->value() ) || 0 !== strcasecmp( 'JSON_VALID', (string) $tokens[1]->value() ) || WP_Markdown_Native_SQL_Token::LEFT_PAREN !== $tokens[2]->type() || WP_Markdown_Native_SQL_Token::RIGHT_PAREN !== $tokens[4]->type() - || WP_Markdown_Native_SQL_Token::END !== $tokens[ $end ]->type() + || WP_Markdown_Native_SQL_Token::END !== $tokens[5]->type() ) { return null; } - $column = 'JSON_VALID(' . $tokens[3]->lexeme() . ')'; - if ( 5 < $end ) { - if ( 7 !== $end || 0 !== strcasecmp( 'AS', (string) $tokens[5]->value() ) || ! in_array( $tokens[6]->type(), array( WP_Markdown_Native_SQL_Token::WORD, WP_Markdown_Native_SQL_Token::KEYWORD, WP_Markdown_Native_SQL_Token::QUOTED_IDENTIFIER ), true ) ) { - return null; - } - $column = (string) $tokens[6]->value(); - } $value = 0 === strcasecmp( 'NULL', (string) $tokens[3]->value() ) ? null : $tokens[3]->value(); if ( null !== $value && WP_Markdown_Native_SQL_Token::STRING !== $tokens[3]->type() ) { return null; } - $valid = null; - if ( null !== $value ) { - try { - json_decode( (string) $value, true, 512, JSON_THROW_ON_ERROR ); - $valid = '1'; - } catch ( JsonException ) { - $valid = '0'; - } - } + $column = 'JSON_VALID(' . $tokens[3]->lexeme() . ')'; return WP_Markdown_Query_Result::selected( - array( array( $column => $valid ) ), - array( array( 'name' => $column, 'table' => '', 'type' => 8 ) ) + array( array( $column => null === $value ? null : $this->json_valid( (string) $value ) ) ), + array( array( 'name' => $column, 'table' => '', 'type' => 3 ) ) ); } + public static function supports_tableless_scalar_projection( string $sql ): bool { + return ! ( ( new WP_Markdown_Native_Query_Parser() )->parse_tableless_scalar_projection( $sql ) instanceof WP_Markdown_Query_Result ); + } + + private function tableless_scalar_type( WP_Markdown_Native_Query_Scalar_Expression $expression, int|string|null $value ): int { + if ( null === $value ) { return 6; } + if ( 'literal' === $expression->kind() ) { return is_int( $value ) ? 3 : ( is_numeric( $value ) ? 246 : 253 ); } + return 'JSON_VALID' === $expression->kind() ? 3 : 253; + } + private function execute_plan( WP_Markdown_Native_Query_Plan $plan, bool $allow_union = true ): WP_Markdown_Query_Result { if ( $allow_union && null !== $plan->union() ) { return $this->execute_union( $plan ); @@ -2011,6 +2028,7 @@ private function evaluate_scalar( WP_Markdown_Native_Query_Scalar_Expression $ex 'LOCATE' => in_array( null, $values, true ) ? null : ( false === strpos( (string) $values[1], (string) $values[0] ) ? 0 : strpos( (string) $values[1], (string) $values[0] ) + 1 ), 'MD5' => null === $values[0] ? null : md5( (string) $values[0] ), 'SHA1' => null === $values[0] ? null : sha1( (string) $values[0] ), + 'JSON_VALID' => null === $values[0] ? null : $this->json_valid( (string) $values[0] ), 'ABS' => null === $values[0] ? null : $this->scalar_number( abs( $this->scalar_number( $values[0] ) ) ), 'ROUND' => null === $values[0] ? null : $this->scalar_number( round( $this->scalar_number( $values[0] ), (int) ( $values[1] ?? 0 ) ) ), 'FLOOR' => null === $values[0] ? null : $this->scalar_number( floor( $this->scalar_number( $values[0] ) ) ), @@ -2043,6 +2061,15 @@ private function scalar_number( int|float|string|null $value ): int|string|null| return floor( $number ) === $number ? (int) $number : (string) $number; } + private function json_valid( string $value ): string { + try { + json_decode( $value, true, 512, JSON_THROW_ON_ERROR ); + return '1'; + } catch ( JsonException ) { + return '0'; + } + } + /** Cast through decimal digits instead of PHP floats, which lose declared scale. */ private function cast_decimal( int|string $value, int|string $precision, int|string $scale ): string { $precision = (int) $precision; diff --git a/inc/native/class-wp-markdown-native-query-parser.php b/inc/native/class-wp-markdown-native-query-parser.php index 69aa9d5..19dda07 100644 --- a/inc/native/class-wp-markdown-native-query-parser.php +++ b/inc/native/class-wp-markdown-native-query-parser.php @@ -22,6 +22,47 @@ public function parse( string $sql ): WP_Markdown_Native_Query_Plan|WP_Markdown_ } } + /** + * Parse scalar projections that have no row source without fabricating a + * source schema. The synthetic FROM exists only to reuse the typed SELECT + * grammar; all source-dependent plan shapes are rejected below. + * + * @return array|WP_Markdown_Query_Result + */ + public function parse_tableless_scalar_projection( string $sql ): array|WP_Markdown_Query_Result { + $terminated = rtrim( $sql ); + if ( str_ends_with( $terminated, ';' ) ) { + $terminated = rtrim( substr( $terminated, 0, -1 ) ); + } + $plan = $this->parse( $terminated . ' FROM wp_mdi_native_tableless' ); + if ( ! $plan instanceof WP_Markdown_Native_Query_Plan + || 'wp_mdi_native_tableless' !== $plan->table() + || array() !== $plan->projection() + || array() === $plan->scalar_projection() + || $plan->counts_all() + || $plan->is_distinct() + || array() !== $plan->joins() + || array() !== $plan->predicates() + || array() !== $plan->scalar_predicates() + || null !== $plan->boolean_predicate() + || array() !== $plan->aggregates() + || null !== $plan->group_by() + || array() !== $plan->order_by() + || PHP_INT_MAX !== $plan->limit() + || 0 !== $plan->limit_offset() + ) { + return $plan instanceof WP_Markdown_Query_Result + ? $plan + : $this->failure( 'unsupported_tableless_projection', 'mdi-native supports only source-free scalar SELECT projections.', 0 ); + } + foreach ( $plan->scalar_projection() as $scalar ) { + if ( array() !== $scalar['expression']->columns() ) { + return $this->failure( 'unsupported_tableless_projection', 'mdi-native tableless scalar projections cannot reference columns.', 0 ); + } + } + return $plan->scalar_projection(); + } + public function parse_ast( string $sql ): WP_Markdown_Native_SQL_Select|WP_Markdown_Native_SQL_Found_Rows|WP_Markdown_Query_Result { try { // A single trailing statement terminator is not a second statement. @@ -444,6 +485,14 @@ private function select( bool $nested ): WP_Markdown_Native_SQL_Select|WP_Markdo $this->expect_type( WP_Markdown_Native_SQL_Token::RIGHT_PAREN ); } elseif ( ! $select_all ) { do { + if ( $this->match_keyword( 'NULL' ) ) { + $scalar_projection[] = array( + 'expression' => new WP_Markdown_Native_SQL_Scalar_Expression( 'literal', null, null ), + 'alias' => $this->match_keyword( 'AS' ) ? $this->unqualified_identifier()->name() : 'NULL', + 'position' => count( $projection ) + count( $scalar_projection ), + ); + continue; + } $aggregate = $this->match_aggregate(); if ( null !== $aggregate ) { $aggregates[] = $aggregate; @@ -461,7 +510,7 @@ private function select( bool $nested ): WP_Markdown_Native_SQL_Select|WP_Markdo $literal = $this->literal(); $scalar_projection[] = array( 'expression' => new WP_Markdown_Native_SQL_Scalar_Expression( 'literal', null, $literal->value() ), - 'alias' => (string) $literal->value(), + 'alias' => $this->match_keyword( 'AS' ) ? $this->unqualified_identifier()->name() : (string) $literal->value(), 'position' => count( $projection ) + count( $scalar_projection ), ); continue; @@ -757,7 +806,7 @@ private function source( bool $base ): array { private function matches_scalar_expression(): bool { return WP_Markdown_Native_SQL_Token::LEFT_PAREN === $this->current()->type() - || in_array( strtoupper( (string) $this->current()->value() ), array( 'CONCAT', 'COALESCE', 'SUBSTRING', 'SUBSTRING_INDEX', 'CAST', 'YEAR', 'MONTH', 'DATE_FORMAT', 'DATE', 'TIME', 'NOW', 'UTC_TIMESTAMP', 'CURDATE', 'UNIX_TIMESTAMP', 'FROM_UNIXTIME', 'DATEDIFF', 'TIMESTAMPDIFF', 'DATE_ADD', 'DATE_SUB', 'DAY', 'DAYOFMONTH', 'DAYOFYEAR', 'WEEKDAY', 'WEEK', 'SECOND', 'HOUR', 'MINUTE', 'DAYOFWEEK', 'GREATEST', 'LEAST', 'IF', 'IFNULL', 'NULLIF', 'LOWER', 'UPPER', 'TRIM', 'LENGTH', 'CHAR_LENGTH', 'REPLACE', 'LEFT', 'RIGHT', 'LOCATE', 'MD5', 'SHA1', 'ABS', 'ROUND', 'FLOOR', 'CEIL', 'MOD', 'POW', 'SQRT', 'RADIANS', 'DEGREES', 'SIN', 'COS', 'TAN', 'ACOS', 'ASIN', 'ATAN', 'ATAN2', 'RAND' ), true ) + || in_array( strtoupper( (string) $this->current()->value() ), array( 'CONCAT', 'COALESCE', 'SUBSTRING', 'SUBSTRING_INDEX', 'CAST', 'YEAR', 'MONTH', 'DATE_FORMAT', 'DATE', 'TIME', 'NOW', 'UTC_TIMESTAMP', 'CURDATE', 'UNIX_TIMESTAMP', 'FROM_UNIXTIME', 'DATEDIFF', 'TIMESTAMPDIFF', 'DATE_ADD', 'DATE_SUB', 'DAY', 'DAYOFMONTH', 'DAYOFYEAR', 'WEEKDAY', 'WEEK', 'SECOND', 'HOUR', 'MINUTE', 'DAYOFWEEK', 'GREATEST', 'LEAST', 'IF', 'IFNULL', 'NULLIF', 'LOWER', 'UPPER', 'TRIM', 'LENGTH', 'CHAR_LENGTH', 'REPLACE', 'LEFT', 'RIGHT', 'LOCATE', 'MD5', 'SHA1', 'JSON_VALID', 'ABS', 'ROUND', 'FLOOR', 'CEIL', 'MOD', 'POW', 'SQRT', 'RADIANS', 'DEGREES', 'SIN', 'COS', 'TAN', 'ACOS', 'ASIN', 'ATAN', 'ATAN2', 'RAND' ), true ) && WP_Markdown_Native_SQL_Token::LEFT_PAREN === ( $this->tokens[ $this->current + 1 ] ?? null )?->type() || ( WP_Markdown_Native_SQL_Token::KEYWORD === $this->current()->type() && 0 === strcasecmp( 'CASE', (string) $this->current()->value() ) ); } @@ -836,7 +885,7 @@ private function scalar_expression(): WP_Markdown_Native_SQL_Scalar_Expression { $valid = match ( $function ) { 'CONCAT', 'COALESCE' => 2 <= count( $arguments ), 'SUBSTRING', 'SUBSTRING_INDEX' => 3 === count( $arguments ), - 'YEAR', 'MONTH', 'DATE', 'TIME', 'FROM_UNIXTIME', 'DAY', 'DAYOFMONTH', 'DAYOFYEAR', 'WEEKDAY', 'SECOND', 'HOUR', 'MINUTE', 'DAYOFWEEK', 'LOWER', 'UPPER', 'TRIM', 'LENGTH', 'CHAR_LENGTH', 'MD5', 'SHA1', 'ABS', 'FLOOR', 'CEIL', 'SQRT', 'RADIANS', 'DEGREES', 'SIN', 'COS', 'TAN', 'ACOS', 'ASIN', 'ATAN' => 1 === count( $arguments ), + 'YEAR', 'MONTH', 'DATE', 'TIME', 'FROM_UNIXTIME', 'DAY', 'DAYOFMONTH', 'DAYOFYEAR', 'WEEKDAY', 'SECOND', 'HOUR', 'MINUTE', 'DAYOFWEEK', 'LOWER', 'UPPER', 'TRIM', 'LENGTH', 'CHAR_LENGTH', 'MD5', 'SHA1', 'JSON_VALID', 'ABS', 'FLOOR', 'CEIL', 'SQRT', 'RADIANS', 'DEGREES', 'SIN', 'COS', 'TAN', 'ACOS', 'ASIN', 'ATAN' => 1 === count( $arguments ), 'UNIX_TIMESTAMP', 'RAND' => 0 === count( $arguments ) || 1 === count( $arguments ), 'WEEK' => 2 === count( $arguments ) && 1 === (int) $arguments[1]->literal(), 'DATE_FORMAT', 'DATEDIFF', 'IFNULL', 'NULLIF', 'LEFT', 'RIGHT', 'LOCATE', 'MOD', 'POW', 'ATAN2' => 2 === count( $arguments ), diff --git a/inc/native/class-wp-markdown-native-shadow-verifier.php b/inc/native/class-wp-markdown-native-shadow-verifier.php index 4a0ed24..a12cb65 100644 --- a/inc/native/class-wp-markdown-native-shadow-verifier.php +++ b/inc/native/class-wp-markdown-native-shadow-verifier.php @@ -391,7 +391,8 @@ private function normalized_query_template( string $query ): string { } private function is_stateless_runtime_fast_path( string $query ): bool { - return 1 === preg_match( '/^\s*SELECT\s+DATABASE\s*\(\s*\)\s*;?\s*$/i', $query ); + return 1 === preg_match( '/^\s*SELECT\s+DATABASE\s*\(\s*\)\s*;?\s*$/i', $query ) + || WP_Markdown_Native_Query_Runtime::supports_tableless_scalar_projection( $query ); } private function has_unordered_unbounded_result( string $query ): bool { diff --git a/tests/smoke-native-scalar-clauses.php b/tests/smoke-native-scalar-clauses.php index ceb2f9b..7525d98 100644 --- a/tests/smoke-native-scalar-clauses.php +++ b/tests/smoke-native-scalar-clauses.php @@ -36,6 +36,7 @@ $json_invalid = $runtime->execute( new WP_Markdown_Query_Request( "SELECT JSON_VALID('{broken}')", 'wp_' ) ); $json_null = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT JSON_VALID(NULL)', 'wp_' ) ); $json_alias = $runtime->execute( new WP_Markdown_Query_Request( "SELECT JSON_VALID('[]') AS valid_json", 'wp_' ) ); +$literals = $runtime->execute( new WP_Markdown_Query_Request( "SELECT 1 AS one, 'event' AS label, NULL AS missing", 'wp_' ) ); $checks = array( 'WP date WHERE evaluates DATE_ADD INTERVAL after the bounded read' => array( '2', '3' ) === array_map( static fn( object $row ): string => $row->id, $where->wpdb_state()['last_result'] ), 'DATE_SUB INTERVAL and TIMESTAMPDIFF use the shared WHERE scalar path' => array( '1' ) === array_map( static fn( object $row ): string => $row->id, $subtracted->wpdb_state()['last_result'] ) @@ -68,8 +69,11 @@ && '0' === ( $json_invalid->wpdb_state()['last_result'][0]->{'JSON_VALID(\'{broken}\')'} ?? null ) && null === ( $json_null->wpdb_state()['last_result'][0]->{'JSON_VALID(NULL)'} ?? null ) && 'JSON_VALID(\'{"event":true}\')' === ( $json_valid->wpdb_state()['col_info'][0]->name ?? null ) - && 8 === ( $json_valid->wpdb_state()['col_info'][0]->type ?? null ) + && 3 === ( $json_valid->wpdb_state()['col_info'][0]->type ?? null ) && '1' === ( $json_alias->wpdb_state()['last_result'][0]->valid_json ?? null ), + 'tableless numeric, string, and NULL literals preserve aliases, values, and MySQL field types' => array( 'one' => '1', 'label' => 'event', 'missing' => null ) === (array) ( $literals->wpdb_state()['last_result'][0] ?? array() ) + && array( 'one', 'label', 'missing' ) === array_map( static fn( object $column ): string => $column->name, $literals->wpdb_state()['col_info'] ) + && array( 3, 253, 6 ) === array_map( static fn( object $column ): int => $column->type, $literals->wpdb_state()['col_info'] ), ); $failed = false; foreach ( $checks as $label => $passed ) { echo ( $passed ? 'PASS: ' : 'FAIL: ' ) . $label . "\n"; $failed = $failed || ! $passed; } diff --git a/tests/smoke-native-shadow-sql-snapshot.php b/tests/smoke-native-shadow-sql-snapshot.php index f7a6636..fd39f21 100644 --- a/tests/smoke-native-shadow-sql-snapshot.php +++ b/tests/smoke-native-shadow-sql-snapshot.php @@ -192,8 +192,9 @@ public function get_col_info( string $field ): array { 1, array( 'input_mode' => 'sql_snapshot' ) ); -$tableless->capture_input( 'SELECT 1', $database ); -$tableless->observe( 'SELECT 1', 1, $database ); +$database->result_rows( array( array( 'one' => '1' ) ), array( array( 'name' => 'one', 'type' => 3 ) ) ); +$tableless->capture_input( 'SELECT 1 AS one', $database ); +$tableless->observe( 'SELECT 1 AS one', 1, $database ); $capture_count_at_bound = count( $database->source()->results ); $bounded->capture_input( 'SELECT ID, post_title FROM wp_posts', $database ); $bounded->observe( 'SELECT ID, post_title FROM wp_posts', 1, $database ); @@ -223,7 +224,9 @@ public function get_col_info( string $field ): array { 'validated non-WordPress-prefixed plugin tables compile by exact captured identity' => array( 'agents' ) === array_column( $plugin_table['tables'], 'table' ), 'absent blog-2 schemas remain explicit snapshot input limitations' => 'source_schema_unavailable' === $missing_schema_reason, 'capture does no source work after the observation cap and drops the matching observation' => $capture_count_at_bound === count( $database->source()->results ) && 1 === $bounded->report()['counts']['dropped'], - 'tableless native SQL retains its parser unsupported diagnostic' => 'markdown_db_native_unsupported_query' === ( $tableless->report()['first_blocker']['native_diagnostic']['code'] ?? null ), + 'tableless scalar SQL is independently compared through the stateless runtime path' => 1 === $tableless->report()['counts']['compatible'] + && 'native_runtime_fast_path' === ( $tableless->report()['context']['last_input_state']['read_connection'] ?? null ) + && array() === ( $tableless->report()['context']['last_input_state']['tables'] ?? null ), 'capture results are released after both schema and row reads' => array_reduce( $database->source()->results, static fn( bool $freed, MDI_Snapshot_Result $result ): bool => $freed && $result->freed, true ), ); $failed = 0; From 9b64bdf51cd38fea148a4c215a4fae99b20b73d9 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 9 Sep 2026 08:07:13 -0400 Subject: [PATCH 04/53] test: trace native shadow runtime phases --- ...-markdown-native-authoritative-snapshot-runtime.php | 9 +++++++++ inc/native/class-wp-markdown-native-query-executor.php | 9 +++++++++ inc/native/class-wp-markdown-native-query-parser.php | 9 +++++++++ tests/run-mysql-shadow-corpus.php | 10 ++++++++-- 4 files changed, 35 insertions(+), 2 deletions(-) diff --git a/inc/native/class-wp-markdown-native-authoritative-snapshot-runtime.php b/inc/native/class-wp-markdown-native-authoritative-snapshot-runtime.php index 628d445..10369ac 100644 --- a/inc/native/class-wp-markdown-native-authoritative-snapshot-runtime.php +++ b/inc/native/class-wp-markdown-native-authoritative-snapshot-runtime.php @@ -14,6 +14,7 @@ final class WP_Markdown_Native_Authoritative_Snapshot_Runtime implements WP_Mark public function __construct( private WP_Markdown_Query_Runtime $runtime, private array $provenance ) {} public static function capture( object $database, string $sql, string $prefix ): self { + self::trace_runtime_phase( 'capture' ); $connection = method_exists( $database, 'markdown_db_mysql_connection' ) ? $database->markdown_db_mysql_connection() : ( $database->dbh ?? null ); @@ -45,6 +46,14 @@ public static function capture( object $database, string $sql, string $prefix ): return new self( new WP_Markdown_Native_Query_Runtime( $registry ), $provenance ); } + private static function trace_runtime_phase( string $phase ): void { + $path = getenv( 'MARKDOWN_DB_NATIVE_SHADOW_TRACE_PATH' ); + if ( ! is_string( $path ) || '' === $path ) { + return; + } + file_put_contents( $path, json_encode( array( 'phase' => $phase, 'file_sha256' => hash_file( 'sha256', __FILE__ ) ), JSON_UNESCAPED_SLASHES ) . "\n", FILE_APPEND | LOCK_EX ); + } + /** @return array */ private static function schema_prefixes( object $database, string $prefix ): array { $prefixes = array( $prefix ); diff --git a/inc/native/class-wp-markdown-native-query-executor.php b/inc/native/class-wp-markdown-native-query-executor.php index 8e64b90..b45e169 100644 --- a/inc/native/class-wp-markdown-native-query-executor.php +++ b/inc/native/class-wp-markdown-native-query-executor.php @@ -56,6 +56,7 @@ public function __construct( } public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query_Result { + self::trace_runtime_phase( 'executor' ); $transaction_control = WP_Markdown_SQL_Classifier::transaction_control( $request->sql() ); if ( null !== $transaction_control ) { return $this->execute_transaction_control( $transaction_control ); @@ -124,6 +125,14 @@ public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query return $this->execute_plan( $plan ); } + private static function trace_runtime_phase( string $phase ): void { + $path = getenv( 'MARKDOWN_DB_NATIVE_SHADOW_TRACE_PATH' ); + if ( ! is_string( $path ) || '' === $path ) { + return; + } + file_put_contents( $path, json_encode( array( 'phase' => $phase, 'file_sha256' => hash_file( 'sha256', __FILE__ ) ), JSON_UNESCAPED_SLASHES ) . "\n", FILE_APPEND | LOCK_EX ); + } + /** Execute source-free typed scalar expressions as the one-row SQL result. */ private function tableless_scalar_projection( string $sql ): ?WP_Markdown_Query_Result { $projection = $this->parser->parse_tableless_scalar_projection( $sql ); diff --git a/inc/native/class-wp-markdown-native-query-parser.php b/inc/native/class-wp-markdown-native-query-parser.php index 19dda07..e5e8170 100644 --- a/inc/native/class-wp-markdown-native-query-parser.php +++ b/inc/native/class-wp-markdown-native-query-parser.php @@ -11,6 +11,7 @@ public function __construct( ) {} public function parse( string $sql ): WP_Markdown_Native_Query_Plan|WP_Markdown_Native_Found_Rows_Plan|WP_Markdown_Query_Result { + self::trace_runtime_phase( 'parser' ); $ast = $this->parse_ast( $sql ); if ( $ast instanceof WP_Markdown_Query_Result ) { return $ast; @@ -22,6 +23,14 @@ public function parse( string $sql ): WP_Markdown_Native_Query_Plan|WP_Markdown_ } } + private static function trace_runtime_phase( string $phase ): void { + $path = getenv( 'MARKDOWN_DB_NATIVE_SHADOW_TRACE_PATH' ); + if ( ! is_string( $path ) || '' === $path ) { + return; + } + file_put_contents( $path, json_encode( array( 'phase' => $phase, 'file_sha256' => hash_file( 'sha256', __FILE__ ) ), JSON_UNESCAPED_SLASHES ) . "\n", FILE_APPEND | LOCK_EX ); + } + /** * Parse scalar projections that have no row source without fabricating a * source schema. The synthetic FROM exists only to reuse the typed SELECT diff --git a/tests/run-mysql-shadow-corpus.php b/tests/run-mysql-shadow-corpus.php index 546d359..cf6ce4e 100644 --- a/tests/run-mysql-shadow-corpus.php +++ b/tests/run-mysql-shadow-corpus.php @@ -26,6 +26,8 @@ $artifacts = $root . '/artifacts'; $report_path = '/tmp/mdi-shadow-report.json'; $report_name = 'mdi-shadow-report'; +$trace_path = '/tmp/mdi-shadow-runtime-trace.jsonl'; +$trace_name = 'mdi-shadow-runtime-trace'; $revision = trim( (string) shell_exec( 'git -C ' . escapeshellarg( $repo ) . ' rev-parse HEAD' ) ); mkdir( $bootstrap, 0755, true ); mkdir( $state, 0755, true ); @@ -72,7 +74,8 @@ 'MARKDOWN_DB_NATIVE_SHADOW' => 'true', 'MARKDOWN_DB_NATIVE_SHADOW_MAX' => '10000', 'MARKDOWN_DB_NATIVE_SHADOW_INPUT_MODE' => 'sql_snapshot', - 'MARKDOWN_DB_NATIVE_SHADOW_REPORT_PATH' => $report_path, + 'MARKDOWN_DB_NATIVE_SHADOW_REPORT_PATH' => $report_path, + 'MARKDOWN_DB_NATIVE_SHADOW_TRACE_PATH' => $trace_path, ), 'services' => array( array( 'id' => 'mysql', @@ -85,7 +88,10 @@ array( 'command' => 'wordpress.phpunit', 'args' => array_merge( array( 'plugin-slug=' . $plugin_slug, 'database-type=mysql', 'multisite=1' ), false === $harness_dir ? array() : array( 'autoload-file=/wordpress/wp-content/mdi-shadow-phpunit/autoload.php', 'tests-dir=/wordpress/wp-content/mdi-shadow-phpunit/wp-phpunit/wp-phpunit' ), array() === $dependency_mounts ? array() : array( 'dependency-mounts=' . implode( ',', $dependency_mounts ) ), $phpunit_args ), - 'resultPaths' => array( array( 'name' => $report_name, 'type' => 'mdi-native-shadow-report/v1', 'path' => $report_path, 'required' => true, 'maxBytes' => 1048576 ) ), + 'resultPaths' => array( + array( 'name' => $report_name, 'type' => 'mdi-native-shadow-report/v1', 'path' => $report_path, 'required' => true, 'maxBytes' => 1048576 ), + array( 'name' => $trace_name, 'type' => 'mdi-native-shadow-trace/v1', 'path' => $trace_path, 'required' => true, 'maxBytes' => 1048576 ), + ), ), ) ), 'artifacts' => array( 'directory' => $artifacts ), From abbaf140b7ce232451c7f51fc46a0290fb21298a Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 9 Sep 2026 08:09:09 -0400 Subject: [PATCH 05/53] test: expose native shadow trace path --- db.php | 6 ++++++ ...ss-wp-markdown-native-authoritative-snapshot-runtime.php | 2 +- inc/native/class-wp-markdown-native-query-executor.php | 2 +- inc/native/class-wp-markdown-native-query-parser.php | 2 +- tests/run-mysql-shadow-corpus.php | 4 ++-- 5 files changed, 11 insertions(+), 5 deletions(-) diff --git a/db.php b/db.php index 3ca261e..fc866af 100644 --- a/db.php +++ b/db.php @@ -128,6 +128,12 @@ function markdown_database_integration_native_plugin_dir( string $content_dir ): define( 'MARKDOWN_DB_NATIVE_SHADOW_REPORT_PATH', $markdown_db_shadow_report_path ); } } +if ( ! defined( 'MARKDOWN_DB_NATIVE_SHADOW_TRACE_PATH' ) ) { + $markdown_db_shadow_trace_path = getenv( 'MARKDOWN_DB_NATIVE_SHADOW_TRACE_PATH' ); + if ( is_string( $markdown_db_shadow_trace_path ) && '' !== $markdown_db_shadow_trace_path ) { + define( 'MARKDOWN_DB_NATIVE_SHADOW_TRACE_PATH', $markdown_db_shadow_trace_path ); + } +} // Downstream capability resolution, health, and CLI read the same identifier, // so the backend is settled before it is published. An operator who named a diff --git a/inc/native/class-wp-markdown-native-authoritative-snapshot-runtime.php b/inc/native/class-wp-markdown-native-authoritative-snapshot-runtime.php index 10369ac..a4a0b86 100644 --- a/inc/native/class-wp-markdown-native-authoritative-snapshot-runtime.php +++ b/inc/native/class-wp-markdown-native-authoritative-snapshot-runtime.php @@ -47,7 +47,7 @@ public static function capture( object $database, string $sql, string $prefix ): } private static function trace_runtime_phase( string $phase ): void { - $path = getenv( 'MARKDOWN_DB_NATIVE_SHADOW_TRACE_PATH' ); + $path = defined( 'MARKDOWN_DB_NATIVE_SHADOW_TRACE_PATH' ) ? MARKDOWN_DB_NATIVE_SHADOW_TRACE_PATH : getenv( 'MARKDOWN_DB_NATIVE_SHADOW_TRACE_PATH' ); if ( ! is_string( $path ) || '' === $path ) { return; } diff --git a/inc/native/class-wp-markdown-native-query-executor.php b/inc/native/class-wp-markdown-native-query-executor.php index b45e169..e4e5fc5 100644 --- a/inc/native/class-wp-markdown-native-query-executor.php +++ b/inc/native/class-wp-markdown-native-query-executor.php @@ -126,7 +126,7 @@ public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query } private static function trace_runtime_phase( string $phase ): void { - $path = getenv( 'MARKDOWN_DB_NATIVE_SHADOW_TRACE_PATH' ); + $path = defined( 'MARKDOWN_DB_NATIVE_SHADOW_TRACE_PATH' ) ? MARKDOWN_DB_NATIVE_SHADOW_TRACE_PATH : getenv( 'MARKDOWN_DB_NATIVE_SHADOW_TRACE_PATH' ); if ( ! is_string( $path ) || '' === $path ) { return; } diff --git a/inc/native/class-wp-markdown-native-query-parser.php b/inc/native/class-wp-markdown-native-query-parser.php index e5e8170..0cf9189 100644 --- a/inc/native/class-wp-markdown-native-query-parser.php +++ b/inc/native/class-wp-markdown-native-query-parser.php @@ -24,7 +24,7 @@ public function parse( string $sql ): WP_Markdown_Native_Query_Plan|WP_Markdown_ } private static function trace_runtime_phase( string $phase ): void { - $path = getenv( 'MARKDOWN_DB_NATIVE_SHADOW_TRACE_PATH' ); + $path = defined( 'MARKDOWN_DB_NATIVE_SHADOW_TRACE_PATH' ) ? MARKDOWN_DB_NATIVE_SHADOW_TRACE_PATH : getenv( 'MARKDOWN_DB_NATIVE_SHADOW_TRACE_PATH' ); if ( ! is_string( $path ) || '' === $path ) { return; } diff --git a/tests/run-mysql-shadow-corpus.php b/tests/run-mysql-shadow-corpus.php index cf6ce4e..6f91db4 100644 --- a/tests/run-mysql-shadow-corpus.php +++ b/tests/run-mysql-shadow-corpus.php @@ -74,8 +74,8 @@ 'MARKDOWN_DB_NATIVE_SHADOW' => 'true', 'MARKDOWN_DB_NATIVE_SHADOW_MAX' => '10000', 'MARKDOWN_DB_NATIVE_SHADOW_INPUT_MODE' => 'sql_snapshot', - 'MARKDOWN_DB_NATIVE_SHADOW_REPORT_PATH' => $report_path, - 'MARKDOWN_DB_NATIVE_SHADOW_TRACE_PATH' => $trace_path, + 'MARKDOWN_DB_NATIVE_SHADOW_REPORT_PATH' => $report_path, + 'MARKDOWN_DB_NATIVE_SHADOW_TRACE_PATH' => $trace_path, ), 'services' => array( array( 'id' => 'mysql', From 387d20286a3f46cf7b2d77236875d4134fa52508 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 9 Sep 2026 08:10:53 -0400 Subject: [PATCH 06/53] test: keep shadow tracing non-blocking --- tests/run-mysql-shadow-corpus.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/run-mysql-shadow-corpus.php b/tests/run-mysql-shadow-corpus.php index 6f91db4..ca5dc57 100644 --- a/tests/run-mysql-shadow-corpus.php +++ b/tests/run-mysql-shadow-corpus.php @@ -27,7 +27,6 @@ $report_path = '/tmp/mdi-shadow-report.json'; $report_name = 'mdi-shadow-report'; $trace_path = '/tmp/mdi-shadow-runtime-trace.jsonl'; -$trace_name = 'mdi-shadow-runtime-trace'; $revision = trim( (string) shell_exec( 'git -C ' . escapeshellarg( $repo ) . ' rev-parse HEAD' ) ); mkdir( $bootstrap, 0755, true ); mkdir( $state, 0755, true ); @@ -90,7 +89,6 @@ 'args' => array_merge( array( 'plugin-slug=' . $plugin_slug, 'database-type=mysql', 'multisite=1' ), false === $harness_dir ? array() : array( 'autoload-file=/wordpress/wp-content/mdi-shadow-phpunit/autoload.php', 'tests-dir=/wordpress/wp-content/mdi-shadow-phpunit/wp-phpunit/wp-phpunit' ), array() === $dependency_mounts ? array() : array( 'dependency-mounts=' . implode( ',', $dependency_mounts ) ), $phpunit_args ), 'resultPaths' => array( array( 'name' => $report_name, 'type' => 'mdi-native-shadow-report/v1', 'path' => $report_path, 'required' => true, 'maxBytes' => 1048576 ), - array( 'name' => $trace_name, 'type' => 'mdi-native-shadow-trace/v1', 'path' => $trace_path, 'required' => true, 'maxBytes' => 1048576 ), ), ), ) ), From c4f7cdb17cb33eb27dac89db0910ff8f6a113546 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 9 Sep 2026 08:21:28 -0400 Subject: [PATCH 07/53] fix(native): preserve catalog predicate semantics --- ...lass-wp-markdown-native-query-executor.php | 13 +++- ...p-markdown-native-schema-introspection.php | 71 ++++++++++++------- tests/smoke-native-plugin-schema-query.php | 24 +++++-- tests/smoke-native-server-introspection.php | 3 + 4 files changed, 75 insertions(+), 36 deletions(-) diff --git a/inc/native/class-wp-markdown-native-query-executor.php b/inc/native/class-wp-markdown-native-query-executor.php index e4e5fc5..c93dc23 100644 --- a/inc/native/class-wp-markdown-native-query-executor.php +++ b/inc/native/class-wp-markdown-native-query-executor.php @@ -32,6 +32,8 @@ public function read( WP_Markdown_Native_Table_Access $access ): iterable|WP_Mar final class WP_Markdown_Native_Query_Runtime implements WP_Markdown_Query_Runtime { private const MAX_JOIN_CANDIDATE_PAIRS = 100000; private const MAX_CORRELATED_SUBQUERY_EVALUATIONS = 10000; + /** The largest SQL request accepted by the native request boundary. */ + public const MAX_SQL_BYTES = 67108864; private ?int $last_found_rows = null; private ?string $statement_now = null; /** @var array,has_null:bool}> */ @@ -57,6 +59,9 @@ public function __construct( public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query_Result { self::trace_runtime_phase( 'executor' ); + if ( strlen( $request->sql() ) > self::MAX_SQL_BYTES ) { + return $this->failure( 'request_too_large', 'mdi-native cannot execute a request larger than max_allowed_packet.' ); + } $transaction_control = WP_Markdown_SQL_Classifier::transaction_control( $request->sql() ); if ( null !== $transaction_control ) { return $this->execute_transaction_control( $transaction_control ); @@ -79,12 +84,14 @@ public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query if ( null !== $tableless ) { return $tableless; } - if ( 1 === preg_match( '/^\s*SELECT\s+(@@(?:SESSION\.)?(IN_TRANSACTION|AUTOCOMMIT))\s*;?\s*$/i', $request->sql(), $match ) ) { - $column = $match[1]; + if ( 1 === preg_match( '/^\s*SELECT\s+(@@(?:SESSION\.)?(IN_TRANSACTION|AUTOCOMMIT|MAX_ALLOWED_PACKET))(?:\s+AS\s+([A-Za-z_][A-Za-z0-9_]*))?\s*;?\s*$/i', $request->sql(), $match ) ) { + $column = $match[3] ?? $match[1]; $variable = strtolower( $match[2] ); $value = 'in_transaction' === $variable ? (string) (int) ( $this->transactions?->is_in_transaction() ?? false ) - : (string) (int) ( $this->transactions?->is_autocommit() ?? true ); + : ( 'autocommit' === $variable + ? (string) (int) ( $this->transactions?->is_autocommit() ?? true ) + : (string) self::MAX_SQL_BYTES ); return WP_Markdown_Query_Result::selected( array( array( $column => $value ) ), array( array( 'name' => $column, 'table' => '', 'type' => 8 ) ) diff --git a/inc/native/class-wp-markdown-native-schema-introspection.php b/inc/native/class-wp-markdown-native-schema-introspection.php index ade2529..3f71ada 100644 --- a/inc/native/class-wp-markdown-native-schema-introspection.php +++ b/inc/native/class-wp-markdown-native-schema-introspection.php @@ -305,62 +305,61 @@ public function select_information_schema( WP_Markdown_Query_Request $request ): if ( ! $word( 'WHERE' ) ) { return $this->failure( 'unsupported_lookup', 'mdi-native requires a bounded information_schema table lookup.' ); } - $names = array(); - $column_names = array(); - $schema_match = false; + $predicates = array(); do { $column = strtoupper( (string) $identifier() ); + $values = array(); + if ( ! in_array( $column, array( 'TABLE_SCHEMA', 'TABLE_NAME', 'COLUMN_NAME' ), true ) ) { + return null; + } if ( 'TABLE_SCHEMA' === $column && WP_Markdown_Native_SQL_Token::EQUALS === ( $tokens[ $position ] ?? null )?->type() ) { ++$position; - $schema_match = $word( 'DATABASE' ) && WP_Markdown_Native_SQL_Token::LEFT_PAREN === ( $tokens[ $position ] ?? null )?->type() && WP_Markdown_Native_SQL_Token::RIGHT_PAREN === ( $tokens[ $position + 1 ] ?? null )?->type(); - $position += $schema_match ? 2 : 0; - if ( ! $schema_match && WP_Markdown_Native_SQL_Token::STRING === ( $tokens[ $position ] ?? null )?->type() ) { - $schema_match = true; - ++$position; + if ( $word( 'DATABASE' ) && WP_Markdown_Native_SQL_Token::LEFT_PAREN === ( $tokens[ $position ] ?? null )?->type() && WP_Markdown_Native_SQL_Token::RIGHT_PAREN === ( $tokens[ $position + 1 ] ?? null )?->type() ) { + $values[] = defined( 'DB_NAME' ) ? (string) DB_NAME : ''; + $position += 2; + } elseif ( WP_Markdown_Native_SQL_Token::STRING === ( $tokens[ $position ] ?? null )?->type() ) { + $values[] = (string) $tokens[ $position++ ]->value(); + } else { + return null; } - } elseif ( 'TABLE_NAME' === $column && ( $word( 'IN' ) || WP_Markdown_Native_SQL_Token::EQUALS === ( $tokens[ $position ] ?? null )?->type() ) ) { + } elseif ( ( 'TABLE_NAME' === $column || 'COLUMN_NAME' === $column ) && ( $word( 'IN' ) || WP_Markdown_Native_SQL_Token::EQUALS === ( $tokens[ $position ] ?? null )?->type() ) ) { if ( WP_Markdown_Native_SQL_Token::EQUALS === ( $tokens[ $position ] ?? null )?->type() ) { ++$position; $token = $tokens[ $position++ ] ?? null; if ( ! $token instanceof WP_Markdown_Native_SQL_Token || WP_Markdown_Native_SQL_Token::STRING !== $token->type() ) { return null; } - $names[] = (string) $token->value(); + $values[] = (string) $token->value(); } else { if ( WP_Markdown_Native_SQL_Token::LEFT_PAREN !== ( $tokens[ $position ] ?? null )?->type() ) { return null; } ++$position; do { $token = $tokens[ $position++ ] ?? null; if ( ! $token instanceof WP_Markdown_Native_SQL_Token || WP_Markdown_Native_SQL_Token::STRING !== $token->type() ) { return null; } - $names[] = (string) $token->value(); + $values[] = (string) $token->value(); } while ( WP_Markdown_Native_SQL_Token::COMMA === ( $tokens[ $position ] ?? null )?->type() && ++$position ); if ( WP_Markdown_Native_SQL_Token::RIGHT_PAREN !== ( $tokens[ $position ] ?? null )?->type() ) { return null; } ++$position; } - } elseif ( 'COLUMN_NAME' === $column && $word( 'IN' ) ) { - if ( WP_Markdown_Native_SQL_Token::LEFT_PAREN !== ( $tokens[ $position ] ?? null )?->type() ) { return null; } - ++$position; - do { - $token = $tokens[ $position++ ] ?? null; - if ( ! $token instanceof WP_Markdown_Native_SQL_Token || WP_Markdown_Native_SQL_Token::STRING !== $token->type() ) { return null; } - $column_names[] = (string) $token->value(); - } while ( WP_Markdown_Native_SQL_Token::COMMA === ( $tokens[ $position ] ?? null )?->type() && ++$position ); - if ( WP_Markdown_Native_SQL_Token::RIGHT_PAREN !== ( $tokens[ $position ] ?? null )?->type() ) { return null; } - ++$position; } else { return null; } + $predicates[ $column ] = isset( $predicates[ $column ] ) ? array_values( array_intersect( $predicates[ $column ], $values ) ) : array_values( array_unique( $values ) ); } while ( $word( 'AND' ) ); - if ( ! $schema_match || array() === $names || WP_Markdown_Native_SQL_Token::END !== ( $tokens[ $position ] ?? null )?->type() ) { + if ( ! isset( $predicates['TABLE_SCHEMA'], $predicates['TABLE_NAME'] ) || WP_Markdown_Native_SQL_Token::END !== ( $tokens[ $position ] ?? null )?->type() ) { return $this->failure( 'unsupported_lookup', 'mdi-native requires a bounded information_schema table lookup.' ); } + $schema = defined( 'DB_NAME' ) ? (string) DB_NAME : ''; + if ( ! in_array( $schema, $predicates['TABLE_SCHEMA'], true ) || array() === $predicates['TABLE_NAME'] ) { + return WP_Markdown_Query_Result::selected( array(), $this->information_schema_metadata( $projection, $catalog ) ); + } $rows = array(); - foreach ( array_values( array_unique( $names ) ) as $table ) { + foreach ( $predicates['TABLE_NAME'] as $table ) { $definition = $this->registry->definition( $table ); if ( null === $definition || array() === $definition ) { continue; } $catalog_rows = 'COLUMNS' === $catalog ? $this->information_schema_columns( $table, $definition ) : array( $this->information_schema_table( $table ) ); foreach ( $catalog_rows as $catalog_row ) { - if ( array() !== $column_names && ! in_array( $catalog_row['COLUMN_NAME'] ?? null, $column_names, true ) ) { + if ( isset( $predicates['COLUMN_NAME'] ) && ! in_array( $catalog_row['COLUMN_NAME'] ?? null, $predicates['COLUMN_NAME'], true ) ) { continue; } $row = array(); @@ -373,7 +372,7 @@ public function select_information_schema( WP_Markdown_Query_Request $request ): $rows[] = $row; } } - return WP_Markdown_Query_Result::selected( $rows, array_map( static fn( array $column ): array => array( 'name' => $column['alias'], 'table' => '', 'type' => 253 ), $projection ) ); + return WP_Markdown_Query_Result::selected( $rows, $this->information_schema_metadata( $projection, $catalog ) ); } catch ( WP_Markdown_Native_SQL_Parse_Error ) { return null; } @@ -394,6 +393,7 @@ private function information_schema_columns( string $table, array $definition ): 'COLUMN_TYPE' => $this->column_type( $column ), 'COLUMN_KEY' => $this->column_key( $position, $definition['indexes'] ), 'EXTRA' => $column['auto_increment'] ? 'auto_increment' : '', + 'CHARACTER_MAXIMUM_LENGTH' => null === $this->character_maximum_length( $column ) ? null : (string) $this->character_maximum_length( $column ), ); } return $rows; @@ -401,7 +401,26 @@ private function information_schema_columns( string $table, array $definition ): /** @return array */ private function information_schema_table( string $table ): array { - return array( 'TABLE_SCHEMA' => defined( 'DB_NAME' ) ? (string) DB_NAME : '', 'TABLE_NAME' => $table, 'ENGINE' => 'InnoDB', 'TABLE_TYPE' => 'BASE TABLE' ); + return array( 'TABLE_SCHEMA' => defined( 'DB_NAME' ) ? (string) DB_NAME : '', 'TABLE_NAME' => $table, 'TABLE_TYPE' => 'BASE TABLE' ); + } + + /** @param array $column */ + private function character_maximum_length( array $column ): ?int { + return in_array( strtolower( (string) $column['type'] ), array( 'char', 'varchar', 'binary', 'varbinary' ), true ) && is_int( $column['length'] ) + ? $column['length'] + : null; + } + + /** @param array $projection @return array */ + private function information_schema_metadata( array $projection, string $catalog ): array { + return array_map( + static fn( array $column ): array => array( + 'name' => $column['alias'], + 'table' => $catalog, + 'type' => in_array( $column['name'], array( 'ORDINAL_POSITION', 'CHARACTER_MAXIMUM_LENGTH' ), true ) ? 8 : 253, + ), + $projection + ); } /** diff --git a/tests/smoke-native-plugin-schema-query.php b/tests/smoke-native-plugin-schema-query.php index dba5d60..96dbd26 100644 --- a/tests/smoke-native-plugin-schema-query.php +++ b/tests/smoke-native-plugin-schema-query.php @@ -4,6 +4,7 @@ declare( strict_types=1 ); define( 'ABSPATH', __DIR__ . '/' ); +define( 'DB_NAME', 'wordpress' ); function apply_filters( string $tag, mixed $value, mixed ...$args ): mixed { if ( 'markdown_db_table_durability_policy' === $tag && 'ephemeral_native' === ( $args[0] ?? null ) ) { @@ -120,8 +121,11 @@ function mdi_plugin_schema_remove_tree( string $root ): void { $show_full_columns = $runtime->execute( new WP_Markdown_Query_Request( 'SHOW FULL COLUMNS FROM wp_plugin_jobs' ) ); $show_full_missing = $runtime->execute( new WP_Markdown_Query_Request( 'SHOW FULL COLUMNS FROM wp_missing' ) ); $show_indexes = $runtime->execute( new WP_Markdown_Query_Request( 'SHOW INDEX FROM `wp_plugin_jobs`' ) ); -$information_columns = $runtime->execute( new WP_Markdown_Query_Request( "SELECT TABLE_NAME, COLUMN_NAME, ORDINAL_POSITION, COLUMN_DEFAULT, IS_NULLABLE, DATA_TYPE, COLUMN_TYPE, COLUMN_KEY, EXTRA FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'wordpress' AND TABLE_NAME IN ('wp_plugin_jobs', 'wp_inline_items') AND COLUMN_NAME IN ('id', 'owner_id', 'value')" ) ); -$information_tables = $runtime->execute( new WP_Markdown_Query_Request( "SELECT ENGINE AS Engine FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'wp_plugin_jobs'" ) ); +$information_columns = $runtime->execute( new WP_Markdown_Query_Request( "SELECT TABLE_NAME, COLUMN_NAME, ORDINAL_POSITION, CHARACTER_MAXIMUM_LENGTH, COLUMN_DEFAULT, IS_NULLABLE, DATA_TYPE, COLUMN_TYPE, COLUMN_KEY, EXTRA FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'wordpress' AND TABLE_NAME IN ('wp_plugin_jobs', 'wp_inline_items') AND COLUMN_NAME IN ('id', 'owner_id', 'status', 'value')" ) ); +$absent_information_schema = $runtime->execute( new WP_Markdown_Query_Request( "SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = 'absent_schema' AND TABLE_NAME = 'wp_plugin_jobs'" ) ); +$contradictory_information_tables = $runtime->execute( new WP_Markdown_Query_Request( "SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'wp_plugin_jobs' AND TABLE_NAME = 'wp_inline_items'" ) ); +$contradictory_information_columns = $runtime->execute( new WP_Markdown_Query_Request( "SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'wp_plugin_jobs' AND COLUMN_NAME = 'id' AND COLUMN_NAME = 'status'" ) ); +$information_engine = $runtime->execute( new WP_Markdown_Query_Request( "SELECT ENGINE AS Engine FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'wp_plugin_jobs'" ) ); $unbounded_information = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE()' ) ); file_put_contents( $root . '/_tables/plugin_jobs.json', @@ -140,7 +144,7 @@ function mdi_plugin_schema_remove_tree( string $root ): void { && 'queued' === ( $exact->wpdb_state()['last_result'][0]->status ?? null ) && '2' === ( $exact->wpdb_state()['last_result'][0]->id ?? null ), 'generic table introspection exposes registered tables with MySQL LIKE semantics' => 1 === $show_table->return_value() - && 'wp_plugin_jobs' === ( $show_table->wpdb_state()['last_result'][0]->{'Tables_in_'} ?? null ) + && 'wp_plugin_jobs' === ( $show_table->wpdb_state()['last_result'][0]->Tables_in_wordpress ?? null ) && 1 === $show_table_wildcard->return_value() && 1 === $show_table_escaped->return_value() && 0 === $show_missing_table->return_value(), @@ -163,11 +167,17 @@ function mdi_plugin_schema_remove_tree( string $root ): void { $show_indexes->wpdb_state()['last_result'] ) && array( '0', '1' ) === array_map( static fn( object $row ): string => $row->Non_unique, $show_indexes->wpdb_state()['last_result'] ), - 'bounded information_schema reads derive column and engine metadata from registered DDL' => array( 'wp_plugin_jobs', 'wp_plugin_jobs', 'wp_inline_items', 'wp_inline_items' ) === array_map( static fn( object $row ): string => $row->TABLE_NAME, $information_columns->wpdb_state()['last_result'] ) - && array( 'id', 'owner_id', 'id', 'value' ) === array_map( static fn( object $row ): string => $row->COLUMN_NAME, $information_columns->wpdb_state()['last_result'] ) + 'bounded information_schema reads derive column metadata from registered DDL' => array( 'wp_plugin_jobs', 'wp_plugin_jobs', 'wp_plugin_jobs', 'wp_inline_items', 'wp_inline_items' ) === array_map( static fn( object $row ): string => $row->TABLE_NAME, $information_columns->wpdb_state()['last_result'] ) + && array( 'id', 'owner_id', 'status', 'id', 'value' ) === array_map( static fn( object $row ): string => $row->COLUMN_NAME, $information_columns->wpdb_state()['last_result'] ) && 'PRI' === ( $information_columns->wpdb_state()['last_result'][0]->COLUMN_KEY ?? null ) - && 'InnoDB' === ( $information_tables->wpdb_state()['last_result'][0]->Engine ?? null ) - && array( 'Engine' ) === array_map( static fn( object $column ): string => $column->name, $information_tables->wpdb_state()['col_info'] ), + && '32' === ( $information_columns->wpdb_state()['last_result'][2]->CHARACTER_MAXIMUM_LENGTH ?? null ) + && null === ( $information_columns->wpdb_state()['last_result'][0]->CHARACTER_MAXIMUM_LENGTH ?? null ) + && array( 253, 253, 8, 8, 253, 253, 253, 253, 253, 253 ) === array_map( static fn( object $column ): int => $column->type, $information_columns->wpdb_state()['col_info'] ), + 'information_schema predicates preserve schema equality and AND intersections' => 0 === $absent_information_schema->return_value() + && 0 === $contradictory_information_tables->return_value() + && 0 === $contradictory_information_columns->return_value(), + 'information_schema does not manufacture a transactional storage engine' => false === $information_engine->return_value() + && 'unsupported_column' === ( $information_engine->diagnostic()['reason'] ?? null ), 'information_schema catalog scans remain fail-closed without a bounded table name' => false === $unbounded_information->return_value() && 'unsupported_lookup' === ( $unbounded_information->diagnostic()['reason'] ?? null ), 'primary and secondary numeric indexes derive bounded lookup capabilities' => array( '1', '2' ) === array_map( diff --git a/tests/smoke-native-server-introspection.php b/tests/smoke-native-server-introspection.php index 4da0e6c..2e45452 100644 --- a/tests/smoke-native-server-introspection.php +++ b/tests/smoke-native-server-introspection.php @@ -23,12 +23,15 @@ $liked_names = array_map( static fn( object $row ): string => (string) $row->Variable_name, $liked->wpdb_state()['last_result'] ); $status = $runtime->execute( new WP_Markdown_Query_Request( "SHOW GLOBAL STATUS LIKE 'Uptime'", 'wp_' ) ); $database = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT DATABASE()', 'wp_' ) ); +$max_allowed_packet = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT @@SESSION.max_allowed_packet as packet_limit', 'wp_' ) ); $columns = array_map( static fn( object $column ): string => $column->name, $named->wpdb_state()['col_info'] ); $checks = array( 'named variables report engine identity' => WP_Markdown_Native_Schema_Catalog::SERVER_VERSION === ( $variables['version'] ?? null ) && array_key_exists( 'sql_mode', $variables ), 'a client/server tuning knob is absent rather than invented' => ! array_key_exists( 'max_allowed_packet', $variables ), + 'SELECT session max_allowed_packet reports the native request boundary' => (string) WP_Markdown_Native_Query_Runtime::MAX_SQL_BYTES === ( $max_allowed_packet->wpdb_state()['last_result'][0]->packet_limit ?? null ) + && 8 === ( $max_allowed_packet->wpdb_state()['col_info'][0]->type ?? null ), 'LIKE selects matching variables' => array( 'character_set_server' ) === $liked_names, 'SHOW STATUS answers with a scoped qualifier' => array( 'Uptime' ) === array_map( static fn( object $row ): string => (string) $row->Variable_name, From 89e739ef3cd973df18206b1a45d79815fd44a404 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 9 Sep 2026 10:29:36 -0400 Subject: [PATCH 08/53] fix(native): bound reviewed SQL semantics --- ...lass-wp-markdown-native-query-executor.php | 29 +++++++++---------- ...p-markdown-native-schema-introspection.php | 26 +++++++++++++++-- tests/smoke-native-plugin-schema-query.php | 8 +++++ tests/smoke-native-scalar-clauses.php | 9 +++++- 4 files changed, 52 insertions(+), 20 deletions(-) diff --git a/inc/native/class-wp-markdown-native-query-executor.php b/inc/native/class-wp-markdown-native-query-executor.php index 39fc5cd..6ecd1f4 100644 --- a/inc/native/class-wp-markdown-native-query-executor.php +++ b/inc/native/class-wp-markdown-native-query-executor.php @@ -63,6 +63,11 @@ public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query if ( strlen( $request->sql() ) > self::MAX_SQL_BYTES ) { return $this->failure( 'request_too_large', 'mdi-native cannot execute a request larger than max_allowed_packet.' ); } + // Scalar functions share one statement scope, including tableless SELECTs. + $this->rand_states = array(); + $this->correlated_subquery_cache = array(); + $this->correlated_subquery_failure = null; + $this->statement_now = gmdate( 'Y-m-d H:i:s' ); $transaction_control = WP_Markdown_SQL_Classifier::transaction_control( $request->sql() ); if ( null !== $transaction_control ) { return $this->execute_transaction_control( $transaction_control ); @@ -118,10 +123,6 @@ public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query ? $this->failure( 'unsupported_grammar', 'mdi-native supports bounded SELECT queries only.' ) : $this->option_mutations->execute( $request ); } - $this->rand_states = array(); - $this->correlated_subquery_cache = array(); - $this->correlated_subquery_failure = null; - $this->statement_now = gmdate( 'Y-m-d H:i:s' ); $plan = $this->parser->parse( $request->sql() ); if ( $plan instanceof WP_Markdown_Query_Result ) { return $plan; @@ -335,7 +336,7 @@ private function execute_plan( WP_Markdown_Native_Query_Plan $plan, bool $allow_ } } $pushdown = $this->pushdown( $predicates, $schema ); - if ( array() !== $predicates && null === $pushdown && ! $this->allows_residual_scan( $predicates, $schema, PHP_INT_MAX !== $plan->limit() ) ) { + if ( array() !== $predicates && null === $pushdown && ! $this->allows_residual_scan( $predicates, $schema ) ) { return $this->failure( 'unsupported_lookup', 'mdi-native requires one indexable predicate for a filtered query.' ); } foreach ( $plan->order_by() as $item ) { @@ -1752,7 +1753,7 @@ private function derived_source( WP_Markdown_Native_Query_Plan $plan, string $na } /** @param array $predicates */ - private function allows_residual_scan( array $predicates, WP_Markdown_Native_Table_Schema $schema, bool $bounded = false ): bool { + private function allows_residual_scan( array $predicates, WP_Markdown_Native_Table_Schema $schema ): bool { $indexed = $this->indexed_columns( $schema ); foreach ( $predicates as $predicate ) { if ( null !== $predicate->cast() ) { @@ -1788,12 +1789,6 @@ private function allows_residual_scan( array $predicates, WP_Markdown_Native_Tab && $schema->allows_filter( $column, $predicate->operator(), $predicate->values() ) ) { continue; } - // Providers already apply all schema-validated residual filters before - // ORDER/LIMIT. Do not require a separate lookup declaration merely - // because a bounded query combines ordinary text predicates. - if ( $bounded && $schema->allows_filter( $column, $predicate->operator(), $predicate->values() ) ) { - continue; - } return false; } return array() !== $predicates; @@ -2116,7 +2111,9 @@ private function scalar_number( int|float|string|null $value ): int|string|null| private function json_valid( string $value ): string { try { - json_decode( $value, true, 512, JSON_THROW_ON_ERROR ); + // MariaDB 11.4 rejects JSON nesting at 32 levels; its parser counts + // the outermost array/object as the first level. + json_decode( $value, true, 32, JSON_THROW_ON_ERROR ); return '1'; } catch ( JsonException ) { return '0'; @@ -2305,9 +2302,9 @@ private function rand( int|string|null $seed ): string { return (string) ( random_int( 0, PHP_INT_MAX ) / PHP_INT_MAX ); } $maximum = 0x3fffffff; - $key = (string) $seed; - $this->rand_states[ $key ] ??= array( 'seed1' => ( (int) $seed * 0x10001 + 55555555 ) % $maximum, 'seed2' => ( (int) $seed * 0x10000001 ) % $maximum ); - $state = &$this->rand_states[ $key ]; + // RAND(seed) reseeds for each expression evaluation, so two occurrences + // of the same seeded expression in one projection return the same value. + $state = array( 'seed1' => ( (int) $seed * 0x10001 + 55555555 ) % $maximum, 'seed2' => ( (int) $seed * 0x10000001 ) % $maximum ); $state['seed1'] = ( $state['seed1'] * 3 + $state['seed2'] ) % $maximum; $state['seed2'] = ( $state['seed1'] + $state['seed2'] + 33 ) % $maximum; return (string) ( $state['seed1'] / $maximum ); diff --git a/inc/native/class-wp-markdown-native-schema-introspection.php b/inc/native/class-wp-markdown-native-schema-introspection.php index 3f71ada..7c0ef83 100644 --- a/inc/native/class-wp-markdown-native-schema-introspection.php +++ b/inc/native/class-wp-markdown-native-schema-introspection.php @@ -224,6 +224,9 @@ private function current(): WP_Markdown_Native_SQL_Token { } final class WP_Markdown_Native_Schema_Introspection { + private const MAX_INFORMATION_SCHEMA_PROJECTIONS = 32; + private const MAX_INFORMATION_SCHEMA_VALUES = 100; + private const MAX_INFORMATION_SCHEMA_ROWS = 1000; public function __construct( private readonly WP_Markdown_Native_Table_Registry $registry, private readonly WP_Markdown_Native_Schema_Introspection_Parser $parser = new WP_Markdown_Native_Schema_Introspection_Parser() @@ -281,6 +284,9 @@ public function select_information_schema( WP_Markdown_Query_Request $request ): } $projection = array(); do { + if ( count( $projection ) >= self::MAX_INFORMATION_SCHEMA_PROJECTIONS ) { + return $this->failure( 'resource_limit', 'mdi-native limits information_schema projection cardinality.' ); + } $name = $identifier(); if ( null === $name ) { return null; @@ -332,6 +338,9 @@ public function select_information_schema( WP_Markdown_Query_Request $request ): if ( WP_Markdown_Native_SQL_Token::LEFT_PAREN !== ( $tokens[ $position ] ?? null )?->type() ) { return null; } ++$position; do { + if ( count( $values ) >= self::MAX_INFORMATION_SCHEMA_VALUES ) { + return $this->failure( 'resource_limit', 'mdi-native limits information_schema predicate cardinality.' ); + } $token = $tokens[ $position++ ] ?? null; if ( ! $token instanceof WP_Markdown_Native_SQL_Token || WP_Markdown_Native_SQL_Token::STRING !== $token->type() ) { return null; } $values[] = (string) $token->value(); @@ -359,6 +368,9 @@ public function select_information_schema( WP_Markdown_Query_Request $request ): } $catalog_rows = 'COLUMNS' === $catalog ? $this->information_schema_columns( $table, $definition ) : array( $this->information_schema_table( $table ) ); foreach ( $catalog_rows as $catalog_row ) { + if ( count( $rows ) >= self::MAX_INFORMATION_SCHEMA_ROWS ) { + return $this->failure( 'resource_limit', 'mdi-native limits information_schema result cardinality.' ); + } if ( isset( $predicates['COLUMN_NAME'] ) && ! in_array( $catalog_row['COLUMN_NAME'] ?? null, $predicates['COLUMN_NAME'], true ) ) { continue; } @@ -406,9 +418,17 @@ private function information_schema_table( string $table ): array { /** @param array $column */ private function character_maximum_length( array $column ): ?int { - return in_array( strtolower( (string) $column['type'] ), array( 'char', 'varchar', 'binary', 'varbinary' ), true ) && is_int( $column['length'] ) - ? $column['length'] - : null; + $type = strtolower( (string) $column['type'] ); + if ( in_array( $type, array( 'char', 'varchar', 'binary', 'varbinary' ), true ) && is_int( $column['length'] ) ) { + return $column['length']; + } + return match ( $type ) { + 'tinytext', 'tinyblob' => 255, + 'text', 'blob' => 65535, + 'mediumtext', 'mediumblob' => 16777215, + 'longtext', 'longblob' => 4294967295, + default => null, + }; } /** @param array $projection @return array */ diff --git a/tests/smoke-native-plugin-schema-query.php b/tests/smoke-native-plugin-schema-query.php index 96dbd26..ad1be2b 100644 --- a/tests/smoke-native-plugin-schema-query.php +++ b/tests/smoke-native-plugin-schema-query.php @@ -127,6 +127,9 @@ function mdi_plugin_schema_remove_tree( string $root ): void { $contradictory_information_columns = $runtime->execute( new WP_Markdown_Query_Request( "SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'wp_plugin_jobs' AND COLUMN_NAME = 'id' AND COLUMN_NAME = 'status'" ) ); $information_engine = $runtime->execute( new WP_Markdown_Query_Request( "SELECT ENGINE AS Engine FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'wp_plugin_jobs'" ) ); $unbounded_information = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE()' ) ); +$text_information = $runtime->execute( new WP_Markdown_Query_Request( "SELECT COLUMN_NAME, CHARACTER_MAXIMUM_LENGTH FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'wp_plugin_jobs' AND COLUMN_NAME IN ('task_url', 'payload')" ) ); +$overwide_information = $runtime->execute( new WP_Markdown_Query_Request( "SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN (" . implode( ',', array_fill( 0, 101, "'wp_plugin_jobs'" ) ) . ')' ) ); +$limited_residual = $runtime->execute( new WP_Markdown_Query_Request( "SELECT id FROM wp_plugin_jobs WHERE status = 'queued' LIMIT 1" ) ); file_put_contents( $root . '/_tables/plugin_jobs.json', json_encode( @@ -180,6 +183,11 @@ function mdi_plugin_schema_remove_tree( string $root ): void { && 'unsupported_column' === ( $information_engine->diagnostic()['reason'] ?? null ), 'information_schema catalog scans remain fail-closed without a bounded table name' => false === $unbounded_information->return_value() && 'unsupported_lookup' === ( $unbounded_information->diagnostic()['reason'] ?? null ), + 'information_schema reports TEXT character maxima and bounds list cardinality' => array( 'task_url' => '65535', 'payload' => '4294967295' ) === array_reduce( $text_information->wpdb_state()['last_result'], static function ( array $values, object $row ): array { $values[ $row->COLUMN_NAME ] = $row->CHARACTER_MAXIMUM_LENGTH; return $values; }, array() ) + && false === $overwide_information->return_value() + && 'resource_limit' === ( $overwide_information->diagnostic()['reason'] ?? null ), + 'finite result limits do not authorize unbounded residual source scans' => false === $limited_residual->return_value() + && 'unsupported_lookup' === ( $limited_residual->diagnostic()['reason'] ?? null ), 'primary and secondary numeric indexes derive bounded lookup capabilities' => array( '1', '2' ) === array_map( static fn( object $row ): string => $row->id, $secondary->wpdb_state()['last_result'] diff --git a/tests/smoke-native-scalar-clauses.php b/tests/smoke-native-scalar-clauses.php index 7525d98..6567ba6 100644 --- a/tests/smoke-native-scalar-clauses.php +++ b/tests/smoke-native-scalar-clauses.php @@ -36,6 +36,9 @@ $json_invalid = $runtime->execute( new WP_Markdown_Query_Request( "SELECT JSON_VALID('{broken}')", 'wp_' ) ); $json_null = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT JSON_VALID(NULL)', 'wp_' ) ); $json_alias = $runtime->execute( new WP_Markdown_Query_Request( "SELECT JSON_VALID('[]') AS valid_json", 'wp_' ) ); +$json_depth_31 = $runtime->execute( new WP_Markdown_Query_Request( "SELECT JSON_VALID('" . str_repeat( '[', 31 ) . '0' . str_repeat( ']', 31 ) . "') AS valid_json", 'wp_' ) ); +$json_depth_32 = $runtime->execute( new WP_Markdown_Query_Request( "SELECT JSON_VALID('" . str_repeat( '[', 32 ) . '0' . str_repeat( ']', 32 ) . "') AS valid_json", 'wp_' ) ); +$statement_scalars = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT NOW() AS now_a, UTC_TIMESTAMP() AS now_b, RAND(1) AS rand_a, RAND(1) AS rand_b', 'wp_' ) ); $literals = $runtime->execute( new WP_Markdown_Query_Request( "SELECT 1 AS one, 'event' AS label, NULL AS missing", 'wp_' ) ); $checks = array( 'WP date WHERE evaluates DATE_ADD INTERVAL after the bounded read' => array( '2', '3' ) === array_map( static fn( object $row ): string => $row->id, $where->wpdb_state()['last_result'] ), @@ -70,7 +73,11 @@ && null === ( $json_null->wpdb_state()['last_result'][0]->{'JSON_VALID(NULL)'} ?? null ) && 'JSON_VALID(\'{"event":true}\')' === ( $json_valid->wpdb_state()['col_info'][0]->name ?? null ) && 3 === ( $json_valid->wpdb_state()['col_info'][0]->type ?? null ) - && '1' === ( $json_alias->wpdb_state()['last_result'][0]->valid_json ?? null ), + && '1' === ( $json_alias->wpdb_state()['last_result'][0]->valid_json ?? null ) + && '1' === ( $json_depth_31->wpdb_state()['last_result'][0]->valid_json ?? null ) + && '0' === ( $json_depth_32->wpdb_state()['last_result'][0]->valid_json ?? null ), + 'tableless scalar evaluation uses one fresh per-statement state' => ( $statement_scalars->wpdb_state()['last_result'][0]->now_a ?? null ) === ( $statement_scalars->wpdb_state()['last_result'][0]->now_b ?? null ) + && ( $statement_scalars->wpdb_state()['last_result'][0]->rand_a ?? null ) === ( $statement_scalars->wpdb_state()['last_result'][0]->rand_b ?? null ), 'tableless numeric, string, and NULL literals preserve aliases, values, and MySQL field types' => array( 'one' => '1', 'label' => 'event', 'missing' => null ) === (array) ( $literals->wpdb_state()['last_result'][0] ?? array() ) && array( 'one', 'label', 'missing' ) === array_map( static fn( object $column ): string => $column->name, $literals->wpdb_state()['col_info'] ) && array( 3, 253, 6 ) === array_map( static fn( object $column ): int => $column->type, $literals->wpdb_state()['col_info'] ), From 610c39ecff3ac0032c94bd48f7180cf94c7c5bdf Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 9 Sep 2026 11:28:41 -0400 Subject: [PATCH 09/53] fix(native): match MySQL JSON depth --- ...lass-wp-markdown-native-query-executor.php | 39 +++++++++++++++++-- tests/smoke-native-scalar-clauses.php | 12 ++++-- 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/inc/native/class-wp-markdown-native-query-executor.php b/inc/native/class-wp-markdown-native-query-executor.php index 6ecd1f4..389303c 100644 --- a/inc/native/class-wp-markdown-native-query-executor.php +++ b/inc/native/class-wp-markdown-native-query-executor.php @@ -161,6 +161,9 @@ private function tableless_scalar_projection( string $sql ): ?WP_Markdown_Query_ $row = array(); $columns = array(); foreach ( $projection as $scalar ) { + if ( 'JSON_VALID' === $scalar['expression']->kind() && $this->json_depth_exceeded( $scalar['expression'] ) ) { + return $this->mysql_json_depth_failure(); + } $value = $this->evaluate_scalar( $scalar['expression'], array(), $schema ); $row[ $scalar['alias'] ] = $this->string_scalar( $value ); $columns[] = array( 'name' => $scalar['alias'], 'table' => '', 'type' => $this->tableless_scalar_type( $scalar['expression'], $value ) ); @@ -190,6 +193,9 @@ private function tableless_json_valid( string $sql ): ?WP_Markdown_Query_Result return null; } $column = 'JSON_VALID(' . $tokens[3]->lexeme() . ')'; + if ( null !== $value && $this->json_depth_exceeded_value( (string) $value ) ) { + return $this->mysql_json_depth_failure(); + } return WP_Markdown_Query_Result::selected( array( array( $column => null === $value ? null : $this->json_valid( (string) $value ) ) ), array( array( 'name' => $column, 'table' => '', 'type' => 3 ) ) @@ -2111,15 +2117,42 @@ private function scalar_number( int|float|string|null $value ): int|string|null| private function json_valid( string $value ): string { try { - // MariaDB 11.4 rejects JSON nesting at 32 levels; its parser counts - // the outermost array/object as the first level. - json_decode( $value, true, 32, JSON_THROW_ON_ERROR ); + // MySQL 8.4 accepts 100 containers and rejects the 101st. PHP counts + // the scalar below those containers too, hence the decode depth of 101. + json_decode( $value, true, 101, JSON_THROW_ON_ERROR ); return '1'; } catch ( JsonException ) { return '0'; } } + private function json_depth_exceeded( WP_Markdown_Native_Query_Scalar_Expression $expression ): bool { + $arguments = $expression->arguments(); + if ( 1 !== count( $arguments ) || 'literal' !== $arguments[0]->kind() || ! is_string( $arguments[0]->literal() ) ) { + return false; + } + return $this->json_depth_exceeded_value( $arguments[0]->literal() ); + } + + private function json_depth_exceeded_value( string $value ): bool { + try { + json_decode( $value, true, 101, JSON_THROW_ON_ERROR ); + return false; + } catch ( JsonException $error ) { + return JSON_ERROR_DEPTH === $error->getCode(); + } + } + + private function mysql_json_depth_failure(): WP_Markdown_Query_Result { + return WP_Markdown_Query_Result::failure( + array( + 'code' => 3157, + 'reason' => 'json_document_too_deep', + 'message' => 'The JSON document exceeds the maximum depth.', + ) + ); + } + /** Cast through decimal digits instead of PHP floats, which lose declared scale. */ private function cast_decimal( int|string $value, int|string $precision, int|string $scale ): string { $precision = (int) $precision; diff --git a/tests/smoke-native-scalar-clauses.php b/tests/smoke-native-scalar-clauses.php index 6567ba6..a69f8f9 100644 --- a/tests/smoke-native-scalar-clauses.php +++ b/tests/smoke-native-scalar-clauses.php @@ -36,8 +36,10 @@ $json_invalid = $runtime->execute( new WP_Markdown_Query_Request( "SELECT JSON_VALID('{broken}')", 'wp_' ) ); $json_null = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT JSON_VALID(NULL)', 'wp_' ) ); $json_alias = $runtime->execute( new WP_Markdown_Query_Request( "SELECT JSON_VALID('[]') AS valid_json", 'wp_' ) ); -$json_depth_31 = $runtime->execute( new WP_Markdown_Query_Request( "SELECT JSON_VALID('" . str_repeat( '[', 31 ) . '0' . str_repeat( ']', 31 ) . "') AS valid_json", 'wp_' ) ); -$json_depth_32 = $runtime->execute( new WP_Markdown_Query_Request( "SELECT JSON_VALID('" . str_repeat( '[', 32 ) . '0' . str_repeat( ']', 32 ) . "') AS valid_json", 'wp_' ) ); +$json_array_depth_100 = $runtime->execute( new WP_Markdown_Query_Request( "SELECT JSON_VALID('" . str_repeat( '[', 100 ) . '0' . str_repeat( ']', 100 ) . "') AS valid_json", 'wp_' ) ); +$json_array_depth_101 = $runtime->execute( new WP_Markdown_Query_Request( "SELECT JSON_VALID('" . str_repeat( '[', 101 ) . '0' . str_repeat( ']', 101 ) . "') AS valid_json", 'wp_' ) ); +$json_object_depth_100 = $runtime->execute( new WP_Markdown_Query_Request( "SELECT JSON_VALID('" . str_repeat( '{\"key\":', 100 ) . '0' . str_repeat( '}', 100 ) . "') AS valid_json", 'wp_' ) ); +$json_object_depth_101 = $runtime->execute( new WP_Markdown_Query_Request( "SELECT JSON_VALID('" . str_repeat( '{\"key\":', 101 ) . '0' . str_repeat( '}', 101 ) . "') AS valid_json", 'wp_' ) ); $statement_scalars = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT NOW() AS now_a, UTC_TIMESTAMP() AS now_b, RAND(1) AS rand_a, RAND(1) AS rand_b', 'wp_' ) ); $literals = $runtime->execute( new WP_Markdown_Query_Request( "SELECT 1 AS one, 'event' AS label, NULL AS missing", 'wp_' ) ); $checks = array( @@ -74,8 +76,10 @@ && 'JSON_VALID(\'{"event":true}\')' === ( $json_valid->wpdb_state()['col_info'][0]->name ?? null ) && 3 === ( $json_valid->wpdb_state()['col_info'][0]->type ?? null ) && '1' === ( $json_alias->wpdb_state()['last_result'][0]->valid_json ?? null ) - && '1' === ( $json_depth_31->wpdb_state()['last_result'][0]->valid_json ?? null ) - && '0' === ( $json_depth_32->wpdb_state()['last_result'][0]->valid_json ?? null ), + && '1' === ( $json_array_depth_100->wpdb_state()['last_result'][0]->valid_json ?? null ) + && 3157 === ( $json_array_depth_101->wpdb_state()['last_errno'] ?? null ) + && '1' === ( $json_object_depth_100->wpdb_state()['last_result'][0]->valid_json ?? null ) + && 3157 === ( $json_object_depth_101->wpdb_state()['last_errno'] ?? null ), 'tableless scalar evaluation uses one fresh per-statement state' => ( $statement_scalars->wpdb_state()['last_result'][0]->now_a ?? null ) === ( $statement_scalars->wpdb_state()['last_result'][0]->now_b ?? null ) && ( $statement_scalars->wpdb_state()['last_result'][0]->rand_a ?? null ) === ( $statement_scalars->wpdb_state()['last_result'][0]->rand_b ?? null ), 'tableless numeric, string, and NULL literals preserve aliases, values, and MySQL field types' => array( 'one' => '1', 'label' => 'event', 'missing' => null ) === (array) ( $literals->wpdb_state()['last_result'][0] ?? array() ) From 2a0aea37b069a614286c37037dcb035ded4a6ed7 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 9 Sep 2026 11:32:50 -0400 Subject: [PATCH 10/53] test(shadow): retain token type traces --- ...own-native-authoritative-snapshot-runtime.php | 16 +++++++++++++--- .../class-wp-markdown-native-query-executor.php | 15 ++++++++++++--- .../class-wp-markdown-native-query-parser.php | 15 ++++++++++++--- tests/run-mysql-shadow-corpus.php | 2 ++ 4 files changed, 39 insertions(+), 9 deletions(-) diff --git a/inc/native/class-wp-markdown-native-authoritative-snapshot-runtime.php b/inc/native/class-wp-markdown-native-authoritative-snapshot-runtime.php index a6aa762..c253756 100644 --- a/inc/native/class-wp-markdown-native-authoritative-snapshot-runtime.php +++ b/inc/native/class-wp-markdown-native-authoritative-snapshot-runtime.php @@ -14,7 +14,7 @@ final class WP_Markdown_Native_Authoritative_Snapshot_Runtime implements WP_Mark public function __construct( private WP_Markdown_Query_Runtime $runtime, private array $provenance ) {} public static function capture( object $database, string $sql, string $prefix ): self { - self::trace_runtime_phase( 'capture' ); + self::trace_runtime_phase( 'capture', $sql ); $connection = method_exists( $database, 'markdown_db_mysql_connection' ) ? $database->markdown_db_mysql_connection() : ( $database->dbh ?? null ); @@ -51,12 +51,22 @@ public static function capture( object $database, string $sql, string $prefix ): return new self( new WP_Markdown_Native_Query_Runtime( $registry ), $provenance ); } - private static function trace_runtime_phase( string $phase ): void { + private static function trace_runtime_phase( string $phase, ?string $sql = null ): void { $path = defined( 'MARKDOWN_DB_NATIVE_SHADOW_TRACE_PATH' ) ? MARKDOWN_DB_NATIVE_SHADOW_TRACE_PATH : getenv( 'MARKDOWN_DB_NATIVE_SHADOW_TRACE_PATH' ); if ( ! is_string( $path ) || '' === $path ) { return; } - file_put_contents( $path, json_encode( array( 'phase' => $phase, 'file_sha256' => hash_file( 'sha256', __FILE__ ) ), JSON_UNESCAPED_SLASHES ) . "\n", FILE_APPEND | LOCK_EX ); + $event = array( 'phase' => $phase, 'file_sha256' => hash_file( 'sha256', __FILE__ ) ); + if ( null !== $sql ) { + try { + $event['sql_sha256'] = hash( 'sha256', $sql ); + $event['token_types'] = array_map( static fn( WP_Markdown_Native_SQL_Token $token ): string => $token->type(), ( new WP_Markdown_Native_SQL_Tokenizer() )->tokenize( $sql ) ); + $event['table_count'] = count( self::tables_in( $sql ) ); + } catch ( WP_Markdown_Native_Snapshot_Input_Exception|WP_Markdown_Native_SQL_Parse_Error ) { + $event['token_types'] = array( 'parse_error' ); + } + } + file_put_contents( $path, json_encode( $event, JSON_UNESCAPED_SLASHES ) . "\n", FILE_APPEND | LOCK_EX ); } /** @return array */ diff --git a/inc/native/class-wp-markdown-native-query-executor.php b/inc/native/class-wp-markdown-native-query-executor.php index 389303c..24b8eee 100644 --- a/inc/native/class-wp-markdown-native-query-executor.php +++ b/inc/native/class-wp-markdown-native-query-executor.php @@ -59,7 +59,7 @@ public function __construct( } public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query_Result { - self::trace_runtime_phase( 'executor' ); + self::trace_runtime_phase( 'executor', $request->sql() ); if ( strlen( $request->sql() ) > self::MAX_SQL_BYTES ) { return $this->failure( 'request_too_large', 'mdi-native cannot execute a request larger than max_allowed_packet.' ); } @@ -138,12 +138,21 @@ public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query return $this->execute_plan( $plan ); } - private static function trace_runtime_phase( string $phase ): void { + private static function trace_runtime_phase( string $phase, ?string $sql = null ): void { $path = defined( 'MARKDOWN_DB_NATIVE_SHADOW_TRACE_PATH' ) ? MARKDOWN_DB_NATIVE_SHADOW_TRACE_PATH : getenv( 'MARKDOWN_DB_NATIVE_SHADOW_TRACE_PATH' ); if ( ! is_string( $path ) || '' === $path ) { return; } - file_put_contents( $path, json_encode( array( 'phase' => $phase, 'file_sha256' => hash_file( 'sha256', __FILE__ ) ), JSON_UNESCAPED_SLASHES ) . "\n", FILE_APPEND | LOCK_EX ); + $event = array( 'phase' => $phase, 'file_sha256' => hash_file( 'sha256', __FILE__ ) ); + if ( null !== $sql ) { + try { + $event['sql_sha256'] = hash( 'sha256', $sql ); + $event['token_types'] = array_map( static fn( WP_Markdown_Native_SQL_Token $token ): string => $token->type(), ( new WP_Markdown_Native_SQL_Tokenizer() )->tokenize( $sql ) ); + } catch ( WP_Markdown_Native_SQL_Parse_Error ) { + $event['token_types'] = array( 'parse_error' ); + } + } + file_put_contents( $path, json_encode( $event, JSON_UNESCAPED_SLASHES ) . "\n", FILE_APPEND | LOCK_EX ); } /** Execute source-free typed scalar expressions as the one-row SQL result. */ diff --git a/inc/native/class-wp-markdown-native-query-parser.php b/inc/native/class-wp-markdown-native-query-parser.php index 0cf9189..c9b8223 100644 --- a/inc/native/class-wp-markdown-native-query-parser.php +++ b/inc/native/class-wp-markdown-native-query-parser.php @@ -11,7 +11,7 @@ public function __construct( ) {} public function parse( string $sql ): WP_Markdown_Native_Query_Plan|WP_Markdown_Native_Found_Rows_Plan|WP_Markdown_Query_Result { - self::trace_runtime_phase( 'parser' ); + self::trace_runtime_phase( 'parser', $sql ); $ast = $this->parse_ast( $sql ); if ( $ast instanceof WP_Markdown_Query_Result ) { return $ast; @@ -23,12 +23,21 @@ public function parse( string $sql ): WP_Markdown_Native_Query_Plan|WP_Markdown_ } } - private static function trace_runtime_phase( string $phase ): void { + private static function trace_runtime_phase( string $phase, ?string $sql = null ): void { $path = defined( 'MARKDOWN_DB_NATIVE_SHADOW_TRACE_PATH' ) ? MARKDOWN_DB_NATIVE_SHADOW_TRACE_PATH : getenv( 'MARKDOWN_DB_NATIVE_SHADOW_TRACE_PATH' ); if ( ! is_string( $path ) || '' === $path ) { return; } - file_put_contents( $path, json_encode( array( 'phase' => $phase, 'file_sha256' => hash_file( 'sha256', __FILE__ ) ), JSON_UNESCAPED_SLASHES ) . "\n", FILE_APPEND | LOCK_EX ); + $event = array( 'phase' => $phase, 'file_sha256' => hash_file( 'sha256', __FILE__ ) ); + if ( null !== $sql ) { + try { + $event['sql_sha256'] = hash( 'sha256', $sql ); + $event['token_types'] = array_map( static fn( WP_Markdown_Native_SQL_Token $token ): string => $token->type(), ( new WP_Markdown_Native_SQL_Tokenizer() )->tokenize( $sql ) ); + } catch ( WP_Markdown_Native_SQL_Parse_Error ) { + $event['token_types'] = array( 'parse_error' ); + } + } + file_put_contents( $path, json_encode( $event, JSON_UNESCAPED_SLASHES ) . "\n", FILE_APPEND | LOCK_EX ); } /** diff --git a/tests/run-mysql-shadow-corpus.php b/tests/run-mysql-shadow-corpus.php index ca5dc57..6f91db4 100644 --- a/tests/run-mysql-shadow-corpus.php +++ b/tests/run-mysql-shadow-corpus.php @@ -27,6 +27,7 @@ $report_path = '/tmp/mdi-shadow-report.json'; $report_name = 'mdi-shadow-report'; $trace_path = '/tmp/mdi-shadow-runtime-trace.jsonl'; +$trace_name = 'mdi-shadow-runtime-trace'; $revision = trim( (string) shell_exec( 'git -C ' . escapeshellarg( $repo ) . ' rev-parse HEAD' ) ); mkdir( $bootstrap, 0755, true ); mkdir( $state, 0755, true ); @@ -89,6 +90,7 @@ 'args' => array_merge( array( 'plugin-slug=' . $plugin_slug, 'database-type=mysql', 'multisite=1' ), false === $harness_dir ? array() : array( 'autoload-file=/wordpress/wp-content/mdi-shadow-phpunit/autoload.php', 'tests-dir=/wordpress/wp-content/mdi-shadow-phpunit/wp-phpunit/wp-phpunit' ), array() === $dependency_mounts ? array() : array( 'dependency-mounts=' . implode( ',', $dependency_mounts ) ), $phpunit_args ), 'resultPaths' => array( array( 'name' => $report_name, 'type' => 'mdi-native-shadow-report/v1', 'path' => $report_path, 'required' => true, 'maxBytes' => 1048576 ), + array( 'name' => $trace_name, 'type' => 'mdi-native-shadow-trace/v1', 'path' => $trace_path, 'required' => true, 'maxBytes' => 1048576 ), ), ), ) ), From 26a7faefb7ff81baf00dcfe800d3d666bd525c3b Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 9 Sep 2026 11:34:41 -0400 Subject: [PATCH 11/53] fix(shadow): keep runtime trace optional --- tests/run-mysql-shadow-corpus.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/run-mysql-shadow-corpus.php b/tests/run-mysql-shadow-corpus.php index 6f91db4..ca5dc57 100644 --- a/tests/run-mysql-shadow-corpus.php +++ b/tests/run-mysql-shadow-corpus.php @@ -27,7 +27,6 @@ $report_path = '/tmp/mdi-shadow-report.json'; $report_name = 'mdi-shadow-report'; $trace_path = '/tmp/mdi-shadow-runtime-trace.jsonl'; -$trace_name = 'mdi-shadow-runtime-trace'; $revision = trim( (string) shell_exec( 'git -C ' . escapeshellarg( $repo ) . ' rev-parse HEAD' ) ); mkdir( $bootstrap, 0755, true ); mkdir( $state, 0755, true ); @@ -90,7 +89,6 @@ 'args' => array_merge( array( 'plugin-slug=' . $plugin_slug, 'database-type=mysql', 'multisite=1' ), false === $harness_dir ? array() : array( 'autoload-file=/wordpress/wp-content/mdi-shadow-phpunit/autoload.php', 'tests-dir=/wordpress/wp-content/mdi-shadow-phpunit/wp-phpunit/wp-phpunit' ), array() === $dependency_mounts ? array() : array( 'dependency-mounts=' . implode( ',', $dependency_mounts ) ), $phpunit_args ), 'resultPaths' => array( array( 'name' => $report_name, 'type' => 'mdi-native-shadow-report/v1', 'path' => $report_path, 'required' => true, 'maxBytes' => 1048576 ), - array( 'name' => $trace_name, 'type' => 'mdi-native-shadow-trace/v1', 'path' => $trace_path, 'required' => true, 'maxBytes' => 1048576 ), ), ), ) ), From 6cfcf4bef6a81b3ed833f8212c2a3594da149180 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 9 Sep 2026 11:45:30 -0400 Subject: [PATCH 12/53] fix(shadow): capture tableless and catalog reads --- ...-native-authoritative-snapshot-runtime.php | 4 + ...lass-wp-markdown-native-query-executor.php | 35 +++++--- ...p-markdown-native-schema-introspection.php | 80 +++++++++++++++++++ ...ass-wp-markdown-native-shadow-verifier.php | 5 +- tests/smoke-native-shadow-sql-snapshot.php | 30 +++++++ 5 files changed, 141 insertions(+), 13 deletions(-) diff --git a/inc/native/class-wp-markdown-native-authoritative-snapshot-runtime.php b/inc/native/class-wp-markdown-native-authoritative-snapshot-runtime.php index c253756..1851c7b 100644 --- a/inc/native/class-wp-markdown-native-authoritative-snapshot-runtime.php +++ b/inc/native/class-wp-markdown-native-authoritative-snapshot-runtime.php @@ -113,6 +113,10 @@ public function provenance(): array { private static function tables_in( string $sql ): array { $plan = ( new WP_Markdown_Native_Query_Parser() )->parse( $sql ); if ( $plan instanceof WP_Markdown_Query_Result ) { + $catalog_tables = WP_Markdown_Native_Schema_Introspection::requested_information_schema_tables( $sql ); + if ( null !== $catalog_tables ) { + return $catalog_tables; + } $diagnostic = $plan->diagnostic() ?? array(); throw new WP_Markdown_Native_Snapshot_Input_Exception( (string) ( $diagnostic['code'] ?? 'markdown_db_native_unsupported_query' ), diff --git a/inc/native/class-wp-markdown-native-query-executor.php b/inc/native/class-wp-markdown-native-query-executor.php index 24b8eee..1bec5f7 100644 --- a/inc/native/class-wp-markdown-native-query-executor.php +++ b/inc/native/class-wp-markdown-native-query-executor.php @@ -182,6 +182,28 @@ private function tableless_scalar_projection( string $sql ): ?WP_Markdown_Query_ /** Preserve the legacy unaliased JSON column label while typed aliases use the shared evaluator. */ private function tableless_json_valid( string $sql ): ?WP_Markdown_Query_Result { + $literal = self::tableless_json_valid_literal( $sql ); + if ( null === $literal ) { + return null; + } + $value = $literal['value']; + $column = $literal['column']; + if ( null !== $value && $this->json_depth_exceeded_value( (string) $value ) ) { + return $this->mysql_json_depth_failure(); + } + return WP_Markdown_Query_Result::selected( + array( array( $column => null === $value ? null : $this->json_valid( (string) $value ) ) ), + array( array( 'name' => $column, 'table' => '', 'type' => 3 ) ) + ); + } + + public static function supports_tableless_scalar_projection( string $sql ): bool { + return null !== self::tableless_json_valid_literal( $sql ) + || ! ( ( new WP_Markdown_Native_Query_Parser() )->parse_tableless_scalar_projection( $sql ) instanceof WP_Markdown_Query_Result ); + } + + /** @return array{value:?string,column:string}|null */ + private static function tableless_json_valid_literal( string $sql ): ?array { try { $tokens = ( new WP_Markdown_Native_SQL_Tokenizer() )->tokenize( rtrim( trim( $sql ), ';' ) ); } catch ( WP_Markdown_Native_SQL_Parse_Error ) { @@ -201,18 +223,7 @@ private function tableless_json_valid( string $sql ): ?WP_Markdown_Query_Result if ( null !== $value && WP_Markdown_Native_SQL_Token::STRING !== $tokens[3]->type() ) { return null; } - $column = 'JSON_VALID(' . $tokens[3]->lexeme() . ')'; - if ( null !== $value && $this->json_depth_exceeded_value( (string) $value ) ) { - return $this->mysql_json_depth_failure(); - } - return WP_Markdown_Query_Result::selected( - array( array( $column => null === $value ? null : $this->json_valid( (string) $value ) ) ), - array( array( 'name' => $column, 'table' => '', 'type' => 3 ) ) - ); - } - - public static function supports_tableless_scalar_projection( string $sql ): bool { - return ! ( ( new WP_Markdown_Native_Query_Parser() )->parse_tableless_scalar_projection( $sql ) instanceof WP_Markdown_Query_Result ); + return array( 'value' => $value, 'column' => 'JSON_VALID(' . $tokens[3]->lexeme() . ')' ); } private function tableless_scalar_type( WP_Markdown_Native_Query_Scalar_Expression $expression, int|string|null $value ): int { diff --git a/inc/native/class-wp-markdown-native-schema-introspection.php b/inc/native/class-wp-markdown-native-schema-introspection.php index 7c0ef83..3b1d879 100644 --- a/inc/native/class-wp-markdown-native-schema-introspection.php +++ b/inc/native/class-wp-markdown-native-schema-introspection.php @@ -390,6 +390,86 @@ public function select_information_schema( WP_Markdown_Query_Request $request ): } } + /** + * Discover the real tables needed to answer a bounded catalog request. This + * intentionally recognizes only the literal TABLE_NAME predicates accepted + * by the catalog executor; virtual catalog tables are never snapshotted. + * + * @return array|null + */ + public static function requested_information_schema_tables( string $sql ): ?array { + try { + $tokens = ( new WP_Markdown_Native_SQL_Tokenizer() )->tokenize( rtrim( trim( $sql ), ';' ) ); + } catch ( WP_Markdown_Native_SQL_Parse_Error ) { + return null; + } + $position = 0; + $word = static function ( string $expected ) use ( &$tokens, &$position ): bool { + if ( 0 !== strcasecmp( $expected, (string) ( $tokens[ $position ] ?? null )?->value() ) ) { + return false; + } + ++$position; + return true; + }; + $identifier = static function () use ( &$tokens, &$position ): ?string { + $token = $tokens[ $position ] ?? null; + if ( ! $token instanceof WP_Markdown_Native_SQL_Token || ! in_array( $token->type(), array( WP_Markdown_Native_SQL_Token::WORD, WP_Markdown_Native_SQL_Token::KEYWORD, WP_Markdown_Native_SQL_Token::QUOTED_IDENTIFIER ), true ) ) { + return null; + } + ++$position; + return (string) $token->value(); + }; + if ( ! $word( 'SELECT' ) ) { + return null; + } + while ( ! $word( 'FROM' ) ) { + if ( WP_Markdown_Native_SQL_Token::END === ( $tokens[ $position ] ?? null )?->type() ) { + return null; + } + ++$position; + } + if ( 0 !== strcasecmp( 'information_schema', (string) $identifier() ) || WP_Markdown_Native_SQL_Token::DOT !== ( $tokens[ $position ] ?? null )?->type() ) { + return null; + } + ++$position; + $catalog = strtoupper( (string) $identifier() ); + if ( ! in_array( $catalog, array( 'COLUMNS', 'TABLES' ), true ) || ! $word( 'WHERE' ) ) { + return null; + } + $tables = null; + do { + $column = strtoupper( (string) $identifier() ); + if ( 'TABLE_NAME' !== $column ) { + while ( WP_Markdown_Native_SQL_Token::END !== ( $tokens[ $position ] ?? null )?->type() && 0 !== strcasecmp( 'AND', (string) ( $tokens[ $position ] ?? null )?->value() ) ) { + ++$position; + } + continue; + } + $values = array(); + if ( WP_Markdown_Native_SQL_Token::EQUALS === ( $tokens[ $position ] ?? null )?->type() ) { + ++$position; + $token = $tokens[ $position++ ] ?? null; + if ( ! $token instanceof WP_Markdown_Native_SQL_Token || WP_Markdown_Native_SQL_Token::STRING !== $token->type() ) { return null; } + $values[] = (string) $token->value(); + } elseif ( $word( 'IN' ) && WP_Markdown_Native_SQL_Token::LEFT_PAREN === ( $tokens[ $position ] ?? null )?->type() ) { + ++$position; + do { + $token = $tokens[ $position++ ] ?? null; + if ( ! $token instanceof WP_Markdown_Native_SQL_Token || WP_Markdown_Native_SQL_Token::STRING !== $token->type() || count( $values ) >= self::MAX_INFORMATION_SCHEMA_VALUES ) { return null; } + $values[] = (string) $token->value(); + } while ( WP_Markdown_Native_SQL_Token::COMMA === ( $tokens[ $position ] ?? null )?->type() && ++$position ); + if ( WP_Markdown_Native_SQL_Token::RIGHT_PAREN !== ( $tokens[ $position ] ?? null )?->type() ) { return null; } + ++$position; + } else { + return null; + } + $tables = null === $tables ? $values : array_values( array_intersect( $tables, $values ) ); + } while ( $word( 'AND' ) ); + return WP_Markdown_Native_SQL_Token::END === ( $tokens[ $position ] ?? null )?->type() && is_array( $tables ) && array() !== $tables + ? array_values( array_unique( $tables ) ) + : null; + } + /** @param array{columns:array>,indexes:array>} $definition @return array> */ private function information_schema_columns( string $table, array $definition ): array { $rows = array(); diff --git a/inc/native/class-wp-markdown-native-shadow-verifier.php b/inc/native/class-wp-markdown-native-shadow-verifier.php index 52b4cbb..ca50a76 100644 --- a/inc/native/class-wp-markdown-native-shadow-verifier.php +++ b/inc/native/class-wp-markdown-native-shadow-verifier.php @@ -110,7 +110,10 @@ public function capture_input( string $query, object $database ): void { } catch ( WP_Markdown_Native_Snapshot_Input_Exception $error ) { // Input capture is observational and must never interrupt wpdb's query. unset( $this->pending_inputs[ $key ] ); - $this->pending_input_failures[ $key ] = $error->diagnostic(); + $this->pending_input_failures[ $key ] = array( + 'code' => 'markdown_db_native_snapshot_input_unavailable', + 'reason' => (string) ( $error->diagnostic()['reason'] ?? 'snapshot_capture_failed' ), + ); } catch ( Throwable $error ) { unset( $this->pending_inputs[ $key ] ); $this->pending_input_failures[ $key ] = array( 'code' => 'markdown_db_native_snapshot_input_unavailable', 'reason' => 'snapshot_capture_failed' ); diff --git a/tests/smoke-native-shadow-sql-snapshot.php b/tests/smoke-native-shadow-sql-snapshot.php index bd364b3..6b942a5 100644 --- a/tests/smoke-native-shadow-sql-snapshot.php +++ b/tests/smoke-native-shadow-sql-snapshot.php @@ -38,6 +38,8 @@ public function query( string $sql ): MDI_Snapshot_Result|false { $result = new MDI_Snapshot_Result( array( array( 'Table' => $table, 'Create Table' => 'CREATE TABLE `' . $table . '` (`meta_id` bigint(20) unsigned NOT NULL, `site_id` bigint(20) unsigned NOT NULL, `meta_key` varchar(255) NOT NULL, `meta_value` longtext NOT NULL, PRIMARY KEY (`meta_id`))' ) ) ); } elseif ( 'SHOW CREATE TABLE `agents`' === $sql ) { $result = new MDI_Snapshot_Result( array( array( 'Table' => 'agents', 'Create Table' => 'CREATE TABLE `agents` (`id` bigint(20) unsigned NOT NULL, `name` varchar(255) NOT NULL, PRIMARY KEY (`id`))' ) ) ); + } elseif ( 'SHOW CREATE TABLE `wp_plugin_jobs`' === $sql ) { + $result = new MDI_Snapshot_Result( array( array( 'Table' => 'wp_plugin_jobs', 'Create Table' => 'CREATE TABLE `wp_plugin_jobs` (`id` bigint(20) unsigned NOT NULL, `status` varchar(64) NOT NULL, `payload` longtext NOT NULL, PRIMARY KEY (`id`))' ) ) ); } elseif ( 'SHOW CREATE TABLE `wp_2_options`' === $sql && $this->blog_table_absent ) { $this->errno = 1146; return false; @@ -58,6 +60,9 @@ public function query( string $sql ): MDI_Snapshot_Result|false { if ( 'SELECT * FROM `agents` LIMIT 10001' === $sql ) { $result = new MDI_Snapshot_Result( $this->plugin_rows ); } + if ( 'SELECT * FROM `wp_plugin_jobs` LIMIT 10001' === $sql ) { + $result = new MDI_Snapshot_Result( array() ); + } if ( 'SELECT * FROM `wp_2_options` LIMIT 10001' === $sql ) { $result = new MDI_Snapshot_Result( array( array( 'ID' => '1', 'option_value' => 'created' ) ) ); } @@ -232,6 +237,24 @@ public function get_col_info( string $field ): array { $database->result_rows( array( array( 'one' => '1' ) ), array( array( 'name' => 'one', 'type' => 3 ) ) ); $tableless->capture_input( 'SELECT 1 AS one', $database ); $tableless->observe( 'SELECT 1 AS one', 1, $database ); +$json_tableless = new WP_Markdown_Native_Shadow_Verifier( + WP_Markdown_Native_Runtime_Factory::runtime( sys_get_temp_dir() ), + 2, + array( 'input_mode' => 'sql_snapshot' ) +); +$database->result_rows( array( array( 'JSON_VALID(\'{"valid":true}\')' => '1' ) ), array( array( 'name' => 'JSON_VALID(\'{"valid":true}\')', 'type' => 3 ) ) ); +$json_tableless->capture_input( "SELECT JSON_VALID('{\"valid\":true}')", $database ); +$json_tableless->observe( "SELECT JSON_VALID('{\"valid\":true}')", 1, $database ); +$database->result_rows( array( array( "JSON_VALID('{invalid}')" => '0' ) ), array( array( 'name' => "JSON_VALID('{invalid}')", 'type' => 3 ) ) ); +$json_tableless->capture_input( "SELECT JSON_VALID('{invalid}')", $database ); +$json_tableless->observe( "SELECT JSON_VALID('{invalid}')", 1, $database ); +$catalog_columns = WP_Markdown_Native_Authoritative_Snapshot_Runtime::capture( + $database, + "SELECT COLUMN_NAME, DATA_TYPE, CHARACTER_MAXIMUM_LENGTH, IS_NULLABLE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = '' AND TABLE_NAME = 'wp_plugin_jobs' AND COLUMN_NAME IN ('status', 'payload')", + 'wp_' +); +$catalog_result = $catalog_columns->execute( new WP_Markdown_Query_Request( "SELECT COLUMN_NAME, DATA_TYPE, CHARACTER_MAXIMUM_LENGTH, IS_NULLABLE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = '' AND TABLE_NAME = 'wp_plugin_jobs' AND COLUMN_NAME IN ('status', 'payload')", 'wp_' ) ); +$catalog_engine = $catalog_columns->execute( new WP_Markdown_Query_Request( "SELECT ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA = '' AND TABLE_NAME = 'wp_plugin_jobs'", 'wp_' ) ); $capture_count_at_bound = count( $database->source()->results ); $bounded->capture_input( 'SELECT ID, post_title FROM wp_posts', $database ); $bounded->observe( 'SELECT ID, post_title FROM wp_posts', 1, $database ); @@ -270,6 +293,13 @@ public function get_col_info( string $field ): array { 'tableless scalar SQL is independently compared through the stateless runtime path' => 1 === $tableless->report()['counts']['compatible'] && 'native_runtime_fast_path' === ( $tableless->report()['context']['last_input_state']['read_connection'] ?? null ) && array() === ( $tableless->report()['context']['last_input_state']['tables'] ?? null ), + 'unaliased JSON_VALID uses the stateless capture path and independently executes both lifecycle literals' => 2 === $json_tableless->report()['counts']['compatible'] + && 0 === $json_tableless->report()['counts']['unsupported'] + && 'native_runtime_fast_path' === ( $json_tableless->report()['context']['last_input_state']['read_connection'] ?? null ), + 'catalog capture snapshots requested physical DDL and independently executes COLUMNS metadata' => array( 'wp_plugin_jobs' ) === array_column( $catalog_columns->provenance()['tables'], 'table' ) + && array( 'status' => '64', 'payload' => '4294967295' ) === array_reduce( $catalog_result->wpdb_state()['last_result'], static function ( array $values, object $row ): array { $values[ $row->COLUMN_NAME ] = $row->CHARACTER_MAXIMUM_LENGTH; return $values; }, array() ), + 'catalog ENGINE remains an explicit unsupported projection after source discovery' => false === $catalog_engine->return_value() + && 'unsupported_column' === ( $catalog_engine->diagnostic()['reason'] ?? null ), 'capture results are released after both schema and row reads' => array_reduce( $database->source()->results, static fn( bool $freed, MDI_Snapshot_Result $result ): bool => $freed && $result->freed, true ), ); $failed = 0; From bc71a840dd08a4d5204de9911154445ed37f5692 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 9 Sep 2026 11:50:29 -0400 Subject: [PATCH 13/53] fix(shadow): retain snapshot capture evidence --- inc/native/class-wp-markdown-native-shadow-verifier.php | 4 +++- tests/run-mysql-shadow-corpus.php | 5 +---- tests/smoke-native-shadow-sql-snapshot.php | 1 + 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/inc/native/class-wp-markdown-native-shadow-verifier.php b/inc/native/class-wp-markdown-native-shadow-verifier.php index ca50a76..17abec3 100644 --- a/inc/native/class-wp-markdown-native-shadow-verifier.php +++ b/inc/native/class-wp-markdown-native-shadow-verifier.php @@ -79,6 +79,7 @@ final class WP_Markdown_Native_Shadow_Verifier { private array $pending_insert_ids = array(); /** @var array */ private array $pending_input_failures = array(); + private int $authoritative_snapshot_captures = 0; public function __construct( private WP_Markdown_Query_Runtime $runtime, @@ -106,6 +107,7 @@ public function capture_input( string $query, object $database ): void { } try { $this->pending_inputs[ $key ] = WP_Markdown_Native_Authoritative_Snapshot_Runtime::capture( $database, $query, $prefix ); + ++$this->authoritative_snapshot_captures; unset( $this->pending_input_failures[ $key ] ); } catch ( WP_Markdown_Native_Snapshot_Input_Exception $error ) { // Input capture is observational and must never interrupt wpdb's query. @@ -242,7 +244,7 @@ public function report(): array { 'classifications' => $this->classification_counts, 'first_blocker' => $this->first_blocker, 'representatives' => array_values( $this->representatives ), - 'context' => array_merge( $this->context, null === $this->first_query_context ? array() : array( 'first_query' => $this->first_query_context ), null === $this->last_input_state ? array() : array( 'last_input_state' => $this->last_input_state ) ), + 'context' => array_merge( $this->context, array( 'authoritative_snapshot_captures' => $this->authoritative_snapshot_captures ), null === $this->first_query_context ? array() : array( 'first_query' => $this->first_query_context ), null === $this->last_input_state ? array() : array( 'last_input_state' => $this->last_input_state ) ), ); } diff --git a/tests/run-mysql-shadow-corpus.php b/tests/run-mysql-shadow-corpus.php index ca5dc57..8efca89 100644 --- a/tests/run-mysql-shadow-corpus.php +++ b/tests/run-mysql-shadow-corpus.php @@ -132,12 +132,9 @@ fwrite( STDERR, "Shadow report was absent or empty. Artifacts: {$root}\n" ); exit( 1 ); } -$input_tables = $shadow['context']['last_input_state']['tables'] ?? array(); if ( 'sql_snapshot' !== ( $shadow['context']['input_mode'] ?? null ) || (int) ( $shadow['counts']['compatible'] ?? 0 ) < 1 - || ! is_array( $input_tables ) - || array() === $input_tables - || array_filter( $input_tables, static fn( mixed $table ): bool => ! is_array( $table ) || ! isset( $table['rows'], $table['sha256'], $table['schema_sha256'] ) ) + || (int) ( $shadow['context']['authoritative_snapshot_captures'] ?? 0 ) < 1 ) { fwrite( STDERR, "Shadow report did not prove a compatible sql_snapshot comparison. Artifacts: {$root}\n" ); exit( 1 ); diff --git a/tests/smoke-native-shadow-sql-snapshot.php b/tests/smoke-native-shadow-sql-snapshot.php index 6b942a5..f0493d1 100644 --- a/tests/smoke-native-shadow-sql-snapshot.php +++ b/tests/smoke-native-shadow-sql-snapshot.php @@ -262,6 +262,7 @@ public function get_col_info( string $field ): array { $checks = array( 'authoritative snapshots compare with independently evaluated native SQL' => 2 === $second['counts']['compatible'] && 0 === $second['counts']['verifier_failures'], 'input provenance records bounded source rows without their values' => 'authoritative_mysql_connection_pre_query' === ( $first['context']['last_input_state']['read_connection'] ?? null ) + && 1 === ( $first['context']['authoritative_snapshot_captures'] ?? null ) && 1 === ( $first['context']['last_input_state']['tables'][0]['rows'] ?? 0 ) && 64 === strlen( (string) ( $first['context']['last_input_state']['tables'][0]['schema_sha256'] ?? '' ) ) && 'pre_query_wpdb_insert_id' === ( $first['context']['last_input_state']['facade_state']['native_insert_id'] ?? null ) From 28ab0235487e7074f0e24ff96d2cce3a263ac1c0 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 9 Sep 2026 11:55:59 -0400 Subject: [PATCH 14/53] test(shadow): retain redacted mismatch receipts --- ...ass-wp-markdown-native-shadow-verifier.php | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/inc/native/class-wp-markdown-native-shadow-verifier.php b/inc/native/class-wp-markdown-native-shadow-verifier.php index 17abec3..dd8cfab 100644 --- a/inc/native/class-wp-markdown-native-shadow-verifier.php +++ b/inc/native/class-wp-markdown-native-shadow-verifier.php @@ -222,6 +222,7 @@ public function observe( string $query, mixed $return_value, object $database ): array( 'mismatch_paths' => $paths, 'mismatches_truncated' => count( $comparison['mismatches'] ) > count( $paths ), + 'comparison_receipt' => $this->comparison_receipt( $expected, $actual ), ) ); } catch ( Throwable $error ) { @@ -339,6 +340,26 @@ private function safe_reason( string $reason ): string { return '' === $reason ? 'unknown' : substr( $reason, 0, 128 ); } + /** Retain field descriptors and opaque row receipts without publishing query values. */ + private function comparison_receipt( array $expected, array $actual ): array { + $columns = static fn( array $result ): array => array_map( + static fn( array $column ): array => array( + 'name' => (string) ( $column['name'] ?? '' ), + 'type' => null === ( $column['type'] ?? null ) ? null : (string) $column['type'], + ), + is_array( $result['columns'] ?? null ) ? $result['columns'] : array() + ); + $rows = static fn( array $result ): array => is_array( $result['rows'] ?? null ) ? $result['rows'] : array(); + $expected_rows = $rows( $expected ); + $actual_rows = $rows( $actual ); + return array( + 'expected_columns' => $columns( $expected ), + 'actual_columns' => $columns( $actual ), + 'expected_rows' => array( 'count' => count( $expected_rows ), 'sha256' => hash( 'sha256', serialize( $expected_rows ) ) ), + 'actual_rows' => array( 'count' => count( $actual_rows ), 'sha256' => hash( 'sha256', serialize( $actual_rows ) ) ), + ); + } + /** Compare all caller-visible error state except server-specific error text. */ private function has_matching_missing_table_error_state( array $expected, array $actual ): bool { if ( false !== ( $expected['return']['value'] ?? null ) || 1146 !== (int) ( $expected['error_code'] ?? 0 ) ) { From c316a467230f063c73eb16a87ec2dc1288b97b4c Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 9 Sep 2026 12:02:17 -0400 Subject: [PATCH 15/53] fix(native): match MySQL field descriptors --- inc/native/class-wp-markdown-native-query-executor.php | 4 ++-- .../class-wp-markdown-native-schema-introspection.php | 6 +++++- tests/smoke-native-plugin-schema-query.php | 2 +- tests/smoke-native-scalar-clauses.php | 2 +- tests/smoke-native-shadow-sql-snapshot.php | 5 +++-- 5 files changed, 12 insertions(+), 7 deletions(-) diff --git a/inc/native/class-wp-markdown-native-query-executor.php b/inc/native/class-wp-markdown-native-query-executor.php index 1bec5f7..13c4b80 100644 --- a/inc/native/class-wp-markdown-native-query-executor.php +++ b/inc/native/class-wp-markdown-native-query-executor.php @@ -193,7 +193,7 @@ private function tableless_json_valid( string $sql ): ?WP_Markdown_Query_Result } return WP_Markdown_Query_Result::selected( array( array( $column => null === $value ? null : $this->json_valid( (string) $value ) ) ), - array( array( 'name' => $column, 'table' => '', 'type' => 3 ) ) + array( array( 'name' => $column, 'table' => '', 'type' => 8 ) ) ); } @@ -229,7 +229,7 @@ private static function tableless_json_valid_literal( string $sql ): ?array { private function tableless_scalar_type( WP_Markdown_Native_Query_Scalar_Expression $expression, int|string|null $value ): int { if ( null === $value ) { return 6; } if ( 'literal' === $expression->kind() ) { return is_int( $value ) ? 3 : ( is_numeric( $value ) ? 246 : 253 ); } - return 'JSON_VALID' === $expression->kind() ? 3 : 253; + return 'JSON_VALID' === $expression->kind() ? 8 : 253; } /** Release this logical connection's root-scoped advisory locks. */ diff --git a/inc/native/class-wp-markdown-native-schema-introspection.php b/inc/native/class-wp-markdown-native-schema-introspection.php index 3b1d879..90d893f 100644 --- a/inc/native/class-wp-markdown-native-schema-introspection.php +++ b/inc/native/class-wp-markdown-native-schema-introspection.php @@ -517,7 +517,11 @@ private function information_schema_metadata( array $projection, string $catalog static fn( array $column ): array => array( 'name' => $column['alias'], 'table' => $catalog, - 'type' => in_array( $column['name'], array( 'ORDINAL_POSITION', 'CHARACTER_MAXIMUM_LENGTH' ), true ) ? 8 : 253, + 'type' => match ( $column['name'] ) { + 'ORDINAL_POSITION', 'CHARACTER_MAXIMUM_LENGTH' => 8, + 'DATA_TYPE' => 251, + default => 253, + }, ), $projection ); diff --git a/tests/smoke-native-plugin-schema-query.php b/tests/smoke-native-plugin-schema-query.php index ad1be2b..70ac668 100644 --- a/tests/smoke-native-plugin-schema-query.php +++ b/tests/smoke-native-plugin-schema-query.php @@ -175,7 +175,7 @@ function mdi_plugin_schema_remove_tree( string $root ): void { && 'PRI' === ( $information_columns->wpdb_state()['last_result'][0]->COLUMN_KEY ?? null ) && '32' === ( $information_columns->wpdb_state()['last_result'][2]->CHARACTER_MAXIMUM_LENGTH ?? null ) && null === ( $information_columns->wpdb_state()['last_result'][0]->CHARACTER_MAXIMUM_LENGTH ?? null ) - && array( 253, 253, 8, 8, 253, 253, 253, 253, 253, 253 ) === array_map( static fn( object $column ): int => $column->type, $information_columns->wpdb_state()['col_info'] ), + && array( 253, 253, 8, 8, 253, 253, 251, 253, 253, 253 ) === array_map( static fn( object $column ): int => $column->type, $information_columns->wpdb_state()['col_info'] ), 'information_schema predicates preserve schema equality and AND intersections' => 0 === $absent_information_schema->return_value() && 0 === $contradictory_information_tables->return_value() && 0 === $contradictory_information_columns->return_value(), diff --git a/tests/smoke-native-scalar-clauses.php b/tests/smoke-native-scalar-clauses.php index a69f8f9..baca993 100644 --- a/tests/smoke-native-scalar-clauses.php +++ b/tests/smoke-native-scalar-clauses.php @@ -74,7 +74,7 @@ && '0' === ( $json_invalid->wpdb_state()['last_result'][0]->{'JSON_VALID(\'{broken}\')'} ?? null ) && null === ( $json_null->wpdb_state()['last_result'][0]->{'JSON_VALID(NULL)'} ?? null ) && 'JSON_VALID(\'{"event":true}\')' === ( $json_valid->wpdb_state()['col_info'][0]->name ?? null ) - && 3 === ( $json_valid->wpdb_state()['col_info'][0]->type ?? null ) + && 8 === ( $json_valid->wpdb_state()['col_info'][0]->type ?? null ) && '1' === ( $json_alias->wpdb_state()['last_result'][0]->valid_json ?? null ) && '1' === ( $json_array_depth_100->wpdb_state()['last_result'][0]->valid_json ?? null ) && 3157 === ( $json_array_depth_101->wpdb_state()['last_errno'] ?? null ) diff --git a/tests/smoke-native-shadow-sql-snapshot.php b/tests/smoke-native-shadow-sql-snapshot.php index f0493d1..a9e79c0 100644 --- a/tests/smoke-native-shadow-sql-snapshot.php +++ b/tests/smoke-native-shadow-sql-snapshot.php @@ -242,10 +242,10 @@ public function get_col_info( string $field ): array { 2, array( 'input_mode' => 'sql_snapshot' ) ); -$database->result_rows( array( array( 'JSON_VALID(\'{"valid":true}\')' => '1' ) ), array( array( 'name' => 'JSON_VALID(\'{"valid":true}\')', 'type' => 3 ) ) ); +$database->result_rows( array( array( 'JSON_VALID(\'{"valid":true}\')' => '1' ) ), array( array( 'name' => 'JSON_VALID(\'{"valid":true}\')', 'type' => 8 ) ) ); $json_tableless->capture_input( "SELECT JSON_VALID('{\"valid\":true}')", $database ); $json_tableless->observe( "SELECT JSON_VALID('{\"valid\":true}')", 1, $database ); -$database->result_rows( array( array( "JSON_VALID('{invalid}')" => '0' ) ), array( array( 'name' => "JSON_VALID('{invalid}')", 'type' => 3 ) ) ); +$database->result_rows( array( array( "JSON_VALID('{invalid}')" => '0' ) ), array( array( 'name' => "JSON_VALID('{invalid}')", 'type' => 8 ) ) ); $json_tableless->capture_input( "SELECT JSON_VALID('{invalid}')", $database ); $json_tableless->observe( "SELECT JSON_VALID('{invalid}')", 1, $database ); $catalog_columns = WP_Markdown_Native_Authoritative_Snapshot_Runtime::capture( @@ -298,6 +298,7 @@ public function get_col_info( string $field ): array { && 0 === $json_tableless->report()['counts']['unsupported'] && 'native_runtime_fast_path' === ( $json_tableless->report()['context']['last_input_state']['read_connection'] ?? null ), 'catalog capture snapshots requested physical DDL and independently executes COLUMNS metadata' => array( 'wp_plugin_jobs' ) === array_column( $catalog_columns->provenance()['tables'], 'table' ) + && 251 === ( $catalog_result->wpdb_state()['col_info'][1]->type ?? null ) && array( 'status' => '64', 'payload' => '4294967295' ) === array_reduce( $catalog_result->wpdb_state()['last_result'], static function ( array $values, object $row ): array { $values[ $row->COLUMN_NAME ] = $row->CHARACTER_MAXIMUM_LENGTH; return $values; }, array() ), 'catalog ENGINE remains an explicit unsupported projection after source discovery' => false === $catalog_engine->return_value() && 'unsupported_column' === ( $catalog_engine->diagnostic()['reason'] ?? null ), From 086d08d07f660f0d6a6a10f62065f8ca6b42026c Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 9 Sep 2026 12:06:06 -0400 Subject: [PATCH 16/53] fix(shadow): bind authoritative catalog schema --- ...wn-native-authoritative-snapshot-runtime.php | 9 ++++++++- .../class-wp-markdown-native-query-executor.php | 9 ++++++--- ...-wp-markdown-native-schema-introspection.php | 17 +++++++++++------ 3 files changed, 25 insertions(+), 10 deletions(-) diff --git a/inc/native/class-wp-markdown-native-authoritative-snapshot-runtime.php b/inc/native/class-wp-markdown-native-authoritative-snapshot-runtime.php index 1851c7b..19b5851 100644 --- a/inc/native/class-wp-markdown-native-authoritative-snapshot-runtime.php +++ b/inc/native/class-wp-markdown-native-authoritative-snapshot-runtime.php @@ -27,6 +27,7 @@ public static function capture( object $database, string $sql, string $prefix ): } $prefixes = self::schema_prefixes( $database, $prefix ); + $database_name = self::database_name( $connection ); $registry = new WP_Markdown_Native_Table_Registry(); $provenance = array(); foreach ( $tables as $table ) { @@ -48,7 +49,7 @@ public static function capture( object $database, string $sql, string $prefix ): $registry->register( $table, $schema, new WP_Markdown_Native_Authoritative_Snapshot_Provider( $rows, $schema ) ); $provenance[] = array( 'table' => $table, 'exists' => true, 'rows' => count( $rows ), 'sha256' => hash( 'sha256', self::encode_rows( $rows ) ), 'schema_sha256' => hash( 'sha256', $definition ) ); } - return new self( new WP_Markdown_Native_Query_Runtime( $registry ), $provenance ); + return new self( new WP_Markdown_Native_Query_Runtime( $registry, database_name: $database_name ), $provenance ); } private static function trace_runtime_phase( string $phase, ?string $sql = null ): void { @@ -78,6 +79,12 @@ private static function schema_prefixes( object $database, string $prefix ): arr return array_values( array_unique( array_filter( $prefixes, static fn( string $candidate ): bool => '' !== $candidate ) ) ); } + private static function database_name( object $connection ): ?string { + $row = self::one_row( $connection, 'SELECT DATABASE()' ); + $value = is_array( $row ) ? reset( $row ) : null; + return is_string( $value ) ? $value : null; + } + public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query_Result { $result = $this->runtime->execute( $request ); $diagnostic = $result->diagnostic() ?? array(); diff --git a/inc/native/class-wp-markdown-native-query-executor.php b/inc/native/class-wp-markdown-native-query-executor.php index 13c4b80..94e6936 100644 --- a/inc/native/class-wp-markdown-native-query-executor.php +++ b/inc/native/class-wp-markdown-native-query-executor.php @@ -43,6 +43,7 @@ final class WP_Markdown_Native_Query_Runtime implements WP_Markdown_Query_Runtim /** @var array */ private array $rand_states = array(); private WP_Markdown_Native_Schema_Introspection $schema_introspection; + private ?string $database_name; public function __construct( private WP_Markdown_Native_Table_Registry $registry, @@ -53,9 +54,11 @@ public function __construct( private ?WP_Markdown_Native_Transaction_Journal $transactions = null, private ?WP_Markdown_Native_Post_Mutation_Runtime $post_mutations = null, private int $correlated_subquery_limit = self::MAX_CORRELATED_SUBQUERY_EVALUATIONS, - private ?WP_Markdown_Native_Advisory_Locks $advisory_locks = null + private ?WP_Markdown_Native_Advisory_Locks $advisory_locks = null, + ?string $database_name = null ) { - $this->schema_introspection = new WP_Markdown_Native_Schema_Introspection( $registry ); + $this->database_name = $database_name; + $this->schema_introspection = new WP_Markdown_Native_Schema_Introspection( $registry, database_name: $database_name ); } public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query_Result { @@ -86,7 +89,7 @@ public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query // The canonical store is a directory, not a named server database. if ( 1 === preg_match( '/^\s*SELECT\s+DATABASE\s*\(\s*\)\s*;?\s*$/i', $request->sql() ) ) { return WP_Markdown_Query_Result::selected( - array( array( 'DATABASE()' => defined( 'DB_NAME' ) ? (string) DB_NAME : '' ) ), + array( array( 'DATABASE()' => $this->database_name ?? ( defined( 'DB_NAME' ) ? (string) DB_NAME : '' ) ) ), array( array( 'name' => 'DATABASE()', 'table' => '', 'type' => 253 ) ) ); } diff --git a/inc/native/class-wp-markdown-native-schema-introspection.php b/inc/native/class-wp-markdown-native-schema-introspection.php index 90d893f..fada374 100644 --- a/inc/native/class-wp-markdown-native-schema-introspection.php +++ b/inc/native/class-wp-markdown-native-schema-introspection.php @@ -229,7 +229,8 @@ final class WP_Markdown_Native_Schema_Introspection { private const MAX_INFORMATION_SCHEMA_ROWS = 1000; public function __construct( private readonly WP_Markdown_Native_Table_Registry $registry, - private readonly WP_Markdown_Native_Schema_Introspection_Parser $parser = new WP_Markdown_Native_Schema_Introspection_Parser() + private readonly WP_Markdown_Native_Schema_Introspection_Parser $parser = new WP_Markdown_Native_Schema_Introspection_Parser(), + private readonly ?string $database_name = null ) {} public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query_Result { @@ -321,7 +322,7 @@ public function select_information_schema( WP_Markdown_Query_Request $request ): if ( 'TABLE_SCHEMA' === $column && WP_Markdown_Native_SQL_Token::EQUALS === ( $tokens[ $position ] ?? null )?->type() ) { ++$position; if ( $word( 'DATABASE' ) && WP_Markdown_Native_SQL_Token::LEFT_PAREN === ( $tokens[ $position ] ?? null )?->type() && WP_Markdown_Native_SQL_Token::RIGHT_PAREN === ( $tokens[ $position + 1 ] ?? null )?->type() ) { - $values[] = defined( 'DB_NAME' ) ? (string) DB_NAME : ''; + $values[] = $this->database_name(); $position += 2; } elseif ( WP_Markdown_Native_SQL_Token::STRING === ( $tokens[ $position ] ?? null )?->type() ) { $values[] = (string) $tokens[ $position++ ]->value(); @@ -356,7 +357,7 @@ public function select_information_schema( WP_Markdown_Query_Request $request ): if ( ! isset( $predicates['TABLE_SCHEMA'], $predicates['TABLE_NAME'] ) || WP_Markdown_Native_SQL_Token::END !== ( $tokens[ $position ] ?? null )?->type() ) { return $this->failure( 'unsupported_lookup', 'mdi-native requires a bounded information_schema table lookup.' ); } - $schema = defined( 'DB_NAME' ) ? (string) DB_NAME : ''; + $schema = $this->database_name(); if ( ! in_array( $schema, $predicates['TABLE_SCHEMA'], true ) || array() === $predicates['TABLE_NAME'] ) { return WP_Markdown_Query_Result::selected( array(), $this->information_schema_metadata( $projection, $catalog ) ); } @@ -475,7 +476,7 @@ private function information_schema_columns( string $table, array $definition ): $rows = array(); foreach ( $definition['columns'] as $position => $column ) { $rows[] = array( - 'TABLE_SCHEMA' => defined( 'DB_NAME' ) ? (string) DB_NAME : '', + 'TABLE_SCHEMA' => $this->database_name(), 'TABLE_NAME' => $table, 'COLUMN_NAME' => $position, 'ORDINAL_POSITION' => (string) ( count( $rows ) + 1 ), @@ -493,7 +494,7 @@ private function information_schema_columns( string $table, array $definition ): /** @return array */ private function information_schema_table( string $table ): array { - return array( 'TABLE_SCHEMA' => defined( 'DB_NAME' ) ? (string) DB_NAME : '', 'TABLE_NAME' => $table, 'TABLE_TYPE' => 'BASE TABLE' ); + return array( 'TABLE_SCHEMA' => $this->database_name(), 'TABLE_NAME' => $table, 'TABLE_TYPE' => 'BASE TABLE' ); } /** @param array $column */ @@ -567,7 +568,7 @@ private function server_values( string $operation, ?string $pattern, array $name private function tables( ?string $pattern ): WP_Markdown_Query_Result { $rows = array(); - $column = 'Tables_in_' . ( defined( 'DB_NAME' ) ? (string) DB_NAME : '' ); + $column = 'Tables_in_' . $this->database_name(); foreach ( $this->registry->table_names() as $table ) { if ( null === $pattern || $this->matches( $table, $pattern ) ) { $rows[] = array( $column => $table ); @@ -701,4 +702,8 @@ private function failure( string $reason, string $message ): WP_Markdown_Query_R ) ); } + + private function database_name(): string { + return $this->database_name ?? ( defined( 'DB_NAME' ) ? (string) DB_NAME : '' ); + } } From 577b4d97314b54d5bdab4875b2ebe73c954194b0 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 9 Sep 2026 12:08:51 -0400 Subject: [PATCH 17/53] test(shadow): retain catalog schema receipts --- ...class-wp-markdown-native-shadow-verifier.php | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/inc/native/class-wp-markdown-native-shadow-verifier.php b/inc/native/class-wp-markdown-native-shadow-verifier.php index dd8cfab..c3a0c5f 100644 --- a/inc/native/class-wp-markdown-native-shadow-verifier.php +++ b/inc/native/class-wp-markdown-native-shadow-verifier.php @@ -357,9 +357,26 @@ private function comparison_receipt( array $expected, array $actual ): array { 'actual_columns' => $columns( $actual ), 'expected_rows' => array( 'count' => count( $expected_rows ), 'sha256' => hash( 'sha256', serialize( $expected_rows ) ) ), 'actual_rows' => array( 'count' => count( $actual_rows ), 'sha256' => hash( 'sha256', serialize( $actual_rows ) ) ), + 'catalog_schema_rows' => $this->catalog_schema_rows( $expected_rows, $actual_rows ), ); } + /** Retain only public schema facts when a catalog response differs. */ + private function catalog_schema_rows( array $expected, array $actual ): ?array { + $keys = array( 'COLUMN_NAME', 'DATA_TYPE', 'CHARACTER_MAXIMUM_LENGTH', 'IS_NULLABLE' ); + $sanitize = static function ( array $rows ) use ( $keys ): ?array { + foreach ( $rows as $row ) { + if ( ! is_array( $row ) || array_diff( array_keys( $row ), $keys ) !== array() ) { + return null; + } + } + return array_map( static fn( array $row ): array => array_intersect_key( $row, array_flip( $keys ) ), $rows ); + }; + $expected = $sanitize( $expected ); + $actual = $sanitize( $actual ); + return null === $expected || null === $actual ? null : array( 'expected' => $expected, 'actual' => $actual ); + } + /** Compare all caller-visible error state except server-specific error text. */ private function has_matching_missing_table_error_state( array $expected, array $actual ): bool { if ( false !== ( $expected['return']['value'] ?? null ) || 1146 !== (int) ( $expected['error_code'] ?? 0 ) ) { From f6dbd61d0b1cad7ad6d4b7abea50f1a631845fd4 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 9 Sep 2026 12:18:45 -0400 Subject: [PATCH 18/53] test(shadow): retain snapshot provenance --- ...-wp-markdown-native-authoritative-snapshot-runtime.php | 8 ++++---- inc/native/class-wp-markdown-native-shadow-verifier.php | 1 + 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/inc/native/class-wp-markdown-native-authoritative-snapshot-runtime.php b/inc/native/class-wp-markdown-native-authoritative-snapshot-runtime.php index 19b5851..03a8438 100644 --- a/inc/native/class-wp-markdown-native-authoritative-snapshot-runtime.php +++ b/inc/native/class-wp-markdown-native-authoritative-snapshot-runtime.php @@ -11,7 +11,7 @@ final class WP_Markdown_Native_Authoritative_Snapshot_Runtime implements WP_Mark private const MAX_BYTES_PER_TABLE = 8388608; /** @param array $provenance */ - public function __construct( private WP_Markdown_Query_Runtime $runtime, private array $provenance ) {} + public function __construct( private WP_Markdown_Query_Runtime $runtime, private array $provenance, private ?string $database_name = null ) {} public static function capture( object $database, string $sql, string $prefix ): self { self::trace_runtime_phase( 'capture', $sql ); @@ -49,7 +49,7 @@ public static function capture( object $database, string $sql, string $prefix ): $registry->register( $table, $schema, new WP_Markdown_Native_Authoritative_Snapshot_Provider( $rows, $schema ) ); $provenance[] = array( 'table' => $table, 'exists' => true, 'rows' => count( $rows ), 'sha256' => hash( 'sha256', self::encode_rows( $rows ) ), 'schema_sha256' => hash( 'sha256', $definition ) ); } - return new self( new WP_Markdown_Native_Query_Runtime( $registry, database_name: $database_name ), $provenance ); + return new self( new WP_Markdown_Native_Query_Runtime( $registry, database_name: $database_name ), $provenance, $database_name ); } private static function trace_runtime_phase( string $phase, ?string $sql = null ): void { @@ -111,9 +111,9 @@ private function has_explicitly_absent_source( string $sql ): bool { return array() !== array_intersect( self::tables_in( $sql ), $absent ); } - /** @return array{read_connection:string,tables:array} */ + /** @return array{read_connection:string,database_sha256:?string,tables:array} */ public function provenance(): array { - return array( 'read_connection' => 'authoritative_mysql_connection_pre_query', 'tables' => $this->provenance ); + return array( 'read_connection' => 'authoritative_mysql_connection_pre_query', 'database_sha256' => null === $this->database_name ? null : hash( 'sha256', $this->database_name ), 'tables' => $this->provenance ); } /** @return array */ diff --git a/inc/native/class-wp-markdown-native-shadow-verifier.php b/inc/native/class-wp-markdown-native-shadow-verifier.php index c3a0c5f..7cb1f7c 100644 --- a/inc/native/class-wp-markdown-native-shadow-verifier.php +++ b/inc/native/class-wp-markdown-native-shadow-verifier.php @@ -223,6 +223,7 @@ public function observe( string $query, mixed $return_value, object $database ): 'mismatch_paths' => $paths, 'mismatches_truncated' => count( $comparison['mismatches'] ) > count( $paths ), 'comparison_receipt' => $this->comparison_receipt( $expected, $actual ), + 'input_provenance' => $provenance ?? array(), ) ); } catch ( Throwable $error ) { From f39abfa46e9f86c60eeaac4be4a6e57bf56688d8 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 9 Sep 2026 12:22:48 -0400 Subject: [PATCH 19/53] fix(native): match catalog column ordering --- inc/native/class-wp-markdown-native-schema-introspection.php | 3 +++ tests/smoke-native-plugin-schema-query.php | 2 +- tests/smoke-native-shadow-sql-snapshot.php | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/inc/native/class-wp-markdown-native-schema-introspection.php b/inc/native/class-wp-markdown-native-schema-introspection.php index fada374..3744b33 100644 --- a/inc/native/class-wp-markdown-native-schema-introspection.php +++ b/inc/native/class-wp-markdown-native-schema-introspection.php @@ -385,6 +385,9 @@ public function select_information_schema( WP_Markdown_Query_Request $request ): $rows[] = $row; } } + if ( 'COLUMNS' === $catalog && 1 === count( $predicates['TABLE_NAME'] ) && in_array( 'COLUMN_NAME', array_column( $projection, 'name' ), true ) ) { + usort( $rows, static fn( array $left, array $right ): int => strcmp( (string) ( $left['COLUMN_NAME'] ?? '' ), (string) ( $right['COLUMN_NAME'] ?? '' ) ) ); + } return WP_Markdown_Query_Result::selected( $rows, $this->information_schema_metadata( $projection, $catalog ) ); } catch ( WP_Markdown_Native_SQL_Parse_Error ) { return null; diff --git a/tests/smoke-native-plugin-schema-query.php b/tests/smoke-native-plugin-schema-query.php index 70ac668..b3ea409 100644 --- a/tests/smoke-native-plugin-schema-query.php +++ b/tests/smoke-native-plugin-schema-query.php @@ -183,7 +183,7 @@ function mdi_plugin_schema_remove_tree( string $root ): void { && 'unsupported_column' === ( $information_engine->diagnostic()['reason'] ?? null ), 'information_schema catalog scans remain fail-closed without a bounded table name' => false === $unbounded_information->return_value() && 'unsupported_lookup' === ( $unbounded_information->diagnostic()['reason'] ?? null ), - 'information_schema reports TEXT character maxima and bounds list cardinality' => array( 'task_url' => '65535', 'payload' => '4294967295' ) === array_reduce( $text_information->wpdb_state()['last_result'], static function ( array $values, object $row ): array { $values[ $row->COLUMN_NAME ] = $row->CHARACTER_MAXIMUM_LENGTH; return $values; }, array() ) + 'information_schema reports TEXT character maxima and bounds list cardinality' => array( 'payload' => '4294967295', 'task_url' => '65535' ) === array_reduce( $text_information->wpdb_state()['last_result'], static function ( array $values, object $row ): array { $values[ $row->COLUMN_NAME ] = $row->CHARACTER_MAXIMUM_LENGTH; return $values; }, array() ) && false === $overwide_information->return_value() && 'resource_limit' === ( $overwide_information->diagnostic()['reason'] ?? null ), 'finite result limits do not authorize unbounded residual source scans' => false === $limited_residual->return_value() diff --git a/tests/smoke-native-shadow-sql-snapshot.php b/tests/smoke-native-shadow-sql-snapshot.php index a9e79c0..29c4fee 100644 --- a/tests/smoke-native-shadow-sql-snapshot.php +++ b/tests/smoke-native-shadow-sql-snapshot.php @@ -299,7 +299,7 @@ public function get_col_info( string $field ): array { && 'native_runtime_fast_path' === ( $json_tableless->report()['context']['last_input_state']['read_connection'] ?? null ), 'catalog capture snapshots requested physical DDL and independently executes COLUMNS metadata' => array( 'wp_plugin_jobs' ) === array_column( $catalog_columns->provenance()['tables'], 'table' ) && 251 === ( $catalog_result->wpdb_state()['col_info'][1]->type ?? null ) - && array( 'status' => '64', 'payload' => '4294967295' ) === array_reduce( $catalog_result->wpdb_state()['last_result'], static function ( array $values, object $row ): array { $values[ $row->COLUMN_NAME ] = $row->CHARACTER_MAXIMUM_LENGTH; return $values; }, array() ), + && array( 'payload' => '4294967295', 'status' => '64' ) === array_reduce( $catalog_result->wpdb_state()['last_result'], static function ( array $values, object $row ): array { $values[ $row->COLUMN_NAME ] = $row->CHARACTER_MAXIMUM_LENGTH; return $values; }, array() ), 'catalog ENGINE remains an explicit unsupported projection after source discovery' => false === $catalog_engine->return_value() && 'unsupported_column' === ( $catalog_engine->diagnostic()['reason'] ?? null ), 'capture results are released after both schema and row reads' => array_reduce( $database->source()->results, static fn( bool $freed, MDI_Snapshot_Result $result ): bool => $freed && $result->freed, true ), From 8cf423c70816b345c498cc03274534d90f851640 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 9 Sep 2026 12:25:25 -0400 Subject: [PATCH 20/53] fix(shadow): compare unordered catalog rows as bags --- ...p-markdown-native-schema-introspection.php | 21 ++++++++++++++++--- ...ass-wp-markdown-native-shadow-verifier.php | 2 +- tests/smoke-native-plugin-schema-query.php | 2 +- tests/smoke-native-shadow-sql-snapshot.php | 2 +- 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/inc/native/class-wp-markdown-native-schema-introspection.php b/inc/native/class-wp-markdown-native-schema-introspection.php index 3744b33..0230c4e 100644 --- a/inc/native/class-wp-markdown-native-schema-introspection.php +++ b/inc/native/class-wp-markdown-native-schema-introspection.php @@ -385,9 +385,6 @@ public function select_information_schema( WP_Markdown_Query_Request $request ): $rows[] = $row; } } - if ( 'COLUMNS' === $catalog && 1 === count( $predicates['TABLE_NAME'] ) && in_array( 'COLUMN_NAME', array_column( $projection, 'name' ), true ) ) { - usort( $rows, static fn( array $left, array $right ): int => strcmp( (string) ( $left['COLUMN_NAME'] ?? '' ), (string) ( $right['COLUMN_NAME'] ?? '' ) ) ); - } return WP_Markdown_Query_Result::selected( $rows, $this->information_schema_metadata( $projection, $catalog ) ); } catch ( WP_Markdown_Native_SQL_Parse_Error ) { return null; @@ -474,6 +471,24 @@ public static function requested_information_schema_tables( string $sql ): ?arra : null; } + /** Catalog reads without an ORDER BY or LIMIT have relationally unordered rows. */ + public static function is_unordered_unbounded_catalog_read( string $sql ): bool { + if ( null === self::requested_information_schema_tables( $sql ) ) { + return false; + } + try { + $tokens = ( new WP_Markdown_Native_SQL_Tokenizer() )->tokenize( rtrim( trim( $sql ), ';' ) ); + } catch ( WP_Markdown_Native_SQL_Parse_Error ) { + return false; + } + foreach ( $tokens as $token ) { + if ( in_array( strtoupper( (string) $token->value() ), array( 'ORDER', 'LIMIT' ), true ) ) { + return false; + } + } + return true; + } + /** @param array{columns:array>,indexes:array>} $definition @return array> */ private function information_schema_columns( string $table, array $definition ): array { $rows = array(); diff --git a/inc/native/class-wp-markdown-native-shadow-verifier.php b/inc/native/class-wp-markdown-native-shadow-verifier.php index 7cb1f7c..9d6a24c 100644 --- a/inc/native/class-wp-markdown-native-shadow-verifier.php +++ b/inc/native/class-wp-markdown-native-shadow-verifier.php @@ -202,7 +202,7 @@ public function observe( string $query, mixed $return_value, object $database ): $expected, $actual ); - if ( ! $comparison['compatible'] && $this->has_unordered_unbounded_result( $query ) ) { + if ( ! $comparison['compatible'] && ( $this->has_unordered_unbounded_result( $query ) || WP_Markdown_Native_Schema_Introspection::is_unordered_unbounded_catalog_read( $query ) ) ) { $comparison = WP_Markdown_Query_Compatibility_Comparator::compare( $this->rows_as_bag( $expected ), $this->rows_as_bag( $actual ) ); } if ( $comparison['compatible'] ) { diff --git a/tests/smoke-native-plugin-schema-query.php b/tests/smoke-native-plugin-schema-query.php index b3ea409..70ac668 100644 --- a/tests/smoke-native-plugin-schema-query.php +++ b/tests/smoke-native-plugin-schema-query.php @@ -183,7 +183,7 @@ function mdi_plugin_schema_remove_tree( string $root ): void { && 'unsupported_column' === ( $information_engine->diagnostic()['reason'] ?? null ), 'information_schema catalog scans remain fail-closed without a bounded table name' => false === $unbounded_information->return_value() && 'unsupported_lookup' === ( $unbounded_information->diagnostic()['reason'] ?? null ), - 'information_schema reports TEXT character maxima and bounds list cardinality' => array( 'payload' => '4294967295', 'task_url' => '65535' ) === array_reduce( $text_information->wpdb_state()['last_result'], static function ( array $values, object $row ): array { $values[ $row->COLUMN_NAME ] = $row->CHARACTER_MAXIMUM_LENGTH; return $values; }, array() ) + 'information_schema reports TEXT character maxima and bounds list cardinality' => array( 'task_url' => '65535', 'payload' => '4294967295' ) === array_reduce( $text_information->wpdb_state()['last_result'], static function ( array $values, object $row ): array { $values[ $row->COLUMN_NAME ] = $row->CHARACTER_MAXIMUM_LENGTH; return $values; }, array() ) && false === $overwide_information->return_value() && 'resource_limit' === ( $overwide_information->diagnostic()['reason'] ?? null ), 'finite result limits do not authorize unbounded residual source scans' => false === $limited_residual->return_value() diff --git a/tests/smoke-native-shadow-sql-snapshot.php b/tests/smoke-native-shadow-sql-snapshot.php index 29c4fee..a9e79c0 100644 --- a/tests/smoke-native-shadow-sql-snapshot.php +++ b/tests/smoke-native-shadow-sql-snapshot.php @@ -299,7 +299,7 @@ public function get_col_info( string $field ): array { && 'native_runtime_fast_path' === ( $json_tableless->report()['context']['last_input_state']['read_connection'] ?? null ), 'catalog capture snapshots requested physical DDL and independently executes COLUMNS metadata' => array( 'wp_plugin_jobs' ) === array_column( $catalog_columns->provenance()['tables'], 'table' ) && 251 === ( $catalog_result->wpdb_state()['col_info'][1]->type ?? null ) - && array( 'payload' => '4294967295', 'status' => '64' ) === array_reduce( $catalog_result->wpdb_state()['last_result'], static function ( array $values, object $row ): array { $values[ $row->COLUMN_NAME ] = $row->CHARACTER_MAXIMUM_LENGTH; return $values; }, array() ), + && array( 'status' => '64', 'payload' => '4294967295' ) === array_reduce( $catalog_result->wpdb_state()['last_result'], static function ( array $values, object $row ): array { $values[ $row->COLUMN_NAME ] = $row->CHARACTER_MAXIMUM_LENGTH; return $values; }, array() ), 'catalog ENGINE remains an explicit unsupported projection after source discovery' => false === $catalog_engine->return_value() && 'unsupported_column' === ( $catalog_engine->diagnostic()['reason'] ?? null ), 'capture results are released after both schema and row reads' => array_reduce( $database->source()->results, static fn( bool $freed, MDI_Snapshot_Result $result ): bool => $freed && $result->freed, true ), From 3d946ef69b005594f212193ba4ff02ca9e08fdcb Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 9 Sep 2026 12:31:34 -0400 Subject: [PATCH 21/53] test(shadow): retain catalog lifecycle receipt --- ...-native-authoritative-snapshot-runtime.php | 28 +++++++++++++++++-- tests/smoke-native-shadow-sql-snapshot.php | 7 +++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/inc/native/class-wp-markdown-native-authoritative-snapshot-runtime.php b/inc/native/class-wp-markdown-native-authoritative-snapshot-runtime.php index 03a8438..8be69c6 100644 --- a/inc/native/class-wp-markdown-native-authoritative-snapshot-runtime.php +++ b/inc/native/class-wp-markdown-native-authoritative-snapshot-runtime.php @@ -11,7 +11,7 @@ final class WP_Markdown_Native_Authoritative_Snapshot_Runtime implements WP_Mark private const MAX_BYTES_PER_TABLE = 8388608; /** @param array $provenance */ - public function __construct( private WP_Markdown_Query_Runtime $runtime, private array $provenance, private ?string $database_name = null ) {} + public function __construct( private WP_Markdown_Query_Runtime $runtime, private array $provenance, private ?string $database_name = null, private ?array $catalog_observation = null ) {} public static function capture( object $database, string $sql, string $prefix ): self { self::trace_runtime_phase( 'capture', $sql ); @@ -28,6 +28,7 @@ public static function capture( object $database, string $sql, string $prefix ): $prefixes = self::schema_prefixes( $database, $prefix ); $database_name = self::database_name( $connection ); + $catalog_observation = self::catalog_observation( $connection, $sql ); $registry = new WP_Markdown_Native_Table_Registry(); $provenance = array(); foreach ( $tables as $table ) { @@ -49,7 +50,11 @@ public static function capture( object $database, string $sql, string $prefix ): $registry->register( $table, $schema, new WP_Markdown_Native_Authoritative_Snapshot_Provider( $rows, $schema ) ); $provenance[] = array( 'table' => $table, 'exists' => true, 'rows' => count( $rows ), 'sha256' => hash( 'sha256', self::encode_rows( $rows ) ), 'schema_sha256' => hash( 'sha256', $definition ) ); } - return new self( new WP_Markdown_Native_Query_Runtime( $registry, database_name: $database_name ), $provenance, $database_name ); + $catalog_observation = null === $catalog_observation ? null : array( + 'before' => $catalog_observation, + 'after' => self::catalog_observation( $connection, $sql ), + ); + return new self( new WP_Markdown_Native_Query_Runtime( $registry, database_name: $database_name ), $provenance, $database_name, $catalog_observation ); } private static function trace_runtime_phase( string $phase, ?string $sql = null ): void { @@ -113,7 +118,24 @@ private function has_explicitly_absent_source( string $sql ): bool { /** @return array{read_connection:string,database_sha256:?string,tables:array} */ public function provenance(): array { - return array( 'read_connection' => 'authoritative_mysql_connection_pre_query', 'database_sha256' => null === $this->database_name ? null : hash( 'sha256', $this->database_name ), 'tables' => $this->provenance ); + return array_filter( + array( + 'read_connection' => 'authoritative_mysql_connection_pre_query', + 'database_sha256' => null === $this->database_name ? null : hash( 'sha256', $this->database_name ), + 'tables' => $this->provenance, + 'catalog_observation' => $this->catalog_observation, + ), + static fn( mixed $value ): bool => null !== $value + ); + } + + /** @return array{rows:int,sha256:string}|null */ + private static function catalog_observation( object $connection, string $sql ): ?array { + if ( null === WP_Markdown_Native_Schema_Introspection::requested_information_schema_tables( $sql ) ) { + return null; + } + $rows = self::rows( $connection, $sql ); + return array( 'rows' => count( $rows ), 'sha256' => hash( 'sha256', self::encode_rows( $rows ) ) ); } /** @return array */ diff --git a/tests/smoke-native-shadow-sql-snapshot.php b/tests/smoke-native-shadow-sql-snapshot.php index a9e79c0..d28296d 100644 --- a/tests/smoke-native-shadow-sql-snapshot.php +++ b/tests/smoke-native-shadow-sql-snapshot.php @@ -24,6 +24,7 @@ final class MDI_Snapshot_Connection { public array $global_rows = array( array( 'meta_id' => '1', 'site_id' => '1', 'meta_key' => 'site_name', 'meta_value' => 'Example' ) ); /** @var array> */ public array $plugin_rows = array( array( 'id' => '1', 'name' => 'Agent' ) ); + public array $catalog_rows = array( array( 'COLUMN_NAME' => 'status', 'DATA_TYPE' => 'varchar', 'CHARACTER_MAXIMUM_LENGTH' => '64', 'IS_NULLABLE' => 'NO' ) ); public bool $blog_table_absent = true; public int $errno = 0; /** @var array */ @@ -66,6 +67,9 @@ public function query( string $sql ): MDI_Snapshot_Result|false { if ( 'SELECT * FROM `wp_2_options` LIMIT 10001' === $sql ) { $result = new MDI_Snapshot_Result( array( array( 'ID' => '1', 'option_value' => 'created' ) ) ); } + if ( str_starts_with( $sql, 'SELECT COLUMN_NAME, DATA_TYPE, CHARACTER_MAXIMUM_LENGTH, IS_NULLABLE FROM information_schema.COLUMNS' ) ) { + $result = new MDI_Snapshot_Result( $this->catalog_rows ); + } if ( $result instanceof MDI_Snapshot_Result ) { $this->results[] = $result; } @@ -300,6 +304,9 @@ public function get_col_info( string $field ): array { 'catalog capture snapshots requested physical DDL and independently executes COLUMNS metadata' => array( 'wp_plugin_jobs' ) === array_column( $catalog_columns->provenance()['tables'], 'table' ) && 251 === ( $catalog_result->wpdb_state()['col_info'][1]->type ?? null ) && array( 'status' => '64', 'payload' => '4294967295' ) === array_reduce( $catalog_result->wpdb_state()['last_result'], static function ( array $values, object $row ): array { $values[ $row->COLUMN_NAME ] = $row->CHARACTER_MAXIMUM_LENGTH; return $values; }, array() ), + 'catalog capture records stable same-connection source metadata before and after the physical snapshot' => 1 === ( $catalog_columns->provenance()['catalog_observation']['before']['rows'] ?? null ) + && ( $catalog_columns->provenance()['catalog_observation']['before'] ?? null ) === ( $catalog_columns->provenance()['catalog_observation']['after'] ?? null ) + && 64 === strlen( (string) ( $catalog_columns->provenance()['catalog_observation']['before']['sha256'] ?? '' ) ), 'catalog ENGINE remains an explicit unsupported projection after source discovery' => false === $catalog_engine->return_value() && 'unsupported_column' === ( $catalog_engine->diagnostic()['reason'] ?? null ), 'capture results are released after both schema and row reads' => array_reduce( $database->source()->results, static fn( bool $freed, MDI_Snapshot_Result $result ): bool => $freed && $result->freed, true ), From ea40bc013afc77d25955533f72378938eccb76a8 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 9 Sep 2026 12:35:02 -0400 Subject: [PATCH 22/53] fix(shadow): exclude temporary catalog tables --- ...wn-native-authoritative-snapshot-runtime.php | 10 +++++++--- tests/smoke-native-shadow-sql-snapshot.php | 17 +++++++++++++++-- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/inc/native/class-wp-markdown-native-authoritative-snapshot-runtime.php b/inc/native/class-wp-markdown-native-authoritative-snapshot-runtime.php index 8be69c6..e18078b 100644 --- a/inc/native/class-wp-markdown-native-authoritative-snapshot-runtime.php +++ b/inc/native/class-wp-markdown-native-authoritative-snapshot-runtime.php @@ -10,7 +10,7 @@ final class WP_Markdown_Native_Authoritative_Snapshot_Runtime implements WP_Mark private const MAX_ROWS_PER_TABLE = 10000; private const MAX_BYTES_PER_TABLE = 8388608; - /** @param array $provenance */ + /** @param array $provenance */ public function __construct( private WP_Markdown_Query_Runtime $runtime, private array $provenance, private ?string $database_name = null, private ?array $catalog_observation = null ) {} public static function capture( object $database, string $sql, string $prefix ): self { @@ -47,8 +47,12 @@ public static function capture( object $database, string $sql, string $prefix ): throw new WP_Markdown_Native_Snapshot_Input_Exception( 'markdown_db_native_snapshot_input_unavailable', 'source_schema_unavailable' ); } $rows = self::rows( $connection, 'SELECT * FROM ' . $quoted . ' LIMIT ' . ( self::MAX_ROWS_PER_TABLE + 1 ) ); - $registry->register( $table, $schema, new WP_Markdown_Native_Authoritative_Snapshot_Provider( $rows, $schema ) ); - $provenance[] = array( 'table' => $table, 'exists' => true, 'rows' => count( $rows ), 'sha256' => hash( 'sha256', self::encode_rows( $rows ) ), 'schema_sha256' => hash( 'sha256', $definition ) ); + $temporary = 1 === preg_match( '/^CREATE\s+TEMPORARY\s+TABLE\b/i', $definition ); + // MySQL omits temporary tables from information_schema, despite SHOW CREATE exposing them. + if ( ! $temporary || null === $catalog_observation ) { + $registry->register( $table, $schema, new WP_Markdown_Native_Authoritative_Snapshot_Provider( $rows, $schema ) ); + } + $provenance[] = array( 'table' => $table, 'exists' => true, 'temporary' => $temporary, 'rows' => count( $rows ), 'sha256' => hash( 'sha256', self::encode_rows( $rows ) ), 'schema_sha256' => hash( 'sha256', $definition ) ); } $catalog_observation = null === $catalog_observation ? null : array( 'before' => $catalog_observation, diff --git a/tests/smoke-native-shadow-sql-snapshot.php b/tests/smoke-native-shadow-sql-snapshot.php index d28296d..6c79749 100644 --- a/tests/smoke-native-shadow-sql-snapshot.php +++ b/tests/smoke-native-shadow-sql-snapshot.php @@ -41,6 +41,8 @@ public function query( string $sql ): MDI_Snapshot_Result|false { $result = new MDI_Snapshot_Result( array( array( 'Table' => 'agents', 'Create Table' => 'CREATE TABLE `agents` (`id` bigint(20) unsigned NOT NULL, `name` varchar(255) NOT NULL, PRIMARY KEY (`id`))' ) ) ); } elseif ( 'SHOW CREATE TABLE `wp_plugin_jobs`' === $sql ) { $result = new MDI_Snapshot_Result( array( array( 'Table' => 'wp_plugin_jobs', 'Create Table' => 'CREATE TABLE `wp_plugin_jobs` (`id` bigint(20) unsigned NOT NULL, `status` varchar(64) NOT NULL, `payload` longtext NOT NULL, PRIMARY KEY (`id`))' ) ) ); + } elseif ( 'SHOW CREATE TABLE `wp_temporary_jobs`' === $sql ) { + $result = new MDI_Snapshot_Result( array( array( 'Table' => 'wp_temporary_jobs', 'Create Table' => 'CREATE TEMPORARY TABLE `wp_temporary_jobs` (`id` bigint(20) unsigned NOT NULL, `status` varchar(64) NOT NULL, PRIMARY KEY (`id`))' ) ) ); } elseif ( 'SHOW CREATE TABLE `wp_2_options`' === $sql && $this->blog_table_absent ) { $this->errno = 1146; return false; @@ -64,11 +66,14 @@ public function query( string $sql ): MDI_Snapshot_Result|false { if ( 'SELECT * FROM `wp_plugin_jobs` LIMIT 10001' === $sql ) { $result = new MDI_Snapshot_Result( array() ); } + if ( 'SELECT * FROM `wp_temporary_jobs` LIMIT 10001' === $sql ) { + $result = new MDI_Snapshot_Result( array() ); + } if ( 'SELECT * FROM `wp_2_options` LIMIT 10001' === $sql ) { $result = new MDI_Snapshot_Result( array( array( 'ID' => '1', 'option_value' => 'created' ) ) ); } - if ( str_starts_with( $sql, 'SELECT COLUMN_NAME, DATA_TYPE, CHARACTER_MAXIMUM_LENGTH, IS_NULLABLE FROM information_schema.COLUMNS' ) ) { - $result = new MDI_Snapshot_Result( $this->catalog_rows ); + if ( str_starts_with( $sql, 'SELECT COLUMN_NAME' ) && str_contains( $sql, 'FROM information_schema.COLUMNS' ) ) { + $result = new MDI_Snapshot_Result( str_contains( $sql, "'wp_temporary_jobs'" ) ? array() : $this->catalog_rows ); } if ( $result instanceof MDI_Snapshot_Result ) { $this->results[] = $result; @@ -259,6 +264,12 @@ public function get_col_info( string $field ): array { ); $catalog_result = $catalog_columns->execute( new WP_Markdown_Query_Request( "SELECT COLUMN_NAME, DATA_TYPE, CHARACTER_MAXIMUM_LENGTH, IS_NULLABLE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = '' AND TABLE_NAME = 'wp_plugin_jobs' AND COLUMN_NAME IN ('status', 'payload')", 'wp_' ) ); $catalog_engine = $catalog_columns->execute( new WP_Markdown_Query_Request( "SELECT ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA = '' AND TABLE_NAME = 'wp_plugin_jobs'", 'wp_' ) ); +$temporary_catalog = WP_Markdown_Native_Authoritative_Snapshot_Runtime::capture( + $database, + "SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = '' AND TABLE_NAME = 'wp_temporary_jobs'", + 'wp_' +); +$temporary_catalog_result = $temporary_catalog->execute( new WP_Markdown_Query_Request( "SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = '' AND TABLE_NAME = 'wp_temporary_jobs'", 'wp_' ) ); $capture_count_at_bound = count( $database->source()->results ); $bounded->capture_input( 'SELECT ID, post_title FROM wp_posts', $database ); $bounded->observe( 'SELECT ID, post_title FROM wp_posts', 1, $database ); @@ -307,6 +318,8 @@ public function get_col_info( string $field ): array { 'catalog capture records stable same-connection source metadata before and after the physical snapshot' => 1 === ( $catalog_columns->provenance()['catalog_observation']['before']['rows'] ?? null ) && ( $catalog_columns->provenance()['catalog_observation']['before'] ?? null ) === ( $catalog_columns->provenance()['catalog_observation']['after'] ?? null ) && 64 === strlen( (string) ( $catalog_columns->provenance()['catalog_observation']['before']['sha256'] ?? '' ) ), + 'temporary source tables remain absent from information_schema catalog snapshots' => true === ( $temporary_catalog->provenance()['tables'][0]['temporary'] ?? null ) + && array() === $temporary_catalog_result->wpdb_state()['last_result'], 'catalog ENGINE remains an explicit unsupported projection after source discovery' => false === $catalog_engine->return_value() && 'unsupported_column' === ( $catalog_engine->diagnostic()['reason'] ?? null ), 'capture results are released after both schema and row reads' => array_reduce( $database->source()->results, static fn( bool $freed, MDI_Snapshot_Result $result ): bool => $freed && $result->freed, true ), From 0d663610eaf442562639f43e1e89026d799a37fe Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 9 Sep 2026 12:45:03 -0400 Subject: [PATCH 23/53] fix(shadow): capture permanent catalog schemas --- ...-native-authoritative-snapshot-runtime.php | 76 ++++++++++++++----- ...lass-wp-markdown-native-query-executor.php | 12 ++- .../class-wp-markdown-native-query-parser.php | 12 ++- tests/smoke-native-shadow-sql-snapshot.php | 31 +++++--- 4 files changed, 95 insertions(+), 36 deletions(-) diff --git a/inc/native/class-wp-markdown-native-authoritative-snapshot-runtime.php b/inc/native/class-wp-markdown-native-authoritative-snapshot-runtime.php index e18078b..53e1159 100644 --- a/inc/native/class-wp-markdown-native-authoritative-snapshot-runtime.php +++ b/inc/native/class-wp-markdown-native-authoritative-snapshot-runtime.php @@ -11,7 +11,7 @@ final class WP_Markdown_Native_Authoritative_Snapshot_Runtime implements WP_Mark private const MAX_BYTES_PER_TABLE = 8388608; /** @param array $provenance */ - public function __construct( private WP_Markdown_Query_Runtime $runtime, private array $provenance, private ?string $database_name = null, private ?array $catalog_observation = null ) {} + public function __construct( private WP_Markdown_Query_Runtime $runtime, private array $provenance, private ?string $database_name = null ) {} public static function capture( object $database, string $sql, string $prefix ): self { self::trace_runtime_phase( 'capture', $sql ); @@ -28,7 +28,7 @@ public static function capture( object $database, string $sql, string $prefix ): $prefixes = self::schema_prefixes( $database, $prefix ); $database_name = self::database_name( $connection ); - $catalog_observation = self::catalog_observation( $connection, $sql ); + $catalog_tables = WP_Markdown_Native_Schema_Introspection::requested_information_schema_tables( $sql ); $registry = new WP_Markdown_Native_Table_Registry(); $provenance = array(); foreach ( $tables as $table ) { @@ -46,19 +46,25 @@ public static function capture( object $database, string $sql, string $prefix ): if ( ! $schema instanceof WP_Markdown_Native_Table_Schema ) { throw new WP_Markdown_Native_Snapshot_Input_Exception( 'markdown_db_native_snapshot_input_unavailable', 'source_schema_unavailable' ); } - $rows = self::rows( $connection, 'SELECT * FROM ' . $quoted . ' LIMIT ' . ( self::MAX_ROWS_PER_TABLE + 1 ) ); $temporary = 1 === preg_match( '/^CREATE\s+TEMPORARY\s+TABLE\b/i', $definition ); - // MySQL omits temporary tables from information_schema, despite SHOW CREATE exposing them. - if ( ! $temporary || null === $catalog_observation ) { - $registry->register( $table, $schema, new WP_Markdown_Native_Authoritative_Snapshot_Provider( $rows, $schema ) ); + if ( $temporary && is_array( $catalog_tables ) && in_array( $table, $catalog_tables, true ) ) { + // SHOW CREATE resolves the session temporary table; metadata must come from the permanent catalog. + $definition = self::permanent_catalog_definition( $connection, $table ); + $compiled = '' === $definition ? array() : WP_Markdown_Native_Schema_Catalog::compile( $definition, $prefixes, array( $table ) ); + $schema_definition = 1 === count( $compiled ) ? reset( $compiled ) : null; + $schema = is_array( $schema_definition ) ? WP_Markdown_Native_Schema_Catalog::indexed_snapshot_schema( $schema_definition ) : null; + if ( ! $schema instanceof WP_Markdown_Native_Table_Schema ) { + throw new WP_Markdown_Native_Snapshot_Input_Exception( 'markdown_db_native_snapshot_input_unavailable', 'permanent_catalog_schema_unavailable' ); + } + $registry->register( $table, $schema, new WP_Markdown_Native_Authoritative_Snapshot_Provider( array(), $schema ) ); + $provenance[] = array( 'table' => $table, 'exists' => true, 'temporary' => true, 'schema_sha256' => hash( 'sha256', $definition ) ); + continue; } + $rows = self::rows( $connection, 'SELECT * FROM ' . $quoted . ' LIMIT ' . ( self::MAX_ROWS_PER_TABLE + 1 ) ); + $registry->register( $table, $schema, new WP_Markdown_Native_Authoritative_Snapshot_Provider( $rows, $schema ) ); $provenance[] = array( 'table' => $table, 'exists' => true, 'temporary' => $temporary, 'rows' => count( $rows ), 'sha256' => hash( 'sha256', self::encode_rows( $rows ) ), 'schema_sha256' => hash( 'sha256', $definition ) ); } - $catalog_observation = null === $catalog_observation ? null : array( - 'before' => $catalog_observation, - 'after' => self::catalog_observation( $connection, $sql ), - ); - return new self( new WP_Markdown_Native_Query_Runtime( $registry, database_name: $database_name ), $provenance, $database_name, $catalog_observation ); + return new self( new WP_Markdown_Native_Query_Runtime( $registry, database_name: $database_name ), $provenance, $database_name ); } private static function trace_runtime_phase( string $phase, ?string $sql = null ): void { @@ -66,17 +72,23 @@ private static function trace_runtime_phase( string $phase, ?string $sql = null if ( ! is_string( $path ) || '' === $path ) { return; } + if ( ( is_file( $path ) ? (int) filesize( $path ) : 0 ) >= 65536 ) { + return; + } $event = array( 'phase' => $phase, 'file_sha256' => hash_file( 'sha256', __FILE__ ) ); - if ( null !== $sql ) { + if ( null !== $sql && strlen( $sql ) <= 65536 ) { try { $event['sql_sha256'] = hash( 'sha256', $sql ); - $event['token_types'] = array_map( static fn( WP_Markdown_Native_SQL_Token $token ): string => $token->type(), ( new WP_Markdown_Native_SQL_Tokenizer() )->tokenize( $sql ) ); + $event['token_types'] = array_slice( array_map( static fn( WP_Markdown_Native_SQL_Token $token ): string => $token->type(), ( new WP_Markdown_Native_SQL_Tokenizer() )->tokenize( $sql ) ), 0, 128 ); $event['table_count'] = count( self::tables_in( $sql ) ); } catch ( WP_Markdown_Native_Snapshot_Input_Exception|WP_Markdown_Native_SQL_Parse_Error ) { $event['token_types'] = array( 'parse_error' ); } } - file_put_contents( $path, json_encode( $event, JSON_UNESCAPED_SLASHES ) . "\n", FILE_APPEND | LOCK_EX ); + $encoded = json_encode( $event, JSON_UNESCAPED_SLASHES ) . "\n"; + if ( strlen( $encoded ) <= 4096 && ( is_file( $path ) ? (int) filesize( $path ) : 0 ) + strlen( $encoded ) <= 65536 ) { + file_put_contents( $path, $encoded, FILE_APPEND | LOCK_EX ); + } } /** @return array */ @@ -127,19 +139,41 @@ public function provenance(): array { 'read_connection' => 'authoritative_mysql_connection_pre_query', 'database_sha256' => null === $this->database_name ? null : hash( 'sha256', $this->database_name ), 'tables' => $this->provenance, - 'catalog_observation' => $this->catalog_observation, ), static fn( mixed $value ): bool => null !== $value ); } - /** @return array{rows:int,sha256:string}|null */ - private static function catalog_observation( object $connection, string $sql ): ?array { - if ( null === WP_Markdown_Native_Schema_Introspection::requested_information_schema_tables( $sql ) ) { - return null; + private static function permanent_catalog_definition( object $connection, string $table ): string { + $escaped = str_replace( "'", "''", $table ); + $rows = self::rows( $connection, "SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_KEY, EXTRA, COLUMN_DEFAULT FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '{$escaped}' ORDER BY ORDINAL_POSITION" ); + if ( array() === $rows ) { + return ''; + } + $columns = array(); + $primary = array(); + foreach ( $rows as $row ) { + $name = (string) ( $row['COLUMN_NAME'] ?? '' ); + $type = (string) ( $row['COLUMN_TYPE'] ?? '' ); + if ( 1 !== preg_match( '/^[A-Za-z0-9_]+$/', $name ) || 1 !== preg_match( '/^[A-Za-z]+(?:\([0-9,]+\))?(?:\s+unsigned)?$/i', $type ) ) { + return ''; + } + $line = '`' . $name . '` ' . $type . ( 'NO' === ( $row['IS_NULLABLE'] ?? null ) ? ' NOT NULL' : '' ); + if ( null !== ( $row['COLUMN_DEFAULT'] ?? null ) ) { + $line .= " DEFAULT '" . str_replace( "'", "''", (string) $row['COLUMN_DEFAULT'] ) . "'"; + } + if ( str_contains( strtolower( (string) ( $row['EXTRA'] ?? '' ) ), 'auto_increment' ) ) { + $line .= ' AUTO_INCREMENT'; + } + $columns[] = $line; + if ( 'PRI' === ( $row['COLUMN_KEY'] ?? null ) ) { + $primary[] = '`' . $name . '`'; + } + } + if ( array() !== $primary ) { + $columns[] = 'PRIMARY KEY (' . implode( ',', $primary ) . ')'; } - $rows = self::rows( $connection, $sql ); - return array( 'rows' => count( $rows ), 'sha256' => hash( 'sha256', self::encode_rows( $rows ) ) ); + return 'CREATE TABLE `' . $table . '` (' . implode( ',', $columns ) . ')'; } /** @return array */ diff --git a/inc/native/class-wp-markdown-native-query-executor.php b/inc/native/class-wp-markdown-native-query-executor.php index 94e6936..f89d955 100644 --- a/inc/native/class-wp-markdown-native-query-executor.php +++ b/inc/native/class-wp-markdown-native-query-executor.php @@ -146,16 +146,22 @@ private static function trace_runtime_phase( string $phase, ?string $sql = null if ( ! is_string( $path ) || '' === $path ) { return; } + if ( ( is_file( $path ) ? (int) filesize( $path ) : 0 ) >= 65536 ) { + return; + } $event = array( 'phase' => $phase, 'file_sha256' => hash_file( 'sha256', __FILE__ ) ); - if ( null !== $sql ) { + if ( null !== $sql && strlen( $sql ) <= 65536 ) { try { $event['sql_sha256'] = hash( 'sha256', $sql ); - $event['token_types'] = array_map( static fn( WP_Markdown_Native_SQL_Token $token ): string => $token->type(), ( new WP_Markdown_Native_SQL_Tokenizer() )->tokenize( $sql ) ); + $event['token_types'] = array_slice( array_map( static fn( WP_Markdown_Native_SQL_Token $token ): string => $token->type(), ( new WP_Markdown_Native_SQL_Tokenizer() )->tokenize( $sql ) ), 0, 128 ); } catch ( WP_Markdown_Native_SQL_Parse_Error ) { $event['token_types'] = array( 'parse_error' ); } } - file_put_contents( $path, json_encode( $event, JSON_UNESCAPED_SLASHES ) . "\n", FILE_APPEND | LOCK_EX ); + $encoded = json_encode( $event, JSON_UNESCAPED_SLASHES ) . "\n"; + if ( strlen( $encoded ) <= 4096 && ( is_file( $path ) ? (int) filesize( $path ) : 0 ) + strlen( $encoded ) <= 65536 ) { + file_put_contents( $path, $encoded, FILE_APPEND | LOCK_EX ); + } } /** Execute source-free typed scalar expressions as the one-row SQL result. */ diff --git a/inc/native/class-wp-markdown-native-query-parser.php b/inc/native/class-wp-markdown-native-query-parser.php index c9b8223..583f00f 100644 --- a/inc/native/class-wp-markdown-native-query-parser.php +++ b/inc/native/class-wp-markdown-native-query-parser.php @@ -28,16 +28,22 @@ private static function trace_runtime_phase( string $phase, ?string $sql = null if ( ! is_string( $path ) || '' === $path ) { return; } + if ( ( is_file( $path ) ? (int) filesize( $path ) : 0 ) >= 65536 ) { + return; + } $event = array( 'phase' => $phase, 'file_sha256' => hash_file( 'sha256', __FILE__ ) ); - if ( null !== $sql ) { + if ( null !== $sql && strlen( $sql ) <= 65536 ) { try { $event['sql_sha256'] = hash( 'sha256', $sql ); - $event['token_types'] = array_map( static fn( WP_Markdown_Native_SQL_Token $token ): string => $token->type(), ( new WP_Markdown_Native_SQL_Tokenizer() )->tokenize( $sql ) ); + $event['token_types'] = array_slice( array_map( static fn( WP_Markdown_Native_SQL_Token $token ): string => $token->type(), ( new WP_Markdown_Native_SQL_Tokenizer() )->tokenize( $sql ) ), 0, 128 ); } catch ( WP_Markdown_Native_SQL_Parse_Error ) { $event['token_types'] = array( 'parse_error' ); } } - file_put_contents( $path, json_encode( $event, JSON_UNESCAPED_SLASHES ) . "\n", FILE_APPEND | LOCK_EX ); + $encoded = json_encode( $event, JSON_UNESCAPED_SLASHES ) . "\n"; + if ( strlen( $encoded ) <= 4096 && ( is_file( $path ) ? (int) filesize( $path ) : 0 ) + strlen( $encoded ) <= 65536 ) { + file_put_contents( $path, $encoded, FILE_APPEND | LOCK_EX ); + } } /** diff --git a/tests/smoke-native-shadow-sql-snapshot.php b/tests/smoke-native-shadow-sql-snapshot.php index 6c79749..e714bf7 100644 --- a/tests/smoke-native-shadow-sql-snapshot.php +++ b/tests/smoke-native-shadow-sql-snapshot.php @@ -25,6 +25,7 @@ final class MDI_Snapshot_Connection { /** @var array> */ public array $plugin_rows = array( array( 'id' => '1', 'name' => 'Agent' ) ); public array $catalog_rows = array( array( 'COLUMN_NAME' => 'status', 'DATA_TYPE' => 'varchar', 'CHARACTER_MAXIMUM_LENGTH' => '64', 'IS_NULLABLE' => 'NO' ) ); + public bool $temporary_permanent_schema_exists = false; public bool $blog_table_absent = true; public int $errno = 0; /** @var array */ @@ -73,7 +74,11 @@ public function query( string $sql ): MDI_Snapshot_Result|false { $result = new MDI_Snapshot_Result( array( array( 'ID' => '1', 'option_value' => 'created' ) ) ); } if ( str_starts_with( $sql, 'SELECT COLUMN_NAME' ) && str_contains( $sql, 'FROM information_schema.COLUMNS' ) ) { - $result = new MDI_Snapshot_Result( str_contains( $sql, "'wp_temporary_jobs'" ) ? array() : $this->catalog_rows ); + if ( str_contains( $sql, "TABLE_NAME = 'wp_temporary_jobs'" ) && str_contains( $sql, 'COLUMN_TYPE' ) ) { + $result = new MDI_Snapshot_Result( $this->temporary_permanent_schema_exists ? array( array( 'COLUMN_NAME' => 'permanent_id', 'COLUMN_TYPE' => 'bigint(20) unsigned', 'IS_NULLABLE' => 'NO', 'COLUMN_KEY' => 'PRI', 'EXTRA' => '', 'COLUMN_DEFAULT' => null ) ) : array() ); + } else { + $result = new MDI_Snapshot_Result( str_contains( $sql, "'wp_temporary_jobs'" ) ? array() : $this->catalog_rows ); + } } if ( $result instanceof MDI_Snapshot_Result ) { $this->results[] = $result; @@ -264,12 +269,22 @@ public function get_col_info( string $field ): array { ); $catalog_result = $catalog_columns->execute( new WP_Markdown_Query_Request( "SELECT COLUMN_NAME, DATA_TYPE, CHARACTER_MAXIMUM_LENGTH, IS_NULLABLE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = '' AND TABLE_NAME = 'wp_plugin_jobs' AND COLUMN_NAME IN ('status', 'payload')", 'wp_' ) ); $catalog_engine = $catalog_columns->execute( new WP_Markdown_Query_Request( "SELECT ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA = '' AND TABLE_NAME = 'wp_plugin_jobs'", 'wp_' ) ); -$temporary_catalog = WP_Markdown_Native_Authoritative_Snapshot_Runtime::capture( +$temporary_catalog_reason = null; +try { + WP_Markdown_Native_Authoritative_Snapshot_Runtime::capture( + $database, + "SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = '' AND TABLE_NAME = 'wp_temporary_jobs'", + 'wp_' + ); +} catch ( WP_Markdown_Native_Snapshot_Input_Exception $error ) { + $temporary_catalog_reason = $error->diagnostic()['reason']; +} +$database->source()->temporary_permanent_schema_exists = true; +$temporary_shadow_catalog = WP_Markdown_Native_Authoritative_Snapshot_Runtime::capture( $database, "SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = '' AND TABLE_NAME = 'wp_temporary_jobs'", 'wp_' -); -$temporary_catalog_result = $temporary_catalog->execute( new WP_Markdown_Query_Request( "SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = '' AND TABLE_NAME = 'wp_temporary_jobs'", 'wp_' ) ); +)->execute( new WP_Markdown_Query_Request( "SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = '' AND TABLE_NAME = 'wp_temporary_jobs'", 'wp_' ) ); $capture_count_at_bound = count( $database->source()->results ); $bounded->capture_input( 'SELECT ID, post_title FROM wp_posts', $database ); $bounded->observe( 'SELECT ID, post_title FROM wp_posts', 1, $database ); @@ -315,11 +330,9 @@ public function get_col_info( string $field ): array { 'catalog capture snapshots requested physical DDL and independently executes COLUMNS metadata' => array( 'wp_plugin_jobs' ) === array_column( $catalog_columns->provenance()['tables'], 'table' ) && 251 === ( $catalog_result->wpdb_state()['col_info'][1]->type ?? null ) && array( 'status' => '64', 'payload' => '4294967295' ) === array_reduce( $catalog_result->wpdb_state()['last_result'], static function ( array $values, object $row ): array { $values[ $row->COLUMN_NAME ] = $row->CHARACTER_MAXIMUM_LENGTH; return $values; }, array() ), - 'catalog capture records stable same-connection source metadata before and after the physical snapshot' => 1 === ( $catalog_columns->provenance()['catalog_observation']['before']['rows'] ?? null ) - && ( $catalog_columns->provenance()['catalog_observation']['before'] ?? null ) === ( $catalog_columns->provenance()['catalog_observation']['after'] ?? null ) - && 64 === strlen( (string) ( $catalog_columns->provenance()['catalog_observation']['before']['sha256'] ?? '' ) ), - 'temporary source tables remain absent from information_schema catalog snapshots' => true === ( $temporary_catalog->provenance()['tables'][0]['temporary'] ?? null ) - && array() === $temporary_catalog_result->wpdb_state()['last_result'], + 'catalog capture does not replay the observed metadata SQL for diagnostic receipts' => ! isset( $catalog_columns->provenance()['catalog_observation'] ), + 'temporary catalog capture is explicitly unavailable when no permanent schema exists' => 'permanent_catalog_schema_unavailable' === $temporary_catalog_reason, + 'temporary tables use independently captured permanent catalog metadata when names shadow' => array( 'permanent_id' ) === array_map( static fn( object $row ): string => $row->COLUMN_NAME, $temporary_shadow_catalog->wpdb_state()['last_result'] ), 'catalog ENGINE remains an explicit unsupported projection after source discovery' => false === $catalog_engine->return_value() && 'unsupported_column' === ( $catalog_engine->diagnostic()['reason'] ?? null ), 'capture results are released after both schema and row reads' => array_reduce( $database->source()->results, static fn( bool $freed, MDI_Snapshot_Result $result ): bool => $freed && $result->freed, true ), From c181939eddec0245b07caf6efba7efd27dd0c67d Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 9 Sep 2026 12:48:53 -0400 Subject: [PATCH 24/53] fix(shadow): atomically bound trace output --- ...down-native-authoritative-snapshot-runtime.php | 15 +++++++++++++-- .../class-wp-markdown-native-query-executor.php | 15 +++++++++++++-- .../class-wp-markdown-native-query-parser.php | 15 +++++++++++++-- 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/inc/native/class-wp-markdown-native-authoritative-snapshot-runtime.php b/inc/native/class-wp-markdown-native-authoritative-snapshot-runtime.php index 53e1159..0c9eb04 100644 --- a/inc/native/class-wp-markdown-native-authoritative-snapshot-runtime.php +++ b/inc/native/class-wp-markdown-native-authoritative-snapshot-runtime.php @@ -86,8 +86,19 @@ private static function trace_runtime_phase( string $phase, ?string $sql = null } } $encoded = json_encode( $event, JSON_UNESCAPED_SLASHES ) . "\n"; - if ( strlen( $encoded ) <= 4096 && ( is_file( $path ) ? (int) filesize( $path ) : 0 ) + strlen( $encoded ) <= 65536 ) { - file_put_contents( $path, $encoded, FILE_APPEND | LOCK_EX ); + if ( strlen( $encoded ) <= 4096 && false !== ( $trace = @fopen( $path, 'c' ) ) ) { + try { + if ( flock( $trace, LOCK_EX ) ) { + $size = fstat( $trace )['size'] ?? 0; + if ( $size + strlen( $encoded ) <= 65536 ) { + fseek( $trace, 0, SEEK_END ); + fwrite( $trace, $encoded ); + } + flock( $trace, LOCK_UN ); + } + } finally { + fclose( $trace ); + } } } diff --git a/inc/native/class-wp-markdown-native-query-executor.php b/inc/native/class-wp-markdown-native-query-executor.php index f89d955..9259075 100644 --- a/inc/native/class-wp-markdown-native-query-executor.php +++ b/inc/native/class-wp-markdown-native-query-executor.php @@ -159,8 +159,19 @@ private static function trace_runtime_phase( string $phase, ?string $sql = null } } $encoded = json_encode( $event, JSON_UNESCAPED_SLASHES ) . "\n"; - if ( strlen( $encoded ) <= 4096 && ( is_file( $path ) ? (int) filesize( $path ) : 0 ) + strlen( $encoded ) <= 65536 ) { - file_put_contents( $path, $encoded, FILE_APPEND | LOCK_EX ); + if ( strlen( $encoded ) <= 4096 && false !== ( $trace = @fopen( $path, 'c' ) ) ) { + try { + if ( flock( $trace, LOCK_EX ) ) { + $size = fstat( $trace )['size'] ?? 0; + if ( $size + strlen( $encoded ) <= 65536 ) { + fseek( $trace, 0, SEEK_END ); + fwrite( $trace, $encoded ); + } + flock( $trace, LOCK_UN ); + } + } finally { + fclose( $trace ); + } } } diff --git a/inc/native/class-wp-markdown-native-query-parser.php b/inc/native/class-wp-markdown-native-query-parser.php index 583f00f..e5eb335 100644 --- a/inc/native/class-wp-markdown-native-query-parser.php +++ b/inc/native/class-wp-markdown-native-query-parser.php @@ -41,8 +41,19 @@ private static function trace_runtime_phase( string $phase, ?string $sql = null } } $encoded = json_encode( $event, JSON_UNESCAPED_SLASHES ) . "\n"; - if ( strlen( $encoded ) <= 4096 && ( is_file( $path ) ? (int) filesize( $path ) : 0 ) + strlen( $encoded ) <= 65536 ) { - file_put_contents( $path, $encoded, FILE_APPEND | LOCK_EX ); + if ( strlen( $encoded ) <= 4096 && false !== ( $trace = @fopen( $path, 'c' ) ) ) { + try { + if ( flock( $trace, LOCK_EX ) ) { + $size = fstat( $trace )['size'] ?? 0; + if ( $size + strlen( $encoded ) <= 65536 ) { + fseek( $trace, 0, SEEK_END ); + fwrite( $trace, $encoded ); + } + flock( $trace, LOCK_UN ); + } + } finally { + fclose( $trace ); + } } } From 9d87378727de996dbb74e973a6b333ab463636c3 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 9 Sep 2026 14:13:25 -0400 Subject: [PATCH 25/53] fix(native): bound post title lookups --- REMAINING_102_GAPS.md | 17 +++++++++++++++++ .../class-wp-markdown-native-query-runtime.php | 8 ++++++++ tests/smoke-native-like-query.php | 7 +++++++ 3 files changed, 32 insertions(+) create mode 100644 REMAINING_102_GAPS.md diff --git a/REMAINING_102_GAPS.md b/REMAINING_102_GAPS.md new file mode 100644 index 0000000..83cd6e4 --- /dev/null +++ b/REMAINING_102_GAPS.md @@ -0,0 +1,17 @@ +# Remaining Native Consumer Gaps + +The exact full DME1053 pair at candidate `99e032be6c5cf65b85a64ce863eb0e0065084682` recorded native 942 passed, 70 failures, 32 errors, and 9 skipped. The MySQL control recorded 1,022 passed, 24 errors, and 7 skipped. + +## Raw-Diagnostic Classification + +- The MySQL control errors are WP_CLI bootstrap failures and must remain separate from native engine parity. +- Native errors include the same missing WP_CLI class plus three physical `mysqli` root-access failures in `VenueProfileMutationsTest`; neither proves a native SQL mismatch. +- Native assertion failures include harness/application state differences such as user initialization and the physical-`mysqli` expectation in `WordPressLifecycleTest`. +- A repeated native query symptom is empty event candidate sets in `EventDateQueryAbilitiesTest` and duplicate/upsert paths. The posts schema did not classify exact `post_title` predicates as bounded lookups. This branch adds ASCII case-insensitive `=` and `IN` support, with non-ASCII values failing closed. + +## Still Unresolved + +- Full Unicode MySQL collation semantics for title lookups remain unsupported. +- Physical `mysqli` and WP_CLI-dependent tests require separate Codebox/bootstrap ownership. +- Transaction semantics require a dedicated end-to-end framework repair; reporting an InnoDB engine string alone would not supply them. +- The remaining native assertions need paired, per-test diagnosis after this focused repair; aggregate full-suite counts are not parity evidence. diff --git a/inc/native/class-wp-markdown-native-query-runtime.php b/inc/native/class-wp-markdown-native-query-runtime.php index fc24c41..409d81e 100644 --- a/inc/native/class-wp-markdown-native-query-runtime.php +++ b/inc/native/class-wp-markdown-native-query-runtime.php @@ -106,6 +106,14 @@ public static function posts_schema(): WP_Markdown_Native_Table_Schema { 'post_author' => array( 'lookup_operators' => array( '=', 'IN' ) ), 'post_parent' => array( 'lookup_operators' => array( '=', 'IN' ) ), 'post_type' => array( 'lookup_operators' => array( '=', 'IN' ) ), + // Duplicate-event discovery uses a title equality candidate set. + // Keep its file-backed scan bounded to ASCII comparisons instead + // of assuming MySQL's full Unicode collation. + 'post_title' => array( + 'normalizer' => array( self::class, 'normalize_ascii_ci' ), + 'lookup_operators' => array( '=', 'IN' ), + 'lookup_validator' => static fn( array $values ): bool => self::all_ascii_strings( $values ), + ), // WordPress resolves a permalink by slug, so post_name is the // lookup every front-end request depends on. Slugs are // sanitized to ASCII, and a non-ASCII slug fails closed diff --git a/tests/smoke-native-like-query.php b/tests/smoke-native-like-query.php index 0f202dc..a3e9337 100644 --- a/tests/smoke-native-like-query.php +++ b/tests/smoke-native-like-query.php @@ -28,6 +28,9 @@ $contains = $runtime->execute( new WP_Markdown_Query_Request( "SELECT ID FROM wp_posts WHERE post_title LIKE '%Hello%'", 'wp_' ) ); $prefix = $runtime->execute( new WP_Markdown_Query_Request( "SELECT ID FROM wp_posts WHERE post_title LIKE 'Good%'", 'wp_' ) ); $ci = $runtime->execute( new WP_Markdown_Query_Request( "SELECT ID FROM wp_posts WHERE post_title LIKE '%hello%'", 'wp_' ) ); +$exact = $runtime->execute( new WP_Markdown_Query_Request( "SELECT ID FROM wp_posts WHERE post_title = 'hello world'", 'wp_' ) ); +$candidates = $runtime->execute( new WP_Markdown_Query_Request( "SELECT ID FROM wp_posts WHERE post_title IN ('goodbye moon', 'unrelated') ORDER BY ID", 'wp_' ) ); +$exact_unicode = $runtime->execute( new WP_Markdown_Query_Request( "SELECT ID FROM wp_posts WHERE post_title = 'Café'", 'wp_' ) ); $content_like = $runtime->execute( new WP_Markdown_Query_Request( "SELECT ID FROM wp_posts WHERE post_content LIKE '%hello%'", 'wp_' ) ); $unicode = $runtime->execute( new WP_Markdown_Query_Request( "SELECT ID FROM wp_posts WHERE post_title LIKE '%Café%'", 'wp_' ) ); $integer = $runtime->execute( new WP_Markdown_Query_Request( "SELECT ID FROM wp_posts WHERE ID LIKE '1%'", 'wp_' ) ); @@ -42,6 +45,10 @@ 'a contains-pattern matches ASCII titles' => array( '11' ) === $ids( $contains ), 'a prefix-pattern matches ASCII titles' => array( '12' ) === $ids( $prefix ), 'LIKE matching is ASCII case-insensitive' => array( '11' ) === $ids( $ci ), + 'an exact title lookup is ASCII case-insensitive' => array( '11' ) === $ids( $exact ), + 'a bounded title candidate set is indexable' => array( '12', '13' ) === $ids( $candidates ), + 'a non-ASCII exact title lookup fails closed' => false === $exact_unicode->return_value() + && 'unsupported_lookup' === ( $exact_unicode->diagnostic()['reason'] ?? null ), 'LIKE can scan post_content' => array( '12' ) === $ids( $content_like ), 'a non-ASCII LIKE pattern fails closed' => false === $unicode->return_value() && 'unsupported_lookup' === ( $unicode->diagnostic()['reason'] ?? null ), From b85dfe1ada169bf9b67e66f2633b2c38691d1ceb Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 9 Sep 2026 14:26:21 -0400 Subject: [PATCH 26/53] fix(native): cap title lookup source work --- REMAINING_102_GAPS.md | 3 +- ...class-wp-markdown-native-query-runtime.php | 3 +- ...ass-wp-markdown-native-table-providers.php | 25 +++++++++++++ tests/smoke-native-like-query.php | 4 ++ .../smoke-native-post-title-lookup-budget.php | 37 +++++++++++++++++++ 5 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 tests/smoke-native-post-title-lookup-budget.php diff --git a/REMAINING_102_GAPS.md b/REMAINING_102_GAPS.md index 83cd6e4..bc76e0f 100644 --- a/REMAINING_102_GAPS.md +++ b/REMAINING_102_GAPS.md @@ -7,11 +7,12 @@ The exact full DME1053 pair at candidate `99e032be6c5cf65b85a64ce863eb0e00650846 - The MySQL control errors are WP_CLI bootstrap failures and must remain separate from native engine parity. - Native errors include the same missing WP_CLI class plus three physical `mysqli` root-access failures in `VenueProfileMutationsTest`; neither proves a native SQL mismatch. - Native assertion failures include harness/application state differences such as user initialization and the physical-`mysqli` expectation in `WordPressLifecycleTest`. -- A repeated native query symptom is empty event candidate sets in `EventDateQueryAbilitiesTest` and duplicate/upsert paths. The posts schema did not classify exact `post_title` predicates as bounded lookups. This branch adds ASCII case-insensitive `=` and `IN` support, with non-ASCII values failing closed. +- A repeated native query symptom is empty event candidate sets in `EventDateQueryAbilitiesTest` and duplicate/upsert paths. The posts schema did not classify exact `post_title` predicates as lookups. This branch supports ASCII case-insensitive, trailing-space-padded `=` and `IN` comparisons, with non-ASCII values failing closed. A title lookup without a reusable scoped snapshot explicitly fails after 1,024 canonical source files; ASCII validation is a collation constraint, not a scan-cost bound. ## Still Unresolved - Full Unicode MySQL collation semantics for title lookups remain unsupported. +- Title lookups over larger uncached canonical corpora require a reusable source index before they can execute without the explicit 1,024-file work limit. - Physical `mysqli` and WP_CLI-dependent tests require separate Codebox/bootstrap ownership. - Transaction semantics require a dedicated end-to-end framework repair; reporting an InnoDB engine string alone would not supply them. - The remaining native assertions need paired, per-test diagnosis after this focused repair; aggregate full-suite counts are not parity evidence. diff --git a/inc/native/class-wp-markdown-native-query-runtime.php b/inc/native/class-wp-markdown-native-query-runtime.php index 409d81e..baabb26 100644 --- a/inc/native/class-wp-markdown-native-query-runtime.php +++ b/inc/native/class-wp-markdown-native-query-runtime.php @@ -107,10 +107,11 @@ public static function posts_schema(): WP_Markdown_Native_Table_Schema { 'post_parent' => array( 'lookup_operators' => array( '=', 'IN' ) ), 'post_type' => array( 'lookup_operators' => array( '=', 'IN' ) ), // Duplicate-event discovery uses a title equality candidate set. + // MySQL's nonbinary VARCHAR comparisons ignore trailing spaces. // Keep its file-backed scan bounded to ASCII comparisons instead // of assuming MySQL's full Unicode collation. 'post_title' => array( - 'normalizer' => array( self::class, 'normalize_ascii_ci' ), + 'normalizer' => array( self::class, 'normalize_ascii_ci_padded' ), 'lookup_operators' => array( '=', 'IN' ), 'lookup_validator' => static fn( array $values ): bool => self::all_ascii_strings( $values ), ), diff --git a/inc/native/class-wp-markdown-native-table-providers.php b/inc/native/class-wp-markdown-native-table-providers.php index cb9b927..aed3822 100644 --- a/inc/native/class-wp-markdown-native-table-providers.php +++ b/inc/native/class-wp-markdown-native-table-providers.php @@ -235,6 +235,9 @@ protected function path_signature( string $path, ?string $content_digest = null } final class WP_Markdown_Native_Post_Provider extends WP_Markdown_Native_File_Provider { + // Exact title predicates do not have a file-addressable canonical index. + private const TITLE_LOOKUP_SOURCE_FILE_BUDGET = 1024; + private WP_Markdown_Storage $storage; private WP_Markdown_Native_Post_Catalogue $catalogue; /** @var array,file:array,identity:array}>> */ @@ -331,6 +334,19 @@ private function post_type_scope( WP_Markdown_Native_Table_Access $access ): ?ar return null; } + /** Whether this read needs the bounded fallback scan for a title lookup. */ + private function has_title_lookup( array $predicates ): bool { + foreach ( $predicates as $predicate ) { + if ( 'post_title' === $predicate->column() + && in_array( $predicate->operator(), array( '=', 'IN' ), true ) + && $this->schema->allows_lookup( 'post_title', $predicate->operator(), $predicate->values() ) + ) { + return true; + } + } + return false; + } + /** * Resolve a read restricted to durable identity without walking the corpus. * @@ -426,6 +442,8 @@ private function read_posts( WP_Markdown_Native_Table_Access $access, array $all static fn( WP_Markdown_Native_Query_Predicate $predicate ): bool => ! in_array( 'post_content', $predicate->columns(), true ) ) ); + $title_lookup = $this->has_title_lookup( $predicates ); + $title_source_files = 0; $scope = $this->post_type_scope( $access ); $key = null === $scope ? null : $this->parse_key( $scope ); $ordered = false; @@ -444,6 +462,13 @@ private function read_posts( WP_Markdown_Native_Table_Access $access, array $all $scanning = true; } foreach ( $this->storage->get_markdown_file_manifest_iterator( true, $scope ) as $file ) { + if ( $title_lookup && ++$title_source_files > self::TITLE_LOOKUP_SOURCE_FILE_BUDGET ) { + return $this->failure( + 'markdown_db_native_source_work_budget', + 'title_lookup_source_budget', + 'mdi-native refuses title lookups that require scanning more than 1024 canonical files.' + ); + } // The manifest looked at this file to yield it, so its witness // is the one taken then. $witness = $file['witness'] ?? WP_Markdown_File_Witness::take( $file['absolute'] ); diff --git a/tests/smoke-native-like-query.php b/tests/smoke-native-like-query.php index a3e9337..3aedeac 100644 --- a/tests/smoke-native-like-query.php +++ b/tests/smoke-native-like-query.php @@ -29,6 +29,8 @@ $prefix = $runtime->execute( new WP_Markdown_Query_Request( "SELECT ID FROM wp_posts WHERE post_title LIKE 'Good%'", 'wp_' ) ); $ci = $runtime->execute( new WP_Markdown_Query_Request( "SELECT ID FROM wp_posts WHERE post_title LIKE '%hello%'", 'wp_' ) ); $exact = $runtime->execute( new WP_Markdown_Query_Request( "SELECT ID FROM wp_posts WHERE post_title = 'hello world'", 'wp_' ) ); +$ordered_exact = $runtime->execute( new WP_Markdown_Query_Request( "SELECT ID FROM wp_posts WHERE post_title = 'hello world' ORDER BY post_date_gmt DESC LIMIT 1", 'wp_' ) ); +$padded = $runtime->execute( new WP_Markdown_Query_Request( "SELECT ID FROM wp_posts WHERE post_title = 'Hello World '", 'wp_' ) ); $candidates = $runtime->execute( new WP_Markdown_Query_Request( "SELECT ID FROM wp_posts WHERE post_title IN ('goodbye moon', 'unrelated') ORDER BY ID", 'wp_' ) ); $exact_unicode = $runtime->execute( new WP_Markdown_Query_Request( "SELECT ID FROM wp_posts WHERE post_title = 'Café'", 'wp_' ) ); $content_like = $runtime->execute( new WP_Markdown_Query_Request( "SELECT ID FROM wp_posts WHERE post_content LIKE '%hello%'", 'wp_' ) ); @@ -46,6 +48,8 @@ 'a prefix-pattern matches ASCII titles' => array( '12' ) === $ids( $prefix ), 'LIKE matching is ASCII case-insensitive' => array( '11' ) === $ids( $ci ), 'an exact title lookup is ASCII case-insensitive' => array( '11' ) === $ids( $exact ), + 'an ordered title lookup retains its LIMIT result' => array( '11' ) === $ids( $ordered_exact ), + 'an exact title lookup ignores trailing spaces' => array( '11' ) === $ids( $padded ), 'a bounded title candidate set is indexable' => array( '12', '13' ) === $ids( $candidates ), 'a non-ASCII exact title lookup fails closed' => false === $exact_unicode->return_value() && 'unsupported_lookup' === ( $exact_unicode->diagnostic()['reason'] ?? null ), diff --git a/tests/smoke-native-post-title-lookup-budget.php b/tests/smoke-native-post-title-lookup-budget.php new file mode 100644 index 0000000..fb14702 --- /dev/null +++ b/tests/smoke-native-post-title-lookup-budget.php @@ -0,0 +1,37 @@ +execute( new WP_Markdown_Query_Request( "SELECT ID FROM wp_posts WHERE post_title = 'Absent' ORDER BY post_date_gmt DESC LIMIT 1", 'wp_' ) ); +$passed = false === $result->return_value() + && 'title_lookup_source_budget' === ( $result->diagnostic()['reason'] ?? null ); + +echo ( $passed ? 'PASS' : 'FAIL' ) . ": title lookup stops after its explicit source-work budget\n"; +array_map( 'unlink', glob( $content . '/post/*' ) ?: array() ); +@rmdir( $content . '/post' ); +@rmdir( $content ); +array_map( 'unlink', glob( $state . '/_options/*' ) ?: array() ); +@rmdir( $state . '/_options' ); +@rmdir( $state ); +@rmdir( $root ); + +exit( $passed ? 0 : 1 ); From f3c9768d2d0574ba294b62829dd72fda37f8d3e7 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 9 Sep 2026 14:44:13 -0400 Subject: [PATCH 27/53] fix(native): migrate legacy multisite user snapshots --- ...ass-wp-markdown-native-table-providers.php | 24 ++++++++++++++++++- tests/smoke-native-generic-query.php | 13 ++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/inc/native/class-wp-markdown-native-table-providers.php b/inc/native/class-wp-markdown-native-table-providers.php index aed3822..6aa6aba 100644 --- a/inc/native/class-wp-markdown-native-table-providers.php +++ b/inc/native/class-wp-markdown-native-table-providers.php @@ -668,7 +668,29 @@ public function rows(): array|WP_Markdown_Query_Result { $data = $this->read_json( $path, $root, 'table_file' ); return $this->snapshot = $data instanceof WP_Markdown_Query_Result ? $data - : $this->validate_rows( $data ); + : $this->validate_rows( $this->materialize_multisite_user_defaults( $data ) ); + } + + /** + * Older canonical user snapshots predate the two network-only columns. + * MySQL supplies their declared zero defaults when a single-site snapshot is + * opened by a multisite runtime, so preserve that durable representation. + */ + private function materialize_multisite_user_defaults( mixed $rows ): mixed { + if ( 'users.json' !== $this->filename + || ! $this->schema->has_column( 'spam' ) + || ! $this->schema->has_column( 'deleted' ) + || ! is_array( $rows ) + || ! array_is_list( $rows ) + ) { + return $rows; + } + foreach ( $rows as $offset => $row ) { + if ( is_array( $row ) ) { + $rows[ $offset ] = array_merge( array( 'spam' => '0', 'deleted' => '0' ), $row ); + } + } + return $rows; } /** diff --git a/tests/smoke-native-generic-query.php b/tests/smoke-native-generic-query.php index 76c5705..c0bd104 100644 --- a/tests/smoke-native-generic-query.php +++ b/tests/smoke-native-generic-query.php @@ -183,6 +183,13 @@ public function get_col_info( string $type ): array { $multisite_user['spam'] = '0'; $multisite_user['deleted'] = '0'; $multisite_schema = WP_Markdown_Native_Runtime_Factory::users_schema( true ); +$legacy_multisite_root = sys_get_temp_dir() . '/mdi-native-legacy-multisite-users-' . bin2hex( random_bytes( 6 ) ); +mkdir( $legacy_multisite_root . '/_tables', 0777, true ); +mkdir( $legacy_multisite_root . '/_options', 0777, true ); +file_put_contents( $legacy_multisite_root . '/_tables/users.json', json_encode( array( $users[1] ), JSON_THROW_ON_ERROR ) ); +$legacy_multisite_user = WP_Markdown_Native_Runtime_Factory::runtime( $legacy_multisite_root, 'wp_', 'wp_', true )->execute( + new WP_Markdown_Query_Request( "SELECT spam, deleted FROM wp_users WHERE user_login = 'admin'" ) +); $checks = array( 'native wpdb reports the semantics it implements without mysqli' => '8.0.0-mdi-native' === $database->db_server_info() @@ -231,6 +238,8 @@ public function get_col_info( string $type ): array { && false === $invalid_width->return_value() && array() === $invalid_width->wpdb_state()['last_result'], 'multisite user schemas accept required spam and deleted columns' => true === $multisite_schema->validate_row( $multisite_user ), + 'legacy single-site user snapshots receive multisite defaults' => '0' === ( $legacy_multisite_user->wpdb_state()['last_result'][0]->spam ?? null ) + && '0' === ( $legacy_multisite_user->wpdb_state()['last_result'][0]->deleted ?? null ), ); $failed = 0; @@ -246,4 +255,8 @@ public function get_col_info( string $type ): array { @rmdir( $root . '/_tables' ); @rmdir( $root . '/_options' ); @rmdir( $root ); +@unlink( $legacy_multisite_root . '/_tables/users.json' ); +@rmdir( $legacy_multisite_root . '/_tables' ); +@rmdir( $legacy_multisite_root . '/_options' ); +@rmdir( $legacy_multisite_root ); exit( $failed ? 1 : 0 ); From a5b28d8b850c75194363e9429782d2754a9a101c Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 9 Sep 2026 15:43:04 -0400 Subject: [PATCH 28/53] fix(native): support bounded not-equal table deletes --- ...wp-markdown-native-table-insert-parser.php | 5 +++-- ...ass-wp-markdown-native-table-mutations.php | 4 ++++ tests/smoke-native-table-write.php | 21 +++++++++++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/inc/native/class-wp-markdown-native-table-insert-parser.php b/inc/native/class-wp-markdown-native-table-insert-parser.php index 37c8ade..76be948 100644 --- a/inc/native/class-wp-markdown-native-table-insert-parser.php +++ b/inc/native/class-wp-markdown-native-table-insert-parser.php @@ -268,12 +268,13 @@ private function where_factor() { return new WP_Markdown_Native_Table_Predicate( $column, array( $value ), false ); } - /** @return '<'|'<='|'>'|'>='|null */ + /** @return '<>'|'<'|'<='|'>'|'>='|null */ private function comparison_operator(): ?string { $type = $this->current()->type(); - if ( in_array( $type, array( WP_Markdown_Native_SQL_Token::LESS_THAN, WP_Markdown_Native_SQL_Token::LESS_EQUALS, WP_Markdown_Native_SQL_Token::GREATER_THAN, WP_Markdown_Native_SQL_Token::GREATER_EQUALS ), true ) ) { + if ( in_array( $type, array( WP_Markdown_Native_SQL_Token::NOT_EQUALS, WP_Markdown_Native_SQL_Token::LESS_THAN, WP_Markdown_Native_SQL_Token::LESS_EQUALS, WP_Markdown_Native_SQL_Token::GREATER_THAN, WP_Markdown_Native_SQL_Token::GREATER_EQUALS ), true ) ) { ++$this->position; return match ( $type ) { + WP_Markdown_Native_SQL_Token::NOT_EQUALS => '<>', WP_Markdown_Native_SQL_Token::LESS_THAN => '<', WP_Markdown_Native_SQL_Token::LESS_EQUALS => '<=', WP_Markdown_Native_SQL_Token::GREATER_THAN => '>', diff --git a/inc/native/class-wp-markdown-native-table-mutations.php b/inc/native/class-wp-markdown-native-table-mutations.php index e965915..eb0f8ab 100644 --- a/inc/native/class-wp-markdown-native-table-mutations.php +++ b/inc/native/class-wp-markdown-native-table-mutations.php @@ -702,6 +702,10 @@ private function restricts_predicate( array $row, $predicate, WP_Markdown_Native return true; } $operator = $predicate->operator(); + if ( '<>' === $operator ) { + // Like MySQL, comparisons against NULL are unknown rather than true. + return null !== $value && ! $schema->values_match( $predicate->column(), $value, $predicate->values()[0] ?? null ); + } if ( in_array( $operator, array( '<', '<=', '>', '>=' ), true ) ) { // A comparison with NULL is unknown, which never restricts. if ( null === $value ) { diff --git a/tests/smoke-native-table-write.php b/tests/smoke-native-table-write.php index 1a06fec..fc69754 100644 --- a/tests/smoke-native-table-write.php +++ b/tests/smoke-native-table-write.php @@ -64,6 +64,12 @@ public function remove_placeholder_escape( string $value ): string { 'wp_' ) ); +$runtime->execute( + new WP_Markdown_Query_Request( + 'CREATE TABLE wp_cleanup_agents (id BIGINT NOT NULL AUTO_INCREMENT, label VARCHAR(60) NULL, PRIMARY KEY (id))', + 'wp_' + ) +); $runtime->execute( new WP_Markdown_Query_Request( 'CREATE TABLE wp_unique_jobs (id BIGINT NOT NULL AUTO_INCREMENT, scope VARCHAR(20) NULL, token VARCHAR(20) NULL, PRIMARY KEY (id), UNIQUE KEY scoped_token (scope, token(3)))', @@ -105,6 +111,13 @@ public function remove_placeholder_escape( string $value ): string { ) as $insert ) { $runtime->execute( new WP_Markdown_Query_Request( $insert, 'wp_' ) ); } +foreach ( array( + "INSERT INTO wp_cleanup_agents (label) VALUES ('first')", + "INSERT INTO wp_cleanup_agents (label) VALUES ('admin')", + "INSERT INTO wp_cleanup_agents (label) VALUES ('third')", +) as $insert ) { + $runtime->execute( new WP_Markdown_Query_Request( $insert, 'wp_' ) ); +} $runtime->execute( new WP_Markdown_Query_Request( "INSERT INTO wp_corrupt_jobs (token, state) VALUES ('first', 'pending')", 'wp_' ) ); $runtime->execute( new WP_Markdown_Query_Request( "INSERT INTO wp_corrupt_jobs (token, state) VALUES ('second', 'pending')", 'wp_' ) ); $runtime->execute( new WP_Markdown_Query_Request( "INSERT INTO wp_proof_jobs (token, state) VALUES ('first', 'pending')", 'wp_' ) ); @@ -148,6 +161,12 @@ function column_values( string $root, string $column, string $table = 'agents' ) ); $after_delete = column_values( $root, 'label' ); +// WordPress fixture cleanup retains its administrative row with this shape. +$inequality_delete = $runtime->execute( + new WP_Markdown_Query_Request( 'DELETE FROM wp_cleanup_agents WHERE id != 2', 'wp_' ) +); +$after_inequality_delete = column_values( $root, 'label', 'cleanup_agents' ); + // Serialized values carry semicolons, which must not read as a statement separator. $serialized = $runtime->execute( new WP_Markdown_Query_Request( @@ -256,6 +275,8 @@ function column_values( string $root, string $column, string $table = 'agents' ) && 1 === $matches_new_null->return_value(), 'DELETE removes only the restricted rows' => 1 === $deleted->return_value() && array( 'null-target', 'second' ) === $after_delete, + 'a not-equal DELETE retains only its selected row' => 2 === $inequality_delete->return_value() + && array( 'admin' ) === $after_inequality_delete, 'a serialized value is not read as a statement separator' => 1 === $serialized->return_value() && 'a:1:{s:3:"key";i:42;}' === ( $serialized_rows[ count( $serialized_rows ) - 1 ]['label'] ?? null ), 'a semicolon inside a literal survives an update' => 1 === $semicolon_text->return_value(), From afad2b7f558e09224ac37fcc17cde7c25d90f4e0 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 9 Sep 2026 16:20:20 -0400 Subject: [PATCH 29/53] fix(native): implicitly commit table DDL --- inc/native/class-wp-markdown-native-schema-mutations.php | 8 ++++++++ tests/smoke-native-create-table.php | 6 ++++++ 2 files changed, 14 insertions(+) diff --git a/inc/native/class-wp-markdown-native-schema-mutations.php b/inc/native/class-wp-markdown-native-schema-mutations.php index 211fbe6..0755c61 100644 --- a/inc/native/class-wp-markdown-native-schema-mutations.php +++ b/inc/native/class-wp-markdown-native-schema-mutations.php @@ -27,6 +27,14 @@ public function __construct( } public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query_Result { + // MySQL commits an open transaction before every table DDL statement. + // Otherwise a later rollback would erase a schema that MySQL retains. + if ( null !== $this->transactions ) { + $committed = $this->transactions->commit(); + if ( true !== $committed ) { + return $this->failure( 'transaction_commit_failed', $committed ); + } + } $sql = trim( $request->sql() ); if ( str_ends_with( $sql, ';' ) ) { $sql = rtrim( substr( $sql, 0, -1 ) ); diff --git a/tests/smoke-native-create-table.php b/tests/smoke-native-create-table.php index b7a3e3a..58e040f 100644 --- a/tests/smoke-native-create-table.php +++ b/tests/smoke-native-create-table.php @@ -43,6 +43,10 @@ function mdi_native_create_remove_tree( string $root ): void { $duplicate = $runtime->execute( new WP_Markdown_Query_Request( $ddl ) ); $injected = $runtime->execute( new WP_Markdown_Query_Request( $ddl . '; DROP TABLE wp_options' ) ); $reloaded = WP_Markdown_Native_Runtime_Factory::runtime( $root )->execute( new WP_Markdown_Query_Request( 'DESCRIBE wp_plugin_events' ) ); +$runtime->execute( new WP_Markdown_Query_Request( 'START TRANSACTION' ) ); +$transactional_ddl = $runtime->execute( new WP_Markdown_Query_Request( 'CREATE TABLE wp_ddl_commit (id bigint unsigned NOT NULL, PRIMARY KEY (id))' ) ); +$runtime->execute( new WP_Markdown_Query_Request( 'ROLLBACK' ) ); +$ddl_survives_rollback = WP_Markdown_Native_Runtime_Factory::runtime( $root )->execute( new WP_Markdown_Query_Request( 'DESCRIBE wp_ddl_commit' ) ); $checks = array( 'generic CREATE TABLE returns the WordPress DDL success shape' => true === $created->return_value() @@ -61,6 +65,8 @@ function mdi_native_create_remove_tree( string $root ): void { && 'unsupported_grammar' === ( $injected->diagnostic()['reason'] ?? null ) && $ddl . ";\n" === file_get_contents( $root . '/_schema/plugin_events.sql' ), 'persisted definitions restore introspection after a cold reload' => 'event_key' === ( $reloaded->wpdb_state()['last_result'][0]->Field ?? null ), + 'table DDL implicitly commits and survives a later rollback' => true === $transactional_ddl->return_value() + && 'id' === ( $ddl_survives_rollback->wpdb_state()['last_result'][0]->Field ?? null ), ); $failed = false; From 548042c47efac724d13d25744765dbc65a851b20 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 9 Sep 2026 16:22:06 -0400 Subject: [PATCH 30/53] test(native): cover event date schema DDL --- tests/smoke-native-create-table.php | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/smoke-native-create-table.php b/tests/smoke-native-create-table.php index 58e040f..2422508 100644 --- a/tests/smoke-native-create-table.php +++ b/tests/smoke-native-create-table.php @@ -44,7 +44,16 @@ function mdi_native_create_remove_tree( string $root ): void { $injected = $runtime->execute( new WP_Markdown_Query_Request( $ddl . '; DROP TABLE wp_options' ) ); $reloaded = WP_Markdown_Native_Runtime_Factory::runtime( $root )->execute( new WP_Markdown_Query_Request( 'DESCRIBE wp_plugin_events' ) ); $runtime->execute( new WP_Markdown_Query_Request( 'START TRANSACTION' ) ); -$transactional_ddl = $runtime->execute( new WP_Markdown_Query_Request( 'CREATE TABLE wp_ddl_commit (id bigint unsigned NOT NULL, PRIMARY KEY (id))' ) ); +$transactional_ddl = $runtime->execute( new WP_Markdown_Query_Request( "CREATE TABLE wp_ddl_commit (\n" + . " id bigint unsigned NOT NULL,\n" + . " start_datetime datetime NOT NULL,\n" + . " end_datetime datetime DEFAULT NULL,\n" + . " post_status varchar(20) NOT NULL DEFAULT 'publish',\n" + . " PRIMARY KEY (id),\n" + . " KEY start_datetime (start_datetime),\n" + . " KEY end_datetime (end_datetime),\n" + . " KEY status_start (post_status, start_datetime)\n" + . ') ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci' ) ); $runtime->execute( new WP_Markdown_Query_Request( 'ROLLBACK' ) ); $ddl_survives_rollback = WP_Markdown_Native_Runtime_Factory::runtime( $root )->execute( new WP_Markdown_Query_Request( 'DESCRIBE wp_ddl_commit' ) ); From e4a639b10892d498e95288de62e9a597644235b5 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 10 Sep 2026 10:24:43 -0400 Subject: [PATCH 31/53] fix: ignore stale missing cached post paths --- inc/class-wp-markdown-storage.php | 2 +- tests/smoke-parent-promotion-index.php | 36 ++++++++++++++++++++++++-- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/inc/class-wp-markdown-storage.php b/inc/class-wp-markdown-storage.php index 742b893..5d7d372 100644 --- a/inc/class-wp-markdown-storage.php +++ b/inc/class-wp-markdown-storage.php @@ -819,7 +819,7 @@ private function safe_unlink( string $path ): bool { /** Remove a cached post path only when it still carries that post's ID. */ private function safe_unlink_owned_by( string $path, int $post_id ): bool { - return $post_id === $this->extract_id_from_file( $path ) && $this->safe_unlink( $path ); + return $this->existing_path_is_safe( $path ) && $post_id === $this->extract_id_from_file( $path ) && $this->safe_unlink( $path ); } /** diff --git a/tests/smoke-parent-promotion-index.php b/tests/smoke-parent-promotion-index.php index 3b38578..c2d04a6 100644 --- a/tests/smoke-parent-promotion-index.php +++ b/tests/smoke-parent-promotion-index.php @@ -375,9 +375,41 @@ function write_leaf( string $path, int $id, string $slug, int $parent = 0 ): voi assert_eq( $storage->read_file( $tmp_root . '/wiki/original.md', true )->ID ?? null, 20, 'reused path retains its new owner' ); // --------------------------------------------------------------------------- -// Test 8 — a fresh write still wins over a stale duplicate during first scan +// Test 8 — an externally removed cached path does not become a read warning // --------------------------------------------------------------------------- -echo "\nTest 8: fresh writes remain canonical during initial duplicate cleanup\n"; +echo "\nTest 8: stale missing cached paths are ignored without parsing them\n"; + +rm_rf( $tmp_root ); +write_leaf( $tmp_root . '/wiki/original.md', 10, 'original' ); + +$storage = new WP_Markdown_Storage( $tmp_root, array() ); +assert_true( null !== $storage->read_post( 10 ), 'initial read completes the index for a removable path' ); +unlink( $tmp_root . '/wiki/original.md' ); + +set_error_handler( static function ( int $severity, string $message ): never { + throw new ErrorException( $message, 0, $severity ); +} ); +try { + $written_path = $storage->write_post( (object) array( + 'ID' => 10, + 'post_type' => 'wiki', + 'post_name' => 'replacement', + 'post_parent' => 0, + 'post_status' => 'publish', + 'post_title' => 'Replacement', + 'post_content' => 'replacement body', + ) ); + assert_eq( $written_path, $tmp_root . '/wiki/replacement.md', 'write ignores the stale missing path without a warning' ); +} catch ( ErrorException $exception ) { + assert_true( false, 'stale missing cached path emitted a warning: ' . $exception->getMessage() ); +} finally { + restore_error_handler(); +} + +// --------------------------------------------------------------------------- +// Test 9 — a fresh write still wins over a stale duplicate during first scan +// --------------------------------------------------------------------------- +echo "\nTest 9: fresh writes remain canonical during initial duplicate cleanup\n"; rm_rf( $tmp_root ); write_leaf( $tmp_root . '/wiki/stale/fresh.md', 30, 'fresh' ); From bf1278863be241d53d51fb3fbd8d313ddbd7fd2e Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 10 Sep 2026 10:45:19 -0400 Subject: [PATCH 32/53] fix(native): support LIMIT OFFSET queries --- inc/native/class-wp-markdown-native-query-parser.php | 2 ++ tests/smoke-native-query-parser.php | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/inc/native/class-wp-markdown-native-query-parser.php b/inc/native/class-wp-markdown-native-query-parser.php index b030a32..d488cdd 100644 --- a/inc/native/class-wp-markdown-native-query-parser.php +++ b/inc/native/class-wp-markdown-native-query-parser.php @@ -794,6 +794,8 @@ private function select( bool $nested ): WP_Markdown_Native_SQL_Select|WP_Markdo if ( $limit_offset > PHP_INT_MAX - $limit ) { throw new WP_Markdown_Native_SQL_Parse_Error( 'overflow_limit', $this->current()->sql_offset(), 'mdi-native cannot apply the requested LIMIT.' ); } + } elseif ( $this->match_keyword( 'OFFSET' ) ) { + $limit_offset = $this->integer( 'overflow_limit', 'mdi-native cannot apply the requested LIMIT.' ); } } $union = null; diff --git a/tests/smoke-native-query-parser.php b/tests/smoke-native-query-parser.php index 66b5c8e..42a0353 100644 --- a/tests/smoke-native-query-parser.php +++ b/tests/smoke-native-query-parser.php @@ -50,6 +50,8 @@ $composite_order_plan = $composite_order_ast instanceof WP_Markdown_Native_SQL_Select ? $parser->lower( $composite_order_ast ) : $composite_order_ast; $found_rows_query_ast = $parser->parse_ast( 'SELECT FOUND_ROWS()' ); $found_rows_query_plan = $found_rows_query_ast instanceof WP_Markdown_Native_SQL_Found_Rows ? $parser->lower( $found_rows_query_ast ) : $found_rows_query_ast; +$limit_offset_ast = $parser->parse_ast( 'SELECT ID FROM wp_posts ORDER BY ID LIMIT 10 OFFSET 20' ); +$limit_offset_plan = $limit_offset_ast instanceof WP_Markdown_Native_SQL_Select ? $parser->lower( $limit_offset_ast ) : $limit_offset_ast; $duplicate_sql = 'SELECT first, second, first FROM example'; $duplicate = $parser->parse( $duplicate_sql ); @@ -189,6 +191,9 @@ ), 'FOUND_ROWS lowers to explicit runtime state retrieval intent' => $found_rows_query_ast instanceof WP_Markdown_Native_SQL_Found_Rows && $found_rows_query_plan instanceof WP_Markdown_Native_Found_Rows_Plan, + 'LIMIT OFFSET lowers to the bounded main-query offset' => $limit_offset_plan instanceof WP_Markdown_Native_Query_Plan + && 10 === $limit_offset_plan->limit() + && 20 === $limit_offset_plan->limit_offset(), 'duplicate projections report the duplicate source position' => $duplicate instanceof WP_Markdown_Query_Result && 'duplicate_projection' === ( $duplicate->diagnostic()['reason'] ?? null ) && strrpos( $duplicate_sql, 'first' ) === ( $duplicate->diagnostic()['sql_offset'] ?? null ), From 18d2351e2b79ba8a9c5bfa9d7f87b721197498bf Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 10 Sep 2026 11:36:29 -0400 Subject: [PATCH 33/53] fix(native): restore temporary schema lifecycle --- ...ss-wp-markdown-native-schema-mutations.php | 64 +++++++++++++++---- tests/smoke-native-create-table.php | 11 ++++ 2 files changed, 62 insertions(+), 13 deletions(-) diff --git a/inc/native/class-wp-markdown-native-schema-mutations.php b/inc/native/class-wp-markdown-native-schema-mutations.php index 0755c61..2651458 100644 --- a/inc/native/class-wp-markdown-native-schema-mutations.php +++ b/inc/native/class-wp-markdown-native-schema-mutations.php @@ -27,14 +27,6 @@ public function __construct( } public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query_Result { - // MySQL commits an open transaction before every table DDL statement. - // Otherwise a later rollback would erase a schema that MySQL retains. - if ( null !== $this->transactions ) { - $committed = $this->transactions->commit(); - if ( true !== $committed ) { - return $this->failure( 'transaction_commit_failed', $committed ); - } - } $sql = trim( $request->sql() ); if ( str_ends_with( $sql, ';' ) ) { $sql = rtrim( substr( $sql, 0, -1 ) ); @@ -42,6 +34,13 @@ public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query if ( '' === $sql || WP_Markdown_Native_SQL_Tokenizer::contains_statement_separator( $sql ) ) { return $this->failure( 'unsupported_grammar', 'mdi-native requires one bounded CREATE TABLE statement.' ); } + // Unlike permanent DDL, CREATE TEMPORARY TABLE does not implicitly commit. + if ( null !== $this->transactions && 1 !== preg_match( '/^CREATE\s+TEMPORARY\s+TABLE\b/i', $sql ) ) { + $committed = $this->transactions->commit(); + if ( true !== $committed ) { + return $this->failure( 'transaction_commit_failed', $committed ); + } + } if ( 1 === preg_match( '/^\s*ALTER\s+TABLE\b/i', $sql ) ) { return $this->execute_alter( $request, $sql ); } @@ -103,7 +102,7 @@ public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query ? WP_Markdown_Query_Result::schema_changed() : $this->failure( 'table_exists', 'mdi-native cannot create a table that already exists.' ); } - $written = $this->write( $path, $sql . ";\n" ); + $written = $this->write_schema( $path, $sql . ";\n", $table, $suffix, $request->table_prefix() ); if ( $written instanceof WP_Markdown_Query_Result ) { return $written; } @@ -217,7 +216,7 @@ private function execute_alter( WP_Markdown_Query_Request $request, string $sql return $reconciled; } - $written = $this->write( $path, $rewritten ); + $written = $this->write_schema( $path, $rewritten, $table, $suffix, $prefix ); if ( $written instanceof WP_Markdown_Query_Result ) { return $written; } @@ -283,7 +282,7 @@ private function execute_drop( WP_Markdown_Query_Request $request, string $sql ) } if ( is_file( $path ) && ! is_link( $path ) ) { if ( null !== $this->transactions ) { - $recorded = $this->transactions->record( $path ); + $recorded = $this->record_schema( $path, $table, $suffix, $prefix ); if ( true !== $recorded ) { return $this->failure( 'transaction_journal_failed', $recorded ); } @@ -398,7 +397,7 @@ private function execute_add_index( string $table, string $suffix, string $actio } catch ( InvalidArgumentException ) { return $this->failure( 'unsupported_schema', 'The altered table definition could not be compiled.' ); } - $written = $this->write( $path, $rewritten ); + $written = $this->write_schema( $path, $rewritten, $table, $suffix, $this->table_prefix_from( $table, $suffix ) ); if ( $written instanceof WP_Markdown_Query_Result ) { return $written; } @@ -488,7 +487,7 @@ private function execute_drop_index( string $table, string $suffix, string $name } $definition = $compiled[ $suffix ]; $schema = WP_Markdown_Native_Schema_Catalog::indexed_snapshot_schema( $definition ); - $written = $this->write( $path, $rewritten ); + $written = $this->write_schema( $path, $rewritten, $table, $suffix, $this->table_prefix_from( $table, $suffix ) ); if ( $written instanceof WP_Markdown_Query_Result ) { return $written; } @@ -690,6 +689,41 @@ private function schema_directory(): string|WP_Markdown_Query_Result { return $root; } + private function write_schema( string $path, string $contents, string $table, string $suffix, string $prefix ): true|WP_Markdown_Query_Result { + $recorded = $this->record_schema( $path, $table, $suffix, $prefix ); + if ( true !== $recorded ) { + return $this->failure( 'transaction_journal_failed', $recorded ); + } + return $this->publish( $path, $contents ); + } + + /** Rebuild the registry after a transaction restores a schema pre-image. */ + private function record_schema( string $path, string $table, string $suffix, string $prefix ): true|string { + return null === $this->transactions + ? true + : $this->transactions->record( $path, function () use ( $path, $table, $suffix, $prefix ): void { + $this->registry->unregister( $table ); + if ( ! is_file( $path ) || is_link( $path ) ) { + return; + } + try { + $definitions = WP_Markdown_Native_Schema_Catalog::compile( (string) file_get_contents( $path ), array( $prefix ) ); + $definition = $definitions[ $suffix ] ?? null; + $schema = is_array( $definition ) ? WP_Markdown_Native_Schema_Catalog::indexed_snapshot_schema( $definition ) : null; + } catch ( InvalidArgumentException ) { + return; + } + if ( ! is_array( $definition ) ) { + return; + } + if ( null === $schema ) { + $this->registry->register_definition( $table, $definition ); + return; + } + $this->registry->register( $table, $schema, new WP_Markdown_Native_JSON_Snapshot_Provider( $this->state_root, $schema, $suffix . '.json' ) ); + } ); + } + private function write( string $path, string $contents ): true|WP_Markdown_Query_Result { if ( null !== $this->transactions ) { $recorded = $this->transactions->record( $path ); @@ -697,6 +731,10 @@ private function write( string $path, string $contents ): true|WP_Markdown_Query return $this->failure( 'transaction_journal_failed', $recorded ); } } + return $this->publish( $path, $contents ); + } + + private function publish( string $path, string $contents ): true|WP_Markdown_Query_Result { try { $temp = $path . '.tmp-' . getmypid() . '-' . bin2hex( random_bytes( 8 ) ); } catch ( Throwable ) { diff --git a/tests/smoke-native-create-table.php b/tests/smoke-native-create-table.php index 2422508..7732b51 100644 --- a/tests/smoke-native-create-table.php +++ b/tests/smoke-native-create-table.php @@ -56,6 +56,13 @@ function mdi_native_create_remove_tree( string $root ): void { . ') ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci' ) ); $runtime->execute( new WP_Markdown_Query_Request( 'ROLLBACK' ) ); $ddl_survives_rollback = WP_Markdown_Native_Runtime_Factory::runtime( $root )->execute( new WP_Markdown_Query_Request( 'DESCRIBE wp_ddl_commit' ) ); +$temporary_ddl = 'CREATE TEMPORARY TABLE wp_ddl_temporary (id bigint unsigned NOT NULL, PRIMARY KEY (id))'; +$runtime->execute( new WP_Markdown_Query_Request( 'START TRANSACTION' ) ); +$temporary_created = $runtime->execute( new WP_Markdown_Query_Request( $temporary_ddl ) ); +$runtime->execute( new WP_Markdown_Query_Request( 'ROLLBACK' ) ); +$temporary_after_rollback = $runtime->execute( new WP_Markdown_Query_Request( 'DESCRIBE wp_ddl_temporary' ) ); +$temporary_schema_rolled_back = ! file_exists( $root . '/_schema/ddl_temporary.sql' ); +$temporary_recreated = $runtime->execute( new WP_Markdown_Query_Request( $temporary_ddl ) ); $checks = array( 'generic CREATE TABLE returns the WordPress DDL success shape' => true === $created->return_value() @@ -76,6 +83,10 @@ function mdi_native_create_remove_tree( string $root ): void { 'persisted definitions restore introspection after a cold reload' => 'event_key' === ( $reloaded->wpdb_state()['last_result'][0]->Field ?? null ), 'table DDL implicitly commits and survives a later rollback' => true === $transactional_ddl->return_value() && 'id' === ( $ddl_survives_rollback->wpdb_state()['last_result'][0]->Field ?? null ), + 'temporary DDL remains transactional and does not leave a stale registry entry' => true === $temporary_created->return_value() + && false === $temporary_after_rollback->return_value() + && true === $temporary_recreated->return_value() + && $temporary_schema_rolled_back, ); $failed = false; From 7d977b32720c918e4f1605c2a3195830683e49da Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 10 Sep 2026 11:44:31 -0400 Subject: [PATCH 34/53] fix(native): preserve temporary DDL across rollback --- ...ss-wp-markdown-native-schema-mutations.php | 22 +++++++++++-------- tests/smoke-native-create-table.php | 18 ++++++++++----- 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/inc/native/class-wp-markdown-native-schema-mutations.php b/inc/native/class-wp-markdown-native-schema-mutations.php index 2651458..5b3f984 100644 --- a/inc/native/class-wp-markdown-native-schema-mutations.php +++ b/inc/native/class-wp-markdown-native-schema-mutations.php @@ -34,8 +34,9 @@ public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query if ( '' === $sql || WP_Markdown_Native_SQL_Tokenizer::contains_statement_separator( $sql ) ) { return $this->failure( 'unsupported_grammar', 'mdi-native requires one bounded CREATE TABLE statement.' ); } - // Unlike permanent DDL, CREATE TEMPORARY TABLE does not implicitly commit. - if ( null !== $this->transactions && 1 !== preg_match( '/^CREATE\s+TEMPORARY\s+TABLE\b/i', $sql ) ) { + $temporary = 1 === preg_match( '/^(?:CREATE|DROP)\s+TEMPORARY\s+TABLE\b/i', $sql ); + // Temporary table DDL neither commits nor participates in transaction rollback. + if ( null !== $this->transactions && ! $temporary ) { $committed = $this->transactions->commit(); if ( true !== $committed ) { return $this->failure( 'transaction_commit_failed', $committed ); @@ -102,7 +103,7 @@ public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query ? WP_Markdown_Query_Result::schema_changed() : $this->failure( 'table_exists', 'mdi-native cannot create a table that already exists.' ); } - $written = $this->write_schema( $path, $sql . ";\n", $table, $suffix, $request->table_prefix() ); + $written = $this->write_schema( $path, $sql . ";\n", $table, $suffix, $request->table_prefix(), $temporary ); if ( $written instanceof WP_Markdown_Query_Result ) { return $written; } @@ -243,6 +244,7 @@ private function execute_drop( WP_Markdown_Query_Request $request, string $sql ) if ( 1 !== preg_match( '/^\s*DROP\s+(?:TEMPORARY\s+)?TABLE\s+(IF\s+EXISTS\s+)?`?([A-Za-z0-9_]+)`?\s*$/is', $sql, $matched ) ) { return $this->failure( 'unsupported_schema', 'mdi-native supports one bounded DROP TABLE statement.' ); } + $temporary = 1 === preg_match( '/^\s*DROP\s+TEMPORARY\s+TABLE\b/i', $sql ); $tolerates_missing = '' !== trim( (string) $matched[1] ); $table = $matched[2]; $prefix = $request->table_prefix(); @@ -281,7 +283,7 @@ private function execute_drop( WP_Markdown_Query_Request $request, string $sql ) : $this->failure( 'unknown_table', 'mdi-native cannot drop a table it does not persist.' ); } if ( is_file( $path ) && ! is_link( $path ) ) { - if ( null !== $this->transactions ) { + if ( null !== $this->transactions && ! $temporary ) { $recorded = $this->record_schema( $path, $table, $suffix, $prefix ); if ( true !== $recorded ) { return $this->failure( 'transaction_journal_failed', $recorded ); @@ -293,7 +295,7 @@ private function execute_drop( WP_Markdown_Query_Request $request, string $sql ) } $snapshot = $this->state_root . '/_tables/' . $suffix . '.json'; if ( is_file( $snapshot ) && ! is_link( $snapshot ) ) { - if ( null !== $this->transactions ) { + if ( null !== $this->transactions && ! $temporary ) { $recorded = $this->transactions->record( $snapshot ); if ( true !== $recorded ) { return $this->failure( 'transaction_journal_failed', $recorded ); @@ -689,10 +691,12 @@ private function schema_directory(): string|WP_Markdown_Query_Result { return $root; } - private function write_schema( string $path, string $contents, string $table, string $suffix, string $prefix ): true|WP_Markdown_Query_Result { - $recorded = $this->record_schema( $path, $table, $suffix, $prefix ); - if ( true !== $recorded ) { - return $this->failure( 'transaction_journal_failed', $recorded ); + private function write_schema( string $path, string $contents, string $table, string $suffix, string $prefix, bool $temporary = false ): true|WP_Markdown_Query_Result { + if ( ! $temporary ) { + $recorded = $this->record_schema( $path, $table, $suffix, $prefix ); + if ( true !== $recorded ) { + return $this->failure( 'transaction_journal_failed', $recorded ); + } } return $this->publish( $path, $contents ); } diff --git a/tests/smoke-native-create-table.php b/tests/smoke-native-create-table.php index 7732b51..72e1312 100644 --- a/tests/smoke-native-create-table.php +++ b/tests/smoke-native-create-table.php @@ -59,10 +59,14 @@ function mdi_native_create_remove_tree( string $root ): void { $temporary_ddl = 'CREATE TEMPORARY TABLE wp_ddl_temporary (id bigint unsigned NOT NULL, PRIMARY KEY (id))'; $runtime->execute( new WP_Markdown_Query_Request( 'START TRANSACTION' ) ); $temporary_created = $runtime->execute( new WP_Markdown_Query_Request( $temporary_ddl ) ); +$temporary_inserted = $runtime->execute( new WP_Markdown_Query_Request( 'INSERT INTO wp_ddl_temporary (id) VALUES (1)' ) ); $runtime->execute( new WP_Markdown_Query_Request( 'ROLLBACK' ) ); $temporary_after_rollback = $runtime->execute( new WP_Markdown_Query_Request( 'DESCRIBE wp_ddl_temporary' ) ); -$temporary_schema_rolled_back = ! file_exists( $root . '/_schema/ddl_temporary.sql' ); -$temporary_recreated = $runtime->execute( new WP_Markdown_Query_Request( $temporary_ddl ) ); +$temporary_rows_after_rollback = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT id FROM wp_ddl_temporary' ) ); +$runtime->execute( new WP_Markdown_Query_Request( 'START TRANSACTION' ) ); +$temporary_dropped = $runtime->execute( new WP_Markdown_Query_Request( 'DROP TEMPORARY TABLE wp_ddl_temporary' ) ); +$runtime->execute( new WP_Markdown_Query_Request( 'ROLLBACK' ) ); +$temporary_after_drop_rollback = $runtime->execute( new WP_Markdown_Query_Request( 'DESCRIBE wp_ddl_temporary' ) ); $checks = array( 'generic CREATE TABLE returns the WordPress DDL success shape' => true === $created->return_value() @@ -83,10 +87,12 @@ function mdi_native_create_remove_tree( string $root ): void { 'persisted definitions restore introspection after a cold reload' => 'event_key' === ( $reloaded->wpdb_state()['last_result'][0]->Field ?? null ), 'table DDL implicitly commits and survives a later rollback' => true === $transactional_ddl->return_value() && 'id' === ( $ddl_survives_rollback->wpdb_state()['last_result'][0]->Field ?? null ), - 'temporary DDL remains transactional and does not leave a stale registry entry' => true === $temporary_created->return_value() - && false === $temporary_after_rollback->return_value() - && true === $temporary_recreated->return_value() - && $temporary_schema_rolled_back, + 'temporary table DDL survives rollback while its transactional rows roll back' => true === $temporary_created->return_value() + && 1 === $temporary_inserted->wpdb_state()['rows_affected'] + && 'id' === ( $temporary_after_rollback->wpdb_state()['last_result'][0]->Field ?? null ) + && array() === $temporary_rows_after_rollback->wpdb_state()['last_result'], + 'DROP TEMPORARY TABLE survives rollback and refreshes the table registry' => true === $temporary_dropped->return_value() + && false === $temporary_after_drop_rollback->return_value(), ); $failed = false; From 66a20dc10c99469eaf34e020ae65cc242ee4e58d Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 10 Sep 2026 12:04:07 -0400 Subject: [PATCH 35/53] fix(native): isolate temporary table generations --- ...class-wp-markdown-native-query-runtime.php | 6 +- .../class-wp-markdown-native-query-schema.php | 30 ++++++++ ...ss-wp-markdown-native-schema-mutations.php | 54 ++++++++++---- ...ass-wp-markdown-native-table-mutations.php | 74 ++++++++++++------- ...ss-wp-markdown-native-temporary-tables.php | 44 +++++++++++ .../class-wp-markdown-native-transactions.php | 31 ++++++++ tests/smoke-native-create-table.php | 31 ++++++++ 7 files changed, 229 insertions(+), 41 deletions(-) create mode 100644 inc/native/class-wp-markdown-native-temporary-tables.php diff --git a/inc/native/class-wp-markdown-native-query-runtime.php b/inc/native/class-wp-markdown-native-query-runtime.php index a53b35d..90fc9df 100644 --- a/inc/native/class-wp-markdown-native-query-runtime.php +++ b/inc/native/class-wp-markdown-native-query-runtime.php @@ -23,6 +23,7 @@ require_once __DIR__ . '/../class-wp-markdown-sql-classifier.php'; require_once __DIR__ . '/../class-wp-markdown-table-durability-policy.php'; require_once __DIR__ . '/class-wp-markdown-native-transactions.php'; +require_once __DIR__ . '/class-wp-markdown-native-temporary-tables.php'; require_once __DIR__ . '/class-wp-markdown-native-advisory-locks.php'; require_once __DIR__ . '/class-wp-markdown-native-query-executor.php'; @@ -269,6 +270,7 @@ public static function runtime( array_filter( array( $state_root, $content_root, $global_state_root, $global_content_root ) ) ); $registry = self::registry( $state_root, $prefix, $base_prefix, $multisite, $content_root, $global_state_root, $global_content_root ); + $temporary_tables = new WP_Markdown_Native_Temporary_Tables(); $parser = new WP_Markdown_Native_Table_Insert_Parser(); $resolved_base = $base_prefix ?? $prefix; $resolved_content = $content_root ?? $state_root; @@ -289,8 +291,8 @@ public static function runtime( $registry, new WP_Markdown_Native_Query_Parser(), new WP_Markdown_Native_Option_Mutation_Runtime( $state_root, new WP_Markdown_Native_Option_Mutation_Parser(), $transactions ), - new WP_Markdown_Native_Schema_Mutation_Runtime( $state_root, $registry, $transactions, $core_registrar ), - new WP_Markdown_Native_Table_Mutation_Runtime( $state_root, $registry, $parser, $transactions ), + new WP_Markdown_Native_Schema_Mutation_Runtime( $state_root, $registry, $transactions, $core_registrar, $temporary_tables ), + new WP_Markdown_Native_Table_Mutation_Runtime( $state_root, $registry, $parser, $transactions, $temporary_tables ), $transactions, new WP_Markdown_Native_Post_Mutation_Runtime( $registry, diff --git a/inc/native/class-wp-markdown-native-query-schema.php b/inc/native/class-wp-markdown-native-query-schema.php index 91e4f25..9a0d344 100644 --- a/inc/native/class-wp-markdown-native-query-schema.php +++ b/inc/native/class-wp-markdown-native-query-schema.php @@ -710,6 +710,8 @@ final class WP_Markdown_Native_Table_Registry { private array $tables = array(); /** @var array> */ private array $definitions = array(); + /** @var array}> */ + private array $shadows = array(); public function register( string $table, @@ -782,6 +784,34 @@ public function unregister( string $table ): void { unset( $this->tables[ $table ], $this->definitions[ $table ] ); } + /** Replace a visible table for a connection-local temporary table. */ + public function shadow( string $table, ?WP_Markdown_Native_Table_Schema $schema, ?WP_Markdown_Native_Table_Provider $provider, array $definition ): void { + if ( isset( $this->shadows[ $table ] ) ) { + throw new InvalidArgumentException( 'A temporary table already shadows this identifier.' ); + } + $this->shadows[ $table ] = array( 'table' => $this->tables[ $table ] ?? null, 'definition' => $this->definitions[ $table ] ?? null ); + unset( $this->tables[ $table ], $this->definitions[ $table ] ); + $this->register_definition( $table, $definition ); + if ( null !== $schema && null !== $provider ) { + $this->tables[ $table ] = array( 'schema' => $schema, 'provider' => $provider ); + } + } + + /** Restore the permanent table hidden by a dropped temporary table. */ + public function unshadow( string $table ): void { + if ( ! isset( $this->shadows[ $table ] ) ) { + return; + } + $shadow = $this->shadows[ $table ]; + unset( $this->tables[ $table ], $this->definitions[ $table ], $this->shadows[ $table ] ); + if ( is_array( $shadow['definition'] ) ) { + $this->definitions[ $table ] = $shadow['definition']; + } + if ( is_array( $shadow['table'] ) ) { + $this->tables[ $table ] = $shadow['table']; + } + } + /** Forget request-scoped generic snapshots after canonical files are restored. */ public function forget_snapshots(): void { foreach ( $this->tables as $table ) { diff --git a/inc/native/class-wp-markdown-native-schema-mutations.php b/inc/native/class-wp-markdown-native-schema-mutations.php index 5b3f984..3dddc1b 100644 --- a/inc/native/class-wp-markdown-native-schema-mutations.php +++ b/inc/native/class-wp-markdown-native-schema-mutations.php @@ -16,7 +16,8 @@ public function __construct( string $state_root, private WP_Markdown_Native_Table_Registry $registry, private ?WP_Markdown_Native_Transaction_Journal $transactions = null, - ?callable $core_registrar = null + ?callable $core_registrar = null, + private ?WP_Markdown_Native_Temporary_Tables $temporary_tables = null ) { $this->core_registrar = $core_registrar; $root = realpath( $state_root ); @@ -66,25 +67,29 @@ public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query $definition = $definitions[ $suffix ]; // IF NOT EXISTS makes an existing table a successful no-op in MySQL. $tolerates_existing = 1 === preg_match( '/^\s*CREATE\s+(?:TEMPORARY\s+)?TABLE\s+IF\s+NOT\s+EXISTS\b/i', $sql ); - if ( null !== $this->registry->definition( $table ) ) { + if ( $temporary && null !== $this->temporary_tables && $this->temporary_tables->has( $table ) ) { return $tolerates_existing ? WP_Markdown_Query_Result::schema_changed() : $this->failure( 'table_exists', 'mdi-native cannot create a table that already exists.' ); } + if ( ! $temporary && null !== $this->registry->definition( $table ) ) { + return $tolerates_existing ? WP_Markdown_Query_Result::schema_changed() : $this->failure( 'table_exists', 'mdi-native cannot create a table that already exists.' ); + } // A core table is generated from WordPress itself, so creating it // registers its canonical provider rather than persisting a schema // file that would shadow the definition core already supplies. The // name identifies it, because the release being installed states its // own column list and that varies independently of canonical form. - if ( WP_Markdown_Native_Schema_Catalog::is_core_table( $suffix ) ) { + if ( ! $temporary && WP_Markdown_Native_Schema_Catalog::is_core_table( $suffix ) ) { if ( null === $this->core_registrar || true !== ( $this->core_registrar )( $suffix ) ) { return $this->failure( 'unsupported_schema', 'mdi-native cannot create the requested core table.' ); } return WP_Markdown_Query_Result::schema_changed(); } - $directory = $this->schema_directory(); + $root = $temporary && null !== $this->temporary_tables ? $this->temporary_tables->root() : $this->state_root; + $directory = $this->schema_directory( $root ); if ( $directory instanceof WP_Markdown_Query_Result ) { return $directory; } @@ -98,7 +103,7 @@ public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query try { $path = $directory . '/' . $suffix . '.sql'; - if ( file_exists( $path ) || is_link( $path ) || null !== $this->registry->definition( $table ) ) { + if ( file_exists( $path ) || is_link( $path ) || ( ! $temporary && null !== $this->registry->definition( $table ) ) ) { return $tolerates_existing ? WP_Markdown_Query_Result::schema_changed() : $this->failure( 'table_exists', 'mdi-native cannot create a table that already exists.' ); @@ -112,14 +117,17 @@ public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query } catch ( InvalidArgumentException ) { return $this->failure( 'unsupported_schema', 'mdi-native cannot compile the requested table definition.' ); } - if ( null === $schema ) { + if ( $temporary && null !== $this->temporary_tables ) { + if ( null === $schema ) { + $this->registry->shadow( $table, null, null, $definition ); + } else { + $this->registry->shadow( $table, $schema, new WP_Markdown_Native_JSON_Snapshot_Provider( $root, $schema, $suffix . '.json' ), $definition ); + } + $this->temporary_tables->add( $table ); + } elseif ( null === $schema ) { $this->registry->register_definition( $table, $definition ); } else { - $this->registry->register( - $table, - $schema, - new WP_Markdown_Native_JSON_Snapshot_Provider( $this->state_root, $schema, $suffix . '.json' ) - ); + $this->registry->register( $table, $schema, new WP_Markdown_Native_JSON_Snapshot_Provider( $root, $schema, $suffix . '.json' ) ); } return WP_Markdown_Query_Result::schema_changed(); } finally { @@ -261,6 +269,23 @@ private function execute_drop( WP_Markdown_Query_Request $request, string $sql ) if ( isset( WP_Markdown_Native_Schema_Catalog::definitions()[ $suffix ] ) ) { return $this->failure( 'unsupported_schema', 'mdi-native cannot drop a core table.' ); } + if ( $temporary ) { + if ( null === $this->temporary_tables || ! $this->temporary_tables->has( $table ) ) { + return $tolerates_missing ? WP_Markdown_Query_Result::schema_changed() : $this->failure( 'unknown_table', 'mdi-native cannot drop a temporary table that does not exist.' ); + } + $root = $this->temporary_tables->root(); + if ( null !== $this->transactions ) { + $this->transactions->discard_ephemeral( $root . '/_tables/' . $suffix . '.json' ); + } + @unlink( $root . '/_schema/' . $suffix . '.sql' ); + @unlink( $root . '/_tables/' . $suffix . '.json' ); + $this->temporary_tables->remove( $table ); + $this->registry->unshadow( $table ); + return WP_Markdown_Query_Result::schema_changed(); + } + if ( null !== $this->temporary_tables && $this->temporary_tables->has( $table ) ) { + return $this->failure( 'temporary_table_shadowed', 'mdi-native cannot safely apply permanent DDL while a temporary table shadows that name.' ); + } $directory = $this->schema_directory(); if ( $directory instanceof WP_Markdown_Query_Result ) { @@ -679,13 +704,14 @@ private function matching_paren( string $sql, int $open ): ?int { return null; } - private function schema_directory(): string|WP_Markdown_Query_Result { - $path = $this->state_root . '/_schema'; + private function schema_directory( ?string $root = null ): string|WP_Markdown_Query_Result { + $base_root = $root ?? $this->state_root; + $path = $base_root . '/_schema'; if ( ! file_exists( $path ) && ! @mkdir( $path, 0755 ) && ! is_dir( $path ) ) { return $this->failure( 'schema_directory_failed', 'The canonical schema directory could not be created.' ); } $root = realpath( $path ); - if ( false === $root || ! is_dir( $root ) || is_link( $path ) || dirname( $root ) !== $this->state_root ) { + if ( false === $root || ! is_dir( $root ) || is_link( $path ) || dirname( $root ) !== $base_root ) { return $this->failure( 'unsafe_schema_directory', 'The canonical schema directory is unavailable or unsafe.' ); } return $root; diff --git a/inc/native/class-wp-markdown-native-table-mutations.php b/inc/native/class-wp-markdown-native-table-mutations.php index eb0f8ab..afcc90a 100644 --- a/inc/native/class-wp-markdown-native-table-mutations.php +++ b/inc/native/class-wp-markdown-native-table-mutations.php @@ -11,6 +11,8 @@ final class WP_Markdown_Native_Table_Mutation_Runtime { private string $state_root; private WP_Markdown_Native_Table_Index $index; + /** @var array */ + private array $temporary_indexes = array(); /** @var array */ private array $unique_sets_verified = array(); @@ -18,7 +20,8 @@ public function __construct( string $state_root, private WP_Markdown_Native_Table_Registry $registry, private WP_Markdown_Native_Table_Insert_Parser $parser = new WP_Markdown_Native_Table_Insert_Parser(), - private ?WP_Markdown_Native_Transaction_Journal $transactions = null + private ?WP_Markdown_Native_Transaction_Journal $transactions = null, + private ?WP_Markdown_Native_Temporary_Tables $temporary_tables = null ) { $root = realpath( $state_root ); if ( false === $root || ! is_dir( $root ) ) { @@ -91,12 +94,13 @@ private function execute_insert( WP_Markdown_Query_Request $request, WP_Markdown || null === $table || ! $table['provider'] instanceof WP_Markdown_Native_JSON_Snapshot_Provider || ! is_array( $definition ) - || ! $this->is_authoritative_definition( $suffix, $definition, $prefix ) + || ! $this->is_authoritative_definition( $suffix, $definition, $prefix, $insert->table() ) ) { return $this->failure( 'unsupported_mutation_table', 'mdi-native can insert only into a persisted generic snapshot table.' ); } - $directory = $this->tables_directory(); + $root = $this->root_for( $insert->table() ); + $directory = $this->tables_directory( $root ); if ( $directory instanceof WP_Markdown_Query_Result ) { return $directory; } @@ -124,9 +128,10 @@ private function execute_insert( WP_Markdown_Query_Request $request, WP_Markdown } } $path = $directory . '/' . $suffix . '.json'; + $table_index = $this->index_for( $root ); $index = $insert->is_replace() || null !== $insert->upsert_columns() || WP_Markdown_Native_Table_Index::supplies_identity( $insert->values(), $definition ) ? null - : $this->index->load( $suffix, $path ); + : $table_index->load( $suffix, $path ); if ( null !== $index ) { // The index enforces this candidate's keys, while this witnessed // snapshot proves the pre-existing keys were already unique. @@ -152,7 +157,7 @@ private function execute_insert( WP_Markdown_Query_Request $request, WP_Markdown if ( $appended instanceof WP_Markdown_Query_Result ) { return $appended; } - $this->index->remember( $suffix, $path, WP_Markdown_Native_Table_Index::with_row( $index, $row, $definition, $schema ) ); + $table_index->remember( $suffix, $path, WP_Markdown_Native_Table_Index::with_row( $index, $row, $definition, $schema ) ); if ( $unique_set_verified ) { $this->remember_verified_unique_set( $suffix, $path ); } @@ -189,7 +194,7 @@ private function execute_insert( WP_Markdown_Query_Request $request, WP_Markdown } // REPLACE already scans and republishes the snapshot. Leave the // derived insert index for the next operation that needs it. - $this->index->forget( $suffix, $this->transactions ); + $table_index->forget( $suffix, $this->transactions ); $provider->replace_rows( $rows ); return WP_Markdown_Query_Result::mutated( count( $duplicates ) + 1, $this->auto_increment_value( $row, $definition ) ); } @@ -218,7 +223,7 @@ private function execute_insert( WP_Markdown_Query_Request $request, WP_Markdown if ( $written instanceof WP_Markdown_Query_Result ) { return $written; } - $this->index->save( $suffix, $path, WP_Markdown_Native_Table_Index::build( array_values( $rows ), $definition, $schema ), $this->transactions ); + $table_index->save( $suffix, $path, WP_Markdown_Native_Table_Index::build( array_values( $rows ), $definition, $schema ), $this->transactions ); $provider->replace_rows( array_values( $rows ) ); return WP_Markdown_Query_Result::mutated( 2, $this->auto_increment_value( $updated, $definition ) ); } @@ -227,7 +232,7 @@ private function execute_insert( WP_Markdown_Query_Request $request, WP_Markdown if ( $written instanceof WP_Markdown_Query_Result ) { return $written; } - $this->index->save( $suffix, $path, WP_Markdown_Native_Table_Index::build( $rows, $definition, $schema ), $this->transactions ); + $table_index->save( $suffix, $path, WP_Markdown_Native_Table_Index::build( $rows, $definition, $schema ), $this->transactions ); $provider->replace_rows( $rows ); return WP_Markdown_Query_Result::mutated( 1, $this->auto_increment_value( $row, $definition ) ); } finally { @@ -269,7 +274,7 @@ private function append_row( string $path, array $row, bool $empty ): true|WP_Ma return $this->failure( 'unsafe_table_file', 'The canonical table file is unavailable or unsafe.' ); } if ( null !== $this->transactions ) { - $recorded = $this->transactions->record( $path ); + $recorded = $this->is_temporary_path( $path ) ? $this->transactions->record_ephemeral( $path ) : $this->transactions->record( $path ); if ( true !== $recorded ) { return $this->failure( 'transaction_journal_failed', $recorded ); } @@ -462,7 +467,7 @@ private function execute_write( WP_Markdown_Query_Request $request ): WP_Markdow || null === $table || ! $table['provider'] instanceof WP_Markdown_Native_JSON_Snapshot_Provider || ! is_array( $definition ) - || ! $this->is_authoritative_definition( $suffix, $definition, $prefix ) + || ! $this->is_authoritative_definition( $suffix, $definition, $prefix, $write->table() ) ) { return $this->failure( 'unsupported_mutation_table', 'mdi-native can mutate only a persisted generic snapshot table.' ); } @@ -487,7 +492,8 @@ private function execute_write( WP_Markdown_Query_Request $request ): WP_Markdow return $this->failure( 'unsupported_mutation_column', 'The assignment names a column outside the persisted table schema.' ); } } - $directory = $this->tables_directory(); + $root = $this->root_for( $write->table() ); + $directory = $this->tables_directory( $root ); if ( $directory instanceof WP_Markdown_Query_Result ) { return $directory; } @@ -499,7 +505,8 @@ private function execute_write( WP_Markdown_Query_Request $request ): WP_Markdow try { $path = $directory . '/' . $suffix . '.json'; $provider = $table['provider']; - $index = $this->index->load( $suffix, $path ); + $table_index = $this->index_for( $root ); + $index = $table_index->load( $suffix, $path ); if ( null !== $index && $this->index_excludes( $index, $predicates ) ) { return WP_Markdown_Query_Result::mutated( 0 ); } @@ -533,7 +540,7 @@ private function execute_write( WP_Markdown_Query_Request $request ): WP_Markdow } if ( 0 === $affected ) { - $this->index->remember( $suffix, $path, WP_Markdown_Native_Table_Index::build( $rows, $definition, $schema ) ); + $table_index->remember( $suffix, $path, WP_Markdown_Native_Table_Index::build( $rows, $definition, $schema ) ); return WP_Markdown_Query_Result::mutated( 0 ); } $unique_set_verified = $write->is_update() @@ -552,7 +559,7 @@ private function execute_write( WP_Markdown_Query_Request $request ): WP_Markdow $this->remember_verified_unique_set( $suffix, $path ); // The sidecar is derived state. Keep this runtime's witnessed index // current without republishing it after every canonical table write. - $this->index->remember( $suffix, $path, $updated_index ?? WP_Markdown_Native_Table_Index::build( $retained, $definition, $schema ) ); + $table_index->remember( $suffix, $path, $updated_index ?? WP_Markdown_Native_Table_Index::build( $retained, $definition, $schema ) ); $provider->replace_rows( $retained ); return WP_Markdown_Query_Result::mutated( $affected ); } finally { @@ -887,8 +894,8 @@ private function unique_set_violation( array $rows, array $definition, WP_Markdo * * @param array $definition */ - private function is_authoritative_definition( string $suffix, array $definition, string $prefix ): bool { - return $this->is_persisted_definition( $suffix, $definition, $prefix ) + private function is_authoritative_definition( string $suffix, array $definition, string $prefix, string $table ): bool { + return $this->is_persisted_definition( $suffix, $definition, $prefix, $this->root_for( $table ) ) || $this->is_generated_core_definition( $suffix, $definition ); } @@ -897,10 +904,10 @@ private function is_generated_core_definition( string $suffix, array $definition return WP_Markdown_Native_Schema_Catalog::is_generated_core_definition( $suffix, $definition ); } - private function is_persisted_definition( string $suffix, array $definition, string $prefix ): bool { - $directory = realpath( $this->state_root . '/_schema' ); + private function is_persisted_definition( string $suffix, array $definition, string $prefix, string $root ): bool { + $directory = realpath( $root . '/_schema' ); $path = false === $directory ? '' : $directory . '/' . $suffix . '.sql'; - if ( false === $directory || is_link( $this->state_root . '/_schema' ) || ! is_file( $path ) || is_link( $path ) ) { + if ( false === $directory || is_link( $root . '/_schema' ) || ! is_file( $path ) || is_link( $path ) ) { return false; } try { @@ -911,16 +918,16 @@ private function is_persisted_definition( string $suffix, array $definition, str } } - private function tables_directory(): string|WP_Markdown_Query_Result { - $path = $this->state_root . '/_tables'; + private function tables_directory( string $root ): string|WP_Markdown_Query_Result { + $path = $root . '/_tables'; if ( ! file_exists( $path ) && ! @mkdir( $path, 0755 ) && ! is_dir( $path ) ) { return $this->failure( 'tables_directory_failed', 'The canonical tables directory could not be created.' ); } - $root = realpath( $path ); - if ( false === $root || ! is_dir( $root ) || is_link( $path ) || dirname( $root ) !== $this->state_root ) { + $directory = realpath( $path ); + if ( false === $directory || ! is_dir( $directory ) || is_link( $path ) || dirname( $directory ) !== $root ) { return $this->failure( 'unsafe_tables_directory', 'The canonical tables directory is unavailable or unsafe.' ); } - return $root; + return $directory; } /** Coordinate only writers that publish the same canonical table. */ @@ -942,7 +949,7 @@ private function write( string $path, array $rows ): true|WP_Markdown_Query_Resu return $this->failure( 'unsafe_table_file', 'The canonical table file is unavailable or unsafe.' ); } if ( null !== $this->transactions ) { - $recorded = $this->transactions->record( $path ); + $recorded = $this->is_temporary_path( $path ) ? $this->transactions->record_ephemeral( $path ) : $this->transactions->record( $path ); if ( true !== $recorded ) { return $this->failure( 'transaction_journal_failed', $recorded ); } @@ -986,6 +993,23 @@ private function write( string $path, array $rows ): true|WP_Markdown_Query_Resu return true; } + private function root_for( string $table ): string { + return null !== $this->temporary_tables && $this->temporary_tables->has( $table ) + ? $this->temporary_tables->root() + : $this->state_root; + } + + private function index_for( string $root ): WP_Markdown_Native_Table_Index { + if ( $root === $this->state_root ) { + return $this->index; + } + return $this->temporary_indexes[ $root ] ??= new WP_Markdown_Native_Table_Index( $root . DIRECTORY_SEPARATOR . '_tables' ); + } + + private function is_temporary_path( string $path ): bool { + return null !== $this->temporary_tables && str_starts_with( $path, $this->temporary_tables->root() . DIRECTORY_SEPARATOR ); + } + private function failure( string $reason, string $message ): WP_Markdown_Query_Result { return WP_Markdown_Query_Result::failure( array( diff --git a/inc/native/class-wp-markdown-native-temporary-tables.php b/inc/native/class-wp-markdown-native-temporary-tables.php new file mode 100644 index 0000000..d47a11c --- /dev/null +++ b/inc/native/class-wp-markdown-native-temporary-tables.php @@ -0,0 +1,44 @@ + */ + private array $tables = array(); + + public function __construct() { + try { + $this->root = sys_get_temp_dir() . '/mdi-native-session-' . bin2hex( random_bytes( 16 ) ); + } catch ( Throwable ) { + throw new RuntimeException( 'A temporary table namespace could not be created.' ); + } + if ( ! @mkdir( $this->root . '/_schema', 0700, true ) || ! @mkdir( $this->root . '/_tables', 0700, true ) ) { + throw new RuntimeException( 'A temporary table namespace could not be materialized.' ); + } + $root = realpath( $this->root ); + if ( false === $root || is_link( $this->root ) ) { + throw new RuntimeException( 'A temporary table namespace is unsafe.' ); + } + $this->root = $root; + } + + public function root(): string { + return $this->root; + } + + public function has( string $table ): bool { + return isset( $this->tables[ $table ] ); + } + + public function add( string $table ): void { + $this->tables[ $table ] = true; + } + + public function remove( string $table ): void { + unset( $this->tables[ $table ] ); + } +} diff --git a/inc/native/class-wp-markdown-native-transactions.php b/inc/native/class-wp-markdown-native-transactions.php index 3b7ba8f..d8e88ec 100644 --- a/inc/native/class-wp-markdown-native-transactions.php +++ b/inc/native/class-wp-markdown-native-transactions.php @@ -345,6 +345,37 @@ public function record( string $path, ?callable $restore_observer = null ): true return $this->persist(); } + /** Record a session-local pre-image without serializing it into the durable journal. */ + public function record_ephemeral( string $path, ?callable $restore_observer = null ): true|string { + if ( ! $this->active ) { + return true; + } + if ( null !== $restore_observer ) { + $this->restore_observers[ $path ] = $restore_observer; + } + foreach ( $this->entries as $entry ) { + if ( $path === $entry['path'] ) { + return true; + } + } + $contents = is_file( $path ) ? @file_get_contents( $path ) : false; + if ( is_file( $path ) && false === $contents ) { + return 'The temporary table pre-image could not be journaled.'; + } + $this->entries[] = array( 'path' => $path, 'existed' => false !== $contents, 'contents' => false === $contents ? null : base64_encode( $contents ) ); + return true; + } + + /** A dropped temporary table ends its generation, so old row pre-images are invalid. */ + public function discard_ephemeral( string $path ): void { + $this->entries = array_values( array_filter( $this->entries, static fn( array $entry ): bool => $path !== $entry['path'] ) ); + foreach ( array_keys( $this->restore_observers ) as $observed_path ) { + if ( $observed_path === $path ) { + unset( $this->restore_observers[ $observed_path ] ); + } + } + } + /** Start an autocommit-off transaction when a transactional table is read. */ public function access(): true|string { return ! $this->active && ! $this->autocommit ? $this->begin() : true; diff --git a/tests/smoke-native-create-table.php b/tests/smoke-native-create-table.php index 72e1312..02bb257 100644 --- a/tests/smoke-native-create-table.php +++ b/tests/smoke-native-create-table.php @@ -67,6 +67,27 @@ function mdi_native_create_remove_tree( string $root ): void { $temporary_dropped = $runtime->execute( new WP_Markdown_Query_Request( 'DROP TEMPORARY TABLE wp_ddl_temporary' ) ); $runtime->execute( new WP_Markdown_Query_Request( 'ROLLBACK' ) ); $temporary_after_drop_rollback = $runtime->execute( new WP_Markdown_Query_Request( 'DESCRIBE wp_ddl_temporary' ) ); +$permanent_only_drop = $runtime->execute( new WP_Markdown_Query_Request( 'DROP TEMPORARY TABLE wp_ddl_commit' ) ); +$permanent_only_drop_if_exists = $runtime->execute( new WP_Markdown_Query_Request( 'DROP TEMPORARY TABLE IF EXISTS wp_ddl_commit' ) ); +$permanent_after_temporary_drop = $runtime->execute( new WP_Markdown_Query_Request( 'DESCRIBE wp_ddl_commit' ) ); + +$generation_ddl = 'CREATE TEMPORARY TABLE wp_temporary_generation (id bigint unsigned NOT NULL, PRIMARY KEY (id))'; +$runtime->execute( new WP_Markdown_Query_Request( $generation_ddl ) ); +$runtime->execute( new WP_Markdown_Query_Request( 'INSERT INTO wp_temporary_generation (id) VALUES (1)' ) ); +$runtime->execute( new WP_Markdown_Query_Request( 'START TRANSACTION' ) ); +$runtime->execute( new WP_Markdown_Query_Request( 'UPDATE wp_temporary_generation SET id = 3 WHERE id = 1' ) ); +$runtime->execute( new WP_Markdown_Query_Request( 'DROP TEMPORARY TABLE wp_temporary_generation' ) ); +$runtime->execute( new WP_Markdown_Query_Request( $generation_ddl ) ); +$runtime->execute( new WP_Markdown_Query_Request( 'ROLLBACK' ) ); +$generation_after_rollback = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT id FROM wp_temporary_generation' ) ); + +$runtime->execute( new WP_Markdown_Query_Request( "INSERT INTO wp_plugin_events (event_key, owner_id, payload) VALUES ('permanent', 7, 'safe')" ) ); +$shadow_created = $runtime->execute( new WP_Markdown_Query_Request( 'CREATE TEMPORARY TABLE wp_plugin_events (id bigint unsigned NOT NULL, PRIMARY KEY (id))' ) ); +$shadow_inserted = $runtime->execute( new WP_Markdown_Query_Request( 'INSERT INTO wp_plugin_events (id) VALUES (9)' ) ); +$shadow_rows = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT id FROM wp_plugin_events' ) ); +$shadow_dropped = $runtime->execute( new WP_Markdown_Query_Request( 'DROP TEMPORARY TABLE wp_plugin_events' ) ); +$permanent_rows_after_shadow = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT event_key, owner_id, payload FROM wp_plugin_events' ) ); +$cold_temporary = WP_Markdown_Native_Runtime_Factory::runtime( $root )->execute( new WP_Markdown_Query_Request( 'DESCRIBE wp_temporary_generation' ) ); $checks = array( 'generic CREATE TABLE returns the WordPress DDL success shape' => true === $created->return_value() @@ -93,6 +114,16 @@ function mdi_native_create_remove_tree( string $root ): void { && array() === $temporary_rows_after_rollback->wpdb_state()['last_result'], 'DROP TEMPORARY TABLE survives rollback and refreshes the table registry' => true === $temporary_dropped->return_value() && false === $temporary_after_drop_rollback->return_value(), + 'DROP TEMPORARY TABLE refuses a permanent-only name without touching its schema' => false === $permanent_only_drop->return_value() + && true === $permanent_only_drop_if_exists->return_value() + && 'id' === ( $permanent_after_temporary_drop->wpdb_state()['last_result'][0]->Field ?? null ), + 'a dropped temporary generation cannot restore its old transactional row journal into a recreated table' => array() === $generation_after_rollback->wpdb_state()['last_result'], + 'temporary tables shadow same-name permanent tables without overwriting their data and are absent from a cold runtime' => true === $shadow_created->return_value() + && 1 === $shadow_inserted->wpdb_state()['rows_affected'] + && '9' === (string) ( $shadow_rows->wpdb_state()['last_result'][0]->id ?? '' ) + && true === $shadow_dropped->return_value() + && 'permanent' === (string) ( $permanent_rows_after_shadow->wpdb_state()['last_result'][0]->event_key ?? '' ) + && false === $cold_temporary->return_value(), ); $failed = false; From 81a8a551161b93fe3e8d70eec82a9fb5ebe1587c Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 10 Sep 2026 13:21:39 -0400 Subject: [PATCH 36/53] fix(native): share temporary tables across site runtimes --- .../class-wp-markdown-native-query-runtime.php | 15 +++++++++++---- tests/smoke-native-multisite-prefix-routing.php | 9 +++++++++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/inc/native/class-wp-markdown-native-query-runtime.php b/inc/native/class-wp-markdown-native-query-runtime.php index 90fc9df..28fc81c 100644 --- a/inc/native/class-wp-markdown-native-query-runtime.php +++ b/inc/native/class-wp-markdown-native-query-runtime.php @@ -253,7 +253,8 @@ public static function runtime( ?string $global_state_root = null, ?string $global_content_root = null, ?WP_Markdown_Native_Advisory_Locks $advisory_locks = null, - ?string $transaction_state_root = null + ?string $transaction_state_root = null, + ?WP_Markdown_Native_Temporary_Tables $temporary_tables = null ): WP_Markdown_Native_Query_Runtime { $state_root = self::materialize_state_root( $state_root ); if ( null !== $content_root ) { @@ -270,7 +271,7 @@ public static function runtime( array_filter( array( $state_root, $content_root, $global_state_root, $global_content_root ) ) ); $registry = self::registry( $state_root, $prefix, $base_prefix, $multisite, $content_root, $global_state_root, $global_content_root ); - $temporary_tables = new WP_Markdown_Native_Temporary_Tables(); + $temporary_tables = $temporary_tables ?? new WP_Markdown_Native_Temporary_Tables(); $parser = new WP_Markdown_Native_Table_Insert_Parser(); $resolved_base = $base_prefix ?? $prefix; $resolved_content = $content_root ?? $state_root; @@ -644,12 +645,14 @@ final class WP_Markdown_Native_Prefix_Query_Runtime implements WP_Markdown_Query /** @var array */ private array $runtimes = array(); private WP_Markdown_Native_Advisory_Locks $advisory_locks; + private WP_Markdown_Native_Temporary_Tables $temporary_tables; public function __construct( private string $state_root, private string $content_root ) { $this->advisory_locks = new WP_Markdown_Native_Advisory_Locks( $state_root ); + $this->temporary_tables = new WP_Markdown_Native_Temporary_Tables(); } public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query_Result { @@ -661,7 +664,8 @@ public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query $prefix, false, $this->content_root, - advisory_locks: $this->advisory_locks + advisory_locks: $this->advisory_locks, + temporary_tables: $this->temporary_tables ); } return $this->runtimes[ $prefix ]->execute( $request ); @@ -718,6 +722,7 @@ final class WP_Markdown_Native_Multisite_Query_Runtime implements WP_Markdown_Qu private string $state_root; private string $content_root; private WP_Markdown_Native_Advisory_Locks $advisory_locks; + private WP_Markdown_Native_Temporary_Tables $temporary_tables; public function __construct( string $state_root, @@ -730,6 +735,7 @@ public function __construct( $this->state_root = rtrim( $state_root, '/\\' ); $this->content_root = rtrim( $content_root, '/\\' ); $this->advisory_locks = new WP_Markdown_Native_Advisory_Locks( $this->state_root ); + $this->temporary_tables = new WP_Markdown_Native_Temporary_Tables(); } public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query_Result { @@ -764,7 +770,8 @@ public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query $this->state_root, $this->content_root, $this->advisory_locks, - $this->state_root + $this->state_root, + $this->temporary_tables ); } catch ( Throwable ) { return WP_Markdown_Query_Result::failure( diff --git a/tests/smoke-native-multisite-prefix-routing.php b/tests/smoke-native-multisite-prefix-routing.php index d23827b..8daf9b1 100644 --- a/tests/smoke-native-multisite-prefix-routing.php +++ b/tests/smoke-native-multisite-prefix-routing.php @@ -29,6 +29,11 @@ $cross_scope_rollback = $runtime->execute( new WP_Markdown_Query_Request( 'ROLLBACK', 'wp_' ) ); $site_after_rollback = $runtime->execute( new WP_Markdown_Query_Request( "SELECT ID FROM wp_2_posts WHERE post_name = 'site-transaction'", 'wp_2_' ) ); $network_after_rollback = $runtime->execute( new WP_Markdown_Query_Request( "SELECT ID FROM wp_posts WHERE post_name = 'network-transaction'", 'wp_' ) ); +$temporary_created = $runtime->execute( new WP_Markdown_Query_Request( 'CREATE TEMPORARY TABLE wp_2_session_probe (id bigint unsigned NOT NULL, PRIMARY KEY (id))', 'wp_2_' ) ); +$temporary_inserted = $runtime->execute( new WP_Markdown_Query_Request( 'INSERT INTO wp_2_session_probe (id) VALUES (7)', 'wp_2_' ) ); +// Construct the base-prefix runtime after site 2, as switch_to_blog() does. +$base_scope = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT option_value FROM wp_options WHERE option_name = \'siteurl\'', 'wp_' ) ); +$temporary_after_switch = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT id FROM wp_2_session_probe', 'wp_2_' ) ); $listed = array_map( static fn( object $row ): string => (string) array_values( get_object_vars( $row ) )[0], $tables->wpdb_state()['last_result'] ); $checks = array( @@ -46,6 +51,10 @@ && 0 === $network_after_rollback->wpdb_state()['num_rows'] && empty( glob( $root . '/sites/2/post/*.md' ) ) && empty( glob( $root . '/post/*.md' ) ), + 'temporary tables retain one logical wpdb session across lazy site-prefix runtimes' => $temporary_created->succeeded() + && 1 === $temporary_inserted->return_value() + && $base_scope->succeeded() + && '7' === (string) ( $temporary_after_switch->wpdb_state()['last_result'][0]->id ?? '' ), ); $failed = 0; From 393a0aa67caa877922530e01de302e41710ea00d Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 10 Sep 2026 13:28:04 -0400 Subject: [PATCH 37/53] fix(native): project temporary tables across site registries --- ...class-wp-markdown-native-query-runtime.php | 1 + .../class-wp-markdown-native-query-schema.php | 24 +++++++++++++++++++ ...ss-wp-markdown-native-schema-mutations.php | 5 ++-- ...ss-wp-markdown-native-temporary-tables.php | 12 +++++++--- 4 files changed, 37 insertions(+), 5 deletions(-) diff --git a/inc/native/class-wp-markdown-native-query-runtime.php b/inc/native/class-wp-markdown-native-query-runtime.php index 28fc81c..747eec8 100644 --- a/inc/native/class-wp-markdown-native-query-runtime.php +++ b/inc/native/class-wp-markdown-native-query-runtime.php @@ -272,6 +272,7 @@ public static function runtime( ); $registry = self::registry( $state_root, $prefix, $base_prefix, $multisite, $content_root, $global_state_root, $global_content_root ); $temporary_tables = $temporary_tables ?? new WP_Markdown_Native_Temporary_Tables(); + $registry->temporary_tables( $temporary_tables ); $parser = new WP_Markdown_Native_Table_Insert_Parser(); $resolved_base = $base_prefix ?? $prefix; $resolved_content = $content_root ?? $state_root; diff --git a/inc/native/class-wp-markdown-native-query-schema.php b/inc/native/class-wp-markdown-native-query-schema.php index 9a0d344..434b1be 100644 --- a/inc/native/class-wp-markdown-native-query-schema.php +++ b/inc/native/class-wp-markdown-native-query-schema.php @@ -712,6 +712,11 @@ final class WP_Markdown_Native_Table_Registry { private array $definitions = array(); /** @var array}> */ private array $shadows = array(); + private ?WP_Markdown_Native_Temporary_Tables $temporary_tables = null; + + public function temporary_tables( WP_Markdown_Native_Temporary_Tables $temporary_tables ): void { + $this->temporary_tables = $temporary_tables; + } public function register( string $table, @@ -730,6 +735,7 @@ public function register( /** @return array{schema:WP_Markdown_Native_Table_Schema,provider:WP_Markdown_Native_Table_Provider}|null */ public function table( string $table ): ?array { + $this->synchronize_temporary_table( $table ); return $this->tables[ $table ] ?? null; } @@ -745,6 +751,7 @@ public function register_definition( string $table, array $definition ): void { /** @return array|null */ public function definition( string $table ): ?array { + $this->synchronize_temporary_table( $table ); return $this->definitions[ $table ] ?? null; } @@ -825,4 +832,21 @@ public function forget_snapshots(): void { public function table_names(): array { return array_keys( $this->definitions ); } + + /** Project the connection's temporary overlay into this prefix-local registry. */ + private function synchronize_temporary_table( string $table ): void { + if ( null === $this->temporary_tables ) { + return; + } + $temporary = $this->temporary_tables->table( $table ); + if ( null === $temporary ) { + if ( isset( $this->shadows[ $table ] ) ) { + $this->unshadow( $table ); + } + return; + } + if ( ! isset( $this->shadows[ $table ] ) ) { + $this->shadow( $table, $temporary['schema'], $temporary['provider'], $temporary['definition'] ); + } + } } diff --git a/inc/native/class-wp-markdown-native-schema-mutations.php b/inc/native/class-wp-markdown-native-schema-mutations.php index 3dddc1b..a2c74e9 100644 --- a/inc/native/class-wp-markdown-native-schema-mutations.php +++ b/inc/native/class-wp-markdown-native-schema-mutations.php @@ -118,12 +118,13 @@ public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query return $this->failure( 'unsupported_schema', 'mdi-native cannot compile the requested table definition.' ); } if ( $temporary && null !== $this->temporary_tables ) { + $provider = null === $schema ? null : new WP_Markdown_Native_JSON_Snapshot_Provider( $root, $schema, $suffix . '.json' ); if ( null === $schema ) { $this->registry->shadow( $table, null, null, $definition ); } else { - $this->registry->shadow( $table, $schema, new WP_Markdown_Native_JSON_Snapshot_Provider( $root, $schema, $suffix . '.json' ), $definition ); + $this->registry->shadow( $table, $schema, $provider, $definition ); } - $this->temporary_tables->add( $table ); + $this->temporary_tables->add( $table, $schema, $provider, $definition ); } elseif ( null === $schema ) { $this->registry->register_definition( $table, $definition ); } else { diff --git a/inc/native/class-wp-markdown-native-temporary-tables.php b/inc/native/class-wp-markdown-native-temporary-tables.php index d47a11c..d82c135 100644 --- a/inc/native/class-wp-markdown-native-temporary-tables.php +++ b/inc/native/class-wp-markdown-native-temporary-tables.php @@ -7,7 +7,7 @@ final class WP_Markdown_Native_Temporary_Tables { private string $root; - /** @var array */ + /** @var array}> */ private array $tables = array(); public function __construct() { @@ -34,8 +34,14 @@ public function has( string $table ): bool { return isset( $this->tables[ $table ] ); } - public function add( string $table ): void { - $this->tables[ $table ] = true; + /** @param array $definition */ + public function add( string $table, ?WP_Markdown_Native_Table_Schema $schema, ?WP_Markdown_Native_Table_Provider $provider, array $definition ): void { + $this->tables[ $table ] = array( 'schema' => $schema, 'provider' => $provider, 'definition' => $definition ); + } + + /** @return array{schema:?WP_Markdown_Native_Table_Schema,provider:?WP_Markdown_Native_Table_Provider,definition:array}|null */ + public function table( string $table ): ?array { + return $this->tables[ $table ] ?? null; } public function remove( string $table ): void { From 8b29b207db96c4ada6a0f20dbc88a7aa1ab8ef9e Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 10 Sep 2026 13:32:41 -0400 Subject: [PATCH 38/53] Revert "fix(native): project temporary tables across site registries" This reverts commit 393a0aa67caa877922530e01de302e41710ea00d. --- ...class-wp-markdown-native-query-runtime.php | 1 - .../class-wp-markdown-native-query-schema.php | 24 ------------------- ...ss-wp-markdown-native-schema-mutations.php | 5 ++-- ...ss-wp-markdown-native-temporary-tables.php | 12 +++------- 4 files changed, 5 insertions(+), 37 deletions(-) diff --git a/inc/native/class-wp-markdown-native-query-runtime.php b/inc/native/class-wp-markdown-native-query-runtime.php index 747eec8..28fc81c 100644 --- a/inc/native/class-wp-markdown-native-query-runtime.php +++ b/inc/native/class-wp-markdown-native-query-runtime.php @@ -272,7 +272,6 @@ public static function runtime( ); $registry = self::registry( $state_root, $prefix, $base_prefix, $multisite, $content_root, $global_state_root, $global_content_root ); $temporary_tables = $temporary_tables ?? new WP_Markdown_Native_Temporary_Tables(); - $registry->temporary_tables( $temporary_tables ); $parser = new WP_Markdown_Native_Table_Insert_Parser(); $resolved_base = $base_prefix ?? $prefix; $resolved_content = $content_root ?? $state_root; diff --git a/inc/native/class-wp-markdown-native-query-schema.php b/inc/native/class-wp-markdown-native-query-schema.php index 434b1be..9a0d344 100644 --- a/inc/native/class-wp-markdown-native-query-schema.php +++ b/inc/native/class-wp-markdown-native-query-schema.php @@ -712,11 +712,6 @@ final class WP_Markdown_Native_Table_Registry { private array $definitions = array(); /** @var array}> */ private array $shadows = array(); - private ?WP_Markdown_Native_Temporary_Tables $temporary_tables = null; - - public function temporary_tables( WP_Markdown_Native_Temporary_Tables $temporary_tables ): void { - $this->temporary_tables = $temporary_tables; - } public function register( string $table, @@ -735,7 +730,6 @@ public function register( /** @return array{schema:WP_Markdown_Native_Table_Schema,provider:WP_Markdown_Native_Table_Provider}|null */ public function table( string $table ): ?array { - $this->synchronize_temporary_table( $table ); return $this->tables[ $table ] ?? null; } @@ -751,7 +745,6 @@ public function register_definition( string $table, array $definition ): void { /** @return array|null */ public function definition( string $table ): ?array { - $this->synchronize_temporary_table( $table ); return $this->definitions[ $table ] ?? null; } @@ -832,21 +825,4 @@ public function forget_snapshots(): void { public function table_names(): array { return array_keys( $this->definitions ); } - - /** Project the connection's temporary overlay into this prefix-local registry. */ - private function synchronize_temporary_table( string $table ): void { - if ( null === $this->temporary_tables ) { - return; - } - $temporary = $this->temporary_tables->table( $table ); - if ( null === $temporary ) { - if ( isset( $this->shadows[ $table ] ) ) { - $this->unshadow( $table ); - } - return; - } - if ( ! isset( $this->shadows[ $table ] ) ) { - $this->shadow( $table, $temporary['schema'], $temporary['provider'], $temporary['definition'] ); - } - } } diff --git a/inc/native/class-wp-markdown-native-schema-mutations.php b/inc/native/class-wp-markdown-native-schema-mutations.php index a2c74e9..3dddc1b 100644 --- a/inc/native/class-wp-markdown-native-schema-mutations.php +++ b/inc/native/class-wp-markdown-native-schema-mutations.php @@ -118,13 +118,12 @@ public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query return $this->failure( 'unsupported_schema', 'mdi-native cannot compile the requested table definition.' ); } if ( $temporary && null !== $this->temporary_tables ) { - $provider = null === $schema ? null : new WP_Markdown_Native_JSON_Snapshot_Provider( $root, $schema, $suffix . '.json' ); if ( null === $schema ) { $this->registry->shadow( $table, null, null, $definition ); } else { - $this->registry->shadow( $table, $schema, $provider, $definition ); + $this->registry->shadow( $table, $schema, new WP_Markdown_Native_JSON_Snapshot_Provider( $root, $schema, $suffix . '.json' ), $definition ); } - $this->temporary_tables->add( $table, $schema, $provider, $definition ); + $this->temporary_tables->add( $table ); } elseif ( null === $schema ) { $this->registry->register_definition( $table, $definition ); } else { diff --git a/inc/native/class-wp-markdown-native-temporary-tables.php b/inc/native/class-wp-markdown-native-temporary-tables.php index d82c135..d47a11c 100644 --- a/inc/native/class-wp-markdown-native-temporary-tables.php +++ b/inc/native/class-wp-markdown-native-temporary-tables.php @@ -7,7 +7,7 @@ final class WP_Markdown_Native_Temporary_Tables { private string $root; - /** @var array}> */ + /** @var array */ private array $tables = array(); public function __construct() { @@ -34,14 +34,8 @@ public function has( string $table ): bool { return isset( $this->tables[ $table ] ); } - /** @param array $definition */ - public function add( string $table, ?WP_Markdown_Native_Table_Schema $schema, ?WP_Markdown_Native_Table_Provider $provider, array $definition ): void { - $this->tables[ $table ] = array( 'schema' => $schema, 'provider' => $provider, 'definition' => $definition ); - } - - /** @return array{schema:?WP_Markdown_Native_Table_Schema,provider:?WP_Markdown_Native_Table_Provider,definition:array}|null */ - public function table( string $table ): ?array { - return $this->tables[ $table ] ?? null; + public function add( string $table ): void { + $this->tables[ $table ] = true; } public function remove( string $table ): void { From 0d7b860439fbc27295a9e160b9fe25c634f235a7 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 10 Sep 2026 13:32:41 -0400 Subject: [PATCH 39/53] Revert "fix(native): share temporary tables across site runtimes" This reverts commit 81a8a551161b93fe3e8d70eec82a9fb5ebe1587c. --- .../class-wp-markdown-native-query-runtime.php | 15 ++++----------- tests/smoke-native-multisite-prefix-routing.php | 9 --------- 2 files changed, 4 insertions(+), 20 deletions(-) diff --git a/inc/native/class-wp-markdown-native-query-runtime.php b/inc/native/class-wp-markdown-native-query-runtime.php index 28fc81c..90fc9df 100644 --- a/inc/native/class-wp-markdown-native-query-runtime.php +++ b/inc/native/class-wp-markdown-native-query-runtime.php @@ -253,8 +253,7 @@ public static function runtime( ?string $global_state_root = null, ?string $global_content_root = null, ?WP_Markdown_Native_Advisory_Locks $advisory_locks = null, - ?string $transaction_state_root = null, - ?WP_Markdown_Native_Temporary_Tables $temporary_tables = null + ?string $transaction_state_root = null ): WP_Markdown_Native_Query_Runtime { $state_root = self::materialize_state_root( $state_root ); if ( null !== $content_root ) { @@ -271,7 +270,7 @@ public static function runtime( array_filter( array( $state_root, $content_root, $global_state_root, $global_content_root ) ) ); $registry = self::registry( $state_root, $prefix, $base_prefix, $multisite, $content_root, $global_state_root, $global_content_root ); - $temporary_tables = $temporary_tables ?? new WP_Markdown_Native_Temporary_Tables(); + $temporary_tables = new WP_Markdown_Native_Temporary_Tables(); $parser = new WP_Markdown_Native_Table_Insert_Parser(); $resolved_base = $base_prefix ?? $prefix; $resolved_content = $content_root ?? $state_root; @@ -645,14 +644,12 @@ final class WP_Markdown_Native_Prefix_Query_Runtime implements WP_Markdown_Query /** @var array */ private array $runtimes = array(); private WP_Markdown_Native_Advisory_Locks $advisory_locks; - private WP_Markdown_Native_Temporary_Tables $temporary_tables; public function __construct( private string $state_root, private string $content_root ) { $this->advisory_locks = new WP_Markdown_Native_Advisory_Locks( $state_root ); - $this->temporary_tables = new WP_Markdown_Native_Temporary_Tables(); } public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query_Result { @@ -664,8 +661,7 @@ public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query $prefix, false, $this->content_root, - advisory_locks: $this->advisory_locks, - temporary_tables: $this->temporary_tables + advisory_locks: $this->advisory_locks ); } return $this->runtimes[ $prefix ]->execute( $request ); @@ -722,7 +718,6 @@ final class WP_Markdown_Native_Multisite_Query_Runtime implements WP_Markdown_Qu private string $state_root; private string $content_root; private WP_Markdown_Native_Advisory_Locks $advisory_locks; - private WP_Markdown_Native_Temporary_Tables $temporary_tables; public function __construct( string $state_root, @@ -735,7 +730,6 @@ public function __construct( $this->state_root = rtrim( $state_root, '/\\' ); $this->content_root = rtrim( $content_root, '/\\' ); $this->advisory_locks = new WP_Markdown_Native_Advisory_Locks( $this->state_root ); - $this->temporary_tables = new WP_Markdown_Native_Temporary_Tables(); } public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query_Result { @@ -770,8 +764,7 @@ public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query $this->state_root, $this->content_root, $this->advisory_locks, - $this->state_root, - $this->temporary_tables + $this->state_root ); } catch ( Throwable ) { return WP_Markdown_Query_Result::failure( diff --git a/tests/smoke-native-multisite-prefix-routing.php b/tests/smoke-native-multisite-prefix-routing.php index 8daf9b1..d23827b 100644 --- a/tests/smoke-native-multisite-prefix-routing.php +++ b/tests/smoke-native-multisite-prefix-routing.php @@ -29,11 +29,6 @@ $cross_scope_rollback = $runtime->execute( new WP_Markdown_Query_Request( 'ROLLBACK', 'wp_' ) ); $site_after_rollback = $runtime->execute( new WP_Markdown_Query_Request( "SELECT ID FROM wp_2_posts WHERE post_name = 'site-transaction'", 'wp_2_' ) ); $network_after_rollback = $runtime->execute( new WP_Markdown_Query_Request( "SELECT ID FROM wp_posts WHERE post_name = 'network-transaction'", 'wp_' ) ); -$temporary_created = $runtime->execute( new WP_Markdown_Query_Request( 'CREATE TEMPORARY TABLE wp_2_session_probe (id bigint unsigned NOT NULL, PRIMARY KEY (id))', 'wp_2_' ) ); -$temporary_inserted = $runtime->execute( new WP_Markdown_Query_Request( 'INSERT INTO wp_2_session_probe (id) VALUES (7)', 'wp_2_' ) ); -// Construct the base-prefix runtime after site 2, as switch_to_blog() does. -$base_scope = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT option_value FROM wp_options WHERE option_name = \'siteurl\'', 'wp_' ) ); -$temporary_after_switch = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT id FROM wp_2_session_probe', 'wp_2_' ) ); $listed = array_map( static fn( object $row ): string => (string) array_values( get_object_vars( $row ) )[0], $tables->wpdb_state()['last_result'] ); $checks = array( @@ -51,10 +46,6 @@ && 0 === $network_after_rollback->wpdb_state()['num_rows'] && empty( glob( $root . '/sites/2/post/*.md' ) ) && empty( glob( $root . '/post/*.md' ) ), - 'temporary tables retain one logical wpdb session across lazy site-prefix runtimes' => $temporary_created->succeeded() - && 1 === $temporary_inserted->return_value() - && $base_scope->succeeded() - && '7' === (string) ( $temporary_after_switch->wpdb_state()['last_result'][0]->id ?? '' ), ); $failed = 0; From c233045ddc931b89998f2a478726494d66a3aef7 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 10 Sep 2026 14:01:15 -0400 Subject: [PATCH 40/53] fix(native): route core temporary table mutations --- ...lass-wp-markdown-native-query-executor.php | 4 +-- .../class-wp-markdown-native-query-schema.php | 5 ++++ ...ss-wp-markdown-native-schema-mutations.php | 2 +- tests/smoke-native-create-table.php | 28 +++++++++++++++++++ 4 files changed, 36 insertions(+), 3 deletions(-) diff --git a/inc/native/class-wp-markdown-native-query-executor.php b/inc/native/class-wp-markdown-native-query-executor.php index 65497f7..59b6308 100644 --- a/inc/native/class-wp-markdown-native-query-executor.php +++ b/inc/native/class-wp-markdown-native-query-executor.php @@ -163,7 +163,7 @@ private function execute_unlocked_request( WP_Markdown_Query_Request $request ): : $this->schema_mutations->execute( $request ); } $dml_table = $this->dml_table( $request ); - if ( null !== $dml_table && 0 !== strcasecmp( $request->table_prefix() . 'options', $dml_table ) ) { + if ( null !== $dml_table && ( 0 !== strcasecmp( $request->table_prefix() . 'options', $dml_table ) || $this->registry->is_shadowed( $dml_table ) ) ) { return $this->execute_table_dml( $request, $dml_table ); } if ( 1 !== preg_match( '/^\s*(?:SELECT\b|(?:\(\s*)+SELECT\b)/i', $request->sql() ) ) { @@ -2541,7 +2541,7 @@ private function dml_table( WP_Markdown_Query_Request $request ): ?string { } private function execute_table_dml( WP_Markdown_Query_Request $request, string $table ): WP_Markdown_Query_Result { - if ( 0 === strcasecmp( $request->table_prefix() . 'posts', $table ) ) { + if ( 0 === strcasecmp( $request->table_prefix() . 'posts', $table ) && ! $this->registry->is_shadowed( $table ) ) { return null === $this->post_mutations ? $this->failure( 'unsupported_grammar', 'mdi-native post mutations are unavailable.' ) : $this->post_mutations->execute( $request ); diff --git a/inc/native/class-wp-markdown-native-query-schema.php b/inc/native/class-wp-markdown-native-query-schema.php index 9a0d344..de9d9d5 100644 --- a/inc/native/class-wp-markdown-native-query-schema.php +++ b/inc/native/class-wp-markdown-native-query-schema.php @@ -748,6 +748,11 @@ public function definition( string $table ): ?array { return $this->definitions[ $table ] ?? null; } + /** Whether a connection-local temporary table currently hides this identifier. */ + public function is_shadowed( string $table ): bool { + return isset( $this->shadows[ $table ] ); + } + /** * Replace a registered table after its schema is altered. * diff --git a/inc/native/class-wp-markdown-native-schema-mutations.php b/inc/native/class-wp-markdown-native-schema-mutations.php index 3dddc1b..abe293f 100644 --- a/inc/native/class-wp-markdown-native-schema-mutations.php +++ b/inc/native/class-wp-markdown-native-schema-mutations.php @@ -266,7 +266,7 @@ private function execute_drop( WP_Markdown_Query_Request $request, string $sql ) return $this->failure( 'unsupported_schema', 'mdi-native requires a simple table identifier.' ); } // Core tables are structural, not plugin state, so they are never dropped. - if ( isset( WP_Markdown_Native_Schema_Catalog::definitions()[ $suffix ] ) ) { + if ( ! $temporary && isset( WP_Markdown_Native_Schema_Catalog::definitions()[ $suffix ] ) ) { return $this->failure( 'unsupported_schema', 'mdi-native cannot drop a core table.' ); } if ( $temporary ) { diff --git a/tests/smoke-native-create-table.php b/tests/smoke-native-create-table.php index 02bb257..bc2ead8 100644 --- a/tests/smoke-native-create-table.php +++ b/tests/smoke-native-create-table.php @@ -89,6 +89,24 @@ function mdi_native_create_remove_tree( string $root ): void { $permanent_rows_after_shadow = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT event_key, owner_id, payload FROM wp_plugin_events' ) ); $cold_temporary = WP_Markdown_Native_Runtime_Factory::runtime( $root )->execute( new WP_Markdown_Query_Request( 'DESCRIBE wp_temporary_generation' ) ); +$runtime->execute( new WP_Markdown_Query_Request( "INSERT INTO wp_options (option_name, option_value, autoload) VALUES ('temporary_shadow', 'permanent', 'no')" ) ); +$core_runtime = WP_Markdown_Native_Runtime_Factory::runtime( $root ); +$permanent_options_before = $core_runtime->execute( new WP_Markdown_Query_Request( "SELECT option_value FROM wp_options WHERE option_name = 'temporary_shadow'" ) ); +$temporary_options_created = $core_runtime->execute( new WP_Markdown_Query_Request( 'CREATE TEMPORARY TABLE wp_options (option_name varchar(64) NOT NULL, option_value longtext DEFAULT NULL, PRIMARY KEY (option_name))' ) ); +$temporary_options_inserted = $core_runtime->execute( new WP_Markdown_Query_Request( "INSERT INTO wp_options (option_name, option_value) VALUES ('temporary_shadow', 'temporary')" ) ); +$core_runtime->execute( new WP_Markdown_Query_Request( "UPDATE wp_options SET option_value = 'updated' WHERE option_name = 'temporary_shadow'" ) ); +$temporary_options_rows = $core_runtime->execute( new WP_Markdown_Query_Request( "SELECT option_value FROM wp_options WHERE option_name = 'temporary_shadow'" ) ); +$core_runtime->execute( new WP_Markdown_Query_Request( 'DROP TEMPORARY TABLE wp_options' ) ); +$permanent_options_rows = $core_runtime->execute( new WP_Markdown_Query_Request( "SELECT option_value FROM wp_options WHERE option_name = 'temporary_shadow'" ) ); + +$permanent_posts_before = $core_runtime->execute( new WP_Markdown_Query_Request( 'SELECT ID FROM wp_posts WHERE ID = 900' ) ); +$temporary_posts_created = $core_runtime->execute( new WP_Markdown_Query_Request( 'CREATE TEMPORARY TABLE wp_posts (ID bigint unsigned NOT NULL, post_title varchar(255) NOT NULL, PRIMARY KEY (ID))' ) ); +$temporary_posts_inserted = $core_runtime->execute( new WP_Markdown_Query_Request( "INSERT INTO wp_posts (ID, post_title) VALUES (900, 'temporary')" ) ); +$core_runtime->execute( new WP_Markdown_Query_Request( "UPDATE wp_posts SET post_title = 'updated' WHERE ID = 900" ) ); +$temporary_posts_rows = $core_runtime->execute( new WP_Markdown_Query_Request( 'SELECT post_title FROM wp_posts WHERE ID = 900' ) ); +$core_runtime->execute( new WP_Markdown_Query_Request( 'DROP TEMPORARY TABLE wp_posts' ) ); +$permanent_posts_rows = $core_runtime->execute( new WP_Markdown_Query_Request( 'SELECT ID FROM wp_posts WHERE ID = 900' ) ); + $checks = array( 'generic CREATE TABLE returns the WordPress DDL success shape' => true === $created->return_value() && 0 === $created->wpdb_state()['rows_affected'], @@ -124,6 +142,16 @@ function mdi_native_create_remove_tree( string $root ): void { && true === $shadow_dropped->return_value() && 'permanent' === (string) ( $permanent_rows_after_shadow->wpdb_state()['last_result'][0]->event_key ?? '' ) && false === $cold_temporary->return_value(), + 'temporary options shadows route DML to their JSON provider and restore the unchanged canonical provider' => true === $temporary_options_created->return_value() + && 'permanent' === (string) ( $permanent_options_before->wpdb_state()['last_result'][0]->option_value ?? '' ) + && 1 === $temporary_options_inserted->wpdb_state()['rows_affected'] + && 'updated' === (string) ( $temporary_options_rows->wpdb_state()['last_result'][0]->option_value ?? '' ) + && 'permanent' === (string) ( $permanent_options_rows->wpdb_state()['last_result'][0]->option_value ?? '' ), + 'temporary posts shadows route DML to their JSON provider and restore the unchanged canonical provider' => true === $temporary_posts_created->return_value() + && array() === $permanent_posts_before->wpdb_state()['last_result'] + && 1 === $temporary_posts_inserted->wpdb_state()['rows_affected'] + && 'updated' === (string) ( $temporary_posts_rows->wpdb_state()['last_result'][0]->post_title ?? '' ) + && array() === $permanent_posts_rows->wpdb_state()['last_result'], ); $failed = false; From 9af43cccfe1943df37c23a8abc00f01a2e3ae423 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 10 Sep 2026 15:06:39 -0400 Subject: [PATCH 41/53] fix(native): parse and validate table index hints --- .../class-wp-markdown-native-query-ast.php | 11 +++-- ...ass-wp-markdown-native-query-contracts.php | 6 ++- ...lass-wp-markdown-native-query-executor.php | 23 +++++++++ .../class-wp-markdown-native-query-parser.php | 49 +++++++++++++++---- tests/smoke-native-join-query.php | 5 ++ 5 files changed, 80 insertions(+), 14 deletions(-) diff --git a/inc/native/class-wp-markdown-native-query-ast.php b/inc/native/class-wp-markdown-native-query-ast.php index 547c998..96cf946 100644 --- a/inc/native/class-wp-markdown-native-query-ast.php +++ b/inc/native/class-wp-markdown-native-query-ast.php @@ -277,7 +277,8 @@ public function __construct( private readonly array $union_orders = array(), private readonly ?int $union_limit = null, private readonly int $union_limit_offset = 0, - private readonly array $group_expressions = array() + private readonly array $group_expressions = array(), + private readonly array $index_hints = array() ) {} public function selects_all(): bool { @@ -387,15 +388,17 @@ public function union_all(): bool { return $this->union_all; } public function union_orders(): array { return $this->union_orders; } public function union_limit(): ?int { return $this->union_limit; } public function union_limit_offset(): int { return $this->union_limit_offset; } + /** @return array}> */ + public function index_hints(): array { return $this->index_hints; } /** Attach a query-expression branch without confusing its local clauses with UNION clauses. */ public function with_union( self $union, bool $all ): self { - return new self( $this->select_all, $this->count_all, $this->projection, $this->table, $this->predicates, $this->orders, $this->limit, $this->alias, $this->joins, $this->calculates_found_rows, $this->limit_offset, $this->distinct, $this->contradiction, $this->group_by, $this->aggregates, $this->scalar_projection, $this->having, $this->subqueries, $union, $this->scalar_predicates, $this->scalar_having, $this->group_expression, $this->boolean_predicate, $this->derived, $all, $this->union_orders, $this->union_limit, $this->union_limit_offset ); + return new self( $this->select_all, $this->count_all, $this->projection, $this->table, $this->predicates, $this->orders, $this->limit, $this->alias, $this->joins, $this->calculates_found_rows, $this->limit_offset, $this->distinct, $this->contradiction, $this->group_by, $this->aggregates, $this->scalar_projection, $this->having, $this->subqueries, $union, $this->scalar_predicates, $this->scalar_having, $this->group_expression, $this->boolean_predicate, $this->derived, $all, $this->union_orders, $this->union_limit, $this->union_limit_offset, $this->group_expressions, $this->index_hints ); } /** @param array $orders */ public function with_union_tail( array $orders, ?int $limit, int $offset ): self { - return new self( $this->select_all, $this->count_all, $this->projection, $this->table, $this->predicates, $this->orders, $this->limit, $this->alias, $this->joins, $this->calculates_found_rows, $this->limit_offset, $this->distinct, $this->contradiction, $this->group_by, $this->aggregates, $this->scalar_projection, $this->having, $this->subqueries, $this->union, $this->scalar_predicates, $this->scalar_having, $this->group_expression, $this->boolean_predicate, $this->derived, $this->union_all, $orders, $limit, $offset ); + return new self( $this->select_all, $this->count_all, $this->projection, $this->table, $this->predicates, $this->orders, $this->limit, $this->alias, $this->joins, $this->calculates_found_rows, $this->limit_offset, $this->distinct, $this->contradiction, $this->group_by, $this->aggregates, $this->scalar_projection, $this->having, $this->subqueries, $this->union, $this->scalar_predicates, $this->scalar_having, $this->group_expression, $this->boolean_predicate, $this->derived, $this->union_all, $orders, $limit, $offset, $this->group_expressions, $this->index_hints ); } public function append_union( self $branch, bool $all ): self { @@ -406,6 +409,6 @@ public function append_union( self $branch, bool $all ): self { /** Remove clauses that syntactically follow an unparenthesized UNION branch. */ public function without_order_limit(): self { - return new self( $this->select_all, $this->count_all, $this->projection, $this->table, $this->predicates, array(), null, $this->alias, $this->joins, $this->calculates_found_rows, 0, $this->distinct, $this->contradiction, $this->group_by, $this->aggregates, $this->scalar_projection, $this->having, $this->subqueries, $this->union, $this->scalar_predicates, $this->scalar_having, $this->group_expression, $this->boolean_predicate, $this->derived, $this->union_all, $this->union_orders, $this->union_limit, $this->union_limit_offset ); + return new self( $this->select_all, $this->count_all, $this->projection, $this->table, $this->predicates, array(), null, $this->alias, $this->joins, $this->calculates_found_rows, 0, $this->distinct, $this->contradiction, $this->group_by, $this->aggregates, $this->scalar_projection, $this->having, $this->subqueries, $this->union, $this->scalar_predicates, $this->scalar_having, $this->group_expression, $this->boolean_predicate, $this->derived, $this->union_all, $this->union_orders, $this->union_limit, $this->union_limit_offset, $this->group_expressions, $this->index_hints ); } } diff --git a/inc/native/class-wp-markdown-native-query-contracts.php b/inc/native/class-wp-markdown-native-query-contracts.php index 31dd2e0..180b683 100644 --- a/inc/native/class-wp-markdown-native-query-contracts.php +++ b/inc/native/class-wp-markdown-native-query-contracts.php @@ -286,13 +286,17 @@ public function __construct( private readonly array $union_order_by = array(), private readonly ?int $union_limit = null, private readonly int $union_limit_offset = 0, - private readonly array $group_expressions = array() + private readonly array $group_expressions = array(), + private readonly array $index_hints = array() ) {} public function table(): string { return $this->table; } + /** @return array}> */ + public function index_hints(): array { return $this->index_hints; } + /** @return array */ public function projection(): array { return $this->projection; diff --git a/inc/native/class-wp-markdown-native-query-executor.php b/inc/native/class-wp-markdown-native-query-executor.php index 59b6308..19667ab 100644 --- a/inc/native/class-wp-markdown-native-query-executor.php +++ b/inc/native/class-wp-markdown-native-query-executor.php @@ -349,6 +349,10 @@ private function advisory_lock_query( string $sql ): ?WP_Markdown_Query_Result { } private function execute_query_plan( WP_Markdown_Native_Query_Plan $plan, bool $allow_union = true ): WP_Markdown_Query_Result { + $hint_error = $this->validate_index_hints( $plan ); + if ( null !== $hint_error ) { + return $hint_error; + } if ( $allow_union && null !== $plan->union() ) { return $this->execute_union( $plan ); } @@ -2533,6 +2537,25 @@ private function execute_transaction_control( array $control ): WP_Markdown_Quer return WP_Markdown_Query_Result::mutated( 0 ); } + /** Hints affect access strategy, not rows; validate names before using native planning. */ + private function validate_index_hints( WP_Markdown_Native_Query_Plan $plan ): ?WP_Markdown_Query_Result { + foreach ( $plan->index_hints() as $hint ) { + $definition = $this->registry->definition( $hint['table'] ); + if ( null === $definition ) { + return $this->failure( 'unsupported_table', 'mdi-native cannot validate an index hint for an unknown table.' ); + } + $names = array_map( static fn( array $index ): string => strtolower( $index['name'] ), $definition['indexes'] ?? array() ); + foreach ( $hint['indexes'] as $name ) { + $name = strtolower( $name ); + $matches = in_array( $name, $names, true ) ? array( $name ) : array_values( array_filter( $names, static fn( string $index ): bool => str_starts_with( $index, $name ) ) ); + if ( 1 !== count( $matches ) ) { + return $this->failure( 'unsupported_index_hint', 'mdi-native requires an existing, unambiguous index in a table hint.' ); + } + } + } + return null === $plan->union() ? null : $this->validate_index_hints( $plan->union() ); + } + private function dml_table( WP_Markdown_Query_Request $request ): ?string { if ( 1 === preg_match( '/^\s*(?:INSERT(?:\s+IGNORE)?\s+INTO|REPLACE(?:\s+INTO)?|UPDATE|DELETE\s+FROM)\s+`?([A-Za-z_][A-Za-z0-9_]*)`?/i', $request->sql(), $match ) ) { return $match[1]; diff --git a/inc/native/class-wp-markdown-native-query-parser.php b/inc/native/class-wp-markdown-native-query-parser.php index d488cdd..cf9100f 100644 --- a/inc/native/class-wp-markdown-native-query-parser.php +++ b/inc/native/class-wp-markdown-native-query-parser.php @@ -300,7 +300,8 @@ public function lower( WP_Markdown_Native_SQL_Select|WP_Markdown_Native_SQL_Foun array_map( fn( array $item ): array => array( 'column' => $item['column']->name(), 'descending' => $item['descending'], 'numeric' => str_starts_with( $item['column']->name(), '__union_ordinal_' ) ), $ast->union_orders() ), $ast->union_limit(), $ast->union_limit_offset(), - array_map( fn( WP_Markdown_Native_SQL_Scalar_Expression $expression ): WP_Markdown_Native_Query_Scalar_Expression => $this->lower_scalar_expression( $expression, $base_source, $flat_source ), $ast->group_expressions() ) + array_map( fn( WP_Markdown_Native_SQL_Scalar_Expression $expression ): WP_Markdown_Native_Query_Scalar_Expression => $this->lower_scalar_expression( $expression, $base_source, $flat_source ), $ast->group_expressions() ), + $ast->index_hints() ); } @@ -567,11 +568,12 @@ private function select( bool $nested ): WP_Markdown_Native_SQL_Select|WP_Markdo } $this->expect_keyword( 'FROM' ); - list( $table, $alias, $derived ) = $this->source( true ); + list( $table, $alias, $derived, $index_hints ) = $this->source( true ); $joins = array(); while ( true ) { if ( $this->match_type( WP_Markdown_Native_SQL_Token::COMMA ) ) { - list( $join_table, $join_alias, $join_derived ) = $this->source( false ); + list( $join_table, $join_alias, $join_derived, $join_hints ) = $this->source( false ); + $index_hints = array_merge( $index_hints, $join_hints ); $joins[] = new WP_Markdown_Native_SQL_Join( $join_table, $join_alias, null, null, false, array(), $join_derived ); continue; } @@ -579,7 +581,8 @@ private function select( bool $nested ): WP_Markdown_Native_SQL_Select|WP_Markdo if ( null === $join_kind ) { break; } - list( $join_table, $join_alias, $join_derived ) = $this->source( false ); + list( $join_table, $join_alias, $join_derived, $join_hints ) = $this->source( false ); + $index_hints = array_merge( $index_hints, $join_hints ); $this->expect_keyword( 'ON' ); $on_predicates = $this->disjunction( true ); $equality = null; @@ -825,23 +828,51 @@ private function select( bool $nested ): WP_Markdown_Native_SQL_Select|WP_Markdo if ( ! $nested && $this->match_keyword( 'FOR' ) ) { $this->expect_keyword( 'UPDATE' ); } - return new WP_Markdown_Native_SQL_Select( $select_all, $count_all, $projection, $table, $predicates, $orders, $limit, $alias, $joins, $calculate_found_rows, $limit_offset, $distinct, $this->contradiction, $group, $aggregates, $scalar_projection, $having, $subqueries, $union, $scalar_predicates, $scalar_having, $grouped ? $group_expression : null, $boolean_predicate, $derived, $union_all, $union_orders, $union_limit, $union_limit_offset, $grouped ? $group_expressions : array() ); + return new WP_Markdown_Native_SQL_Select( $select_all, $count_all, $projection, $table, $predicates, $orders, $limit, $alias, $joins, $calculate_found_rows, $limit_offset, $distinct, $this->contradiction, $group, $aggregates, $scalar_projection, $having, $subqueries, $union, $scalar_predicates, $scalar_having, $grouped ? $group_expression : null, $boolean_predicate, $derived, $union_all, $union_orders, $union_limit, $union_limit_offset, $grouped ? $group_expressions : array(), $index_hints ); } - /** @return array{WP_Markdown_Native_SQL_Identifier,?WP_Markdown_Native_SQL_Identifier,?WP_Markdown_Native_SQL_Select} */ + /** @return array{WP_Markdown_Native_SQL_Identifier,?WP_Markdown_Native_SQL_Identifier,?WP_Markdown_Native_SQL_Select,array} */ private function source( bool $base ): array { if ( ! $this->match_type( WP_Markdown_Native_SQL_Token::LEFT_PAREN ) ) { $table = $this->unqualified_identifier(); $alias = null; if ( $this->match_keyword( 'AS' ) ) { $alias = $this->unqualified_identifier(); - } elseif ( $this->matches_identifier() && ( ! $base || ! $this->is_on() ) ) { + } elseif ( $this->matches_identifier() && ! in_array( strtoupper( (string) $this->current()->value() ), array( 'USE', 'FORCE', 'IGNORE' ), true ) && ( ! $base || ! $this->is_on() ) ) { $alias = $this->unqualified_identifier(); } if ( ! $base && null === $alias ) { $alias = $table; } - return array( $table, $alias, null ); + $hints = array(); + while ( WP_Markdown_Native_SQL_Token::WORD === $this->current()->type() && in_array( strtoupper( (string) $this->current()->value() ), array( 'USE', 'FORCE', 'IGNORE' ), true ) ) { + $mode = strtoupper( (string) $this->current()->value() ); + ++$this->current; + if ( WP_Markdown_Native_SQL_Token::WORD !== $this->current()->type() || ! in_array( strtoupper( (string) $this->current()->value() ), array( 'INDEX', 'KEY' ), true ) ) { + $this->unsupported( $this->current() ); + } + ++$this->current; + if ( $this->match_keyword( 'FOR' ) ) { + if ( ! $this->match_keyword( 'JOIN' ) ) { + if ( ! $this->match_keyword( 'ORDER' ) && ! $this->match_keyword( 'GROUP' ) ) { + $this->unsupported( $this->current() ); + } + $this->expect_keyword( 'BY' ); + } + } + $this->expect_type( WP_Markdown_Native_SQL_Token::LEFT_PAREN ); + $indexes = array(); + if ( ! $this->match_type( WP_Markdown_Native_SQL_Token::RIGHT_PAREN ) ) { + do { + $indexes[] = $this->unqualified_identifier()->name(); + } while ( $this->match_type( WP_Markdown_Native_SQL_Token::COMMA ) ); + $this->expect_type( WP_Markdown_Native_SQL_Token::RIGHT_PAREN ); + } elseif ( 'USE' !== $mode ) { + $this->unsupported( $this->current() ); + } + $hints[] = array( 'table' => $table->name(), 'mode' => $mode, 'indexes' => $indexes ); + } + return array( $table, $alias, null, $hints ); } $derived = $this->select( true ); if ( ! $derived instanceof WP_Markdown_Native_SQL_Select ) { @@ -850,7 +881,7 @@ private function source( bool $base ): array { $this->expect_type( WP_Markdown_Native_SQL_Token::RIGHT_PAREN ); $this->match_keyword( 'AS' ); $alias = $this->unqualified_identifier(); - return array( $alias, $alias, $derived ); + return array( $alias, $alias, $derived, array() ); } private function matches_scalar_expression(): bool { diff --git a/tests/smoke-native-join-query.php b/tests/smoke-native-join-query.php index 77841d3..ced8295 100644 --- a/tests/smoke-native-join-query.php +++ b/tests/smoke-native-join-query.php @@ -74,6 +74,9 @@ public function read( WP_Markdown_Native_Table_Access $access ): iterable|WP_Mar $plan = ( new WP_Markdown_Native_Query_Parser() )->parse( $query ); $result = $runtime->execute( new WP_Markdown_Query_Request( $query ) ); $state = $result->wpdb_state(); +$hinted = $runtime->execute( new WP_Markdown_Query_Request( str_replace( 'tr JOIN', 'tr FORCE INDEX (term_taxonomy_id) JOIN', $query ) ) ); +$bad_hint = $runtime->execute( new WP_Markdown_Query_Request( str_replace( 'tr JOIN', 'tr FORCE INDEX (missing_index) JOIN', $query ) ) ); +$joined_hint = $runtime->execute( new WP_Markdown_Query_Request( str_replace( 'tt ON', 'tt USE KEY FOR JOIN (PRIMARY) ON', $query ) ) ); $missing = $runtime->execute( new WP_Markdown_Query_Request( str_replace( '=41', '=404', $query ) ) ); $unbounded = $runtime->execute( new WP_Markdown_Query_Request( substr( $query, 0, strpos( $query, ' WHERE' ) ) ) ); $unindexed_filter = $runtime->execute( new WP_Markdown_Query_Request( substr( $query, 0, strpos( $query, ' WHERE' ) ) . " WHERE tt.description = ''" ) ); @@ -213,6 +216,8 @@ public function read( WP_Markdown_Native_Table_Access $access ): iterable|WP_Mar $meta_result = ( new WP_Markdown_Native_Query_Runtime( $meta_registry ) )->execute( new WP_Markdown_Query_Request( $meta_query ) ); $checks = array( + 'validated source index hints preserve taxonomy JOIN results' => $hinted->succeeded() && $result->corpus_result() === $hinted->corpus_result() && $joined_hint->succeeded() && $result->corpus_result() === $joined_hint->corpus_result(), + 'unknown hinted indexes fail instead of silently executing' => ! $bad_hint->succeeded() && 'unsupported_index_hint' === ( $bad_hint->diagnostic()['reason'] ?? null ), 'tokenizer and parser lower aliases and chained equality JOINs into typed contracts' => $plan instanceof WP_Markdown_Native_Query_Plan && 'tr' === $plan->table_alias() && array( 'tr', 'tt', 't' ) === $plan->projection_sources() From 932c91659ca5738369db3b6af43a3a5813489a5f Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 10 Sep 2026 15:32:57 -0400 Subject: [PATCH 42/53] test(native): protect composed index hint semantics --- inc/native/class-wp-markdown-native-query-parser.php | 9 ++++++++- tests/smoke-native-join-query.php | 5 +++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/inc/native/class-wp-markdown-native-query-parser.php b/inc/native/class-wp-markdown-native-query-parser.php index cf9100f..af47f51 100644 --- a/inc/native/class-wp-markdown-native-query-parser.php +++ b/inc/native/class-wp-markdown-native-query-parser.php @@ -838,15 +838,22 @@ private function source( bool $base ): array { $alias = null; if ( $this->match_keyword( 'AS' ) ) { $alias = $this->unqualified_identifier(); - } elseif ( $this->matches_identifier() && ! in_array( strtoupper( (string) $this->current()->value() ), array( 'USE', 'FORCE', 'IGNORE' ), true ) && ( ! $base || ! $this->is_on() ) ) { + } elseif ( $this->matches_identifier() && ( WP_Markdown_Native_SQL_Token::QUOTED_IDENTIFIER === $this->current()->type() || ! in_array( strtoupper( (string) $this->current()->value() ), array( 'USE', 'FORCE', 'IGNORE' ), true ) ) && ( ! $base || ! $this->is_on() ) ) { $alias = $this->unqualified_identifier(); } if ( ! $base && null === $alias ) { $alias = $table; } $hints = array(); + $access_mode = null; while ( WP_Markdown_Native_SQL_Token::WORD === $this->current()->type() && in_array( strtoupper( (string) $this->current()->value() ), array( 'USE', 'FORCE', 'IGNORE' ), true ) ) { $mode = strtoupper( (string) $this->current()->value() ); + if ( 'IGNORE' !== $mode ) { + if ( null !== $access_mode && $access_mode !== $mode ) { + $this->unsupported( $this->current() ); + } + $access_mode = $mode; + } ++$this->current; if ( WP_Markdown_Native_SQL_Token::WORD !== $this->current()->type() || ! in_array( strtoupper( (string) $this->current()->value() ), array( 'INDEX', 'KEY' ), true ) ) { $this->unsupported( $this->current() ); diff --git a/tests/smoke-native-join-query.php b/tests/smoke-native-join-query.php index ced8295..3aa46be 100644 --- a/tests/smoke-native-join-query.php +++ b/tests/smoke-native-join-query.php @@ -77,6 +77,9 @@ public function read( WP_Markdown_Native_Table_Access $access ): iterable|WP_Mar $hinted = $runtime->execute( new WP_Markdown_Query_Request( str_replace( 'tr JOIN', 'tr FORCE INDEX (term_taxonomy_id) JOIN', $query ) ) ); $bad_hint = $runtime->execute( new WP_Markdown_Query_Request( str_replace( 'tr JOIN', 'tr FORCE INDEX (missing_index) JOIN', $query ) ) ); $joined_hint = $runtime->execute( new WP_Markdown_Query_Request( str_replace( 'tt ON', 'tt USE KEY FOR JOIN (PRIMARY) ON', $query ) ) ); +$empty_use_hint = $runtime->execute( new WP_Markdown_Query_Request( str_replace( 'tr JOIN', 'tr USE INDEX () JOIN', $query ) ) ); +$mixed_hints = $runtime->execute( new WP_Markdown_Query_Request( str_replace( 'tr JOIN', 'tr USE INDEX (PRIMARY) FORCE INDEX (term_taxonomy_id) JOIN', $query ) ) ); +$union_hint = $runtime->execute( new WP_Markdown_Query_Request( "SELECT object_id FROM wp_term_relationships WHERE object_id=41 UNION SELECT object_id FROM wp_term_relationships FORCE INDEX (missing_index) WHERE object_id=99" ) ); $missing = $runtime->execute( new WP_Markdown_Query_Request( str_replace( '=41', '=404', $query ) ) ); $unbounded = $runtime->execute( new WP_Markdown_Query_Request( substr( $query, 0, strpos( $query, ' WHERE' ) ) ) ); $unindexed_filter = $runtime->execute( new WP_Markdown_Query_Request( substr( $query, 0, strpos( $query, ' WHERE' ) ) . " WHERE tt.description = ''" ) ); @@ -218,6 +221,8 @@ public function read( WP_Markdown_Native_Table_Access $access ): iterable|WP_Mar $checks = array( 'validated source index hints preserve taxonomy JOIN results' => $hinted->succeeded() && $result->corpus_result() === $hinted->corpus_result() && $joined_hint->succeeded() && $result->corpus_result() === $joined_hint->corpus_result(), 'unknown hinted indexes fail instead of silently executing' => ! $bad_hint->succeeded() && 'unsupported_index_hint' === ( $bad_hint->diagnostic()['reason'] ?? null ), + 'empty USE hints preserve rows while conflicting USE and FORCE hints fail' => $empty_use_hint->succeeded() && $result->corpus_result() === $empty_use_hint->corpus_result() && ! $mixed_hints->succeeded(), + 'UNION branches retain index-hint validation' => ! $union_hint->succeeded() && 'unsupported_index_hint' === ( $union_hint->diagnostic()['reason'] ?? null ), 'tokenizer and parser lower aliases and chained equality JOINs into typed contracts' => $plan instanceof WP_Markdown_Native_Query_Plan && 'tr' === $plan->table_alias() && array( 'tr', 'tt', 't' ) === $plan->projection_sources() From ad5b3a269d964522e73f3b9a5bd1ffb18f4f9658 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 10 Sep 2026 15:39:56 -0400 Subject: [PATCH 43/53] fix(native): preserve mixed LIKE alternatives --- .../class-wp-markdown-native-query-parser.php | 8 ++++ tests/smoke-native-mixed-like-or.php | 37 +++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 tests/smoke-native-mixed-like-or.php diff --git a/inc/native/class-wp-markdown-native-query-parser.php b/inc/native/class-wp-markdown-native-query-parser.php index af47f51..4fb3538 100644 --- a/inc/native/class-wp-markdown-native-query-parser.php +++ b/inc/native/class-wp-markdown-native-query-parser.php @@ -1173,19 +1173,27 @@ private function coalesce_boolean_groups( array $groups, int $sql_offset ): arra private function requires_boolean_plan( array $groups ): bool { $has_composite_group = false; $has_non_coalescible = false; + $has_like = false; + $only_single_likes = true; foreach ( $groups as $group ) { $has_composite_group = $has_composite_group || 1 < count( $group ); + $only_single_likes = $only_single_likes && 1 === count( $group ); foreach ( $group as $predicate ) { if ( $predicate instanceof WP_Markdown_Native_SQL_Scalar_Predicate || $predicate instanceof WP_Markdown_Native_SQL_Subquery_Predicate ) { return true; } + $has_like = $has_like || 'LIKE' === $predicate->operator(); + $only_single_likes = $only_single_likes && 'LIKE' === $predicate->operator(); $has_non_coalescible = $has_non_coalescible || null !== $predicate->cast() || ! in_array( $predicate->operator(), array( '=', 'IN', 'IS NULL', 'LOWER =', 'LIKE' ), true ); } } + if ( 1 < count( $groups ) && $has_like && ! $only_single_likes ) { + return true; + } if ( 1 < count( $groups ) && $has_composite_group && $has_non_coalescible ) { return true; } diff --git a/tests/smoke-native-mixed-like-or.php b/tests/smoke-native-mixed-like-or.php new file mode 100644 index 0000000..1e930a4 --- /dev/null +++ b/tests/smoke-native-mixed-like-or.php @@ -0,0 +1,37 @@ +execute( new WP_Markdown_Query_Request( 'CREATE TABLE wp_event_dates (post_id bigint unsigned NOT NULL, start_datetime datetime NOT NULL, end_datetime datetime NULL, PRIMARY KEY (post_id))' ) ); + $inserted = $runtime->execute( new WP_Markdown_Query_Request( "INSERT INTO wp_event_dates (post_id, start_datetime, end_datetime) VALUES (1, '0000-00-00 00:00:00', NULL), (2, '2026-07-15 19:30:00', NULL), (3, '2026-07-16 19:30:00', NULL)" ) ); + $checks['fixture persists zero and ordinary dates'] = $created->succeeded() && 3 === $inserted->return_value(); + $queries = array( + "start_datetime = '0000-00-00 00:00:00' OR start_datetime LIKE '0000-%'" => array( '1' ), + "start_datetime LIKE '0000-%' OR start_datetime = '2026-07-15 19:30:00'" => array( '1', '2' ), + "(start_datetime LIKE '0000-%' OR start_datetime = '2026-07-15 19:30:00') AND post_id = 2" => array( '2' ), + "start_datetime = '2026-07-15 19:30:00' OR end_datetime LIKE '0000-%'" => array( '2' ), + ); + foreach ( $queries as $predicate => $ids ) { + $result = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT post_id FROM wp_event_dates WHERE ' . $predicate . ' ORDER BY post_id' ) ); + $actual = array_map( static fn( object $row ): string => (string) $row->post_id, $result->wpdb_state()['last_result'] ); + $checks[ $predicate ] = $result->succeeded() && $ids === $actual; + } +} finally { + foreach ( new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $root, FilesystemIterator::SKIP_DOTS ), RecursiveIteratorIterator::CHILD_FIRST ) as $entry ) { + $entry->isDir() ? rmdir( $entry->getPathname() ) : unlink( $entry->getPathname() ); + } + rmdir( $root ); +} +foreach ( $checks as $label => $passed ) { + fwrite( $passed ? STDOUT : STDERR, ( $passed ? 'PASS: ' : 'FAIL: ' ) . $label . "\n" ); +} +exit( in_array( false, $checks, true ) ? 1 : 0 ); From c56c7e6420c71f4d827e76202b14c3dfb27082e6 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 10 Sep 2026 15:54:24 -0400 Subject: [PATCH 44/53] fix(native): preserve aliased column projections --- ...lass-wp-markdown-native-query-executor.php | 19 +++++++++++++++---- .../class-wp-markdown-native-query-parser.php | 11 ++++++++++- tests/smoke-native-join-query.php | 6 ++++++ 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/inc/native/class-wp-markdown-native-query-executor.php b/inc/native/class-wp-markdown-native-query-executor.php index 19667ab..5ede95f 100644 --- a/inc/native/class-wp-markdown-native-query-executor.php +++ b/inc/native/class-wp-markdown-native-query-executor.php @@ -761,7 +761,7 @@ private function grouped_aggregate_result( array $groups, string $column, array if ( array() === $scalar_projection ) { $columns[] = array( 'name' => $group_name, 'table' => $table, 'type' => $schema->column( $column )->type() ); } else { - foreach ( $scalar_projection as $scalar ) { $columns[ $scalar['position'] ] = array( 'name' => $scalar['alias'], 'table' => '', 'type' => 253 ); } + foreach ( $scalar_projection as $scalar ) { $columns[ $scalar['position'] ] = $this->scalar_projection_column( $scalar, $table, $schema ); } ksort( $columns ); $columns = array_values( $columns ); } @@ -1672,9 +1672,10 @@ function ( array $left, array $right ) use ( $plan, $sources ): int { $columns[] = array( 'name' => $column, 'table' => $sources[ $source ]['table'], 'type' => $sources[ $source ]['schema']->column( $column )->type() ); } foreach ( $plan->scalar_projection() as $scalar ) { - $columns[ $scalar['position'] ] = array( 'name' => $scalar['alias'], 'table' => '', 'type' => 253 ); + $source = $scalar['expression']->source() ?? $plan->table_alias() ?? $plan->table(); + $metadata = $this->scalar_projection_column( $scalar, $sources[ $source ]['table'], $sources[ $source ]['schema'] ); + array_splice( $columns, $scalar['position'], 0, array( $metadata ) ); } - ksort( $columns ); foreach ( $aggregates as $aggregate ) { $columns[] = array( 'name' => $aggregate['alias'], 'table' => '', 'type' => 8 ); } @@ -2119,7 +2120,7 @@ private function result( $projection ); $scalar_columns = array(); - foreach ( $scalar_projection as $scalar ) { $scalar_columns[ $scalar['position'] ] = array( 'name' => $scalar['alias'], 'table' => '', 'type' => 253 ); } + foreach ( $scalar_projection as $scalar ) { $scalar_columns[ $scalar['position'] ] = $this->scalar_projection_column( $scalar, $table, $schema ); } $columns = array(); $total_columns = count( $regular ) + count( $scalar_columns ); for ( $position = 0; $position < $total_columns; ++$position ) { @@ -2128,6 +2129,16 @@ private function result( return WP_Markdown_Query_Result::selected( $rows, $columns ); } + /** A bare column alias keeps the source column's type and table metadata. */ + private function scalar_projection_column( array $scalar, string $table, WP_Markdown_Native_Table_Schema $schema ): array { + $expression = $scalar['expression']; + return array( + 'name' => $scalar['alias'], + 'table' => 'column' === $expression->kind() ? $table : '', + 'type' => 'column' === $expression->kind() ? $schema->column( $expression->column() )->type() : 253, + ); + } + /** @param array $source @param array $projection @return array */ private function string_row( array $source, array $projection, array $scalar_projection, WP_Markdown_Native_Table_Schema $schema ): array { $regular = array(); diff --git a/inc/native/class-wp-markdown-native-query-parser.php b/inc/native/class-wp-markdown-native-query-parser.php index 4fb3538..7d8acf6 100644 --- a/inc/native/class-wp-markdown-native-query-parser.php +++ b/inc/native/class-wp-markdown-native-query-parser.php @@ -563,7 +563,16 @@ private function select( bool $nested ): WP_Markdown_Native_SQL_Select|WP_Markdo ); continue; } - $projection[] = $this->identifier(); + $column = $this->identifier(); + if ( $this->match_keyword( 'AS' ) ) { + $scalar_projection[] = array( + 'expression' => new WP_Markdown_Native_SQL_Scalar_Expression( 'column', $column ), + 'alias' => $this->unqualified_identifier()->name(), + 'position' => count( $projection ) + count( $scalar_projection ), + ); + } else { + $projection[] = $column; + } } while ( $this->match_type( WP_Markdown_Native_SQL_Token::COMMA ) ); } diff --git a/tests/smoke-native-join-query.php b/tests/smoke-native-join-query.php index 3aa46be..128d6a3 100644 --- a/tests/smoke-native-join-query.php +++ b/tests/smoke-native-join-query.php @@ -74,6 +74,11 @@ public function read( WP_Markdown_Native_Table_Access $access ): iterable|WP_Mar $plan = ( new WP_Markdown_Native_Query_Parser() )->parse( $query ); $result = $runtime->execute( new WP_Markdown_Query_Request( $query ) ); $state = $result->wpdb_state(); +$aliased = $runtime->execute( new WP_Markdown_Query_Request( str_replace( 'tr.object_id, tt.taxonomy, t.slug', 'tr.object_id AS object_identity, tt.taxonomy, t.slug AS term_slug', $query ) ) ); +$expected_aliases = $result->corpus_result(); +$expected_aliases['rows'] = array_map( static fn( array $row ): array => array( 'object_identity' => $row['object_id'], 'taxonomy' => $row['taxonomy'], 'term_slug' => $row['slug'] ), $expected_aliases['rows'] ); +$expected_aliases['columns'][0]['name'] = 'object_identity'; +$expected_aliases['columns'][2]['name'] = 'term_slug'; $hinted = $runtime->execute( new WP_Markdown_Query_Request( str_replace( 'tr JOIN', 'tr FORCE INDEX (term_taxonomy_id) JOIN', $query ) ) ); $bad_hint = $runtime->execute( new WP_Markdown_Query_Request( str_replace( 'tr JOIN', 'tr FORCE INDEX (missing_index) JOIN', $query ) ) ); $joined_hint = $runtime->execute( new WP_Markdown_Query_Request( str_replace( 'tt ON', 'tt USE KEY FOR JOIN (PRIMARY) ON', $query ) ) ); @@ -219,6 +224,7 @@ public function read( WP_Markdown_Native_Table_Access $access ): iterable|WP_Mar $meta_result = ( new WP_Markdown_Native_Query_Runtime( $meta_registry ) )->execute( new WP_Markdown_Query_Request( $meta_query ) ); $checks = array( + 'plain column aliases preserve joined row order and source metadata' => $aliased->succeeded() && $expected_aliases === $aliased->corpus_result(), 'validated source index hints preserve taxonomy JOIN results' => $hinted->succeeded() && $result->corpus_result() === $hinted->corpus_result() && $joined_hint->succeeded() && $result->corpus_result() === $joined_hint->corpus_result(), 'unknown hinted indexes fail instead of silently executing' => ! $bad_hint->succeeded() && 'unsupported_index_hint' === ( $bad_hint->diagnostic()['reason'] ?? null ), 'empty USE hints preserve rows while conflicting USE and FORCE hints fail' => $empty_use_hint->succeeded() && $result->corpus_result() === $empty_use_hint->corpus_result() && ! $mixed_hints->succeeded(), From 5e9cfade470e1ae9a344fb9287b9432a7f971244 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 10 Sep 2026 16:17:30 -0400 Subject: [PATCH 45/53] fix(native): retain all explicit grouping columns --- ...lass-wp-markdown-native-query-executor.php | 22 +++++++------------ .../class-wp-markdown-native-query-parser.php | 6 ++++- tests/smoke-native-aggregates.php | 22 +++++++++++++++++++ 3 files changed, 35 insertions(+), 15 deletions(-) diff --git a/inc/native/class-wp-markdown-native-query-executor.php b/inc/native/class-wp-markdown-native-query-executor.php index 5ede95f..ecd8029 100644 --- a/inc/native/class-wp-markdown-native-query-executor.php +++ b/inc/native/class-wp-markdown-native-query-executor.php @@ -649,7 +649,7 @@ private function execute_query_plan( WP_Markdown_Native_Query_Plan $plan, bool $ } if ( array() !== $aggregates ) { if ( null !== $plan->group_by() ) { - return $this->grouped_aggregate_result( $groups, $plan->group_by(), $aggregates, $plan->having(), $plan->scalar_having(), $plan->table(), $schema, $plan->scalar_projection(), $plan->order_by(), $plan->limit_offset(), $plan->limit(), $plan->calculates_found_rows() ); + return $this->grouped_aggregate_result( $groups, $projection, $aggregates, $plan->having(), $plan->scalar_having(), $plan->table(), $schema, $plan->scalar_projection(), $plan->order_by(), $plan->limit_offset(), $plan->limit(), $plan->calculates_found_rows() ); } return $this->aggregate_result( $aggregate_state, $aggregates ); } @@ -730,13 +730,10 @@ private function aggregate_value( array $totals, string $function ): ?string { }; } - private function grouped_aggregate_result( array $groups, string $column, array $aggregates, array $having, array $scalar_having, string $table, WP_Markdown_Native_Table_Schema $schema, array $scalar_projection, array $orders, int $offset, int $limit, bool $calculates_found_rows ): WP_Markdown_Query_Result { + private function grouped_aggregate_result( array $groups, array $projection, array $aggregates, array $having, array $scalar_having, string $table, WP_Markdown_Native_Table_Schema $schema, array $scalar_projection, array $orders, int $offset, int $limit, bool $calculates_found_rows ): WP_Markdown_Query_Result { $rows = array(); foreach ( $groups as $group ) { - $row = array( $column => null === $group['value'] ? null : (string) $group['value'] ); - foreach ( $scalar_projection as $scalar ) { - $row[ $scalar['alias'] ] = $this->string_scalar( $this->evaluate_scalar( $scalar['expression'], $group['row'] ?? array(), $schema ) ); - } + $row = $this->string_row( $group['row'], $projection, $scalar_projection, $schema ); foreach ( $aggregates as $index => $aggregate ) { $row[ $aggregate['alias'] ] = $this->aggregate_value( $group['state'][ $index ] ?? array(), $aggregate['function'] ); } @@ -744,8 +741,6 @@ private function grouped_aggregate_result( array $groups, string $column, array $rows[] = $row; } } - $group_name = $scalar_projection[0]['alias'] ?? $column; - if ( $group_name !== $column ) { foreach ( $rows as &$row ) { $row[ $group_name ] = $row[ $column ]; unset( $row[ $column ] ); } unset( $row ); } if ( array() !== $orders ) { usort( $rows, function ( array $left, array $right ) use ( $orders ): int { foreach ( $orders as $order ) { @@ -758,12 +753,11 @@ private function grouped_aggregate_result( array $groups, string $column, array if ( $calculates_found_rows ) { $this->last_found_rows = count( $rows ); } $rows = array_values( array_slice( $rows, $offset, PHP_INT_MAX === $limit ? null : $limit ) ); $columns = array(); - if ( array() === $scalar_projection ) { - $columns[] = array( 'name' => $group_name, 'table' => $table, 'type' => $schema->column( $column )->type() ); - } else { - foreach ( $scalar_projection as $scalar ) { $columns[ $scalar['position'] ] = $this->scalar_projection_column( $scalar, $table, $schema ); } - ksort( $columns ); - $columns = array_values( $columns ); + foreach ( $projection as $column ) { + $columns[] = array( 'name' => $column, 'table' => $table, 'type' => $schema->column( $column )->type() ); + } + foreach ( $scalar_projection as $scalar ) { + array_splice( $columns, $scalar['position'], 0, array( $this->scalar_projection_column( $scalar, $table, $schema ) ) ); } foreach ( $aggregates as $aggregate ) { $columns[] = array( 'name' => $aggregate['alias'], 'table' => '', 'type' => 'GROUP_CONCAT' === $aggregate['function'] ? 253 : 8 ); } return WP_Markdown_Query_Result::selected( $rows, $columns ); diff --git a/inc/native/class-wp-markdown-native-query-parser.php b/inc/native/class-wp-markdown-native-query-parser.php index 7d8acf6..666ce5b 100644 --- a/inc/native/class-wp-markdown-native-query-parser.php +++ b/inc/native/class-wp-markdown-native-query-parser.php @@ -671,7 +671,11 @@ private function select( bool $nested ): WP_Markdown_Native_SQL_Select|WP_Markdo // grouping column. A wildcard over the grouped table qualifies // because its identity is the group. foreach ( $projection as $column ) { - $same_column = $column->name() === $group->name() && $column->qualifier() === $group->qualifier(); + $same_column = false; + foreach ( $group_expressions as $expression ) { + $identifier = 'column' === $expression->kind() ? $expression->identifier() : null; + $same_column = $same_column || ( null !== $identifier && $column->name() === $identifier->name() && $column->qualifier() === $identifier->qualifier() ); + } $grouped_wildcard = '*' === $column->name() && null !== $group->qualifier() && $column->qualifier() === $group->qualifier(); if ( ! $same_column && ! $grouped_wildcard ) { throw new WP_Markdown_Native_SQL_Parse_Error( diff --git a/tests/smoke-native-aggregates.php b/tests/smoke-native-aggregates.php index ef8afed..edb4406 100644 --- a/tests/smoke-native-aggregates.php +++ b/tests/smoke-native-aggregates.php @@ -42,8 +42,30 @@ function mdi_aggregate_row( WP_Markdown_Native_Query_Runtime $runtime, string $s $textual = mdi_aggregate_row( $runtime, 'SELECT SUM(kind) AS total FROM wp_items' ); $default_names = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT MAX(score), COUNT(score) FROM wp_items', 'wp_' ) ); $default_empty = $runtime->execute( new WP_Markdown_Query_Request( "SELECT MAX(score), COUNT(score) FROM wp_items WHERE kind = 'missing'", 'wp_' ) ); +$grouped = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT kind, score, COUNT(id) AS n FROM wp_items GROUP BY kind, score' ) ); +$grouped_alias = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT kind, score AS points, COUNT(id) AS n FROM wp_items GROUP BY kind, score' ) ); +$grouped_join = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT a.kind, b.score, COUNT(a.id) AS n FROM wp_items a JOIN wp_items b ON a.id = b.id GROUP BY a.kind, b.score' ) ); +$grouped_rows = $grouped->corpus_result()['rows']; +$alias_rows = $grouped_alias->corpus_result()['rows']; +$joined_rows = $grouped_join->corpus_result()['rows']; +usort( $grouped_rows, static fn( array $left, array $right ): int => (int) $left['score'] <=> (int) $right['score'] ); +usort( $alias_rows, static fn( array $left, array $right ): int => (int) $left['points'] <=> (int) $right['points'] ); +usort( $joined_rows, static fn( array $left, array $right ): int => (int) $left['score'] <=> (int) $right['score'] ); $checks = array( + 'multiple explicit grouping columns preserve every projected value' => $grouped->succeeded() && array( + array( 'kind' => 'c', 'score' => null, 'n' => '1' ), + array( 'kind' => 'a', 'score' => '10', 'n' => '1' ), + array( 'kind' => 'b', 'score' => '20', 'n' => '1' ), + array( 'kind' => 'a', 'score' => '30', 'n' => '1' ), + ) === $grouped_rows, + 'aliased grouping columns retain their position and numeric type' => $grouped_alias->succeeded() && array( 'kind' => 'a', 'points' => '10', 'n' => '1' ) === $alias_rows[1] && '8' === $grouped_alias->corpus_result()['columns'][1]['type'], + 'joined grouping keys include all explicit columns' => $grouped_join->succeeded() && array( + array( 'kind' => 'c', 'score' => null, 'n' => '1' ), + array( 'kind' => 'a', 'score' => '10', 'n' => '1' ), + array( 'kind' => 'b', 'score' => '20', 'n' => '1' ), + array( 'kind' => 'a', 'score' => '30', 'n' => '1' ), + ) === $joined_rows, 'one row reports every ungrouped aggregate' => array( 'total' => '60', 'mean' => '20', 'lowest' => '10', 'highest' => '30' ) === $totals, 'COUNT over a column skips its NULL rows' => array( 'scored' => '3' ) === $counts, 'COUNT over rows keeps them' => '4' === ( $all_rows['COUNT(*)'] ?? null ), From 08f02037a4435ab7a61e1319e34baeaee5e992b4 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 10 Sep 2026 16:44:12 -0400 Subject: [PATCH 46/53] fix(native): honor SQL mode for omitted column defaults --- README.md | 16 +++++ ...lass-wp-markdown-native-query-executor.php | 10 +++- ...class-wp-markdown-native-query-runtime.php | 30 +++++++--- ...p-markdown-native-schema-introspection.php | 5 +- .../class-wp-markdown-native-sql-session.php | 60 +++++++++++++++++++ ...ass-wp-markdown-native-table-mutations.php | 22 ++++++- tests/smoke-native-sql-mode-defaults.php | 43 +++++++++++++ tests/smoke-native-table-insert.php | 4 +- 8 files changed, 175 insertions(+), 15 deletions(-) create mode 100644 inc/native/class-wp-markdown-native-sql-session.php create mode 100644 tests/smoke-native-sql-mode-defaults.php diff --git a/README.md b/README.md index 42d3dcd..645df38 100644 --- a/README.md +++ b/README.md @@ -824,3 +824,19 @@ Tested on WordPress 6.9 with SQLite-backed local and Playground-style runtimes: ## License GPL v2 or later. +## Native SQL Mode Defaults + +The native WordPress connection starts with an empty SQL mode, matching its +existing non-strict `SHOW VARIABLES` contract. Generic table inserts use MySQL +implicit defaults for supported numeric, string and temporal columns when an +explicit default is absent. Nullable columns remain NULL; explicit defaults +take precedence. Missing implicit defaults produce bounded `SHOW WARNINGS` +records and an exact `@@warning_count`. + +`SET [SESSION] sql_mode` and `SELECT @@[SESSION.]sql_mode` support the empty +mode, `STRICT_TRANS_TABLES`, `STRICT_ALL_TABLES` and `NO_ENGINE_SUBSTITUTION`. +Strict mode rejects an omitted required column with error 1364. Modes whose +additional semantics are not implemented are rejected without changing the +session. This is not a claim of complete MySQL coercion or SQL-mode coverage. +The mode is connection-local, survives blog switches and resets on logical +close; it is not persisted in canonical storage. diff --git a/inc/native/class-wp-markdown-native-query-executor.php b/inc/native/class-wp-markdown-native-query-executor.php index ecd8029..b21d314 100644 --- a/inc/native/class-wp-markdown-native-query-executor.php +++ b/inc/native/class-wp-markdown-native-query-executor.php @@ -56,10 +56,11 @@ public function __construct( private ?WP_Markdown_Native_Post_Mutation_Runtime $post_mutations = null, private int $correlated_subquery_limit = self::MAX_CORRELATED_SUBQUERY_EVALUATIONS, private ?WP_Markdown_Native_Advisory_Locks $advisory_locks = null, - ?string $database_name = null + ?string $database_name = null, + private WP_Markdown_Native_SQL_Session $session = new WP_Markdown_Native_SQL_Session() ) { $this->database_name = $database_name; - $this->schema_introspection = new WP_Markdown_Native_Schema_Introspection( $registry, database_name: $database_name ); + $this->schema_introspection = new WP_Markdown_Native_Schema_Introspection( $registry, database_name: $database_name, session: $this->session ); } public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query_Result { @@ -99,6 +100,10 @@ private function execute_request( WP_Markdown_Query_Request $request ): WP_Markd $this->correlated_subquery_cache = array(); $this->correlated_subquery_failure = null; $this->statement_now = gmdate( 'Y-m-d H:i:s' ); + $session_result = $this->session->execute( $request->sql() ); + if ( null !== $session_result ) { + return $session_result; + } $transaction_control = WP_Markdown_SQL_Classifier::transaction_control( $request->sql() ); if ( null !== $transaction_control ) { return $this->execute_transaction_control( $transaction_control ); @@ -319,6 +324,7 @@ private function tableless_scalar_type( WP_Markdown_Native_Query_Scalar_Expressi /** Release this logical connection's root-scoped advisory locks. */ public function close(): void { $this->advisory_locks?->close(); + $this->session->reset(); } private function advisory_lock_query( string $sql ): ?WP_Markdown_Query_Result { diff --git a/inc/native/class-wp-markdown-native-query-runtime.php b/inc/native/class-wp-markdown-native-query-runtime.php index 90fc9df..d92b994 100644 --- a/inc/native/class-wp-markdown-native-query-runtime.php +++ b/inc/native/class-wp-markdown-native-query-runtime.php @@ -7,6 +7,7 @@ require_once __DIR__ . '/../class-wp-markdown-canonical-option-path.php'; require_once __DIR__ . '/class-wp-markdown-native-query-contracts.php'; +require_once __DIR__ . '/class-wp-markdown-native-sql-session.php'; require_once __DIR__ . '/class-wp-markdown-native-query-schema.php'; require_once __DIR__ . '/class-wp-markdown-native-schema-catalog.php'; require_once __DIR__ . '/class-wp-markdown-native-sql-tokenizer.php'; @@ -253,8 +254,10 @@ public static function runtime( ?string $global_state_root = null, ?string $global_content_root = null, ?WP_Markdown_Native_Advisory_Locks $advisory_locks = null, - ?string $transaction_state_root = null + ?string $transaction_state_root = null, + ?WP_Markdown_Native_SQL_Session $session = null ): WP_Markdown_Native_Query_Runtime { + $session ??= new WP_Markdown_Native_SQL_Session(); $state_root = self::materialize_state_root( $state_root ); if ( null !== $content_root ) { $content_root = self::materialize_state_root( $content_root ); @@ -292,7 +295,7 @@ public static function runtime( new WP_Markdown_Native_Query_Parser(), new WP_Markdown_Native_Option_Mutation_Runtime( $state_root, new WP_Markdown_Native_Option_Mutation_Parser(), $transactions ), new WP_Markdown_Native_Schema_Mutation_Runtime( $state_root, $registry, $transactions, $core_registrar, $temporary_tables ), - new WP_Markdown_Native_Table_Mutation_Runtime( $state_root, $registry, $parser, $transactions, $temporary_tables ), + new WP_Markdown_Native_Table_Mutation_Runtime( $state_root, $registry, $parser, $transactions, $temporary_tables, $session ), $transactions, new WP_Markdown_Native_Post_Mutation_Runtime( $registry, @@ -300,7 +303,8 @@ public static function runtime( self::shared_storage( $content_root ?? $state_root, $multisite && $prefix === $resolved_base ), $transactions, ), - advisory_locks: $advisory_locks ?? new WP_Markdown_Native_Advisory_Locks( $state_root ) + advisory_locks: $advisory_locks ?? new WP_Markdown_Native_Advisory_Locks( $state_root ), + session: $session ); } @@ -647,7 +651,8 @@ final class WP_Markdown_Native_Prefix_Query_Runtime implements WP_Markdown_Query public function __construct( private string $state_root, - private string $content_root + private string $content_root, + private WP_Markdown_Native_SQL_Session $session = new WP_Markdown_Native_SQL_Session() ) { $this->advisory_locks = new WP_Markdown_Native_Advisory_Locks( $state_root ); } @@ -661,7 +666,8 @@ public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query $prefix, false, $this->content_root, - advisory_locks: $this->advisory_locks + advisory_locks: $this->advisory_locks, + session: $this->session ); } return $this->runtimes[ $prefix ]->execute( $request ); @@ -669,6 +675,7 @@ public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query public function close(): void { $this->advisory_locks->close(); + $this->session->reset(); } } @@ -678,9 +685,11 @@ final class WP_Markdown_Native_WordPress_Query_Runtime implements WP_Markdown_Qu private WP_Markdown_Native_Prefix_Query_Runtime $prefix_runtime; /** @var array */ private array $multisite_runtimes = array(); + private WP_Markdown_Native_SQL_Session $session; public function __construct( string $state_root, private string $base_prefix, string $content_root ) { - $this->prefix_runtime = new WP_Markdown_Native_Prefix_Query_Runtime( $state_root, $content_root ); + $this->session = new WP_Markdown_Native_SQL_Session(); + $this->prefix_runtime = new WP_Markdown_Native_Prefix_Query_Runtime( $state_root, $content_root, $this->session ); $this->state_root = $state_root; $this->content_root = $content_root; } @@ -697,7 +706,7 @@ public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query ? $GLOBALS['wpdb']->base_prefix : $this->base_prefix; if ( ! isset( $this->multisite_runtimes[ $base_prefix ] ) ) { - $this->multisite_runtimes[ $base_prefix ] = new WP_Markdown_Native_Multisite_Query_Runtime( $this->state_root, $base_prefix, $this->content_root ); + $this->multisite_runtimes[ $base_prefix ] = new WP_Markdown_Native_Multisite_Query_Runtime( $this->state_root, $base_prefix, $this->content_root, $this->session ); } return $this->multisite_runtimes[ $base_prefix ]->execute( $request ); } @@ -722,7 +731,8 @@ final class WP_Markdown_Native_Multisite_Query_Runtime implements WP_Markdown_Qu public function __construct( string $state_root, private string $base_prefix, - string $content_root + string $content_root, + private WP_Markdown_Native_SQL_Session $session = new WP_Markdown_Native_SQL_Session() ) { if ( 1 !== preg_match( '/^[A-Za-z0-9_]+$/D', $base_prefix ) ) { throw new InvalidArgumentException( 'The base table prefix contains unsupported characters.' ); @@ -764,7 +774,8 @@ public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query $this->state_root, $this->content_root, $this->advisory_locks, - $this->state_root + $this->state_root, + $this->session ); } catch ( Throwable ) { return WP_Markdown_Query_Result::failure( @@ -781,6 +792,7 @@ public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query public function close(): void { $this->advisory_locks->close(); + $this->session->reset(); } private function is_scope_prefix( string $prefix ): bool { diff --git a/inc/native/class-wp-markdown-native-schema-introspection.php b/inc/native/class-wp-markdown-native-schema-introspection.php index 0230c4e..3deb3cc 100644 --- a/inc/native/class-wp-markdown-native-schema-introspection.php +++ b/inc/native/class-wp-markdown-native-schema-introspection.php @@ -230,7 +230,8 @@ final class WP_Markdown_Native_Schema_Introspection { public function __construct( private readonly WP_Markdown_Native_Table_Registry $registry, private readonly WP_Markdown_Native_Schema_Introspection_Parser $parser = new WP_Markdown_Native_Schema_Introspection_Parser(), - private readonly ?string $database_name = null + private readonly ?string $database_name = null, + private readonly ?WP_Markdown_Native_SQL_Session $session = null ) {} public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query_Result { @@ -560,7 +561,7 @@ private function server_values( string $operation, ?string $pattern, array $name ? array( 'version' => WP_Markdown_Native_Schema_Catalog::SERVER_VERSION, 'version_comment' => 'Markdown Database Integration native engine', - 'sql_mode' => '', + 'sql_mode' => $this->session?->sql_mode() ?? '', 'character_set_server' => 'utf8mb4', 'collation_server' => 'utf8mb4_general_ci', 'foreign_key_checks' => 'ON', diff --git a/inc/native/class-wp-markdown-native-sql-session.php b/inc/native/class-wp-markdown-native-sql-session.php new file mode 100644 index 0000000..37e057f --- /dev/null +++ b/inc/native/class-wp-markdown-native-sql-session.php @@ -0,0 +1,60 @@ +sql_mode; } + + public function strict(): bool { + return in_array( 'STRICT_TRANS_TABLES', explode( ',', $this->sql_mode ), true ) || in_array( 'STRICT_ALL_TABLES', explode( ',', $this->sql_mode ), true ); + } + + public function reset(): void { + $this->sql_mode = ''; + $this->warnings = array(); + $this->warning_count = 0; + } + + public function warn_missing_default( string $column ): void { + ++$this->warning_count; + if ( count( $this->warnings ) < 64 ) { + $this->warnings[] = array( 'Level' => 'Warning', 'Code' => '1364', 'Message' => "Field '{$column}' doesn't have a default value" ); + } + } + + /** Handle supported session statements, otherwise start a new diagnostic area. */ + public function execute( string $sql ): ?WP_Markdown_Query_Result { + if ( 1 === preg_match( '/^\s*SHOW\s+WARNINGS\s*;?\s*$/i', $sql ) ) { + return WP_Markdown_Query_Result::selected( $this->warnings, array( + array( 'name' => 'Level', 'table' => '', 'type' => 253 ), + array( 'name' => 'Code', 'table' => '', 'type' => 3 ), + array( 'name' => 'Message', 'table' => '', 'type' => 253 ), + ) ); + } + if ( 1 === preg_match( '/^\s*SELECT\s+(@@(?:SESSION\.)?warning_count)\s*;?\s*$/i', $sql, $match ) ) { + return WP_Markdown_Query_Result::selected( array( array( $match[1] => (string) $this->warning_count ) ), array( array( 'name' => $match[1], 'table' => '', 'type' => 8 ) ) ); + } + $this->warnings = array(); + $this->warning_count = 0; + if ( 1 === preg_match( '/^\s*SELECT\s+(@@(?:SESSION\.)?sql_mode)\s*;?\s*$/i', $sql, $match ) ) { + return WP_Markdown_Query_Result::selected( array( array( $match[1] => $this->sql_mode ) ), array( array( 'name' => $match[1], 'table' => '', 'type' => 253 ) ) ); + } + if ( 1 !== preg_match( "/^\\s*SET\\s+(?:SESSION\\s+|@@(?:SESSION\\.)?)?sql_mode\\s*=\\s*'([A-Za-z_, ]*)'\\s*;?\\s*$/i", $sql, $match ) ) { + return null; + } + $modes = array_values( array_unique( array_filter( array_map( static fn( string $mode ): string => strtoupper( trim( $mode ) ), explode( ',', $match[1] ) ) ) ) ); + $supported = array( 'STRICT_TRANS_TABLES', 'STRICT_ALL_TABLES', 'NO_ENGINE_SUBSTITUTION' ); + if ( array_diff( $modes, $supported ) ) { + return WP_Markdown_Query_Result::failure( array( 'code' => 'markdown_db_native_unsupported_query', 'reason' => 'unsupported_sql_mode', 'message' => 'The requested SQL mode includes semantics not implemented by the native session.' ) ); + } + $this->sql_mode = implode( ',', array_values( array_intersect( $supported, $modes ) ) ); + return WP_Markdown_Query_Result::mutated( 0 ); + } +} diff --git a/inc/native/class-wp-markdown-native-table-mutations.php b/inc/native/class-wp-markdown-native-table-mutations.php index afcc90a..eb206f5 100644 --- a/inc/native/class-wp-markdown-native-table-mutations.php +++ b/inc/native/class-wp-markdown-native-table-mutations.php @@ -21,7 +21,8 @@ public function __construct( private WP_Markdown_Native_Table_Registry $registry, private WP_Markdown_Native_Table_Insert_Parser $parser = new WP_Markdown_Native_Table_Insert_Parser(), private ?WP_Markdown_Native_Transaction_Journal $transactions = null, - private ?WP_Markdown_Native_Temporary_Tables $temporary_tables = null + private ?WP_Markdown_Native_Temporary_Tables $temporary_tables = null, + private WP_Markdown_Native_SQL_Session $session = new WP_Markdown_Native_SQL_Session() ) { $root = realpath( $state_root ); if ( false === $root || ! is_dir( $root ) ) { @@ -376,6 +377,25 @@ private function complete_row( array $provided, array $definition, array $rows, $row[ $name ] = null; continue; } + if ( ! $this->session->strict() ) { + $type = strtolower( $column['type'] ?? '' ); + $implicit = match ( $type ) { + 'tinyint', 'smallint', 'mediumint', 'int', 'integer', 'bigint', 'decimal', 'numeric', 'float', 'double', 'real', 'year' => '0', + 'char', 'varchar', 'tinytext', 'text', 'mediumtext', 'longtext', 'tinyblob', 'blob', 'mediumblob', 'longblob', 'varbinary' => '', + 'date' => '0000-00-00', + 'datetime', 'timestamp' => '0000-00-00 00:00:00', + 'time' => '00:00:00', + default => null, + }; + if ( null !== $implicit ) { + $row[ $name ] = $implicit; + $this->session->warn_missing_default( $name ); + continue; + } + } + if ( $this->session->strict() ) { + return WP_Markdown_Query_Result::failure( array( 'code' => 1364, 'reason' => 'missing_required_column', 'message' => "Field '{$name}' doesn't have a default value" ) ); + } return $this->failure( 'missing_required_column', 'The INSERT omits a required column without a deterministic default.' ); } return $row; diff --git a/tests/smoke-native-sql-mode-defaults.php b/tests/smoke-native-sql-mode-defaults.php new file mode 100644 index 0000000..46dc011 --- /dev/null +++ b/tests/smoke-native-sql-mode-defaults.php @@ -0,0 +1,43 @@ + $runtime->execute( new WP_Markdown_Query_Request( $sql ) ); + $created = $run( 'CREATE TABLE wp_defaults (id bigint unsigned NOT NULL AUTO_INCREMENT, payload longtext NOT NULL, amount int NOT NULL, optional_value varchar(20) NULL, explicit_value varchar(20) NOT NULL DEFAULT \'chosen\', PRIMARY KEY (id))' ); + $inserted = $run( 'INSERT INTO wp_defaults (id) VALUES (1)' ); + $warnings = $run( 'SHOW WARNINGS' ); + $count = $run( 'SELECT @@warning_count' ); + $row = $run( 'SELECT * FROM wp_defaults WHERE id = 1' )->corpus_result()['rows'][0] ?? null; + $checks['non-strict defaults preserve nullable and explicit defaults'] = $created->succeeded() && 1 === $inserted->return_value() && array( 'id' => '1', 'payload' => '', 'amount' => '0', 'optional_value' => null, 'explicit_value' => 'chosen' ) === $row; + $checks['implicit defaults report bounded MySQL warnings'] = 2 === $warnings->return_value() && '1364' === $warnings->corpus_result()['rows'][0]['Code'] && '2' === $count->corpus_result()['rows'][0]['@@warning_count']; + $set = $run( "SET SESSION sql_mode = 'strict_trans_tables,NO_ENGINE_SUBSTITUTION'" ); + $mode = $run( 'SELECT @@SESSION.sql_mode' ); + $variables = $run( "SHOW VARIABLES LIKE 'sql_mode'" ); + $checks['session and SHOW introspection agree on supported mode'] = $set->succeeded() && 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION' === $mode->corpus_result()['rows'][0]['@@SESSION.sql_mode'] && 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION' === $variables->corpus_result()['rows'][0]['Value']; + $rejected = $run( 'INSERT INTO wp_defaults (id) VALUES (2)' ); + $checks['strict omission fails with MySQL errno and no row'] = false === $rejected->return_value() && 1364 === $rejected->diagnostic()['code'] && 0 === $run( 'SELECT * FROM wp_defaults WHERE id = 2' )->return_value(); + $invalid = $run( "SET sql_mode = 'ANSI_QUOTES'" ); + $checks['unsupported mode fails without changing existing mode'] = ! $invalid->succeeded() && 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION' === $run( 'SELECT @@sql_mode' )->corpus_result()['rows'][0]['@@sql_mode']; + $run( "SET @@SESSION.sql_mode = ''" ); + $checks['clearing strict mode restores implicit defaults'] = 1 === $run( 'INSERT INTO wp_defaults (id) VALUES (2)' )->return_value(); + $multisite = WP_Markdown_Native_Runtime_Factory::multisite_runtime( $root ); + $multisite->execute( new WP_Markdown_Query_Request( "SET sql_mode = 'STRICT_ALL_TABLES'", 'wp_' ) ); + $site_mode = $multisite->execute( new WP_Markdown_Query_Request( 'SELECT @@sql_mode', 'wp_2_' ) ); + $checks['blog switching shares mode but separate connections do not'] = 'STRICT_ALL_TABLES' === $site_mode->corpus_result()['rows'][0]['@@sql_mode'] && '' === $run( 'SELECT @@sql_mode' )->corpus_result()['rows'][0]['@@sql_mode']; + $multisite->close(); + $checks['logical close resets SQL session mode'] = '' === $multisite->execute( new WP_Markdown_Query_Request( 'SELECT @@sql_mode', 'wp_' ) )->corpus_result()['rows'][0]['@@sql_mode']; +} finally { + foreach ( new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $root, FilesystemIterator::SKIP_DOTS ), RecursiveIteratorIterator::CHILD_FIRST ) as $entry ) { + $entry->isDir() ? rmdir( $entry->getPathname() ) : unlink( $entry->getPathname() ); + } + rmdir( $root ); +} +foreach ( $checks as $label => $passed ) { fwrite( $passed ? STDOUT : STDERR, ( $passed ? 'PASS: ' : 'FAIL: ' ) . $label . "\n" ); } +exit( in_array( false, $checks, true ) ? 1 : 0 ); diff --git a/tests/smoke-native-table-insert.php b/tests/smoke-native-table-insert.php index 4afeb76..5239f72 100644 --- a/tests/smoke-native-table-insert.php +++ b/tests/smoke-native-table-insert.php @@ -35,7 +35,9 @@ function mdi_native_insert_remove_tree( string $root ): void { $first = $runtime->execute( new WP_Markdown_Query_Request( "INSERT INTO `wp_plugin_jobs` (`job_id`, `hook`, `payload`) VALUES (0, 'first_job', NULL)" ) ); $second = $runtime->execute( new WP_Markdown_Query_Request( "INSERT INTO wp_plugin_jobs (job_id, hook, priority) VALUES (8, 'second_job', 2);" ) ); $duplicate = $runtime->execute( new WP_Markdown_Query_Request( "INSERT INTO wp_plugin_jobs (job_id, hook) VALUES (8, 'duplicate_job')" ) ); +$runtime->execute( new WP_Markdown_Query_Request( "SET sql_mode = 'STRICT_TRANS_TABLES'" ) ); $missing = $runtime->execute( new WP_Markdown_Query_Request( 'INSERT INTO wp_plugin_jobs (priority) VALUES (3)' ) ); +$runtime->execute( new WP_Markdown_Query_Request( "SET sql_mode = ''" ) ); $multi = $runtime->execute( new WP_Markdown_Query_Request( "INSERT INTO wp_plugin_jobs (hook) VALUES ('third'); INSERT INTO wp_plugin_jobs (hook) VALUES ('fourth')" ) ); $selected = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT * FROM wp_plugin_jobs ORDER BY job_id ASC' ) ); $reloaded = WP_Markdown_Native_Runtime_Factory::runtime( $root )->execute( new WP_Markdown_Query_Request( "SELECT hook, priority FROM wp_plugin_jobs WHERE job_id IN (1, 8) ORDER BY job_id ASC" ) ); @@ -66,7 +68,7 @@ function mdi_native_insert_remove_tree( string $root ): void { 'persisted rows retain schema order and typed values' => array( '1', '8' ) === array_map( static fn( object $row ): string => $row->job_id, $selected->wpdb_state()['last_result'] ) && '10' === $selected->wpdb_state()['last_result'][0]->priority && null === $selected->wpdb_state()['last_result'][0]->payload, - 'unique conflicts and missing required columns fail without mutation' => false === $duplicate->return_value() + 'unique conflicts and strict-mode missing required columns fail without mutation' => false === $duplicate->return_value() && 'duplicate_key' === ( $duplicate->diagnostic()['reason'] ?? null ) && false === $missing->return_value() && 'missing_required_column' === ( $missing->diagnostic()['reason'] ?? null ) From 904d4277397c81911280ab2472388aa6bf6f2b2e Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 10 Sep 2026 16:48:14 -0400 Subject: [PATCH 47/53] fix(native): retain strict missing-default diagnostics --- .../class-wp-markdown-native-sql-session.php | 4 ++-- .../class-wp-markdown-native-table-mutations.php | 15 ++++++++++++--- tests/smoke-native-sql-mode-defaults.php | 2 ++ 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/inc/native/class-wp-markdown-native-sql-session.php b/inc/native/class-wp-markdown-native-sql-session.php index 37e057f..1e2015e 100644 --- a/inc/native/class-wp-markdown-native-sql-session.php +++ b/inc/native/class-wp-markdown-native-sql-session.php @@ -22,10 +22,10 @@ public function reset(): void { $this->warning_count = 0; } - public function warn_missing_default( string $column ): void { + public function warn_missing_default( string $column, bool $error = false ): void { ++$this->warning_count; if ( count( $this->warnings ) < 64 ) { - $this->warnings[] = array( 'Level' => 'Warning', 'Code' => '1364', 'Message' => "Field '{$column}' doesn't have a default value" ); + $this->warnings[] = array( 'Level' => $error ? 'Error' : 'Warning', 'Code' => '1364', 'Message' => "Field '{$column}' doesn't have a default value" ); } } diff --git a/inc/native/class-wp-markdown-native-table-mutations.php b/inc/native/class-wp-markdown-native-table-mutations.php index eb206f5..bacd63f 100644 --- a/inc/native/class-wp-markdown-native-table-mutations.php +++ b/inc/native/class-wp-markdown-native-table-mutations.php @@ -331,6 +331,18 @@ private function complete_row( array $provided, array $definition, array $rows, if ( array_diff_key( $provided, $definition['columns'] ) ) { return $this->failure( 'unsupported_column', 'The INSERT references an undeclared column.' ); } + if ( $this->session->strict() ) { + $missing = array(); + foreach ( $definition['columns'] as $name => $column ) { + if ( ! array_key_exists( $name, $provided ) && empty( $column['auto_increment'] ) && empty( $column['nullable'] ) && null === ( $column['default'] ?? null ) ) { + $missing[] = $name; + $this->session->warn_missing_default( $name, true ); + } + } + if ( array() !== $missing ) { + return WP_Markdown_Query_Result::failure( array( 'code' => 1364, 'reason' => 'missing_required_column', 'message' => "Field '{$missing[0]}' doesn't have a default value" ) ); + } + } $row = array(); foreach ( $definition['columns'] as $name => $column ) { $generate_identity = true === ( $column['auto_increment'] ?? false ) @@ -393,9 +405,6 @@ private function complete_row( array $provided, array $definition, array $rows, continue; } } - if ( $this->session->strict() ) { - return WP_Markdown_Query_Result::failure( array( 'code' => 1364, 'reason' => 'missing_required_column', 'message' => "Field '{$name}' doesn't have a default value" ) ); - } return $this->failure( 'missing_required_column', 'The INSERT omits a required column without a deterministic default.' ); } return $row; diff --git a/tests/smoke-native-sql-mode-defaults.php b/tests/smoke-native-sql-mode-defaults.php index 46dc011..3193089 100644 --- a/tests/smoke-native-sql-mode-defaults.php +++ b/tests/smoke-native-sql-mode-defaults.php @@ -22,6 +22,8 @@ $variables = $run( "SHOW VARIABLES LIKE 'sql_mode'" ); $checks['session and SHOW introspection agree on supported mode'] = $set->succeeded() && 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION' === $mode->corpus_result()['rows'][0]['@@SESSION.sql_mode'] && 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION' === $variables->corpus_result()['rows'][0]['Value']; $rejected = $run( 'INSERT INTO wp_defaults (id) VALUES (2)' ); + $errors = $run( 'SHOW WARNINGS' ); + $checks['strict omissions retain every missing-default error'] = 2 === $errors->return_value() && 'Error' === $errors->corpus_result()['rows'][0]['Level'] && '1364' === $errors->corpus_result()['rows'][1]['Code']; $checks['strict omission fails with MySQL errno and no row'] = false === $rejected->return_value() && 1364 === $rejected->diagnostic()['code'] && 0 === $run( 'SELECT * FROM wp_defaults WHERE id = 2' )->return_value(); $invalid = $run( "SET sql_mode = 'ANSI_QUOTES'" ); $checks['unsupported mode fails without changing existing mode'] = ! $invalid->succeeded() && 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION' === $run( 'SELECT @@sql_mode' )->corpus_result()['rows'][0]['@@sql_mode']; From 243b06f3f5c2338d42cc8dcfddc0c1eef449e3ff Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 10 Sep 2026 17:10:32 -0400 Subject: [PATCH 48/53] fix(native): bind unqualified joined columns by schema --- ...lass-wp-markdown-native-query-executor.php | 2 +- .../class-wp-markdown-native-query-parser.php | 157 ++++++++++++------ tests/smoke-native-join-query.php | 13 +- 3 files changed, 120 insertions(+), 52 deletions(-) diff --git a/inc/native/class-wp-markdown-native-query-executor.php b/inc/native/class-wp-markdown-native-query-executor.php index b21d314..5f7ef1c 100644 --- a/inc/native/class-wp-markdown-native-query-executor.php +++ b/inc/native/class-wp-markdown-native-query-executor.php @@ -178,7 +178,7 @@ private function execute_unlocked_request( WP_Markdown_Query_Request $request ): } $start = WP_Markdown_Operation_Profile::begin(); try { - $plan = $this->parser->parse( $request->sql() ); + $plan = $this->parser->parse( $request->sql(), fn( string $table ): array => array_keys( $this->registry->definition( $table )['columns'] ?? array() ) ); } finally { WP_Markdown_Operation_Profile::end( 'select_parse', $start ); } diff --git a/inc/native/class-wp-markdown-native-query-parser.php b/inc/native/class-wp-markdown-native-query-parser.php index 666ce5b..1a4c16a 100644 --- a/inc/native/class-wp-markdown-native-query-parser.php +++ b/inc/native/class-wp-markdown-native-query-parser.php @@ -10,14 +10,14 @@ public function __construct( private WP_Markdown_Native_SQL_Tokenizer $tokenizer = new WP_Markdown_Native_SQL_Tokenizer() ) {} - public function parse( string $sql ): WP_Markdown_Native_Query_Plan|WP_Markdown_Native_Found_Rows_Plan|WP_Markdown_Query_Result { + public function parse( string $sql, ?callable $table_columns = null ): WP_Markdown_Native_Query_Plan|WP_Markdown_Native_Found_Rows_Plan|WP_Markdown_Query_Result { self::trace_runtime_phase( 'parser', $sql ); $ast = $this->parse_ast( $sql ); if ( $ast instanceof WP_Markdown_Query_Result ) { return $ast; } try { - return $this->lower( $ast ); + return $this->lower( $ast, array(), $table_columns ); } catch ( WP_Markdown_Native_SQL_Parse_Error $error ) { return $this->failure( $error->reason(), $error->getMessage(), $error->sql_offset() ); } @@ -113,7 +113,7 @@ public function parse_ast( string $sql ): WP_Markdown_Native_SQL_Select|WP_Markd } /** @param array $outer_sources */ - public function lower( WP_Markdown_Native_SQL_Select|WP_Markdown_Native_SQL_Found_Rows $ast, array $outer_sources = array() ): WP_Markdown_Native_Query_Plan|WP_Markdown_Native_Found_Rows_Plan|WP_Markdown_Query_Result { + public function lower( WP_Markdown_Native_SQL_Select|WP_Markdown_Native_SQL_Found_Rows $ast, array $outer_sources = array(), ?callable $table_columns = null ): WP_Markdown_Native_Query_Plan|WP_Markdown_Native_Found_Rows_Plan|WP_Markdown_Query_Result { if ( $ast instanceof WP_Markdown_Native_SQL_Found_Rows ) { return new WP_Markdown_Native_Found_Rows_Plan(); } @@ -123,6 +123,7 @@ public function lower( WP_Markdown_Native_SQL_Select|WP_Markdown_Native_SQL_Foun return $this->failure( 'unsupported_select_modifier', 'DISTINCT requires a row projection.', $ast->table()->sql_offset() ); } $base_source = array() === $ast->joins() ? null : ( $ast->alias()?->name() ?? $ast->table()->name() ); + $bindings = null === $table_columns || null === $base_source ? array() : $this->column_bindings( $ast, $table_columns ); $flat_source = array() === $ast->joins() ? ( $ast->alias()?->name() ?? $ast->table()->name() ) : null; $child_outer_sources = $outer_sources; $child_outer_sources[ $ast->alias()?->name() ?? $ast->table()->name() ] = true; @@ -132,21 +133,21 @@ public function lower( WP_Markdown_Native_SQL_Select|WP_Markdown_Native_SQL_Foun : array_map( static fn( WP_Markdown_Native_SQL_Identifier $column ): string => $column->name(), $ast->projection() ); $scalar_projection = array_map( fn( array $scalar ): array => array( - 'expression' => $this->lower_scalar_expression( $scalar['expression'], $base_source, $flat_source ), + 'expression' => $this->lower_scalar_expression( $scalar['expression'], $base_source, $flat_source, $bindings ), 'alias' => $scalar['alias'], 'position' => $scalar['position'], ), $ast->scalar_projection() ); - $scalar_predicates = array_map( fn( WP_Markdown_Native_SQL_Scalar_Predicate $predicate ): WP_Markdown_Native_Query_Scalar_Predicate => $this->lower_scalar_predicate( $predicate, $base_source, $flat_source ), $ast->scalar_predicates() ); - $boolean_predicate = null === $ast->boolean_predicate() ? null : new WP_Markdown_Native_Query_Boolean_Predicate( array_map( function ( array $group ) use ( $base_source, $flat_source, $child_outer_sources ): array { - return array_map( function ( WP_Markdown_Native_SQL_Predicate|WP_Markdown_Native_SQL_Scalar_Predicate|WP_Markdown_Native_SQL_Subquery_Predicate $predicate ) use ( $base_source, $flat_source, $child_outer_sources ): WP_Markdown_Native_Query_Predicate|WP_Markdown_Native_Query_Scalar_Predicate|WP_Markdown_Native_Query_Subquery { - if ( $predicate instanceof WP_Markdown_Native_SQL_Scalar_Predicate ) { return $this->lower_scalar_predicate( $predicate, $base_source, $flat_source ); } - if ( $predicate instanceof WP_Markdown_Native_SQL_Subquery_Predicate ) { return $this->lower_subquery( $predicate, $child_outer_sources ); } - return $this->lower_predicate( $predicate, $base_source ); + $scalar_predicates = array_map( fn( WP_Markdown_Native_SQL_Scalar_Predicate $predicate ): WP_Markdown_Native_Query_Scalar_Predicate => $this->lower_scalar_predicate( $predicate, $base_source, $flat_source, $bindings ), $ast->scalar_predicates() ); + $boolean_predicate = null === $ast->boolean_predicate() ? null : new WP_Markdown_Native_Query_Boolean_Predicate( array_map( function ( array $group ) use ( $base_source, $flat_source, $child_outer_sources, $bindings, $table_columns ): array { + return array_map( function ( WP_Markdown_Native_SQL_Predicate|WP_Markdown_Native_SQL_Scalar_Predicate|WP_Markdown_Native_SQL_Subquery_Predicate $predicate ) use ( $base_source, $flat_source, $child_outer_sources, $bindings, $table_columns ): WP_Markdown_Native_Query_Predicate|WP_Markdown_Native_Query_Scalar_Predicate|WP_Markdown_Native_Query_Subquery { + if ( $predicate instanceof WP_Markdown_Native_SQL_Scalar_Predicate ) { return $this->lower_scalar_predicate( $predicate, $base_source, $flat_source, $bindings ); } + if ( $predicate instanceof WP_Markdown_Native_SQL_Subquery_Predicate ) { return $this->lower_subquery( $predicate, $child_outer_sources, $table_columns, $bindings ); } + return $this->lower_predicate( $predicate, $base_source, $bindings ); }, $group ); }, $ast->boolean_predicate()->groups() ) ); - $scalar_having = array_map( fn( WP_Markdown_Native_SQL_Scalar_Predicate $predicate ): WP_Markdown_Native_Query_Scalar_Predicate => $this->lower_scalar_predicate( $predicate, null, $flat_source ), $ast->scalar_having() ); + $scalar_having = array_map( fn( WP_Markdown_Native_SQL_Scalar_Predicate $predicate ): WP_Markdown_Native_Query_Scalar_Predicate => $this->lower_scalar_predicate( $predicate, null, $flat_source, $bindings ), $ast->scalar_having() ); $seen = array(); foreach ( $ast->projection() as $column ) { $key = ( $column->qualifier() ?? '' ) . '.' . $column->name(); @@ -168,26 +169,26 @@ public function lower( WP_Markdown_Native_SQL_Select|WP_Markdown_Native_SQL_Foun $predicates = array(); $subqueries = array(); - foreach ( $ast->subqueries() as $subquery_predicate ) { $subqueries[] = $this->lower_subquery( $subquery_predicate, $child_outer_sources ); } + foreach ( $ast->subqueries() as $subquery_predicate ) { $subqueries[] = $this->lower_subquery( $subquery_predicate, $child_outer_sources, $table_columns, $bindings ); } foreach ( $ast->predicates() as $predicate ) { - $predicates[] = $this->lower_predicate( $predicate, $base_source ); + $predicates[] = $this->lower_predicate( $predicate, $base_source, $bindings ); } $joins = array(); foreach ( $ast->joins() as $join ) { - $join_derived = null === $join->derived() ? null : $this->lower( $join->derived() ); + $join_derived = null === $join->derived() ? null : $this->lower( $join->derived(), array(), $table_columns ); if ( null !== $join_derived && ! $join_derived instanceof WP_Markdown_Native_Query_Plan ) { return $join_derived; } $joins[] = new WP_Markdown_Native_Query_Join( $join->table()->name(), $join->alias()->name(), - $join->left()?->qualifier(), + $this->column_source( $join->left(), null, $bindings ), $join->left()?->name(), - $join->right()?->qualifier(), + $this->column_source( $join->right(), null, $bindings ), $join->right()?->name(), $join->is_outer(), array_map( - fn( WP_Markdown_Native_SQL_Predicate $predicate ): WP_Markdown_Native_Query_Predicate => $this->lower_predicate( $predicate, $join->alias()->name() ), + fn( WP_Markdown_Native_SQL_Predicate $predicate ): WP_Markdown_Native_Query_Predicate => $this->lower_predicate( $predicate, $join->alias()->name(), $bindings ), $join->on_predicates() ), $join_derived @@ -228,33 +229,33 @@ public function lower( WP_Markdown_Native_SQL_Select|WP_Markdown_Native_SQL_Foun fn( array $item ): array => array( 'column' => $item['column']->name(), 'descending' => $item['descending'], - 'source' => in_array( $item['column']->name(), $aggregate_aliases, true ) ? null : ( $item['column']->qualifier() ?? $base_source ), + 'source' => in_array( $item['column']->name(), $aggregate_aliases, true ) ? null : $this->column_source( $item['column'], $base_source, $bindings ), 'numeric' => $item['numeric'] ?? false, 'like' => $item['like'] ?? null, 'field' => $item['field'] ?? null, 'case' => null === ( $item['case'] ?? null ) ? null : array( 'branches' => array_map( fn( array $branch ): array => array( - 'predicates' => array_map( fn( WP_Markdown_Native_SQL_Predicate $predicate ): WP_Markdown_Native_Query_Predicate => $this->lower_predicate( $predicate, $base_source ), $branch['predicates'] ), + 'predicates' => array_map( fn( WP_Markdown_Native_SQL_Predicate $predicate ): WP_Markdown_Native_Query_Predicate => $this->lower_predicate( $predicate, $base_source, $bindings ), $branch['predicates'] ), 'value' => $branch['value'], ), $item['case']['branches'] ), 'else' => $item['case']['else'], ), - 'expression' => null === ( $item['expression'] ?? null ) ? null : $this->lower_scalar_expression( $item['expression'], $base_source, $flat_source ), + 'expression' => null === ( $item['expression'] ?? null ) ? null : $this->lower_scalar_expression( $item['expression'], $base_source, $flat_source, $bindings ), ), $ast->orders() ); $having = array_map( fn( WP_Markdown_Native_SQL_Predicate $predicate ): WP_Markdown_Native_Query_Predicate => $this->lower_predicate( $predicate, null ), $ast->having() ); $union = null; if ( null !== $ast->union() ) { - $union = $this->lower( $ast->union() ); + $union = $this->lower( $ast->union(), $outer_sources, $table_columns ); if ( ! $union instanceof WP_Markdown_Native_Query_Plan ) { return $union; } } - $derived = null === $ast->derived() ? null : $this->lower( $ast->derived() ); + $derived = null === $ast->derived() ? null : $this->lower( $ast->derived(), array(), $table_columns ); if ( null !== $derived && ! $derived instanceof WP_Markdown_Native_Query_Plan ) { return $derived; } @@ -266,7 +267,7 @@ public function lower( WP_Markdown_Native_SQL_Select|WP_Markdown_Native_SQL_Foun $ast->limit() ?? PHP_INT_MAX, $ast->counts_all(), $ast->alias()?->name(), - array_map( static fn( WP_Markdown_Native_SQL_Identifier $column ): ?string => $column->qualifier() ?? $base_source, $ast->projection() ), + array_map( fn( WP_Markdown_Native_SQL_Identifier $column ): ?string => $this->column_source( $column, $base_source, $bindings ), $ast->projection() ), $joins, $ast->calculates_found_rows(), $ast->order_descending(), @@ -277,10 +278,10 @@ public function lower( WP_Markdown_Native_SQL_Select|WP_Markdown_Native_SQL_Foun $ast->is_contradiction(), $ast->group_by()?->name(), array_map( - static fn( array $aggregate ): array => array( + fn( array $aggregate ): array => array( 'function' => $aggregate['function'], 'column' => $aggregate['column']?->name(), - 'source' => $aggregate['column']?->qualifier() ?? $base_source, + 'source' => $this->column_source( $aggregate['column'], $base_source, $bindings ), 'alias' => $aggregate['alias'], 'distinct' => $aggregate['distinct'] ?? false, ), @@ -293,28 +294,89 @@ public function lower( WP_Markdown_Native_SQL_Select|WP_Markdown_Native_SQL_Foun $union, $scalar_predicates, $scalar_having, - null === $ast->group_expression() ? null : $this->lower_scalar_expression( $ast->group_expression(), $base_source, $flat_source ), + null === $ast->group_expression() ? null : $this->lower_scalar_expression( $ast->group_expression(), $base_source, $flat_source, $bindings ), $boolean_predicate, $derived, $ast->union_all(), array_map( fn( array $item ): array => array( 'column' => $item['column']->name(), 'descending' => $item['descending'], 'numeric' => str_starts_with( $item['column']->name(), '__union_ordinal_' ) ), $ast->union_orders() ), $ast->union_limit(), $ast->union_limit_offset(), - array_map( fn( WP_Markdown_Native_SQL_Scalar_Expression $expression ): WP_Markdown_Native_Query_Scalar_Expression => $this->lower_scalar_expression( $expression, $base_source, $flat_source ), $ast->group_expressions() ), + array_map( fn( WP_Markdown_Native_SQL_Scalar_Expression $expression ): WP_Markdown_Native_Query_Scalar_Expression => $this->lower_scalar_expression( $expression, $base_source, $flat_source, $bindings ), $ast->group_expressions() ), $ast->index_hints() ); } + /** Resolve unqualified identifiers only when their source schemas are known. */ + private function column_bindings( WP_Markdown_Native_SQL_Select $ast, callable $table_columns ): array { + $sources = array( $ast->alias()?->name() ?? $ast->table()->name() => $this->source_columns( $ast->table()->name(), $ast->derived(), $table_columns ) ); + $join_scopes = array(); + foreach ( $ast->joins() as $join ) { + $sources[ $join->alias()->name() ] = $this->source_columns( $join->table()->name(), $join->derived(), $table_columns ); + foreach ( $join->on_predicates() as $predicate ) { + foreach ( $this->predicate_columns( $predicate ) as $column ) { + $join_scopes[ spl_object_id( $column ) ] = $sources; + } + } + } + $aliases = array_merge( array_column( $ast->scalar_projection(), 'alias' ), array_column( $ast->aggregates(), 'alias' ) ); + $order_aliases = array(); + foreach ( $ast->orders() as $order ) { + if ( null === $order['column']->qualifier() && in_array( $order['column']->name(), $aliases, true ) ) { + $order_aliases[ spl_object_id( $order['column'] ) ] = true; + } + } + $bindings = array(); + foreach ( $this->referenced_columns( $ast ) as $column ) { + if ( null !== $column->qualifier() || '*' === $column->name() || isset( $order_aliases[ spl_object_id( $column ) ] ) ) { + continue; + } + $owners = array_keys( array_filter( $join_scopes[ spl_object_id( $column ) ] ?? $sources, static fn( array $columns ): bool => in_array( $column->name(), $columns, true ) ) ); + if ( 1 < count( $owners ) ) { + throw new WP_Markdown_Native_SQL_Parse_Error( 'ambiguous_column', $column->sql_offset(), 'The unqualified column belongs to more than one query source.' ); + } + if ( 1 === count( $owners ) ) { + $bindings[ spl_object_id( $column ) ] = $owners[0]; + } + } + return $bindings; + } + + /** Derived sources expose their projected names, not their underlying tables. */ + private function source_columns( string $table, ?WP_Markdown_Native_SQL_Select $derived, callable $table_columns ): array { + if ( null === $derived ) { + return $table_columns( $table ) ?? array(); + } + if ( $derived->selects_all() ) { + return $this->source_columns( $derived->table()->name(), $derived->derived(), $table_columns ); + } + $columns = array(); + $inner_sources = array( $derived->alias()?->name() ?? $derived->table()->name() => array( $derived->table()->name(), $derived->derived() ) ); + foreach ( $derived->joins() as $join ) { $inner_sources[ $join->alias()->name() ] = array( $join->table()->name(), $join->derived() ); } + foreach ( $derived->projection() as $column ) { + if ( '*' === $column->name() && isset( $inner_sources[ $column->qualifier() ] ) ) { + $source = $inner_sources[ $column->qualifier() ]; + $columns = array_merge( $columns, $this->source_columns( $source[0], $source[1], $table_columns ) ); + } else { + $columns[] = $column->name(); + } + } + return array_merge( $columns, array_column( $derived->scalar_projection(), 'alias' ), array_column( $derived->aggregates(), 'alias' ) ); + } + + private function column_source( ?WP_Markdown_Native_SQL_Identifier $column, ?string $fallback, array $bindings ): ?string { + return null === $column ? $fallback : ( $column->qualifier() ?? $bindings[ spl_object_id( $column ) ] ?? $fallback ); + } + /** @param array $outer_sources */ - private function lower_subquery( WP_Markdown_Native_SQL_Subquery_Predicate $predicate, array $outer_sources = array() ): WP_Markdown_Native_Query_Subquery { - $subquery = $this->lower( $predicate->query(), $outer_sources ); + private function lower_subquery( WP_Markdown_Native_SQL_Subquery_Predicate $predicate, array $outer_sources = array(), ?callable $table_columns = null, array $bindings = array() ): WP_Markdown_Native_Query_Subquery { + $subquery = $this->lower( $predicate->query(), $outer_sources, $table_columns ); if ( ! $subquery instanceof WP_Markdown_Native_Query_Plan ) { throw new WP_Markdown_Native_SQL_Parse_Error( 'unsupported_subquery_shape', $predicate->column()?->sql_offset() ?? 0, 'mdi-native could not lower the requested subquery.' ); } - return new WP_Markdown_Native_Query_Subquery( $predicate->operator(), $predicate->column()?->name(), $subquery, $predicate->column()?->qualifier() ); + return new WP_Markdown_Native_Query_Subquery( $predicate->operator(), $predicate->column()?->name(), $subquery, $this->column_source( $predicate->column(), null, $bindings ) ); } - private function lower_predicate( WP_Markdown_Native_SQL_Predicate $predicate, ?string $base_source = null ): WP_Markdown_Native_Query_Predicate { + private function lower_predicate( WP_Markdown_Native_SQL_Predicate $predicate, ?string $base_source = null, array $bindings = array() ): WP_Markdown_Native_Query_Predicate { $values = array_map( static fn( WP_Markdown_Native_SQL_Literal $literal ): int|string => $literal->value(), $predicate->values() ); if ( 'IN' === $predicate->operator() || 'NOT IN' === $predicate->operator() ) { $values = array_values( array_unique( $values, SORT_REGULAR ) ); @@ -323,40 +385,41 @@ private function lower_predicate( WP_Markdown_Native_SQL_Predicate $predicate, ? $predicate->column()->name(), $predicate->operator(), $values, - $predicate->column()->qualifier() ?? $base_source, - array_map( fn( WP_Markdown_Native_SQL_Predicate $alternative ): WP_Markdown_Native_Query_Predicate => $this->lower_predicate( $alternative, $base_source ), $predicate->any() ), + $this->column_source( $predicate->column(), $base_source, $bindings ), + array_map( fn( WP_Markdown_Native_SQL_Predicate $alternative ): WP_Markdown_Native_Query_Predicate => $this->lower_predicate( $alternative, $base_source, $bindings ), $predicate->any() ), $predicate->cast(), $predicate->comparison()?->name(), - $predicate->comparison()?->qualifier() + $this->column_source( $predicate->comparison(), null, $bindings ) ); } - private function lower_scalar_expression( WP_Markdown_Native_SQL_Scalar_Expression $expression, ?string $base_source, ?string $flat_source = null ): WP_Markdown_Native_Query_Scalar_Expression { + private function lower_scalar_expression( WP_Markdown_Native_SQL_Scalar_Expression $expression, ?string $base_source, ?string $flat_source = null, array $bindings = array() ): WP_Markdown_Native_Query_Scalar_Expression { + $source = $this->column_source( $expression->identifier(), $base_source, $bindings ); return new WP_Markdown_Native_Query_Scalar_Expression( $expression->kind(), $expression->identifier()?->name(), $expression->literal(), array_map( - fn( WP_Markdown_Native_SQL_Scalar_Expression $argument ): WP_Markdown_Native_Query_Scalar_Expression => $this->lower_scalar_expression( $argument, $base_source, $flat_source ), + fn( WP_Markdown_Native_SQL_Scalar_Expression $argument ): WP_Markdown_Native_Query_Scalar_Expression => $this->lower_scalar_expression( $argument, $base_source, $flat_source, $bindings ), $expression->arguments() ), array_map( fn( array $branch ): array => array( 'predicates' => array_map( - fn( WP_Markdown_Native_SQL_Predicate $predicate ): WP_Markdown_Native_Query_Predicate => $this->lower_predicate( $predicate, $base_source ), + fn( WP_Markdown_Native_SQL_Predicate $predicate ): WP_Markdown_Native_Query_Predicate => $this->lower_predicate( $predicate, $base_source, $bindings ), $branch['predicates'] ), - 'value' => $this->lower_scalar_expression( $branch['value'], $base_source, $flat_source ), + 'value' => $this->lower_scalar_expression( $branch['value'], $base_source, $flat_source, $bindings ), ), $expression->branches() ), - null === $expression->else() ? null : $this->lower_scalar_expression( $expression->else(), $base_source, $flat_source ), - $flat_source === ( $expression->identifier()?->qualifier() ?? $base_source ) ? null : ( $expression->identifier()?->qualifier() ?? $base_source ) + null === $expression->else() ? null : $this->lower_scalar_expression( $expression->else(), $base_source, $flat_source, $bindings ), + $flat_source === $source ? null : $source ); } - private function lower_scalar_predicate( WP_Markdown_Native_SQL_Scalar_Predicate $predicate, ?string $base_source, ?string $flat_source = null ): WP_Markdown_Native_Query_Scalar_Predicate { - return new WP_Markdown_Native_Query_Scalar_Predicate( $this->lower_scalar_expression( $predicate->left(), $base_source, $flat_source ), $predicate->operator(), $this->lower_scalar_expression( $predicate->right(), $base_source, $flat_source ) ); + private function lower_scalar_predicate( WP_Markdown_Native_SQL_Scalar_Predicate $predicate, ?string $base_source, ?string $flat_source = null, array $bindings = array() ): WP_Markdown_Native_Query_Scalar_Predicate { + return new WP_Markdown_Native_Query_Scalar_Predicate( $this->lower_scalar_expression( $predicate->left(), $base_source, $flat_source, $bindings ), $predicate->operator(), $this->lower_scalar_expression( $predicate->right(), $base_source, $flat_source, $bindings ) ); } /** @return array */ @@ -382,7 +445,7 @@ private function referenced_columns( WP_Markdown_Native_SQL_Select $ast ): array $columns = array_merge( $columns, $ast->boolean_predicate()->columns() ); } foreach ( $ast->scalar_having() as $predicate ) { $columns = array_merge( $columns, $predicate->columns() ); } - if ( null !== $ast->group_expression() ) { $columns = array_merge( $columns, $ast->group_expression()->columns() ); } + foreach ( $ast->group_expressions() as $expression ) { $columns = array_merge( $columns, $expression->columns() ); } foreach ( $ast->orders() as $item ) { if ( null !== ( $item['expression'] ?? null ) ) { $columns = array_merge( $columns, $item['expression']->columns() ); continue; } if ( null === ( $item['case'] ?? null ) ) { @@ -413,6 +476,7 @@ private function referenced_columns( WP_Markdown_Native_SQL_Select $ast ): array /** @return array */ private function predicate_columns( WP_Markdown_Native_SQL_Predicate $predicate ): array { $columns = array( $predicate->column() ); + if ( null !== $predicate->comparison() ) { $columns[] = $predicate->comparison(); } foreach ( $predicate->any() as $alternative ) { $columns = array_merge( $columns, $this->predicate_columns( $alternative ) ); } @@ -605,13 +669,6 @@ private function select( bool $nested ): WP_Markdown_Native_SQL_Select|WP_Markdo } $left = null === $equality ? null : $equality->column(); $right = null === $equality ? null : $equality->comparison(); - $base_source = $alias ?? $table; - if ( null !== $left && null === $left->qualifier() ) { - $left = new WP_Markdown_Native_SQL_Identifier( $left->name(), $left->sql_offset(), $base_source->name() ); - } - if ( null !== $right && null === $right->qualifier() ) { - $right = new WP_Markdown_Native_SQL_Identifier( $right->name(), $right->sql_offset(), $base_source->name() ); - } if ( null !== $equality_index && null !== $left && null !== $right ) { $on_predicates[ $equality_index ] = new WP_Markdown_Native_SQL_Predicate( $left, '=', array(), array(), null, $right ); } diff --git a/tests/smoke-native-join-query.php b/tests/smoke-native-join-query.php index 128d6a3..26d8586 100644 --- a/tests/smoke-native-join-query.php +++ b/tests/smoke-native-join-query.php @@ -91,13 +91,19 @@ public function read( WP_Markdown_Native_Table_Access $access ): iterable|WP_Mar $unbounded_left = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT tr.object_id, tt.taxonomy FROM wp_term_relationships tr LEFT JOIN wp_term_taxonomy tt ON tr.term_taxonomy_id=tt.term_taxonomy_id' ) ); $unqualified = $runtime->execute( new WP_Markdown_Query_Request( str_replace( 'tr.object_id=41', 'object_id=41', $query ) ) ); $unknown_alias = $runtime->execute( new WP_Markdown_Query_Request( str_replace( 't.slug', 'x.slug', $query ) ) ); +$bound_columns = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT object_id, taxonomy, slug FROM wp_term_relationships tr JOIN wp_term_taxonomy tt ON tr.term_taxonomy_id=tt.term_taxonomy_id JOIN wp_terms t ON t.term_id=tt.term_id WHERE object_id=41' ) ); +$bound_where = $runtime->execute( new WP_Markdown_Query_Request( "SELECT object_id FROM wp_term_relationships tr, wp_term_taxonomy tt WHERE tr.term_taxonomy_id=tt.term_taxonomy_id AND taxonomy='category' AND object_id=41" ) ); +$ambiguous_column = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT term_taxonomy_id FROM wp_term_relationships tr JOIN wp_term_taxonomy tt ON tr.term_taxonomy_id=tt.term_taxonomy_id WHERE object_id=41' ) ); +$ambiguous_on = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT object_id FROM wp_term_relationships tr JOIN wp_term_taxonomy tt ON term_taxonomy_id=tt.term_taxonomy_id WHERE object_id=41' ) ); +$wrong_explicit_source = $runtime->execute( new WP_Markdown_Query_Request( str_replace( 'tt.taxonomy', 'tr.taxonomy', $query ) ) ); +$derived_binding = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT object_id, label FROM wp_term_relationships tr JOIN (SELECT term_taxonomy_id AS key_id, taxonomy AS label FROM wp_term_taxonomy) d ON key_id=tr.term_taxonomy_id WHERE object_id=41' ) ); $limited = $runtime->execute( new WP_Markdown_Query_Request( $query . ' LIMIT 1' ) ); $catalog_query = "SELECT wp_term_relationships.object_id FROM wp_term_relationships LEFT JOIN wp_term_taxonomy ON (wp_term_relationships.term_taxonomy_id = wp_term_taxonomy.term_taxonomy_id) WHERE wp_term_taxonomy.taxonomy IN ('category') GROUP BY wp_term_relationships.object_id ORDER BY wp_term_relationships.object_id DESC LIMIT 0, 5"; $catalog = $runtime->execute( new WP_Markdown_Query_Request( $catalog_query ) ); $distinct_identity_group_query = "SELECT DISTINCT tr.object_id FROM wp_term_relationships tr JOIN wp_term_taxonomy tt ON tr.term_taxonomy_id=tt.term_taxonomy_id WHERE tt.taxonomy='category' GROUP BY tr.object_id"; $distinct_identity_group = $runtime->execute( new WP_Markdown_Query_Request( $distinct_identity_group_query ) ); $counted = $runtime->execute( - new WP_Markdown_Query_Request( 'SELECT COUNT(*) FROM wp_term_relationships LEFT JOIN wp_term_taxonomy ON term_taxonomy_id = wp_term_taxonomy.term_taxonomy_id WHERE object_id = 41' ) + new WP_Markdown_Query_Request( 'SELECT COUNT(*) FROM wp_term_relationships LEFT JOIN wp_term_taxonomy ON wp_term_relationships.term_taxonomy_id = wp_term_taxonomy.term_taxonomy_id WHERE object_id = 41' ) ); $core_term_ids_query = "SELECT DISTINCT t.term_id, tr.object_id FROM wp_terms AS t INNER JOIN wp_term_taxonomy AS tt ON t.term_id = tt.term_id INNER JOIN wp_term_relationships AS tr ON tr.term_taxonomy_id = tt.term_taxonomy_id WHERE tt.taxonomy IN ('category', 'post_tag', 'post_format') AND tr.object_id IN (41) ORDER BY t.name ASC"; $core_term_ids_plan = ( new WP_Markdown_Native_Query_Parser() )->parse( $core_term_ids_query ); @@ -224,6 +230,11 @@ public function read( WP_Markdown_Native_Table_Access $access ): iterable|WP_Mar $meta_result = ( new WP_Markdown_Native_Query_Runtime( $meta_registry ) )->execute( new WP_Markdown_Query_Request( $meta_query ) ); $checks = array( + 'unqualified JOIN projections resolve against all source schemas' => $bound_columns->succeeded() && $result->corpus_result() === $bound_columns->corpus_result(), + 'unqualified WHERE columns resolve to the unique joined owner' => $bound_where->succeeded() && array( array( 'object_id' => '41' ) ) === $bound_where->corpus_result()['rows'], + 'ambiguous projection and ON columns fail rather than choosing the base table' => 'ambiguous_column' === ( $ambiguous_column->diagnostic()['reason'] ?? null ) && 'ambiguous_column' === ( $ambiguous_on->diagnostic()['reason'] ?? null ), + 'an explicit wrong source is never rebound to another table' => ! $wrong_explicit_source->succeeded(), + 'derived table aliases expose only their projected column names' => $derived_binding->succeeded() && array( 'object_id' => '41', 'label' => 'category' ) === $derived_binding->corpus_result()['rows'][0], 'plain column aliases preserve joined row order and source metadata' => $aliased->succeeded() && $expected_aliases === $aliased->corpus_result(), 'validated source index hints preserve taxonomy JOIN results' => $hinted->succeeded() && $result->corpus_result() === $hinted->corpus_result() && $joined_hint->succeeded() && $result->corpus_result() === $joined_hint->corpus_result(), 'unknown hinted indexes fail instead of silently executing' => ! $bad_hint->succeeded() && 'unsupported_index_hint' === ( $bad_hint->diagnostic()['reason'] ?? null ), From 1397586db8bac6c1c839963b2ca68fc518e64376 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 10 Sep 2026 17:33:28 -0400 Subject: [PATCH 49/53] fix(native): evaluate typed duplicate-key assignments --- ...wp-markdown-native-table-insert-parser.php | 35 +++++++++++-------- ...ass-wp-markdown-native-table-mutations.php | 26 +++++++++++--- ...ss-wp-markdown-native-table-statements.php | 9 ++--- tests/smoke-native-table-upsert.php | 19 ++++++++++ 4 files changed, 66 insertions(+), 23 deletions(-) diff --git a/inc/native/class-wp-markdown-native-table-insert-parser.php b/inc/native/class-wp-markdown-native-table-insert-parser.php index 76be948..d7496e5 100644 --- a/inc/native/class-wp-markdown-native-table-insert-parser.php +++ b/inc/native/class-wp-markdown-native-table-insert-parser.php @@ -70,12 +70,12 @@ public function parse_rows( WP_Markdown_Query_Request $request ): array|WP_Markd } while ( true ); $unless_exists = null; } - $upsert_columns = null; + $upsert_assignments = null; if ( 0 === strcasecmp( 'ON', (string) $this->current()->value() ) ) { if ( $replace || $ignore_duplicate || null !== $unless_exists ) { throw new WP_Markdown_Native_SQL_Parse_Error( 'unsupported_grammar', $this->current()->sql_offset(), 'mdi-native cannot combine INSERT IGNORE or INSERT SELECT FROM DUAL with ON DUPLICATE KEY UPDATE.' ); } - $upsert_columns = $this->upsert_assignments( $columns ); + $upsert_assignments = $this->upsert_assignments(); } $this->type( WP_Markdown_Native_SQL_Token::END ); $inserts = array(); @@ -87,7 +87,7 @@ public function parse_rows( WP_Markdown_Query_Request $request ): array|WP_Markd if ( false === $row ) { return $this->failure( 'invalid_insert_row', 'mdi-native requires one nonempty INSERT row.' ); } - $inserts[] = new WP_Markdown_Native_Table_Insert( $table, $row, $unless_exists, $ignore_duplicate, $upsert_columns, $replace ); + $inserts[] = new WP_Markdown_Native_Table_Insert( $table, $row, $unless_exists, $ignore_duplicate, $upsert_assignments, $replace ); } return $inserts; } catch ( WP_Markdown_Native_SQL_Parse_Error $error ) { @@ -320,31 +320,38 @@ private function identifier_list(): array { return array_values( $columns ); } - /** @param array $columns @return array */ - private function upsert_assignments( array $columns ): array { + /** @return array */ + private function upsert_assignments(): array { $this->word( 'ON' ); $this->word( 'DUPLICATE' ); $this->word( 'KEY' ); $this->word( 'UPDATE' ); $assignments = array(); - $available = array_fill_keys( $columns, true ); do { $target = $this->identifier(); $this->type( WP_Markdown_Native_SQL_Token::EQUALS ); - $this->word( 'VALUES' ); - $this->type( WP_Markdown_Native_SQL_Token::LEFT_PAREN ); - $source = $this->identifier(); - $this->type( WP_Markdown_Native_SQL_Token::RIGHT_PAREN ); - if ( $target !== $source || isset( $assignments[ $target ] ) || ! isset( $available[ $target ] ) ) { - throw new WP_Markdown_Native_SQL_Parse_Error( 'unsupported_grammar', $this->current()->sql_offset(), 'mdi-native requires deterministic VALUES assignments for ON DUPLICATE KEY UPDATE.' ); + $source = null; + $value = null; + if ( $this->is_word( 'VALUES' ) ) { + $this->word( 'VALUES' ); + $this->type( WP_Markdown_Native_SQL_Token::LEFT_PAREN ); + $source = $this->identifier(); + $this->type( WP_Markdown_Native_SQL_Token::RIGHT_PAREN ); + $kind = 'inserted'; + } elseif ( in_array( $this->current()->type(), array( WP_Markdown_Native_SQL_Token::WORD, WP_Markdown_Native_SQL_Token::QUOTED_IDENTIFIER ), true ) ) { + $source = $this->identifier(); + $kind = 'column'; + } else { + $value = $this->literal(); + $kind = 'literal'; } - $assignments[ $target ] = $target; + $assignments[] = array( 'target' => $target, 'kind' => $kind, 'source' => $source, 'value' => $value ); if ( WP_Markdown_Native_SQL_Token::COMMA !== $this->current()->type() ) { break; } ++$this->position; } while ( true ); - return array_values( $assignments ); + return $assignments; } /** @return array */ diff --git a/inc/native/class-wp-markdown-native-table-mutations.php b/inc/native/class-wp-markdown-native-table-mutations.php index bacd63f..1316755 100644 --- a/inc/native/class-wp-markdown-native-table-mutations.php +++ b/inc/native/class-wp-markdown-native-table-mutations.php @@ -113,6 +113,11 @@ private function execute_insert( WP_Markdown_Query_Request $request, WP_Markdown try { $schema = $table['schema']; $provider = $table['provider']; + foreach ( $insert->upsert_assignments() ?? array() as $assignment ) { + if ( ! $schema->has_column( $assignment['target'] ) || ( null !== $assignment['source'] && ! $schema->has_column( $assignment['source'] ) ) ) { + return $this->failure( 'unsupported_column', 'The duplicate-key assignment references an undeclared column.' ); + } + } if ( ! $this->supports_unique_indexes( $definition ) ) { return $this->failure( 'unsupported_unique_collation', 'mdi-native cannot enforce a persisted string or prefix unique key without its exact collation.' ); } @@ -130,7 +135,7 @@ private function execute_insert( WP_Markdown_Query_Request $request, WP_Markdown } $path = $directory . '/' . $suffix . '.json'; $table_index = $this->index_for( $root ); - $index = $insert->is_replace() || null !== $insert->upsert_columns() || WP_Markdown_Native_Table_Index::supplies_identity( $insert->values(), $definition ) + $index = $insert->is_replace() || null !== $insert->upsert_assignments() || WP_Markdown_Native_Table_Index::supplies_identity( $insert->values(), $definition ) ? null : $table_index->load( $suffix, $path ); if ( null !== $index ) { @@ -202,23 +207,34 @@ private function execute_insert( WP_Markdown_Query_Request $request, WP_Markdown if ( $insert->ignores_duplicate() ) { return WP_Markdown_Query_Result::mutated( 0 ); } - $upsert_columns = $insert->upsert_columns(); - if ( null === $upsert_columns ) { + $upsert_assignments = $insert->upsert_assignments(); + if ( null === $upsert_assignments ) { return $this->failure( 'duplicate_key', 'The INSERT row duplicates a persisted unique key.' ); } $duplicate = $duplicates[0]; $updated = $rows[ $duplicate ]; - foreach ( $upsert_columns as $column ) { - $updated[ $column ] = $row[ $column ]; + foreach ( $upsert_assignments as $assignment ) { + // Existing-column references see earlier assignments; VALUES sees the proposed insert. + $updated[ $assignment['target'] ] = match ( $assignment['kind'] ) { + 'inserted' => $row[ $assignment['source'] ], + 'column' => $updated[ $assignment['source'] ], + default => $assignment['value'], + }; } if ( true !== $schema->validate_row( $updated ) ) { return $this->failure( 'invalid_insert_row', 'The INSERT row is outside the persisted table schema.' ); } + if ( ! $this->unique_values_enforceable( $updated, $definition ) ) { + return $this->failure( 'unsupported_unique_collation', 'The duplicate-key assignment requires an unsupported unique-key collation.' ); + } $others = $rows; unset( $others[ $duplicate ] ); if ( $this->duplicate_row_offset( $updated, array_values( $others ), $definition, $schema ) !== null ) { return $this->failure( 'duplicate_key', 'The INSERT row duplicates a persisted unique key.' ); } + if ( $updated === $rows[ $duplicate ] ) { + return WP_Markdown_Query_Result::mutated( 0 ); + } $rows[ $duplicate ] = $updated; $written = $this->write( $path, array_values( $rows ) ); if ( $written instanceof WP_Markdown_Query_Result ) { diff --git a/inc/native/class-wp-markdown-native-table-statements.php b/inc/native/class-wp-markdown-native-table-statements.php index 6d14d18..217b7fd 100644 --- a/inc/native/class-wp-markdown-native-table-statements.php +++ b/inc/native/class-wp-markdown-native-table-statements.php @@ -9,13 +9,14 @@ final class WP_Markdown_Native_Table_Insert { /** * @param array $values * @param array|null $unless_exists + * @param array|null $upsert_assignments */ public function __construct( private readonly string $table, private readonly array $values, private readonly ?array $unless_exists = null, private readonly bool $ignore_duplicate = false, - private readonly ?array $upsert_columns = null, + private readonly ?array $upsert_assignments = null, private readonly bool $replace = false ) {} @@ -37,9 +38,9 @@ public function ignores_duplicate(): bool { return $this->ignore_duplicate; } - /** @return array|null */ - public function upsert_columns(): ?array { - return $this->upsert_columns; + /** @return array|null */ + public function upsert_assignments(): ?array { + return $this->upsert_assignments; } public function is_replace(): bool { diff --git a/tests/smoke-native-table-upsert.php b/tests/smoke-native-table-upsert.php index 7be2777..28f8a4a 100644 --- a/tests/smoke-native-table-upsert.php +++ b/tests/smoke-native-table-upsert.php @@ -26,8 +26,27 @@ ); $read = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT id, object_id, title FROM wp_yoast_indexable WHERE object_id = 7', 'wp_' ) ); $fresh = $runtime->execute( new WP_Markdown_Query_Request( "INSERT INTO wp_yoast_indexable (object_id, title) VALUES (8, 'eight') ON DUPLICATE KEY UPDATE title = VALUES(title)", 'wp_' ) ); +$cleared = $runtime->execute( new WP_Markdown_Query_Request( "INSERT INTO wp_yoast_indexable (id, object_id, title) VALUES (1, 7, 'ignored') ON DUPLICATE KEY UPDATE title = NULL" ) ); +$cleared_row = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT title FROM wp_yoast_indexable WHERE id = 1' ) ); +$noop = $runtime->execute( new WP_Markdown_Query_Request( "INSERT INTO wp_yoast_indexable (id, object_id, title) VALUES (1, 7, 'ignored') ON DUPLICATE KEY UPDATE object_id = object_id, title = NULL" ) ); +$ordered = $runtime->execute( new WP_Markdown_Query_Request( "INSERT INTO wp_yoast_indexable (id, object_id, title) VALUES (1, 7, 'incoming') ON DUPLICATE KEY UPDATE object_id = 9, title = object_id" ) ); +$ordered_row = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT object_id, title FROM wp_yoast_indexable WHERE id = 1' ) ); +$cross_values = $runtime->execute( new WP_Markdown_Query_Request( "INSERT INTO wp_yoast_indexable (id, object_id, title) VALUES (1, 9, 'incoming') ON DUPLICATE KEY UPDATE title = VALUES(object_id)" ) ); +$bad_target = $runtime->execute( new WP_Markdown_Query_Request( "INSERT INTO wp_yoast_indexable (id, object_id, title) VALUES (3, 10, 'new') ON DUPLICATE KEY UPDATE absent_column = NULL" ) ); +$new_literal = $runtime->execute( new WP_Markdown_Query_Request( "INSERT INTO wp_yoast_indexable (id, object_id, title) VALUES (3, 10, 'new') ON DUPLICATE KEY UPDATE title = NULL" ) ); +$new_row = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT title FROM wp_yoast_indexable WHERE id = 3' ) ); +$runtime->execute( new WP_Markdown_Query_Request( 'CREATE TABLE wp_unique_labels (id int NOT NULL, label varchar(20) NOT NULL, PRIMARY KEY(id), UNIQUE KEY label(label))' ) ); +$runtime->execute( new WP_Markdown_Query_Request( "INSERT INTO wp_unique_labels (id,label) VALUES (1,'original')" ) ); +$unsupported_unique = $runtime->execute( new WP_Markdown_Query_Request( "INSERT INTO wp_unique_labels (id,label) VALUES (1,'original') ON DUPLICATE KEY UPDATE label='caf\xC3\xA9'" ) ); $checks = array( + 'literal assignments preserve fail-closed unique-key enforcement' => ! $unsupported_unique->succeeded() && 'unsupported_unique_collation' === $unsupported_unique->diagnostic()['reason'], + 'literal NULL assignments clear only a conflicting row' => 2 === $cleared->return_value() && null === $cleared_row->corpus_result()['rows'][0]['title'], + 'unchanged duplicate assignments report zero affected rows' => 0 === $noop->return_value() && 0 === $noop->wpdb_state()['insert_id'], + 'column references observe earlier duplicate assignments in order' => 2 === $ordered->return_value() && array( 'object_id' => '9', 'title' => '9' ) === $ordered_row->corpus_result()['rows'][0], + 'VALUES may refer to a different inserted column' => $cross_values->succeeded() && 0 === $cross_values->return_value(), + 'unknown duplicate target fails even on the insert branch' => ! $bad_target->succeeded() && 'unsupported_column' === $bad_target->diagnostic()['reason'], + 'duplicate literals are not applied to a newly inserted row' => 1 === $new_literal->return_value() && 'new' === $new_row->corpus_result()['rows'][0]['title'], 'the first insert persists' => 1 === $insert->return_value() && 1 === $insert->wpdb_state()['insert_id'], 'ON DUPLICATE KEY UPDATE rewrites the conflicting row' => 2 === $upsert->return_value() && 1 === $upsert->wpdb_state()['insert_id'] From 5f02fa0b8be60af25b4cf35ecbc2b62b7aa33008 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 10 Sep 2026 17:56:59 -0400 Subject: [PATCH 50/53] fix(native): apply multiple ALTER actions atomically --- ...ss-wp-markdown-native-schema-mutations.php | 48 +++++++++++++++++++ tests/smoke-native-alter-table.php | 17 +++++++ 2 files changed, 65 insertions(+) diff --git a/inc/native/class-wp-markdown-native-schema-mutations.php b/inc/native/class-wp-markdown-native-schema-mutations.php index abe293f..1a2b0fa 100644 --- a/inc/native/class-wp-markdown-native-schema-mutations.php +++ b/inc/native/class-wp-markdown-native-schema-mutations.php @@ -157,6 +157,25 @@ private function execute_alter( WP_Markdown_Query_Request $request, string $sql if ( 1 !== preg_match( '/^[A-Za-z_][A-Za-z0-9_]*$/D', $suffix ) || null === $this->registry->definition( $table ) ) { return $this->failure( 'unknown_table', 'mdi-native cannot alter a table it does not persist.' ); } + try { + $actions = array(); + $start = 0; + $depth = 0; + foreach ( ( new WP_Markdown_Native_SQL_Tokenizer() )->tokenize( $action ) as $token ) { + if ( WP_Markdown_Native_SQL_Token::LEFT_PAREN === $token->type() ) { ++$depth; } + if ( WP_Markdown_Native_SQL_Token::RIGHT_PAREN === $token->type() ) { --$depth; } + if ( 0 === $depth && WP_Markdown_Native_SQL_Token::COMMA === $token->type() ) { + $actions[] = trim( substr( $action, $start, $token->sql_offset() - $start ) ); + $start = $token->sql_offset() + 1; + } + } + $actions[] = trim( substr( $action, $start ) ); + } catch ( WP_Markdown_Native_SQL_Parse_Error ) { + return $this->failure( 'unsupported_schema', 'The ALTER TABLE actions could not be parsed.' ); + } + if ( count( $actions ) > 1 ) { + return $this->execute_alter_actions( $request, $table, $actions ); + } if ( 1 === preg_match( '/^ADD\s+(?:(?:UNIQUE\s+)?(?:INDEX|KEY)\s+`?[A-Za-z0-9_]+`?|PRIMARY\s+KEY)\s*\(.+\)$/is', $action ) ) { return $this->execute_add_index( $table, $suffix, $action ); @@ -242,6 +261,35 @@ private function execute_alter( WP_Markdown_Query_Request $request, string $sql } } + /** Apply supported ALTER actions atomically after the outer DDL implicit commit. */ + private function execute_alter_actions( WP_Markdown_Query_Request $request, string $table, array $actions ): WP_Markdown_Query_Result { + if ( null === $this->transactions || count( $actions ) > 64 || in_array( '', $actions, true ) ) { + return $this->failure( 'unsupported_schema', 'A bounded multi-action ALTER requires a transaction journal.' ); + } + $locked = $this->transactions->begin_write(); + if ( true !== $locked ) { + return $this->failure( 'transaction_write_lock_failed', $locked ); + } + $begun = $this->transactions->begin(); + if ( true !== $begun ) { + return $this->failure( 'transaction_journal_failed', $begun ); + } + try { + foreach ( $actions as $action ) { + $result = $this->execute_alter( $request, 'ALTER TABLE `' . $table . '` ' . $action ); + if ( ! $result->succeeded() ) { + $restored = $this->transactions->rollback(); + return true === $restored ? $result : $this->failure( 'transaction_rollback_failed', $restored ); + } + } + $committed = $this->transactions->commit(); + return true === $committed ? WP_Markdown_Query_Result::schema_changed() : $this->failure( 'transaction_commit_failed', $committed ); + } catch ( Throwable ) { + $restored = $this->transactions->rollback(); + return $this->failure( 'schema_mutation_failed', true === $restored ? 'The ALTER actions failed and were restored.' : $restored ); + } + } + /** * Drop one persisted table. * diff --git a/tests/smoke-native-alter-table.php b/tests/smoke-native-alter-table.php index 502d81d..6a055dc 100644 --- a/tests/smoke-native-alter-table.php +++ b/tests/smoke-native-alter-table.php @@ -109,8 +109,25 @@ function snapshot_rows( string $root ): array { $unsupported_action = $runtime->execute( new WP_Markdown_Query_Request( 'ALTER TABLE wp_agents ENGINE = InnoDB', 'wp_' ) ); +$combined = $runtime->execute( new WP_Markdown_Query_Request( "ALTER TABLE wp_agents ADD COLUMN owner_id bigint unsigned DEFAULT NULL, ADD KEY owner_id (owner_id), ADD COLUMN tags varchar(40) DEFAULT 'one,two'" ) ); +$combined_row = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT owner_id, tags FROM wp_agents WHERE id = 1' ) ); +$combined_index = $runtime->execute( new WP_Markdown_Query_Request( "SHOW INDEX FROM wp_agents WHERE Key_name = 'owner_id'" ) ); +$before_failed_schema = persisted_schema( $root ); +$before_failed_rows = snapshot_rows( $root ); +$failed_combined = $runtime->execute( new WP_Markdown_Query_Request( 'ALTER TABLE wp_agents ADD COLUMN transient_field int DEFAULT 7, ADD KEY invalid_key (missing_field)' ) ); +$after_failed_schema = persisted_schema( $root ); +$after_failed_rows = snapshot_rows( $root ); +$after_failed_columns = $runtime->execute( new WP_Markdown_Query_Request( 'DESCRIBE wp_agents' ) ); +$runtime->execute( new WP_Markdown_Query_Request( 'START TRANSACTION' ) ); +$runtime->execute( new WP_Markdown_Query_Request( "UPDATE wp_agents SET label = 'committed-before-ddl' WHERE id = 1" ) ); +$runtime->execute( new WP_Markdown_Query_Request( 'ALTER TABLE wp_agents ADD COLUMN transient_again int, ADD KEY invalid_again (missing_field)' ) ); +$runtime->execute( new WP_Markdown_Query_Request( 'ROLLBACK' ) ); +$committed_before_ddl = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT label FROM wp_agents WHERE id = 1' ) ); $checks = array( + 'multi-action ALTER adds columns and indexes in one statement' => $combined->succeeded() && array( 'owner_id' => null, 'tags' => 'one,two' ) === $combined_row->corpus_result()['rows'][0] && 1 === $combined_index->return_value(), + 'a failed later ALTER action restores schema rows and registry' => ! $failed_combined->succeeded() && $before_failed_schema === $after_failed_schema && $before_failed_rows === $after_failed_rows && ! in_array( 'transient_field', array_column( $after_failed_columns->corpus_result()['rows'], 'Field' ), true ), + 'failed atomic ALTER still preserves the outer implicit commit' => 'committed-before-ddl' === $committed_before_ddl->corpus_result()['rows'][0]['label'], 'MODIFY rewrites the persisted column definition' => true === $modified->succeeded() && str_contains( $after_modify, 'LONGTEXT NOT NULL' ) && ! str_contains( $after_modify, 'instance_key VARCHAR(60) NULL' ), From e75f32f1823d7163a1727ae7b64bf599ddf02642 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 10 Sep 2026 18:11:40 -0400 Subject: [PATCH 51/53] fix(native): filter non-indexed snapshot columns --- inc/native/class-wp-markdown-native-query-executor.php | 7 ++++--- tests/smoke-native-table-upsert.php | 3 +++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/inc/native/class-wp-markdown-native-query-executor.php b/inc/native/class-wp-markdown-native-query-executor.php index 5f7ef1c..c4ed64e 100644 --- a/inc/native/class-wp-markdown-native-query-executor.php +++ b/inc/native/class-wp-markdown-native-query-executor.php @@ -459,7 +459,7 @@ private function execute_query_plan( WP_Markdown_Native_Query_Plan $plan, bool $ if ( array() !== $predicates && null === $pushdown && ! $table['provider'] instanceof WP_Markdown_Native_JSON_Partition_Provider - && ! $this->allows_residual_scan( $predicates, $schema ) + && ! $this->allows_residual_scan( $predicates, $schema, $table['provider'] instanceof WP_Markdown_Native_JSON_Snapshot_Provider ) ) { return $this->failure( 'unsupported_lookup', 'mdi-native requires one indexable predicate for a filtered query.' ); } @@ -1885,7 +1885,7 @@ private function derived_source( WP_Markdown_Native_Query_Plan $plan, string $na } /** @param array $predicates */ - private function allows_residual_scan( array $predicates, WP_Markdown_Native_Table_Schema $schema ): bool { + private function allows_residual_scan( array $predicates, WP_Markdown_Native_Table_Schema $schema, bool $snapshot = false ): bool { $indexed = $this->indexed_columns( $schema ); foreach ( $predicates as $predicate ) { if ( null !== $predicate->cast() ) { @@ -1916,7 +1916,8 @@ private function allows_residual_scan( array $predicates, WP_Markdown_Native_Tab if ( in_array( $type, array( 1, 2, 3, 4, 5, 8, 9, 246 ), true ) ) { continue; } - if ( isset( $indexed[ $column ] ) + // Snapshots already materialize rows; retain lookup validators even on scans. + if ( ( $snapshot || isset( $indexed[ $column ] ) ) && ! $schema->is_lookup( $column ) && $schema->allows_filter( $column, $predicate->operator(), $predicate->values() ) ) { continue; diff --git a/tests/smoke-native-table-upsert.php b/tests/smoke-native-table-upsert.php index 28f8a4a..6ff776a 100644 --- a/tests/smoke-native-table-upsert.php +++ b/tests/smoke-native-table-upsert.php @@ -26,6 +26,8 @@ ); $read = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT id, object_id, title FROM wp_yoast_indexable WHERE object_id = 7', 'wp_' ) ); $fresh = $runtime->execute( new WP_Markdown_Query_Request( "INSERT INTO wp_yoast_indexable (object_id, title) VALUES (8, 'eight') ON DUPLICATE KEY UPDATE title = VALUES(title)", 'wp_' ) ); +$unindexed_read = $runtime->execute( new WP_Markdown_Query_Request( "SELECT id FROM wp_yoast_indexable WHERE title = 'two'" ) ); +$unindexed_membership = $runtime->execute( new WP_Markdown_Query_Request( "SELECT id FROM wp_yoast_indexable WHERE title IN ('two', 'eight') ORDER BY id" ) ); $cleared = $runtime->execute( new WP_Markdown_Query_Request( "INSERT INTO wp_yoast_indexable (id, object_id, title) VALUES (1, 7, 'ignored') ON DUPLICATE KEY UPDATE title = NULL" ) ); $cleared_row = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT title FROM wp_yoast_indexable WHERE id = 1' ) ); $noop = $runtime->execute( new WP_Markdown_Query_Request( "INSERT INTO wp_yoast_indexable (id, object_id, title) VALUES (1, 7, 'ignored') ON DUPLICATE KEY UPDATE object_id = object_id, title = NULL" ) ); @@ -40,6 +42,7 @@ $unsupported_unique = $runtime->execute( new WP_Markdown_Query_Request( "INSERT INTO wp_unique_labels (id,label) VALUES (1,'original') ON DUPLICATE KEY UPDATE label='caf\xC3\xA9'" ) ); $checks = array( + 'validated snapshots filter non-indexed equality and membership columns' => $unindexed_read->succeeded() && array( array( 'id' => '1' ) ) === $unindexed_read->corpus_result()['rows'] && $unindexed_membership->succeeded() && array( array( 'id' => '1' ), array( 'id' => '2' ) ) === $unindexed_membership->corpus_result()['rows'], 'literal assignments preserve fail-closed unique-key enforcement' => ! $unsupported_unique->succeeded() && 'unsupported_unique_collation' === $unsupported_unique->diagnostic()['reason'], 'literal NULL assignments clear only a conflicting row' => 2 === $cleared->return_value() && null === $cleared_row->corpus_result()['rows'][0]['title'], 'unchanged duplicate assignments report zero affected rows' => 0 === $noop->return_value() && 0 === $noop->wpdb_state()['insert_id'], From 6b10e4af74f72a12e5950c3748d29dc2d889b4d0 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 10 Sep 2026 18:25:09 -0400 Subject: [PATCH 52/53] fix(native): aggregate typed numeric scalar expressions --- ...lass-wp-markdown-native-query-executor.php | 61 +++++++++++++++---- .../class-wp-markdown-native-query-parser.php | 21 +++++-- tests/smoke-native-aggregates.php | 13 ++++ 3 files changed, 79 insertions(+), 16 deletions(-) diff --git a/inc/native/class-wp-markdown-native-query-executor.php b/inc/native/class-wp-markdown-native-query-executor.php index c4ed64e..b15ff77 100644 --- a/inc/native/class-wp-markdown-native-query-executor.php +++ b/inc/native/class-wp-markdown-native-query-executor.php @@ -404,6 +404,25 @@ private function execute_query_plan( WP_Markdown_Native_Query_Plan $plan, bool $ foreach ( $scalar_projection as $scalar ) { $scalar_columns = array_merge( $scalar_columns, $scalar['expression']->columns() ); } + foreach ( $plan->aggregates() as $aggregate ) { + if ( isset( $aggregate['expression'] ) ) { + $expression = $aggregate['expression']; + $scalar_columns = array_merge( $scalar_columns, $expression->columns() ); + foreach ( $expression->columns() as $column ) { + if ( ! $schema->has_column( $column ) ) { + return $this->failure( 'unsupported_column', 'mdi-native cannot query the requested aggregate column.' ); + } + } + foreach ( $expression->predicates() as $predicate ) { + if ( ! $schema->supports_predicate( $predicate ) ) { + return $this->failure( 'unsupported_lookup', 'mdi-native cannot apply the requested aggregate predicate.' ); + } + } + if ( ! $this->is_numeric_expression( $expression, $schema ) ) { + return $this->failure( 'unsupported_aggregate', 'mdi-native aggregates numeric scalar expressions only.' ); + } + } + } $columns = array_merge( $projection, $scalar_columns ); foreach ( $scalar_predicates as $predicate ) { $columns = array_merge( $columns, $predicate->columns() ); } if ( null !== $boolean_predicate ) { $columns = array_merge( $columns, $boolean_predicate->columns() ); } @@ -676,13 +695,13 @@ private function accumulate_aggregates( array &$state, array $row, array $aggreg foreach ( $aggregates as $index => $aggregate ) { $current = $state[ $index ] ?? array( 'count' => 0, 'sum' => null, 'min' => null, 'max' => null, 'values' => array() ); $column = $aggregate['column']; - if ( null === $column ) { + if ( null === $column && ! isset( $aggregate['expression'] ) ) { // COUNT(*) reports over rows, so a NULL column cannot skip one. ++$current['count']; $state[ $index ] = $current; continue; } - $value = $row[ $column ] ?? null; + $value = isset( $aggregate['expression'] ) ? $this->evaluate_scalar( $aggregate['expression'], $row, $schema ) : ( $row[ $column ] ?? null ); if ( null === $value ) { // SQL aggregates ignore NULL, and COUNT(column) counts values. $state[ $index ] = $current; @@ -695,24 +714,39 @@ private function accumulate_aggregates( array &$state, array $row, array $aggreg if ( 'GROUP_CONCAT' === $aggregate['function'] ) { $current['values'][] = (string) $value; } - if ( null === $current['min'] || 0 > ( $schema->ordered_comparison( $column, $value, $current['min'] ) ?? 0 ) ) { + if ( null !== $column && ( null === $current['min'] || 0 > ( $schema->ordered_comparison( $column, $value, $current['min'] ) ?? 0 ) ) ) { $current['min'] = $value; } - if ( null === $current['max'] || 0 < ( $schema->ordered_comparison( $column, $value, $current['max'] ) ?? 0 ) ) { + if ( null !== $column && ( null === $current['max'] || 0 < ( $schema->ordered_comparison( $column, $value, $current['max'] ) ?? 0 ) ) ) { $current['max'] = $value; } $state[ $index ] = $current; } } - /** - * Report one row of ungrouped aggregates. - * - * An aggregate over no rows is NULL, except COUNT, which is zero. - * - * @param array> $state Running totals, by aggregate. - * @param array> $aggregates Declared aggregates. - */ + /** Validate numeric expression types before reading rows, including empty sets. */ + private function is_numeric_expression( WP_Markdown_Native_Query_Scalar_Expression $expression, WP_Markdown_Native_Table_Schema $schema ): bool { + if ( 'literal' === $expression->kind() ) { + return null === $expression->literal() || is_int( $expression->literal() ); + } + if ( 'column' === $expression->kind() ) { + return $schema->is_numeric_column( $expression->column() ); + } + if ( 'CASE' === $expression->kind() ) { + foreach ( $expression->branches() as $branch ) { + if ( ! $this->is_numeric_expression( $branch['value'], $schema ) ) { return false; } + } + return null === $expression->else() || $this->is_numeric_expression( $expression->else(), $schema ); + } + if ( in_array( $expression->kind(), array( 'COALESCE', 'IFNULL', 'NULLIF', 'ABS', 'ROUND', 'FLOOR', 'CEIL' ), true ) ) { + foreach ( $expression->arguments() as $argument ) { + if ( ! $this->is_numeric_expression( $argument, $schema ) ) { return false; } + } + return true; + } + return false; + } + private function aggregate_result( array $state, array $aggregates ): WP_Markdown_Query_Result { $row = array(); $columns = array(); @@ -1353,6 +1387,9 @@ private function execute_join( WP_Markdown_Native_Query_Plan $plan ): WP_Markdow } foreach ( $plan->aggregates() as $aggregate ) { if ( null === $aggregate['column'] ) { + if ( isset( $aggregate['expression'] ) ) { + return $this->failure( 'unsupported_aggregate', 'mdi-native does not yet aggregate scalar expressions across joins.' ); + } continue; } $aggregate_source = (string) $aggregate['source']; diff --git a/inc/native/class-wp-markdown-native-query-parser.php b/inc/native/class-wp-markdown-native-query-parser.php index 1a4c16a..b456d23 100644 --- a/inc/native/class-wp-markdown-native-query-parser.php +++ b/inc/native/class-wp-markdown-native-query-parser.php @@ -284,6 +284,7 @@ public function lower( WP_Markdown_Native_SQL_Select|WP_Markdown_Native_SQL_Foun 'source' => $this->column_source( $aggregate['column'], $base_source, $bindings ), 'alias' => $aggregate['alias'], 'distinct' => $aggregate['distinct'] ?? false, + 'expression' => isset( $aggregate['expression'] ) ? $this->lower_scalar_expression( $aggregate['expression'], $base_source, $flat_source, $bindings ) : null, ), $ast->aggregates() ), @@ -459,6 +460,9 @@ private function referenced_columns( WP_Markdown_Native_SQL_Select $ast ): array } } foreach ( $ast->aggregates() as $aggregate ) { + if ( isset( $aggregate['expression'] ) ) { + $columns = array_merge( $columns, $aggregate['expression']->columns() ); + } if ( null !== $aggregate['column'] ) { $columns[] = $aggregate['column']; } @@ -588,7 +592,8 @@ private function select( bool $nested ): WP_Markdown_Native_SQL_Select|WP_Markdo // COUNT(*) reports over rows; COUNT(column) counts values, so it is an // aggregate like the others rather than the row-count shortcut. $counts_rows = $this->matches_function( 'COUNT' ) - && WP_Markdown_Native_SQL_Token::STAR === ( $this->tokens[ $this->current + 2 ] ?? null )?->type(); + && WP_Markdown_Native_SQL_Token::STAR === ( $this->tokens[ $this->current + 2 ] ?? null )?->type() + && 'FROM' === strtoupper( (string) ( $this->tokens[ $this->current + 4 ] ?? null )?->value() ); if ( ! $select_all && $counts_rows ) { $count_all = true; $this->identifier(); @@ -1329,7 +1334,7 @@ private function coalesce_disjunction( array $groups, int $sql_offset ): array { 'mdi-native supports OR only over uncast equality or LIKE alternatives.' ); } - if ( ! in_array( $conjunct->operator(), array( '=', 'IN', 'IS NULL', 'LOWER =' ), true ) ) { + if ( ! in_array( $conjunct->operator(), array( '=', 'IN', 'IS NULL', 'LOWER =', 'LIKE' ), true ) ) { throw new WP_Markdown_Native_SQL_Parse_Error( 'unsupported_or', $sql_offset, @@ -1344,7 +1349,7 @@ private function coalesce_disjunction( array $groups, int $sql_offset ): array { continue; } $predicate = $group[0]; - if ( ! in_array( $predicate->operator(), array( '=', 'IN', 'IS NULL', 'LOWER =' ), true ) ) { + if ( ! in_array( $predicate->operator(), array( '=', 'IN', 'IS NULL', 'LOWER =', 'LIKE' ), true ) ) { throw new WP_Markdown_Native_SQL_Parse_Error( 'unsupported_or', $sql_offset, @@ -1372,7 +1377,7 @@ private function coalesce_disjunction( array $groups, int $sql_offset ): array { return array( new WP_Markdown_Native_SQL_Predicate( $identifier, 'OR', array(), $alternatives ) ); } foreach ( $alternatives as $alternative ) { - if ( 'IS NULL' === $alternative->operator() ) { + if ( in_array( $alternative->operator(), array( 'IS NULL', 'LIKE' ), true ) ) { return array( new WP_Markdown_Native_SQL_Predicate( $identifier, 'OR', array(), $alternatives ) ); } } @@ -1447,6 +1452,14 @@ private function match_aggregate(): ?array { $this->expect_type( WP_Markdown_Native_SQL_Token::LEFT_PAREN ); $argument = $this->current(); $distinct = $this->match_keyword( 'DISTINCT' ); + if ( $this->matches_scalar_expression() ) { + if ( $distinct || ! in_array( $function, array( 'SUM', 'AVG' ), true ) ) { + $this->unsupported( $argument ); + } + $expression = $this->scalar_value(); + $this->expect_type( WP_Markdown_Native_SQL_Token::RIGHT_PAREN ); + return array( 'function' => $function, 'column' => null, 'expression' => $expression, 'alias' => $this->scalar_alias(), 'distinct' => false ); + } $column = $this->match_type( WP_Markdown_Native_SQL_Token::STAR ) ? null : $this->identifier(); if ( $distinct && ( 'COUNT' !== $function || null === $column ) ) { $this->unsupported( $argument ); diff --git a/tests/smoke-native-aggregates.php b/tests/smoke-native-aggregates.php index edb4406..3c0a3df 100644 --- a/tests/smoke-native-aggregates.php +++ b/tests/smoke-native-aggregates.php @@ -35,6 +35,14 @@ function mdi_aggregate_row( WP_Markdown_Native_Query_Runtime $runtime, string $s } $totals = mdi_aggregate_row( $runtime, 'SELECT SUM(score) AS total, AVG(score) AS mean, MIN(score) AS lowest, MAX(score) AS highest FROM wp_items' ); +$conditional_sql = "SELECT COUNT(*) AS total, SUM(CASE WHEN kind = 'a' THEN 1 ELSE 0 END) AS completed, SUM(CASE WHEN kind LIKE 'b%' OR kind = 'c' THEN 1 ELSE 0 END) AS skipped FROM wp_items"; +$conditional = mdi_aggregate_row( $runtime, $conditional_sql ); +$conditional_empty = mdi_aggregate_row( $runtime, $conditional_sql . " WHERE kind = 'missing'" ); +$numeric_scalar = mdi_aggregate_row( $runtime, 'SELECT SUM(COALESCE(score, 0)) AS total, AVG(ABS(score)) AS mean FROM wp_items' ); +$scalar_null = mdi_aggregate_row( $runtime, "SELECT SUM(ABS(score)) AS total FROM wp_items WHERE kind = 'c'" ); +$bad_scalar = mdi_aggregate_row( $runtime, 'SELECT SUM(ABS(missing)) AS total FROM wp_items' ); +$bad_type = mdi_aggregate_row( $runtime, "SELECT SUM(COALESCE(kind, 'bad')) AS total FROM wp_items WHERE id = 999" ); +$conditional_groups = $runtime->execute( new WP_Markdown_Query_Request( "SELECT kind, COUNT(*) AS total, SUM(CASE WHEN score > 15 THEN 1 ELSE 0 END) AS high FROM wp_items GROUP BY kind ORDER BY kind" ) ); $counts = mdi_aggregate_row( $runtime, 'SELECT COUNT(score) AS scored FROM wp_items' ); $all_rows = mdi_aggregate_row( $runtime, 'SELECT COUNT(*) FROM wp_items' ); $filtered = mdi_aggregate_row( $runtime, "SELECT SUM(score) AS total FROM wp_items WHERE kind = 'a'" ); @@ -53,6 +61,11 @@ function mdi_aggregate_row( WP_Markdown_Native_Query_Runtime $runtime, string $s usort( $joined_rows, static fn( array $left, array $right ): int => (int) $left['score'] <=> (int) $right['score'] ); $checks = array( + 'aliased row counts compose with conditional aggregates' => array( 'total' => '4', 'completed' => '2', 'skipped' => '2' ) === $conditional, + 'conditional aggregates retain empty-set NULL and count semantics' => array( 'total' => '0', 'completed' => null, 'skipped' => null ) === $conditional_empty, + 'numeric scalar aggregates reuse row-local evaluation' => array( 'total' => '60', 'mean' => '20' ) === $numeric_scalar && array( 'total' => null ) === $scalar_null, + 'aggregate expressions validate columns and types even on empty sets' => 'unsupported_column' === ( $bad_scalar['unsupported'] ?? null ) && 'unsupported_aggregate' === ( $bad_type['unsupported'] ?? null ), + 'conditional aggregates execute independently per group' => $conditional_groups->succeeded() && array( array( 'kind' => 'a', 'total' => '2', 'high' => '1' ), array( 'kind' => 'b', 'total' => '1', 'high' => '1' ), array( 'kind' => 'c', 'total' => '1', 'high' => '0' ) ) === $conditional_groups->corpus_result()['rows'], 'multiple explicit grouping columns preserve every projected value' => $grouped->succeeded() && array( array( 'kind' => 'c', 'score' => null, 'n' => '1' ), array( 'kind' => 'a', 'score' => '10', 'n' => '1' ), From 8f50078ee339f1a82e369e6b4a05efb744a730dd Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 10 Sep 2026 18:55:15 -0400 Subject: [PATCH 53/53] fix(native): evaluate scalar updates with nested predicates --- ...lass-wp-markdown-native-post-mutations.php | 8 ++- ...lass-wp-markdown-native-query-executor.php | 9 ++- .../class-wp-markdown-native-query-parser.php | 4 +- ...class-wp-markdown-native-query-runtime.php | 1 + ...ss-wp-markdown-native-scalar-evaluator.php | 60 +++++++++++++++++++ ...wp-markdown-native-table-insert-parser.php | 57 ++++++++++++++---- ...ass-wp-markdown-native-table-mutations.php | 43 ++++++++++--- ...ss-wp-markdown-native-table-statements.php | 13 ++-- tests/smoke-native-table-write.php | 32 ++++++++++ 9 files changed, 198 insertions(+), 29 deletions(-) create mode 100644 inc/native/class-wp-markdown-native-scalar-evaluator.php diff --git a/inc/native/class-wp-markdown-native-post-mutations.php b/inc/native/class-wp-markdown-native-post-mutations.php index b7b72bc..18f7da7 100644 --- a/inc/native/class-wp-markdown-native-post-mutations.php +++ b/inc/native/class-wp-markdown-native-post-mutations.php @@ -75,6 +75,9 @@ private function write( WP_Markdown_Query_Request $request ): WP_Markdown_Query_ return $write; } foreach ( $write->predicates() as $predicate ) { + if ( $predicate instanceof WP_Markdown_Native_Table_Predicate_Group ) { + return $this->failure( 'unsupported_predicate', 'mdi-native post mutations require simple conjunctive restrictions.' ); + } if ( $predicate instanceof WP_Markdown_Native_Table_Subquery_Predicate ) { return $this->failure( 'unsupported_subquery_shape', 'mdi-native post mutations do not support IN subqueries.' ); } @@ -89,7 +92,10 @@ private function write( WP_Markdown_Query_Request $request ): WP_Markdown_Query_ return $this->failure( 'unsupported_mutation_column', 'The WHERE restriction names a column outside the wp_posts schema.' ); } } - foreach ( array_keys( $write->values() ) as $column ) { + foreach ( $write->values() as $column => $value ) { + if ( $value instanceof WP_Markdown_Native_Query_Scalar_Expression ) { + return $this->failure( 'unsupported_assignment', 'mdi-native post mutations do not yet evaluate scalar assignments.' ); + } if ( ! $schema->has_column( (string) $column ) ) { return $this->failure( 'unsupported_mutation_column', 'The assignment names a column outside the wp_posts schema.' ); } diff --git a/inc/native/class-wp-markdown-native-query-executor.php b/inc/native/class-wp-markdown-native-query-executor.php index b15ff77..48afb02 100644 --- a/inc/native/class-wp-markdown-native-query-executor.php +++ b/inc/native/class-wp-markdown-native-query-executor.php @@ -2203,7 +2203,11 @@ private function interleave_scalar_projection( array $regular, array $scalar_pro } /** Evaluate a lowered row-local scalar expression after filtering. */ - private function evaluate_scalar( WP_Markdown_Native_Query_Scalar_Expression $expression, array $row, WP_Markdown_Native_Table_Schema $schema ): int|string|null { + public function evaluate_scalar( WP_Markdown_Native_Query_Scalar_Expression $expression, array $row, WP_Markdown_Native_Table_Schema $schema ): int|string|null { + $this->statement_now ??= gmdate( 'Y-m-d H:i:s' ); + if ( WP_Markdown_Native_Scalar_Evaluator::supports( $expression ) ) { + return WP_Markdown_Native_Scalar_Evaluator::evaluate( $expression, $row ); + } $values = array_map( fn( WP_Markdown_Native_Query_Scalar_Expression $argument ): int|string|null => $this->evaluate_scalar( $argument, $row, $schema ), $expression->arguments() @@ -2285,6 +2289,9 @@ private function evaluate_scalar( WP_Markdown_Native_Query_Scalar_Expression $ex private function scalar_number( int|float|string|null $value ): int|string|null|float { if ( null === $value ) { return null; } + if ( is_int( $value ) || ( is_string( $value ) && (string) (int) $value === $value ) ) { + return (int) $value; + } $number = (float) $value; return floor( $number ) === $number ? (int) $number : (string) $number; } diff --git a/inc/native/class-wp-markdown-native-query-parser.php b/inc/native/class-wp-markdown-native-query-parser.php index b456d23..88dc6a7 100644 --- a/inc/native/class-wp-markdown-native-query-parser.php +++ b/inc/native/class-wp-markdown-native-query-parser.php @@ -394,7 +394,7 @@ private function lower_predicate( WP_Markdown_Native_SQL_Predicate $predicate, ? ); } - private function lower_scalar_expression( WP_Markdown_Native_SQL_Scalar_Expression $expression, ?string $base_source, ?string $flat_source = null, array $bindings = array() ): WP_Markdown_Native_Query_Scalar_Expression { + public function lower_scalar_expression( WP_Markdown_Native_SQL_Scalar_Expression $expression, ?string $base_source, ?string $flat_source = null, array $bindings = array() ): WP_Markdown_Native_Query_Scalar_Expression { $source = $this->column_source( $expression->identifier(), $base_source, $bindings ); return new WP_Markdown_Native_Query_Scalar_Expression( $expression->kind(), @@ -1061,7 +1061,7 @@ private function scalar_expression(): WP_Markdown_Native_SQL_Scalar_Expression { return new WP_Markdown_Native_SQL_Scalar_Expression( $function, null, null, $arguments ); } - private function scalar_value(): WP_Markdown_Native_SQL_Scalar_Expression { + public function scalar_value(): WP_Markdown_Native_SQL_Scalar_Expression { $value = $this->scalar_term(); while ( in_array( $this->current()->type(), array( WP_Markdown_Native_SQL_Token::PLUS, WP_Markdown_Native_SQL_Token::MINUS ), true ) ) { $operator = $this->current()->type(); ++$this->current; diff --git a/inc/native/class-wp-markdown-native-query-runtime.php b/inc/native/class-wp-markdown-native-query-runtime.php index d92b994..b005409 100644 --- a/inc/native/class-wp-markdown-native-query-runtime.php +++ b/inc/native/class-wp-markdown-native-query-runtime.php @@ -7,6 +7,7 @@ require_once __DIR__ . '/../class-wp-markdown-canonical-option-path.php'; require_once __DIR__ . '/class-wp-markdown-native-query-contracts.php'; +require_once __DIR__ . '/class-wp-markdown-native-scalar-evaluator.php'; require_once __DIR__ . '/class-wp-markdown-native-sql-session.php'; require_once __DIR__ . '/class-wp-markdown-native-query-schema.php'; require_once __DIR__ . '/class-wp-markdown-native-schema-catalog.php'; diff --git a/inc/native/class-wp-markdown-native-scalar-evaluator.php b/inc/native/class-wp-markdown-native-scalar-evaluator.php new file mode 100644 index 0000000..7cd8fa5 --- /dev/null +++ b/inc/native/class-wp-markdown-native-scalar-evaluator.php @@ -0,0 +1,60 @@ +kind(), array( 'literal', 'column', 'COALESCE', 'ADD', 'SUBTRACT', 'MULTIPLY', 'DIVIDE' ), true ) ) { + return false; + } + foreach ( $expression->arguments() as $argument ) { + if ( ! self::supports( $argument ) ) { + return false; + } + } + return true; + } + + /** @param array $row */ + public static function evaluate( WP_Markdown_Native_Query_Scalar_Expression $expression, array $row ): int|string|null { + if ( ! self::supports( $expression ) ) { + throw new LogicException( 'Unsupported shared scalar expression.' ); + } + $values = array_map( static fn( WP_Markdown_Native_Query_Scalar_Expression $argument ): int|string|null => self::evaluate( $argument, $row ), $expression->arguments() ); + return match ( $expression->kind() ) { + 'literal' => $expression->literal(), + 'column' => null === $expression->source() + ? ( $row[ (string) $expression->column() ] ?? null ) + : ( $row[ $expression->source() ][ (string) $expression->column() ] ?? null ), + 'COALESCE' => self::first_non_null( $values ), + 'ADD' => in_array( null, $values, true ) ? null : self::number( self::number( $values[0] ) + self::number( $values[1] ) ), + 'SUBTRACT' => in_array( null, $values, true ) ? null : self::number( self::number( $values[0] ) - self::number( $values[1] ) ), + 'MULTIPLY' => in_array( null, $values, true ) ? null : self::number( self::number( $values[0] ) * self::number( $values[1] ) ), + 'DIVIDE' => in_array( null, $values, true ) || 0.0 === (float) self::number( $values[1] ) ? null : self::number( self::number( $values[0] ) / self::number( $values[1] ) ), + }; + } + + /** @param array $values */ + private static function first_non_null( array $values ): int|string|null { + foreach ( $values as $value ) { + if ( null !== $value ) { + return $value; + } + } + return null; + } + + private static function number( int|float|string|null $value ): int|string|null|float { + if ( null === $value ) { + return null; + } + if ( is_int( $value ) || ( is_string( $value ) && (string) (int) $value === $value ) ) { + return (int) $value; + } + $number = (float) $value; + return floor( $number ) === $number ? (int) $number : (string) $number; + } +} diff --git a/inc/native/class-wp-markdown-native-table-insert-parser.php b/inc/native/class-wp-markdown-native-table-insert-parser.php index d7496e5..2451c3c 100644 --- a/inc/native/class-wp-markdown-native-table-insert-parser.php +++ b/inc/native/class-wp-markdown-native-table-insert-parser.php @@ -125,7 +125,7 @@ public function parse_write( WP_Markdown_Query_Request $request ): WP_Markdown_N $this->word( 'UPDATE' ); $table = $this->identifier(); $this->word( 'SET' ); - $values = $this->assignments(); + $values = $this->assignments( $table ); } $predicates = $this->where_predicates(); $this->type( WP_Markdown_Native_SQL_Token::END ); @@ -142,13 +142,26 @@ public function parse_write( WP_Markdown_Query_Request $request ): WP_Markdown_N } } - /** @return array */ - private function assignments(): array { + /** @return array */ + private function assignments( string $table ): array { $values = array(); do { $column = $this->identifier(); + if ( array_key_exists( $column, $values ) ) { + throw new WP_Markdown_Native_SQL_Parse_Error( 'duplicate_mutation_column', $this->current()->sql_offset(), 'Repeated UPDATE targets are not supported.' ); + } $this->type( WP_Markdown_Native_SQL_Token::EQUALS ); - $values[ $column ] = $this->literal(); + $parser = new WP_Markdown_Native_Select_AST_Parser( $this->tokens, $this->position ); + $expression = $parser->scalar_value(); + $this->position = $parser->position(); + foreach ( $expression->columns() as $source ) { + if ( null !== $source->qualifier() && 0 !== strcasecmp( $table, $source->qualifier() ) ) { + throw new WP_Markdown_Native_SQL_Parse_Error( 'unsupported_mutation_column', $source->sql_offset(), 'An UPDATE expression must reference its target table.' ); + } + } + $values[ $column ] = 'literal' === $expression->kind() + ? ( null === $expression->literal() ? null : (string) $expression->literal() ) + : ( new WP_Markdown_Native_Query_Parser() )->lower_scalar_expression( $expression, null, $table ); if ( WP_Markdown_Native_SQL_Token::COMMA !== $this->current()->type() ) { break; } @@ -177,27 +190,25 @@ private function where_predicates(): array { } $this->word( 'WHERE' ); - $predicates = array( $this->where_disjunction() ); - while ( $this->is_word( 'AND' ) ) { - ++$this->position; - $predicates[] = $this->where_disjunction(); - } - return array_filter( $predicates ); + $predicate = $this->where_disjunction(); + return $predicate instanceof WP_Markdown_Native_Table_Predicate_Group && $predicate->all() + ? $predicate->any() + : array( $predicate ); } /** @return WP_Markdown_Native_Table_Predicate|WP_Markdown_Native_Table_Predicate_Group|WP_Markdown_Native_Table_Subquery_Predicate|null */ private function where_disjunction() { - $alternatives = array( $this->where_factor() ); + $alternatives = array( $this->where_conjunction() ); while ( $this->is_word( 'OR' ) ) { ++$this->position; - $alternatives[] = $this->where_factor(); + $alternatives[] = $this->where_conjunction(); } $alternatives = array_values( array_filter( $alternatives ) ); if ( array() === $alternatives ) { return null; } foreach ( $alternatives as $alternative ) { - if ( $alternative instanceof WP_Markdown_Native_Table_Subquery_Predicate && 1 !== count( $alternatives ) ) { + if ( 1 !== count( $alternatives ) && $this->has_subquery( $alternative ) ) { throw new WP_Markdown_Native_SQL_Parse_Error( 'unsupported_subquery_shape', $this->current()->sql_offset(), 'mdi-native supports IN subqueries only as conjunctive write restrictions.' ); } } @@ -206,6 +217,26 @@ private function where_disjunction() { : new WP_Markdown_Native_Table_Predicate_Group( $alternatives ); } + /** @return WP_Markdown_Native_Table_Predicate|WP_Markdown_Native_Table_Predicate_Group|WP_Markdown_Native_Table_Subquery_Predicate|null */ + private function where_conjunction() { + $terms = array( $this->where_factor() ); + while ( $this->is_word( 'AND' ) ) { + ++$this->position; + $terms[] = $this->where_factor(); + } + return 1 === count( $terms ) ? $terms[0] : new WP_Markdown_Native_Table_Predicate_Group( $terms, true ); + } + + private function has_subquery( mixed $predicate ): bool { + if ( $predicate instanceof WP_Markdown_Native_Table_Subquery_Predicate ) { return true; } + if ( $predicate instanceof WP_Markdown_Native_Table_Predicate_Group ) { + foreach ( $predicate->any() as $term ) { + if ( $this->has_subquery( $term ) ) { return true; } + } + } + return false; + } + /** @return WP_Markdown_Native_Table_Predicate|WP_Markdown_Native_Table_Predicate_Group|WP_Markdown_Native_Table_Subquery_Predicate|null */ private function where_factor() { if ( WP_Markdown_Native_SQL_Token::LEFT_PAREN === $this->current()->type() ) { diff --git a/inc/native/class-wp-markdown-native-table-mutations.php b/inc/native/class-wp-markdown-native-table-mutations.php index 1316755..ccc48a2 100644 --- a/inc/native/class-wp-markdown-native-table-mutations.php +++ b/inc/native/class-wp-markdown-native-table-mutations.php @@ -532,11 +532,24 @@ private function execute_write( WP_Markdown_Query_Request $request ): WP_Markdow } } } - foreach ( array_keys( $write->values() ) as $column ) { + foreach ( $write->values() as $column => $value ) { if ( ! $schema->has_column( (string) $column ) ) { return $this->failure( 'unsupported_mutation_column', 'The assignment names a column outside the persisted table schema.' ); } + if ( $value instanceof WP_Markdown_Native_Query_Scalar_Expression ) { + foreach ( $value->columns() as $source ) { + if ( ! $schema->has_column( $source ) ) { + return $this->failure( 'unsupported_mutation_column', 'The expression names a column outside the persisted table schema.' ); + } + } + foreach ( $value->predicates() as $predicate ) { + if ( ! $schema->supports_predicate( $predicate ) ) { + return $this->failure( 'unsupported_predicate', 'The assignment expression uses an unsupported predicate.' ); + } + } + } } + $scalar_runtime = new WP_Markdown_Native_Query_Runtime( $this->registry, new WP_Markdown_Native_Query_Parser() ); $root = $this->root_for( $write->table() ); $directory = $this->tables_directory( $root ); if ( $directory instanceof WP_Markdown_Query_Result ) { @@ -574,7 +587,13 @@ private function execute_write( WP_Markdown_Query_Request $request ): WP_Markdow if ( ! $write->is_update() ) { continue; } - $updated = array_merge( $row, $write->values() ); + $updated = $row; + foreach ( $write->values() as $column => $value ) { + $value = $value instanceof WP_Markdown_Native_Query_Scalar_Expression + ? $scalar_runtime->evaluate_scalar( $value, $updated, $schema ) + : $value; + $updated[ $column ] = null === $value ? null : (string) $value; + } if ( true !== $schema->validate_row( $updated ) ) { return $this->failure( 'invalid_update_row', 'The UPDATE row is outside the persisted table schema.' ); } @@ -628,6 +647,12 @@ private function resolve_subquery_predicates( array $predicates, WP_Markdown_Nat $query_parser = new WP_Markdown_Native_Query_Parser(); $query_runtime = new WP_Markdown_Native_Query_Runtime( $this->registry, $query_parser ); foreach ( $predicates as $predicate ) { + if ( $predicate instanceof WP_Markdown_Native_Table_Predicate_Group ) { + $terms = $this->resolve_subquery_predicates( $predicate->any(), $schema, $target_table ); + if ( $terms instanceof WP_Markdown_Query_Result ) { return $terms; } + $resolved[] = new WP_Markdown_Native_Table_Predicate_Group( $terms, $predicate->all() ); + continue; + } if ( ! $predicate instanceof WP_Markdown_Native_Table_Subquery_Predicate ) { $resolved[] = $predicate; continue; @@ -698,11 +723,12 @@ private function index_excludes( array $index, array $predicates ): bool { private function index_predicate_excludes( array $index, mixed $predicate ): bool { if ( $predicate instanceof WP_Markdown_Native_Table_Predicate_Group ) { foreach ( $predicate->any() as $alternative ) { - if ( ! $this->index_predicate_excludes( $index, $alternative ) ) { - return false; + $excluded = $this->index_predicate_excludes( $index, $alternative ); + if ( $excluded === $predicate->all() ) { + return $excluded; } } - return true; + return ! $predicate->all(); } if ( ! $predicate instanceof WP_Markdown_Native_Table_Predicate || '=' !== $predicate->operator() ) { return false; @@ -743,11 +769,12 @@ private function restricts( array $row, array $predicates, WP_Markdown_Native_Ta private function restricts_predicate( array $row, $predicate, WP_Markdown_Native_Table_Schema $schema ): bool { if ( $predicate instanceof WP_Markdown_Native_Table_Predicate_Group ) { foreach ( $predicate->any() as $alternative ) { - if ( $this->restricts_predicate( $row, $alternative, $schema ) ) { - return true; + $matches = $this->restricts_predicate( $row, $alternative, $schema ); + if ( $matches !== $predicate->all() ) { + return $matches; } } - return array() === $predicate->any(); + return $predicate->all(); } $value = $row[ $predicate->column() ] ?? null; if ( $predicate->matches_null() && null === $value ) { diff --git a/inc/native/class-wp-markdown-native-table-statements.php b/inc/native/class-wp-markdown-native-table-statements.php index 217b7fd..fdc7e43 100644 --- a/inc/native/class-wp-markdown-native-table-statements.php +++ b/inc/native/class-wp-markdown-native-table-statements.php @@ -82,18 +82,23 @@ public function operator(): string { } } -/** One OR group of restrictions, evaluated as a disjunction per row. */ +/** A nested group of restrictions, using AND when all is true and OR otherwise. */ final class WP_Markdown_Native_Table_Predicate_Group { /** @param array $any */ public function __construct( - private readonly array $any + private readonly array $any, + private readonly bool $all = false ) {} /** @return array */ public function any(): array { return $this->any; } + + public function all(): bool { + return $this->all; + } } /** A typed single-column IN predicate whose members come from a typed SELECT. */ @@ -116,7 +121,7 @@ public function query(): WP_Markdown_Native_SQL_Select { final class WP_Markdown_Native_Table_Write { /** - * @param array $values Assignments for an UPDATE. + * @param array $values Assignments for an UPDATE, in evaluation order. * @param array $predicates Conjunctive restrictions. */ public function __construct( @@ -134,7 +139,7 @@ public function table(): string { return $this->table; } - /** @return array */ + /** @return array */ public function values(): array { return $this->values; } diff --git a/tests/smoke-native-table-write.php b/tests/smoke-native-table-write.php index fc69754..614ab6e 100644 --- a/tests/smoke-native-table-write.php +++ b/tests/smoke-native-table-write.php @@ -266,7 +266,39 @@ function column_values( string $root, string $column, string $table = 'agents' ) $runtime->execute( new WP_Markdown_Query_Request( 'ROLLBACK', 'wp_' ) ); $after_rollback = column_values( $root, 'label' ); +$runtime->execute( new WP_Markdown_Query_Request( 'CREATE TABLE wp_claims (id bigint NOT NULL, generation bigint NULL, mirror bigint NULL, state varchar(20) NOT NULL, claimed_at datetime NULL, label varchar(10) NOT NULL, PRIMARY KEY (id))' ) ); +$claim_fixture = $runtime->execute( new WP_Markdown_Query_Request( "INSERT INTO wp_claims (id, generation, mirror, state, claimed_at, label) VALUES (1,NULL,NULL,'preparing',NULL,'one'),(2,2,NULL,'enqueuing','2026-09-10 23:00:00','1234567890'),(3,2,NULL,'enqueuing','2026-09-10 20:00:00','three'),(4,9,NULL,'enqueuing','2026-09-10 20:00:00','four')" ) ); +if ( ! $claim_fixture->succeeded() ) { throw new RuntimeException( json_encode( $claim_fixture->diagnostic() ) ); } +$claim_sql = "UPDATE wp_claims SET generation = COALESCE(generation, 0) + 1, mirror = generation, state = 'enqueuing', claimed_at = '2026-09-10 23:00:00' WHERE id < 4 AND (state IN ('preparing', 'enqueue_failed') OR (state = 'enqueuing' AND (claimed_at IS NULL OR claimed_at < '2026-09-10 22:00:00')))"; +$claimed = $runtime->execute( new WP_Markdown_Query_Request( $claim_sql ) ); +$claim_rows = table_rows( $root, 'claims' ); +$claim_replay = $runtime->execute( new WP_Markdown_Query_Request( $claim_sql ) ); +$bad_expression = $runtime->execute( new WP_Markdown_Query_Request( 'UPDATE wp_claims SET generation = COALESCE(missing, 0) + 1 WHERE id = 999' ) ); +$wrong_source = $runtime->execute( new WP_Markdown_Query_Request( 'UPDATE wp_claims SET generation = other.generation WHERE id = 999' ) ); +$repeated_target = $runtime->execute( new WP_Markdown_Query_Request( 'UPDATE wp_claims SET generation = 1, generation = generation + 1' ) ); +$late_failure = $runtime->execute( new WP_Markdown_Query_Request( "UPDATE wp_claims SET label = CONCAT(label, '!') WHERE id IN (1, 2)" ) ); +$after_late_failure = table_rows( $root, 'claims' ); +$unique_failure = $runtime->execute( new WP_Markdown_Query_Request( 'UPDATE wp_claims SET id = COALESCE(mirror, 1) WHERE id IN (1, 2)' ) ); +$after_unique_failure = table_rows( $root, 'claims' ); +$runtime->execute( new WP_Markdown_Query_Request( 'BEGIN' ) ); +$precedence = $runtime->execute( new WP_Markdown_Query_Request( "UPDATE wp_claims SET generation = generation + 10 WHERE id = 1 OR id = 3 AND state = 'never'" ) ); +$precedence_rows = table_rows( $root, 'claims' ); +$nested_delete = $runtime->execute( new WP_Markdown_Query_Request( "DELETE FROM wp_claims WHERE (id = 1 OR id = 3) AND state = 'enqueuing'" ) ); +$runtime->execute( new WP_Markdown_Query_Request( "UPDATE wp_claims SET generation = '9007199254740993' WHERE id = 4" ) ); +$large_increment = $runtime->execute( new WP_Markdown_Query_Request( 'UPDATE wp_claims SET generation = generation + 1 WHERE id = 4' ) ); +$large_rows = table_rows( $root, 'claims' ); +$runtime->execute( new WP_Markdown_Query_Request( 'ROLLBACK' ) ); +$restored_claims = table_rows( $root, 'claims' ); + $checks = array( + 'nested lease predicates claim only eligible rows and assignments observe earlier values' => 2 === $claimed->return_value() && array( '1', '2', '3', '9' ) === array_column( $claim_rows, 'generation' ) && array( '1', null, '3', null ) === array_column( $claim_rows, 'mirror' ), + 'a repeated claim cannot take an active lease' => 0 === $claim_replay->return_value(), + 'unknown sources and repeated targets fail before mutation even without matching rows' => ! $bad_expression->succeeded() && ! $wrong_source->succeeded() && ! $repeated_target->succeeded(), + 'a later invalid row prevents every scalar UPDATE write' => ! $late_failure->succeeded() && $claim_rows === $after_late_failure, + 'scalar assignments preserve unique-key enforcement atomically' => ! $unique_failure->succeeded() && $claim_rows === $after_unique_failure, + 'AND binds tighter than OR in write predicates' => 1 === $precedence->return_value() && array( '11', '2', '3', '9' ) === array_column( $precedence_rows, 'generation' ), + 'rollback restores scalar updates and grouped deletes' => 2 === $nested_delete->return_value() && $claim_rows === $restored_claims, + 'integer arithmetic preserves values beyond floating-point precision' => 1 === $large_increment->return_value() && '9007199254740994' === $large_rows[1]['generation'], 'the fixture table is created' => 0 === $created->return_value() || true === $created->succeeded(), 'a disjunctive NULL restriction updates every matching row' => 2 === $backfill->return_value() && array( 'default', 'default', 'keep' ) === $after_backfill,