Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ final class WP_Markdown_Native_Authoritative_Snapshot_Runtime implements WP_Mark
private const MAX_ROWS_PER_TABLE = 10000;
private const MAX_BYTES_PER_TABLE = 8388608;

/** @param array<int,array{table:string,rows:int,sha256:string,schema_sha256:string}> $provenance */
/** @param array<int,array{table:string,exists:bool,rows?:int,sha256?:string,schema_sha256?:string}> $provenance */
public function __construct( private WP_Markdown_Query_Runtime $runtime, private array $provenance ) {}

public static function capture( object $database, string $sql, string $prefix ): self {
Expand All @@ -31,6 +31,11 @@ public static function capture( object $database, string $sql, string $prefix ):
foreach ( $tables as $table ) {
$quoted = '`' . str_replace( '`', '``', $table ) . '`';
$ddl = self::one_row( $connection, 'SHOW CREATE TABLE ' . $quoted );
if ( null === $ddl && 1146 === self::error_code( $connection ) ) {
// An absent source is a fact usable for an independent native error run.
$provenance[] = array( 'table' => $table, 'exists' => false );
continue;
}
$definition = is_array( $ddl ) ? (string) ( array_values( $ddl )[1] ?? '' ) : '';
$compiled = '' === $definition ? array() : WP_Markdown_Native_Schema_Catalog::compile( $definition, $prefixes, array( $table ) );
$schema_definition = 1 === count( $compiled ) ? reset( $compiled ) : null;
Expand All @@ -40,7 +45,7 @@ public static function capture( object $database, string $sql, string $prefix ):
}
$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, 'rows' => count( $rows ), 'sha256' => hash( 'sha256', self::encode_rows( $rows ) ), 'schema_sha256' => hash( 'sha256', $definition ) );
$provenance[] = array( 'table' => $table, 'exists' => true, 'rows' => count( $rows ), 'sha256' => hash( 'sha256', self::encode_rows( $rows ) ), 'schema_sha256' => hash( 'sha256', $definition ) );
}
return new self( new WP_Markdown_Native_Query_Runtime( $registry ), $provenance );
}
Expand All @@ -55,10 +60,32 @@ private static function schema_prefixes( object $database, string $prefix ): arr
}

public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query_Result {
return $this->runtime->execute( $request );
$result = $this->runtime->execute( $request );
$diagnostic = $result->diagnostic() ?? array();
if ( 'unsupported_table' !== ( $diagnostic['reason'] ?? null ) || ! $this->has_explicitly_absent_source( $request->sql() ) ) {
return $result;
}
return WP_Markdown_Query_Result::failure(
array(
'code' => 1146,
'reason' => 'missing_table',
'message' => 'The requested table does not exist.',
)
);
}

/** Only snapshot discovery, never an unregistered native table, proves absence. */
private function has_explicitly_absent_source( string $sql ): bool {
$absent = array();
foreach ( $this->provenance as $table ) {
if ( is_array( $table ) && false === ( $table['exists'] ?? null ) && is_string( $table['table'] ?? null ) ) {
$absent[] = $table['table'];
}
}
return array() !== array_intersect( self::tables_in( $sql ), $absent );
}

/** @return array{read_connection:string,tables:array<int,array{table:string,rows:int,sha256:string,schema_sha256:string}>} */
/** @return array{read_connection:string,tables:array<int,array{table:string,exists:bool,rows?:int,sha256?:string,schema_sha256?:string}>} */
public function provenance(): array {
return array( 'read_connection' => 'authoritative_mysql_connection_pre_query', 'tables' => $this->provenance );
}
Expand Down Expand Up @@ -191,6 +218,13 @@ private static function query( object $connection, string $sql ): mixed {
return $connection->query( $sql );
}

private static function error_code( object $connection ): int {
if ( isset( $connection->errno ) ) {
return (int) $connection->errno;
}
return method_exists( $connection, 'errno' ) ? (int) $connection->errno() : 0;
}

