diff --git a/REMAINING_102_GAPS.md b/REMAINING_102_GAPS.md new file mode 100644 index 0000000..bc76e0f --- /dev/null +++ b/REMAINING_102_GAPS.md @@ -0,0 +1,18 @@ +# 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 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/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 3b16572..0c9eb04 100644 --- a/inc/native/class-wp-markdown-native-authoritative-snapshot-runtime.php +++ b/inc/native/class-wp-markdown-native-authoritative-snapshot-runtime.php @@ -10,10 +10,11 @@ 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 */ - public function __construct( private WP_Markdown_Query_Runtime $runtime, private array $provenance ) {} + /** @param 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 ); $connection = method_exists( $database, 'markdown_db_mysql_connection' ) ? $database->markdown_db_mysql_connection() : ( $database->dbh ?? null ); @@ -26,6 +27,8 @@ public static function capture( object $database, string $sql, string $prefix ): } $prefixes = self::schema_prefixes( $database, $prefix ); + $database_name = self::database_name( $connection ); + $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 ) { @@ -43,11 +46,60 @@ 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' ); } + $temporary = 1 === preg_match( '/^CREATE\s+TEMPORARY\s+TABLE\b/i', $definition ); + 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, 'rows' => count( $rows ), 'sha256' => hash( 'sha256', self::encode_rows( $rows ) ), 'schema_sha256' => hash( 'sha256', $definition ) ); + $provenance[] = array( 'table' => $table, 'exists' => true, 'temporary' => $temporary, '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 ); + } + + 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; + } + if ( ( is_file( $path ) ? (int) filesize( $path ) : 0 ) >= 65536 ) { + return; + } + $event = array( 'phase' => $phase, 'file_sha256' => hash_file( 'sha256', __FILE__ ) ); + if ( null !== $sql && strlen( $sql ) <= 65536 ) { + try { + $event['sql_sha256'] = hash( 'sha256', $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' ); + } + } + $encoded = json_encode( $event, JSON_UNESCAPED_SLASHES ) . "\n"; + 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 ); + } } - return new self( new WP_Markdown_Native_Query_Runtime( $registry ), $provenance ); } /** @return array */ @@ -59,6 +111,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(); @@ -85,15 +143,58 @@ 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_filter( + array( + 'read_connection' => 'authoritative_mysql_connection_pre_query', + 'database_sha256' => null === $this->database_name ? null : hash( 'sha256', $this->database_name ), + 'tables' => $this->provenance, + ), + static fn( mixed $value ): bool => null !== $value + ); + } + + 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 ) . ')'; + } + return 'CREATE TABLE `' . $table . '` (' . implode( ',', $columns ) . ')'; } /** @return 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 465099e..65497f7 100644 --- a/inc/native/class-wp-markdown-native-query-executor.php +++ b/inc/native/class-wp-markdown-native-query-executor.php @@ -33,6 +33,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}> */ @@ -42,6 +44,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, @@ -52,9 +55,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 { @@ -85,6 +90,15 @@ public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query } private function execute_request( WP_Markdown_Query_Request $request ): WP_Markdown_Query_Result { + 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.' ); + } + // 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 ); @@ -109,6 +123,10 @@ private function execute_unlocked_request( WP_Markdown_Query_Request $request ): 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; + } $advisory_lock = $this->advisory_lock_query( $request->sql() ); if ( null !== $advisory_lock ) { return $advisory_lock; @@ -116,16 +134,22 @@ private function execute_unlocked_request( WP_Markdown_Query_Request $request ): // 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 ) ) ); } - if ( 1 === preg_match( '/^\s*SELECT\s+(@@(?:SESSION\.)?(IN_TRANSACTION|AUTOCOMMIT))\s*;?\s*$/i', $request->sql(), $match ) ) { - $column = $match[1]; + $tableless = $this->tableless_scalar_projection( $request->sql() ); + if ( null !== $tableless ) { + return $tableless; + } + 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 ) ) @@ -147,10 +171,6 @@ private function execute_unlocked_request( WP_Markdown_Query_Request $request ): ? $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' ); $start = WP_Markdown_Operation_Profile::begin(); try { $plan = $this->parser->parse( $request->sql() ); @@ -185,6 +205,117 @@ private function execute_select_plan( WP_Markdown_Native_Query_Plan|WP_Markdown_ return $this->execute_query_plan( $plan ); } + 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; + } + if ( ( is_file( $path ) ? (int) filesize( $path ) : 0 ) >= 65536 ) { + return; + } + $event = array( 'phase' => $phase, 'file_sha256' => hash_file( 'sha256', __FILE__ ) ); + if ( null !== $sql && strlen( $sql ) <= 65536 ) { + try { + $event['sql_sha256'] = hash( 'sha256', $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' ); + } + } + $encoded = json_encode( $event, JSON_UNESCAPED_SLASHES ) . "\n"; + 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 ); + } + } + } + + /** 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 ) { + 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 ) ); + } + 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 { + $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' => 8 ) ) + ); + } + + 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 ) { + return null; + } + $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() + ) { + 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; + } + 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 { + if ( null === $value ) { return 6; } + if ( 'literal' === $expression->kind() ) { return is_int( $value ) ? 3 : ( is_numeric( $value ) ? 246 : 253 ); } + return 'JSON_VALID' === $expression->kind() ? 8 : 253; + } + /** Release this logical connection's root-scoped advisory locks. */ public function close(): void { $this->advisory_locks?->close(); @@ -2072,6 +2203,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] ) ) ), @@ -2104,6 +2236,44 @@ 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 { + // 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; @@ -2286,9 +2456,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-query-parser.php b/inc/native/class-wp-markdown-native-query-parser.php index c4f88d5..b030a32 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', $sql ); $ast = $this->parse_ast( $sql ); if ( $ast instanceof WP_Markdown_Query_Result ) { return $ast; @@ -22,6 +23,81 @@ public function parse( string $sql ): WP_Markdown_Native_Query_Plan|WP_Markdown_ } } + 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; + } + if ( ( is_file( $path ) ? (int) filesize( $path ) : 0 ) >= 65536 ) { + return; + } + $event = array( 'phase' => $phase, 'file_sha256' => hash_file( 'sha256', __FILE__ ) ); + if ( null !== $sql && strlen( $sql ) <= 65536 ) { + try { + $event['sql_sha256'] = hash( 'sha256', $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' ); + } + } + $encoded = json_encode( $event, JSON_UNESCAPED_SLASHES ) . "\n"; + 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 ); + } + } + } + + /** + * 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. @@ -456,6 +532,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; @@ -473,7 +557,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; @@ -769,7 +853,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() ) ); } @@ -848,7 +932,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 ), @@ -1257,10 +1341,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-query-runtime.php b/inc/native/class-wp-markdown-native-query-runtime.php index da039bf..a53b35d 100644 --- a/inc/native/class-wp-markdown-native-query-runtime.php +++ b/inc/native/class-wp-markdown-native-query-runtime.php @@ -106,6 +106,15 @@ 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. + // 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_padded' ), + '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 @@ -116,7 +125,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 90eb1a2..0230c4e 100644 --- a/inc/native/class-wp-markdown-native-schema-introspection.php +++ b/inc/native/class-wp-markdown-native-schema-introspection.php @@ -224,9 +224,13 @@ 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() + 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 { @@ -250,6 +254,298 @@ 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 { + 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; + } + $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.' ); + } + $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; + 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[] = $this->database_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 || '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; } + $values[] = (string) $token->value(); + } else { + 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(); + } 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 ( ! 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 = $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 ) ); + } + $rows = array(); + 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 ( 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; + } + $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, $this->information_schema_metadata( $projection, $catalog ) ); + } catch ( WP_Markdown_Native_SQL_Parse_Error ) { + return null; + } + } + + /** + * 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; + } + + /** 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(); + foreach ( $definition['columns'] as $position => $column ) { + $rows[] = array( + 'TABLE_SCHEMA' => $this->database_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' : '', + 'CHARACTER_MAXIMUM_LENGTH' => null === $this->character_maximum_length( $column ) ? null : (string) $this->character_maximum_length( $column ), + ); + } + return $rows; + } + + /** @return array */ + private function information_schema_table( string $table ): array { + return array( 'TABLE_SCHEMA' => $this->database_name(), 'TABLE_NAME' => $table, 'TABLE_TYPE' => 'BASE TABLE' ); + } + + /** @param array $column */ + private function character_maximum_length( array $column ): ?int { + $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 */ + 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' => match ( $column['name'] ) { + 'ORDINAL_POSITION', 'CHARACTER_MAXIMUM_LENGTH' => 8, + 'DATA_TYPE' => 251, + default => 253, + }, + ), + $projection + ); + } + /** * Report the server variables a file-backed engine can answer honestly. * @@ -290,7 +586,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 ); @@ -424,4 +720,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 : '' ); + } } 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/inc/native/class-wp-markdown-native-shadow-verifier.php b/inc/native/class-wp-markdown-native-shadow-verifier.php index f72ae59..9d6a24c 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,11 +107,15 @@ 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. 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' ); @@ -197,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'] ) { @@ -217,6 +222,8 @@ 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 ), + 'input_provenance' => $provenance ?? array(), ) ); } catch ( Throwable $error ) { @@ -239,7 +246,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 ) ), ); } @@ -334,6 +341,43 @@ 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 ) ) ), + '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 ) ) { @@ -425,7 +469,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/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/inc/native/class-wp-markdown-native-table-providers.php b/inc/native/class-wp-markdown-native-table-providers.php index cb9b927..6aa6aba 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'] ); @@ -643,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/run-mysql-shadow-corpus.php b/tests/run-mysql-shadow-corpus.php index 546d359..8efca89 100644 --- a/tests/run-mysql-shadow-corpus.php +++ b/tests/run-mysql-shadow-corpus.php @@ -26,6 +26,7 @@ $artifacts = $root . '/artifacts'; $report_path = '/tmp/mdi-shadow-report.json'; $report_name = 'mdi-shadow-report'; +$trace_path = '/tmp/mdi-shadow-runtime-trace.jsonl'; $revision = trim( (string) shell_exec( 'git -C ' . escapeshellarg( $repo ) . ' rev-parse HEAD' ) ); mkdir( $bootstrap, 0755, true ); mkdir( $state, 0755, true ); @@ -73,6 +74,7 @@ '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, ), 'services' => array( array( 'id' => 'mysql', @@ -85,7 +87,9 @@ 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 ), + ), ), ) ), 'artifacts' => array( 'directory' => $artifacts ), @@ -128,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-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-create-table.php b/tests/smoke-native-create-table.php index b7a3e3a..2422508 100644 --- a/tests/smoke-native-create-table.php +++ b/tests/smoke-native-create-table.php @@ -43,6 +43,19 @@ 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 (\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' ) ); $checks = array( 'generic CREATE TABLE returns the WordPress DDL success shape' => true === $created->return_value() @@ -61,6 +74,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; 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 ); diff --git a/tests/smoke-native-like-query.php b/tests/smoke-native-like-query.php index 0f202dc..3aedeac 100644 --- a/tests/smoke-native-like-query.php +++ b/tests/smoke-native-like-query.php @@ -28,6 +28,11 @@ $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_' ) ); +$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_' ) ); $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 +47,12 @@ '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 ), + '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 ), '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 ), diff --git a/tests/smoke-native-plugin-schema-query.php b/tests/smoke-native-plugin-schema-query.php index eab0c37..cfeb2a0 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,6 +121,15 @@ 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, 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()' ) ); +$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( @@ -137,7 +147,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(), @@ -160,6 +170,24 @@ 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 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 ) + && '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, 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(), + '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 ), + '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-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 ); 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..baca993 100644 --- a/tests/smoke-native-scalar-clauses.php +++ b/tests/smoke-native-scalar-clauses.php @@ -32,6 +32,16 @@ $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_' ) ); +$json_alias = $runtime->execute( new WP_Markdown_Query_Request( "SELECT JSON_VALID('[]') 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( '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 +70,21 @@ '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 ) + && '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 ) + && '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() ) + && 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-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, diff --git a/tests/smoke-native-shadow-sql-snapshot.php b/tests/smoke-native-shadow-sql-snapshot.php index 197513f..e714bf7 100644 --- a/tests/smoke-native-shadow-sql-snapshot.php +++ b/tests/smoke-native-shadow-sql-snapshot.php @@ -24,6 +24,8 @@ 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 $temporary_permanent_schema_exists = false; public bool $blog_table_absent = true; public int $errno = 0; /** @var array */ @@ -38,6 +40,10 @@ 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_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; @@ -58,9 +64,22 @@ 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_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' ) && str_contains( $sql, 'FROM information_schema.COLUMNS' ) ) { + 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; } @@ -229,8 +248,43 @@ 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 ); +$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' => 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' => 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( + $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_' ) ); +$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_' +)->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 ); @@ -238,6 +292,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 ) @@ -266,7 +321,20 @@ public function get_col_info( string $field ): array { 'duplicate JOIN aliases cannot count as compatible missing-table errors' => 1 === ( $duplicate_alias_report['counts']['unsupported'] ?? null ) && 0 === ( $duplicate_alias_report['counts']['compatible_missing_table_errors'] ?? null ), '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 ), + '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' ) + && 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 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 ), ); $failed = 0; 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(),