Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions inc/native/class-wp-markdown-native-query-contracts.php
Original file line number Diff line number Diff line change
Expand Up @@ -571,8 +571,19 @@ interface WP_Markdown_Query_Runtime {
public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query_Result;
}

/** Optional proof surface for callers that require atomic canonical table writes. */
interface WP_Markdown_Native_Transactional_Table_Support {
/** @param string[] $tables */
public function supports_transactional_tables( array $tables ): bool;
}

/** Providers supply validated rows without exposing storage to the executor. */
interface WP_Markdown_Native_Table_Provider {
/** @return iterable<int,array<string,mixed>>|WP_Markdown_Query_Result */
public function read( WP_Markdown_Native_Table_Access $access ): iterable|WP_Markdown_Query_Result;
}

/** A canonical provider exposes the factory-validated root it reads and writes. */
interface WP_Markdown_Native_Canonical_Table_Provider extends WP_Markdown_Native_Table_Provider {
public function canonical_root(): string;
}
60 changes: 55 additions & 5 deletions inc/native/class-wp-markdown-native-query-executor.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ public function read( WP_Markdown_Native_Table_Access $access ): iterable|WP_Mar
}
}

final class WP_Markdown_Native_Query_Runtime implements WP_Markdown_Query_Runtime {
final class WP_Markdown_Native_Query_Runtime implements WP_Markdown_Query_Runtime, WP_Markdown_Native_Transactional_Table_Support {
private const MAX_JOIN_CANDIDATE_PAIRS = 100000;
private const MAX_CORRELATED_SUBQUERY_EVALUATIONS = 10000;
/** The largest SQL request accepted by the native request boundary. */
Expand Down Expand Up @@ -90,6 +90,40 @@ public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query
}
}

/**
* Confirm that every exact table has a recognized canonical provider, its
* matching configured mutation runtime, and a factory-admitted journal root.
* This is an atomic-write guarantee, not an InnoDB or mysqli-session claim.
*
* @param string[] $tables
*/
public function supports_transactional_tables( array $tables ): bool {
if ( null === $this->transactions || array() === $tables ) {
return false;
}

foreach ( $tables as $table_name ) {
if ( ! is_string( $table_name ) || 1 !== preg_match( '/^[A-Za-z_][A-Za-z0-9_]*$/D', $table_name ) || $this->registry->is_shadowed( $table_name ) ) {
return false;
}
$table = $this->registry->table( $table_name );
if ( null === $table || ! $this->supports_transactional_provider( $table['provider'] ) ) {
return false;
}
}

return true;
}