private static function free( object $result ): void {
if ( method_exists( $result, 'free' ) ) {
$result->free();
Expand Down
34 changes: 34 additions & 0 deletions inc/native/class-wp-markdown-native-shadow-verifier.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ final class WP_Markdown_Native_Shadow_Verifier {
private int $sequence = 0;
private array $counts = array(
'compatible' => 0,
'compatible_reads' => 0,
'compatible_missing_table_errors' => 0,
'unsupported' => 0,
'mismatched' => 0,
'ignored' => 0,
Expand Down Expand Up @@ -165,6 +167,12 @@ public function observe( string $query, mixed $return_value, object $database ):
new WP_Markdown_Query_Request( $query, $prefix )
);
if ( ! $native->succeeded() ) {
$expected = WP_Markdown_WPDB_Result_Snapshot::capture( $return_value, $database );
if ( $this->has_matching_missing_table_error_state( $expected, $native->corpus_result( $pre_query_insert_id ) ) ) {
++$this->counts['compatible'];
++$this->counts['compatible_missing_table_errors'];
return;
}
$diagnostic = $native->diagnostic() ?? array();
$status = 'markdown_db_native_unsupported_query' === ( $diagnostic['code'] ?? '' )
? 'unsupported'
Expand Down Expand Up @@ -194,6 +202,7 @@ public function observe( string $query, mixed $return_value, object $database ):
}
if ( $comparison['compatible'] ) {
++$this->counts['compatible'];
++$this->counts['compatible_reads'];
return;
}

Expand Down Expand Up @@ -325,6 +334,31 @@ private function safe_reason( string $reason ): string {
return '' === $reason ? 'unknown' : substr( $reason, 0, 128 );
}

/** 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 ) ) {
return false;
}
if ( false !== ( $actual['return']['value'] ?? null ) || 1146 !== (int) ( $actual['error_code'] ?? 0 ) ) {
return false;
}
return $this->normalized_error_state( $expected ) === $this->normalized_error_state( $actual );
}

/** @return array<string,mixed> */
private function normalized_error_state( array $result ): array {
return array(
'return' => $result['return'] ?? null,
'rows' => $result['rows'] ?? null,
'columns' => $result['columns'] ?? null,
'error_code' => $result['error_code'] ?? null,
'insert_id' => $result['insert_id'] ?? null,
'rows_affected' => $result['rows_affected'] ?? null,
'num_rows' => $result['num_rows'] ?? null,
'exception' => $result['exception'] ?? null,
);
}

private function query_template( string $query ): string {
$template = $this->normalized_query_template( $query );
return strlen( $template ) > 500 ? substr( $template, 0, 500 ) . '...' : $template;
Expand Down
47 changes: 45 additions & 2 deletions tests/smoke-native-shadow-sql-snapshot.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,12 @@ 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<int,array<string,mixed>> */
public array $plugin_rows = array( array( 'id' => '1', 'name' => 'Agent' ) );
public bool $blog_table_absent = true;
public int $errno = 0;
/** @var array<int,MDI_Snapshot_Result> */
public array $results = array();
public function query( string $sql ): MDI_Snapshot_Result|false {
$this->errno = 0;
$result = false;
if ( 'SHOW CREATE TABLE `wp_postmeta`' === $sql ) {
$result = new MDI_Snapshot_Result( array( array( 'Table' => 'wp_postmeta', 'Create Table' => 'CREATE TABLE `wp_postmeta` (`meta_id` bigint(20) unsigned NOT NULL, `post_id` bigint(20) unsigned NOT NULL, PRIMARY KEY (`meta_id`))' ) ) );
Expand All @@ -35,8 +38,11 @@ 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_2_options`' === $sql ) {
} elseif ( 'SHOW CREATE TABLE `wp_2_options`' === $sql && $this->blog_table_absent ) {
$this->errno = 1146;
return false;
} elseif ( 'SHOW CREATE TABLE `wp_2_options`' === $sql ) {
$result = new MDI_Snapshot_Result( array( array( 'Table' => 'wp_2_options', 'Create Table' => 'CREATE TABLE `wp_2_options` (`ID` bigint(20) unsigned NOT NULL, `option_value` varchar(255) NOT NULL, PRIMARY KEY (`ID`))' ) ) );
} elseif ( str_starts_with( $sql, 'SHOW CREATE TABLE' ) ) {
$result = new MDI_Snapshot_Result( array( array( 'Table' => 'wp_posts', 'Create Table' => 'CREATE TABLE `wp_posts` (`ID` bigint(20) unsigned NOT NULL, `post_title` varchar(255) NOT NULL, PRIMARY KEY (`ID`))' ) ) );
}
Expand All @@ -52,6 +58,9 @@ public function query( string $sql ): MDI_Snapshot_Result|false {
if ( 'SELECT * FROM `agents` LIMIT 10001' === $sql ) {
$result = new MDI_Snapshot_Result( $this->plugin_rows );
}
if ( 'SELECT * FROM `wp_2_options` LIMIT 10001' === $sql ) {
$result = new MDI_Snapshot_Result( array( array( 'ID' => '1', 'option_value' => 'created' ) ) );
}
if ( $result instanceof MDI_Snapshot_Result ) {
$this->results[] = $result;
}
Expand All @@ -65,6 +74,7 @@ final class MDI_Snapshot_Database {
public array $last_result = array();
public int $num_rows = 0;
public string $last_error = '';
public int $last_errno = 0;
public int $insert_id = 0;
public int $rows_affected = 0;
protected ?array $col_info = null;
Expand Down Expand Up @@ -155,6 +165,33 @@ public function get_col_info( string $field ): array {
} catch ( WP_Markdown_Native_Snapshot_Input_Exception $error ) {
$missing_schema_reason = $error->diagnostic()['reason'];
}
$missing_table = 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() );
$database->last_error = 'private missing table message';
$database->last_errno = 1146;
$database->insert_id = 73;
$missing_table->capture_input( 'SELECT option_value FROM wp_2_options', $database );
$missing_table->observe( 'SELECT option_value FROM wp_2_options', false, $database );
$missing_table_report = $missing_table->report();
$duplicate_alias = new WP_Markdown_Native_Shadow_Verifier(
WP_Markdown_Native_Runtime_Factory::runtime( sys_get_temp_dir() ),
1,
array( 'input_mode' => 'sql_snapshot' )
);
$duplicate_alias->capture_input( 'SELECT p.ID FROM wp_posts p JOIN wp_2_options p ON p.ID = p.ID', $database );
$duplicate_alias->observe( 'SELECT p.ID FROM wp_posts p JOIN wp_2_options p ON p.ID = p.ID', false, $database );
$duplicate_alias_report = $duplicate_alias->report();
$database->source()->blog_table_absent = false;
$database->result_rows( array( array( 'option_value' => 'created' ) ), array( array( 'name' => 'option_value', 'type' => 253 ) ) );
$database->last_error = '';
$database->last_errno = 0;
$missing_table->capture_input( 'SELECT option_value FROM wp_2_options', $database );
$missing_table->observe( 'SELECT option_value FROM wp_2_options', 1, $database );
$missing_table_recovered = $missing_table->report();
$reordered = new WP_Markdown_Native_Shadow_Verifier(
WP_Markdown_Native_Runtime_Factory::runtime( sys_get_temp_dir() ),
1,
Expand Down Expand Up @@ -221,7 +258,13 @@ public function get_col_info( string $field ): array {
'global tables use the base prefix when the active blog prefix differs' => array( 'wp_sitemeta' ) === array_column( $global_table['tables'], 'table' )
&& array( 'wp_usermeta' ) === array_column( $user_meta_table['tables'], 'table' ),
'validated non-WordPress-prefixed plugin tables compile by exact captured identity' => array( 'agents' ) === array_column( $plugin_table['tables'], 'table' ),
'absent blog-2 schemas remain explicit snapshot input limitations' => 'source_schema_unavailable' === $missing_schema_reason,
'absent blog-2 schemas are independently compared as normalized missing-table errors' => null === $missing_schema_reason
&& 1 === ( $missing_table_report['counts']['compatible_missing_table_errors'] ?? null )
&& ! str_contains( json_encode( $missing_table_report, JSON_THROW_ON_ERROR ), 'private missing table message' ),
'created source tables recover from a prior missing-table comparison' => 2 === ( $missing_table_recovered['counts']['compatible'] ?? null )
&& 1 === ( $missing_table_recovered['counts']['compatible_reads'] ?? null ),
'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 ),
'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 ),
Expand Down
22 changes: 22 additions & 0 deletions tests/smoke-native-shadow-verifier.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
class MDI_Shadow_Database {
public string $prefix = 'wp_';
public string $last_error = '';
public int $last_errno = 0;
public int $insert_id = 0;
public int $rows_affected = 0;
public int $num_rows = 0;
Expand Down Expand Up @@ -124,6 +125,15 @@ public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query
);
}
}

final class MDI_Wrong_Missing_Table_Runtime implements WP_Markdown_Query_Runtime {
public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query_Result {
unset( $request );
return WP_Markdown_Query_Result::failure(
array( 'code' => 1054, 'reason' => 'unknown_column', 'message' => 'private wrong native error' )
);
}
}
$database->result(
array( array( 'status' => 'publish' ), array( 'status' => 'draft' ) ),
array( array( 'name' => 'status', 'type' => 253 ) )
Expand All @@ -139,6 +149,15 @@ public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query
$failed_verifier->observe( "SELECT option_name FROM wp_options WHERE option_name = 'sql-secret--hash#quote''double-second'", 0, $database );
$failed_verifier->observe( 'SELECT option_name FROM wp_options WHERE option_id = 0xD34DB33F OR option_id = 12.345e+6', 0, $database );
$failure_report = $failed_verifier->report();
$database->result( array(), array() );
$database->last_error = 'private authoritative missing table error';
$database->last_errno = 1146;
$missing_table = new WP_Markdown_Native_Shadow_Verifier( WP_Markdown_Native_Runtime_Factory::runtime( sys_get_temp_dir() ) );
$missing_table->observe( 'SELECT ID FROM wp_missing_table', false, $database );
$wrong_missing_table = new WP_Markdown_Native_Shadow_Verifier( new MDI_Wrong_Missing_Table_Runtime() );
$wrong_missing_table->observe( 'SELECT ID FROM wp_missing_table', false, $database );
$missing_table_report = $missing_table->report();
$wrong_missing_table_report = $wrong_missing_table->report();

class WP_SQLite_DB {
public string $prefix = 'wp_';
Expand Down Expand Up @@ -215,6 +234,9 @@ public function observe( string $query, mixed $return_value, object $database ):
&& ! str_contains( json_encode( $failure_report, JSON_THROW_ON_ERROR ), 'sql-secret' )
&& ! str_contains( json_encode( $failure_report, JSON_THROW_ON_ERROR ), 'D34DB33F' )
&& ! str_contains( json_encode( $failure_report, JSON_THROW_ON_ERROR ), '12.345e+6' ),
'unregistered native tables remain unsupported without a snapshot absence fact' => 1 === ( $missing_table_report['counts']['unsupported'] ?? null )
&& 0 === ( $missing_table_report['counts']['compatible_missing_table_errors'] ?? null ),
'a different native error does not match a missing-table oracle outcome' => 1 === ( $wrong_missing_table_report['counts']['mismatched'] ?? null ),
'SQLite authoritative returns and public state survive hostile observers' => 1 === $sqlite_return
&& 1 === $hostile->calls
&& 'wp_' === $sqlite->prefix
Expand Down