This commit is contained in:
2026-07-02 15:54:39 -06:00
commit 9883323161
17470 changed files with 4470592 additions and 0 deletions
@@ -0,0 +1,63 @@
<?php
class WP_MySQL_Parser extends WP_Parser {
/**
* The current query AST.
*
* @var WP_Parser_Node|null
*/
private $current_ast;
/**
* Reset this parser with a new token stream.
*
* @param array<WP_Parser_Token> $tokens The parser tokens.
*/
public function reset_tokens( array $tokens ): void {
$this->tokens = $tokens;
$this->position = 0;
$this->current_ast = null;
}
/**
* Parse the next query from the input SQL string.
*
* This method reads tokens until a query is parsed, or the parsing fails.
* It returns a boolean indicating whether a query was successfully parsed.
*
* Example:
*
* // Parse all queries in the input SQL string.
* $parser = new WP_MySQL_Parser( $sql );
* while ( $parser->next_query() ) {
* $ast = $parser->get_query_ast();
* if ( ! $ast ) {
* // The parsing failed.
* }
* // The query was successfully parsed.
* }
*
* @return bool Whether a query was successfully parsed.
*/
public function next_query(): bool {
if ( $this->position >= count( $this->tokens ) ) {
return false;
}
$this->current_ast = $this->parse();
return true;
}
/**
* Get the current query AST.
*
* When no query has been parsed yet, the parsing failed, or the end of the
* input was reached, this method returns null.
*
* @see WP_MySQL_Parser::next_query() for usage example.
*
* @return WP_Parser_Node|null The current query AST, or null if no query was parsed.
*/
public function get_query_ast(): ?WP_Parser_Node {
return $this->current_ast;
}
}
@@ -0,0 +1,187 @@
<?php
/**
* MySQL token.
*
* This class represents a MySQL SQL token that is produced by WP_MySQL_Lexer,
* and consumed by WP_MySQL_Parser during the parsing process.
*/
class WP_MySQL_Token extends WP_Parser_Token {
/**
* Whether the NO_BACKSLASH_ESCAPES SQL mode is enabled.
*
* @var bool
*/
private $sql_mode_no_backslash_escapes_enabled;
/**
* Constructor.
*
* @param int $id Token type.
* @param int $start Byte offset in the input where the token begins.
* @param int $length Byte length of the token in the input.
* @param string $input Input bytes from which the token was parsed.
* @param bool $sql_mode_no_backslash_escapes_enabled Whether the NO_BACKSLASH_ESCAPES SQL mode is enabled.
*/
public function __construct(
int $id,
int $start,
int $length,
string $input,
bool $sql_mode_no_backslash_escapes_enabled
) {
$this->id = $id;
$this->start = $start;
$this->length = $length;
$this->input = $input;
$this->sql_mode_no_backslash_escapes_enabled = $sql_mode_no_backslash_escapes_enabled;
}
/**
* Get the name of the token.
*
* This method is intended to be used only for testing and debugging purposes,
* when tokens need to be presented by their names in a human-readable form.
* It should not be used in production code, as it's not performance-optimized.
*
* @return string The token name.
*/
public function get_name(): string {
$name = WP_MySQL_Lexer::get_token_name( $this->id );
if ( null === $name ) {
$name = 'UNKNOWN';
}
return $name;
}
/**
* Get the real unquoted value of the token.
*
* @return string The token value.
*/
public function get_value(): string {
$value = $this->get_bytes();
if (
WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $this->id
|| WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $this->id
|| WP_MySQL_Lexer::BACK_TICK_QUOTED_ID === $this->id
) {
// Remove bounding quotes.
$quote = $value[0];
$value = substr( $value, 1, -1 );
/*
* When the NO_BACKSLASH_ESCAPES SQL mode is enabled, we only need to
* handle escaped bounding quotes, as the other characters preserve
* their literal values.
*/
if ( $this->sql_mode_no_backslash_escapes_enabled ) {
return str_replace( $quote . $quote, $quote, $value );
}
/**
* Unescape MySQL escape sequences.
*
* MySQL string literals use backslash as an escape character, and
* the string bounding quotes can also be escaped by being doubled.
*
* The escaping is done according to the following rules:
*
* 1. Some special character escape sequences are recognized.
* For example, "\n" is a newline character, "\0" is ASCII NULL.
* 2. A specific treatment is applied to "\%" and "\_" sequences.
* This is due to their special meaning for pattern matching.
* 3. Other backslash-prefixed characters resolve to their literal
* values. For example, "\x" represents "x", "\\" represents "\".
*
* Despite looking similar, these rules are different from the C-style
* string escaping, so we cannot use "strip(c)slashes()" in this case.
*
* See: https://dev.mysql.com/doc/refman/8.4/en/string-literals.html
*/
$backslash = chr( 92 );
$replacements = array(
/*
* MySQL special character escape sequences.
*/
( $backslash . '0' ) => chr( 0 ), // An ASCII NULL character (\0).
( $backslash . "'" ) => chr( 39 ), // A single quote character (').
( $backslash . '"' ) => chr( 34 ), // A double quote character (").
( $backslash . 'b' ) => chr( 8 ), // A backspace character.
( $backslash . 'n' ) => chr( 10 ), // A newline (linefeed) character (\n).
( $backslash . 'r' ) => chr( 13 ), // A carriage return character (\r).
( $backslash . 't' ) => chr( 9 ), // A tab character (\t).
( $backslash . 'Z' ) => chr( 26 ), // An ASCII 26 (Control+Z) character.
/*
* Normalize escaping of "%" and "_" characters.
*
* MySQL has unusual handling for "\%" and "\_" in all string literals.
* While other sequences follow the C-style escaping ("\?" is "?", etc.),
* "\%" resolves to "\%" and "\_" resolves to "\_" (unlike in C strings).
*
* This means that "\%" behaves like "\\%", and "\_" behaves like "\\_".
* To preserve this behavior, we need to add a second backslash here.
*
* From https://dev.mysql.com/doc/refman/8.4/en/string-literals.html:
* > The \% and \_ sequences are used to search for literal instances
* > of % and _ in pattern-matching contexts where they would otherwise
* > be interpreted as wildcard characters. If you use \% or \_ outside
* > of pattern-matching contexts, they evaluate to the strings \% and
* > \_, not to % and _.
*/
( $backslash . '%' ) => $backslash . $backslash . '%',
( $backslash . '_' ) => $backslash . $backslash . '_',
/*
* Preserve a double backslash as-is, so that the trailing backslash
* is not consumed as the beginning of an escape sequence like "\n".
*
* Resolving "\\" to "\" will be handled in the next step, where all
* other backslash-prefixed characters resolve to their literal values.
*/
( $backslash . $backslash )
=> $backslash . $backslash,
/*
* The bounding quotes can also be escaped by being doubled.
*/
( $quote . $quote ) => $quote,
);
/*
* Apply the replacements.
*
* It is important to use "strtr()" and not "str_replace()", because
* "str_replace()" applies replacements one after another, modifying
* intermediate changes rather than just the original string:
*
* - str_replace( [ 'a', 'b' ], [ 'b', 'c' ], 'ab' ); // 'cc' (bad)
* - strtr( 'ab', [ 'a' => 'b', 'b' => 'c' ] ); // 'bc' (good)
*/
$value = strtr( $value, $replacements );
/*
* A backslash with any other character represents the character itself.
* That is, \x evaluates to x, \\ evaluates to \, and \🙂 evaluates to 🙂.
*/
$preg_quoted_backslash = preg_quote( $backslash );
$value = preg_replace( "/$preg_quoted_backslash(.)/u", '$1', $value );
}
return $value;
}
/**
* Get the token representation as a string.
*
* This method is intended to be used only for testing and debugging purposes,
* when tokens need to be presented in a human-readable form. It should not
* be used in production code, as it's not performance-optimized.
*
* @return string
*/
public function __toString(): string {
return $this->get_value() . '<' . $this->id . ',' . $this->get_name() . '>';
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
<?php
class WP_MySQL_Lexer extends WP_MySQL_Native_Lexer {}
@@ -0,0 +1,179 @@
<?php
/**
* Parser node backed by a native (Rust) AST.
*
* Constructed by the native MySQL parser extension. Read methods delegate
* into the Rust-owned AST so children are never copied into PHP unless a
* caller actually walks the tree. On the first mutation (append_child or
* merge_fragment), the node materializes its children into the inherited
* `$children` array and behaves like a plain WP_Parser_Node from then on.
*/
class WP_MySQL_Native_Parser_Node extends WP_Parser_Node {
private $was_mutated = false;
public function __construct( $rule_id, $rule_name ) {
parent::__construct( $rule_id, $rule_name );
}
public function __destruct() {
if ( function_exists( 'wp_sqlite_mysql_native_ast_release_wrapper' ) ) {
wp_sqlite_mysql_native_ast_release_wrapper( $this );
}
}
/** @inheritDoc */
public function append_child( $node ) {
$this->materialize_native_children();
parent::append_child( $node );
}
/** @inheritDoc */
public function merge_fragment( $node ) {
$this->materialize_native_children();
if ( $node instanceof self ) {
$node->materialize_native_children();
}
parent::merge_fragment( $node );
}
/** @inheritDoc */
public function has_child(): bool {
if ( $this->was_mutated ) {
return parent::has_child();
}
return wp_sqlite_mysql_native_ast_has_child( $this );
}
/** @inheritDoc */
public function has_child_node( ?string $rule_name = null ): bool {
if ( $this->was_mutated ) {
return parent::has_child_node( $rule_name );
}
return wp_sqlite_mysql_native_ast_has_child_node( $this, $rule_name );
}
/** @inheritDoc */
public function has_child_token( ?int $token_id = null ): bool {
if ( $this->was_mutated ) {
return parent::has_child_token( $token_id );
}
return wp_sqlite_mysql_native_ast_has_child_token( $this, $token_id );
}
/** @inheritDoc */
public function get_first_child() {
if ( $this->was_mutated ) {
return parent::get_first_child();
}
return wp_sqlite_mysql_native_ast_get_first_child( $this );
}
/** @inheritDoc */
public function get_first_child_node( ?string $rule_name = null ): ?WP_Parser_Node {
if ( $this->was_mutated ) {
return parent::get_first_child_node( $rule_name );
}
return wp_sqlite_mysql_native_ast_get_first_child_node( $this, $rule_name );
}
/** @inheritDoc */
public function get_first_child_token( ?int $token_id = null ): ?WP_Parser_Token {
if ( $this->was_mutated ) {
return parent::get_first_child_token( $token_id );
}
return wp_sqlite_mysql_native_ast_get_first_child_token( $this, $token_id );
}
/** @inheritDoc */
public function get_first_descendant_node( ?string $rule_name = null ): ?WP_Parser_Node {
if ( $this->was_mutated ) {
return parent::get_first_descendant_node( $rule_name );
}
return wp_sqlite_mysql_native_ast_get_first_descendant_node( $this, $rule_name );
}
/** @inheritDoc */
public function get_first_descendant_token( ?int $token_id = null ): ?WP_Parser_Token {
if ( $this->was_mutated ) {
return parent::get_first_descendant_token( $token_id );
}
return wp_sqlite_mysql_native_ast_get_first_descendant_token( $this, $token_id );
}
/** @inheritDoc */
public function get_children(): array {
if ( $this->was_mutated ) {
return parent::get_children();
}
return wp_sqlite_mysql_native_ast_get_children( $this );
}
/** @inheritDoc */
public function get_child_nodes( ?string $rule_name = null ): array {
if ( $this->was_mutated ) {
return parent::get_child_nodes( $rule_name );
}
return wp_sqlite_mysql_native_ast_get_child_nodes( $this, $rule_name );
}
/** @inheritDoc */
public function get_child_tokens( ?int $token_id = null ): array {
if ( $this->was_mutated ) {
return parent::get_child_tokens( $token_id );
}
return wp_sqlite_mysql_native_ast_get_child_tokens( $this, $token_id );
}
/** @inheritDoc */
public function get_descendants(): array {
if ( $this->was_mutated ) {
return parent::get_descendants();
}
return wp_sqlite_mysql_native_ast_get_descendants( $this );
}
/** @inheritDoc */
public function get_descendant_nodes( ?string $rule_name = null ): array {
if ( $this->was_mutated ) {
return parent::get_descendant_nodes( $rule_name );
}
return wp_sqlite_mysql_native_ast_get_descendant_nodes( $this, $rule_name );
}
/** @inheritDoc */
public function get_descendant_tokens( ?int $token_id = null ): array {
if ( $this->was_mutated ) {
return parent::get_descendant_tokens( $token_id );
}
return wp_sqlite_mysql_native_ast_get_descendant_tokens( $this, $token_id );
}
/** @inheritDoc */
public function get_start(): int {
if ( $this->was_mutated ) {
return parent::get_start();
}
return wp_sqlite_mysql_native_ast_get_start( $this );
}
/** @inheritDoc */
public function get_length(): int {
if ( $this->was_mutated ) {
return parent::get_length();
}
return wp_sqlite_mysql_native_ast_get_length( $this );
}
private function materialize_native_children(): void {
if ( $this->was_mutated ) {
return;
}
$this->children = wp_sqlite_mysql_native_ast_get_children( $this );
$this->was_mutated = true;
if ( function_exists( 'wp_sqlite_mysql_native_ast_materialize_wrapper' ) ) {
wp_sqlite_mysql_native_ast_materialize_wrapper( $this );
}
}
}
@@ -0,0 +1,14 @@
<?php
/**
* Native-mode public parser entry point.
*
* Always extends the pure-PHP `WP_Parser` so `$parser instanceof WP_Parser`
* keeps working for callers regardless of whether the Rust extension is
* loaded. The actual parsing work is delegated to a composed
* `WP_MySQL_Native_Parser` instance via the `WP_MySQL_Native_Parser_Impl`
* trait — see that file for the per-method delegation.
*/
class WP_MySQL_Parser extends WP_Parser {
use WP_MySQL_Native_Parser_Impl;
}
@@ -0,0 +1,22 @@
<?php
/**
* Bridge helpers for the optional Rust MySQL lexer/parser extension.
* PHP keeps the grammar object, while Rust owns the exported parser state.
*/
/**
* Export grammar internals for the native parser.
*
* @param WP_Parser_Grammar $grammar Parser grammar.
* @return array<string, mixed>
*/
function wp_sqlite_mysql_native_export_grammar( WP_Parser_Grammar $grammar ): array {
return array(
'highest_terminal_id' => $grammar->highest_terminal_id,
'rules' => $grammar->rules,
'lookahead_is_match_possible' => $grammar->lookahead_is_match_possible,
'rule_names' => $grammar->rule_names,
'fragment_ids' => $grammar->fragment_ids,
);
}
@@ -0,0 +1,55 @@
<?php
/**
* Native-mode `WP_MySQL_Parser` implementation, delivered as a trait.
*
* The class that uses this trait (`WP_MySQL_Parser` in native mode)
* extends the pure-PHP `WP_Parser` so callers' `instanceof WP_Parser`
* checks keep working, while the actual parsing work is delegated to
* the Rust-registered `WP_MySQL_Native_Parser` instance held in
* `$this->native`. `WP_Parser`'s state (`$grammar`, `$tokens`,
* `$position`) stays inert in native mode — the trait's overrides
* never read it.
*
* Adding a public method here is enough to plumb a new public method
* through to the native parser; the using class does not need touching.
*/
trait WP_MySQL_Native_Parser_Impl {
/**
* @var WP_MySQL_Native_Parser
*/
private $native;
/**
* @param WP_Parser_Grammar $grammar
* @param array<WP_Parser_Token>|WP_MySQL_Native_Token_Stream $tokens
*/
public function __construct( WP_Parser_Grammar $grammar, $tokens ) {
// WP_Parser's `array $tokens` constructor signature can't accept
// the native token stream object; its `$this->tokens` /
// `$this->position` state is inert in native mode anyway, so we
// pass an empty array to satisfy the parent contract and keep
// the actual tokens on the native parser.
parent::__construct( $grammar, array() );
$this->native = new WP_MySQL_Native_Parser( $grammar, $tokens );
}
/**
* @param array<WP_Parser_Token>|WP_MySQL_Native_Token_Stream $tokens
*/
public function reset_tokens( $tokens ): void {
$this->native->reset_tokens( $tokens );
}
public function next_query(): bool {
return $this->native->next_query();
}
public function get_query_ast(): ?WP_Parser_Node {
return $this->native->get_query_ast();
}
public function parse() {
return $this->native->parse();
}
}