private function supports_transactional_provider( WP_Markdown_Native_Table_Provider $provider ): bool {
if ( ! $provider instanceof WP_Markdown_Native_Canonical_Table_Provider || ! $this->transactions->covers_root( $provider->canonical_root() ) ) {
return false;
}
return ( $provider instanceof WP_Markdown_Native_Post_Provider && null !== $this->post_mutations )
|| ( $provider instanceof WP_Markdown_Native_Option_Provider && null !== $this->option_mutations )
|| ( $provider instanceof WP_Markdown_Native_JSON_Snapshot_Provider && null !== $this->table_mutations );
}

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 ) {
Expand All @@ -108,17 +142,26 @@ private function execute_request( WP_Markdown_Query_Request $request ): WP_Markd
if ( null !== $transaction_control ) {
return $this->execute_transaction_control( $transaction_control );
}
$write_admitted = null !== $this->transactions && null !== WP_Markdown_SQL_Classifier::mutation( $request->sql() );
if ( $write_admitted ) {
// Advisory locks have their own root-scoped lock files. Holding the
// transaction lock while waiting for one would invert their release order.
$mutation = null !== WP_Markdown_SQL_Classifier::mutation( $request->sql() );
$canonical_admitted = null !== $this->transactions && ! $this->is_advisory_lock_statement( $request->sql() );
if ( $canonical_admitted ) {
$transactional_view = $this->transactions->is_in_transaction();
$locked = $this->transactions->begin_write();
if ( true !== $locked ) {
return $this->failure( 'transaction_write_lock_failed', $locked );
return $this->failure( $mutation ? 'transaction_write_lock_failed' : 'transaction_read_lock_failed', $locked );
}
if ( ! $transactional_view || $this->transactions->waited_for_write_lock() ) {
// Autocommit requests start a fresh canonical view. A transaction that
// waited also cannot retain snapshots from before the prior commit.
$this->registry->forget_snapshots();
}
}
try {
return $this->execute_unlocked_request( $request );
} finally {
if ( $write_admitted ) {
if ( $canonical_admitted ) {
$this->transactions->finish_write();
}
}
Expand Down Expand Up @@ -354,6 +397,10 @@ private function advisory_lock_query( string $sql ): ?WP_Markdown_Query_Result {
);
}

private function is_advisory_lock_statement( string $sql ): bool {
return 1 === preg_match( '/^\s*SELECT\s+(?:GET_LOCK|RELEASE_LOCK)\s*\(/i', $sql );
}

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 ) {
Expand Down Expand Up @@ -2583,6 +2630,9 @@ private function execute_transaction_control( array $control ): WP_Markdown_Quer
if ( true !== $outcome ) {
return $this->failure( 'transaction_control_failed', $outcome );
}
if ( $this->transactions->waited_for_write_lock() || in_array( $control['action'], array( 'begin', 'autocommit_0' ), true ) ) {
$this->registry->forget_snapshots();
}
if ( 'commit_chain' === $control['action'] || 'rollback_chain' === $control['action'] ) {
$chained = $this->transactions->begin();
if ( true !== $chained ) {
Expand Down
61 changes: 57 additions & 4 deletions inc/native/class-wp-markdown-native-query-runtime.php
Original file line number Diff line number Diff line change
Expand Up @@ -618,7 +618,7 @@ private static function all_ascii_strings( array $values ): bool {
}
}

final class WP_Markdown_Native_Option_Query_Runtime implements WP_Markdown_Query_Runtime {
final class WP_Markdown_Native_Option_Query_Runtime implements WP_Markdown_Query_Runtime, WP_Markdown_Native_Transactional_Table_Support {

private WP_Markdown_Native_Query_Runtime $runtime;

Expand All @@ -641,10 +641,14 @@ public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query
}
return $this->runtime->execute( $request );
}

public function supports_transactional_tables( array $tables ): bool {
return $this->runtime->supports_transactional_tables( $tables );
}
}

/** Lazily construct a single-root runtime for the prefix selected by wpdb. */
final class WP_Markdown_Native_Prefix_Query_Runtime implements WP_Markdown_Query_Runtime {
final class WP_Markdown_Native_Prefix_Query_Runtime implements WP_Markdown_Query_Runtime, WP_Markdown_Native_Transactional_Table_Support {

/** @var array<string,WP_Markdown_Native_Query_Runtime> */
private array $runtimes = array();
Expand Down Expand Up @@ -674,14 +678,30 @@ public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query
return $this->runtimes[ $prefix ]->execute( $request );
}

public function supports_transactional_tables( array $tables ): bool {
$prefix = isset( $GLOBALS['wpdb']->prefix ) && is_string( $GLOBALS['wpdb']->prefix ) ? $GLOBALS['wpdb']->prefix : 'wp_';
if ( ! isset( $this->runtimes[ $prefix ] ) ) {
$this->runtimes[ $prefix ] = WP_Markdown_Native_Runtime_Factory::runtime(
$this->state_root,
$prefix,
$prefix,
false,
$this->content_root,
advisory_locks: $this->advisory_locks,
session: $this->session
);
}
return $this->runtimes[ $prefix ]->supports_transactional_tables( $tables );
}

public function close(): void {
$this->advisory_locks->close();
$this->session->reset();
}
}

/** Defer WordPress topology detection because db.php precedes multisite bootstrap. */
final class WP_Markdown_Native_WordPress_Query_Runtime implements WP_Markdown_Query_Runtime {
final class WP_Markdown_Native_WordPress_Query_Runtime implements WP_Markdown_Query_Runtime, WP_Markdown_Native_Transactional_Table_Support {

private WP_Markdown_Native_Prefix_Query_Runtime $prefix_runtime;
/** @var array<string,WP_Markdown_Native_Multisite_Query_Runtime> */
Expand Down Expand Up @@ -718,10 +738,22 @@ public function close(): void {
$runtime->close();
}
}

public function supports_transactional_tables( array $tables ): bool {
$multisite = ( defined( 'WP_INSTALLING_NETWORK' ) && WP_INSTALLING_NETWORK ) || ( defined( 'MULTISITE' ) && MULTISITE ) || ( function_exists( 'is_multisite' ) && is_multisite() );
if ( ! $multisite ) {
return $this->prefix_runtime->supports_transactional_tables( $tables );
}
$base_prefix = isset( $GLOBALS['wpdb']->base_prefix ) && is_string( $GLOBALS['wpdb']->base_prefix ) ? $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->session );
}
return $this->multisite_runtimes[ $base_prefix ]->supports_transactional_tables( $tables );
}
}

