diff --git a/README.md b/README.md index 5f3ef3d..bcacce8 100644 --- a/README.md +++ b/README.md @@ -849,3 +849,19 @@ Tested on WordPress 6.9 with SQLite-backed local and Playground-style runtimes: ## License GPL v2 or later. +## Native SQL Mode Defaults + +The native WordPress connection starts with an empty SQL mode, matching its +existing non-strict `SHOW VARIABLES` contract. Generic table inserts use MySQL +implicit defaults for supported numeric, string and temporal columns when an +explicit default is absent. Nullable columns remain NULL; explicit defaults +take precedence. Missing implicit defaults produce bounded `SHOW WARNINGS` +records and an exact `@@warning_count`. + +`SET [SESSION] sql_mode` and `SELECT @@[SESSION.]sql_mode` support the empty +mode, `STRICT_TRANS_TABLES`, `STRICT_ALL_TABLES` and `NO_ENGINE_SUBSTITUTION`. +Strict mode rejects an omitted required column with error 1364. Modes whose +additional semantics are not implemented are rejected without changing the +session. This is not a claim of complete MySQL coercion or SQL-mode coverage. +The mode is connection-local, survives blog switches and resets on logical +close; it is not persisted in canonical storage. diff --git a/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/class-wp-markdown-storage.php b/inc/class-wp-markdown-storage.php index 742b893..5d7d372 100644 --- a/inc/class-wp-markdown-storage.php +++ b/inc/class-wp-markdown-storage.php @@ -819,7 +819,7 @@ private function safe_unlink( string $path ): bool { /** Remove a cached post path only when it still carries that post's ID. */ private function safe_unlink_owned_by( string $path, int $post_id ): bool { - return $post_id === $this->extract_id_from_file( $path ) && $this->safe_unlink( $path ); + return $this->existing_path_is_safe( $path ) && $post_id === $this->extract_id_from_file( $path ) && $this->safe_unlink( $path ); } /** diff --git a/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-post-mutations.php b/inc/native/class-wp-markdown-native-post-mutations.php index b7b72bc..18f7da7 100644 --- a/inc/native/class-wp-markdown-native-post-mutations.php +++ b/inc/native/class-wp-markdown-native-post-mutations.php @@ -75,6 +75,9 @@ private function write( WP_Markdown_Query_Request $request ): WP_Markdown_Query_ return $write; } foreach ( $write->predicates() as $predicate ) { + if ( $predicate instanceof WP_Markdown_Native_Table_Predicate_Group ) { + return $this->failure( 'unsupported_predicate', 'mdi-native post mutations require simple conjunctive restrictions.' ); + } if ( $predicate instanceof WP_Markdown_Native_Table_Subquery_Predicate ) { return $this->failure( 'unsupported_subquery_shape', 'mdi-native post mutations do not support IN subqueries.' ); } @@ -89,7 +92,10 @@ private function write( WP_Markdown_Query_Request $request ): WP_Markdown_Query_ return $this->failure( 'unsupported_mutation_column', 'The WHERE restriction names a column outside the wp_posts schema.' ); } } - foreach ( array_keys( $write->values() ) as $column ) { + foreach ( $write->values() as $column => $value ) { + if ( $value instanceof WP_Markdown_Native_Query_Scalar_Expression ) { + return $this->failure( 'unsupported_assignment', 'mdi-native post mutations do not yet evaluate scalar assignments.' ); + } if ( ! $schema->has_column( (string) $column ) ) { return $this->failure( 'unsupported_mutation_column', 'The assignment names a column outside the wp_posts schema.' ); } diff --git a/inc/native/class-wp-markdown-native-query-ast.php b/inc/native/class-wp-markdown-native-query-ast.php index 547c998..96cf946 100644 --- a/inc/native/class-wp-markdown-native-query-ast.php +++ b/inc/native/class-wp-markdown-native-query-ast.php @@ -277,7 +277,8 @@ public function __construct( private readonly array $union_orders = array(), private readonly ?int $union_limit = null, private readonly int $union_limit_offset = 0, - private readonly array $group_expressions = array() + private readonly array $group_expressions = array(), + private readonly array $index_hints = array() ) {} public function selects_all(): bool { @@ -387,15 +388,17 @@ public function union_all(): bool { return $this->union_all; } public function union_orders(): array { return $this->union_orders; } public function union_limit(): ?int { return $this->union_limit; } public function union_limit_offset(): int { return $this->union_limit_offset; } + /** @return array}> */ + public function index_hints(): array { return $this->index_hints; } /** Attach a query-expression branch without confusing its local clauses with UNION clauses. */ public function with_union( self $union, bool $all ): self { - return new self( $this->select_all, $this->count_all, $this->projection, $this->table, $this->predicates, $this->orders, $this->limit, $this->alias, $this->joins, $this->calculates_found_rows, $this->limit_offset, $this->distinct, $this->contradiction, $this->group_by, $this->aggregates, $this->scalar_projection, $this->having, $this->subqueries, $union, $this->scalar_predicates, $this->scalar_having, $this->group_expression, $this->boolean_predicate, $this->derived, $all, $this->union_orders, $this->union_limit, $this->union_limit_offset ); + return new self( $this->select_all, $this->count_all, $this->projection, $this->table, $this->predicates, $this->orders, $this->limit, $this->alias, $this->joins, $this->calculates_found_rows, $this->limit_offset, $this->distinct, $this->contradiction, $this->group_by, $this->aggregates, $this->scalar_projection, $this->having, $this->subqueries, $union, $this->scalar_predicates, $this->scalar_having, $this->group_expression, $this->boolean_predicate, $this->derived, $all, $this->union_orders, $this->union_limit, $this->union_limit_offset, $this->group_expressions, $this->index_hints ); } /** @param array $orders */ public function with_union_tail( array $orders, ?int $limit, int $offset ): self { - return new self( $this->select_all, $this->count_all, $this->projection, $this->table, $this->predicates, $this->orders, $this->limit, $this->alias, $this->joins, $this->calculates_found_rows, $this->limit_offset, $this->distinct, $this->contradiction, $this->group_by, $this->aggregates, $this->scalar_projection, $this->having, $this->subqueries, $this->union, $this->scalar_predicates, $this->scalar_having, $this->group_expression, $this->boolean_predicate, $this->derived, $this->union_all, $orders, $limit, $offset ); + return new self( $this->select_all, $this->count_all, $this->projection, $this->table, $this->predicates, $this->orders, $this->limit, $this->alias, $this->joins, $this->calculates_found_rows, $this->limit_offset, $this->distinct, $this->contradiction, $this->group_by, $this->aggregates, $this->scalar_projection, $this->having, $this->subqueries, $this->union, $this->scalar_predicates, $this->scalar_having, $this->group_expression, $this->boolean_predicate, $this->derived, $this->union_all, $orders, $limit, $offset, $this->group_expressions, $this->index_hints ); } public function append_union( self $branch, bool $all ): self { @@ -406,6 +409,6 @@ public function append_union( self $branch, bool $all ): self { /** Remove clauses that syntactically follow an unparenthesized UNION branch. */ public function without_order_limit(): self { - return new self( $this->select_all, $this->count_all, $this->projection, $this->table, $this->predicates, array(), null, $this->alias, $this->joins, $this->calculates_found_rows, 0, $this->distinct, $this->contradiction, $this->group_by, $this->aggregates, $this->scalar_projection, $this->having, $this->subqueries, $this->union, $this->scalar_predicates, $this->scalar_having, $this->group_expression, $this->boolean_predicate, $this->derived, $this->union_all, $this->union_orders, $this->union_limit, $this->union_limit_offset ); + return new self( $this->select_all, $this->count_all, $this->projection, $this->table, $this->predicates, array(), null, $this->alias, $this->joins, $this->calculates_found_rows, 0, $this->distinct, $this->contradiction, $this->group_by, $this->aggregates, $this->scalar_projection, $this->having, $this->subqueries, $this->union, $this->scalar_predicates, $this->scalar_having, $this->group_expression, $this->boolean_predicate, $this->derived, $this->union_all, $this->union_orders, $this->union_limit, $this->union_limit_offset, $this->group_expressions, $this->index_hints ); } } diff --git a/inc/native/class-wp-markdown-native-query-contracts.php b/inc/native/class-wp-markdown-native-query-contracts.php index 31dd2e0..180b683 100644 --- a/inc/native/class-wp-markdown-native-query-contracts.php +++ b/inc/native/class-wp-markdown-native-query-contracts.php @@ -286,13 +286,17 @@ public function __construct( private readonly array $union_order_by = array(), private readonly ?int $union_limit = null, private readonly int $union_limit_offset = 0, - private readonly array $group_expressions = array() + private readonly array $group_expressions = array(), + private readonly array $index_hints = array() ) {} public function table(): string { return $this->table; } + /** @return array}> */ + public function index_hints(): array { return $this->index_hints; } + /** @return array */ public function projection(): array { return $this->projection; diff --git a/inc/native/class-wp-markdown-native-query-executor.php b/inc/native/class-wp-markdown-native-query-executor.php index 465099e..48afb02 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,12 @@ 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, + private WP_Markdown_Native_SQL_Session $session = new WP_Markdown_Native_SQL_Session() ) { - $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, session: $this->session ); } public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query_Result { @@ -85,6 +91,19 @@ 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' ); + $session_result = $this->session->execute( $request->sql() ); + if ( null !== $session_result ) { + return $session_result; + } $transaction_control = WP_Markdown_SQL_Classifier::transaction_control( $request->sql() ); if ( null !== $transaction_control ) { return $this->execute_transaction_control( $transaction_control ); @@ -109,6 +128,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 +139,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 ) ) @@ -139,7 +168,7 @@ private function execute_unlocked_request( WP_Markdown_Query_Request $request ): : $this->schema_mutations->execute( $request ); } $dml_table = $this->dml_table( $request ); - if ( null !== $dml_table && 0 !== strcasecmp( $request->table_prefix() . 'options', $dml_table ) ) { + if ( null !== $dml_table && ( 0 !== strcasecmp( $request->table_prefix() . 'options', $dml_table ) || $this->registry->is_shadowed( $dml_table ) ) ) { return $this->execute_table_dml( $request, $dml_table ); } if ( 1 !== preg_match( '/^\s*(?:SELECT\b|(?:\(\s*)+SELECT\b)/i', $request->sql() ) ) { @@ -147,13 +176,9 @@ 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() ); + $plan = $this->parser->parse( $request->sql(), fn( string $table ): array => array_keys( $this->registry->definition( $table )['columns'] ?? array() ) ); } finally { WP_Markdown_Operation_Profile::end( 'select_parse', $start ); } @@ -185,9 +210,121 @@ 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(); + $this->session->reset(); } private function advisory_lock_query( string $sql ): ?WP_Markdown_Query_Result { @@ -218,6 +355,10 @@ private function advisory_lock_query( string $sql ): ?WP_Markdown_Query_Result { } private function execute_query_plan( WP_Markdown_Native_Query_Plan $plan, bool $allow_union = true ): WP_Markdown_Query_Result { + $hint_error = $this->validate_index_hints( $plan ); + if ( null !== $hint_error ) { + return $hint_error; + } if ( $allow_union && null !== $plan->union() ) { return $this->execute_union( $plan ); } @@ -263,6 +404,25 @@ private function execute_query_plan( WP_Markdown_Native_Query_Plan $plan, bool $ foreach ( $scalar_projection as $scalar ) { $scalar_columns = array_merge( $scalar_columns, $scalar['expression']->columns() ); } + foreach ( $plan->aggregates() as $aggregate ) { + if ( isset( $aggregate['expression'] ) ) { + $expression = $aggregate['expression']; + $scalar_columns = array_merge( $scalar_columns, $expression->columns() ); + foreach ( $expression->columns() as $column ) { + if ( ! $schema->has_column( $column ) ) { + return $this->failure( 'unsupported_column', 'mdi-native cannot query the requested aggregate column.' ); + } + } + foreach ( $expression->predicates() as $predicate ) { + if ( ! $schema->supports_predicate( $predicate ) ) { + return $this->failure( 'unsupported_lookup', 'mdi-native cannot apply the requested aggregate predicate.' ); + } + } + if ( ! $this->is_numeric_expression( $expression, $schema ) ) { + return $this->failure( 'unsupported_aggregate', 'mdi-native aggregates numeric scalar expressions only.' ); + } + } + } $columns = array_merge( $projection, $scalar_columns ); foreach ( $scalar_predicates as $predicate ) { $columns = array_merge( $columns, $predicate->columns() ); } if ( null !== $boolean_predicate ) { $columns = array_merge( $columns, $boolean_predicate->columns() ); } @@ -318,7 +478,7 @@ private function execute_query_plan( WP_Markdown_Native_Query_Plan $plan, bool $ if ( array() !== $predicates && null === $pushdown && ! $table['provider'] instanceof WP_Markdown_Native_JSON_Partition_Provider - && ! $this->allows_residual_scan( $predicates, $schema ) + && ! $this->allows_residual_scan( $predicates, $schema, $table['provider'] instanceof WP_Markdown_Native_JSON_Snapshot_Provider ) ) { return $this->failure( 'unsupported_lookup', 'mdi-native requires one indexable predicate for a filtered query.' ); } @@ -514,7 +674,7 @@ private function execute_query_plan( WP_Markdown_Native_Query_Plan $plan, bool $ } if ( array() !== $aggregates ) { if ( null !== $plan->group_by() ) { - return $this->grouped_aggregate_result( $groups, $plan->group_by(), $aggregates, $plan->having(), $plan->scalar_having(), $plan->table(), $schema, $plan->scalar_projection(), $plan->order_by(), $plan->limit_offset(), $plan->limit(), $plan->calculates_found_rows() ); + return $this->grouped_aggregate_result( $groups, $projection, $aggregates, $plan->having(), $plan->scalar_having(), $plan->table(), $schema, $plan->scalar_projection(), $plan->order_by(), $plan->limit_offset(), $plan->limit(), $plan->calculates_found_rows() ); } return $this->aggregate_result( $aggregate_state, $aggregates ); } @@ -535,13 +695,13 @@ private function accumulate_aggregates( array &$state, array $row, array $aggreg foreach ( $aggregates as $index => $aggregate ) { $current = $state[ $index ] ?? array( 'count' => 0, 'sum' => null, 'min' => null, 'max' => null, 'values' => array() ); $column = $aggregate['column']; - if ( null === $column ) { + if ( null === $column && ! isset( $aggregate['expression'] ) ) { // COUNT(*) reports over rows, so a NULL column cannot skip one. ++$current['count']; $state[ $index ] = $current; continue; } - $value = $row[ $column ] ?? null; + $value = isset( $aggregate['expression'] ) ? $this->evaluate_scalar( $aggregate['expression'], $row, $schema ) : ( $row[ $column ] ?? null ); if ( null === $value ) { // SQL aggregates ignore NULL, and COUNT(column) counts values. $state[ $index ] = $current; @@ -554,24 +714,39 @@ private function accumulate_aggregates( array &$state, array $row, array $aggreg if ( 'GROUP_CONCAT' === $aggregate['function'] ) { $current['values'][] = (string) $value; } - if ( null === $current['min'] || 0 > ( $schema->ordered_comparison( $column, $value, $current['min'] ) ?? 0 ) ) { + if ( null !== $column && ( null === $current['min'] || 0 > ( $schema->ordered_comparison( $column, $value, $current['min'] ) ?? 0 ) ) ) { $current['min'] = $value; } - if ( null === $current['max'] || 0 < ( $schema->ordered_comparison( $column, $value, $current['max'] ) ?? 0 ) ) { + if ( null !== $column && ( null === $current['max'] || 0 < ( $schema->ordered_comparison( $column, $value, $current['max'] ) ?? 0 ) ) ) { $current['max'] = $value; } $state[ $index ] = $current; } } - /** - * Report one row of ungrouped aggregates. - * - * An aggregate over no rows is NULL, except COUNT, which is zero. - * - * @param array> $state Running totals, by aggregate. - * @param array> $aggregates Declared aggregates. - */ + /** Validate numeric expression types before reading rows, including empty sets. */ + private function is_numeric_expression( WP_Markdown_Native_Query_Scalar_Expression $expression, WP_Markdown_Native_Table_Schema $schema ): bool { + if ( 'literal' === $expression->kind() ) { + return null === $expression->literal() || is_int( $expression->literal() ); + } + if ( 'column' === $expression->kind() ) { + return $schema->is_numeric_column( $expression->column() ); + } + if ( 'CASE' === $expression->kind() ) { + foreach ( $expression->branches() as $branch ) { + if ( ! $this->is_numeric_expression( $branch['value'], $schema ) ) { return false; } + } + return null === $expression->else() || $this->is_numeric_expression( $expression->else(), $schema ); + } + if ( in_array( $expression->kind(), array( 'COALESCE', 'IFNULL', 'NULLIF', 'ABS', 'ROUND', 'FLOOR', 'CEIL' ), true ) ) { + foreach ( $expression->arguments() as $argument ) { + if ( ! $this->is_numeric_expression( $argument, $schema ) ) { return false; } + } + return true; + } + return false; + } + private function aggregate_result( array $state, array $aggregates ): WP_Markdown_Query_Result { $row = array(); $columns = array(); @@ -595,13 +770,10 @@ private function aggregate_value( array $totals, string $function ): ?string { }; } - private function grouped_aggregate_result( array $groups, string $column, array $aggregates, array $having, array $scalar_having, string $table, WP_Markdown_Native_Table_Schema $schema, array $scalar_projection, array $orders, int $offset, int $limit, bool $calculates_found_rows ): WP_Markdown_Query_Result { + private function grouped_aggregate_result( array $groups, array $projection, array $aggregates, array $having, array $scalar_having, string $table, WP_Markdown_Native_Table_Schema $schema, array $scalar_projection, array $orders, int $offset, int $limit, bool $calculates_found_rows ): WP_Markdown_Query_Result { $rows = array(); foreach ( $groups as $group ) { - $row = array( $column => null === $group['value'] ? null : (string) $group['value'] ); - foreach ( $scalar_projection as $scalar ) { - $row[ $scalar['alias'] ] = $this->string_scalar( $this->evaluate_scalar( $scalar['expression'], $group['row'] ?? array(), $schema ) ); - } + $row = $this->string_row( $group['row'], $projection, $scalar_projection, $schema ); foreach ( $aggregates as $index => $aggregate ) { $row[ $aggregate['alias'] ] = $this->aggregate_value( $group['state'][ $index ] ?? array(), $aggregate['function'] ); } @@ -609,8 +781,6 @@ private function grouped_aggregate_result( array $groups, string $column, array $rows[] = $row; } } - $group_name = $scalar_projection[0]['alias'] ?? $column; - if ( $group_name !== $column ) { foreach ( $rows as &$row ) { $row[ $group_name ] = $row[ $column ]; unset( $row[ $column ] ); } unset( $row ); } if ( array() !== $orders ) { usort( $rows, function ( array $left, array $right ) use ( $orders ): int { foreach ( $orders as $order ) { @@ -623,12 +793,11 @@ private function grouped_aggregate_result( array $groups, string $column, array if ( $calculates_found_rows ) { $this->last_found_rows = count( $rows ); } $rows = array_values( array_slice( $rows, $offset, PHP_INT_MAX === $limit ? null : $limit ) ); $columns = array(); - if ( array() === $scalar_projection ) { - $columns[] = array( 'name' => $group_name, 'table' => $table, 'type' => $schema->column( $column )->type() ); - } else { - foreach ( $scalar_projection as $scalar ) { $columns[ $scalar['position'] ] = array( 'name' => $scalar['alias'], 'table' => '', 'type' => 253 ); } - ksort( $columns ); - $columns = array_values( $columns ); + foreach ( $projection as $column ) { + $columns[] = array( 'name' => $column, 'table' => $table, 'type' => $schema->column( $column )->type() ); + } + foreach ( $scalar_projection as $scalar ) { + array_splice( $columns, $scalar['position'], 0, array( $this->scalar_projection_column( $scalar, $table, $schema ) ) ); } foreach ( $aggregates as $aggregate ) { $columns[] = array( 'name' => $aggregate['alias'], 'table' => '', 'type' => 'GROUP_CONCAT' === $aggregate['function'] ? 253 : 8 ); } return WP_Markdown_Query_Result::selected( $rows, $columns ); @@ -1218,6 +1387,9 @@ private function execute_join( WP_Markdown_Native_Query_Plan $plan ): WP_Markdow } foreach ( $plan->aggregates() as $aggregate ) { if ( null === $aggregate['column'] ) { + if ( isset( $aggregate['expression'] ) ) { + return $this->failure( 'unsupported_aggregate', 'mdi-native does not yet aggregate scalar expressions across joins.' ); + } continue; } $aggregate_source = (string) $aggregate['source']; @@ -1537,9 +1709,10 @@ function ( array $left, array $right ) use ( $plan, $sources ): int { $columns[] = array( 'name' => $column, 'table' => $sources[ $source ]['table'], 'type' => $sources[ $source ]['schema']->column( $column )->type() ); } foreach ( $plan->scalar_projection() as $scalar ) { - $columns[ $scalar['position'] ] = array( 'name' => $scalar['alias'], 'table' => '', 'type' => 253 ); + $source = $scalar['expression']->source() ?? $plan->table_alias() ?? $plan->table(); + $metadata = $this->scalar_projection_column( $scalar, $sources[ $source ]['table'], $sources[ $source ]['schema'] ); + array_splice( $columns, $scalar['position'], 0, array( $metadata ) ); } - ksort( $columns ); foreach ( $aggregates as $aggregate ) { $columns[] = array( 'name' => $aggregate['alias'], 'table' => '', 'type' => 8 ); } @@ -1749,7 +1922,7 @@ private function derived_source( WP_Markdown_Native_Query_Plan $plan, string $na } /** @param array $predicates */ - private function allows_residual_scan( array $predicates, WP_Markdown_Native_Table_Schema $schema ): bool { + private function allows_residual_scan( array $predicates, WP_Markdown_Native_Table_Schema $schema, bool $snapshot = false ): bool { $indexed = $this->indexed_columns( $schema ); foreach ( $predicates as $predicate ) { if ( null !== $predicate->cast() ) { @@ -1780,7 +1953,8 @@ private function allows_residual_scan( array $predicates, WP_Markdown_Native_Tab if ( in_array( $type, array( 1, 2, 3, 4, 5, 8, 9, 246 ), true ) ) { continue; } - if ( isset( $indexed[ $column ] ) + // Snapshots already materialize rows; retain lookup validators even on scans. + if ( ( $snapshot || isset( $indexed[ $column ] ) ) && ! $schema->is_lookup( $column ) && $schema->allows_filter( $column, $predicate->operator(), $predicate->values() ) ) { continue; @@ -1984,7 +2158,7 @@ private function result( $projection ); $scalar_columns = array(); - foreach ( $scalar_projection as $scalar ) { $scalar_columns[ $scalar['position'] ] = array( 'name' => $scalar['alias'], 'table' => '', 'type' => 253 ); } + foreach ( $scalar_projection as $scalar ) { $scalar_columns[ $scalar['position'] ] = $this->scalar_projection_column( $scalar, $table, $schema ); } $columns = array(); $total_columns = count( $regular ) + count( $scalar_columns ); for ( $position = 0; $position < $total_columns; ++$position ) { @@ -1993,6 +2167,16 @@ private function result( return WP_Markdown_Query_Result::selected( $rows, $columns ); } + /** A bare column alias keeps the source column's type and table metadata. */ + private function scalar_projection_column( array $scalar, string $table, WP_Markdown_Native_Table_Schema $schema ): array { + $expression = $scalar['expression']; + return array( + 'name' => $scalar['alias'], + 'table' => 'column' === $expression->kind() ? $table : '', + 'type' => 'column' === $expression->kind() ? $schema->column( $expression->column() )->type() : 253, + ); + } + /** @param array $source @param array $projection @return array */ private function string_row( array $source, array $projection, array $scalar_projection, WP_Markdown_Native_Table_Schema $schema ): array { $regular = array(); @@ -2019,7 +2203,11 @@ private function interleave_scalar_projection( array $regular, array $scalar_pro } /** Evaluate a lowered row-local scalar expression after filtering. */ - private function evaluate_scalar( WP_Markdown_Native_Query_Scalar_Expression $expression, array $row, WP_Markdown_Native_Table_Schema $schema ): int|string|null { + public function evaluate_scalar( WP_Markdown_Native_Query_Scalar_Expression $expression, array $row, WP_Markdown_Native_Table_Schema $schema ): int|string|null { + $this->statement_now ??= gmdate( 'Y-m-d H:i:s' ); + if ( WP_Markdown_Native_Scalar_Evaluator::supports( $expression ) ) { + return WP_Markdown_Native_Scalar_Evaluator::evaluate( $expression, $row ); + } $values = array_map( fn( WP_Markdown_Native_Query_Scalar_Expression $argument ): int|string|null => $this->evaluate_scalar( $argument, $row, $schema ), $expression->arguments() @@ -2072,6 +2260,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] ) ) ), @@ -2100,10 +2289,51 @@ private function evaluate_scalar( WP_Markdown_Native_Query_Scalar_Expression $ex private function scalar_number( int|float|string|null $value ): int|string|null|float { if ( null === $value ) { return null; } + if ( is_int( $value ) || ( is_string( $value ) && (string) (int) $value === $value ) ) { + return (int) $value; + } $number = (float) $value; return floor( $number ) === $number ? (int) $number : (string) $number; } + 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 +2516,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 ); @@ -2363,6 +2593,25 @@ private function execute_transaction_control( array $control ): WP_Markdown_Quer return WP_Markdown_Query_Result::mutated( 0 ); } + /** Hints affect access strategy, not rows; validate names before using native planning. */ + private function validate_index_hints( WP_Markdown_Native_Query_Plan $plan ): ?WP_Markdown_Query_Result { + foreach ( $plan->index_hints() as $hint ) { + $definition = $this->registry->definition( $hint['table'] ); + if ( null === $definition ) { + return $this->failure( 'unsupported_table', 'mdi-native cannot validate an index hint for an unknown table.' ); + } + $names = array_map( static fn( array $index ): string => strtolower( $index['name'] ), $definition['indexes'] ?? array() ); + foreach ( $hint['indexes'] as $name ) { + $name = strtolower( $name ); + $matches = in_array( $name, $names, true ) ? array( $name ) : array_values( array_filter( $names, static fn( string $index ): bool => str_starts_with( $index, $name ) ) ); + if ( 1 !== count( $matches ) ) { + return $this->failure( 'unsupported_index_hint', 'mdi-native requires an existing, unambiguous index in a table hint.' ); + } + } + } + return null === $plan->union() ? null : $this->validate_index_hints( $plan->union() ); + } + private function dml_table( WP_Markdown_Query_Request $request ): ?string { if ( 1 === preg_match( '/^\s*(?:INSERT(?:\s+IGNORE)?\s+INTO|REPLACE(?:\s+INTO)?|UPDATE|DELETE\s+FROM)\s+`?([A-Za-z_][A-Za-z0-9_]*)`?/i', $request->sql(), $match ) ) { return $match[1]; @@ -2371,7 +2620,7 @@ private function dml_table( WP_Markdown_Query_Request $request ): ?string { } private function execute_table_dml( WP_Markdown_Query_Request $request, string $table ): WP_Markdown_Query_Result { - if ( 0 === strcasecmp( $request->table_prefix() . 'posts', $table ) ) { + if ( 0 === strcasecmp( $request->table_prefix() . 'posts', $table ) && ! $this->registry->is_shadowed( $table ) ) { return null === $this->post_mutations ? $this->failure( 'unsupported_grammar', 'mdi-native post mutations are unavailable.' ) : $this->post_mutations->execute( $request ); diff --git a/inc/native/class-wp-markdown-native-query-parser.php b/inc/native/class-wp-markdown-native-query-parser.php index c4f88d5..88dc6a7 100644 --- a/inc/native/class-wp-markdown-native-query-parser.php +++ b/inc/native/class-wp-markdown-native-query-parser.php @@ -10,18 +10,94 @@ public function __construct( private WP_Markdown_Native_SQL_Tokenizer $tokenizer = new WP_Markdown_Native_SQL_Tokenizer() ) {} - public function parse( string $sql ): WP_Markdown_Native_Query_Plan|WP_Markdown_Native_Found_Rows_Plan|WP_Markdown_Query_Result { + public function parse( string $sql, ?callable $table_columns = null ): WP_Markdown_Native_Query_Plan|WP_Markdown_Native_Found_Rows_Plan|WP_Markdown_Query_Result { + self::trace_runtime_phase( 'parser', $sql ); $ast = $this->parse_ast( $sql ); if ( $ast instanceof WP_Markdown_Query_Result ) { return $ast; } try { - return $this->lower( $ast ); + return $this->lower( $ast, array(), $table_columns ); } catch ( WP_Markdown_Native_SQL_Parse_Error $error ) { return $this->failure( $error->reason(), $error->getMessage(), $error->sql_offset() ); } } + 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. @@ -37,7 +113,7 @@ public function parse_ast( string $sql ): WP_Markdown_Native_SQL_Select|WP_Markd } /** @param array $outer_sources */ - public function lower( WP_Markdown_Native_SQL_Select|WP_Markdown_Native_SQL_Found_Rows $ast, array $outer_sources = array() ): WP_Markdown_Native_Query_Plan|WP_Markdown_Native_Found_Rows_Plan|WP_Markdown_Query_Result { + public function lower( WP_Markdown_Native_SQL_Select|WP_Markdown_Native_SQL_Found_Rows $ast, array $outer_sources = array(), ?callable $table_columns = null ): WP_Markdown_Native_Query_Plan|WP_Markdown_Native_Found_Rows_Plan|WP_Markdown_Query_Result { if ( $ast instanceof WP_Markdown_Native_SQL_Found_Rows ) { return new WP_Markdown_Native_Found_Rows_Plan(); } @@ -47,6 +123,7 @@ public function lower( WP_Markdown_Native_SQL_Select|WP_Markdown_Native_SQL_Foun return $this->failure( 'unsupported_select_modifier', 'DISTINCT requires a row projection.', $ast->table()->sql_offset() ); } $base_source = array() === $ast->joins() ? null : ( $ast->alias()?->name() ?? $ast->table()->name() ); + $bindings = null === $table_columns || null === $base_source ? array() : $this->column_bindings( $ast, $table_columns ); $flat_source = array() === $ast->joins() ? ( $ast->alias()?->name() ?? $ast->table()->name() ) : null; $child_outer_sources = $outer_sources; $child_outer_sources[ $ast->alias()?->name() ?? $ast->table()->name() ] = true; @@ -56,21 +133,21 @@ public function lower( WP_Markdown_Native_SQL_Select|WP_Markdown_Native_SQL_Foun : array_map( static fn( WP_Markdown_Native_SQL_Identifier $column ): string => $column->name(), $ast->projection() ); $scalar_projection = array_map( fn( array $scalar ): array => array( - 'expression' => $this->lower_scalar_expression( $scalar['expression'], $base_source, $flat_source ), + 'expression' => $this->lower_scalar_expression( $scalar['expression'], $base_source, $flat_source, $bindings ), 'alias' => $scalar['alias'], 'position' => $scalar['position'], ), $ast->scalar_projection() ); - $scalar_predicates = array_map( fn( WP_Markdown_Native_SQL_Scalar_Predicate $predicate ): WP_Markdown_Native_Query_Scalar_Predicate => $this->lower_scalar_predicate( $predicate, $base_source, $flat_source ), $ast->scalar_predicates() ); - $boolean_predicate = null === $ast->boolean_predicate() ? null : new WP_Markdown_Native_Query_Boolean_Predicate( array_map( function ( array $group ) use ( $base_source, $flat_source, $child_outer_sources ): array { - return array_map( function ( WP_Markdown_Native_SQL_Predicate|WP_Markdown_Native_SQL_Scalar_Predicate|WP_Markdown_Native_SQL_Subquery_Predicate $predicate ) use ( $base_source, $flat_source, $child_outer_sources ): WP_Markdown_Native_Query_Predicate|WP_Markdown_Native_Query_Scalar_Predicate|WP_Markdown_Native_Query_Subquery { - if ( $predicate instanceof WP_Markdown_Native_SQL_Scalar_Predicate ) { return $this->lower_scalar_predicate( $predicate, $base_source, $flat_source ); } - if ( $predicate instanceof WP_Markdown_Native_SQL_Subquery_Predicate ) { return $this->lower_subquery( $predicate, $child_outer_sources ); } - return $this->lower_predicate( $predicate, $base_source ); + $scalar_predicates = array_map( fn( WP_Markdown_Native_SQL_Scalar_Predicate $predicate ): WP_Markdown_Native_Query_Scalar_Predicate => $this->lower_scalar_predicate( $predicate, $base_source, $flat_source, $bindings ), $ast->scalar_predicates() ); + $boolean_predicate = null === $ast->boolean_predicate() ? null : new WP_Markdown_Native_Query_Boolean_Predicate( array_map( function ( array $group ) use ( $base_source, $flat_source, $child_outer_sources, $bindings, $table_columns ): array { + return array_map( function ( WP_Markdown_Native_SQL_Predicate|WP_Markdown_Native_SQL_Scalar_Predicate|WP_Markdown_Native_SQL_Subquery_Predicate $predicate ) use ( $base_source, $flat_source, $child_outer_sources, $bindings, $table_columns ): WP_Markdown_Native_Query_Predicate|WP_Markdown_Native_Query_Scalar_Predicate|WP_Markdown_Native_Query_Subquery { + if ( $predicate instanceof WP_Markdown_Native_SQL_Scalar_Predicate ) { return $this->lower_scalar_predicate( $predicate, $base_source, $flat_source, $bindings ); } + if ( $predicate instanceof WP_Markdown_Native_SQL_Subquery_Predicate ) { return $this->lower_subquery( $predicate, $child_outer_sources, $table_columns, $bindings ); } + return $this->lower_predicate( $predicate, $base_source, $bindings ); }, $group ); }, $ast->boolean_predicate()->groups() ) ); - $scalar_having = array_map( fn( WP_Markdown_Native_SQL_Scalar_Predicate $predicate ): WP_Markdown_Native_Query_Scalar_Predicate => $this->lower_scalar_predicate( $predicate, null, $flat_source ), $ast->scalar_having() ); + $scalar_having = array_map( fn( WP_Markdown_Native_SQL_Scalar_Predicate $predicate ): WP_Markdown_Native_Query_Scalar_Predicate => $this->lower_scalar_predicate( $predicate, null, $flat_source, $bindings ), $ast->scalar_having() ); $seen = array(); foreach ( $ast->projection() as $column ) { $key = ( $column->qualifier() ?? '' ) . '.' . $column->name(); @@ -92,26 +169,26 @@ public function lower( WP_Markdown_Native_SQL_Select|WP_Markdown_Native_SQL_Foun $predicates = array(); $subqueries = array(); - foreach ( $ast->subqueries() as $subquery_predicate ) { $subqueries[] = $this->lower_subquery( $subquery_predicate, $child_outer_sources ); } + foreach ( $ast->subqueries() as $subquery_predicate ) { $subqueries[] = $this->lower_subquery( $subquery_predicate, $child_outer_sources, $table_columns, $bindings ); } foreach ( $ast->predicates() as $predicate ) { - $predicates[] = $this->lower_predicate( $predicate, $base_source ); + $predicates[] = $this->lower_predicate( $predicate, $base_source, $bindings ); } $joins = array(); foreach ( $ast->joins() as $join ) { - $join_derived = null === $join->derived() ? null : $this->lower( $join->derived() ); + $join_derived = null === $join->derived() ? null : $this->lower( $join->derived(), array(), $table_columns ); if ( null !== $join_derived && ! $join_derived instanceof WP_Markdown_Native_Query_Plan ) { return $join_derived; } $joins[] = new WP_Markdown_Native_Query_Join( $join->table()->name(), $join->alias()->name(), - $join->left()?->qualifier(), + $this->column_source( $join->left(), null, $bindings ), $join->left()?->name(), - $join->right()?->qualifier(), + $this->column_source( $join->right(), null, $bindings ), $join->right()?->name(), $join->is_outer(), array_map( - fn( WP_Markdown_Native_SQL_Predicate $predicate ): WP_Markdown_Native_Query_Predicate => $this->lower_predicate( $predicate, $join->alias()->name() ), + fn( WP_Markdown_Native_SQL_Predicate $predicate ): WP_Markdown_Native_Query_Predicate => $this->lower_predicate( $predicate, $join->alias()->name(), $bindings ), $join->on_predicates() ), $join_derived @@ -152,33 +229,33 @@ public function lower( WP_Markdown_Native_SQL_Select|WP_Markdown_Native_SQL_Foun fn( array $item ): array => array( 'column' => $item['column']->name(), 'descending' => $item['descending'], - 'source' => in_array( $item['column']->name(), $aggregate_aliases, true ) ? null : ( $item['column']->qualifier() ?? $base_source ), + 'source' => in_array( $item['column']->name(), $aggregate_aliases, true ) ? null : $this->column_source( $item['column'], $base_source, $bindings ), 'numeric' => $item['numeric'] ?? false, 'like' => $item['like'] ?? null, 'field' => $item['field'] ?? null, 'case' => null === ( $item['case'] ?? null ) ? null : array( 'branches' => array_map( fn( array $branch ): array => array( - 'predicates' => array_map( fn( WP_Markdown_Native_SQL_Predicate $predicate ): WP_Markdown_Native_Query_Predicate => $this->lower_predicate( $predicate, $base_source ), $branch['predicates'] ), + 'predicates' => array_map( fn( WP_Markdown_Native_SQL_Predicate $predicate ): WP_Markdown_Native_Query_Predicate => $this->lower_predicate( $predicate, $base_source, $bindings ), $branch['predicates'] ), 'value' => $branch['value'], ), $item['case']['branches'] ), 'else' => $item['case']['else'], ), - 'expression' => null === ( $item['expression'] ?? null ) ? null : $this->lower_scalar_expression( $item['expression'], $base_source, $flat_source ), + 'expression' => null === ( $item['expression'] ?? null ) ? null : $this->lower_scalar_expression( $item['expression'], $base_source, $flat_source, $bindings ), ), $ast->orders() ); $having = array_map( fn( WP_Markdown_Native_SQL_Predicate $predicate ): WP_Markdown_Native_Query_Predicate => $this->lower_predicate( $predicate, null ), $ast->having() ); $union = null; if ( null !== $ast->union() ) { - $union = $this->lower( $ast->union() ); + $union = $this->lower( $ast->union(), $outer_sources, $table_columns ); if ( ! $union instanceof WP_Markdown_Native_Query_Plan ) { return $union; } } - $derived = null === $ast->derived() ? null : $this->lower( $ast->derived() ); + $derived = null === $ast->derived() ? null : $this->lower( $ast->derived(), array(), $table_columns ); if ( null !== $derived && ! $derived instanceof WP_Markdown_Native_Query_Plan ) { return $derived; } @@ -190,7 +267,7 @@ public function lower( WP_Markdown_Native_SQL_Select|WP_Markdown_Native_SQL_Foun $ast->limit() ?? PHP_INT_MAX, $ast->counts_all(), $ast->alias()?->name(), - array_map( static fn( WP_Markdown_Native_SQL_Identifier $column ): ?string => $column->qualifier() ?? $base_source, $ast->projection() ), + array_map( fn( WP_Markdown_Native_SQL_Identifier $column ): ?string => $this->column_source( $column, $base_source, $bindings ), $ast->projection() ), $joins, $ast->calculates_found_rows(), $ast->order_descending(), @@ -201,12 +278,13 @@ public function lower( WP_Markdown_Native_SQL_Select|WP_Markdown_Native_SQL_Foun $ast->is_contradiction(), $ast->group_by()?->name(), array_map( - static fn( array $aggregate ): array => array( + fn( array $aggregate ): array => array( 'function' => $aggregate['function'], 'column' => $aggregate['column']?->name(), - 'source' => $aggregate['column']?->qualifier() ?? $base_source, + 'source' => $this->column_source( $aggregate['column'], $base_source, $bindings ), 'alias' => $aggregate['alias'], 'distinct' => $aggregate['distinct'] ?? false, + 'expression' => isset( $aggregate['expression'] ) ? $this->lower_scalar_expression( $aggregate['expression'], $base_source, $flat_source, $bindings ) : null, ), $ast->aggregates() ), @@ -217,27 +295,89 @@ public function lower( WP_Markdown_Native_SQL_Select|WP_Markdown_Native_SQL_Foun $union, $scalar_predicates, $scalar_having, - null === $ast->group_expression() ? null : $this->lower_scalar_expression( $ast->group_expression(), $base_source, $flat_source ), + null === $ast->group_expression() ? null : $this->lower_scalar_expression( $ast->group_expression(), $base_source, $flat_source, $bindings ), $boolean_predicate, $derived, $ast->union_all(), array_map( fn( array $item ): array => array( 'column' => $item['column']->name(), 'descending' => $item['descending'], 'numeric' => str_starts_with( $item['column']->name(), '__union_ordinal_' ) ), $ast->union_orders() ), $ast->union_limit(), $ast->union_limit_offset(), - array_map( fn( WP_Markdown_Native_SQL_Scalar_Expression $expression ): WP_Markdown_Native_Query_Scalar_Expression => $this->lower_scalar_expression( $expression, $base_source, $flat_source ), $ast->group_expressions() ) + array_map( fn( WP_Markdown_Native_SQL_Scalar_Expression $expression ): WP_Markdown_Native_Query_Scalar_Expression => $this->lower_scalar_expression( $expression, $base_source, $flat_source, $bindings ), $ast->group_expressions() ), + $ast->index_hints() ); } + /** Resolve unqualified identifiers only when their source schemas are known. */ + private function column_bindings( WP_Markdown_Native_SQL_Select $ast, callable $table_columns ): array { + $sources = array( $ast->alias()?->name() ?? $ast->table()->name() => $this->source_columns( $ast->table()->name(), $ast->derived(), $table_columns ) ); + $join_scopes = array(); + foreach ( $ast->joins() as $join ) { + $sources[ $join->alias()->name() ] = $this->source_columns( $join->table()->name(), $join->derived(), $table_columns ); + foreach ( $join->on_predicates() as $predicate ) { + foreach ( $this->predicate_columns( $predicate ) as $column ) { + $join_scopes[ spl_object_id( $column ) ] = $sources; + } + } + } + $aliases = array_merge( array_column( $ast->scalar_projection(), 'alias' ), array_column( $ast->aggregates(), 'alias' ) ); + $order_aliases = array(); + foreach ( $ast->orders() as $order ) { + if ( null === $order['column']->qualifier() && in_array( $order['column']->name(), $aliases, true ) ) { + $order_aliases[ spl_object_id( $order['column'] ) ] = true; + } + } + $bindings = array(); + foreach ( $this->referenced_columns( $ast ) as $column ) { + if ( null !== $column->qualifier() || '*' === $column->name() || isset( $order_aliases[ spl_object_id( $column ) ] ) ) { + continue; + } + $owners = array_keys( array_filter( $join_scopes[ spl_object_id( $column ) ] ?? $sources, static fn( array $columns ): bool => in_array( $column->name(), $columns, true ) ) ); + if ( 1 < count( $owners ) ) { + throw new WP_Markdown_Native_SQL_Parse_Error( 'ambiguous_column', $column->sql_offset(), 'The unqualified column belongs to more than one query source.' ); + } + if ( 1 === count( $owners ) ) { + $bindings[ spl_object_id( $column ) ] = $owners[0]; + } + } + return $bindings; + } + + /** Derived sources expose their projected names, not their underlying tables. */ + private function source_columns( string $table, ?WP_Markdown_Native_SQL_Select $derived, callable $table_columns ): array { + if ( null === $derived ) { + return $table_columns( $table ) ?? array(); + } + if ( $derived->selects_all() ) { + return $this->source_columns( $derived->table()->name(), $derived->derived(), $table_columns ); + } + $columns = array(); + $inner_sources = array( $derived->alias()?->name() ?? $derived->table()->name() => array( $derived->table()->name(), $derived->derived() ) ); + foreach ( $derived->joins() as $join ) { $inner_sources[ $join->alias()->name() ] = array( $join->table()->name(), $join->derived() ); } + foreach ( $derived->projection() as $column ) { + if ( '*' === $column->name() && isset( $inner_sources[ $column->qualifier() ] ) ) { + $source = $inner_sources[ $column->qualifier() ]; + $columns = array_merge( $columns, $this->source_columns( $source[0], $source[1], $table_columns ) ); + } else { + $columns[] = $column->name(); + } + } + return array_merge( $columns, array_column( $derived->scalar_projection(), 'alias' ), array_column( $derived->aggregates(), 'alias' ) ); + } + + private function column_source( ?WP_Markdown_Native_SQL_Identifier $column, ?string $fallback, array $bindings ): ?string { + return null === $column ? $fallback : ( $column->qualifier() ?? $bindings[ spl_object_id( $column ) ] ?? $fallback ); + } + /** @param array $outer_sources */ - private function lower_subquery( WP_Markdown_Native_SQL_Subquery_Predicate $predicate, array $outer_sources = array() ): WP_Markdown_Native_Query_Subquery { - $subquery = $this->lower( $predicate->query(), $outer_sources ); + private function lower_subquery( WP_Markdown_Native_SQL_Subquery_Predicate $predicate, array $outer_sources = array(), ?callable $table_columns = null, array $bindings = array() ): WP_Markdown_Native_Query_Subquery { + $subquery = $this->lower( $predicate->query(), $outer_sources, $table_columns ); if ( ! $subquery instanceof WP_Markdown_Native_Query_Plan ) { throw new WP_Markdown_Native_SQL_Parse_Error( 'unsupported_subquery_shape', $predicate->column()?->sql_offset() ?? 0, 'mdi-native could not lower the requested subquery.' ); } - return new WP_Markdown_Native_Query_Subquery( $predicate->operator(), $predicate->column()?->name(), $subquery, $predicate->column()?->qualifier() ); + return new WP_Markdown_Native_Query_Subquery( $predicate->operator(), $predicate->column()?->name(), $subquery, $this->column_source( $predicate->column(), null, $bindings ) ); } - private function lower_predicate( WP_Markdown_Native_SQL_Predicate $predicate, ?string $base_source = null ): WP_Markdown_Native_Query_Predicate { + private function lower_predicate( WP_Markdown_Native_SQL_Predicate $predicate, ?string $base_source = null, array $bindings = array() ): WP_Markdown_Native_Query_Predicate { $values = array_map( static fn( WP_Markdown_Native_SQL_Literal $literal ): int|string => $literal->value(), $predicate->values() ); if ( 'IN' === $predicate->operator() || 'NOT IN' === $predicate->operator() ) { $values = array_values( array_unique( $values, SORT_REGULAR ) ); @@ -246,40 +386,41 @@ private function lower_predicate( WP_Markdown_Native_SQL_Predicate $predicate, ? $predicate->column()->name(), $predicate->operator(), $values, - $predicate->column()->qualifier() ?? $base_source, - array_map( fn( WP_Markdown_Native_SQL_Predicate $alternative ): WP_Markdown_Native_Query_Predicate => $this->lower_predicate( $alternative, $base_source ), $predicate->any() ), + $this->column_source( $predicate->column(), $base_source, $bindings ), + array_map( fn( WP_Markdown_Native_SQL_Predicate $alternative ): WP_Markdown_Native_Query_Predicate => $this->lower_predicate( $alternative, $base_source, $bindings ), $predicate->any() ), $predicate->cast(), $predicate->comparison()?->name(), - $predicate->comparison()?->qualifier() + $this->column_source( $predicate->comparison(), null, $bindings ) ); } - private function lower_scalar_expression( WP_Markdown_Native_SQL_Scalar_Expression $expression, ?string $base_source, ?string $flat_source = null ): WP_Markdown_Native_Query_Scalar_Expression { + public function lower_scalar_expression( WP_Markdown_Native_SQL_Scalar_Expression $expression, ?string $base_source, ?string $flat_source = null, array $bindings = array() ): WP_Markdown_Native_Query_Scalar_Expression { + $source = $this->column_source( $expression->identifier(), $base_source, $bindings ); return new WP_Markdown_Native_Query_Scalar_Expression( $expression->kind(), $expression->identifier()?->name(), $expression->literal(), array_map( - fn( WP_Markdown_Native_SQL_Scalar_Expression $argument ): WP_Markdown_Native_Query_Scalar_Expression => $this->lower_scalar_expression( $argument, $base_source, $flat_source ), + fn( WP_Markdown_Native_SQL_Scalar_Expression $argument ): WP_Markdown_Native_Query_Scalar_Expression => $this->lower_scalar_expression( $argument, $base_source, $flat_source, $bindings ), $expression->arguments() ), array_map( fn( array $branch ): array => array( 'predicates' => array_map( - fn( WP_Markdown_Native_SQL_Predicate $predicate ): WP_Markdown_Native_Query_Predicate => $this->lower_predicate( $predicate, $base_source ), + fn( WP_Markdown_Native_SQL_Predicate $predicate ): WP_Markdown_Native_Query_Predicate => $this->lower_predicate( $predicate, $base_source, $bindings ), $branch['predicates'] ), - 'value' => $this->lower_scalar_expression( $branch['value'], $base_source, $flat_source ), + 'value' => $this->lower_scalar_expression( $branch['value'], $base_source, $flat_source, $bindings ), ), $expression->branches() ), - null === $expression->else() ? null : $this->lower_scalar_expression( $expression->else(), $base_source, $flat_source ), - $flat_source === ( $expression->identifier()?->qualifier() ?? $base_source ) ? null : ( $expression->identifier()?->qualifier() ?? $base_source ) + null === $expression->else() ? null : $this->lower_scalar_expression( $expression->else(), $base_source, $flat_source, $bindings ), + $flat_source === $source ? null : $source ); } - private function lower_scalar_predicate( WP_Markdown_Native_SQL_Scalar_Predicate $predicate, ?string $base_source, ?string $flat_source = null ): WP_Markdown_Native_Query_Scalar_Predicate { - return new WP_Markdown_Native_Query_Scalar_Predicate( $this->lower_scalar_expression( $predicate->left(), $base_source, $flat_source ), $predicate->operator(), $this->lower_scalar_expression( $predicate->right(), $base_source, $flat_source ) ); + private function lower_scalar_predicate( WP_Markdown_Native_SQL_Scalar_Predicate $predicate, ?string $base_source, ?string $flat_source = null, array $bindings = array() ): WP_Markdown_Native_Query_Scalar_Predicate { + return new WP_Markdown_Native_Query_Scalar_Predicate( $this->lower_scalar_expression( $predicate->left(), $base_source, $flat_source, $bindings ), $predicate->operator(), $this->lower_scalar_expression( $predicate->right(), $base_source, $flat_source, $bindings ) ); } /** @return array */ @@ -305,7 +446,7 @@ private function referenced_columns( WP_Markdown_Native_SQL_Select $ast ): array $columns = array_merge( $columns, $ast->boolean_predicate()->columns() ); } foreach ( $ast->scalar_having() as $predicate ) { $columns = array_merge( $columns, $predicate->columns() ); } - if ( null !== $ast->group_expression() ) { $columns = array_merge( $columns, $ast->group_expression()->columns() ); } + foreach ( $ast->group_expressions() as $expression ) { $columns = array_merge( $columns, $expression->columns() ); } foreach ( $ast->orders() as $item ) { if ( null !== ( $item['expression'] ?? null ) ) { $columns = array_merge( $columns, $item['expression']->columns() ); continue; } if ( null === ( $item['case'] ?? null ) ) { @@ -319,6 +460,9 @@ private function referenced_columns( WP_Markdown_Native_SQL_Select $ast ): array } } foreach ( $ast->aggregates() as $aggregate ) { + if ( isset( $aggregate['expression'] ) ) { + $columns = array_merge( $columns, $aggregate['expression']->columns() ); + } if ( null !== $aggregate['column'] ) { $columns[] = $aggregate['column']; } @@ -336,6 +480,7 @@ private function referenced_columns( WP_Markdown_Native_SQL_Select $ast ): array /** @return array */ private function predicate_columns( WP_Markdown_Native_SQL_Predicate $predicate ): array { $columns = array( $predicate->column() ); + if ( null !== $predicate->comparison() ) { $columns[] = $predicate->comparison(); } foreach ( $predicate->any() as $alternative ) { $columns = array_merge( $columns, $this->predicate_columns( $alternative ) ); } @@ -447,7 +592,8 @@ private function select( bool $nested ): WP_Markdown_Native_SQL_Select|WP_Markdo // COUNT(*) reports over rows; COUNT(column) counts values, so it is an // aggregate like the others rather than the row-count shortcut. $counts_rows = $this->matches_function( 'COUNT' ) - && WP_Markdown_Native_SQL_Token::STAR === ( $this->tokens[ $this->current + 2 ] ?? null )?->type(); + && WP_Markdown_Native_SQL_Token::STAR === ( $this->tokens[ $this->current + 2 ] ?? null )?->type() + && 'FROM' === strtoupper( (string) ( $this->tokens[ $this->current + 4 ] ?? null )?->value() ); if ( ! $select_all && $counts_rows ) { $count_all = true; $this->identifier(); @@ -456,6 +602,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,21 +627,31 @@ 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; } - $projection[] = $this->identifier(); + $column = $this->identifier(); + if ( $this->match_keyword( 'AS' ) ) { + $scalar_projection[] = array( + 'expression' => new WP_Markdown_Native_SQL_Scalar_Expression( 'column', $column ), + 'alias' => $this->unqualified_identifier()->name(), + 'position' => count( $projection ) + count( $scalar_projection ), + ); + } else { + $projection[] = $column; + } } while ( $this->match_type( WP_Markdown_Native_SQL_Token::COMMA ) ); } $this->expect_keyword( 'FROM' ); - list( $table, $alias, $derived ) = $this->source( true ); + list( $table, $alias, $derived, $index_hints ) = $this->source( true ); $joins = array(); while ( true ) { if ( $this->match_type( WP_Markdown_Native_SQL_Token::COMMA ) ) { - list( $join_table, $join_alias, $join_derived ) = $this->source( false ); + list( $join_table, $join_alias, $join_derived, $join_hints ) = $this->source( false ); + $index_hints = array_merge( $index_hints, $join_hints ); $joins[] = new WP_Markdown_Native_SQL_Join( $join_table, $join_alias, null, null, false, array(), $join_derived ); continue; } @@ -495,7 +659,8 @@ private function select( bool $nested ): WP_Markdown_Native_SQL_Select|WP_Markdo if ( null === $join_kind ) { break; } - list( $join_table, $join_alias, $join_derived ) = $this->source( false ); + list( $join_table, $join_alias, $join_derived, $join_hints ) = $this->source( false ); + $index_hints = array_merge( $index_hints, $join_hints ); $this->expect_keyword( 'ON' ); $on_predicates = $this->disjunction( true ); $equality = null; @@ -509,13 +674,6 @@ private function select( bool $nested ): WP_Markdown_Native_SQL_Select|WP_Markdo } $left = null === $equality ? null : $equality->column(); $right = null === $equality ? null : $equality->comparison(); - $base_source = $alias ?? $table; - if ( null !== $left && null === $left->qualifier() ) { - $left = new WP_Markdown_Native_SQL_Identifier( $left->name(), $left->sql_offset(), $base_source->name() ); - } - if ( null !== $right && null === $right->qualifier() ) { - $right = new WP_Markdown_Native_SQL_Identifier( $right->name(), $right->sql_offset(), $base_source->name() ); - } if ( null !== $equality_index && null !== $left && null !== $right ) { $on_predicates[ $equality_index ] = new WP_Markdown_Native_SQL_Predicate( $left, '=', array(), array(), null, $right ); } @@ -575,7 +733,11 @@ private function select( bool $nested ): WP_Markdown_Native_SQL_Select|WP_Markdo // grouping column. A wildcard over the grouped table qualifies // because its identity is the group. foreach ( $projection as $column ) { - $same_column = $column->name() === $group->name() && $column->qualifier() === $group->qualifier(); + $same_column = false; + foreach ( $group_expressions as $expression ) { + $identifier = 'column' === $expression->kind() ? $expression->identifier() : null; + $same_column = $same_column || ( null !== $identifier && $column->name() === $identifier->name() && $column->qualifier() === $identifier->qualifier() ); + } $grouped_wildcard = '*' === $column->name() && null !== $group->qualifier() && $column->qualifier() === $group->qualifier(); if ( ! $same_column && ! $grouped_wildcard ) { throw new WP_Markdown_Native_SQL_Parse_Error( @@ -710,6 +872,8 @@ private function select( bool $nested ): WP_Markdown_Native_SQL_Select|WP_Markdo if ( $limit_offset > PHP_INT_MAX - $limit ) { throw new WP_Markdown_Native_SQL_Parse_Error( 'overflow_limit', $this->current()->sql_offset(), 'mdi-native cannot apply the requested LIMIT.' ); } + } elseif ( $this->match_keyword( 'OFFSET' ) ) { + $limit_offset = $this->integer( 'overflow_limit', 'mdi-native cannot apply the requested LIMIT.' ); } } $union = null; @@ -739,23 +903,58 @@ private function select( bool $nested ): WP_Markdown_Native_SQL_Select|WP_Markdo if ( ! $nested && $this->match_keyword( 'FOR' ) ) { $this->expect_keyword( 'UPDATE' ); } - return new WP_Markdown_Native_SQL_Select( $select_all, $count_all, $projection, $table, $predicates, $orders, $limit, $alias, $joins, $calculate_found_rows, $limit_offset, $distinct, $this->contradiction, $group, $aggregates, $scalar_projection, $having, $subqueries, $union, $scalar_predicates, $scalar_having, $grouped ? $group_expression : null, $boolean_predicate, $derived, $union_all, $union_orders, $union_limit, $union_limit_offset, $grouped ? $group_expressions : array() ); + return new WP_Markdown_Native_SQL_Select( $select_all, $count_all, $projection, $table, $predicates, $orders, $limit, $alias, $joins, $calculate_found_rows, $limit_offset, $distinct, $this->contradiction, $group, $aggregates, $scalar_projection, $having, $subqueries, $union, $scalar_predicates, $scalar_having, $grouped ? $group_expression : null, $boolean_predicate, $derived, $union_all, $union_orders, $union_limit, $union_limit_offset, $grouped ? $group_expressions : array(), $index_hints ); } - /** @return array{WP_Markdown_Native_SQL_Identifier,?WP_Markdown_Native_SQL_Identifier,?WP_Markdown_Native_SQL_Select} */ + /** @return array{WP_Markdown_Native_SQL_Identifier,?WP_Markdown_Native_SQL_Identifier,?WP_Markdown_Native_SQL_Select,array} */ private function source( bool $base ): array { if ( ! $this->match_type( WP_Markdown_Native_SQL_Token::LEFT_PAREN ) ) { $table = $this->unqualified_identifier(); $alias = null; if ( $this->match_keyword( 'AS' ) ) { $alias = $this->unqualified_identifier(); - } elseif ( $this->matches_identifier() && ( ! $base || ! $this->is_on() ) ) { + } elseif ( $this->matches_identifier() && ( WP_Markdown_Native_SQL_Token::QUOTED_IDENTIFIER === $this->current()->type() || ! in_array( strtoupper( (string) $this->current()->value() ), array( 'USE', 'FORCE', 'IGNORE' ), true ) ) && ( ! $base || ! $this->is_on() ) ) { $alias = $this->unqualified_identifier(); } if ( ! $base && null === $alias ) { $alias = $table; } - return array( $table, $alias, null ); + $hints = array(); + $access_mode = null; + while ( WP_Markdown_Native_SQL_Token::WORD === $this->current()->type() && in_array( strtoupper( (string) $this->current()->value() ), array( 'USE', 'FORCE', 'IGNORE' ), true ) ) { + $mode = strtoupper( (string) $this->current()->value() ); + if ( 'IGNORE' !== $mode ) { + if ( null !== $access_mode && $access_mode !== $mode ) { + $this->unsupported( $this->current() ); + } + $access_mode = $mode; + } + ++$this->current; + if ( WP_Markdown_Native_SQL_Token::WORD !== $this->current()->type() || ! in_array( strtoupper( (string) $this->current()->value() ), array( 'INDEX', 'KEY' ), true ) ) { + $this->unsupported( $this->current() ); + } + ++$this->current; + if ( $this->match_keyword( 'FOR' ) ) { + if ( ! $this->match_keyword( 'JOIN' ) ) { + if ( ! $this->match_keyword( 'ORDER' ) && ! $this->match_keyword( 'GROUP' ) ) { + $this->unsupported( $this->current() ); + } + $this->expect_keyword( 'BY' ); + } + } + $this->expect_type( WP_Markdown_Native_SQL_Token::LEFT_PAREN ); + $indexes = array(); + if ( ! $this->match_type( WP_Markdown_Native_SQL_Token::RIGHT_PAREN ) ) { + do { + $indexes[] = $this->unqualified_identifier()->name(); + } while ( $this->match_type( WP_Markdown_Native_SQL_Token::COMMA ) ); + $this->expect_type( WP_Markdown_Native_SQL_Token::RIGHT_PAREN ); + } elseif ( 'USE' !== $mode ) { + $this->unsupported( $this->current() ); + } + $hints[] = array( 'table' => $table->name(), 'mode' => $mode, 'indexes' => $indexes ); + } + return array( $table, $alias, null, $hints ); } $derived = $this->select( true ); if ( ! $derived instanceof WP_Markdown_Native_SQL_Select ) { @@ -764,12 +963,12 @@ private function source( bool $base ): array { $this->expect_type( WP_Markdown_Native_SQL_Token::RIGHT_PAREN ); $this->match_keyword( 'AS' ); $alias = $this->unqualified_identifier(); - return array( $alias, $alias, $derived ); + return array( $alias, $alias, $derived, array() ); } private function matches_scalar_expression(): bool { 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 +1047,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 ), @@ -862,7 +1061,7 @@ private function scalar_expression(): WP_Markdown_Native_SQL_Scalar_Expression { return new WP_Markdown_Native_SQL_Scalar_Expression( $function, null, null, $arguments ); } - private function scalar_value(): WP_Markdown_Native_SQL_Scalar_Expression { + public function scalar_value(): WP_Markdown_Native_SQL_Scalar_Expression { $value = $this->scalar_term(); while ( in_array( $this->current()->type(), array( WP_Markdown_Native_SQL_Token::PLUS, WP_Markdown_Native_SQL_Token::MINUS ), true ) ) { $operator = $this->current()->type(); ++$this->current; @@ -1049,19 +1248,27 @@ private function coalesce_boolean_groups( array $groups, int $sql_offset ): arra private function requires_boolean_plan( array $groups ): bool { $has_composite_group = false; $has_non_coalescible = false; + $has_like = false; + $only_single_likes = true; foreach ( $groups as $group ) { $has_composite_group = $has_composite_group || 1 < count( $group ); + $only_single_likes = $only_single_likes && 1 === count( $group ); foreach ( $group as $predicate ) { if ( $predicate instanceof WP_Markdown_Native_SQL_Scalar_Predicate || $predicate instanceof WP_Markdown_Native_SQL_Subquery_Predicate ) { return true; } + $has_like = $has_like || 'LIKE' === $predicate->operator(); + $only_single_likes = $only_single_likes && 'LIKE' === $predicate->operator(); $has_non_coalescible = $has_non_coalescible || null !== $predicate->cast() || ! in_array( $predicate->operator(), array( '=', 'IN', 'IS NULL', 'LOWER =', 'LIKE' ), true ); } } + if ( 1 < count( $groups ) && $has_like && ! $only_single_likes ) { + return true; + } if ( 1 < count( $groups ) && $has_composite_group && $has_non_coalescible ) { return true; } @@ -1127,7 +1334,7 @@ private function coalesce_disjunction( array $groups, int $sql_offset ): array { 'mdi-native supports OR only over uncast equality or LIKE alternatives.' ); } - if ( ! in_array( $conjunct->operator(), array( '=', 'IN', 'IS NULL', 'LOWER =' ), true ) ) { + if ( ! in_array( $conjunct->operator(), array( '=', 'IN', 'IS NULL', 'LOWER =', 'LIKE' ), true ) ) { throw new WP_Markdown_Native_SQL_Parse_Error( 'unsupported_or', $sql_offset, @@ -1142,7 +1349,7 @@ private function coalesce_disjunction( array $groups, int $sql_offset ): array { continue; } $predicate = $group[0]; - if ( ! in_array( $predicate->operator(), array( '=', 'IN', 'IS NULL', 'LOWER =' ), true ) ) { + if ( ! in_array( $predicate->operator(), array( '=', 'IN', 'IS NULL', 'LOWER =', 'LIKE' ), true ) ) { throw new WP_Markdown_Native_SQL_Parse_Error( 'unsupported_or', $sql_offset, @@ -1170,7 +1377,7 @@ private function coalesce_disjunction( array $groups, int $sql_offset ): array { return array( new WP_Markdown_Native_SQL_Predicate( $identifier, 'OR', array(), $alternatives ) ); } foreach ( $alternatives as $alternative ) { - if ( 'IS NULL' === $alternative->operator() ) { + if ( in_array( $alternative->operator(), array( 'IS NULL', 'LIKE' ), true ) ) { return array( new WP_Markdown_Native_SQL_Predicate( $identifier, 'OR', array(), $alternatives ) ); } } @@ -1245,6 +1452,14 @@ private function match_aggregate(): ?array { $this->expect_type( WP_Markdown_Native_SQL_Token::LEFT_PAREN ); $argument = $this->current(); $distinct = $this->match_keyword( 'DISTINCT' ); + if ( $this->matches_scalar_expression() ) { + if ( $distinct || ! in_array( $function, array( 'SUM', 'AVG' ), true ) ) { + $this->unsupported( $argument ); + } + $expression = $this->scalar_value(); + $this->expect_type( WP_Markdown_Native_SQL_Token::RIGHT_PAREN ); + return array( 'function' => $function, 'column' => null, 'expression' => $expression, 'alias' => $this->scalar_alias(), 'distinct' => false ); + } $column = $this->match_type( WP_Markdown_Native_SQL_Token::STAR ) ? null : $this->identifier(); if ( $distinct && ( 'COUNT' !== $function || null === $column ) ) { $this->unsupported( $argument ); @@ -1257,10 +1472,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..b005409 100644 --- a/inc/native/class-wp-markdown-native-query-runtime.php +++ b/inc/native/class-wp-markdown-native-query-runtime.php @@ -7,6 +7,8 @@ require_once __DIR__ . '/../class-wp-markdown-canonical-option-path.php'; require_once __DIR__ . '/class-wp-markdown-native-query-contracts.php'; +require_once __DIR__ . '/class-wp-markdown-native-scalar-evaluator.php'; +require_once __DIR__ . '/class-wp-markdown-native-sql-session.php'; require_once __DIR__ . '/class-wp-markdown-native-query-schema.php'; require_once __DIR__ . '/class-wp-markdown-native-schema-catalog.php'; require_once __DIR__ . '/class-wp-markdown-native-sql-tokenizer.php'; @@ -23,6 +25,7 @@ require_once __DIR__ . '/../class-wp-markdown-sql-classifier.php'; require_once __DIR__ . '/../class-wp-markdown-table-durability-policy.php'; require_once __DIR__ . '/class-wp-markdown-native-transactions.php'; +require_once __DIR__ . '/class-wp-markdown-native-temporary-tables.php'; require_once __DIR__ . '/class-wp-markdown-native-advisory-locks.php'; require_once __DIR__ . '/class-wp-markdown-native-query-executor.php'; @@ -106,6 +109,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 +128,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' ), ) ); } @@ -243,8 +255,10 @@ public static function runtime( ?string $global_state_root = null, ?string $global_content_root = null, ?WP_Markdown_Native_Advisory_Locks $advisory_locks = null, - ?string $transaction_state_root = null + ?string $transaction_state_root = null, + ?WP_Markdown_Native_SQL_Session $session = null ): WP_Markdown_Native_Query_Runtime { + $session ??= new WP_Markdown_Native_SQL_Session(); $state_root = self::materialize_state_root( $state_root ); if ( null !== $content_root ) { $content_root = self::materialize_state_root( $content_root ); @@ -260,6 +274,7 @@ public static function runtime( array_filter( array( $state_root, $content_root, $global_state_root, $global_content_root ) ) ); $registry = self::registry( $state_root, $prefix, $base_prefix, $multisite, $content_root, $global_state_root, $global_content_root ); + $temporary_tables = new WP_Markdown_Native_Temporary_Tables(); $parser = new WP_Markdown_Native_Table_Insert_Parser(); $resolved_base = $base_prefix ?? $prefix; $resolved_content = $content_root ?? $state_root; @@ -280,8 +295,8 @@ public static function runtime( $registry, new WP_Markdown_Native_Query_Parser(), new WP_Markdown_Native_Option_Mutation_Runtime( $state_root, new WP_Markdown_Native_Option_Mutation_Parser(), $transactions ), - new WP_Markdown_Native_Schema_Mutation_Runtime( $state_root, $registry, $transactions, $core_registrar ), - new WP_Markdown_Native_Table_Mutation_Runtime( $state_root, $registry, $parser, $transactions ), + new WP_Markdown_Native_Schema_Mutation_Runtime( $state_root, $registry, $transactions, $core_registrar, $temporary_tables ), + new WP_Markdown_Native_Table_Mutation_Runtime( $state_root, $registry, $parser, $transactions, $temporary_tables, $session ), $transactions, new WP_Markdown_Native_Post_Mutation_Runtime( $registry, @@ -289,7 +304,8 @@ public static function runtime( self::shared_storage( $content_root ?? $state_root, $multisite && $prefix === $resolved_base ), $transactions, ), - advisory_locks: $advisory_locks ?? new WP_Markdown_Native_Advisory_Locks( $state_root ) + advisory_locks: $advisory_locks ?? new WP_Markdown_Native_Advisory_Locks( $state_root ), + session: $session ); } @@ -636,7 +652,8 @@ final class WP_Markdown_Native_Prefix_Query_Runtime implements WP_Markdown_Query public function __construct( private string $state_root, - private string $content_root + private string $content_root, + private WP_Markdown_Native_SQL_Session $session = new WP_Markdown_Native_SQL_Session() ) { $this->advisory_locks = new WP_Markdown_Native_Advisory_Locks( $state_root ); } @@ -650,7 +667,8 @@ public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query $prefix, false, $this->content_root, - advisory_locks: $this->advisory_locks + advisory_locks: $this->advisory_locks, + session: $this->session ); } return $this->runtimes[ $prefix ]->execute( $request ); @@ -658,6 +676,7 @@ public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query public function close(): void { $this->advisory_locks->close(); + $this->session->reset(); } } @@ -667,9 +686,11 @@ final class WP_Markdown_Native_WordPress_Query_Runtime implements WP_Markdown_Qu private WP_Markdown_Native_Prefix_Query_Runtime $prefix_runtime; /** @var array */ private array $multisite_runtimes = array(); + private WP_Markdown_Native_SQL_Session $session; public function __construct( string $state_root, private string $base_prefix, string $content_root ) { - $this->prefix_runtime = new WP_Markdown_Native_Prefix_Query_Runtime( $state_root, $content_root ); + $this->session = new WP_Markdown_Native_SQL_Session(); + $this->prefix_runtime = new WP_Markdown_Native_Prefix_Query_Runtime( $state_root, $content_root, $this->session ); $this->state_root = $state_root; $this->content_root = $content_root; } @@ -686,7 +707,7 @@ public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query ? $GLOBALS['wpdb']->base_prefix : $this->base_prefix; if ( ! isset( $this->multisite_runtimes[ $base_prefix ] ) ) { - $this->multisite_runtimes[ $base_prefix ] = new WP_Markdown_Native_Multisite_Query_Runtime( $this->state_root, $base_prefix, $this->content_root ); + $this->multisite_runtimes[ $base_prefix ] = new WP_Markdown_Native_Multisite_Query_Runtime( $this->state_root, $base_prefix, $this->content_root, $this->session ); } return $this->multisite_runtimes[ $base_prefix ]->execute( $request ); } @@ -711,7 +732,8 @@ final class WP_Markdown_Native_Multisite_Query_Runtime implements WP_Markdown_Qu public function __construct( string $state_root, private string $base_prefix, - string $content_root + string $content_root, + private WP_Markdown_Native_SQL_Session $session = new WP_Markdown_Native_SQL_Session() ) { if ( 1 !== preg_match( '/^[A-Za-z0-9_]+$/D', $base_prefix ) ) { throw new InvalidArgumentException( 'The base table prefix contains unsupported characters.' ); @@ -753,7 +775,8 @@ public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query $this->state_root, $this->content_root, $this->advisory_locks, - $this->state_root + $this->state_root, + $this->session ); } catch ( Throwable ) { return WP_Markdown_Query_Result::failure( @@ -770,6 +793,7 @@ public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query public function close(): void { $this->advisory_locks->close(); + $this->session->reset(); } private function is_scope_prefix( string $prefix ): bool { diff --git a/inc/native/class-wp-markdown-native-query-schema.php b/inc/native/class-wp-markdown-native-query-schema.php index 91e4f25..de9d9d5 100644 --- a/inc/native/class-wp-markdown-native-query-schema.php +++ b/inc/native/class-wp-markdown-native-query-schema.php @@ -710,6 +710,8 @@ final class WP_Markdown_Native_Table_Registry { private array $tables = array(); /** @var array> */ private array $definitions = array(); + /** @var array}> */ + private array $shadows = array(); public function register( string $table, @@ -746,6 +748,11 @@ public function definition( string $table ): ?array { return $this->definitions[ $table ] ?? null; } + /** Whether a connection-local temporary table currently hides this identifier. */ + public function is_shadowed( string $table ): bool { + return isset( $this->shadows[ $table ] ); + } + /** * Replace a registered table after its schema is altered. * @@ -782,6 +789,34 @@ public function unregister( string $table ): void { unset( $this->tables[ $table ], $this->definitions[ $table ] ); } + /** Replace a visible table for a connection-local temporary table. */ + public function shadow( string $table, ?WP_Markdown_Native_Table_Schema $schema, ?WP_Markdown_Native_Table_Provider $provider, array $definition ): void { + if ( isset( $this->shadows[ $table ] ) ) { + throw new InvalidArgumentException( 'A temporary table already shadows this identifier.' ); + } + $this->shadows[ $table ] = array( 'table' => $this->tables[ $table ] ?? null, 'definition' => $this->definitions[ $table ] ?? null ); + unset( $this->tables[ $table ], $this->definitions[ $table ] ); + $this->register_definition( $table, $definition ); + if ( null !== $schema && null !== $provider ) { + $this->tables[ $table ] = array( 'schema' => $schema, 'provider' => $provider ); + } + } + + /** Restore the permanent table hidden by a dropped temporary table. */ + public function unshadow( string $table ): void { + if ( ! isset( $this->shadows[ $table ] ) ) { + return; + } + $shadow = $this->shadows[ $table ]; + unset( $this->tables[ $table ], $this->definitions[ $table ], $this->shadows[ $table ] ); + if ( is_array( $shadow['definition'] ) ) { + $this->definitions[ $table ] = $shadow['definition']; + } + if ( is_array( $shadow['table'] ) ) { + $this->tables[ $table ] = $shadow['table']; + } + } + /** Forget request-scoped generic snapshots after canonical files are restored. */ public function forget_snapshots(): void { foreach ( $this->tables as $table ) { diff --git a/inc/native/class-wp-markdown-native-scalar-evaluator.php b/inc/native/class-wp-markdown-native-scalar-evaluator.php new file mode 100644 index 0000000..7cd8fa5 --- /dev/null +++ b/inc/native/class-wp-markdown-native-scalar-evaluator.php @@ -0,0 +1,60 @@ +kind(), array( 'literal', 'column', 'COALESCE', 'ADD', 'SUBTRACT', 'MULTIPLY', 'DIVIDE' ), true ) ) { + return false; + } + foreach ( $expression->arguments() as $argument ) { + if ( ! self::supports( $argument ) ) { + return false; + } + } + return true; + } + + /** @param array $row */ + public static function evaluate( WP_Markdown_Native_Query_Scalar_Expression $expression, array $row ): int|string|null { + if ( ! self::supports( $expression ) ) { + throw new LogicException( 'Unsupported shared scalar expression.' ); + } + $values = array_map( static fn( WP_Markdown_Native_Query_Scalar_Expression $argument ): int|string|null => self::evaluate( $argument, $row ), $expression->arguments() ); + return match ( $expression->kind() ) { + 'literal' => $expression->literal(), + 'column' => null === $expression->source() + ? ( $row[ (string) $expression->column() ] ?? null ) + : ( $row[ $expression->source() ][ (string) $expression->column() ] ?? null ), + 'COALESCE' => self::first_non_null( $values ), + 'ADD' => in_array( null, $values, true ) ? null : self::number( self::number( $values[0] ) + self::number( $values[1] ) ), + 'SUBTRACT' => in_array( null, $values, true ) ? null : self::number( self::number( $values[0] ) - self::number( $values[1] ) ), + 'MULTIPLY' => in_array( null, $values, true ) ? null : self::number( self::number( $values[0] ) * self::number( $values[1] ) ), + 'DIVIDE' => in_array( null, $values, true ) || 0.0 === (float) self::number( $values[1] ) ? null : self::number( self::number( $values[0] ) / self::number( $values[1] ) ), + }; + } + + /** @param array $values */ + private static function first_non_null( array $values ): int|string|null { + foreach ( $values as $value ) { + if ( null !== $value ) { + return $value; + } + } + return null; + } + + private static function number( int|float|string|null $value ): int|string|null|float { + if ( null === $value ) { + return null; + } + if ( is_int( $value ) || ( is_string( $value ) && (string) (int) $value === $value ) ) { + return (int) $value; + } + $number = (float) $value; + return floor( $number ) === $number ? (int) $number : (string) $number; + } +} diff --git a/inc/native/class-wp-markdown-native-schema-introspection.php b/inc/native/class-wp-markdown-native-schema-introspection.php index 90eb1a2..3deb3cc 100644 --- a/inc/native/class-wp-markdown-native-schema-introspection.php +++ b/inc/native/class-wp-markdown-native-schema-introspection.php @@ -224,9 +224,14 @@ 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, + private readonly ?WP_Markdown_Native_SQL_Session $session = null ) {} public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query_Result { @@ -250,6 +255,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. * @@ -264,7 +561,7 @@ private function server_values( string $operation, ?string $pattern, array $name ? array( 'version' => WP_Markdown_Native_Schema_Catalog::SERVER_VERSION, 'version_comment' => 'Markdown Database Integration native engine', - 'sql_mode' => '', + 'sql_mode' => $this->session?->sql_mode() ?? '', 'character_set_server' => 'utf8mb4', 'collation_server' => 'utf8mb4_general_ci', 'foreign_key_checks' => 'ON', @@ -290,7 +587,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 +721,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..1a2b0fa 100644 --- a/inc/native/class-wp-markdown-native-schema-mutations.php +++ b/inc/native/class-wp-markdown-native-schema-mutations.php @@ -16,7 +16,8 @@ public function __construct( string $state_root, private WP_Markdown_Native_Table_Registry $registry, private ?WP_Markdown_Native_Transaction_Journal $transactions = null, - ?callable $core_registrar = null + ?callable $core_registrar = null, + private ?WP_Markdown_Native_Temporary_Tables $temporary_tables = null ) { $this->core_registrar = $core_registrar; $root = realpath( $state_root ); @@ -34,6 +35,14 @@ public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query if ( '' === $sql || WP_Markdown_Native_SQL_Tokenizer::contains_statement_separator( $sql ) ) { return $this->failure( 'unsupported_grammar', 'mdi-native requires one bounded CREATE TABLE statement.' ); } + $temporary = 1 === preg_match( '/^(?:CREATE|DROP)\s+TEMPORARY\s+TABLE\b/i', $sql ); + // Temporary table DDL neither commits nor participates in transaction rollback. + if ( null !== $this->transactions && ! $temporary ) { + $committed = $this->transactions->commit(); + if ( true !== $committed ) { + return $this->failure( 'transaction_commit_failed', $committed ); + } + } if ( 1 === preg_match( '/^\s*ALTER\s+TABLE\b/i', $sql ) ) { return $this->execute_alter( $request, $sql ); } @@ -58,25 +67,29 @@ public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query $definition = $definitions[ $suffix ]; // IF NOT EXISTS makes an existing table a successful no-op in MySQL. $tolerates_existing = 1 === preg_match( '/^\s*CREATE\s+(?:TEMPORARY\s+)?TABLE\s+IF\s+NOT\s+EXISTS\b/i', $sql ); - if ( null !== $this->registry->definition( $table ) ) { + if ( $temporary && null !== $this->temporary_tables && $this->temporary_tables->has( $table ) ) { return $tolerates_existing ? WP_Markdown_Query_Result::schema_changed() : $this->failure( 'table_exists', 'mdi-native cannot create a table that already exists.' ); } + if ( ! $temporary && null !== $this->registry->definition( $table ) ) { + return $tolerates_existing ? WP_Markdown_Query_Result::schema_changed() : $this->failure( 'table_exists', 'mdi-native cannot create a table that already exists.' ); + } // A core table is generated from WordPress itself, so creating it // registers its canonical provider rather than persisting a schema // file that would shadow the definition core already supplies. The // name identifies it, because the release being installed states its // own column list and that varies independently of canonical form. - if ( WP_Markdown_Native_Schema_Catalog::is_core_table( $suffix ) ) { + if ( ! $temporary && WP_Markdown_Native_Schema_Catalog::is_core_table( $suffix ) ) { if ( null === $this->core_registrar || true !== ( $this->core_registrar )( $suffix ) ) { return $this->failure( 'unsupported_schema', 'mdi-native cannot create the requested core table.' ); } return WP_Markdown_Query_Result::schema_changed(); } - $directory = $this->schema_directory(); + $root = $temporary && null !== $this->temporary_tables ? $this->temporary_tables->root() : $this->state_root; + $directory = $this->schema_directory( $root ); if ( $directory instanceof WP_Markdown_Query_Result ) { return $directory; } @@ -90,12 +103,12 @@ public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query try { $path = $directory . '/' . $suffix . '.sql'; - if ( file_exists( $path ) || is_link( $path ) || null !== $this->registry->definition( $table ) ) { + if ( file_exists( $path ) || is_link( $path ) || ( ! $temporary && null !== $this->registry->definition( $table ) ) ) { return $tolerates_existing ? WP_Markdown_Query_Result::schema_changed() : $this->failure( 'table_exists', 'mdi-native cannot create a table that already exists.' ); } - $written = $this->write( $path, $sql . ";\n" ); + $written = $this->write_schema( $path, $sql . ";\n", $table, $suffix, $request->table_prefix(), $temporary ); if ( $written instanceof WP_Markdown_Query_Result ) { return $written; } @@ -104,14 +117,17 @@ public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query } catch ( InvalidArgumentException ) { return $this->failure( 'unsupported_schema', 'mdi-native cannot compile the requested table definition.' ); } - if ( null === $schema ) { + if ( $temporary && null !== $this->temporary_tables ) { + if ( null === $schema ) { + $this->registry->shadow( $table, null, null, $definition ); + } else { + $this->registry->shadow( $table, $schema, new WP_Markdown_Native_JSON_Snapshot_Provider( $root, $schema, $suffix . '.json' ), $definition ); + } + $this->temporary_tables->add( $table ); + } elseif ( null === $schema ) { $this->registry->register_definition( $table, $definition ); } else { - $this->registry->register( - $table, - $schema, - new WP_Markdown_Native_JSON_Snapshot_Provider( $this->state_root, $schema, $suffix . '.json' ) - ); + $this->registry->register( $table, $schema, new WP_Markdown_Native_JSON_Snapshot_Provider( $root, $schema, $suffix . '.json' ) ); } return WP_Markdown_Query_Result::schema_changed(); } finally { @@ -141,6 +157,25 @@ private function execute_alter( WP_Markdown_Query_Request $request, string $sql if ( 1 !== preg_match( '/^[A-Za-z_][A-Za-z0-9_]*$/D', $suffix ) || null === $this->registry->definition( $table ) ) { return $this->failure( 'unknown_table', 'mdi-native cannot alter a table it does not persist.' ); } + try { + $actions = array(); + $start = 0; + $depth = 0; + foreach ( ( new WP_Markdown_Native_SQL_Tokenizer() )->tokenize( $action ) as $token ) { + if ( WP_Markdown_Native_SQL_Token::LEFT_PAREN === $token->type() ) { ++$depth; } + if ( WP_Markdown_Native_SQL_Token::RIGHT_PAREN === $token->type() ) { --$depth; } + if ( 0 === $depth && WP_Markdown_Native_SQL_Token::COMMA === $token->type() ) { + $actions[] = trim( substr( $action, $start, $token->sql_offset() - $start ) ); + $start = $token->sql_offset() + 1; + } + } + $actions[] = trim( substr( $action, $start ) ); + } catch ( WP_Markdown_Native_SQL_Parse_Error ) { + return $this->failure( 'unsupported_schema', 'The ALTER TABLE actions could not be parsed.' ); + } + if ( count( $actions ) > 1 ) { + return $this->execute_alter_actions( $request, $table, $actions ); + } if ( 1 === preg_match( '/^ADD\s+(?:(?:UNIQUE\s+)?(?:INDEX|KEY)\s+`?[A-Za-z0-9_]+`?|PRIMARY\s+KEY)\s*\(.+\)$/is', $action ) ) { return $this->execute_add_index( $table, $suffix, $action ); @@ -209,7 +244,7 @@ private function execute_alter( WP_Markdown_Query_Request $request, string $sql return $reconciled; } - $written = $this->write( $path, $rewritten ); + $written = $this->write_schema( $path, $rewritten, $table, $suffix, $prefix ); if ( $written instanceof WP_Markdown_Query_Result ) { return $written; } @@ -226,6 +261,35 @@ private function execute_alter( WP_Markdown_Query_Request $request, string $sql } } + /** Apply supported ALTER actions atomically after the outer DDL implicit commit. */ + private function execute_alter_actions( WP_Markdown_Query_Request $request, string $table, array $actions ): WP_Markdown_Query_Result { + if ( null === $this->transactions || count( $actions ) > 64 || in_array( '', $actions, true ) ) { + return $this->failure( 'unsupported_schema', 'A bounded multi-action ALTER requires a transaction journal.' ); + } + $locked = $this->transactions->begin_write(); + if ( true !== $locked ) { + return $this->failure( 'transaction_write_lock_failed', $locked ); + } + $begun = $this->transactions->begin(); + if ( true !== $begun ) { + return $this->failure( 'transaction_journal_failed', $begun ); + } + try { + foreach ( $actions as $action ) { + $result = $this->execute_alter( $request, 'ALTER TABLE `' . $table . '` ' . $action ); + if ( ! $result->succeeded() ) { + $restored = $this->transactions->rollback(); + return true === $restored ? $result : $this->failure( 'transaction_rollback_failed', $restored ); + } + } + $committed = $this->transactions->commit(); + return true === $committed ? WP_Markdown_Query_Result::schema_changed() : $this->failure( 'transaction_commit_failed', $committed ); + } catch ( Throwable ) { + $restored = $this->transactions->rollback(); + return $this->failure( 'schema_mutation_failed', true === $restored ? 'The ALTER actions failed and were restored.' : $restored ); + } + } + /** * Drop one persisted table. * @@ -236,6 +300,7 @@ private function execute_drop( WP_Markdown_Query_Request $request, string $sql ) if ( 1 !== preg_match( '/^\s*DROP\s+(?:TEMPORARY\s+)?TABLE\s+(IF\s+EXISTS\s+)?`?([A-Za-z0-9_]+)`?\s*$/is', $sql, $matched ) ) { return $this->failure( 'unsupported_schema', 'mdi-native supports one bounded DROP TABLE statement.' ); } + $temporary = 1 === preg_match( '/^\s*DROP\s+TEMPORARY\s+TABLE\b/i', $sql ); $tolerates_missing = '' !== trim( (string) $matched[1] ); $table = $matched[2]; $prefix = $request->table_prefix(); @@ -249,9 +314,26 @@ private function execute_drop( WP_Markdown_Query_Request $request, string $sql ) return $this->failure( 'unsupported_schema', 'mdi-native requires a simple table identifier.' ); } // Core tables are structural, not plugin state, so they are never dropped. - if ( isset( WP_Markdown_Native_Schema_Catalog::definitions()[ $suffix ] ) ) { + if ( ! $temporary && isset( WP_Markdown_Native_Schema_Catalog::definitions()[ $suffix ] ) ) { return $this->failure( 'unsupported_schema', 'mdi-native cannot drop a core table.' ); } + if ( $temporary ) { + if ( null === $this->temporary_tables || ! $this->temporary_tables->has( $table ) ) { + return $tolerates_missing ? WP_Markdown_Query_Result::schema_changed() : $this->failure( 'unknown_table', 'mdi-native cannot drop a temporary table that does not exist.' ); + } + $root = $this->temporary_tables->root(); + if ( null !== $this->transactions ) { + $this->transactions->discard_ephemeral( $root . '/_tables/' . $suffix . '.json' ); + } + @unlink( $root . '/_schema/' . $suffix . '.sql' ); + @unlink( $root . '/_tables/' . $suffix . '.json' ); + $this->temporary_tables->remove( $table ); + $this->registry->unshadow( $table ); + return WP_Markdown_Query_Result::schema_changed(); + } + if ( null !== $this->temporary_tables && $this->temporary_tables->has( $table ) ) { + return $this->failure( 'temporary_table_shadowed', 'mdi-native cannot safely apply permanent DDL while a temporary table shadows that name.' ); + } $directory = $this->schema_directory(); if ( $directory instanceof WP_Markdown_Query_Result ) { @@ -274,8 +356,8 @@ private function execute_drop( WP_Markdown_Query_Request $request, string $sql ) : $this->failure( 'unknown_table', 'mdi-native cannot drop a table it does not persist.' ); } if ( is_file( $path ) && ! is_link( $path ) ) { - if ( null !== $this->transactions ) { - $recorded = $this->transactions->record( $path ); + if ( null !== $this->transactions && ! $temporary ) { + $recorded = $this->record_schema( $path, $table, $suffix, $prefix ); if ( true !== $recorded ) { return $this->failure( 'transaction_journal_failed', $recorded ); } @@ -286,7 +368,7 @@ private function execute_drop( WP_Markdown_Query_Request $request, string $sql ) } $snapshot = $this->state_root . '/_tables/' . $suffix . '.json'; if ( is_file( $snapshot ) && ! is_link( $snapshot ) ) { - if ( null !== $this->transactions ) { + if ( null !== $this->transactions && ! $temporary ) { $recorded = $this->transactions->record( $snapshot ); if ( true !== $recorded ) { return $this->failure( 'transaction_journal_failed', $recorded ); @@ -390,7 +472,7 @@ private function execute_add_index( string $table, string $suffix, string $actio } catch ( InvalidArgumentException ) { return $this->failure( 'unsupported_schema', 'The altered table definition could not be compiled.' ); } - $written = $this->write( $path, $rewritten ); + $written = $this->write_schema( $path, $rewritten, $table, $suffix, $this->table_prefix_from( $table, $suffix ) ); if ( $written instanceof WP_Markdown_Query_Result ) { return $written; } @@ -480,7 +562,7 @@ private function execute_drop_index( string $table, string $suffix, string $name } $definition = $compiled[ $suffix ]; $schema = WP_Markdown_Native_Schema_Catalog::indexed_snapshot_schema( $definition ); - $written = $this->write( $path, $rewritten ); + $written = $this->write_schema( $path, $rewritten, $table, $suffix, $this->table_prefix_from( $table, $suffix ) ); if ( $written instanceof WP_Markdown_Query_Result ) { return $written; } @@ -670,18 +752,56 @@ private function matching_paren( string $sql, int $open ): ?int { return null; } - private function schema_directory(): string|WP_Markdown_Query_Result { - $path = $this->state_root . '/_schema'; + private function schema_directory( ?string $root = null ): string|WP_Markdown_Query_Result { + $base_root = $root ?? $this->state_root; + $path = $base_root . '/_schema'; if ( ! file_exists( $path ) && ! @mkdir( $path, 0755 ) && ! is_dir( $path ) ) { return $this->failure( 'schema_directory_failed', 'The canonical schema directory could not be created.' ); } $root = realpath( $path ); - if ( false === $root || ! is_dir( $root ) || is_link( $path ) || dirname( $root ) !== $this->state_root ) { + if ( false === $root || ! is_dir( $root ) || is_link( $path ) || dirname( $root ) !== $base_root ) { return $this->failure( 'unsafe_schema_directory', 'The canonical schema directory is unavailable or unsafe.' ); } return $root; } + private function write_schema( string $path, string $contents, string $table, string $suffix, string $prefix, bool $temporary = false ): true|WP_Markdown_Query_Result { + if ( ! $temporary ) { + $recorded = $this->record_schema( $path, $table, $suffix, $prefix ); + if ( true !== $recorded ) { + return $this->failure( 'transaction_journal_failed', $recorded ); + } + } + return $this->publish( $path, $contents ); + } + + /** Rebuild the registry after a transaction restores a schema pre-image. */ + private function record_schema( string $path, string $table, string $suffix, string $prefix ): true|string { + return null === $this->transactions + ? true + : $this->transactions->record( $path, function () use ( $path, $table, $suffix, $prefix ): void { + $this->registry->unregister( $table ); + if ( ! is_file( $path ) || is_link( $path ) ) { + return; + } + try { + $definitions = WP_Markdown_Native_Schema_Catalog::compile( (string) file_get_contents( $path ), array( $prefix ) ); + $definition = $definitions[ $suffix ] ?? null; + $schema = is_array( $definition ) ? WP_Markdown_Native_Schema_Catalog::indexed_snapshot_schema( $definition ) : null; + } catch ( InvalidArgumentException ) { + return; + } + if ( ! is_array( $definition ) ) { + return; + } + if ( null === $schema ) { + $this->registry->register_definition( $table, $definition ); + return; + } + $this->registry->register( $table, $schema, new WP_Markdown_Native_JSON_Snapshot_Provider( $this->state_root, $schema, $suffix . '.json' ) ); + } ); + } + private function write( string $path, string $contents ): true|WP_Markdown_Query_Result { if ( null !== $this->transactions ) { $recorded = $this->transactions->record( $path ); @@ -689,6 +809,10 @@ private function write( string $path, string $contents ): true|WP_Markdown_Query return $this->failure( 'transaction_journal_failed', $recorded ); } } + return $this->publish( $path, $contents ); + } + + private function publish( string $path, string $contents ): true|WP_Markdown_Query_Result { try { $temp = $path . '.tmp-' . getmypid() . '-' . bin2hex( random_bytes( 8 ) ); } catch ( Throwable ) { diff --git a/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-sql-session.php b/inc/native/class-wp-markdown-native-sql-session.php new file mode 100644 index 0000000..1e2015e --- /dev/null +++ b/inc/native/class-wp-markdown-native-sql-session.php @@ -0,0 +1,60 @@ +sql_mode; } + + public function strict(): bool { + return in_array( 'STRICT_TRANS_TABLES', explode( ',', $this->sql_mode ), true ) || in_array( 'STRICT_ALL_TABLES', explode( ',', $this->sql_mode ), true ); + } + + public function reset(): void { + $this->sql_mode = ''; + $this->warnings = array(); + $this->warning_count = 0; + } + + public function warn_missing_default( string $column, bool $error = false ): void { + ++$this->warning_count; + if ( count( $this->warnings ) < 64 ) { + $this->warnings[] = array( 'Level' => $error ? 'Error' : 'Warning', 'Code' => '1364', 'Message' => "Field '{$column}' doesn't have a default value" ); + } + } + + /** Handle supported session statements, otherwise start a new diagnostic area. */ + public function execute( string $sql ): ?WP_Markdown_Query_Result { + if ( 1 === preg_match( '/^\s*SHOW\s+WARNINGS\s*;?\s*$/i', $sql ) ) { + return WP_Markdown_Query_Result::selected( $this->warnings, array( + array( 'name' => 'Level', 'table' => '', 'type' => 253 ), + array( 'name' => 'Code', 'table' => '', 'type' => 3 ), + array( 'name' => 'Message', 'table' => '', 'type' => 253 ), + ) ); + } + if ( 1 === preg_match( '/^\s*SELECT\s+(@@(?:SESSION\.)?warning_count)\s*;?\s*$/i', $sql, $match ) ) { + return WP_Markdown_Query_Result::selected( array( array( $match[1] => (string) $this->warning_count ) ), array( array( 'name' => $match[1], 'table' => '', 'type' => 8 ) ) ); + } + $this->warnings = array(); + $this->warning_count = 0; + if ( 1 === preg_match( '/^\s*SELECT\s+(@@(?:SESSION\.)?sql_mode)\s*;?\s*$/i', $sql, $match ) ) { + return WP_Markdown_Query_Result::selected( array( array( $match[1] => $this->sql_mode ) ), array( array( 'name' => $match[1], 'table' => '', 'type' => 253 ) ) ); + } + if ( 1 !== preg_match( "/^\\s*SET\\s+(?:SESSION\\s+|@@(?:SESSION\\.)?)?sql_mode\\s*=\\s*'([A-Za-z_, ]*)'\\s*;?\\s*$/i", $sql, $match ) ) { + return null; + } + $modes = array_values( array_unique( array_filter( array_map( static fn( string $mode ): string => strtoupper( trim( $mode ) ), explode( ',', $match[1] ) ) ) ) ); + $supported = array( 'STRICT_TRANS_TABLES', 'STRICT_ALL_TABLES', 'NO_ENGINE_SUBSTITUTION' ); + if ( array_diff( $modes, $supported ) ) { + return WP_Markdown_Query_Result::failure( array( 'code' => 'markdown_db_native_unsupported_query', 'reason' => 'unsupported_sql_mode', 'message' => 'The requested SQL mode includes semantics not implemented by the native session.' ) ); + } + $this->sql_mode = implode( ',', array_values( array_intersect( $supported, $modes ) ) ); + return WP_Markdown_Query_Result::mutated( 0 ); + } +} diff --git a/inc/native/class-wp-markdown-native-table-insert-parser.php b/inc/native/class-wp-markdown-native-table-insert-parser.php index 37c8ade..2451c3c 100644 --- a/inc/native/class-wp-markdown-native-table-insert-parser.php +++ b/inc/native/class-wp-markdown-native-table-insert-parser.php @@ -70,12 +70,12 @@ public function parse_rows( WP_Markdown_Query_Request $request ): array|WP_Markd } while ( true ); $unless_exists = null; } - $upsert_columns = null; + $upsert_assignments = null; if ( 0 === strcasecmp( 'ON', (string) $this->current()->value() ) ) { if ( $replace || $ignore_duplicate || null !== $unless_exists ) { throw new WP_Markdown_Native_SQL_Parse_Error( 'unsupported_grammar', $this->current()->sql_offset(), 'mdi-native cannot combine INSERT IGNORE or INSERT SELECT FROM DUAL with ON DUPLICATE KEY UPDATE.' ); } - $upsert_columns = $this->upsert_assignments( $columns ); + $upsert_assignments = $this->upsert_assignments(); } $this->type( WP_Markdown_Native_SQL_Token::END ); $inserts = array(); @@ -87,7 +87,7 @@ public function parse_rows( WP_Markdown_Query_Request $request ): array|WP_Markd if ( false === $row ) { return $this->failure( 'invalid_insert_row', 'mdi-native requires one nonempty INSERT row.' ); } - $inserts[] = new WP_Markdown_Native_Table_Insert( $table, $row, $unless_exists, $ignore_duplicate, $upsert_columns, $replace ); + $inserts[] = new WP_Markdown_Native_Table_Insert( $table, $row, $unless_exists, $ignore_duplicate, $upsert_assignments, $replace ); } return $inserts; } catch ( WP_Markdown_Native_SQL_Parse_Error $error ) { @@ -125,7 +125,7 @@ public function parse_write( WP_Markdown_Query_Request $request ): WP_Markdown_N $this->word( 'UPDATE' ); $table = $this->identifier(); $this->word( 'SET' ); - $values = $this->assignments(); + $values = $this->assignments( $table ); } $predicates = $this->where_predicates(); $this->type( WP_Markdown_Native_SQL_Token::END ); @@ -142,13 +142,26 @@ public function parse_write( WP_Markdown_Query_Request $request ): WP_Markdown_N } } - /** @return array */ - private function assignments(): array { + /** @return array */ + private function assignments( string $table ): array { $values = array(); do { $column = $this->identifier(); + if ( array_key_exists( $column, $values ) ) { + throw new WP_Markdown_Native_SQL_Parse_Error( 'duplicate_mutation_column', $this->current()->sql_offset(), 'Repeated UPDATE targets are not supported.' ); + } $this->type( WP_Markdown_Native_SQL_Token::EQUALS ); - $values[ $column ] = $this->literal(); + $parser = new WP_Markdown_Native_Select_AST_Parser( $this->tokens, $this->position ); + $expression = $parser->scalar_value(); + $this->position = $parser->position(); + foreach ( $expression->columns() as $source ) { + if ( null !== $source->qualifier() && 0 !== strcasecmp( $table, $source->qualifier() ) ) { + throw new WP_Markdown_Native_SQL_Parse_Error( 'unsupported_mutation_column', $source->sql_offset(), 'An UPDATE expression must reference its target table.' ); + } + } + $values[ $column ] = 'literal' === $expression->kind() + ? ( null === $expression->literal() ? null : (string) $expression->literal() ) + : ( new WP_Markdown_Native_Query_Parser() )->lower_scalar_expression( $expression, null, $table ); if ( WP_Markdown_Native_SQL_Token::COMMA !== $this->current()->type() ) { break; } @@ -177,27 +190,25 @@ private function where_predicates(): array { } $this->word( 'WHERE' ); - $predicates = array( $this->where_disjunction() ); - while ( $this->is_word( 'AND' ) ) { - ++$this->position; - $predicates[] = $this->where_disjunction(); - } - return array_filter( $predicates ); + $predicate = $this->where_disjunction(); + return $predicate instanceof WP_Markdown_Native_Table_Predicate_Group && $predicate->all() + ? $predicate->any() + : array( $predicate ); } /** @return WP_Markdown_Native_Table_Predicate|WP_Markdown_Native_Table_Predicate_Group|WP_Markdown_Native_Table_Subquery_Predicate|null */ private function where_disjunction() { - $alternatives = array( $this->where_factor() ); + $alternatives = array( $this->where_conjunction() ); while ( $this->is_word( 'OR' ) ) { ++$this->position; - $alternatives[] = $this->where_factor(); + $alternatives[] = $this->where_conjunction(); } $alternatives = array_values( array_filter( $alternatives ) ); if ( array() === $alternatives ) { return null; } foreach ( $alternatives as $alternative ) { - if ( $alternative instanceof WP_Markdown_Native_Table_Subquery_Predicate && 1 !== count( $alternatives ) ) { + if ( 1 !== count( $alternatives ) && $this->has_subquery( $alternative ) ) { throw new WP_Markdown_Native_SQL_Parse_Error( 'unsupported_subquery_shape', $this->current()->sql_offset(), 'mdi-native supports IN subqueries only as conjunctive write restrictions.' ); } } @@ -206,6 +217,26 @@ private function where_disjunction() { : new WP_Markdown_Native_Table_Predicate_Group( $alternatives ); } + /** @return WP_Markdown_Native_Table_Predicate|WP_Markdown_Native_Table_Predicate_Group|WP_Markdown_Native_Table_Subquery_Predicate|null */ + private function where_conjunction() { + $terms = array( $this->where_factor() ); + while ( $this->is_word( 'AND' ) ) { + ++$this->position; + $terms[] = $this->where_factor(); + } + return 1 === count( $terms ) ? $terms[0] : new WP_Markdown_Native_Table_Predicate_Group( $terms, true ); + } + + private function has_subquery( mixed $predicate ): bool { + if ( $predicate instanceof WP_Markdown_Native_Table_Subquery_Predicate ) { return true; } + if ( $predicate instanceof WP_Markdown_Native_Table_Predicate_Group ) { + foreach ( $predicate->any() as $term ) { + if ( $this->has_subquery( $term ) ) { return true; } + } + } + return false; + } + /** @return WP_Markdown_Native_Table_Predicate|WP_Markdown_Native_Table_Predicate_Group|WP_Markdown_Native_Table_Subquery_Predicate|null */ private function where_factor() { if ( WP_Markdown_Native_SQL_Token::LEFT_PAREN === $this->current()->type() ) { @@ -268,12 +299,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 => '>', @@ -319,31 +351,38 @@ private function identifier_list(): array { return array_values( $columns ); } - /** @param array $columns @return array */ - private function upsert_assignments( array $columns ): array { + /** @return array */ + private function upsert_assignments(): array { $this->word( 'ON' ); $this->word( 'DUPLICATE' ); $this->word( 'KEY' ); $this->word( 'UPDATE' ); $assignments = array(); - $available = array_fill_keys( $columns, true ); do { $target = $this->identifier(); $this->type( WP_Markdown_Native_SQL_Token::EQUALS ); - $this->word( 'VALUES' ); - $this->type( WP_Markdown_Native_SQL_Token::LEFT_PAREN ); - $source = $this->identifier(); - $this->type( WP_Markdown_Native_SQL_Token::RIGHT_PAREN ); - if ( $target !== $source || isset( $assignments[ $target ] ) || ! isset( $available[ $target ] ) ) { - throw new WP_Markdown_Native_SQL_Parse_Error( 'unsupported_grammar', $this->current()->sql_offset(), 'mdi-native requires deterministic VALUES assignments for ON DUPLICATE KEY UPDATE.' ); + $source = null; + $value = null; + if ( $this->is_word( 'VALUES' ) ) { + $this->word( 'VALUES' ); + $this->type( WP_Markdown_Native_SQL_Token::LEFT_PAREN ); + $source = $this->identifier(); + $this->type( WP_Markdown_Native_SQL_Token::RIGHT_PAREN ); + $kind = 'inserted'; + } elseif ( in_array( $this->current()->type(), array( WP_Markdown_Native_SQL_Token::WORD, WP_Markdown_Native_SQL_Token::QUOTED_IDENTIFIER ), true ) ) { + $source = $this->identifier(); + $kind = 'column'; + } else { + $value = $this->literal(); + $kind = 'literal'; } - $assignments[ $target ] = $target; + $assignments[] = array( 'target' => $target, 'kind' => $kind, 'source' => $source, 'value' => $value ); if ( WP_Markdown_Native_SQL_Token::COMMA !== $this->current()->type() ) { break; } ++$this->position; } while ( true ); - return array_values( $assignments ); + return $assignments; } /** @return array */ diff --git a/inc/native/class-wp-markdown-native-table-mutations.php b/inc/native/class-wp-markdown-native-table-mutations.php index e965915..ccc48a2 100644 --- a/inc/native/class-wp-markdown-native-table-mutations.php +++ b/inc/native/class-wp-markdown-native-table-mutations.php @@ -11,6 +11,8 @@ final class WP_Markdown_Native_Table_Mutation_Runtime { private string $state_root; private WP_Markdown_Native_Table_Index $index; + /** @var array */ + private array $temporary_indexes = array(); /** @var array */ private array $unique_sets_verified = array(); @@ -18,7 +20,9 @@ public function __construct( string $state_root, private WP_Markdown_Native_Table_Registry $registry, private WP_Markdown_Native_Table_Insert_Parser $parser = new WP_Markdown_Native_Table_Insert_Parser(), - private ?WP_Markdown_Native_Transaction_Journal $transactions = null + private ?WP_Markdown_Native_Transaction_Journal $transactions = null, + private ?WP_Markdown_Native_Temporary_Tables $temporary_tables = null, + private WP_Markdown_Native_SQL_Session $session = new WP_Markdown_Native_SQL_Session() ) { $root = realpath( $state_root ); if ( false === $root || ! is_dir( $root ) ) { @@ -91,12 +95,13 @@ private function execute_insert( WP_Markdown_Query_Request $request, WP_Markdown || null === $table || ! $table['provider'] instanceof WP_Markdown_Native_JSON_Snapshot_Provider || ! is_array( $definition ) - || ! $this->is_authoritative_definition( $suffix, $definition, $prefix ) + || ! $this->is_authoritative_definition( $suffix, $definition, $prefix, $insert->table() ) ) { return $this->failure( 'unsupported_mutation_table', 'mdi-native can insert only into a persisted generic snapshot table.' ); } - $directory = $this->tables_directory(); + $root = $this->root_for( $insert->table() ); + $directory = $this->tables_directory( $root ); if ( $directory instanceof WP_Markdown_Query_Result ) { return $directory; } @@ -108,6 +113,11 @@ private function execute_insert( WP_Markdown_Query_Request $request, WP_Markdown try { $schema = $table['schema']; $provider = $table['provider']; + foreach ( $insert->upsert_assignments() ?? array() as $assignment ) { + if ( ! $schema->has_column( $assignment['target'] ) || ( null !== $assignment['source'] && ! $schema->has_column( $assignment['source'] ) ) ) { + return $this->failure( 'unsupported_column', 'The duplicate-key assignment references an undeclared column.' ); + } + } if ( ! $this->supports_unique_indexes( $definition ) ) { return $this->failure( 'unsupported_unique_collation', 'mdi-native cannot enforce a persisted string or prefix unique key without its exact collation.' ); } @@ -124,9 +134,10 @@ private function execute_insert( WP_Markdown_Query_Request $request, WP_Markdown } } $path = $directory . '/' . $suffix . '.json'; - $index = $insert->is_replace() || null !== $insert->upsert_columns() || WP_Markdown_Native_Table_Index::supplies_identity( $insert->values(), $definition ) + $table_index = $this->index_for( $root ); + $index = $insert->is_replace() || null !== $insert->upsert_assignments() || WP_Markdown_Native_Table_Index::supplies_identity( $insert->values(), $definition ) ? null - : $this->index->load( $suffix, $path ); + : $table_index->load( $suffix, $path ); if ( null !== $index ) { // The index enforces this candidate's keys, while this witnessed // snapshot proves the pre-existing keys were already unique. @@ -152,7 +163,7 @@ private function execute_insert( WP_Markdown_Query_Request $request, WP_Markdown if ( $appended instanceof WP_Markdown_Query_Result ) { return $appended; } - $this->index->remember( $suffix, $path, WP_Markdown_Native_Table_Index::with_row( $index, $row, $definition, $schema ) ); + $table_index->remember( $suffix, $path, WP_Markdown_Native_Table_Index::with_row( $index, $row, $definition, $schema ) ); if ( $unique_set_verified ) { $this->remember_verified_unique_set( $suffix, $path ); } @@ -189,36 +200,47 @@ private function execute_insert( WP_Markdown_Query_Request $request, WP_Markdown } // REPLACE already scans and republishes the snapshot. Leave the // derived insert index for the next operation that needs it. - $this->index->forget( $suffix, $this->transactions ); + $table_index->forget( $suffix, $this->transactions ); $provider->replace_rows( $rows ); return WP_Markdown_Query_Result::mutated( count( $duplicates ) + 1, $this->auto_increment_value( $row, $definition ) ); } if ( $insert->ignores_duplicate() ) { return WP_Markdown_Query_Result::mutated( 0 ); } - $upsert_columns = $insert->upsert_columns(); - if ( null === $upsert_columns ) { + $upsert_assignments = $insert->upsert_assignments(); + if ( null === $upsert_assignments ) { return $this->failure( 'duplicate_key', 'The INSERT row duplicates a persisted unique key.' ); } $duplicate = $duplicates[0]; $updated = $rows[ $duplicate ]; - foreach ( $upsert_columns as $column ) { - $updated[ $column ] = $row[ $column ]; + foreach ( $upsert_assignments as $assignment ) { + // Existing-column references see earlier assignments; VALUES sees the proposed insert. + $updated[ $assignment['target'] ] = match ( $assignment['kind'] ) { + 'inserted' => $row[ $assignment['source'] ], + 'column' => $updated[ $assignment['source'] ], + default => $assignment['value'], + }; } if ( true !== $schema->validate_row( $updated ) ) { return $this->failure( 'invalid_insert_row', 'The INSERT row is outside the persisted table schema.' ); } + if ( ! $this->unique_values_enforceable( $updated, $definition ) ) { + return $this->failure( 'unsupported_unique_collation', 'The duplicate-key assignment requires an unsupported unique-key collation.' ); + } $others = $rows; unset( $others[ $duplicate ] ); if ( $this->duplicate_row_offset( $updated, array_values( $others ), $definition, $schema ) !== null ) { return $this->failure( 'duplicate_key', 'The INSERT row duplicates a persisted unique key.' ); } + if ( $updated === $rows[ $duplicate ] ) { + return WP_Markdown_Query_Result::mutated( 0 ); + } $rows[ $duplicate ] = $updated; $written = $this->write( $path, array_values( $rows ) ); if ( $written instanceof WP_Markdown_Query_Result ) { return $written; } - $this->index->save( $suffix, $path, WP_Markdown_Native_Table_Index::build( array_values( $rows ), $definition, $schema ), $this->transactions ); + $table_index->save( $suffix, $path, WP_Markdown_Native_Table_Index::build( array_values( $rows ), $definition, $schema ), $this->transactions ); $provider->replace_rows( array_values( $rows ) ); return WP_Markdown_Query_Result::mutated( 2, $this->auto_increment_value( $updated, $definition ) ); } @@ -227,7 +249,7 @@ private function execute_insert( WP_Markdown_Query_Request $request, WP_Markdown if ( $written instanceof WP_Markdown_Query_Result ) { return $written; } - $this->index->save( $suffix, $path, WP_Markdown_Native_Table_Index::build( $rows, $definition, $schema ), $this->transactions ); + $table_index->save( $suffix, $path, WP_Markdown_Native_Table_Index::build( $rows, $definition, $schema ), $this->transactions ); $provider->replace_rows( $rows ); return WP_Markdown_Query_Result::mutated( 1, $this->auto_increment_value( $row, $definition ) ); } finally { @@ -269,7 +291,7 @@ private function append_row( string $path, array $row, bool $empty ): true|WP_Ma return $this->failure( 'unsafe_table_file', 'The canonical table file is unavailable or unsafe.' ); } if ( null !== $this->transactions ) { - $recorded = $this->transactions->record( $path ); + $recorded = $this->is_temporary_path( $path ) ? $this->transactions->record_ephemeral( $path ) : $this->transactions->record( $path ); if ( true !== $recorded ) { return $this->failure( 'transaction_journal_failed', $recorded ); } @@ -325,6 +347,18 @@ private function complete_row( array $provided, array $definition, array $rows, if ( array_diff_key( $provided, $definition['columns'] ) ) { return $this->failure( 'unsupported_column', 'The INSERT references an undeclared column.' ); } + if ( $this->session->strict() ) { + $missing = array(); + foreach ( $definition['columns'] as $name => $column ) { + if ( ! array_key_exists( $name, $provided ) && empty( $column['auto_increment'] ) && empty( $column['nullable'] ) && null === ( $column['default'] ?? null ) ) { + $missing[] = $name; + $this->session->warn_missing_default( $name, true ); + } + } + if ( array() !== $missing ) { + return WP_Markdown_Query_Result::failure( array( 'code' => 1364, 'reason' => 'missing_required_column', 'message' => "Field '{$missing[0]}' doesn't have a default value" ) ); + } + } $row = array(); foreach ( $definition['columns'] as $name => $column ) { $generate_identity = true === ( $column['auto_increment'] ?? false ) @@ -371,6 +405,22 @@ private function complete_row( array $provided, array $definition, array $rows, $row[ $name ] = null; continue; } + if ( ! $this->session->strict() ) { + $type = strtolower( $column['type'] ?? '' ); + $implicit = match ( $type ) { + 'tinyint', 'smallint', 'mediumint', 'int', 'integer', 'bigint', 'decimal', 'numeric', 'float', 'double', 'real', 'year' => '0', + 'char', 'varchar', 'tinytext', 'text', 'mediumtext', 'longtext', 'tinyblob', 'blob', 'mediumblob', 'longblob', 'varbinary' => '', + 'date' => '0000-00-00', + 'datetime', 'timestamp' => '0000-00-00 00:00:00', + 'time' => '00:00:00', + default => null, + }; + if ( null !== $implicit ) { + $row[ $name ] = $implicit; + $this->session->warn_missing_default( $name ); + continue; + } + } return $this->failure( 'missing_required_column', 'The INSERT omits a required column without a deterministic default.' ); } return $row; @@ -462,7 +512,7 @@ private function execute_write( WP_Markdown_Query_Request $request ): WP_Markdow || null === $table || ! $table['provider'] instanceof WP_Markdown_Native_JSON_Snapshot_Provider || ! is_array( $definition ) - || ! $this->is_authoritative_definition( $suffix, $definition, $prefix ) + || ! $this->is_authoritative_definition( $suffix, $definition, $prefix, $write->table() ) ) { return $this->failure( 'unsupported_mutation_table', 'mdi-native can mutate only a persisted generic snapshot table.' ); } @@ -482,12 +532,26 @@ private function execute_write( WP_Markdown_Query_Request $request ): WP_Markdow } } } - foreach ( array_keys( $write->values() ) as $column ) { + foreach ( $write->values() as $column => $value ) { if ( ! $schema->has_column( (string) $column ) ) { return $this->failure( 'unsupported_mutation_column', 'The assignment names a column outside the persisted table schema.' ); } + if ( $value instanceof WP_Markdown_Native_Query_Scalar_Expression ) { + foreach ( $value->columns() as $source ) { + if ( ! $schema->has_column( $source ) ) { + return $this->failure( 'unsupported_mutation_column', 'The expression names a column outside the persisted table schema.' ); + } + } + foreach ( $value->predicates() as $predicate ) { + if ( ! $schema->supports_predicate( $predicate ) ) { + return $this->failure( 'unsupported_predicate', 'The assignment expression uses an unsupported predicate.' ); + } + } + } } - $directory = $this->tables_directory(); + $scalar_runtime = new WP_Markdown_Native_Query_Runtime( $this->registry, new WP_Markdown_Native_Query_Parser() ); + $root = $this->root_for( $write->table() ); + $directory = $this->tables_directory( $root ); if ( $directory instanceof WP_Markdown_Query_Result ) { return $directory; } @@ -499,7 +563,8 @@ private function execute_write( WP_Markdown_Query_Request $request ): WP_Markdow try { $path = $directory . '/' . $suffix . '.json'; $provider = $table['provider']; - $index = $this->index->load( $suffix, $path ); + $table_index = $this->index_for( $root ); + $index = $table_index->load( $suffix, $path ); if ( null !== $index && $this->index_excludes( $index, $predicates ) ) { return WP_Markdown_Query_Result::mutated( 0 ); } @@ -522,7 +587,13 @@ private function execute_write( WP_Markdown_Query_Request $request ): WP_Markdow if ( ! $write->is_update() ) { continue; } - $updated = array_merge( $row, $write->values() ); + $updated = $row; + foreach ( $write->values() as $column => $value ) { + $value = $value instanceof WP_Markdown_Native_Query_Scalar_Expression + ? $scalar_runtime->evaluate_scalar( $value, $updated, $schema ) + : $value; + $updated[ $column ] = null === $value ? null : (string) $value; + } if ( true !== $schema->validate_row( $updated ) ) { return $this->failure( 'invalid_update_row', 'The UPDATE row is outside the persisted table schema.' ); } @@ -533,7 +604,7 @@ private function execute_write( WP_Markdown_Query_Request $request ): WP_Markdow } if ( 0 === $affected ) { - $this->index->remember( $suffix, $path, WP_Markdown_Native_Table_Index::build( $rows, $definition, $schema ) ); + $table_index->remember( $suffix, $path, WP_Markdown_Native_Table_Index::build( $rows, $definition, $schema ) ); return WP_Markdown_Query_Result::mutated( 0 ); } $unique_set_verified = $write->is_update() @@ -552,7 +623,7 @@ private function execute_write( WP_Markdown_Query_Request $request ): WP_Markdow $this->remember_verified_unique_set( $suffix, $path ); // The sidecar is derived state. Keep this runtime's witnessed index // current without republishing it after every canonical table write. - $this->index->remember( $suffix, $path, $updated_index ?? WP_Markdown_Native_Table_Index::build( $retained, $definition, $schema ) ); + $table_index->remember( $suffix, $path, $updated_index ?? WP_Markdown_Native_Table_Index::build( $retained, $definition, $schema ) ); $provider->replace_rows( $retained ); return WP_Markdown_Query_Result::mutated( $affected ); } finally { @@ -576,6 +647,12 @@ private function resolve_subquery_predicates( array $predicates, WP_Markdown_Nat $query_parser = new WP_Markdown_Native_Query_Parser(); $query_runtime = new WP_Markdown_Native_Query_Runtime( $this->registry, $query_parser ); foreach ( $predicates as $predicate ) { + if ( $predicate instanceof WP_Markdown_Native_Table_Predicate_Group ) { + $terms = $this->resolve_subquery_predicates( $predicate->any(), $schema, $target_table ); + if ( $terms instanceof WP_Markdown_Query_Result ) { return $terms; } + $resolved[] = new WP_Markdown_Native_Table_Predicate_Group( $terms, $predicate->all() ); + continue; + } if ( ! $predicate instanceof WP_Markdown_Native_Table_Subquery_Predicate ) { $resolved[] = $predicate; continue; @@ -646,11 +723,12 @@ private function index_excludes( array $index, array $predicates ): bool { private function index_predicate_excludes( array $index, mixed $predicate ): bool { if ( $predicate instanceof WP_Markdown_Native_Table_Predicate_Group ) { foreach ( $predicate->any() as $alternative ) { - if ( ! $this->index_predicate_excludes( $index, $alternative ) ) { - return false; + $excluded = $this->index_predicate_excludes( $index, $alternative ); + if ( $excluded === $predicate->all() ) { + return $excluded; } } - return true; + return ! $predicate->all(); } if ( ! $predicate instanceof WP_Markdown_Native_Table_Predicate || '=' !== $predicate->operator() ) { return false; @@ -691,17 +769,22 @@ private function restricts( array $row, array $predicates, WP_Markdown_Native_Ta private function restricts_predicate( array $row, $predicate, WP_Markdown_Native_Table_Schema $schema ): bool { if ( $predicate instanceof WP_Markdown_Native_Table_Predicate_Group ) { foreach ( $predicate->any() as $alternative ) { - if ( $this->restricts_predicate( $row, $alternative, $schema ) ) { - return true; + $matches = $this->restricts_predicate( $row, $alternative, $schema ); + if ( $matches !== $predicate->all() ) { + return $matches; } } - return array() === $predicate->any(); + return $predicate->all(); } $value = $row[ $predicate->column() ] ?? null; if ( $predicate->matches_null() && null === $value ) { 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 ) { @@ -883,8 +966,8 @@ private function unique_set_violation( array $rows, array $definition, WP_Markdo * * @param array $definition */ - private function is_authoritative_definition( string $suffix, array $definition, string $prefix ): bool { - return $this->is_persisted_definition( $suffix, $definition, $prefix ) + private function is_authoritative_definition( string $suffix, array $definition, string $prefix, string $table ): bool { + return $this->is_persisted_definition( $suffix, $definition, $prefix, $this->root_for( $table ) ) || $this->is_generated_core_definition( $suffix, $definition ); } @@ -893,10 +976,10 @@ private function is_generated_core_definition( string $suffix, array $definition return WP_Markdown_Native_Schema_Catalog::is_generated_core_definition( $suffix, $definition ); } - private function is_persisted_definition( string $suffix, array $definition, string $prefix ): bool { - $directory = realpath( $this->state_root . '/_schema' ); + private function is_persisted_definition( string $suffix, array $definition, string $prefix, string $root ): bool { + $directory = realpath( $root . '/_schema' ); $path = false === $directory ? '' : $directory . '/' . $suffix . '.sql'; - if ( false === $directory || is_link( $this->state_root . '/_schema' ) || ! is_file( $path ) || is_link( $path ) ) { + if ( false === $directory || is_link( $root . '/_schema' ) || ! is_file( $path ) || is_link( $path ) ) { return false; } try { @@ -907,16 +990,16 @@ private function is_persisted_definition( string $suffix, array $definition, str } } - private function tables_directory(): string|WP_Markdown_Query_Result { - $path = $this->state_root . '/_tables'; + private function tables_directory( string $root ): string|WP_Markdown_Query_Result { + $path = $root . '/_tables'; if ( ! file_exists( $path ) && ! @mkdir( $path, 0755 ) && ! is_dir( $path ) ) { return $this->failure( 'tables_directory_failed', 'The canonical tables directory could not be created.' ); } - $root = realpath( $path ); - if ( false === $root || ! is_dir( $root ) || is_link( $path ) || dirname( $root ) !== $this->state_root ) { + $directory = realpath( $path ); + if ( false === $directory || ! is_dir( $directory ) || is_link( $path ) || dirname( $directory ) !== $root ) { return $this->failure( 'unsafe_tables_directory', 'The canonical tables directory is unavailable or unsafe.' ); } - return $root; + return $directory; } /** Coordinate only writers that publish the same canonical table. */ @@ -938,7 +1021,7 @@ private function write( string $path, array $rows ): true|WP_Markdown_Query_Resu return $this->failure( 'unsafe_table_file', 'The canonical table file is unavailable or unsafe.' ); } if ( null !== $this->transactions ) { - $recorded = $this->transactions->record( $path ); + $recorded = $this->is_temporary_path( $path ) ? $this->transactions->record_ephemeral( $path ) : $this->transactions->record( $path ); if ( true !== $recorded ) { return $this->failure( 'transaction_journal_failed', $recorded ); } @@ -982,6 +1065,23 @@ private function write( string $path, array $rows ): true|WP_Markdown_Query_Resu return true; } + private function root_for( string $table ): string { + return null !== $this->temporary_tables && $this->temporary_tables->has( $table ) + ? $this->temporary_tables->root() + : $this->state_root; + } + + private function index_for( string $root ): WP_Markdown_Native_Table_Index { + if ( $root === $this->state_root ) { + return $this->index; + } + return $this->temporary_indexes[ $root ] ??= new WP_Markdown_Native_Table_Index( $root . DIRECTORY_SEPARATOR . '_tables' ); + } + + private function is_temporary_path( string $path ): bool { + return null !== $this->temporary_tables && str_starts_with( $path, $this->temporary_tables->root() . DIRECTORY_SEPARATOR ); + } + private function failure( string $reason, string $message ): WP_Markdown_Query_Result { return WP_Markdown_Query_Result::failure( array( diff --git a/inc/native/class-wp-markdown-native-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/inc/native/class-wp-markdown-native-table-statements.php b/inc/native/class-wp-markdown-native-table-statements.php index 6d14d18..fdc7e43 100644 --- a/inc/native/class-wp-markdown-native-table-statements.php +++ b/inc/native/class-wp-markdown-native-table-statements.php @@ -9,13 +9,14 @@ final class WP_Markdown_Native_Table_Insert { /** * @param array $values * @param array|null $unless_exists + * @param array|null $upsert_assignments */ public function __construct( private readonly string $table, private readonly array $values, private readonly ?array $unless_exists = null, private readonly bool $ignore_duplicate = false, - private readonly ?array $upsert_columns = null, + private readonly ?array $upsert_assignments = null, private readonly bool $replace = false ) {} @@ -37,9 +38,9 @@ public function ignores_duplicate(): bool { return $this->ignore_duplicate; } - /** @return array|null */ - public function upsert_columns(): ?array { - return $this->upsert_columns; + /** @return array|null */ + public function upsert_assignments(): ?array { + return $this->upsert_assignments; } public function is_replace(): bool { @@ -81,18 +82,23 @@ public function operator(): string { } } -/** One OR group of restrictions, evaluated as a disjunction per row. */ +/** A nested group of restrictions, using AND when all is true and OR otherwise. */ final class WP_Markdown_Native_Table_Predicate_Group { /** @param array $any */ public function __construct( - private readonly array $any + private readonly array $any, + private readonly bool $all = false ) {} /** @return array */ public function any(): array { return $this->any; } + + public function all(): bool { + return $this->all; + } } /** A typed single-column IN predicate whose members come from a typed SELECT. */ @@ -115,7 +121,7 @@ public function query(): WP_Markdown_Native_SQL_Select { final class WP_Markdown_Native_Table_Write { /** - * @param array $values Assignments for an UPDATE. + * @param array $values Assignments for an UPDATE, in evaluation order. * @param array $predicates Conjunctive restrictions. */ public function __construct( @@ -133,7 +139,7 @@ public function table(): string { return $this->table; } - /** @return array */ + /** @return array */ public function values(): array { return $this->values; } diff --git a/inc/native/class-wp-markdown-native-temporary-tables.php b/inc/native/class-wp-markdown-native-temporary-tables.php new file mode 100644 index 0000000..d47a11c --- /dev/null +++ b/inc/native/class-wp-markdown-native-temporary-tables.php @@ -0,0 +1,44 @@ + */ + private array $tables = array(); + + public function __construct() { + try { + $this->root = sys_get_temp_dir() . '/mdi-native-session-' . bin2hex( random_bytes( 16 ) ); + } catch ( Throwable ) { + throw new RuntimeException( 'A temporary table namespace could not be created.' ); + } + if ( ! @mkdir( $this->root . '/_schema', 0700, true ) || ! @mkdir( $this->root . '/_tables', 0700, true ) ) { + throw new RuntimeException( 'A temporary table namespace could not be materialized.' ); + } + $root = realpath( $this->root ); + if ( false === $root || is_link( $this->root ) ) { + throw new RuntimeException( 'A temporary table namespace is unsafe.' ); + } + $this->root = $root; + } + + public function root(): string { + return $this->root; + } + + public function has( string $table ): bool { + return isset( $this->tables[ $table ] ); + } + + public function add( string $table ): void { + $this->tables[ $table ] = true; + } + + public function remove( string $table ): void { + unset( $this->tables[ $table ] ); + } +} diff --git a/inc/native/class-wp-markdown-native-transactions.php b/inc/native/class-wp-markdown-native-transactions.php index 3b7ba8f..d8e88ec 100644 --- a/inc/native/class-wp-markdown-native-transactions.php +++ b/inc/native/class-wp-markdown-native-transactions.php @@ -345,6 +345,37 @@ public function record( string $path, ?callable $restore_observer = null ): true return $this->persist(); } + /** Record a session-local pre-image without serializing it into the durable journal. */ + public function record_ephemeral( string $path, ?callable $restore_observer = null ): true|string { + if ( ! $this->active ) { + return true; + } + if ( null !== $restore_observer ) { + $this->restore_observers[ $path ] = $restore_observer; + } + foreach ( $this->entries as $entry ) { + if ( $path === $entry['path'] ) { + return true; + } + } + $contents = is_file( $path ) ? @file_get_contents( $path ) : false; + if ( is_file( $path ) && false === $contents ) { + return 'The temporary table pre-image could not be journaled.'; + } + $this->entries[] = array( 'path' => $path, 'existed' => false !== $contents, 'contents' => false === $contents ? null : base64_encode( $contents ) ); + return true; + } + + /** A dropped temporary table ends its generation, so old row pre-images are invalid. */ + public function discard_ephemeral( string $path ): void { + $this->entries = array_values( array_filter( $this->entries, static fn( array $entry ): bool => $path !== $entry['path'] ) ); + foreach ( array_keys( $this->restore_observers ) as $observed_path ) { + if ( $observed_path === $path ) { + unset( $this->restore_observers[ $observed_path ] ); + } + } + } + /** Start an autocommit-off transaction when a transactional table is read. */ public function access(): true|string { return ! $this->active && ! $this->autocommit ? $this->begin() : true; diff --git a/tests/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..3c0a3df 100644 --- a/tests/smoke-native-aggregates.php +++ b/tests/smoke-native-aggregates.php @@ -35,19 +35,59 @@ function mdi_aggregate_row( WP_Markdown_Native_Query_Runtime $runtime, string $s } $totals = mdi_aggregate_row( $runtime, 'SELECT SUM(score) AS total, AVG(score) AS mean, MIN(score) AS lowest, MAX(score) AS highest FROM wp_items' ); +$conditional_sql = "SELECT COUNT(*) AS total, SUM(CASE WHEN kind = 'a' THEN 1 ELSE 0 END) AS completed, SUM(CASE WHEN kind LIKE 'b%' OR kind = 'c' THEN 1 ELSE 0 END) AS skipped FROM wp_items"; +$conditional = mdi_aggregate_row( $runtime, $conditional_sql ); +$conditional_empty = mdi_aggregate_row( $runtime, $conditional_sql . " WHERE kind = 'missing'" ); +$numeric_scalar = mdi_aggregate_row( $runtime, 'SELECT SUM(COALESCE(score, 0)) AS total, AVG(ABS(score)) AS mean FROM wp_items' ); +$scalar_null = mdi_aggregate_row( $runtime, "SELECT SUM(ABS(score)) AS total FROM wp_items WHERE kind = 'c'" ); +$bad_scalar = mdi_aggregate_row( $runtime, 'SELECT SUM(ABS(missing)) AS total FROM wp_items' ); +$bad_type = mdi_aggregate_row( $runtime, "SELECT SUM(COALESCE(kind, 'bad')) AS total FROM wp_items WHERE id = 999" ); +$conditional_groups = $runtime->execute( new WP_Markdown_Query_Request( "SELECT kind, COUNT(*) AS total, SUM(CASE WHEN score > 15 THEN 1 ELSE 0 END) AS high FROM wp_items GROUP BY kind ORDER BY kind" ) ); $counts = mdi_aggregate_row( $runtime, 'SELECT COUNT(score) AS scored FROM wp_items' ); $all_rows = mdi_aggregate_row( $runtime, 'SELECT COUNT(*) FROM wp_items' ); $filtered = mdi_aggregate_row( $runtime, "SELECT SUM(score) AS total FROM wp_items WHERE kind = 'a'" ); $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_' ) ); +$grouped = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT kind, score, COUNT(id) AS n FROM wp_items GROUP BY kind, score' ) ); +$grouped_alias = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT kind, score AS points, COUNT(id) AS n FROM wp_items GROUP BY kind, score' ) ); +$grouped_join = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT a.kind, b.score, COUNT(a.id) AS n FROM wp_items a JOIN wp_items b ON a.id = b.id GROUP BY a.kind, b.score' ) ); +$grouped_rows = $grouped->corpus_result()['rows']; +$alias_rows = $grouped_alias->corpus_result()['rows']; +$joined_rows = $grouped_join->corpus_result()['rows']; +usort( $grouped_rows, static fn( array $left, array $right ): int => (int) $left['score'] <=> (int) $right['score'] ); +usort( $alias_rows, static fn( array $left, array $right ): int => (int) $left['points'] <=> (int) $right['points'] ); +usort( $joined_rows, static fn( array $left, array $right ): int => (int) $left['score'] <=> (int) $right['score'] ); $checks = array( + 'aliased row counts compose with conditional aggregates' => array( 'total' => '4', 'completed' => '2', 'skipped' => '2' ) === $conditional, + 'conditional aggregates retain empty-set NULL and count semantics' => array( 'total' => '0', 'completed' => null, 'skipped' => null ) === $conditional_empty, + 'numeric scalar aggregates reuse row-local evaluation' => array( 'total' => '60', 'mean' => '20' ) === $numeric_scalar && array( 'total' => null ) === $scalar_null, + 'aggregate expressions validate columns and types even on empty sets' => 'unsupported_column' === ( $bad_scalar['unsupported'] ?? null ) && 'unsupported_aggregate' === ( $bad_type['unsupported'] ?? null ), + 'conditional aggregates execute independently per group' => $conditional_groups->succeeded() && array( array( 'kind' => 'a', 'total' => '2', 'high' => '1' ), array( 'kind' => 'b', 'total' => '1', 'high' => '1' ), array( 'kind' => 'c', 'total' => '1', 'high' => '0' ) ) === $conditional_groups->corpus_result()['rows'], + 'multiple explicit grouping columns preserve every projected value' => $grouped->succeeded() && array( + array( 'kind' => 'c', 'score' => null, 'n' => '1' ), + array( 'kind' => 'a', 'score' => '10', 'n' => '1' ), + array( 'kind' => 'b', 'score' => '20', 'n' => '1' ), + array( 'kind' => 'a', 'score' => '30', 'n' => '1' ), + ) === $grouped_rows, + 'aliased grouping columns retain their position and numeric type' => $grouped_alias->succeeded() && array( 'kind' => 'a', 'points' => '10', 'n' => '1' ) === $alias_rows[1] && '8' === $grouped_alias->corpus_result()['columns'][1]['type'], + 'joined grouping keys include all explicit columns' => $grouped_join->succeeded() && array( + array( 'kind' => 'c', 'score' => null, 'n' => '1' ), + array( 'kind' => 'a', 'score' => '10', 'n' => '1' ), + array( 'kind' => 'b', 'score' => '20', 'n' => '1' ), + array( 'kind' => 'a', 'score' => '30', 'n' => '1' ), + ) === $joined_rows, 'one row reports every ungrouped aggregate' => array( 'total' => '60', 'mean' => '20', 'lowest' => '10', 'highest' => '30' ) === $totals, 'COUNT over a column skips its NULL rows' => array( 'scored' => '3' ) === $counts, 'COUNT over rows keeps them' => '4' === ( $all_rows['COUNT(*)'] ?? null ), '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-alter-table.php b/tests/smoke-native-alter-table.php index 502d81d..6a055dc 100644 --- a/tests/smoke-native-alter-table.php +++ b/tests/smoke-native-alter-table.php @@ -109,8 +109,25 @@ function snapshot_rows( string $root ): array { $unsupported_action = $runtime->execute( new WP_Markdown_Query_Request( 'ALTER TABLE wp_agents ENGINE = InnoDB', 'wp_' ) ); +$combined = $runtime->execute( new WP_Markdown_Query_Request( "ALTER TABLE wp_agents ADD COLUMN owner_id bigint unsigned DEFAULT NULL, ADD KEY owner_id (owner_id), ADD COLUMN tags varchar(40) DEFAULT 'one,two'" ) ); +$combined_row = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT owner_id, tags FROM wp_agents WHERE id = 1' ) ); +$combined_index = $runtime->execute( new WP_Markdown_Query_Request( "SHOW INDEX FROM wp_agents WHERE Key_name = 'owner_id'" ) ); +$before_failed_schema = persisted_schema( $root ); +$before_failed_rows = snapshot_rows( $root ); +$failed_combined = $runtime->execute( new WP_Markdown_Query_Request( 'ALTER TABLE wp_agents ADD COLUMN transient_field int DEFAULT 7, ADD KEY invalid_key (missing_field)' ) ); +$after_failed_schema = persisted_schema( $root ); +$after_failed_rows = snapshot_rows( $root ); +$after_failed_columns = $runtime->execute( new WP_Markdown_Query_Request( 'DESCRIBE wp_agents' ) ); +$runtime->execute( new WP_Markdown_Query_Request( 'START TRANSACTION' ) ); +$runtime->execute( new WP_Markdown_Query_Request( "UPDATE wp_agents SET label = 'committed-before-ddl' WHERE id = 1" ) ); +$runtime->execute( new WP_Markdown_Query_Request( 'ALTER TABLE wp_agents ADD COLUMN transient_again int, ADD KEY invalid_again (missing_field)' ) ); +$runtime->execute( new WP_Markdown_Query_Request( 'ROLLBACK' ) ); +$committed_before_ddl = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT label FROM wp_agents WHERE id = 1' ) ); $checks = array( + 'multi-action ALTER adds columns and indexes in one statement' => $combined->succeeded() && array( 'owner_id' => null, 'tags' => 'one,two' ) === $combined_row->corpus_result()['rows'][0] && 1 === $combined_index->return_value(), + 'a failed later ALTER action restores schema rows and registry' => ! $failed_combined->succeeded() && $before_failed_schema === $after_failed_schema && $before_failed_rows === $after_failed_rows && ! in_array( 'transient_field', array_column( $after_failed_columns->corpus_result()['rows'], 'Field' ), true ), + 'failed atomic ALTER still preserves the outer implicit commit' => 'committed-before-ddl' === $committed_before_ddl->corpus_result()['rows'][0]['label'], 'MODIFY rewrites the persisted column definition' => true === $modified->succeeded() && str_contains( $after_modify, 'LONGTEXT NOT NULL' ) && ! str_contains( $after_modify, 'instance_key VARCHAR(60) NULL' ), diff --git a/tests/smoke-native-create-table.php b/tests/smoke-native-create-table.php index b7a3e3a..bc2ead8 100644 --- a/tests/smoke-native-create-table.php +++ b/tests/smoke-native-create-table.php @@ -43,6 +43,69 @@ 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' ) ); +$temporary_ddl = 'CREATE TEMPORARY TABLE wp_ddl_temporary (id bigint unsigned NOT NULL, PRIMARY KEY (id))'; +$runtime->execute( new WP_Markdown_Query_Request( 'START TRANSACTION' ) ); +$temporary_created = $runtime->execute( new WP_Markdown_Query_Request( $temporary_ddl ) ); +$temporary_inserted = $runtime->execute( new WP_Markdown_Query_Request( 'INSERT INTO wp_ddl_temporary (id) VALUES (1)' ) ); +$runtime->execute( new WP_Markdown_Query_Request( 'ROLLBACK' ) ); +$temporary_after_rollback = $runtime->execute( new WP_Markdown_Query_Request( 'DESCRIBE wp_ddl_temporary' ) ); +$temporary_rows_after_rollback = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT id FROM wp_ddl_temporary' ) ); +$runtime->execute( new WP_Markdown_Query_Request( 'START TRANSACTION' ) ); +$temporary_dropped = $runtime->execute( new WP_Markdown_Query_Request( 'DROP TEMPORARY TABLE wp_ddl_temporary' ) ); +$runtime->execute( new WP_Markdown_Query_Request( 'ROLLBACK' ) ); +$temporary_after_drop_rollback = $runtime->execute( new WP_Markdown_Query_Request( 'DESCRIBE wp_ddl_temporary' ) ); +$permanent_only_drop = $runtime->execute( new WP_Markdown_Query_Request( 'DROP TEMPORARY TABLE wp_ddl_commit' ) ); +$permanent_only_drop_if_exists = $runtime->execute( new WP_Markdown_Query_Request( 'DROP TEMPORARY TABLE IF EXISTS wp_ddl_commit' ) ); +$permanent_after_temporary_drop = $runtime->execute( new WP_Markdown_Query_Request( 'DESCRIBE wp_ddl_commit' ) ); + +$generation_ddl = 'CREATE TEMPORARY TABLE wp_temporary_generation (id bigint unsigned NOT NULL, PRIMARY KEY (id))'; +$runtime->execute( new WP_Markdown_Query_Request( $generation_ddl ) ); +$runtime->execute( new WP_Markdown_Query_Request( 'INSERT INTO wp_temporary_generation (id) VALUES (1)' ) ); +$runtime->execute( new WP_Markdown_Query_Request( 'START TRANSACTION' ) ); +$runtime->execute( new WP_Markdown_Query_Request( 'UPDATE wp_temporary_generation SET id = 3 WHERE id = 1' ) ); +$runtime->execute( new WP_Markdown_Query_Request( 'DROP TEMPORARY TABLE wp_temporary_generation' ) ); +$runtime->execute( new WP_Markdown_Query_Request( $generation_ddl ) ); +$runtime->execute( new WP_Markdown_Query_Request( 'ROLLBACK' ) ); +$generation_after_rollback = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT id FROM wp_temporary_generation' ) ); + +$runtime->execute( new WP_Markdown_Query_Request( "INSERT INTO wp_plugin_events (event_key, owner_id, payload) VALUES ('permanent', 7, 'safe')" ) ); +$shadow_created = $runtime->execute( new WP_Markdown_Query_Request( 'CREATE TEMPORARY TABLE wp_plugin_events (id bigint unsigned NOT NULL, PRIMARY KEY (id))' ) ); +$shadow_inserted = $runtime->execute( new WP_Markdown_Query_Request( 'INSERT INTO wp_plugin_events (id) VALUES (9)' ) ); +$shadow_rows = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT id FROM wp_plugin_events' ) ); +$shadow_dropped = $runtime->execute( new WP_Markdown_Query_Request( 'DROP TEMPORARY TABLE wp_plugin_events' ) ); +$permanent_rows_after_shadow = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT event_key, owner_id, payload FROM wp_plugin_events' ) ); +$cold_temporary = WP_Markdown_Native_Runtime_Factory::runtime( $root )->execute( new WP_Markdown_Query_Request( 'DESCRIBE wp_temporary_generation' ) ); + +$runtime->execute( new WP_Markdown_Query_Request( "INSERT INTO wp_options (option_name, option_value, autoload) VALUES ('temporary_shadow', 'permanent', 'no')" ) ); +$core_runtime = WP_Markdown_Native_Runtime_Factory::runtime( $root ); +$permanent_options_before = $core_runtime->execute( new WP_Markdown_Query_Request( "SELECT option_value FROM wp_options WHERE option_name = 'temporary_shadow'" ) ); +$temporary_options_created = $core_runtime->execute( new WP_Markdown_Query_Request( 'CREATE TEMPORARY TABLE wp_options (option_name varchar(64) NOT NULL, option_value longtext DEFAULT NULL, PRIMARY KEY (option_name))' ) ); +$temporary_options_inserted = $core_runtime->execute( new WP_Markdown_Query_Request( "INSERT INTO wp_options (option_name, option_value) VALUES ('temporary_shadow', 'temporary')" ) ); +$core_runtime->execute( new WP_Markdown_Query_Request( "UPDATE wp_options SET option_value = 'updated' WHERE option_name = 'temporary_shadow'" ) ); +$temporary_options_rows = $core_runtime->execute( new WP_Markdown_Query_Request( "SELECT option_value FROM wp_options WHERE option_name = 'temporary_shadow'" ) ); +$core_runtime->execute( new WP_Markdown_Query_Request( 'DROP TEMPORARY TABLE wp_options' ) ); +$permanent_options_rows = $core_runtime->execute( new WP_Markdown_Query_Request( "SELECT option_value FROM wp_options WHERE option_name = 'temporary_shadow'" ) ); + +$permanent_posts_before = $core_runtime->execute( new WP_Markdown_Query_Request( 'SELECT ID FROM wp_posts WHERE ID = 900' ) ); +$temporary_posts_created = $core_runtime->execute( new WP_Markdown_Query_Request( 'CREATE TEMPORARY TABLE wp_posts (ID bigint unsigned NOT NULL, post_title varchar(255) NOT NULL, PRIMARY KEY (ID))' ) ); +$temporary_posts_inserted = $core_runtime->execute( new WP_Markdown_Query_Request( "INSERT INTO wp_posts (ID, post_title) VALUES (900, 'temporary')" ) ); +$core_runtime->execute( new WP_Markdown_Query_Request( "UPDATE wp_posts SET post_title = 'updated' WHERE ID = 900" ) ); +$temporary_posts_rows = $core_runtime->execute( new WP_Markdown_Query_Request( 'SELECT post_title FROM wp_posts WHERE ID = 900' ) ); +$core_runtime->execute( new WP_Markdown_Query_Request( 'DROP TEMPORARY TABLE wp_posts' ) ); +$permanent_posts_rows = $core_runtime->execute( new WP_Markdown_Query_Request( 'SELECT ID FROM wp_posts WHERE ID = 900' ) ); $checks = array( 'generic CREATE TABLE returns the WordPress DDL success shape' => true === $created->return_value() @@ -61,6 +124,34 @@ 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 ), + 'temporary table DDL survives rollback while its transactional rows roll back' => true === $temporary_created->return_value() + && 1 === $temporary_inserted->wpdb_state()['rows_affected'] + && 'id' === ( $temporary_after_rollback->wpdb_state()['last_result'][0]->Field ?? null ) + && array() === $temporary_rows_after_rollback->wpdb_state()['last_result'], + 'DROP TEMPORARY TABLE survives rollback and refreshes the table registry' => true === $temporary_dropped->return_value() + && false === $temporary_after_drop_rollback->return_value(), + 'DROP TEMPORARY TABLE refuses a permanent-only name without touching its schema' => false === $permanent_only_drop->return_value() + && true === $permanent_only_drop_if_exists->return_value() + && 'id' === ( $permanent_after_temporary_drop->wpdb_state()['last_result'][0]->Field ?? null ), + 'a dropped temporary generation cannot restore its old transactional row journal into a recreated table' => array() === $generation_after_rollback->wpdb_state()['last_result'], + 'temporary tables shadow same-name permanent tables without overwriting their data and are absent from a cold runtime' => true === $shadow_created->return_value() + && 1 === $shadow_inserted->wpdb_state()['rows_affected'] + && '9' === (string) ( $shadow_rows->wpdb_state()['last_result'][0]->id ?? '' ) + && true === $shadow_dropped->return_value() + && 'permanent' === (string) ( $permanent_rows_after_shadow->wpdb_state()['last_result'][0]->event_key ?? '' ) + && false === $cold_temporary->return_value(), + 'temporary options shadows route DML to their JSON provider and restore the unchanged canonical provider' => true === $temporary_options_created->return_value() + && 'permanent' === (string) ( $permanent_options_before->wpdb_state()['last_result'][0]->option_value ?? '' ) + && 1 === $temporary_options_inserted->wpdb_state()['rows_affected'] + && 'updated' === (string) ( $temporary_options_rows->wpdb_state()['last_result'][0]->option_value ?? '' ) + && 'permanent' === (string) ( $permanent_options_rows->wpdb_state()['last_result'][0]->option_value ?? '' ), + 'temporary posts shadows route DML to their JSON provider and restore the unchanged canonical provider' => true === $temporary_posts_created->return_value() + && array() === $permanent_posts_before->wpdb_state()['last_result'] + && 1 === $temporary_posts_inserted->wpdb_state()['rows_affected'] + && 'updated' === (string) ( $temporary_posts_rows->wpdb_state()['last_result'][0]->post_title ?? '' ) + && array() === $permanent_posts_rows->wpdb_state()['last_result'], ); $failed = false; 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-join-query.php b/tests/smoke-native-join-query.php index 77841d3..26d8586 100644 --- a/tests/smoke-native-join-query.php +++ b/tests/smoke-native-join-query.php @@ -74,19 +74,36 @@ public function read( WP_Markdown_Native_Table_Access $access ): iterable|WP_Mar $plan = ( new WP_Markdown_Native_Query_Parser() )->parse( $query ); $result = $runtime->execute( new WP_Markdown_Query_Request( $query ) ); $state = $result->wpdb_state(); +$aliased = $runtime->execute( new WP_Markdown_Query_Request( str_replace( 'tr.object_id, tt.taxonomy, t.slug', 'tr.object_id AS object_identity, tt.taxonomy, t.slug AS term_slug', $query ) ) ); +$expected_aliases = $result->corpus_result(); +$expected_aliases['rows'] = array_map( static fn( array $row ): array => array( 'object_identity' => $row['object_id'], 'taxonomy' => $row['taxonomy'], 'term_slug' => $row['slug'] ), $expected_aliases['rows'] ); +$expected_aliases['columns'][0]['name'] = 'object_identity'; +$expected_aliases['columns'][2]['name'] = 'term_slug'; +$hinted = $runtime->execute( new WP_Markdown_Query_Request( str_replace( 'tr JOIN', 'tr FORCE INDEX (term_taxonomy_id) JOIN', $query ) ) ); +$bad_hint = $runtime->execute( new WP_Markdown_Query_Request( str_replace( 'tr JOIN', 'tr FORCE INDEX (missing_index) JOIN', $query ) ) ); +$joined_hint = $runtime->execute( new WP_Markdown_Query_Request( str_replace( 'tt ON', 'tt USE KEY FOR JOIN (PRIMARY) ON', $query ) ) ); +$empty_use_hint = $runtime->execute( new WP_Markdown_Query_Request( str_replace( 'tr JOIN', 'tr USE INDEX () JOIN', $query ) ) ); +$mixed_hints = $runtime->execute( new WP_Markdown_Query_Request( str_replace( 'tr JOIN', 'tr USE INDEX (PRIMARY) FORCE INDEX (term_taxonomy_id) JOIN', $query ) ) ); +$union_hint = $runtime->execute( new WP_Markdown_Query_Request( "SELECT object_id FROM wp_term_relationships WHERE object_id=41 UNION SELECT object_id FROM wp_term_relationships FORCE INDEX (missing_index) WHERE object_id=99" ) ); $missing = $runtime->execute( new WP_Markdown_Query_Request( str_replace( '=41', '=404', $query ) ) ); $unbounded = $runtime->execute( new WP_Markdown_Query_Request( substr( $query, 0, strpos( $query, ' WHERE' ) ) ) ); $unindexed_filter = $runtime->execute( new WP_Markdown_Query_Request( substr( $query, 0, strpos( $query, ' WHERE' ) ) . " WHERE tt.description = ''" ) ); $unbounded_left = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT tr.object_id, tt.taxonomy FROM wp_term_relationships tr LEFT JOIN wp_term_taxonomy tt ON tr.term_taxonomy_id=tt.term_taxonomy_id' ) ); $unqualified = $runtime->execute( new WP_Markdown_Query_Request( str_replace( 'tr.object_id=41', 'object_id=41', $query ) ) ); $unknown_alias = $runtime->execute( new WP_Markdown_Query_Request( str_replace( 't.slug', 'x.slug', $query ) ) ); +$bound_columns = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT object_id, taxonomy, slug FROM wp_term_relationships tr JOIN wp_term_taxonomy tt ON tr.term_taxonomy_id=tt.term_taxonomy_id JOIN wp_terms t ON t.term_id=tt.term_id WHERE object_id=41' ) ); +$bound_where = $runtime->execute( new WP_Markdown_Query_Request( "SELECT object_id FROM wp_term_relationships tr, wp_term_taxonomy tt WHERE tr.term_taxonomy_id=tt.term_taxonomy_id AND taxonomy='category' AND object_id=41" ) ); +$ambiguous_column = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT term_taxonomy_id FROM wp_term_relationships tr JOIN wp_term_taxonomy tt ON tr.term_taxonomy_id=tt.term_taxonomy_id WHERE object_id=41' ) ); +$ambiguous_on = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT object_id FROM wp_term_relationships tr JOIN wp_term_taxonomy tt ON term_taxonomy_id=tt.term_taxonomy_id WHERE object_id=41' ) ); +$wrong_explicit_source = $runtime->execute( new WP_Markdown_Query_Request( str_replace( 'tt.taxonomy', 'tr.taxonomy', $query ) ) ); +$derived_binding = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT object_id, label FROM wp_term_relationships tr JOIN (SELECT term_taxonomy_id AS key_id, taxonomy AS label FROM wp_term_taxonomy) d ON key_id=tr.term_taxonomy_id WHERE object_id=41' ) ); $limited = $runtime->execute( new WP_Markdown_Query_Request( $query . ' LIMIT 1' ) ); $catalog_query = "SELECT wp_term_relationships.object_id FROM wp_term_relationships LEFT JOIN wp_term_taxonomy ON (wp_term_relationships.term_taxonomy_id = wp_term_taxonomy.term_taxonomy_id) WHERE wp_term_taxonomy.taxonomy IN ('category') GROUP BY wp_term_relationships.object_id ORDER BY wp_term_relationships.object_id DESC LIMIT 0, 5"; $catalog = $runtime->execute( new WP_Markdown_Query_Request( $catalog_query ) ); $distinct_identity_group_query = "SELECT DISTINCT tr.object_id FROM wp_term_relationships tr JOIN wp_term_taxonomy tt ON tr.term_taxonomy_id=tt.term_taxonomy_id WHERE tt.taxonomy='category' GROUP BY tr.object_id"; $distinct_identity_group = $runtime->execute( new WP_Markdown_Query_Request( $distinct_identity_group_query ) ); $counted = $runtime->execute( - new WP_Markdown_Query_Request( 'SELECT COUNT(*) FROM wp_term_relationships LEFT JOIN wp_term_taxonomy ON term_taxonomy_id = wp_term_taxonomy.term_taxonomy_id WHERE object_id = 41' ) + new WP_Markdown_Query_Request( 'SELECT COUNT(*) FROM wp_term_relationships LEFT JOIN wp_term_taxonomy ON wp_term_relationships.term_taxonomy_id = wp_term_taxonomy.term_taxonomy_id WHERE object_id = 41' ) ); $core_term_ids_query = "SELECT DISTINCT t.term_id, tr.object_id FROM wp_terms AS t INNER JOIN wp_term_taxonomy AS tt ON t.term_id = tt.term_id INNER JOIN wp_term_relationships AS tr ON tr.term_taxonomy_id = tt.term_taxonomy_id WHERE tt.taxonomy IN ('category', 'post_tag', 'post_format') AND tr.object_id IN (41) ORDER BY t.name ASC"; $core_term_ids_plan = ( new WP_Markdown_Native_Query_Parser() )->parse( $core_term_ids_query ); @@ -213,6 +230,16 @@ public function read( WP_Markdown_Native_Table_Access $access ): iterable|WP_Mar $meta_result = ( new WP_Markdown_Native_Query_Runtime( $meta_registry ) )->execute( new WP_Markdown_Query_Request( $meta_query ) ); $checks = array( + 'unqualified JOIN projections resolve against all source schemas' => $bound_columns->succeeded() && $result->corpus_result() === $bound_columns->corpus_result(), + 'unqualified WHERE columns resolve to the unique joined owner' => $bound_where->succeeded() && array( array( 'object_id' => '41' ) ) === $bound_where->corpus_result()['rows'], + 'ambiguous projection and ON columns fail rather than choosing the base table' => 'ambiguous_column' === ( $ambiguous_column->diagnostic()['reason'] ?? null ) && 'ambiguous_column' === ( $ambiguous_on->diagnostic()['reason'] ?? null ), + 'an explicit wrong source is never rebound to another table' => ! $wrong_explicit_source->succeeded(), + 'derived table aliases expose only their projected column names' => $derived_binding->succeeded() && array( 'object_id' => '41', 'label' => 'category' ) === $derived_binding->corpus_result()['rows'][0], + 'plain column aliases preserve joined row order and source metadata' => $aliased->succeeded() && $expected_aliases === $aliased->corpus_result(), + 'validated source index hints preserve taxonomy JOIN results' => $hinted->succeeded() && $result->corpus_result() === $hinted->corpus_result() && $joined_hint->succeeded() && $result->corpus_result() === $joined_hint->corpus_result(), + 'unknown hinted indexes fail instead of silently executing' => ! $bad_hint->succeeded() && 'unsupported_index_hint' === ( $bad_hint->diagnostic()['reason'] ?? null ), + 'empty USE hints preserve rows while conflicting USE and FORCE hints fail' => $empty_use_hint->succeeded() && $result->corpus_result() === $empty_use_hint->corpus_result() && ! $mixed_hints->succeeded(), + 'UNION branches retain index-hint validation' => ! $union_hint->succeeded() && 'unsupported_index_hint' === ( $union_hint->diagnostic()['reason'] ?? null ), 'tokenizer and parser lower aliases and chained equality JOINs into typed contracts' => $plan instanceof WP_Markdown_Native_Query_Plan && 'tr' === $plan->table_alias() && array( 'tr', 'tt', 't' ) === $plan->projection_sources() 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-mixed-like-or.php b/tests/smoke-native-mixed-like-or.php new file mode 100644 index 0000000..1e930a4 --- /dev/null +++ b/tests/smoke-native-mixed-like-or.php @@ -0,0 +1,37 @@ +execute( new WP_Markdown_Query_Request( 'CREATE TABLE wp_event_dates (post_id bigint unsigned NOT NULL, start_datetime datetime NOT NULL, end_datetime datetime NULL, PRIMARY KEY (post_id))' ) ); + $inserted = $runtime->execute( new WP_Markdown_Query_Request( "INSERT INTO wp_event_dates (post_id, start_datetime, end_datetime) VALUES (1, '0000-00-00 00:00:00', NULL), (2, '2026-07-15 19:30:00', NULL), (3, '2026-07-16 19:30:00', NULL)" ) ); + $checks['fixture persists zero and ordinary dates'] = $created->succeeded() && 3 === $inserted->return_value(); + $queries = array( + "start_datetime = '0000-00-00 00:00:00' OR start_datetime LIKE '0000-%'" => array( '1' ), + "start_datetime LIKE '0000-%' OR start_datetime = '2026-07-15 19:30:00'" => array( '1', '2' ), + "(start_datetime LIKE '0000-%' OR start_datetime = '2026-07-15 19:30:00') AND post_id = 2" => array( '2' ), + "start_datetime = '2026-07-15 19:30:00' OR end_datetime LIKE '0000-%'" => array( '2' ), + ); + foreach ( $queries as $predicate => $ids ) { + $result = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT post_id FROM wp_event_dates WHERE ' . $predicate . ' ORDER BY post_id' ) ); + $actual = array_map( static fn( object $row ): string => (string) $row->post_id, $result->wpdb_state()['last_result'] ); + $checks[ $predicate ] = $result->succeeded() && $ids === $actual; + } +} finally { + foreach ( new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $root, FilesystemIterator::SKIP_DOTS ), RecursiveIteratorIterator::CHILD_FIRST ) as $entry ) { + $entry->isDir() ? rmdir( $entry->getPathname() ) : unlink( $entry->getPathname() ); + } + rmdir( $root ); +} +foreach ( $checks as $label => $passed ) { + fwrite( $passed ? STDOUT : STDERR, ( $passed ? 'PASS: ' : 'FAIL: ' ) . $label . "\n" ); +} +exit( in_array( false, $checks, true ) ? 1 : 0 ); 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..42a0353 100644 --- a/tests/smoke-native-query-parser.php +++ b/tests/smoke-native-query-parser.php @@ -50,6 +50,8 @@ $composite_order_plan = $composite_order_ast instanceof WP_Markdown_Native_SQL_Select ? $parser->lower( $composite_order_ast ) : $composite_order_ast; $found_rows_query_ast = $parser->parse_ast( 'SELECT FOUND_ROWS()' ); $found_rows_query_plan = $found_rows_query_ast instanceof WP_Markdown_Native_SQL_Found_Rows ? $parser->lower( $found_rows_query_ast ) : $found_rows_query_ast; +$limit_offset_ast = $parser->parse_ast( 'SELECT ID FROM wp_posts ORDER BY ID LIMIT 10 OFFSET 20' ); +$limit_offset_plan = $limit_offset_ast instanceof WP_Markdown_Native_SQL_Select ? $parser->lower( $limit_offset_ast ) : $limit_offset_ast; $duplicate_sql = 'SELECT first, second, first FROM example'; $duplicate = $parser->parse( $duplicate_sql ); @@ -189,6 +191,9 @@ ), 'FOUND_ROWS lowers to explicit runtime state retrieval intent' => $found_rows_query_ast instanceof WP_Markdown_Native_SQL_Found_Rows && $found_rows_query_plan instanceof WP_Markdown_Native_Found_Rows_Plan, + 'LIMIT OFFSET lowers to the bounded main-query offset' => $limit_offset_plan instanceof WP_Markdown_Native_Query_Plan + && 10 === $limit_offset_plan->limit() + && 20 === $limit_offset_plan->limit_offset(), 'duplicate projections report the duplicate source position' => $duplicate instanceof WP_Markdown_Query_Result && 'duplicate_projection' === ( $duplicate->diagnostic()['reason'] ?? null ) && strrpos( $duplicate_sql, 'first' ) === ( $duplicate->diagnostic()['sql_offset'] ?? null ), @@ -208,7 +213,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 +223,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 +232,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-sql-mode-defaults.php b/tests/smoke-native-sql-mode-defaults.php new file mode 100644 index 0000000..3193089 --- /dev/null +++ b/tests/smoke-native-sql-mode-defaults.php @@ -0,0 +1,45 @@ + $runtime->execute( new WP_Markdown_Query_Request( $sql ) ); + $created = $run( 'CREATE TABLE wp_defaults (id bigint unsigned NOT NULL AUTO_INCREMENT, payload longtext NOT NULL, amount int NOT NULL, optional_value varchar(20) NULL, explicit_value varchar(20) NOT NULL DEFAULT \'chosen\', PRIMARY KEY (id))' ); + $inserted = $run( 'INSERT INTO wp_defaults (id) VALUES (1)' ); + $warnings = $run( 'SHOW WARNINGS' ); + $count = $run( 'SELECT @@warning_count' ); + $row = $run( 'SELECT * FROM wp_defaults WHERE id = 1' )->corpus_result()['rows'][0] ?? null; + $checks['non-strict defaults preserve nullable and explicit defaults'] = $created->succeeded() && 1 === $inserted->return_value() && array( 'id' => '1', 'payload' => '', 'amount' => '0', 'optional_value' => null, 'explicit_value' => 'chosen' ) === $row; + $checks['implicit defaults report bounded MySQL warnings'] = 2 === $warnings->return_value() && '1364' === $warnings->corpus_result()['rows'][0]['Code'] && '2' === $count->corpus_result()['rows'][0]['@@warning_count']; + $set = $run( "SET SESSION sql_mode = 'strict_trans_tables,NO_ENGINE_SUBSTITUTION'" ); + $mode = $run( 'SELECT @@SESSION.sql_mode' ); + $variables = $run( "SHOW VARIABLES LIKE 'sql_mode'" ); + $checks['session and SHOW introspection agree on supported mode'] = $set->succeeded() && 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION' === $mode->corpus_result()['rows'][0]['@@SESSION.sql_mode'] && 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION' === $variables->corpus_result()['rows'][0]['Value']; + $rejected = $run( 'INSERT INTO wp_defaults (id) VALUES (2)' ); + $errors = $run( 'SHOW WARNINGS' ); + $checks['strict omissions retain every missing-default error'] = 2 === $errors->return_value() && 'Error' === $errors->corpus_result()['rows'][0]['Level'] && '1364' === $errors->corpus_result()['rows'][1]['Code']; + $checks['strict omission fails with MySQL errno and no row'] = false === $rejected->return_value() && 1364 === $rejected->diagnostic()['code'] && 0 === $run( 'SELECT * FROM wp_defaults WHERE id = 2' )->return_value(); + $invalid = $run( "SET sql_mode = 'ANSI_QUOTES'" ); + $checks['unsupported mode fails without changing existing mode'] = ! $invalid->succeeded() && 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION' === $run( 'SELECT @@sql_mode' )->corpus_result()['rows'][0]['@@sql_mode']; + $run( "SET @@SESSION.sql_mode = ''" ); + $checks['clearing strict mode restores implicit defaults'] = 1 === $run( 'INSERT INTO wp_defaults (id) VALUES (2)' )->return_value(); + $multisite = WP_Markdown_Native_Runtime_Factory::multisite_runtime( $root ); + $multisite->execute( new WP_Markdown_Query_Request( "SET sql_mode = 'STRICT_ALL_TABLES'", 'wp_' ) ); + $site_mode = $multisite->execute( new WP_Markdown_Query_Request( 'SELECT @@sql_mode', 'wp_2_' ) ); + $checks['blog switching shares mode but separate connections do not'] = 'STRICT_ALL_TABLES' === $site_mode->corpus_result()['rows'][0]['@@sql_mode'] && '' === $run( 'SELECT @@sql_mode' )->corpus_result()['rows'][0]['@@sql_mode']; + $multisite->close(); + $checks['logical close resets SQL session mode'] = '' === $multisite->execute( new WP_Markdown_Query_Request( 'SELECT @@sql_mode', 'wp_' ) )->corpus_result()['rows'][0]['@@sql_mode']; +} finally { + foreach ( new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $root, FilesystemIterator::SKIP_DOTS ), RecursiveIteratorIterator::CHILD_FIRST ) as $entry ) { + $entry->isDir() ? rmdir( $entry->getPathname() ) : unlink( $entry->getPathname() ); + } + rmdir( $root ); +} +foreach ( $checks as $label => $passed ) { fwrite( $passed ? STDOUT : STDERR, ( $passed ? 'PASS: ' : 'FAIL: ' ) . $label . "\n" ); } +exit( in_array( false, $checks, true ) ? 1 : 0 ); diff --git a/tests/smoke-native-table-insert.php b/tests/smoke-native-table-insert.php index 4afeb76..5239f72 100644 --- a/tests/smoke-native-table-insert.php +++ b/tests/smoke-native-table-insert.php @@ -35,7 +35,9 @@ function mdi_native_insert_remove_tree( string $root ): void { $first = $runtime->execute( new WP_Markdown_Query_Request( "INSERT INTO `wp_plugin_jobs` (`job_id`, `hook`, `payload`) VALUES (0, 'first_job', NULL)" ) ); $second = $runtime->execute( new WP_Markdown_Query_Request( "INSERT INTO wp_plugin_jobs (job_id, hook, priority) VALUES (8, 'second_job', 2);" ) ); $duplicate = $runtime->execute( new WP_Markdown_Query_Request( "INSERT INTO wp_plugin_jobs (job_id, hook) VALUES (8, 'duplicate_job')" ) ); +$runtime->execute( new WP_Markdown_Query_Request( "SET sql_mode = 'STRICT_TRANS_TABLES'" ) ); $missing = $runtime->execute( new WP_Markdown_Query_Request( 'INSERT INTO wp_plugin_jobs (priority) VALUES (3)' ) ); +$runtime->execute( new WP_Markdown_Query_Request( "SET sql_mode = ''" ) ); $multi = $runtime->execute( new WP_Markdown_Query_Request( "INSERT INTO wp_plugin_jobs (hook) VALUES ('third'); INSERT INTO wp_plugin_jobs (hook) VALUES ('fourth')" ) ); $selected = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT * FROM wp_plugin_jobs ORDER BY job_id ASC' ) ); $reloaded = WP_Markdown_Native_Runtime_Factory::runtime( $root )->execute( new WP_Markdown_Query_Request( "SELECT hook, priority FROM wp_plugin_jobs WHERE job_id IN (1, 8) ORDER BY job_id ASC" ) ); @@ -66,7 +68,7 @@ function mdi_native_insert_remove_tree( string $root ): void { 'persisted rows retain schema order and typed values' => array( '1', '8' ) === array_map( static fn( object $row ): string => $row->job_id, $selected->wpdb_state()['last_result'] ) && '10' === $selected->wpdb_state()['last_result'][0]->priority && null === $selected->wpdb_state()['last_result'][0]->payload, - 'unique conflicts and missing required columns fail without mutation' => false === $duplicate->return_value() + 'unique conflicts and strict-mode missing required columns fail without mutation' => false === $duplicate->return_value() && 'duplicate_key' === ( $duplicate->diagnostic()['reason'] ?? null ) && false === $missing->return_value() && 'missing_required_column' === ( $missing->diagnostic()['reason'] ?? null ) diff --git a/tests/smoke-native-table-upsert.php b/tests/smoke-native-table-upsert.php index 7be2777..6ff776a 100644 --- a/tests/smoke-native-table-upsert.php +++ b/tests/smoke-native-table-upsert.php @@ -26,8 +26,30 @@ ); $read = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT id, object_id, title FROM wp_yoast_indexable WHERE object_id = 7', 'wp_' ) ); $fresh = $runtime->execute( new WP_Markdown_Query_Request( "INSERT INTO wp_yoast_indexable (object_id, title) VALUES (8, 'eight') ON DUPLICATE KEY UPDATE title = VALUES(title)", 'wp_' ) ); +$unindexed_read = $runtime->execute( new WP_Markdown_Query_Request( "SELECT id FROM wp_yoast_indexable WHERE title = 'two'" ) ); +$unindexed_membership = $runtime->execute( new WP_Markdown_Query_Request( "SELECT id FROM wp_yoast_indexable WHERE title IN ('two', 'eight') ORDER BY id" ) ); +$cleared = $runtime->execute( new WP_Markdown_Query_Request( "INSERT INTO wp_yoast_indexable (id, object_id, title) VALUES (1, 7, 'ignored') ON DUPLICATE KEY UPDATE title = NULL" ) ); +$cleared_row = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT title FROM wp_yoast_indexable WHERE id = 1' ) ); +$noop = $runtime->execute( new WP_Markdown_Query_Request( "INSERT INTO wp_yoast_indexable (id, object_id, title) VALUES (1, 7, 'ignored') ON DUPLICATE KEY UPDATE object_id = object_id, title = NULL" ) ); +$ordered = $runtime->execute( new WP_Markdown_Query_Request( "INSERT INTO wp_yoast_indexable (id, object_id, title) VALUES (1, 7, 'incoming') ON DUPLICATE KEY UPDATE object_id = 9, title = object_id" ) ); +$ordered_row = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT object_id, title FROM wp_yoast_indexable WHERE id = 1' ) ); +$cross_values = $runtime->execute( new WP_Markdown_Query_Request( "INSERT INTO wp_yoast_indexable (id, object_id, title) VALUES (1, 9, 'incoming') ON DUPLICATE KEY UPDATE title = VALUES(object_id)" ) ); +$bad_target = $runtime->execute( new WP_Markdown_Query_Request( "INSERT INTO wp_yoast_indexable (id, object_id, title) VALUES (3, 10, 'new') ON DUPLICATE KEY UPDATE absent_column = NULL" ) ); +$new_literal = $runtime->execute( new WP_Markdown_Query_Request( "INSERT INTO wp_yoast_indexable (id, object_id, title) VALUES (3, 10, 'new') ON DUPLICATE KEY UPDATE title = NULL" ) ); +$new_row = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT title FROM wp_yoast_indexable WHERE id = 3' ) ); +$runtime->execute( new WP_Markdown_Query_Request( 'CREATE TABLE wp_unique_labels (id int NOT NULL, label varchar(20) NOT NULL, PRIMARY KEY(id), UNIQUE KEY label(label))' ) ); +$runtime->execute( new WP_Markdown_Query_Request( "INSERT INTO wp_unique_labels (id,label) VALUES (1,'original')" ) ); +$unsupported_unique = $runtime->execute( new WP_Markdown_Query_Request( "INSERT INTO wp_unique_labels (id,label) VALUES (1,'original') ON DUPLICATE KEY UPDATE label='caf\xC3\xA9'" ) ); $checks = array( + 'validated snapshots filter non-indexed equality and membership columns' => $unindexed_read->succeeded() && array( array( 'id' => '1' ) ) === $unindexed_read->corpus_result()['rows'] && $unindexed_membership->succeeded() && array( array( 'id' => '1' ), array( 'id' => '2' ) ) === $unindexed_membership->corpus_result()['rows'], + 'literal assignments preserve fail-closed unique-key enforcement' => ! $unsupported_unique->succeeded() && 'unsupported_unique_collation' === $unsupported_unique->diagnostic()['reason'], + 'literal NULL assignments clear only a conflicting row' => 2 === $cleared->return_value() && null === $cleared_row->corpus_result()['rows'][0]['title'], + 'unchanged duplicate assignments report zero affected rows' => 0 === $noop->return_value() && 0 === $noop->wpdb_state()['insert_id'], + 'column references observe earlier duplicate assignments in order' => 2 === $ordered->return_value() && array( 'object_id' => '9', 'title' => '9' ) === $ordered_row->corpus_result()['rows'][0], + 'VALUES may refer to a different inserted column' => $cross_values->succeeded() && 0 === $cross_values->return_value(), + 'unknown duplicate target fails even on the insert branch' => ! $bad_target->succeeded() && 'unsupported_column' === $bad_target->diagnostic()['reason'], + 'duplicate literals are not applied to a newly inserted row' => 1 === $new_literal->return_value() && 'new' === $new_row->corpus_result()['rows'][0]['title'], 'the first insert persists' => 1 === $insert->return_value() && 1 === $insert->wpdb_state()['insert_id'], 'ON DUPLICATE KEY UPDATE rewrites the conflicting row' => 2 === $upsert->return_value() && 1 === $upsert->wpdb_state()['insert_id'] diff --git a/tests/smoke-native-table-write.php b/tests/smoke-native-table-write.php index 1a06fec..614ab6e 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( @@ -247,7 +266,39 @@ function column_values( string $root, string $column, string $table = 'agents' ) $runtime->execute( new WP_Markdown_Query_Request( 'ROLLBACK', 'wp_' ) ); $after_rollback = column_values( $root, 'label' ); +$runtime->execute( new WP_Markdown_Query_Request( 'CREATE TABLE wp_claims (id bigint NOT NULL, generation bigint NULL, mirror bigint NULL, state varchar(20) NOT NULL, claimed_at datetime NULL, label varchar(10) NOT NULL, PRIMARY KEY (id))' ) ); +$claim_fixture = $runtime->execute( new WP_Markdown_Query_Request( "INSERT INTO wp_claims (id, generation, mirror, state, claimed_at, label) VALUES (1,NULL,NULL,'preparing',NULL,'one'),(2,2,NULL,'enqueuing','2026-09-10 23:00:00','1234567890'),(3,2,NULL,'enqueuing','2026-09-10 20:00:00','three'),(4,9,NULL,'enqueuing','2026-09-10 20:00:00','four')" ) ); +if ( ! $claim_fixture->succeeded() ) { throw new RuntimeException( json_encode( $claim_fixture->diagnostic() ) ); } +$claim_sql = "UPDATE wp_claims SET generation = COALESCE(generation, 0) + 1, mirror = generation, state = 'enqueuing', claimed_at = '2026-09-10 23:00:00' WHERE id < 4 AND (state IN ('preparing', 'enqueue_failed') OR (state = 'enqueuing' AND (claimed_at IS NULL OR claimed_at < '2026-09-10 22:00:00')))"; +$claimed = $runtime->execute( new WP_Markdown_Query_Request( $claim_sql ) ); +$claim_rows = table_rows( $root, 'claims' ); +$claim_replay = $runtime->execute( new WP_Markdown_Query_Request( $claim_sql ) ); +$bad_expression = $runtime->execute( new WP_Markdown_Query_Request( 'UPDATE wp_claims SET generation = COALESCE(missing, 0) + 1 WHERE id = 999' ) ); +$wrong_source = $runtime->execute( new WP_Markdown_Query_Request( 'UPDATE wp_claims SET generation = other.generation WHERE id = 999' ) ); +$repeated_target = $runtime->execute( new WP_Markdown_Query_Request( 'UPDATE wp_claims SET generation = 1, generation = generation + 1' ) ); +$late_failure = $runtime->execute( new WP_Markdown_Query_Request( "UPDATE wp_claims SET label = CONCAT(label, '!') WHERE id IN (1, 2)" ) ); +$after_late_failure = table_rows( $root, 'claims' ); +$unique_failure = $runtime->execute( new WP_Markdown_Query_Request( 'UPDATE wp_claims SET id = COALESCE(mirror, 1) WHERE id IN (1, 2)' ) ); +$after_unique_failure = table_rows( $root, 'claims' ); +$runtime->execute( new WP_Markdown_Query_Request( 'BEGIN' ) ); +$precedence = $runtime->execute( new WP_Markdown_Query_Request( "UPDATE wp_claims SET generation = generation + 10 WHERE id = 1 OR id = 3 AND state = 'never'" ) ); +$precedence_rows = table_rows( $root, 'claims' ); +$nested_delete = $runtime->execute( new WP_Markdown_Query_Request( "DELETE FROM wp_claims WHERE (id = 1 OR id = 3) AND state = 'enqueuing'" ) ); +$runtime->execute( new WP_Markdown_Query_Request( "UPDATE wp_claims SET generation = '9007199254740993' WHERE id = 4" ) ); +$large_increment = $runtime->execute( new WP_Markdown_Query_Request( 'UPDATE wp_claims SET generation = generation + 1 WHERE id = 4' ) ); +$large_rows = table_rows( $root, 'claims' ); +$runtime->execute( new WP_Markdown_Query_Request( 'ROLLBACK' ) ); +$restored_claims = table_rows( $root, 'claims' ); + $checks = array( + 'nested lease predicates claim only eligible rows and assignments observe earlier values' => 2 === $claimed->return_value() && array( '1', '2', '3', '9' ) === array_column( $claim_rows, 'generation' ) && array( '1', null, '3', null ) === array_column( $claim_rows, 'mirror' ), + 'a repeated claim cannot take an active lease' => 0 === $claim_replay->return_value(), + 'unknown sources and repeated targets fail before mutation even without matching rows' => ! $bad_expression->succeeded() && ! $wrong_source->succeeded() && ! $repeated_target->succeeded(), + 'a later invalid row prevents every scalar UPDATE write' => ! $late_failure->succeeded() && $claim_rows === $after_late_failure, + 'scalar assignments preserve unique-key enforcement atomically' => ! $unique_failure->succeeded() && $claim_rows === $after_unique_failure, + 'AND binds tighter than OR in write predicates' => 1 === $precedence->return_value() && array( '11', '2', '3', '9' ) === array_column( $precedence_rows, 'generation' ), + 'rollback restores scalar updates and grouped deletes' => 2 === $nested_delete->return_value() && $claim_rows === $restored_claims, + 'integer arithmetic preserves values beyond floating-point precision' => 1 === $large_increment->return_value() && '9007199254740994' === $large_rows[1]['generation'], 'the fixture table is created' => 0 === $created->return_value() || true === $created->succeeded(), 'a disjunctive NULL restriction updates every matching row' => 2 === $backfill->return_value() && array( 'default', 'default', 'keep' ) === $after_backfill, @@ -256,6 +307,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(), diff --git a/tests/smoke-parent-promotion-index.php b/tests/smoke-parent-promotion-index.php index 3b38578..c2d04a6 100644 --- a/tests/smoke-parent-promotion-index.php +++ b/tests/smoke-parent-promotion-index.php @@ -375,9 +375,41 @@ function write_leaf( string $path, int $id, string $slug, int $parent = 0 ): voi assert_eq( $storage->read_file( $tmp_root . '/wiki/original.md', true )->ID ?? null, 20, 'reused path retains its new owner' ); // --------------------------------------------------------------------------- -// Test 8 — a fresh write still wins over a stale duplicate during first scan +// Test 8 — an externally removed cached path does not become a read warning // --------------------------------------------------------------------------- -echo "\nTest 8: fresh writes remain canonical during initial duplicate cleanup\n"; +echo "\nTest 8: stale missing cached paths are ignored without parsing them\n"; + +rm_rf( $tmp_root ); +write_leaf( $tmp_root . '/wiki/original.md', 10, 'original' ); + +$storage = new WP_Markdown_Storage( $tmp_root, array() ); +assert_true( null !== $storage->read_post( 10 ), 'initial read completes the index for a removable path' ); +unlink( $tmp_root . '/wiki/original.md' ); + +set_error_handler( static function ( int $severity, string $message ): never { + throw new ErrorException( $message, 0, $severity ); +} ); +try { + $written_path = $storage->write_post( (object) array( + 'ID' => 10, + 'post_type' => 'wiki', + 'post_name' => 'replacement', + 'post_parent' => 0, + 'post_status' => 'publish', + 'post_title' => 'Replacement', + 'post_content' => 'replacement body', + ) ); + assert_eq( $written_path, $tmp_root . '/wiki/replacement.md', 'write ignores the stale missing path without a warning' ); +} catch ( ErrorException $exception ) { + assert_true( false, 'stale missing cached path emitted a warning: ' . $exception->getMessage() ); +} finally { + restore_error_handler(); +} + +// --------------------------------------------------------------------------- +// Test 9 — a fresh write still wins over a stale duplicate during first scan +// --------------------------------------------------------------------------- +echo "\nTest 9: fresh writes remain canonical during initial duplicate cleanup\n"; rm_rf( $tmp_root ); write_leaf( $tmp_root . '/wiki/stale/fresh.md', 30, 'fresh' );