diff --git a/.github/workflows/mysql-parser-extension-tests.yml b/.github/workflows/mysql-parser-extension-tests.yml index 5f8e49d3..234bb5c7 100644 --- a/.github/workflows/mysql-parser-extension-tests.yml +++ b/.github/workflows/mysql-parser-extension-tests.yml @@ -81,7 +81,10 @@ jobs: exit( 1 ); } ' - ./vendor/bin/phpunit -c ./phpunit.xml.dist tests/mysql/WP_MySQL_Lexer_Tests.php tests/parser/WP_Parser_Node_Tests.php + working-directory: packages/mysql-on-sqlite + + - name: Run PHPUnit tests with parser extension + run: php -d extension="$GITHUB_WORKSPACE/packages/php-ext-wp-mysql-parser/target/debug/libwp_mysql_parser.so" ./vendor/bin/phpunit -c ./phpunit.xml.dist working-directory: packages/mysql-on-sqlite sqlite-driver-extension-tests: @@ -149,3 +152,7 @@ jobs: exit( 1 ); } ' + + - name: Run PHPUnit tests with SQLite driver using parser extension + run: php -d extension="$GITHUB_WORKSPACE/packages/php-ext-wp-mysql-parser/target/debug/libwp_mysql_parser.so" ./vendor/bin/phpunit -c ./phpunit.xml.dist + working-directory: packages/mysql-on-sqlite diff --git a/.github/workflows/native-ast-perf.yml b/.github/workflows/native-ast-perf.yml new file mode 100644 index 00000000..9b51eb8e --- /dev/null +++ b/.github/workflows/native-ast-perf.yml @@ -0,0 +1,216 @@ +name: Native AST Walk Perf + +on: + push: + paths: + - '.github/workflows/native-ast-perf.yml' + - 'packages/mysql-on-sqlite/src/mysql/native/**' + - 'packages/mysql-on-sqlite/tests/tools/run-native-ast-walk-benchmark.php' + - 'packages/php-ext-wp-mysql-parser/**' + pull_request: + paths: + - '.github/workflows/native-ast-perf.yml' + - 'packages/mysql-on-sqlite/src/mysql/native/**' + - 'packages/mysql-on-sqlite/tests/tools/run-native-ast-walk-benchmark.php' + - 'packages/php-ext-wp-mysql-parser/**' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + perf: + name: Native AST walk benchmark + runs-on: ubuntu-latest + timeout-minutes: 25 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + coverage: none + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install native build dependencies + run: | + sudo apt-get update + sudo apt-get install -y libclang-dev + echo "PHP_CONFIG=$(command -v php-config)" >> "$GITHUB_ENV" + LIBCLANG_SO="$(find /usr/lib -name 'libclang.so*' | head -n 1)" + echo "LIBCLANG_PATH=$(dirname "$LIBCLANG_SO")" >> "$GITHUB_ENV" + + - name: Install Composer dependencies (root) + uses: ramsey/composer-install@v3 + with: + ignore-cache: "yes" + composer-options: "--optimize-autoloader" + + - name: Install Composer dependencies (mysql-on-sqlite) + uses: ramsey/composer-install@v3 + with: + working-directory: packages/mysql-on-sqlite + ignore-cache: "yes" + composer-options: "--optimize-autoloader" + + - name: Download MySQL test query corpus + working-directory: packages/mysql-on-sqlite + run: bash tests/tools/mysql-download-tests.sh || true + + - name: Build parser extension (release) + run: cargo build --release + working-directory: packages/php-ext-wp-mysql-parser + + - name: Locate built extension + run: | + EXT="$GITHUB_WORKSPACE/packages/php-ext-wp-mysql-parser/target/release/libwp_mysql_parser.so" + test -f "$EXT" || { echo "Extension not built at $EXT"; exit 1; } + echo "NATIVE_EXT=$EXT" >> "$GITHUB_ENV" + + - name: Benchmark — pure-PHP path (parse only) + working-directory: packages/mysql-on-sqlite + run: | + php tests/tools/run-native-ast-walk-benchmark.php --no-walk | tee php-parse-only.txt + + - name: Benchmark — pure-PHP path (walk) + working-directory: packages/mysql-on-sqlite + run: | + php tests/tools/run-native-ast-walk-benchmark.php | tee php-walk.txt + + - name: Benchmark — native path (parse only) + working-directory: packages/mysql-on-sqlite + run: | + php -d extension="$NATIVE_EXT" tests/tools/run-native-ast-walk-benchmark.php --no-walk | tee native-parse-only.txt + + - name: Benchmark — native path (walk, with identity cache) + working-directory: packages/mysql-on-sqlite + run: | + php -d extension="$NATIVE_EXT" tests/tools/run-native-ast-walk-benchmark.php | tee native-walk.txt + + - name: Check out baseline (PR base, no identity cache) + run: | + git fetch --no-tags --depth=1 origin codex/native-lazy-ast-facade + git worktree add ../baseline FETCH_HEAD + + - name: Install Composer dependencies (baseline mysql-on-sqlite) + uses: ramsey/composer-install@v3 + with: + working-directory: ../baseline/packages/mysql-on-sqlite + ignore-cache: "yes" + composer-options: "--optimize-autoloader" + + - name: Build baseline parser extension (release) + run: cargo build --release + working-directory: ../baseline/packages/php-ext-wp-mysql-parser + + - name: Stage benchmark + corpus into baseline + run: | + mkdir -p ../baseline/packages/mysql-on-sqlite/tests/mysql/data + cp packages/mysql-on-sqlite/tests/tools/run-native-ast-walk-benchmark.php \ + ../baseline/packages/mysql-on-sqlite/tests/tools/ + cp packages/mysql-on-sqlite/tests/mysql/data/*.csv \ + ../baseline/packages/mysql-on-sqlite/tests/mysql/data/ 2>/dev/null || true + + - name: Benchmark — baseline native path (walk, no identity cache) + working-directory: ../baseline/packages/mysql-on-sqlite + run: | + BASE_EXT="$(realpath ../../packages/php-ext-wp-mysql-parser/target/release/libwp_mysql_parser.so)" + php -d extension="$BASE_EXT" tests/tools/run-native-ast-walk-benchmark.php \ + | tee "$GITHUB_WORKSPACE/packages/mysql-on-sqlite/baseline-native-walk.txt" + + - name: Benchmark — baseline native path (parse only) + working-directory: ../baseline/packages/mysql-on-sqlite + run: | + BASE_EXT="$(realpath ../../packages/php-ext-wp-mysql-parser/target/release/libwp_mysql_parser.so)" + php -d extension="$BASE_EXT" tests/tools/run-native-ast-walk-benchmark.php --no-walk \ + | tee "$GITHUB_WORKSPACE/packages/mysql-on-sqlite/baseline-native-parse-only.txt" + + # Hit-heavy scenarios — these are where the per-AST identity cache is + # supposed to win. The baseline reallocates wrappers on every accessor + # call, while the PR reuses them. Run on both to make the gap visible. + - name: Benchmark — native rewalk x10 (this PR) + working-directory: packages/mysql-on-sqlite + run: | + php -d extension="$NATIVE_EXT" tests/tools/run-native-ast-walk-benchmark.php --mode=rewalk --repeat=10 \ + | tee native-rewalk.txt + + - name: Benchmark — baseline rewalk x10 + working-directory: ../baseline/packages/mysql-on-sqlite + run: | + BASE_EXT="$(realpath ../../packages/php-ext-wp-mysql-parser/target/release/libwp_mysql_parser.so)" + php -d extension="$BASE_EXT" tests/tools/run-native-ast-walk-benchmark.php --mode=rewalk --repeat=10 \ + | tee "$GITHUB_WORKSPACE/packages/mysql-on-sqlite/baseline-native-rewalk.txt" + + - name: Benchmark — native reread x20 (this PR) + working-directory: packages/mysql-on-sqlite + run: | + php -d extension="$NATIVE_EXT" tests/tools/run-native-ast-walk-benchmark.php --mode=reread --repeat=20 \ + | tee native-reread.txt + + - name: Benchmark — baseline reread x20 + working-directory: ../baseline/packages/mysql-on-sqlite + run: | + BASE_EXT="$(realpath ../../packages/php-ext-wp-mysql-parser/target/release/libwp_mysql_parser.so)" + php -d extension="$BASE_EXT" tests/tools/run-native-ast-walk-benchmark.php --mode=reread --repeat=20 \ + | tee "$GITHUB_WORKSPACE/packages/mysql-on-sqlite/baseline-native-reread.txt" + + - name: Benchmark — native subtree x5 (this PR) + working-directory: packages/mysql-on-sqlite + run: | + php -d extension="$NATIVE_EXT" tests/tools/run-native-ast-walk-benchmark.php --mode=subtree --repeat=5 \ + | tee native-subtree.txt + + - name: Benchmark — baseline subtree x5 + working-directory: ../baseline/packages/mysql-on-sqlite + run: | + BASE_EXT="$(realpath ../../packages/php-ext-wp-mysql-parser/target/release/libwp_mysql_parser.so)" + php -d extension="$BASE_EXT" tests/tools/run-native-ast-walk-benchmark.php --mode=subtree --repeat=5 \ + | tee "$GITHUB_WORKSPACE/packages/mysql-on-sqlite/baseline-native-subtree.txt" + + - name: Summarize + if: always() + working-directory: packages/mysql-on-sqlite + run: | + extract() { + # Pull a numeric field (e.g. duration=1.23s) from a benchmark + # output line. Returns "n/a" if missing. + local file="$1" key="$2" + [ -f "$file" ] || { echo "n/a"; return; } + grep -oE "${key}=[^ ]+" "$file" | head -1 | cut -d= -f2 || echo "n/a" + } + + { + echo '### Native AST walk perf' + echo + echo '| scenario | result |' + echo '|---|---|' + for f in php-parse-only.txt php-walk.txt native-parse-only.txt native-walk.txt baseline-native-parse-only.txt baseline-native-walk.txt native-rewalk.txt baseline-native-rewalk.txt native-reread.txt baseline-native-reread.txt native-subtree.txt baseline-native-subtree.txt; do + [ -f "$f" ] || continue + line="$(cat "$f")" + echo "| ${f%.txt} | \`$line\` |" + done + echo + echo '### Identity-cache cost (native walk: with cache vs PR base without)' + echo + echo '| metric | with cache (this PR) | baseline | delta |' + echo '|---|---|---|---|' + for key in duration qps peak_mem walked_nodes; do + with="$(extract native-walk.txt "$key")" + base="$(extract baseline-native-walk.txt "$key")" + echo "| $key | $with | $base | — |" + done + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload raw output + if: always() + uses: actions/upload-artifact@v4 + with: + name: native-ast-perf-${{ github.run_id }} + path: packages/mysql-on-sqlite/*.txt + if-no-files-found: warn diff --git a/.github/workflows/wp-tests-phpunit.yml b/.github/workflows/wp-tests-phpunit.yml index f2c8faf6..31db5ef0 100644 --- a/.github/workflows/wp-tests-phpunit.yml +++ b/.github/workflows/wp-tests-phpunit.yml @@ -54,8 +54,8 @@ jobs: - name: Build and load parser extension in WordPress PHP containers run: bash .github/workflows/wp-tests-phpunit-native-extension-setup.sh - - name: Verify WordPress uses parser extension - run: cd wordpress && node tools/local-env/scripts/docker.js run --rm php php /var/www/native-verify-extension.php + - name: Run WordPress PHPUnit tests with parser extension + run: node .github/workflows/wp-tests-phpunit-run.js - name: Stop Docker containers if: always() diff --git a/packages/mysql-on-sqlite/src/load.php b/packages/mysql-on-sqlite/src/load.php index 2070c631..4cbb4208 100644 --- a/packages/mysql-on-sqlite/src/load.php +++ b/packages/mysql-on-sqlite/src/load.php @@ -27,6 +27,7 @@ if ( class_exists( 'WP_MySQL_Native_Parser', false ) ) { require_once __DIR__ . '/mysql/native/mysql-rust-bridge.php'; + require_once __DIR__ . '/mysql/native/class-wp-mysql-native-ast-cache.php'; require_once __DIR__ . '/mysql/native/class-wp-mysql-native-parser-node.php'; require_once __DIR__ . '/mysql/native/class-wp-mysql-parser.php'; } else { diff --git a/packages/mysql-on-sqlite/src/mysql/class-wp-mysql-parser.php b/packages/mysql-on-sqlite/src/mysql/class-wp-mysql-parser.php index f291064e..69282b9c 100644 --- a/packages/mysql-on-sqlite/src/mysql/class-wp-mysql-parser.php +++ b/packages/mysql-on-sqlite/src/mysql/class-wp-mysql-parser.php @@ -8,6 +8,17 @@ class WP_MySQL_Parser extends WP_Parser { */ private $current_ast; + /** + * Reset this parser with a new token stream. + * + * @param array $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. * diff --git a/packages/mysql-on-sqlite/src/mysql/native/class-wp-mysql-native-ast-cache.php b/packages/mysql-on-sqlite/src/mysql/native/class-wp-mysql-native-ast-cache.php new file mode 100644 index 00000000..f21bb785 --- /dev/null +++ b/packages/mysql-on-sqlite/src/mysql/native/class-wp-mysql-native-ast-cache.php @@ -0,0 +1,23 @@ + WP_MySQL_Native_Parser_Node. + * + * @var array + */ + public $nodes = array(); +} diff --git a/packages/mysql-on-sqlite/src/mysql/native/class-wp-mysql-native-parser-node.php b/packages/mysql-on-sqlite/src/mysql/native/class-wp-mysql-native-parser-node.php index e796bf68..fae84b4c 100644 --- a/packages/mysql-on-sqlite/src/mysql/native/class-wp-mysql-native-parser-node.php +++ b/packages/mysql-on-sqlite/src/mysql/native/class-wp-mysql-native-parser-node.php @@ -8,12 +8,29 @@ * 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. + * + * Wrappers returned by accessors are interned through a per-AST identity + * map (WP_MySQL_Native_AST_Cache) so two reads of the same logical node + * yield the same PHP instance. This preserves the WP_Parser_Node contract + * that mutations performed on a child via `get_first_child_node()` remain + * visible when the same child is reached again, including after the parent + * has materialized. */ class WP_MySQL_Native_Parser_Node extends WP_Parser_Node { private $native_ast = null; private $native_node_index = null; private $was_mutated = false; + /** + * Per-AST identity map shared between every interned wrapper. + * + * Created lazily on the first child access; the root wrapper is the + * first entry. Children inherit the same cache instance by reference. + * + * @var WP_MySQL_Native_AST_Cache|null + */ + private $cache = null; + public function __construct( $rule_id, $rule_name, $native_ast = null, $native_node_index = null ) { parent::__construct( $rule_id, $rule_name ); @@ -21,6 +38,19 @@ public function __construct( $rule_id, $rule_name, $native_ast = null, $native_n $this->native_node_index = $native_node_index; } + /** + * Native node index in the Rust-owned arena. + * + * Exposed so the identity cache can key on it. Returns null after + * the wrapper has materialized — at that point the node is detached + * from the native arena and behaves like a plain WP_Parser_Node. + * + * @return int|null + */ + public function get_native_node_index(): ?int { + return $this->native_node_index; + } + /** @inheritDoc */ public function append_child( $node ) { $this->materialize_native_children(); @@ -65,7 +95,7 @@ public function get_first_child() { if ( $this->was_mutated() ) { return parent::get_first_child(); } - return wp_sqlite_mysql_native_ast_get_first_child( $this->native_ast, $this->native_node_index ); + return $this->intern( wp_sqlite_mysql_native_ast_get_first_child( $this->native_ast, $this->native_node_index ) ); } /** @inheritDoc */ @@ -73,7 +103,7 @@ public function get_first_child_node( ?string $rule_name = null ): ?WP_Parser_No if ( $this->was_mutated() ) { return parent::get_first_child_node( $rule_name ); } - return wp_sqlite_mysql_native_ast_get_first_child_node( $this->native_ast, $this->native_node_index, $rule_name ); + return $this->intern( wp_sqlite_mysql_native_ast_get_first_child_node( $this->native_ast, $this->native_node_index, $rule_name ) ); } /** @inheritDoc */ @@ -89,7 +119,7 @@ public function get_first_descendant_node( ?string $rule_name = null ): ?WP_Pars if ( $this->was_mutated() ) { return parent::get_first_descendant_node( $rule_name ); } - return wp_sqlite_mysql_native_ast_get_first_descendant_node( $this->native_ast, $this->native_node_index, $rule_name ); + return $this->intern( wp_sqlite_mysql_native_ast_get_first_descendant_node( $this->native_ast, $this->native_node_index, $rule_name ) ); } /** @inheritDoc */ @@ -105,7 +135,7 @@ public function get_children(): array { if ( $this->was_mutated() ) { return parent::get_children(); } - return wp_sqlite_mysql_native_ast_get_children( $this->native_ast, $this->native_node_index ); + return $this->intern_all( wp_sqlite_mysql_native_ast_get_children( $this->native_ast, $this->native_node_index ) ); } /** @inheritDoc */ @@ -113,7 +143,7 @@ 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->native_ast, $this->native_node_index, $rule_name ); + return $this->intern_nodes( wp_sqlite_mysql_native_ast_get_child_nodes( $this->native_ast, $this->native_node_index, $rule_name ) ); } /** @inheritDoc */ @@ -129,7 +159,7 @@ public function get_descendants(): array { if ( $this->was_mutated() ) { return parent::get_descendants(); } - return wp_sqlite_mysql_native_ast_get_descendants( $this->native_ast, $this->native_node_index ); + return $this->intern_all( wp_sqlite_mysql_native_ast_get_descendants( $this->native_ast, $this->native_node_index ) ); } /** @inheritDoc */ @@ -137,7 +167,7 @@ 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->native_ast, $this->native_node_index, $rule_name ); + return $this->intern_nodes( wp_sqlite_mysql_native_ast_get_descendant_nodes( $this->native_ast, $this->native_node_index, $rule_name ) ); } /** @inheritDoc */ @@ -168,12 +198,128 @@ private function was_mutated(): bool { return $this->was_mutated; } + /** + * Intern a single accessor return value through the per-AST cache. + * + * Tokens and nulls pass through untouched. Native node wrappers are + * keyed on their `native_node_index`: on cache miss, the freshly + * constructed wrapper is stored and given the cache reference; on + * cache hit, the canonical instance is returned and the new wrapper + * is discarded so callers see stable identity and surviving mutations. + * + * @param mixed $value Return value from the Rust bridge. + * @return mixed + */ + private function intern( $value ) { + if ( ! $value instanceof WP_MySQL_Native_Parser_Node ) { + return $value; + } + + $cache = $this->ensure_cache(); + $index = $value->native_node_index; + if ( null === $index ) { + return $value; + } + if ( isset( $cache->nodes[ $index ] ) ) { + return $cache->nodes[ $index ]; + } + $value->cache = $cache; + $cache->nodes[ $index ] = $value; + return $value; + } + + /** + * Intern every entry in an accessor return array. + * + * Hot path: this runs once per descendant when a caller walks the tree, + * so cache lookup and the cache-miss write are inlined and the cache + * reference is hoisted out of the loop. + * + * @param array $values + * @return array + */ + private function intern_all( array $values ): array { + if ( ! $values ) { + return $values; + } + $cache = $this->cache ?? $this->ensure_cache(); + $nodes = &$cache->nodes; + foreach ( $values as $i => $value ) { + if ( ! $value instanceof WP_MySQL_Native_Parser_Node ) { + continue; + } + $index = $value->native_node_index; + if ( null === $index ) { + continue; + } + if ( isset( $nodes[ $index ] ) ) { + $values[ $i ] = $nodes[ $index ]; + } else { + $value->cache = $cache; + $nodes[ $index ] = $value; + } + } + return $values; + } + + /** + * Intern array of guaranteed-node results (no token/null mixing). + * + * Used by `get_child_nodes()` / `get_descendant_nodes()` whose Rust + * bridge returns only WP_MySQL_Native_Parser_Node instances. Skips + * the per-item `instanceof` check that intern_all() must do for the + * mixed `get_children()` / `get_descendants()` arrays. + * + * @param array $values + * @return array + */ + private function intern_nodes( array $values ): array { + if ( ! $values ) { + return $values; + } + $cache = $this->cache ?? $this->ensure_cache(); + $nodes = &$cache->nodes; + foreach ( $values as $i => $value ) { + $index = $value->native_node_index; + if ( isset( $nodes[ $index ] ) ) { + $values[ $i ] = $nodes[ $index ]; + } else { + $value->cache = $cache; + $nodes[ $index ] = $value; + } + } + return $values; + } + + /** + * Lazily build (or reuse) the per-AST identity map. + * + * The root wrapper is constructed without a cache, so the first time + * any accessor needs to intern a child, it creates the cache and + * registers itself as the root entry. Subsequent interns on this + * wrapper or any descendant share the same cache by reference. + * + * @return WP_MySQL_Native_AST_Cache + */ + private function ensure_cache(): WP_MySQL_Native_AST_Cache { + if ( null === $this->cache ) { + $this->cache = new WP_MySQL_Native_AST_Cache(); + if ( null !== $this->native_node_index ) { + $this->cache->nodes[ $this->native_node_index ] = $this; + } + } + return $this->cache; + } + private function materialize_native_children(): void { if ( $this->was_mutated ) { return; } - $this->children = wp_sqlite_mysql_native_ast_get_children( $this->native_ast, $this->native_node_index ); + // Pull children through the cache so any wrapper a caller already + // mutated via get_first_child_node() etc. survives the transition + // into $this->children — same instance, same mutations. + $this->children = $this->intern_all( wp_sqlite_mysql_native_ast_get_children( $this->native_ast, $this->native_node_index ) ); $this->native_ast = null; $this->native_node_index = null; $this->was_mutated = true; diff --git a/packages/mysql-on-sqlite/src/parser/class-wp-parser-grammar.php b/packages/mysql-on-sqlite/src/parser/class-wp-parser-grammar.php index 8c17b458..9bf30b97 100644 --- a/packages/mysql-on-sqlite/src/parser/class-wp-parser-grammar.php +++ b/packages/mysql-on-sqlite/src/parser/class-wp-parser-grammar.php @@ -32,6 +32,7 @@ class WP_Parser_Grammar { public $lookahead_is_match_possible = array(); public $lowest_non_terminal_id; public $highest_terminal_id; + public $native_grammar; public function __construct( array $rules ) { $this->inflate( $rules ); diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-pdo-mysql-on-sqlite.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-pdo-mysql-on-sqlite.php index b3653ffe..a8ddf146 100644 --- a/packages/mysql-on-sqlite/src/sqlite/class-wp-pdo-mysql-on-sqlite.php +++ b/packages/mysql-on-sqlite/src/sqlite/class-wp-pdo-mysql-on-sqlite.php @@ -410,6 +410,13 @@ class WP_PDO_MySQL_On_SQLite extends PDO { */ private static $mysql_grammar; + /** + * A reusable parser instance for MySQL queries. + * + * @var WP_MySQL_Parser|null + */ + private $mysql_parser = null; + /** * The main database name. * @@ -1160,11 +1167,27 @@ public function create_parser( string $query ): WP_MySQL_Parser { ); if ( $lexer instanceof WP_MySQL_Native_Lexer ) { $tokens = $lexer->native_token_stream(); - return new WP_MySQL_Parser( self::$mysql_grammar, $tokens ); + return $this->reset_or_create_parser( $tokens ); } $tokens = $lexer->remaining_tokens(); - return new WP_MySQL_Parser( self::$mysql_grammar, $tokens ); + return $this->reset_or_create_parser( $tokens ); + } + + /** + * Reset the reusable parser with new tokens or create it on first use. + * + * @param array|object $tokens Parser tokens. + * @return WP_MySQL_Parser A parser initialized for the token stream. + */ + private function reset_or_create_parser( $tokens ): WP_MySQL_Parser { + if ( null === $this->mysql_parser || ! method_exists( $this->mysql_parser, 'reset_tokens' ) ) { + $this->mysql_parser = new WP_MySQL_Parser( self::$mysql_grammar, $tokens ); + } else { + $this->mysql_parser->reset_tokens( $tokens ); + } + + return $this->mysql_parser; } /** diff --git a/packages/mysql-on-sqlite/tests/mysql/native/WP_MySQL_Native_Parser_Node_Identity_Tests.php b/packages/mysql-on-sqlite/tests/mysql/native/WP_MySQL_Native_Parser_Node_Identity_Tests.php new file mode 100644 index 00000000..bd3f8ffe --- /dev/null +++ b/packages/mysql-on-sqlite/tests/mysql/native/WP_MySQL_Native_Parser_Node_Identity_Tests.php @@ -0,0 +1,118 @@ +markTestSkipped( 'Native MySQL parser extension is not loaded.' ); + } + } + + private function parse( string $sql ): WP_Parser_Node { + static $grammar = null; + if ( null === $grammar ) { + $grammar = new WP_Parser_Grammar( include __DIR__ . '/../../../src/mysql/mysql-grammar.php' ); + } + $lexer = new WP_MySQL_Lexer( $sql ); + $tokens = $lexer instanceof WP_MySQL_Native_Lexer + ? $lexer->native_token_stream() + : $lexer->remaining_tokens(); + $parser = new WP_MySQL_Parser( $grammar, $tokens ); + $tree = $parser->parse(); + $this->assertNotNull( $tree, 'Failed to parse SQL: ' . $sql ); + return $tree; + } + + public function test_get_first_child_node_returns_same_instance(): void { + $tree = $this->parse( 'SELECT 1 + 2' ); + + $first = $tree->get_first_child_node(); + $second = $tree->get_first_child_node(); + + $this->assertNotNull( $first ); + $this->assertSame( $first, $second ); + } + + public function test_get_children_returns_same_instances_across_calls(): void { + $tree = $this->parse( 'SELECT 1, 2, 3' ); + + $first_pass = $tree->get_children(); + $second_pass = $tree->get_children(); + + $this->assertSameSize( $first_pass, $second_pass ); + foreach ( $first_pass as $i => $child ) { + if ( $child instanceof WP_Parser_Node ) { + $this->assertSame( $child, $second_pass[ $i ] ); + } + } + } + + public function test_descendant_lookup_shares_identity_with_child_lookup(): void { + $tree = $this->parse( 'SELECT 1 + 2' ); + + $descendant = $tree->get_first_descendant_node(); + $this->assertNotNull( $descendant ); + + // Walk down to the same node via direct children. We don't know the + // exact depth, so we descend until we hit the descendant we found. + $cursor = $tree; + while ( null !== $cursor && $cursor !== $descendant ) { + $next = $cursor->get_first_child_node(); + if ( $next === $cursor ) { + break; + } + $cursor = $next; + } + + $this->assertSame( $descendant, $cursor, 'Descendant and child lookups must return the same wrapper instance.' ); + } + + public function test_mutation_on_child_survives_re_read(): void { + $tree = $this->parse( 'SELECT 1 + 2' ); + + $child = $tree->get_first_child_node(); + $this->assertNotNull( $child ); + + // Mutate via the public WP_Parser_Node API — this is exactly the + // kind of state the reviewer worried would be lost when accessors + // hand back fresh wrappers. rule_name is a declared public property + // that the parser itself sets, so PHP 8.2's dynamic-property + // deprecation does not apply here. + $child->rule_name = 'mutated-rule'; + + $same_child = $tree->get_first_child_node(); + $this->assertSame( $child, $same_child ); + $this->assertSame( 'mutated-rule', $same_child->rule_name ); + } + + public function test_mutation_survives_parent_materialization(): void { + $tree = $this->parse( 'SELECT 1 + 2' ); + + $child = $tree->get_first_child_node(); + $this->assertNotNull( $child ); + $child->rule_name = 'before-materialize'; + + // Force the parent to materialize its native children by appending + // a sibling. After this, the parent walks $this->children directly. + $sibling = new WP_Parser_Node( 0, 'synthetic' ); + $tree->append_child( $sibling ); + + $children = $tree->get_children(); + $this->assertContains( $child, $children, 'Materialized children must include the previously-mutated wrapper.' ); + $this->assertSame( 'before-materialize', $child->rule_name ); + } +} diff --git a/packages/mysql-on-sqlite/tests/tools/run-native-ast-walk-benchmark.php b/packages/mysql-on-sqlite/tests/tools/run-native-ast-walk-benchmark.php new file mode 100644 index 00000000..3927d5c0 --- /dev/null +++ b/packages/mysql-on-sqlite/tests/tools/run-native-ast-walk-benchmark.php @@ -0,0 +1,186 @@ +native_token_stream() + : $lexer->remaining_tokens(); + $parser = new WP_MySQL_Parser( $grammar, $tokens ); + $ast = $parser->parse(); + if ( null === $ast ) { + ++$failures; + continue; + } + ++$total; + + switch ( $mode ) { + case 'no-walk': + break; + + case 'walk': + $descendants = $ast->get_descendants(); + $walked += count( $descendants ); + + $first = $ast->get_first_child_node(); + if ( null !== $first ) { + $again = $ast->get_first_child_node(); + if ( $first !== $again ) { + $identity_ok = false; + } + } + break; + + case 'rewalk': + // Repeated full-tree walks. After the first pass every wrapper + // the cache returns is a hit; without the cache, every pass + // re-allocates wrappers for the entire tree from scratch. + for ( $r = 0; $r < $repeat; $r++ ) { + $descendants = $ast->get_descendants(); + $walked += count( $descendants ); + } + break; + + case 'reread': + // Repeated top-level child reads. Models analysis passes that + // keep poking at the root of the tree. + for ( $r = 0; $r < $repeat; $r++ ) { + $child = $ast->get_first_child_node(); + if ( null !== $child ) { + ++$walked; + // Identity must hold across repeated reads. + if ( $r > 0 && $child !== $prev ) { + $identity_ok = false; + } + $prev = $child; + } + } + break; + + case 'subtree': + // Walk descendants once, then for each descendant re-read its + // first child N times. Models translator/rewriter passes that + // re-enter previously visited subtrees. + $descendants = $ast->get_descendants(); + foreach ( $descendants as $d ) { + if ( ! $d instanceof WP_Parser_Node ) { + continue; + } + for ( $r = 0; $r < $repeat; $r++ ) { + $child = $d->get_first_child_node(); + if ( null !== $child ) { + ++$walked; + } + } + } + break; + + default: + fwrite( STDERR, "Unknown mode: $mode\n" ); + exit( 2 ); + } + } catch ( Throwable $e ) { + ++$failures; + } +} + +$duration = microtime( true ) - $start; +$peak_mb = memory_get_peak_usage( true ) / 1024 / 1024; +$native = class_exists( 'WP_MySQL_Native_Parser', false ) ? 'native' : 'php'; + +printf( + "path=%s mode=%s repeat=%d parsed=%d walked_nodes=%d failures=%d duration=%.4fs qps=%d peak_mem=%.1fMB identity_ok=%s\n", + $native, + $mode, + $repeat, + $total, + $walked, + $failures, + $duration, + $total > 0 ? (int) ( $total / $duration ) : 0, + $peak_mb, + $identity_ok ? 'true' : 'FALSE' +); + +if ( ! $identity_ok ) { + fwrite( STDERR, "Identity check failed: accessor returned different instances on repeat read.\n" ); + exit( 1 ); +} diff --git a/packages/php-ext-wp-mysql-parser/src/lib.rs b/packages/php-ext-wp-mysql-parser/src/lib.rs index bb93ae09..909ec379 100644 --- a/packages/php-ext-wp-mysql-parser/src/lib.rs +++ b/packages/php-ext-wp-mysql-parser/src/lib.rs @@ -3,7 +3,7 @@ use std::collections::{HashMap, HashSet}; use std::os::raw::c_char; use std::ptr; -use std::sync::{Arc, Mutex, OnceLock}; +use std::sync::Arc; use ext_php_rs::convert::{FromZval, IntoZval, IntoZvalDyn}; use ext_php_rs::exception::{PhpException, PhpResult}; @@ -93,10 +93,19 @@ fn update_object_property( } fn create_mysql_token(sql_zval: &Zval, token: TokenInfo, no_backslash: bool) -> PhpResult { + let classes = php_classes()?; + create_mysql_token_with_classes(sql_zval, token, no_backslash, &classes) +} + +fn create_mysql_token_with_classes( + sql_zval: &Zval, + token: TokenInfo, + no_backslash: bool, + classes: &PhpClasses, +) -> PhpResult { let id = token.id; let start = i64::try_from(token.start).map_err(php_error)?; let length = i64::try_from(token.end.saturating_sub(token.start)).map_err(php_error)?; - let classes = php_classes()?; let mut object = classes.mysql_token.new(); update_object_property(&mut object, classes.parser_token, "id", id)?; @@ -915,12 +924,6 @@ struct Rule { is_fragment: bool, } -#[derive(Clone, Copy, Eq, Hash, PartialEq)] -struct GrammarCacheKey { - low: u64, - high: u64, -} - impl Grammar { fn rule(&self, rule_id: i64) -> Option<&Rule> { usize::try_from(rule_id) @@ -930,10 +933,11 @@ impl Grammar { } } -// Cache only Rust-owned grammar data, keyed by exported grammar content. Zend -// object handles are request-local and can be reused, so they must not identify -// cached entries. -static GRAMMAR_CACHE: OnceLock>>> = OnceLock::new(); +#[php_class] +#[php(name = "WP_MySQL_Native_Grammar")] +pub struct WpMySqlNativeGrammar { + grammar: Arc, +} enum ParserTokenSource { Php(Vec), @@ -945,7 +949,7 @@ enum ParserTokenSource { } impl ParserTokenSource { - fn create_php_token(&self, index: usize) -> PhpResult { + fn create_php_token_with_classes(&self, index: usize, classes: &PhpClasses) -> PhpResult { match self { Self::Php(tokens) => tokens .get(index) @@ -960,7 +964,7 @@ impl ParserTokenSource { .get(index) .copied() .ok_or_else(|| php_error("Parser token index is out of range"))?; - create_mysql_token(sql_zval, token, *no_backslash) + create_mysql_token_with_classes(sql_zval, token, *no_backslash, classes) } } } @@ -1025,6 +1029,7 @@ struct NativeAstNode { children: Vec, first_token: Option, last_token: Option, + descendant_count: usize, } struct NativeAstArena { @@ -1054,10 +1059,12 @@ impl NativeAstArena { let index = self.nodes.len(); let mut first_token = None; let mut last_token = None; + let mut descendant_count = 0; for child in &children { match child { NativeAstChild::Node(child_index) => { if let Some(node) = self.nodes.get(*child_index) { + descendant_count += 1 + node.descendant_count; if first_token.is_none() { first_token = node.first_token; } @@ -1071,6 +1078,7 @@ impl NativeAstArena { first_token = Some(*token_index); } last_token = Some(*token_index); + descendant_count += 1; } } } @@ -1080,11 +1088,21 @@ impl NativeAstArena { children, first_token, last_token, + descendant_count, }); index } fn create_php_ast(&self, native_ast_zval: &Zval) -> PhpResult { + let classes = php_classes()?; + self.create_php_ast_with_classes(native_ast_zval, &classes) + } + + fn create_php_ast_with_classes( + &self, + native_ast_zval: &Zval, + classes: &PhpClasses, + ) -> PhpResult { match self.root { NativeAstRoot::No => Ok(Zval::null()), NativeAstRoot::Empty => { @@ -1092,14 +1110,22 @@ impl NativeAstArena { zval.set_bool(true); Ok(zval) } - NativeAstRoot::Node(index) => self.create_php_node(native_ast_zval, index), - NativeAstRoot::Token(index) => self.token_source.create_php_token(index), + NativeAstRoot::Node(index) => { + self.create_php_node_with_classes(native_ast_zval, index, classes) + } + NativeAstRoot::Token(index) => self + .token_source + .create_php_token_with_classes(index, classes), } } - fn create_php_node(&self, native_ast_zval: &Zval, index: usize) -> PhpResult { + fn create_php_node_with_classes( + &self, + native_ast_zval: &Zval, + index: usize, + classes: &PhpClasses, + ) -> PhpResult { let node = self.node(index)?; - let classes = php_classes()?; let mut object = classes.native_parser_node.new(); let rule_name = self .grammar @@ -1142,10 +1168,19 @@ impl NativeAstArena { .ok_or_else(|| php_error("Native AST node index is out of range")) } - fn child_to_zval(&self, native_ast_zval: &Zval, child: NativeAstChild) -> PhpResult { + fn child_to_zval_with_classes( + &self, + native_ast_zval: &Zval, + child: NativeAstChild, + classes: &PhpClasses, + ) -> PhpResult { match child { - NativeAstChild::Node(index) => self.create_php_node(native_ast_zval, index), - NativeAstChild::Token(index) => self.token_source.create_php_token(index), + NativeAstChild::Node(index) => { + self.create_php_node_with_classes(native_ast_zval, index, classes) + } + NativeAstChild::Token(index) => self + .token_source + .create_php_token_with_classes(index, classes), } } @@ -1177,8 +1212,9 @@ impl NativeAstArena { } fn descendant_stack(&self, index: usize) -> PhpResult> { - let mut stack = self.node(index)?.children.clone(); - stack.reverse(); + let node = self.node(index)?; + let mut stack = Vec::with_capacity(node.descendant_count); + stack.extend(node.children.iter().rev().copied()); Ok(stack) } } @@ -1243,6 +1279,7 @@ pub fn wp_sqlite_mysql_native_ast_get_first_child( node_index: i64, ) -> PhpResult { let ast = native_ast(native_ast_zval)?; + let classes = php_classes()?; let Some(child) = ast .arena .node(native_ast_node_index(node_index)?)? @@ -1252,7 +1289,8 @@ pub fn wp_sqlite_mysql_native_ast_get_first_child( else { return Ok(Zval::null()); }; - ast.arena.child_to_zval(native_ast_zval, child) + ast.arena + .child_to_zval_with_classes(native_ast_zval, child, &classes) } #[php_function] @@ -1262,9 +1300,12 @@ pub fn wp_sqlite_mysql_native_ast_get_first_child_node( rule_name: Option, ) -> PhpResult { let ast = native_ast(native_ast_zval)?; + let classes = php_classes()?; for child in &ast.arena.node(native_ast_node_index(node_index)?)?.children { if ast.arena.child_node_matches(*child, rule_name.as_deref()) { - return ast.arena.child_to_zval(native_ast_zval, *child); + return ast + .arena + .child_to_zval_with_classes(native_ast_zval, *child, &classes); } } Ok(Zval::null()) @@ -1277,9 +1318,12 @@ pub fn wp_sqlite_mysql_native_ast_get_first_child_token( token_id: Option, ) -> PhpResult { let ast = native_ast(native_ast_zval)?; + let classes = php_classes()?; for child in &ast.arena.node(native_ast_node_index(node_index)?)?.children { if ast.arena.child_token_matches(*child, token_id) { - return ast.arena.child_to_zval(native_ast_zval, *child); + return ast + .arena + .child_to_zval_with_classes(native_ast_zval, *child, &classes); } } Ok(Zval::null()) @@ -1292,12 +1336,15 @@ pub fn wp_sqlite_mysql_native_ast_get_first_descendant_node( rule_name: Option, ) -> PhpResult { let ast = native_ast(native_ast_zval)?; + let classes = php_classes()?; let mut stack = ast .arena .descendant_stack(native_ast_node_index(node_index)?)?; while let Some(child) = stack.pop() { if ast.arena.child_node_matches(child, rule_name.as_deref()) { - return ast.arena.child_to_zval(native_ast_zval, child); + return ast + .arena + .child_to_zval_with_classes(native_ast_zval, child, &classes); } if let NativeAstChild::Node(index) = child { for child in ast.arena.node(index)?.children.iter().rev() { @@ -1315,12 +1362,15 @@ pub fn wp_sqlite_mysql_native_ast_get_first_descendant_token( token_id: Option, ) -> PhpResult { let ast = native_ast(native_ast_zval)?; + let classes = php_classes()?; let mut stack = ast .arena .descendant_stack(native_ast_node_index(node_index)?)?; while let Some(child) = stack.pop() { if ast.arena.child_token_matches(child, token_id) { - return ast.arena.child_to_zval(native_ast_zval, child); + return ast + .arena + .child_to_zval_with_classes(native_ast_zval, child, &classes); } if let NativeAstChild::Node(index) = child { for child in ast.arena.node(index)?.children.iter().rev() { @@ -1337,12 +1387,16 @@ pub fn wp_sqlite_mysql_native_ast_get_children( node_index: i64, ) -> PhpResult> { let ast = native_ast(native_ast_zval)?; + let classes = php_classes()?; ast.arena .node(native_ast_node_index(node_index)?)? .children .iter() .copied() - .map(|child| ast.arena.child_to_zval(native_ast_zval, child)) + .map(|child| { + ast.arena + .child_to_zval_with_classes(native_ast_zval, child, &classes) + }) .collect() } @@ -1353,13 +1407,17 @@ pub fn wp_sqlite_mysql_native_ast_get_child_nodes( rule_name: Option, ) -> PhpResult> { let ast = native_ast(native_ast_zval)?; + let classes = php_classes()?; ast.arena .node(native_ast_node_index(node_index)?)? .children .iter() .copied() .filter(|child| ast.arena.child_node_matches(*child, rule_name.as_deref())) - .map(|child| ast.arena.child_to_zval(native_ast_zval, child)) + .map(|child| { + ast.arena + .child_to_zval_with_classes(native_ast_zval, child, &classes) + }) .collect() } @@ -1370,13 +1428,17 @@ pub fn wp_sqlite_mysql_native_ast_get_child_tokens( token_id: Option, ) -> PhpResult> { let ast = native_ast(native_ast_zval)?; + let classes = php_classes()?; ast.arena .node(native_ast_node_index(node_index)?)? .children .iter() .copied() .filter(|child| ast.arena.child_token_matches(*child, token_id)) - .map(|child| ast.arena.child_to_zval(native_ast_zval, child)) + .map(|child| { + ast.arena + .child_to_zval_with_classes(native_ast_zval, child, &classes) + }) .collect() } @@ -1386,12 +1448,17 @@ pub fn wp_sqlite_mysql_native_ast_get_descendants( node_index: i64, ) -> PhpResult> { let ast = native_ast(native_ast_zval)?; - let mut descendants = Vec::new(); + let classes = php_classes()?; + let root = ast.arena.node(native_ast_node_index(node_index)?)?; + let mut descendants = Vec::with_capacity(root.descendant_count); let mut stack = ast .arena .descendant_stack(native_ast_node_index(node_index)?)?; while let Some(child) = stack.pop() { - descendants.push(ast.arena.child_to_zval(native_ast_zval, child)?); + descendants.push( + ast.arena + .child_to_zval_with_classes(native_ast_zval, child, &classes)?, + ); if let NativeAstChild::Node(index) = child { for child in ast.arena.node(index)?.children.iter().rev() { stack.push(*child); @@ -1408,13 +1475,18 @@ pub fn wp_sqlite_mysql_native_ast_get_descendant_nodes( rule_name: Option, ) -> PhpResult> { let ast = native_ast(native_ast_zval)?; + let classes = php_classes()?; let mut descendants = Vec::new(); let mut stack = ast .arena .descendant_stack(native_ast_node_index(node_index)?)?; while let Some(child) = stack.pop() { if ast.arena.child_node_matches(child, rule_name.as_deref()) { - descendants.push(ast.arena.child_to_zval(native_ast_zval, child)?); + descendants.push(ast.arena.child_to_zval_with_classes( + native_ast_zval, + child, + &classes, + )?); } if let NativeAstChild::Node(index) = child { for child in ast.arena.node(index)?.children.iter().rev() { @@ -1432,13 +1504,18 @@ pub fn wp_sqlite_mysql_native_ast_get_descendant_tokens( token_id: Option, ) -> PhpResult> { let ast = native_ast(native_ast_zval)?; + let classes = php_classes()?; let mut descendants = Vec::new(); let mut stack = ast .arena .descendant_stack(native_ast_node_index(node_index)?)?; while let Some(child) = stack.pop() { if ast.arena.child_token_matches(child, token_id) { - descendants.push(ast.arena.child_to_zval(native_ast_zval, child)?); + descendants.push(ast.arena.child_to_zval_with_classes( + native_ast_zval, + child, + &classes, + )?); } if let NativeAstChild::Node(index) = child { for child in ast.arena.node(index)?.children.iter().rev() { @@ -1510,6 +1587,18 @@ impl WpMySqlNativeParser { }) } + pub fn reset_tokens(&mut self, tokens: &mut Zval) -> PhpResult<()> { + let (token_source, token_ids) = export_tokens(tokens)?; + + self.token_source = Arc::new(token_source); + self.token_ids = token_ids; + self.position = 0; + self.current_ast = None; + self.current_php_ast = None; + + Ok(()) + } + pub fn parse(&mut self) -> PhpResult { stacker::maybe_grow(STACK_RED_ZONE, STACK_GROW_SIZE, || { let ast = self.parse_native_ast()?; @@ -1685,6 +1774,10 @@ impl WpMySqlNativeParser { } fn export_grammar(grammar_zval: &mut Zval) -> PhpResult> { + if let Some(cached) = cached_native_grammar(grammar_zval)? { + return Ok(cached); + } + let exported = php_function("wp_sqlite_mysql_native_export_grammar")? .try_call(vec![&*grammar_zval as &dyn IntoZvalDyn]) .map_err(php_error)?; @@ -1692,16 +1785,6 @@ fn export_grammar(grammar_zval: &mut Zval) -> PhpResult> { .array() .ok_or_else(|| php_error("Exported grammar must be an array"))?; - let cache_key = grammar_cache_key(array)?; - if let Some(cached) = GRAMMAR_CACHE - .get_or_init(|| Mutex::new(HashMap::new())) - .lock() - .map_err(|_| php_error("Grammar cache lock poisoned"))? - .get(&cache_key) - { - return Ok(Arc::clone(cached)); - } - let highest_terminal_id = array .get("highest_terminal_id") .and_then(Zval::long) @@ -1751,130 +1834,37 @@ fn export_grammar(grammar_zval: &mut Zval) -> PhpResult> { select_statement_rule_id, }); - GRAMMAR_CACHE - .get_or_init(|| Mutex::new(HashMap::new())) - .lock() - .map_err(|_| php_error("Grammar cache lock poisoned"))? - .insert(cache_key, Arc::clone(&grammar)); + cache_native_grammar(grammar_zval, Arc::clone(&grammar))?; Ok(grammar) } -fn grammar_cache_key(array: &ZendHashTable) -> PhpResult { - let mut hasher = GrammarCacheHasher::new(); - hash_grammar_array(&mut hasher, array)?; - Ok(hasher.finish()) -} - -struct GrammarCacheHasher { - low: u64, - high: u64, -} - -impl GrammarCacheHasher { - fn new() -> Self { - Self { - low: 0xcbf29ce484222325, - high: 0x6c62272e07bb0142, - } - } - - fn write_byte(&mut self, byte: u8) { - self.low ^= u64::from(byte); - self.low = self.low.wrapping_mul(0x100000001b3); - self.high ^= u64::from(byte).rotate_left(5); - self.high = self.high.wrapping_mul(0x100000001b3 ^ 0x9e3779b97f4a7c15); - } - - fn write_bytes(&mut self, bytes: &[u8]) { - self.write_usize(bytes.len()); - for byte in bytes { - self.write_byte(*byte); - } - } - - fn write_i64(&mut self, value: i64) { - for byte in value.to_le_bytes() { - self.write_byte(byte); - } - } - - fn write_u64(&mut self, value: u64) { - for byte in value.to_le_bytes() { - self.write_byte(byte); - } - } - - fn write_usize(&mut self, value: usize) { - self.write_u64(value as u64); - } - - fn finish(self) -> GrammarCacheKey { - GrammarCacheKey { - low: self.low, - high: self.high, - } - } -} - -fn hash_grammar_array(hasher: &mut GrammarCacheHasher, array: &ZendHashTable) -> PhpResult<()> { - hasher.write_usize(array.len()); - for (key, value) in array { - hash_grammar_array_key(hasher, key); - hash_grammar_zval(hasher, value)?; - } - Ok(()) -} - -fn hash_grammar_zval(hasher: &mut GrammarCacheHasher, zval: &Zval) -> PhpResult<()> { - let zval = zval.dereference(); - match zval.get_type() { - DataType::Null => hasher.write_byte(0), - DataType::False => hasher.write_byte(1), - DataType::True => hasher.write_byte(2), - DataType::Long => { - hasher.write_byte(3); - hasher.write_i64( - zval.long() - .ok_or_else(|| php_error("Grammar integer value is invalid"))?, - ); - } - DataType::String => { - hasher.write_byte(4); - hasher.write_bytes( - zval.str() - .ok_or_else(|| php_error("Grammar string value is invalid"))? - .as_bytes(), - ); - } - DataType::Array => { - hasher.write_byte(5); - let array = zval - .array() - .ok_or_else(|| php_error("Grammar array value is invalid"))?; - hash_grammar_array(hasher, array)?; - } - _ => return Err(php_error("Unsupported grammar cache value")), - } +fn cached_native_grammar(grammar: &Zval) -> PhpResult>> { + let object = grammar + .object() + .ok_or_else(|| php_error("Parser grammar must be an object"))?; + let properties = object.get_properties().map_err(php_error)?; + let Some(native_grammar) = properties.get("native_grammar") else { + return Ok(None); + }; + let Some(native_grammar) = <&WpMySqlNativeGrammar as FromZval>::from_zval(native_grammar) + else { + return Ok(None); + }; - Ok(()) + Ok(Some(Arc::clone(&native_grammar.grammar))) } -fn hash_grammar_array_key(hasher: &mut GrammarCacheHasher, key: ArrayKey<'_>) { - match key { - ArrayKey::Long(value) => { - hasher.write_byte(0); - hasher.write_i64(value); - } - ArrayKey::String(value) => { - hasher.write_byte(1); - hasher.write_bytes(value.as_bytes()); - } - ArrayKey::Str(value) => { - hasher.write_byte(1); - hasher.write_bytes(value.as_bytes()); - } - } +fn cache_native_grammar(grammar_zval: &mut Zval, grammar: Arc) -> PhpResult<()> { + let object = grammar_zval + .object_mut() + .ok_or_else(|| php_error("Parser grammar must be an object"))?; + let native_grammar = WpMySqlNativeGrammar { grammar } + .into_zval(false) + .map_err(php_error)?; + object + .set_property("native_grammar", native_grammar) + .map_err(php_error) } fn export_tokens(tokens: &mut Zval) -> PhpResult<(ParserTokenSource, Vec)> { @@ -2028,6 +2018,7 @@ extern "C" fn php_module_info(_module: *mut ModuleEntry) { #[php_module] pub fn get_module(module: ModuleBuilder) -> ModuleBuilder { module + .class::() .class::() .class::() .class::()