/** Lazily compose a native runtime for each WordPress multisite table scope. */
final class WP_Markdown_Native_Multisite_Query_Runtime implements WP_Markdown_Query_Runtime {
final class WP_Markdown_Native_Multisite_Query_Runtime implements WP_Markdown_Query_Runtime, WP_Markdown_Native_Transactional_Table_Support {

/** @var array<string,WP_Markdown_Native_Query_Runtime> */
private array $runtimes = array();
Expand Down Expand Up @@ -791,6 +823,27 @@ public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query
return $this->runtimes[ $prefix ]->execute( $request );
}

public function supports_transactional_tables( array $tables ): bool {
$prefix = isset( $GLOBALS['wpdb']->prefix ) && is_string( $GLOBALS['wpdb']->prefix ) ? $GLOBALS['wpdb']->prefix : $this->base_prefix;
if ( ! $this->is_scope_prefix( $prefix ) ) {
return false;
}
if ( ! isset( $this->runtimes[ $prefix ] ) ) {
$roots = $this->roots( $prefix );
if ( null === $roots ) {
return false;
}
try {
$this->runtimes[ $prefix ] = WP_Markdown_Native_Runtime_Factory::runtime(
$roots['state'], $prefix, $this->base_prefix, true, $roots['content'], $this->state_root, $this->content_root, $this->advisory_locks, $this->state_root, $this->session
);
} catch ( Throwable ) {
return false;
}
}
return $this->runtimes[ $prefix ]->supports_transactional_tables( $tables );
}

public function close(): void {
$this->advisory_locks->close();
$this->session->reset();
Expand Down
23 changes: 15 additions & 8 deletions inc/native/class-wp-markdown-native-table-insert-parser.php
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ public function parse_rows( WP_Markdown_Query_Request $request ): array|WP_Markd
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_assignments = $this->upsert_assignments();
$upsert_assignments = $this->upsert_assignments( $table );
}
$this->type( WP_Markdown_Native_SQL_Token::END );
$inserts = array();
Expand Down Expand Up @@ -351,8 +351,8 @@ private function identifier_list(): array {
return array_values( $columns );
}

/** @return array<int,array{target:string,kind:string,source:?string,value:int|string|null}> */
private function upsert_assignments(): array {
/** @return array<int,array{target:string,kind:string,source:?string,value:int|string|null|WP_Markdown_Native_Query_Scalar_Expression}> */
private function upsert_assignments( string $table ): array {
$this->word( 'ON' );
$this->word( 'DUPLICATE' );
$this->word( 'KEY' );
Expand All @@ -369,12 +369,19 @@ private function upsert_assignments(): array {
$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';
$parser = new WP_Markdown_Native_Select_AST_Parser( $this->tokens, $this->position );
$expression = $parser->scalar_value();
$this->position = $parser->position();
foreach ( $expression->columns() as $column ) {
if ( null !== $column->qualifier() && 0 !== strcasecmp( $table, $column->qualifier() ) ) {
throw new WP_Markdown_Native_SQL_Parse_Error( 'unsupported_mutation_column', $column->sql_offset(), 'A duplicate-key expression must reference its target table.' );
}
}
$value = 'literal' === $expression->kind()
? $expression->literal()
: ( new WP_Markdown_Native_Query_Parser() )->lower_scalar_expression( $expression, null, $table );
$kind = $value instanceof WP_Markdown_Native_Query_Scalar_Expression ? 'expression' : 'literal';
}
$assignments[] = array( 'target' => $target, 'kind' => $kind, 'source' => $source, 'value' => $value );
if ( WP_Markdown_Native_SQL_Token::COMMA !== $this->current()->type() ) {
Expand Down
16 changes: 14 additions & 2 deletions inc/native/class-wp-markdown-native-table-mutations.php
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,16 @@ private function execute_insert( WP_Markdown_Query_Request $request, WP_Markdown
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 ( $assignment['value'] instanceof WP_Markdown_Native_Query_Scalar_Expression ) {
if ( ! WP_Markdown_Native_Scalar_Evaluator::supports( $assignment['value'] ) ) {
return $this->failure( 'unsupported_mutation_expression', 'The duplicate-key assignment uses an unsupported expression.' );
}
foreach ( $assignment['value']->columns() as $column ) {
if ( ! $schema->has_column( $column ) ) {
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.' );
Expand Down Expand Up @@ -213,13 +223,15 @@ private function execute_insert( WP_Markdown_Query_Request $request, WP_Markdown
}
$duplicate = $duplicates[0];
$updated = $rows[ $duplicate ];
$scalar_runtime = new WP_Markdown_Native_Query_Runtime( $this->registry, new WP_Markdown_Native_Query_Parser() );
foreach ( $upsert_assignments as $assignment ) {
// Existing-column references see earlier assignments; VALUES sees the proposed insert.
$updated[ $assignment['target'] ] = match ( $assignment['kind'] ) {
$value = match ( $assignment['kind'] ) {
'inserted' => $row[ $assignment['source'] ],
'column' => $updated[ $assignment['source'] ],
'expression' => $scalar_runtime->evaluate_scalar( $assignment['value'], $updated, $schema ),
default => $assignment['value'],
};
$updated[ $assignment['target'] ] = null === $value ? null : (string) $value;
}
if ( true !== $schema->validate_row( $updated ) ) {
return $this->failure( 'invalid_insert_row', 'The INSERT row is outside the persisted table schema.' );
Expand Down
6 changes: 5 additions & 1 deletion inc/native/class-wp-markdown-native-table-providers.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
require_once __DIR__ . '/class-wp-markdown-native-post-catalogue.php';
require_once __DIR__ . '/class-wp-markdown-native-option-catalogue.php';

abstract class WP_Markdown_Native_File_Provider implements WP_Markdown_Native_Table_Provider {
abstract class WP_Markdown_Native_File_Provider implements WP_Markdown_Native_Canonical_Table_Provider {

protected string $state_root;

Expand All @@ -25,6 +25,10 @@ public function __construct(
$this->state_root = rtrim( $root, DIRECTORY_SEPARATOR );
}

public function canonical_root(): string {
return $this->state_root;
}

protected function failure( string $code, string $reason, string $message ): WP_Markdown_Query_Result {
return WP_Markdown_Query_Result::failure(
array(
Expand Down
4 changes: 2 additions & 2 deletions inc/native/class-wp-markdown-native-table-statements.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ final class WP_Markdown_Native_Table_Insert {
/**
* @param array<string,int|string|null> $values
* @param array<int,WP_Markdown_Native_Table_Predicate>|null $unless_exists
* @param array<int,array{target:string,kind:string,source:?string,value:int|string|null}>|null $upsert_assignments
* @param array<int,array{target:string,kind:string,source:?string,value:int|string|null|WP_Markdown_Native_Query_Scalar_Expression}>|null $upsert_assignments
*/
public function __construct(
private readonly string $table,
Expand Down Expand Up @@ -38,7 +38,7 @@ public function ignores_duplicate(): bool {
return $this->ignore_duplicate;
}

/** @return array<int,array{target:string,kind:string,source:?string,value:int|string|null}>|null */
/** @return array<int,array{target:string,kind:string,source:?string,value:int|string|null|WP_Markdown_Native_Query_Scalar_Expression}>|null */
public function upsert_assignments(): ?array {
return $this->upsert_assignments;
}
Expand Down
Loading