From cd2c37c0a3b9a58fab1b32c8095a4fd44a2fa726 Mon Sep 17 00:00:00 2001 From: adamziel Date: Wed, 17 Jun 2026 01:17:42 +0000 Subject: [PATCH 01/37] Add SQLancer fuzz regressions --- bin/run-sqlancer-sqlite-fuzz.sh | 133 ++++++++++++++++++ .../sqlite/class-wp-pdo-mysql-on-sqlite.php | 119 +++++++++++++--- ...s-wp-sqlite-information-schema-builder.php | 8 +- ...s-wp-sqlite-pdo-user-defined-functions.php | 30 ++++ .../tests/WP_SQLite_Driver_Tests.php | 104 +++++++++++++- .../specs/sqlancer-fuzz-regressions.test.js | 133 ++++++++++++++++++ tests/fuzz/replay-sqlancer-log.php | 95 +++++++++++++ 7 files changed, 599 insertions(+), 23 deletions(-) create mode 100755 bin/run-sqlancer-sqlite-fuzz.sh create mode 100644 tests/e2e/specs/sqlancer-fuzz-regressions.test.js create mode 100644 tests/fuzz/replay-sqlancer-log.php diff --git a/bin/run-sqlancer-sqlite-fuzz.sh b/bin/run-sqlancer-sqlite-fuzz.sh new file mode 100755 index 000000000..14e2511f9 --- /dev/null +++ b/bin/run-sqlancer-sqlite-fuzz.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SQLANCER_DIR="${SQLANCER_DIR:-/tmp/sqlancer}" +SQLANCER_REPO="${SQLANCER_REPO:-https://github.com/sqlancer/sqlancer.git}" +SQLANCER_IMAGE="${SQLANCER_IMAGE:-maven:3.9-eclipse-temurin-21}" +MYSQL_IMAGE="${MYSQL_IMAGE:-mysql:8.4}" +MYSQL_PASSWORD="${MYSQL_PASSWORD:-sqlancer}" +RANDOM_SEED="${RANDOM_SEED:-20260617}" +NUM_QUERIES="${NUM_QUERIES:-200}" +MAX_GENERATED_DATABASES="${MAX_GENERATED_DATABASES:-1}" +DATABASE_PREFIX="${DATABASE_PREFIX:-sdi_fuzz}" +ARTIFACTS_DIR="${ARTIFACTS_DIR:-/tmp/sdi-sqlancer-artifacts/$(date -u +%Y%m%d-%H%M%S)}" + +NETWORK="sdi-sqlancer-$$" +MYSQL_CONTAINER="sdi-sqlancer-mysql-$$" +CURRENT_DB="" +SKIP_ARGS=() + +cleanup() { + docker rm -f "$MYSQL_CONTAINER" >/dev/null 2>&1 || true + docker network rm "$NETWORK" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +if [ ! -f "$SQLANCER_DIR/pom.xml" ]; then + git clone --depth 1 "$SQLANCER_REPO" "$SQLANCER_DIR" +fi + +SQLANCER_JAR="$(find "$SQLANCER_DIR/target" -maxdepth 1 -type f -name 'sqlancer-*.jar' 2>/dev/null | sort | head -n 1 || true)" +if [ -z "$SQLANCER_JAR" ]; then + docker run --rm \ + -v "$SQLANCER_DIR:/src" \ + -w /src \ + "$SQLANCER_IMAGE" \ + mvn -DskipTests package + SQLANCER_JAR="$(find "$SQLANCER_DIR/target" -maxdepth 1 -type f -name 'sqlancer-*.jar' | sort | head -n 1)" +fi + +mkdir -p "$ARTIFACTS_DIR" +docker run --rm \ + -v "$SQLANCER_DIR:/sqlancer" \ + -w /sqlancer \ + "$SQLANCER_IMAGE" \ + sh -c 'rm -rf logs/mysql' + +docker network create "$NETWORK" >/dev/null +docker run -d --rm \ + --name "$MYSQL_CONTAINER" \ + --network "$NETWORK" \ + -e MYSQL_ROOT_PASSWORD="$MYSQL_PASSWORD" \ + -e MYSQL_ROOT_HOST=% \ + "$MYSQL_IMAGE" \ + --mysql-native-password=ON >/dev/null + +for _ in $(seq 1 60); do + if docker run --rm --network "$NETWORK" "$MYSQL_IMAGE" mysqladmin ping -h"$MYSQL_CONTAINER" -uroot -p"$MYSQL_PASSWORD" --silent >/dev/null 2>&1; then + break + fi + sleep 1 +done + +docker run --rm --network "$NETWORK" "$MYSQL_IMAGE" mysqladmin ping -h"$MYSQL_CONTAINER" -uroot -p"$MYSQL_PASSWORD" --silent >/dev/null + +docker run --rm \ + --network "$NETWORK" \ + -v "$SQLANCER_DIR:/sqlancer" \ + -w /sqlancer \ + "$SQLANCER_IMAGE" \ + java -jar "target/$(basename "$SQLANCER_JAR")" \ + --num-threads 1 \ + --num-queries "$NUM_QUERIES" \ + --max-generated-databases "$MAX_GENERATED_DATABASES" \ + --num-tries 1 \ + --database-prefix "$DATABASE_PREFIX" \ + --random-seed "$RANDOM_SEED" \ + --username root \ + --password "$MYSQL_PASSWORD" \ + --host "$MYSQL_CONTAINER" \ + --port 3306 \ + mysql --oracle FUZZER + +LOG_FILE="$(find "$SQLANCER_DIR/logs/mysql" -maxdepth 1 -type f -name '*-cur.log' | sort | head -n 1)" +if [ -z "$LOG_FILE" ]; then + echo "No SQLancer MySQL log was generated." >&2 + exit 1 +fi + +cp "$LOG_FILE" "$ARTIFACTS_DIR/" +LOG_FILE="$ARTIFACTS_DIR/$(basename "$LOG_FILE")" +MYSQL_FAILURES_FILE="$ARTIFACTS_DIR/mysql-rejected-lines.txt" +: > "$MYSQL_FAILURES_FILE" + +LINE_NUMBER=0 +while IFS= read -r LINE || [ -n "$LINE" ]; do + LINE_NUMBER=$(( LINE_NUMBER + 1 )) + SQL="$(php -r ' + $line = trim(stream_get_contents(STDIN)); + if ($line === "" || strpos($line, "--") === 0) { + exit; + } + echo preg_replace("/;\s*--\s*\d+ms;?$/", ";", $line); + ' <<< "$LINE")" + + if [ -z "$SQL" ]; then + continue + fi + + if [[ "$SQL" =~ ^[Uu][Ss][Ee][[:space:]]+([^[:space:];]+) ]]; then + CURRENT_DB="${BASH_REMATCH[1]}" + continue + fi + + TMP_SQL="$(mktemp)" + printf '%s\n' "$SQL" > "$TMP_SQL" + if [ -n "$CURRENT_DB" ]; then + MYSQL_ARGS=( --database="$CURRENT_DB" ) + else + MYSQL_ARGS=() + fi + + if ! docker exec -i "$MYSQL_CONTAINER" mysql -uroot -p"$MYSQL_PASSWORD" --batch --raw "${MYSQL_ARGS[@]}" < "$TMP_SQL" >/dev/null 2>&1; then + printf '%s\n' "$LINE_NUMBER" >> "$MYSQL_FAILURES_FILE" + SKIP_ARGS+=( "--skip-line=$LINE_NUMBER" ) + fi + rm -f "$TMP_SQL" +done < "$LOG_FILE" + +php "$ROOT_DIR/tests/fuzz/replay-sqlancer-log.php" "$LOG_FILE" "${SKIP_ARGS[@]}" + +printf 'SQLancer log: %s\n' "$LOG_FILE" +printf 'MySQL-rejected line list: %s\n' "$MYSQL_FAILURES_FILE" 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 7130fb631..408bbbd56 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 @@ -1881,6 +1881,7 @@ private function execute_select_statement( WP_Parser_Node $node ): void { private function execute_insert_or_replace_statement( WP_Parser_Node $node ): void { $parts = array(); $on_conflict_update_list = null; + $ignore_errors = $node->has_child_token( WP_MySQL_Lexer::IGNORE_SYMBOL ); foreach ( $node->get_children() as $child ) { $is_token = $child instanceof WP_MySQL_Token; $is_node = $child instanceof WP_Parser_Node; @@ -1897,13 +1898,25 @@ private function execute_insert_or_replace_statement( WP_Parser_Node $node ): vo } } - // Skip the SET keyword in "INSERT INTO ... SET ..." syntax. - if ( $is_token && WP_MySQL_Lexer::SET_SYMBOL === $child->id ) { + // Skip the SET keyword in "INSERT INTO ... SET ..." syntax and + // the legacy DELAYED modifier that MySQL now treats as a no-op. + if ( + $is_token + && ( + WP_MySQL_Lexer::SET_SYMBOL === $child->id + || WP_MySQL_Lexer::DELAYED_SYMBOL === $child->id + || WP_MySQL_Lexer::LOW_PRIORITY_SYMBOL === $child->id + || WP_MySQL_Lexer::HIGH_PRIORITY_SYMBOL === $child->id + ) + ) { + continue; + } + if ( $is_node && 'insertLockOption' === $child->rule_name ) { continue; } if ( $is_token && WP_MySQL_Lexer::IGNORE_SYMBOL === $child->id ) { - // Translate "UPDATE IGNORE" to "UPDATE OR IGNORE". + // Translate "INSERT IGNORE" to "INSERT OR IGNORE". $parts[] = 'OR IGNORE'; } elseif ( $is_node @@ -1915,7 +1928,7 @@ private function execute_insert_or_replace_statement( WP_Parser_Node $node ): vo ) { $table_ref = $node->get_first_child_node( 'tableRef' ); $table_name = $this->unquote_sqlite_identifier( $this->translate( $table_ref ) ); - $parts[] = $this->translate_insert_or_replace_body( $table_name, $child ); + $parts[] = $this->translate_insert_or_replace_body( $table_name, $child, $ignore_errors ); } elseif ( $is_node && 'insertUpdateList' === $child->rule_name ) { /* * Translate "ON DUPLICATE KEY UPDATE" to "ON CONFLICT DO UPDATE SET". @@ -2372,7 +2385,13 @@ private function execute_delete_statement( WP_Parser_Node $node ): void { return; } - $query = $this->translate( $node ); + $query = 'DELETE FROM ' . $this->translate_sequence( + array( + $table_ref, + $node->get_first_child_node( 'tableAlias' ), + $node->get_first_child_node( 'whereClause' ), + ) + ); $this->last_result_statement = $this->execute_sqlite_query( $query ); } @@ -3504,7 +3523,8 @@ private function execute_set_system_variable_statement( $this->session_system_variables[ $name ] = $value; } } elseif ( WP_MySQL_Lexer::GLOBAL_SYMBOL === $type ) { - throw $this->new_not_supported_exception( "SET statement type: 'GLOBAL'" ); + // MySQL accepts many GLOBAL server variable assignments. SQLite has + // no server-global state, so accept them as no-ops for compatibility. } elseif ( WP_MySQL_Lexer::PERSIST_SYMBOL === $type ) { throw $this->new_not_supported_exception( "SET statement type: 'PERSIST'" ); } elseif ( WP_MySQL_Lexer::PERSIST_ONLY_SYMBOL === $type ) { @@ -3717,6 +3737,8 @@ private function translate( $node ): ?string { $rule_name = $node->rule_name; switch ( $rule_name ) { + case 'expr': + return $this->translate_expr( $node ); case 'queryExpression': return $this->translate_query_expression( $node ); case 'querySpecification': @@ -3942,6 +3964,32 @@ private function translate( $node ): ?string { } } + /** + * Translate a MySQL expression to SQLite. + * + * @param WP_Parser_Node $node The "expr" AST node. + * @return string|null The translated expression. + */ + private function translate_expr( WP_Parser_Node $node ): ?string { + $children = $node->get_children(); + if ( + 3 === count( $children ) + && $children[1] instanceof WP_MySQL_Token + && WP_MySQL_Lexer::XOR_SYMBOL === $children[1]->id + ) { + $left = $this->translate( $children[0] ); + $right = $this->translate( $children[2] ); + + return sprintf( + '((%1$s) OR (%2$s)) AND NOT ((%1$s) AND (%2$s))', + $left, + $right + ); + } + + return $this->translate_sequence( $children ); + } + /** * Translate a MySQL token to SQLite. * @@ -4003,6 +4051,15 @@ private function translate_token( WP_MySQL_Token $token ): ?string { * statement translation and then removed from the output here. */ return null; + case WP_MySQL_Lexer::DISTINCT_SYMBOL: + if ( 'DISTINCTROW' === strtoupper( $token->get_value() ) ) { + return 'DISTINCT'; + } + return $token->get_value(); + case WP_MySQL_Lexer::LOGICAL_AND_OPERATOR: + return 'AND'; + case WP_MySQL_Lexer::LOGICAL_NOT_OPERATOR: + return 'NOT'; default: return $token->get_value(); } @@ -4644,7 +4701,7 @@ private function translate_function_call( WP_Parser_Node $node ): string { ); return $this->quote_sqlite_value( $value ); default: - return $this->translate_sequence( $node->get_children() ); + return sprintf( '%s(%s)', strtolower( $name ), implode( ', ', $args ) ); } } @@ -5226,16 +5283,18 @@ private function translate_show_like_or_where_condition( WP_Parser_Node $like_or */ private function translate_insert_or_replace_body( string $table_name, - WP_Parser_Node $node + WP_Parser_Node $node, + bool $ignore_errors = false ): string { // This method is always used with the main database. $database = $this->get_saved_db_name( $this->main_db_name ); // Check if strict mode is enabled. - $is_strict_mode = ( + $is_strict_mode = ( $this->is_sql_mode_active( 'STRICT_TRANS_TABLES' ) || $this->is_sql_mode_active( 'STRICT_ALL_TABLES' ) ); + $use_implicit_defaults = ! $is_strict_mode || $ignore_errors; // Get column metadata for the target table from the information schema. $is_temporary = $this->information_schema_builder->temporary_table_exists( $table_name ); @@ -5312,12 +5371,12 @@ private function translate_insert_or_replace_body( $columns = array_values( array_filter( $columns, - function ( $column ) use ( $is_strict_mode, $insert_map ) { + function ( $column ) use ( $use_implicit_defaults, $insert_map ) { $is_omitted = ! isset( $insert_map[ $column['COLUMN_NAME'] ] ); if ( ! $is_omitted ) { return true; } - if ( $is_strict_mode ) { + if ( ! $use_implicit_defaults ) { return false; } $is_nullable = 'YES' === $column['IS_NULLABLE']; @@ -5404,7 +5463,7 @@ function ( $column ) use ( $is_strict_mode, $insert_map ) { // When a column value is included, we need to apply type casting. $position = array_search( $column['COLUMN_NAME'], $insert_list, true ); $identifier = $this->quote_sqlite_identifier( $select_list[ $position ] ); - $value = $this->cast_value_for_saving( $column['DATA_TYPE'], $identifier ); + $value = $this->cast_value_for_saving( $column['DATA_TYPE'], $identifier, $ignore_errors ); $is_auto_increment = str_contains( $column['EXTRA'], 'auto_increment' ); /* @@ -5433,7 +5492,7 @@ function ( $column ) use ( $is_strict_mode, $insert_map ) { */ $is_insert_from_select = 'insertQueryExpression' === $node->rule_name; if ( - ! $is_strict_mode + $use_implicit_defaults && ! $is_auto_increment && $is_insert_from_select && 'NO' === $column['IS_NULLABLE'] @@ -5931,7 +5990,8 @@ private function create_table_reference_map( WP_Parser_Node $node ): array { */ private function cast_value_for_saving( string $mysql_data_type, - string $translated_value + string $translated_value, + bool $ignore_errors = false ): string { // TODO: This is also a good place to implement checks for maximum column // lengths with truncating or bailing out depending on the SQL mode. @@ -6005,7 +6065,7 @@ private function cast_value_for_saving( // In strict mode, invalid date/time values are rejected. // In non-strict mode, they get an IMPLICIT DEFAULT value. - if ( $is_strict_mode ) { + if ( $is_strict_mode && ! $ignore_errors ) { $fallback = sprintf( "THROW('Incorrect %s value: ''' || %s || '''')", $mysql_data_type, @@ -6069,7 +6129,34 @@ private function cast_value_for_saving( * all special cases. We may improve this further to accept * BLOBs for numeric types, and other special behaviors. */ - if ( ! $is_strict_mode || 'TEXT' === $sqlite_data_type || 'BLOB' === $sqlite_data_type ) { + $is_integer_type = in_array( + $mysql_data_type, + array( + 'bit', + 'bool', + 'boolean', + 'tinyint', + 'smallint', + 'mediumint', + 'int', + 'integer', + 'bigint', + ), + true + ); + if ( $is_integer_type ) { + if ( ! $is_strict_mode || $ignore_errors ) { + return sprintf( 'CAST(ROUND(%s) AS INTEGER)', $translated_value ); + } + return sprintf( + "CASE WHEN TYPEOF(%s) IN ('integer', 'real') THEN CAST(ROUND(%s) AS INTEGER) ELSE %s END", + $translated_value, + $translated_value, + $translated_value + ); + } + + if ( ! $is_strict_mode || $ignore_errors || 'TEXT' === $sqlite_data_type || 'BLOB' === $sqlite_data_type ) { return sprintf( 'CAST(%s AS %s)', $translated_value, $sqlite_data_type ); } return $translated_value; diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-information-schema-builder.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-information-schema-builder.php index 8e84a9f24..e58de0b89 100644 --- a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-information-schema-builder.php +++ b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-information-schema-builder.php @@ -1557,7 +1557,7 @@ private function extract_column_statistics_data( bool $nullable ): ?array { // Handle inline PRIMARY KEY and UNIQUE constraints. - $has_inline_primary_key = null !== $node->get_first_descendant_token( WP_MySQL_Lexer::KEY_SYMBOL ); + $has_inline_primary_key = null !== $node->get_first_descendant_token( WP_MySQL_Lexer::PRIMARY_SYMBOL ); $has_inline_unique_key = null !== $node->get_first_descendant_token( WP_MySQL_Lexer::UNIQUE_SYMBOL ); if ( $has_inline_primary_key || $has_inline_unique_key ) { $index_name = $has_inline_primary_key ? 'PRIMARY' : $column_name; @@ -2114,7 +2114,7 @@ private function get_column_nullable( WP_Parser_Node $node ): string { foreach ( $node->get_descendant_nodes( 'columnAttribute' ) as $attr ) { // PRIMARY KEY columns are always NOT NULL. - if ( $attr->has_child_token( WP_MySQL_Lexer::KEY_SYMBOL ) ) { + if ( $attr->has_child_token( WP_MySQL_Lexer::PRIMARY_SYMBOL ) ) { return 'NO'; } @@ -2137,9 +2137,7 @@ private function get_column_nullable( WP_Parser_Node $node ): string { */ private function get_column_key( WP_Parser_Node $node ): string { // 1. PRI: Column is a primary key or its any component. - if ( - null !== $node->get_first_descendant_token( WP_MySQL_Lexer::KEY_SYMBOL ) - ) { + if ( null !== $node->get_first_descendant_token( WP_MySQL_Lexer::PRIMARY_SYMBOL ) ) { return 'PRI'; } diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-pdo-user-defined-functions.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-pdo-user-defined-functions.php index 7abd5add8..c73d35a1f 100644 --- a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-pdo-user-defined-functions.php +++ b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-pdo-user-defined-functions.php @@ -84,6 +84,7 @@ public static function register_for( $pdo ): self { 'to_base64' => 'to_base64', 'inet_ntoa' => 'inet_ntoa', 'inet_aton' => 'inet_aton', + 'bit_count' => 'bit_count', 'datediff' => 'datediff', 'locate' => 'locate', 'utc_date' => 'utc_date', @@ -199,6 +200,29 @@ public function md5( $field ) { return md5( $field ); } + /** + * Emulate MySQL BIT_COUNT(). + * + * @param int|float|string|null $value Value to inspect. + * + * @return int|null Number of set bits, or null for null input. + */ + public function bit_count( $value ) { + if ( null === $value ) { + return null; + } + + $value = (int) $value; + $count = 0; + for ( $i = 0; $i < 64; ++$i ) { + if ( 0 !== ( $value & 1 ) ) { + ++$count; + } + $value >>= 1; + } + return $count; + } + /** * Method to emulate MySQL's seeded RAND(N) function. * @@ -693,6 +717,9 @@ public function log() { */ public function least() { $arg_list = func_get_args(); + if ( in_array( null, $arg_list, true ) ) { + return null; + } return min( $arg_list ); } @@ -706,6 +733,9 @@ public function least() { */ public function greatest() { $arg_list = func_get_args(); + if ( in_array( null, $arg_list, true ) ) { + return null; + } return max( $arg_list ); } diff --git a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php index 18a0424c9..a80fcc1a3 100644 --- a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php @@ -10551,6 +10551,106 @@ public function testInsertWithoutInto(): void { $this->assertEquals( 'c', $res[2]->name ); } + public function testSqlancerInsertIgnoreCoercesInvalidNumericValues(): void { + $this->assertQuery( 'CREATE TABLE t0(c0 DECIMAL ZEROFILL COLUMN_FORMAT DEFAULT)' ); + + $is_legacy_sqlite = version_compare( $this->engine->get_sqlite_version(), WP_PDO_MySQL_On_SQLite::MINIMUM_SQLITE_VERSION, '<' ); + if ( ! $is_legacy_sqlite ) { + $this->assertQueryError( + 'INSERT INTO t0(c0) VALUES("ds")', + 'SQLSTATE[23000]: Integrity constraint violation: 19 cannot store TEXT value in REAL column t0.c0' + ); + } + + $this->assertQuery( 'INSERT IGNORE INTO t0(c0) VALUES("ds")' ); + $result = $this->assertQuery( 'SELECT c0, c0 + 0 AS numeric_value FROM t0' ); + + $this->assertCount( 1, $result ); + $this->assertSame( 0.0, (float) $result[0]->numeric_value ); + + $this->assertQuery( 'INSERT DELAYED IGNORE INTO t0(c0) VALUES(0.23742865885084252)' ); + $result = $this->assertQuery( 'SELECT COUNT(*) AS rows_count FROM t0' ); + + $this->assertSame( '2', $result[0]->rows_count ); + } + + public function testSqlancerDeleteIgnoreDropsIgnoredRows(): void { + $this->assertQuery( 'CREATE TABLE t0(c0 DECIMAL)' ); + $this->assertQuery( 'INSERT INTO t0(c0) VALUES(1), (2)' ); + + $this->assertQuery( 'DELETE IGNORE FROM t0 WHERE c0 = 1' ); + $result = $this->assertQuery( 'SELECT c0 FROM t0 ORDER BY c0' ); + + $this->assertCount( 1, $result ); + $this->assertSame( 2.0, (float) $result[0]->c0 ); + } + + public function testSqlancerDeleteWithBitCountPredicate(): void { + $result = $this->assertQuery( 'SELECT BIT_COUNT(NULL) AS null_bits, BIT_COUNT(0) AS zero_bits, BIT_COUNT(7) AS seven_bits, BIT_COUNT(-1) AS negative_bits' ); + + $this->assertNull( $result[0]->null_bits ); + $this->assertSame( '0', $result[0]->zero_bits ); + $this->assertSame( '3', $result[0]->seven_bits ); + $this->assertSame( '64', $result[0]->negative_bits ); + + $this->assertQuery( "CREATE TABLE t0(c0 INT(95) ZEROFILL UNIQUE KEY COMMENT 'asdf' STORAGE DISK COLUMN_FORMAT FIXED)" ); + $this->assertQuery( 'INSERT INTO t0(c0) VALUES(1), (2)' ); + + $this->assertQuery( 'DELETE LOW_PRIORITY FROM t0 WHERE BIT_COUNT(NULL)' ); + $result = $this->assertQuery( 'SELECT COUNT(*) AS rows_count FROM t0' ); + + $this->assertSame( '2', $result[0]->rows_count ); + } + + public function testSqlancerSetGlobalServerVariableIsAccepted(): void { + $this->assertQuery( 'SET GLOBAL myisam_sort_buffer_size = 5931344759664966748' ); + $result = $this->assertQuery( 'SELECT 1 AS still_connected' ); + + $this->assertSame( '1', $result[0]->still_connected ); + } + + public function testSqlancerReplaceLowPriorityDropsInsertModifier(): void { + $this->assertQuery( 'CREATE TABLE t0(c0 INT)' ); + $this->assertQuery( 'REPLACE LOW_PRIORITY INTO t0(c0) VALUES(0.8086755056097884), (0.16838264227471722), (0.7427700179628559)' ); + $result = $this->assertQuery( 'SELECT GROUP_CONCAT(c0 ORDER BY rowid) AS saved_values FROM t0' ); + + $this->assertSame( '1,0,1', $result[0]->saved_values ); + } + + public function testSqlancerInlineUniqueKeyAllowsReplaceNullValues(): void { + $this->assertQuery( "CREATE TABLE t0(c0 INT(95) ZEROFILL UNIQUE KEY COMMENT 'asdf' STORAGE DISK COLUMN_FORMAT FIXED)" ); + + $columns = $this->assertQuery( 'SHOW COLUMNS FROM t0' ); + $this->assertSame( 'YES', $columns[0]->Null ); + $this->assertSame( 'UNI', $columns[0]->Key ); + + $this->assertQuery( 'REPLACE INTO t0(c0) VALUES(NULL)' ); + $result = $this->assertQuery( 'SELECT COUNT(*) AS rows_count, SUM(c0 IS NULL) AS null_rows FROM t0' ); + + $this->assertSame( '1', $result[0]->rows_count ); + $this->assertSame( '1', $result[0]->null_rows ); + } + + public function testSqlancerGreatestFunctionCallUsesUdf(): void { + $this->assertQuery( 'CREATE TABLE t0(c0 INT)' ); + $this->assertQuery( 'INSERT INTO t0(c0) VALUES(1), (2)' ); + + $result = $this->assertQuery( 'SELECT DISTINCTROW MAX(GREATEST(NULL, t0.c0)) AS greatest_with_null, LEAST(NULL, 1) AS least_with_null FROM t0' ); + + $this->assertNull( $result[0]->greatest_with_null ); + $this->assertNull( $result[0]->least_with_null ); + } + + public function testSqlancerBooleanOperators(): void { + $result = $this->assertQuery( 'SELECT (2 XOR 3) AS both_true, (1 XOR 0) AS one_true, (1 && 0) AS and_symbol, (! 1) AS not_symbol, (NULL XOR 1) AS null_xor' ); + + $this->assertSame( '0', $result[0]->both_true ); + $this->assertSame( '1', $result[0]->one_true ); + $this->assertSame( '0', $result[0]->and_symbol ); + $this->assertSame( '0', $result[0]->not_symbol ); + $this->assertNull( $result[0]->null_xor ); + } + public function testInsertIntoSetSyntax(): void { $this->assertQuery( 'CREATE TABLE t ( @@ -11371,7 +11471,7 @@ public function testCastValuesOnInsertInNonStrictMode(): void { $this->assertSame( '1', $result[4]->value ); $this->assertSame( '2', $result[5]->value ); $this->assertSame( '3', $result[6]->value ); - $this->assertSame( '4', $result[7]->value ); // TODO: 5 in MySQL + $this->assertSame( '5', $result[7]->value ); $this->assertSame( '0', $result[8]->value ); // TODO: 5 in MySQL $this->assertSame( '0', $result[9]->value ); // TODO: 6 in MySQL $this->assertQuery( 'DROP TABLE t' ); @@ -11936,7 +12036,7 @@ public function testCastValuesOnUpdateInNonStrictMode(): void { $this->assertSame( '3', $this->assertQuery( 'SELECT * FROM t' )[0]->value ); $this->assertQuery( "UPDATE t SET value = '4.5'" ); - $this->assertSame( '4', $this->assertQuery( 'SELECT * FROM t' )[0]->value ); // TODO: 5 in MySQL + $this->assertSame( '5', $this->assertQuery( 'SELECT * FROM t' )[0]->value ); $this->assertQuery( 'UPDATE t SET value = 0x05' ); $this->assertSame( '0', $this->assertQuery( 'SELECT * FROM t' )[0]->value ); // TODO: 5 in MySQL diff --git a/tests/e2e/specs/sqlancer-fuzz-regressions.test.js b/tests/e2e/specs/sqlancer-fuzz-regressions.test.js new file mode 100644 index 000000000..1f310581d --- /dev/null +++ b/tests/e2e/specs/sqlancer-fuzz-regressions.test.js @@ -0,0 +1,133 @@ +/** + * External dependencies + */ +import { execFileSync } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** + * WordPress dependencies + */ +import { test, expect } from '@wordpress/e2e-test-utils-playwright'; + +const repoRoot = path.resolve( + path.dirname( fileURLToPath( import.meta.url ) ), + '../../..' +); +const wordpressPath = path.join( repoRoot, 'wordpress' ); + +test.describe( 'SQLancer fuzz regressions', () => { + test( 'replays reduced INSERT and DELETE modifier failures', () => { + const output = execFileSync( + 'npm', + [ + '--prefix', + wordpressPath, + 'run', + 'env:cli', + '--', + 'eval', + ` +global $wpdb; + +$table = $wpdb->prefix . 'sqlancer_t0'; + +function sdi_sqlancer_query( $sql ) { + global $wpdb; + + $result = $wpdb->query( $sql ); + if ( false === $result ) { + throw new RuntimeException( $wpdb->last_error . ' for query: ' . $sql ); + } +} + +sdi_sqlancer_query( "DROP TABLE IF EXISTS $table" ); +sdi_sqlancer_query( "CREATE TABLE $table(c0 DECIMAL ZEROFILL COLUMN_FORMAT DEFAULT)" ); +sdi_sqlancer_query( "INSERT IGNORE INTO $table(c0) VALUES(\\"ds\\")" ); + +$row = $wpdb->get_row( "SELECT c0 + 0 AS numeric_value FROM $table", ARRAY_A ); +if ( 0.0 !== (float) $row['numeric_value'] ) { + throw new RuntimeException( 'Expected INSERT IGNORE invalid decimal value to coerce to zero.' ); +} + +sdi_sqlancer_query( "INSERT DELAYED IGNORE INTO $table(c0) VALUES(0.23742865885084252)" ); +$row = $wpdb->get_row( "SELECT COUNT(*) AS rows_count FROM $table", ARRAY_A ); +if ( '2' !== (string) $row['rows_count'] ) { + throw new RuntimeException( 'Expected INSERT DELAYED IGNORE to insert a second row.' ); +} + +sdi_sqlancer_query( "DROP TABLE IF EXISTS $table" ); +sdi_sqlancer_query( "CREATE TABLE $table(c0 DECIMAL)" ); +sdi_sqlancer_query( "INSERT INTO $table(c0) VALUES(1), (2)" ); +sdi_sqlancer_query( "DELETE IGNORE FROM $table WHERE c0 = 1" ); + +$row = $wpdb->get_row( "SELECT COUNT(*) AS rows_count, SUM(c0 + 0) AS numeric_sum FROM $table", ARRAY_A ); +sdi_sqlancer_query( "DROP TABLE IF EXISTS $table" ); +sdi_sqlancer_query( "CREATE TABLE $table(c0 INT(95) ZEROFILL UNIQUE KEY COMMENT 'asdf' STORAGE DISK COLUMN_FORMAT FIXED)" ); +sdi_sqlancer_query( "INSERT INTO $table(c0) VALUES(1), (2)" ); +sdi_sqlancer_query( "DELETE LOW_PRIORITY FROM $table WHERE BIT_COUNT(NULL)" ); +sdi_sqlancer_query( "SET GLOBAL myisam_sort_buffer_size = 5931344759664966748" ); +sdi_sqlancer_query( "REPLACE LOW_PRIORITY INTO $table(c0) VALUES(3), (4), (5)" ); +sdi_sqlancer_query( "REPLACE INTO $table(c0) VALUES(NULL)" ); + +$bit_count_row = $wpdb->get_row( "SELECT DISTINCTROW COUNT(*) AS rows_count, BIT_COUNT(-1) AS negative_bits, MAX(GREATEST(NULL, c0)) AS greatest_with_null FROM $table", ARRAY_A ); +$boolean_row = $wpdb->get_row( "SELECT (2 XOR 3) AS both_true, (1 XOR 0) AS one_true, (1 && 0) AS and_symbol, (! 1) AS not_symbol, (NULL XOR 1) AS null_xor", ARRAY_A ); +echo 'SQLANCER_JSON:' . wp_json_encode( $row ) . PHP_EOL; +echo 'SQLANCER_BIT_COUNT_JSON:' . wp_json_encode( $bit_count_row ) . PHP_EOL; +echo 'SQLANCER_BOOLEAN_JSON:' . wp_json_encode( $boolean_row ) . PHP_EOL; +`, + ], + { + cwd: repoRoot, + encoding: 'utf8', + } + ); + + const jsonLine = output + .trim() + .split( /\r?\n/ ) + .find( ( line ) => line.startsWith( 'SQLANCER_JSON:' ) ); + + expect( jsonLine ).toBeTruthy(); + expect( JSON.parse( jsonLine.replace( 'SQLANCER_JSON:', '' ) ) ).toEqual( + { + rows_count: '1', + numeric_sum: '2', + } + ); + + const bitCountJsonLine = output + .trim() + .split( /\r?\n/ ) + .find( ( line ) => line.startsWith( 'SQLANCER_BIT_COUNT_JSON:' ) ); + + expect( bitCountJsonLine ).toBeTruthy(); + expect( + JSON.parse( + bitCountJsonLine.replace( 'SQLANCER_BIT_COUNT_JSON:', '' ) + ) + ).toEqual( { + rows_count: '6', + negative_bits: '64', + greatest_with_null: null, + } ); + + const booleanJsonLine = output + .trim() + .split( /\r?\n/ ) + .find( ( line ) => line.startsWith( 'SQLANCER_BOOLEAN_JSON:' ) ); + + expect( booleanJsonLine ).toBeTruthy(); + expect( + JSON.parse( + booleanJsonLine.replace( 'SQLANCER_BOOLEAN_JSON:', '' ) + ) + ).toEqual( { + both_true: '0', + one_true: '1', + and_symbol: '0', + not_symbol: '0', + null_xor: null, + } ); + } ); +} ); diff --git a/tests/fuzz/replay-sqlancer-log.php b/tests/fuzz/replay-sqlancer-log.php new file mode 100644 index 000000000..a2de108de --- /dev/null +++ b/tests/fuzz/replay-sqlancer-log.php @@ -0,0 +1,95 @@ + [--skip-line=N ...]\n" ); + exit( 1 ); +} + +require_once dirname( __DIR__, 2 ) . '/packages/mysql-on-sqlite/tests/bootstrap.php'; + +$pdo_class = PHP_VERSION_ID >= 80400 ? PDO\SQLite::class : PDO::class; +$pdo = new $pdo_class( 'sqlite::memory:' ); +$driver = new WP_SQLite_Driver( + new WP_SQLite_Connection( array( 'pdo' => $pdo ) ), + 'wp' +); + +$line_number = 0; +foreach ( file( $log_file, FILE_IGNORE_NEW_LINES ) as $line ) { + ++$line_number; + + $sql = sqlancer_log_line_to_sql( $line ); + if ( null === $sql ) { + continue; + } + + if ( + isset( $skip_lines[ $line_number ] ) + || preg_match( '/^(DROP DATABASE|CREATE DATABASE|USE)\b/i', $sql ) + ) { + printf( "SKIP line %d: %s\n", $line_number, $sql ); + continue; + } + + try { + $driver->query( $sql ); + printf( "OK line %d: %s\n", $line_number, $sql ); + } catch ( Throwable $e ) { + fprintf( STDERR, "FAIL line %d: %s\n", $line_number, $sql ); + fprintf( STDERR, "%s: %s\n", get_class( $e ), $e->getMessage() ); + foreach ( $driver->get_last_sqlite_queries() as $query ) { + fprintf( + STDERR, + " SQLITE: %s PARAMS=%s\n", + $query['sql'], + json_encode( $query['params'] ) + ); + } + exit( 1 ); + } +} + +echo "REPLAY_OK\n"; + +/** + * Extract SQL from a SQLancer log line. + * + * @param string $line Log line. + * @return string|null SQL statement, or null for metadata/blank lines. + */ +function sqlancer_log_line_to_sql( $line ) { + $line = trim( $line ); + if ( '' === $line || 0 === strpos( $line, '--' ) ) { + return null; + } + + return preg_replace( '/;\s*--\s*\d+ms;?$/', ';', $line ); +} From 940a270919cb2c214e93c4fbcb4e5e5b48a1e3f1 Mon Sep 17 00:00:00 2001 From: adamziel Date: Wed, 17 Jun 2026 01:22:40 +0000 Subject: [PATCH 02/37] Handle MySQL IS UNKNOWN expressions --- .../src/sqlite/class-wp-pdo-mysql-on-sqlite.php | 14 ++++++++++++++ .../tests/WP_SQLite_Driver_Tests.php | 4 +++- tests/e2e/specs/sqlancer-fuzz-regressions.test.js | 4 +++- 3 files changed, 20 insertions(+), 2 deletions(-) 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 408bbbd56..5bf262ea2 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 @@ -3972,6 +3972,20 @@ private function translate( $node ): ?string { */ private function translate_expr( WP_Parser_Node $node ): ?string { $children = $node->get_children(); + $last = end( $children ); + if ( + count( $children ) >= 3 + && $children[1] instanceof WP_MySQL_Token + && WP_MySQL_Lexer::IS_SYMBOL === $children[1]->id + && $last instanceof WP_MySQL_Token + && WP_MySQL_Lexer::UNKNOWN_SYMBOL === $last->id + ) { + $left = $this->translate( $children[0] ); + $has_not = null !== $node->get_first_child_node( 'notRule' ); + + return sprintf( '(%s) IS %sNULL', $left, $has_not ? 'NOT ' : '' ); + } + if ( 3 === count( $children ) && $children[1] instanceof WP_MySQL_Token diff --git a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php index a80fcc1a3..54799eba6 100644 --- a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php @@ -10642,12 +10642,14 @@ public function testSqlancerGreatestFunctionCallUsesUdf(): void { } public function testSqlancerBooleanOperators(): void { - $result = $this->assertQuery( 'SELECT (2 XOR 3) AS both_true, (1 XOR 0) AS one_true, (1 && 0) AS and_symbol, (! 1) AS not_symbol, (NULL XOR 1) AS null_xor' ); + $result = $this->assertQuery( 'SELECT (2 XOR 3) AS both_true, (1 XOR 0) AS one_true, (1 && 0) AS and_symbol, (! 1) AS not_symbol, (NULL IS UNKNOWN) AS null_unknown, (1 IS NOT UNKNOWN) AS one_not_unknown, (NULL XOR 1) AS null_xor' ); $this->assertSame( '0', $result[0]->both_true ); $this->assertSame( '1', $result[0]->one_true ); $this->assertSame( '0', $result[0]->and_symbol ); $this->assertSame( '0', $result[0]->not_symbol ); + $this->assertSame( '1', $result[0]->null_unknown ); + $this->assertSame( '1', $result[0]->one_not_unknown ); $this->assertNull( $result[0]->null_xor ); } diff --git a/tests/e2e/specs/sqlancer-fuzz-regressions.test.js b/tests/e2e/specs/sqlancer-fuzz-regressions.test.js index 1f310581d..53e119e82 100644 --- a/tests/e2e/specs/sqlancer-fuzz-regressions.test.js +++ b/tests/e2e/specs/sqlancer-fuzz-regressions.test.js @@ -71,7 +71,7 @@ sdi_sqlancer_query( "REPLACE LOW_PRIORITY INTO $table(c0) VALUES(3), (4), (5)" ) sdi_sqlancer_query( "REPLACE INTO $table(c0) VALUES(NULL)" ); $bit_count_row = $wpdb->get_row( "SELECT DISTINCTROW COUNT(*) AS rows_count, BIT_COUNT(-1) AS negative_bits, MAX(GREATEST(NULL, c0)) AS greatest_with_null FROM $table", ARRAY_A ); -$boolean_row = $wpdb->get_row( "SELECT (2 XOR 3) AS both_true, (1 XOR 0) AS one_true, (1 && 0) AS and_symbol, (! 1) AS not_symbol, (NULL XOR 1) AS null_xor", ARRAY_A ); +$boolean_row = $wpdb->get_row( "SELECT (2 XOR 3) AS both_true, (1 XOR 0) AS one_true, (1 && 0) AS and_symbol, (! 1) AS not_symbol, (NULL IS UNKNOWN) AS null_unknown, (1 IS NOT UNKNOWN) AS one_not_unknown, (NULL XOR 1) AS null_xor", ARRAY_A ); echo 'SQLANCER_JSON:' . wp_json_encode( $row ) . PHP_EOL; echo 'SQLANCER_BIT_COUNT_JSON:' . wp_json_encode( $bit_count_row ) . PHP_EOL; echo 'SQLANCER_BOOLEAN_JSON:' . wp_json_encode( $boolean_row ) . PHP_EOL; @@ -127,6 +127,8 @@ echo 'SQLANCER_BOOLEAN_JSON:' . wp_json_encode( $boolean_row ) . PHP_EOL; one_true: '1', and_symbol: '0', not_symbol: '0', + null_unknown: '1', + one_not_unknown: '1', null_xor: null, } ); } ); From 890f3eab13e8f3e82111d841a83de868ccd94d06 Mon Sep 17 00:00:00 2001 From: adamziel Date: Wed, 17 Jun 2026 01:36:49 +0000 Subject: [PATCH 03/37] Handle SQLancer functional index cases --- .../sqlite/class-wp-pdo-mysql-on-sqlite.php | 50 +++++++++++++- ...s-wp-sqlite-information-schema-builder.php | 54 ++++++++++----- ...s-wp-sqlite-pdo-user-defined-functions.php | 66 ++++++++++++++++++- .../tests/WP_SQLite_Driver_Tests.php | 12 ++++ .../specs/sqlancer-fuzz-regressions.test.js | 34 ++++++++-- 5 files changed, 189 insertions(+), 27 deletions(-) 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 5bf262ea2..b0fadd6fa 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 @@ -2662,6 +2662,7 @@ private function execute_create_index_statement( WP_Parser_Node $node ): void { $is_unique = $create_index->has_child_token( WP_MySQL_Lexer::UNIQUE_SYMBOL ); // Get the key parts. + $key_parts = array(); $key_list_variants = $target->get_first_child_node( 'keyListVariants' ); $key_list_nodes = $key_list_variants->get_first_child_node()->get_child_nodes(); foreach ( $key_list_nodes as $key_list_node ) { @@ -2678,7 +2679,10 @@ private function execute_create_index_statement( WP_Parser_Node $node ): void { $key_part .= ' ' . $this->translate( $direction ); } } else { - $key_part = $this->translate( $key_part_node ); + $key_part = $this->dequalify_index_expression( + $this->translate( $key_part_node ), + $table_name + ); } $key_parts[] = $key_part; } @@ -2695,6 +2699,38 @@ private function execute_create_index_statement( WP_Parser_Node $node ): void { ); } + /** + * Translate a stored functional index expression for SQLite CREATE INDEX. + * + * @param string $expression The MySQL expression stored in information schema. + * @param string $table_name The table being indexed. + * @return string The translated SQLite expression. + */ + private function translate_stored_index_expression( string $expression, string $table_name ): string { + $ast = $this->create_parser( 'SELECT ' . $expression )->parse(); + $expr = $ast->get_first_descendant_node( 'selectItem' )->get_first_child_node(); + + return $this->dequalify_index_expression( $this->translate( $expr ), $table_name ); + } + + /** + * Remove the current table qualifier from an SQLite index expression. + * + * SQLite prohibits the "." operator in index expressions, while MySQL allows + * references such as t0.c0 in functional index definitions. + * + * @param string $expression The translated SQLite expression. + * @param string $table_name The table being indexed. + * @return string The expression with current-table qualifiers removed. + */ + private function dequalify_index_expression( string $expression, string $table_name ): string { + return str_replace( + $this->quote_sqlite_identifier( $table_name ) . '.', + '', + $expression + ); + } + /** * Translate and execute a MySQL DROP INDEX statement in SQLite. * @@ -6438,8 +6474,16 @@ private function get_sqlite_create_table_statement( $info = $constraint[1]; $column_list = array_map( - function ( $column ) { - $fragment = $this->quote_sqlite_identifier( $column['COLUMN_NAME'] ); + function ( $column ) use ( $table_name ) { + if ( null === $column['COLUMN_NAME'] ) { + $fragment = $this->translate_stored_index_expression( + $column['EXPRESSION'], + $table_name + ); + } else { + $fragment = $this->quote_sqlite_identifier( $column['COLUMN_NAME'] ); + } + if ( 'D' === $column['COLLATION'] ) { $fragment .= ' DESC'; } diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-information-schema-builder.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-information-schema-builder.php index e58de0b89..c4260624b 100644 --- a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-information-schema-builder.php +++ b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-information-schema-builder.php @@ -1648,7 +1648,9 @@ private function extract_index_statistics_data( // Get first index column data type (needed for index type). $first_column_name = $this->get_index_column_name( $key_parts[0] ); - $first_column_type = $column_info_map[ $first_column_name ]['DATA_TYPE'] ?? null; + $first_column_type = null === $first_column_name + ? null + : ( $column_info_map[ $first_column_name ]['DATA_TYPE'] ?? null ); $has_spatial_column = null !== $first_column_type && $this->is_spatial_data_type( $first_column_type ); $non_unique = $this->get_index_non_unique( $keyword ); @@ -1660,26 +1662,29 @@ private function extract_index_statistics_data( foreach ( $key_parts as $i => $key_part ) { $column_name = $key_part_column_names[ $i ]; $collation = $this->get_index_column_collation( $key_part, $index_type ); - $column_info = $column_info_map[ $column_name ] ?? null; + $column_info = null === $column_name ? null : ( $column_info_map[ $column_name ] ?? null ); - if ( null === $column_info ) { + if ( null === $column_name ) { + $nullable = 'YES'; + $sub_part = null; + } elseif ( null === $column_info ) { throw WP_SQLite_Information_Schema_Exception::key_column_not_found( $column_name ); - } - - if ( - 'PRIMARY' === $index_name - || 'NO' === $column_info_map[ $column_name ]['IS_NULLABLE'] - ) { - $nullable = ''; } else { - $nullable = 'YES'; - } + if ( + 'PRIMARY' === $index_name + || 'NO' === $column_info_map[ $column_name ]['IS_NULLABLE'] + ) { + $nullable = ''; + } else { + $nullable = 'YES'; + } - $sub_part = $this->get_index_column_sub_part( - $key_part, - $column_info_map[ $column_name ]['CHARACTER_MAXIMUM_LENGTH'], - $has_spatial_column - ); + $sub_part = $this->get_index_column_sub_part( + $key_part, + $column_info_map[ $column_name ]['CHARACTER_MAXIMUM_LENGTH'], + $has_spatial_column + ); + } $statistics_data[] = array( 'table_schema' => self::SAVED_DATABASE_NAME, @@ -1698,7 +1703,7 @@ private function extract_index_statistics_data( 'comment' => '', // not implemented 'index_comment' => $index_comment, 'is_visible' => 'YES', // @TODO: Save actual visibility value. - 'expression' => null, // @TODO + 'expression' => $this->get_index_expression( $key_part ), ); $seq_in_index += 1; @@ -2859,6 +2864,19 @@ private function get_index_column_name( WP_Parser_Node $node ): ?string { return $this->get_value( $node->get_first_descendant_node( 'identifier' ) ); } + /** + * Extract index expression from a functional index part. + * + * @param WP_Parser_Node $node The key part or expression AST node. + * @return string|null The expression as stored in information schema. + */ + private function get_index_expression( WP_Parser_Node $node ): ?string { + if ( 'keyPart' === $node->rule_name ) { + return null; + } + return $this->serialize_mysql_expression( $node ); + } + /** * Extract index column name from the "keyPart" AST node. * diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-pdo-user-defined-functions.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-pdo-user-defined-functions.php index c73d35a1f..a0d11938c 100644 --- a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-pdo-user-defined-functions.php +++ b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-pdo-user-defined-functions.php @@ -31,15 +31,35 @@ class WP_SQLite_PDO_User_Defined_Functions { public static function register_for( $pdo ): self { $instance = new self(); foreach ( $instance->functions as $f => $t ) { + $flags = isset( $instance->deterministic_functions[ $f ] ) + ? self::get_sqlite_deterministic_flag() + : 0; if ( $pdo instanceof PDO\SQLite ) { - $pdo->createFunction( $f, array( $instance, $t ) ); + $pdo->createFunction( $f, array( $instance, $t ), -1, $flags ); } else { - $pdo->sqliteCreateFunction( $f, array( $instance, $t ) ); + $pdo->sqliteCreateFunction( $f, array( $instance, $t ), -1, $flags ); } } return $instance; } + /** + * Gets the SQLite deterministic UDF flag across supported PHP versions. + * + * @return int The deterministic flag, or 0 when unavailable. + */ + private static function get_sqlite_deterministic_flag(): int { + if ( defined( 'Pdo\Sqlite::DETERMINISTIC' ) ) { + return constant( 'Pdo\Sqlite::DETERMINISTIC' ); + } + + if ( defined( 'PDO::SQLITE_DETERMINISTIC' ) ) { + return constant( 'PDO::SQLITE_DETERMINISTIC' ); + } + + return 0; + } + /** * Array to define MySQL function => function defined with PHP. * @@ -96,6 +116,48 @@ public static function register_for( $pdo ): self { '_helper_like_to_glob_pattern' => '_helper_like_to_glob_pattern', ); + /** + * Pure UDFs that SQLite may use in schema-level expressions. + * + * SQLite requires functions used in index expressions to be marked + * deterministic. Time, random, lock, and stateful functions stay excluded. + * + * @var array + */ + private $deterministic_functions = array( + 'month' => true, + 'monthnum' => true, + 'year' => true, + 'day' => true, + 'hour' => true, + 'minute' => true, + 'second' => true, + 'week' => true, + 'weekday' => true, + 'dayofweek' => true, + 'dayofmonth' => true, + 'md5' => true, + 'from_unixtime' => true, + 'isnull' => true, + 'if' => true, + 'regexp' => true, + 'field' => true, + 'log' => true, + 'least' => true, + 'greatest' => true, + 'ucase' => true, + 'lcase' => true, + 'unhex' => true, + 'from_base64' => true, + 'to_base64' => true, + 'inet_ntoa' => true, + 'inet_aton' => true, + 'bit_count' => true, + 'datediff' => true, + 'locate' => true, + '_helper_like_to_glob_pattern' => true, + ); + /** * First element of the RAND(N) LCG state (the value the output is derived from). * diff --git a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php index 54799eba6..77cb5b2c3 100644 --- a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php @@ -10653,6 +10653,18 @@ public function testSqlancerBooleanOperators(): void { $this->assertNull( $result[0]->null_xor ); } + public function testSqlancerFunctionalIndexRecordsExpressionMetadata(): void { + $this->assertQuery( 'CREATE TABLE t0(c0 DOUBLE, c1 DOUBLE)' ); + $this->assertQuery( 'CREATE INDEX i0 ON t0(((IF(NULL, t0.c1, t0.c0)))) ALGORITHM DEFAULT' ); + $this->assertQuery( 'ALTER TABLE t0 DISABLE KEYS' ); + + $result = $this->assertQuery( 'SHOW INDEX FROM t0' ); + + $this->assertCount( 1, $result ); + $this->assertNull( $result[0]->Column_name ); + $this->assertSame( '(IF(NULL , t0 . c1 , t0 . c0))', $result[0]->Expression ); + } + public function testInsertIntoSetSyntax(): void { $this->assertQuery( 'CREATE TABLE t ( diff --git a/tests/e2e/specs/sqlancer-fuzz-regressions.test.js b/tests/e2e/specs/sqlancer-fuzz-regressions.test.js index 53e119e82..22c0f8dac 100644 --- a/tests/e2e/specs/sqlancer-fuzz-regressions.test.js +++ b/tests/e2e/specs/sqlancer-fuzz-regressions.test.js @@ -72,9 +72,18 @@ sdi_sqlancer_query( "REPLACE INTO $table(c0) VALUES(NULL)" ); $bit_count_row = $wpdb->get_row( "SELECT DISTINCTROW COUNT(*) AS rows_count, BIT_COUNT(-1) AS negative_bits, MAX(GREATEST(NULL, c0)) AS greatest_with_null FROM $table", ARRAY_A ); $boolean_row = $wpdb->get_row( "SELECT (2 XOR 3) AS both_true, (1 XOR 0) AS one_true, (1 && 0) AS and_symbol, (! 1) AS not_symbol, (NULL IS UNKNOWN) AS null_unknown, (1 IS NOT UNKNOWN) AS one_not_unknown, (NULL XOR 1) AS null_xor", ARRAY_A ); + +$index_table = $wpdb->prefix . 'sqlancer_expr_index'; +sdi_sqlancer_query( "DROP TABLE IF EXISTS $index_table" ); +sdi_sqlancer_query( "CREATE TABLE $index_table(c0 DOUBLE, c1 DOUBLE)" ); +sdi_sqlancer_query( "CREATE INDEX i0 ON $index_table(((IF(NULL, $index_table.c1, $index_table.c0)))) ALGORITHM DEFAULT" ); +sdi_sqlancer_query( "ALTER TABLE $index_table DISABLE KEYS" ); +$index_row = $wpdb->get_row( "SHOW INDEX FROM $index_table WHERE Key_name = 'i0'", ARRAY_A ); + echo 'SQLANCER_JSON:' . wp_json_encode( $row ) . PHP_EOL; echo 'SQLANCER_BIT_COUNT_JSON:' . wp_json_encode( $bit_count_row ) . PHP_EOL; echo 'SQLANCER_BOOLEAN_JSON:' . wp_json_encode( $boolean_row ) . PHP_EOL; +echo 'SQLANCER_INDEX_JSON:' . wp_json_encode( $index_row ) . PHP_EOL; `, ], { @@ -119,9 +128,7 @@ echo 'SQLANCER_BOOLEAN_JSON:' . wp_json_encode( $boolean_row ) . PHP_EOL; expect( booleanJsonLine ).toBeTruthy(); expect( - JSON.parse( - booleanJsonLine.replace( 'SQLANCER_BOOLEAN_JSON:', '' ) - ) + JSON.parse( booleanJsonLine.replace( 'SQLANCER_BOOLEAN_JSON:', '' ) ) ).toEqual( { both_true: '0', one_true: '1', @@ -131,5 +138,24 @@ echo 'SQLANCER_BOOLEAN_JSON:' . wp_json_encode( $boolean_row ) . PHP_EOL; one_not_unknown: '1', null_xor: null, } ); + + const indexJsonLine = output + .trim() + .split( /\r?\n/ ) + .find( ( line ) => line.startsWith( 'SQLANCER_INDEX_JSON:' ) ); + + expect( indexJsonLine ).toBeTruthy(); + const indexRow = JSON.parse( + indexJsonLine.replace( 'SQLANCER_INDEX_JSON:', '' ) + ); + expect( indexRow ).toEqual( + expect.objectContaining( { + Key_name: 'i0', + Column_name: null, + } ) + ); + expect( indexRow.Expression ).toContain( 'IF(NULL' ); + expect( indexRow.Expression ).toContain( 'sqlancer_expr_index . c1' ); + expect( indexRow.Expression ).toContain( 'sqlancer_expr_index . c0' ); + } ); } ); -} ); From 88f9f05599d39de11812ada0563dab8334711f8c Mon Sep 17 00:00:00 2001 From: adamziel Date: Wed, 17 Jun 2026 01:49:18 +0000 Subject: [PATCH 04/37] Handle SQLancer aggregate edge cases --- .../sqlite/class-wp-pdo-mysql-on-sqlite.php | 71 ++++++++++++++++++- ...s-wp-sqlite-pdo-user-defined-functions.php | 18 +++++ .../tests/WP_SQLite_Driver_Tests.php | 18 +++++ .../specs/sqlancer-fuzz-regressions.test.js | 27 +++++++ 4 files changed, 131 insertions(+), 3 deletions(-) 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 b0fadd6fa..4d691890c 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 @@ -3882,6 +3882,8 @@ private function translate( $node ): ?string { return $this->translate_runtime_function_call( $node ); case 'functionCall': return $this->translate_function_call( $node ); + case 'sumExpr': + return $this->translate_sum_expr( $node ); case 'substringFunction': $nodes = $node->get_child_nodes(); if ( count( $nodes ) === 2 ) { @@ -4379,9 +4381,11 @@ private function translate_query_specification( WP_Parser_Node $node ): string { */ $group_by_list = $group_by->get_first_child_node( 'orderList' ); foreach ( $group_by_list->get_child_nodes() as $group_by_item ) { - $group_by_expr = $group_by_item->get_first_child_node( 'expr' ); - $disambiguated_item = $this->disambiguate_item( $disambiguation_map, $group_by_expr ); - $disambiguated_group_by_list[] = $disambiguated_item ?? $this->translate( $group_by_expr ); + $group_by_expr = $group_by_item->get_first_child_node( 'expr' ); + $disambiguated_item = $this->disambiguate_item( $disambiguation_map, $group_by_expr ); + + $disambiguated_group_by_list[] = $disambiguated_item + ?? $this->translate_group_by_expression( $group_by_expr ); } $group_by_clause = 'GROUP BY ' . implode( ', ', $disambiguated_group_by_list ); } @@ -4639,6 +4643,48 @@ private function translate_runtime_function_call( WP_Parser_Node $node ): string } } + /** + * Translate a MySQL aggregate expression to SQLite. + * + * @param WP_Parser_Node $node The "sumExpr" AST node. + * @return string The translated value. + * @throws WP_SQLite_Driver_Exception When the translation fails. + */ + private function translate_sum_expr( WP_Parser_Node $node ): string { + $aggregate = $node->get_first_child_token(); + if ( + ! $aggregate + || WP_MySQL_Lexer::COUNT_SYMBOL !== $aggregate->id + || ! $node->has_child_token( WP_MySQL_Lexer::DISTINCT_SYMBOL ) + ) { + return $this->translate_sequence( $node->get_children() ); + } + + $expr_list = $node->get_first_child_node( 'exprList' ); + if ( ! $expr_list ) { + return $this->translate_sequence( $node->get_children() ); + } + + $exprs = $expr_list->get_child_nodes( 'expr' ); + if ( count( $exprs ) < 2 ) { + return $this->translate_sequence( $node->get_children() ); + } + + $args = array(); + $null_checks = array(); + foreach ( $exprs as $expr ) { + $arg = $this->translate( $expr ); + $args[] = $arg; + $null_checks[] = sprintf( '%s IS NULL', $arg ); + } + + return sprintf( + 'COUNT(DISTINCT CASE WHEN %s THEN NULL ELSE _mysql_count_distinct_tuple(%s) END)', + implode( ' OR ', $null_checks ), + implode( ', ', $args ) + ); + } + /** * Translate a MySQL function call to SQLite. * @@ -5886,6 +5932,25 @@ private function disambiguate_item( array $disambiguation_map, WP_Parser_Node $e return null; } + /** + * Translate a GROUP BY expression. + * + * SQLite treats integer constants in GROUP BY as select-list ordinals. MySQL + * accepts negative integer constants as expressions, so force those through + * SQLite's expression path. + * + * @param WP_Parser_Node $expr The expression AST node. + * @return string The translated GROUP BY expression. + */ + private function translate_group_by_expression( WP_Parser_Node $expr ): string { + $translated = $this->translate( $expr ); + if ( preg_match( '/^-\\s*\\d+$/', $translated ) ) { + return '0 + ' . $translated; + } + + return $translated; + } + /** * Create a SELECT item disambiguation map from a SELECT item list for use * with the ORDER BY, GROUP BY, and HAVING clause disambiguation algorithm. diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-pdo-user-defined-functions.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-pdo-user-defined-functions.php index a0d11938c..557c08fca 100644 --- a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-pdo-user-defined-functions.php +++ b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-pdo-user-defined-functions.php @@ -113,6 +113,7 @@ private static function get_sqlite_deterministic_flag(): int { 'version' => 'version', // Internal helper functions. + '_mysql_count_distinct_tuple' => '_mysql_count_distinct_tuple', '_helper_like_to_glob_pattern' => '_helper_like_to_glob_pattern', ); @@ -155,6 +156,7 @@ private static function get_sqlite_deterministic_flag(): int { 'bit_count' => true, 'datediff' => true, 'locate' => true, + '_mysql_count_distinct_tuple' => true, '_helper_like_to_glob_pattern' => true, ); @@ -285,6 +287,22 @@ public function bit_count( $value ) { return $count; } + /** + * Build an internal key for MySQL COUNT(DISTINCT expr, ...). + * + * @return string|null Serialized tuple key, or null when any argument is null. + */ + public function _mysql_count_distinct_tuple() { + $args = func_get_args(); + foreach ( $args as $arg ) { + if ( null === $arg ) { + return null; + } + } + + return serialize( $args ); + } + /** * Method to emulate MySQL's seeded RAND(N) function. * diff --git a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php index 77cb5b2c3..0f5b9127a 100644 --- a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php @@ -10665,6 +10665,24 @@ public function testSqlancerFunctionalIndexRecordsExpressionMetadata(): void { $this->assertSame( '(IF(NULL , t0 . c1 , t0 . c0))', $result[0]->Expression ); } + public function testSqlancerGroupByNegativeIntegerLiteralIsExpression(): void { + $this->assertQuery( 'CREATE TABLE t0(c0 INT)' ); + $this->assertQuery( 'INSERT INTO t0(c0) VALUES(1), (2)' ); + + $result = $this->assertQuery( 'SELECT -272848287 AS ref0 FROM t0 GROUP BY -272848287 LIMIT 5406272978560205348 OFFSET 6829953128963339467' ); + + $this->assertSame( array(), $result ); + } + + public function testSqlancerCountDistinctMultipleExpressionsUsesTupleIdentity(): void { + $this->assertQuery( 'CREATE TABLE t0(c0 INT, c1 TEXT)' ); + $this->assertQuery( "INSERT INTO t0(c0, c1) VALUES(1, 'a'), (1, 'a'), (1, 'b'), (NULL, 'b'), (2, NULL)" ); + + $result = $this->assertQuery( 'SELECT COUNT(DISTINCT c0, c1) AS tuple_count FROM t0' ); + + $this->assertSame( '2', $result[0]->tuple_count ); + } + public function testInsertIntoSetSyntax(): void { $this->assertQuery( 'CREATE TABLE t ( diff --git a/tests/e2e/specs/sqlancer-fuzz-regressions.test.js b/tests/e2e/specs/sqlancer-fuzz-regressions.test.js index 22c0f8dac..f62e53f93 100644 --- a/tests/e2e/specs/sqlancer-fuzz-regressions.test.js +++ b/tests/e2e/specs/sqlancer-fuzz-regressions.test.js @@ -72,6 +72,7 @@ sdi_sqlancer_query( "REPLACE INTO $table(c0) VALUES(NULL)" ); $bit_count_row = $wpdb->get_row( "SELECT DISTINCTROW COUNT(*) AS rows_count, BIT_COUNT(-1) AS negative_bits, MAX(GREATEST(NULL, c0)) AS greatest_with_null FROM $table", ARRAY_A ); $boolean_row = $wpdb->get_row( "SELECT (2 XOR 3) AS both_true, (1 XOR 0) AS one_true, (1 && 0) AS and_symbol, (! 1) AS not_symbol, (NULL IS UNKNOWN) AS null_unknown, (1 IS NOT UNKNOWN) AS one_not_unknown, (NULL XOR 1) AS null_xor", ARRAY_A ); +sdi_sqlancer_query( "SELECT -272848287 AS ref0 FROM $table GROUP BY -272848287" ); $index_table = $wpdb->prefix . 'sqlancer_expr_index'; sdi_sqlancer_query( "DROP TABLE IF EXISTS $index_table" ); @@ -80,10 +81,17 @@ sdi_sqlancer_query( "CREATE INDEX i0 ON $index_table(((IF(NULL, $index_table.c1, sdi_sqlancer_query( "ALTER TABLE $index_table DISABLE KEYS" ); $index_row = $wpdb->get_row( "SHOW INDEX FROM $index_table WHERE Key_name = 'i0'", ARRAY_A ); +$count_distinct_table = $wpdb->prefix . 'sqlancer_count_distinct'; +sdi_sqlancer_query( "DROP TABLE IF EXISTS $count_distinct_table" ); +sdi_sqlancer_query( "CREATE TABLE $count_distinct_table(c0 INT, c1 TEXT)" ); +sdi_sqlancer_query( "INSERT INTO $count_distinct_table(c0, c1) VALUES(1, 'a'), (1, 'a'), (1, 'b'), (NULL, 'b'), (2, NULL)" ); +$count_distinct_row = $wpdb->get_row( "SELECT COUNT(DISTINCT c0, c1) AS tuple_count FROM $count_distinct_table", ARRAY_A ); + echo 'SQLANCER_JSON:' . wp_json_encode( $row ) . PHP_EOL; echo 'SQLANCER_BIT_COUNT_JSON:' . wp_json_encode( $bit_count_row ) . PHP_EOL; echo 'SQLANCER_BOOLEAN_JSON:' . wp_json_encode( $boolean_row ) . PHP_EOL; echo 'SQLANCER_INDEX_JSON:' . wp_json_encode( $index_row ) . PHP_EOL; +echo 'SQLANCER_COUNT_DISTINCT_JSON:' . wp_json_encode( $count_distinct_row ) . PHP_EOL; `, ], { @@ -157,5 +165,24 @@ echo 'SQLANCER_INDEX_JSON:' . wp_json_encode( $index_row ) . PHP_EOL; expect( indexRow.Expression ).toContain( 'IF(NULL' ); expect( indexRow.Expression ).toContain( 'sqlancer_expr_index . c1' ); expect( indexRow.Expression ).toContain( 'sqlancer_expr_index . c0' ); + + const countDistinctJsonLine = output + .trim() + .split( /\r?\n/ ) + .find( ( line ) => + line.startsWith( 'SQLANCER_COUNT_DISTINCT_JSON:' ) + ); + + expect( countDistinctJsonLine ).toBeTruthy(); + expect( + JSON.parse( + countDistinctJsonLine.replace( + 'SQLANCER_COUNT_DISTINCT_JSON:', + '' + ) + ) + ).toEqual( { + tuple_count: '2', + } ); } ); } ); From e2e47110b230dd4a23cd83f59a837499a98f8719 Mon Sep 17 00:00:00 2001 From: adamziel Date: Wed, 17 Jun 2026 01:54:10 +0000 Subject: [PATCH 05/37] Handle SQLancer ALTER TABLE renames --- .../sqlite/class-wp-pdo-mysql-on-sqlite.php | 37 +++++++++++++++++++ ...s-wp-sqlite-information-schema-builder.php | 33 +++++++++++++++++ .../tests/WP_SQLite_Driver_Tests.php | 17 +++++++++ .../specs/sqlancer-fuzz-regressions.test.js | 23 ++++++++++++ 4 files changed, 110 insertions(+) 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 4d691890c..69f3b0146 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 @@ -2481,6 +2481,7 @@ private function execute_alter_table_statement( WP_Parser_Node $node ): void { } $table_is_temporary = $this->information_schema_builder->temporary_table_exists( $table_name ); + $new_table_name = $this->get_alter_table_rename_name( $node ); // Save all column names from the original table. $columns_table = $this->information_schema_builder->get_table_name( $table_is_temporary, 'columns' ); @@ -2550,10 +2551,46 @@ private function execute_alter_table_statement( WP_Parser_Node $node ): void { // Apply AUTO_INCREMENT = N table option, if any. $this->apply_auto_increment_table_option( $table_is_temporary, $table_name, $node ); + if ( null !== $new_table_name && $new_table_name !== $table_name ) { + $this->execute_sqlite_query( + sprintf( + 'ALTER TABLE %s RENAME TO %s', + $this->quote_sqlite_identifier( $table_name ), + $this->quote_sqlite_identifier( $new_table_name ) + ) + ); + $this->information_schema_builder->record_rename_table( $table_is_temporary, $table_name, $new_table_name ); + } + // @TODO: Consider using a "fast path" for ALTER TABLE statements that // consist only of operations that SQLite's ALTER TABLE supports. } + /** + * Get the target table name from an ALTER TABLE rename action. + * + * @param WP_Parser_Node $node The "alterStatement" AST node. + * @return string|null The target table name, or null when no table rename is present. + */ + private function get_alter_table_rename_name( WP_Parser_Node $node ): ?string { + foreach ( $node->get_descendant_nodes( 'alterListItem' ) as $action ) { + $first_token = $action->get_first_child_token(); + if ( + ! $first_token + || WP_MySQL_Lexer::RENAME_SYMBOL !== $first_token->id + || ! $action->has_child_node( 'tableName' ) + ) { + continue; + } + + return $this->unquote_sqlite_identifier( + $this->translate( $action->get_first_child_node( 'tableName' ) ) + ); + } + + return null; + } + /** * Translate and execute a MySQL DROP TABLE statement in SQLite. * diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-information-schema-builder.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-information-schema-builder.php index c4260624b..f0bf2482c 100644 --- a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-information-schema-builder.php +++ b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-information-schema-builder.php @@ -761,6 +761,39 @@ public function record_alter_table( WP_Parser_Node $node ): void { } } + /** + * Record an ALTER TABLE rename in the information schema. + * + * @param bool $table_is_temporary Whether the table is temporary. + * @param string $old_table_name The old table name. + * @param string $new_table_name The new table name. + */ + public function record_rename_table( bool $table_is_temporary, string $old_table_name, string $new_table_name ): void { + foreach ( array( 'tables', 'columns', 'statistics', 'table_constraints', 'key_column_usage' ) as $table ) { + $this->update_values( + $this->get_table_name( $table_is_temporary, $table ), + array( + 'table_name' => $new_table_name, + ), + array( + 'table_name' => $old_table_name, + ) + ); + } + + foreach ( array( 'referential_constraints', 'key_column_usage' ) as $table ) { + $this->update_values( + $this->get_table_name( $table_is_temporary, $table ), + array( + 'referenced_table_name' => $new_table_name, + ), + array( + 'referenced_table_name' => $old_table_name, + ) + ); + } + } + /** * Analyze DROP TABLE statement and record data in the information schema. * diff --git a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php index 0f5b9127a..391807097 100644 --- a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php @@ -10683,6 +10683,23 @@ public function testSqlancerCountDistinctMultipleExpressionsUsesTupleIdentity(): $this->assertSame( '2', $result[0]->tuple_count ); } + public function testSqlancerAlterTableBareRenameChangesTableName(): void { + $this->assertQuery( 'CREATE TABLE t0(c0 DOUBLE)' ); + $this->assertQuery( 'INSERT INTO t0(c0) VALUES(1565814287)' ); + $this->assertQuery( 'ALTER TABLE t0 STATS_PERSISTENT 0, RENAME t2, FORCE, ROW_FORMAT COMPACT' ); + + $result = $this->assertQuery( "SELECT COUNT(*) AS tables_count FROM information_schema.TABLES WHERE TABLE_NAME = 't2'" ); + $this->assertSame( '1', $result[0]->tables_count ); + + $result = $this->assertQuery( 'SELECT COUNT(*) AS rows_count FROM t2' ); + $this->assertSame( '1', $result[0]->rows_count ); + + $this->assertQuery( "ALTER TABLE t2 FORCE, ROW_FORMAT DEFAULT, COMPRESSION 'LZ4', INSERT_METHOD NO, PACK_KEYS 0, CHECKSUM 0, ALGORITHM COPY, RENAME TO t0" ); + $result = $this->assertQuery( 'SELECT COUNT(*) AS rows_count FROM t0' ); + + $this->assertSame( '1', $result[0]->rows_count ); + } + public function testInsertIntoSetSyntax(): void { $this->assertQuery( 'CREATE TABLE t ( diff --git a/tests/e2e/specs/sqlancer-fuzz-regressions.test.js b/tests/e2e/specs/sqlancer-fuzz-regressions.test.js index f62e53f93..cf6662b70 100644 --- a/tests/e2e/specs/sqlancer-fuzz-regressions.test.js +++ b/tests/e2e/specs/sqlancer-fuzz-regressions.test.js @@ -87,11 +87,22 @@ sdi_sqlancer_query( "CREATE TABLE $count_distinct_table(c0 INT, c1 TEXT)" ); sdi_sqlancer_query( "INSERT INTO $count_distinct_table(c0, c1) VALUES(1, 'a'), (1, 'a'), (1, 'b'), (NULL, 'b'), (2, NULL)" ); $count_distinct_row = $wpdb->get_row( "SELECT COUNT(DISTINCT c0, c1) AS tuple_count FROM $count_distinct_table", ARRAY_A ); +$rename_table = $wpdb->prefix . 'sqlancer_rename_t0'; +$renamed_table = $wpdb->prefix . 'sqlancer_rename_t2'; +sdi_sqlancer_query( "DROP TABLE IF EXISTS $rename_table" ); +sdi_sqlancer_query( "DROP TABLE IF EXISTS $renamed_table" ); +sdi_sqlancer_query( "CREATE TABLE $rename_table(c0 DOUBLE)" ); +sdi_sqlancer_query( "INSERT INTO $rename_table(c0) VALUES(1565814287)" ); +sdi_sqlancer_query( "ALTER TABLE $rename_table STATS_PERSISTENT 0, RENAME $renamed_table, FORCE, ROW_FORMAT COMPACT" ); +$rename_row = $wpdb->get_row( "SELECT COUNT(*) AS rows_count FROM $renamed_table", ARRAY_A ); +sdi_sqlancer_query( "ALTER TABLE $renamed_table FORCE, ROW_FORMAT DEFAULT, COMPRESSION 'LZ4', INSERT_METHOD NO, PACK_KEYS 0, CHECKSUM 0, ALGORITHM COPY, RENAME TO $rename_table" ); + echo 'SQLANCER_JSON:' . wp_json_encode( $row ) . PHP_EOL; echo 'SQLANCER_BIT_COUNT_JSON:' . wp_json_encode( $bit_count_row ) . PHP_EOL; echo 'SQLANCER_BOOLEAN_JSON:' . wp_json_encode( $boolean_row ) . PHP_EOL; echo 'SQLANCER_INDEX_JSON:' . wp_json_encode( $index_row ) . PHP_EOL; echo 'SQLANCER_COUNT_DISTINCT_JSON:' . wp_json_encode( $count_distinct_row ) . PHP_EOL; +echo 'SQLANCER_RENAME_JSON:' . wp_json_encode( $rename_row ) . PHP_EOL; `, ], { @@ -184,5 +195,17 @@ echo 'SQLANCER_COUNT_DISTINCT_JSON:' . wp_json_encode( $count_distinct_row ) . P ).toEqual( { tuple_count: '2', } ); + + const renameJsonLine = output + .trim() + .split( /\r?\n/ ) + .find( ( line ) => line.startsWith( 'SQLANCER_RENAME_JSON:' ) ); + + expect( renameJsonLine ).toBeTruthy(); + expect( JSON.parse( renameJsonLine.replace( 'SQLANCER_RENAME_JSON:', '' ) ) ).toEqual( + { + rows_count: '1', + } + ); } ); } ); From 5372002ca312dc4c74c159196af6a3c2ec0b67c8 Mon Sep 17 00:00:00 2001 From: adamziel Date: Wed, 17 Jun 2026 02:05:54 +0000 Subject: [PATCH 06/37] Handle SQLancer signed integer casts --- .../sqlite/class-wp-pdo-mysql-on-sqlite.php | 7 +++ ...s-wp-sqlite-pdo-user-defined-functions.php | 28 ++++++++++ .../tests/WP_SQLite_Driver_Tests.php | 18 +++++++ .../WP_SQLite_Driver_Translation_Tests.php | 2 +- .../specs/sqlancer-fuzz-regressions.test.js | 52 +++++++++++++++++++ 5 files changed, 106 insertions(+), 1 deletion(-) 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 69f3b0146..e848480c3 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 @@ -4551,6 +4551,13 @@ private function translate_cast_expr( WP_Parser_Node $expr, WP_Parser_Node $cast if ( $cast_type->has_child_token( WP_MySQL_Lexer::BINARY_SYMBOL ) ) { return sprintf( 'CAST(%s AS TEXT) COLLATE BINARY', $this->translate( $expr ) ); } + if ( + $cast_type->has_child_token( WP_MySQL_Lexer::SIGNED_SYMBOL ) + || $cast_type->has_child_token( WP_MySQL_Lexer::UNSIGNED_SYMBOL ) + ) { + // @TODO: Emulate UNSIGNED overflow wrapping. + return sprintf( '_mysql_cast_integer(%s)', $this->translate( $expr ) ); + } return sprintf( 'CAST(%s AS %s)', $this->translate( $expr ), $this->translate( $cast_type ) ); } diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-pdo-user-defined-functions.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-pdo-user-defined-functions.php index 557c08fca..cf3ab7f47 100644 --- a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-pdo-user-defined-functions.php +++ b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-pdo-user-defined-functions.php @@ -113,6 +113,7 @@ private static function get_sqlite_deterministic_flag(): int { 'version' => 'version', // Internal helper functions. + '_mysql_cast_integer' => '_mysql_cast_integer', '_mysql_count_distinct_tuple' => '_mysql_count_distinct_tuple', '_helper_like_to_glob_pattern' => '_helper_like_to_glob_pattern', ); @@ -156,6 +157,7 @@ private static function get_sqlite_deterministic_flag(): int { 'bit_count' => true, 'datediff' => true, 'locate' => true, + '_mysql_cast_integer' => true, '_mysql_count_distinct_tuple' => true, '_helper_like_to_glob_pattern' => true, ); @@ -287,6 +289,32 @@ public function bit_count( $value ) { return $count; } + /** + * Emulate the integer conversion used by MySQL SIGNED/UNSIGNED casts. + * + * MySQL rounds numeric values, but parses string values through their + * leading integer text. SQLite's plain integer cast always truncates. + * + * @param int|float|string|null $value Value to cast. + * + * @return int|null Integer cast value, or null for null input. + */ + public function _mysql_cast_integer( $value ) { + if ( null === $value ) { + return null; + } + + if ( is_int( $value ) ) { + return $value; + } + + if ( is_float( $value ) ) { + return (int) round( $value, 0, PHP_ROUND_HALF_UP ); + } + + return (int) $value; + } + /** * Build an internal key for MySQL COUNT(DISTINCT expr, ...). * diff --git a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php index 391807097..3249c641d 100644 --- a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php @@ -10700,6 +10700,24 @@ public function testSqlancerAlterTableBareRenameChangesTableName(): void { $this->assertSame( '1', $result[0]->rows_count ); } + public function testSqlancerCastSignedRoundsNumericValuesInPredicates(): void { + $result = $this->assertQuery( "SELECT CAST(0.8338761836534807 AS SIGNED) AS rounded_real, CAST('0.8338761836534807' AS SIGNED) AS truncated_text" ); + + $this->assertSame( '1', $result[0]->rounded_real ); + $this->assertSame( '0', $result[0]->truncated_text ); + + $this->assertQuery( 'CREATE TABLE t0(c0 SMALLINT UNIQUE KEY)' ); + $this->assertQuery( 'INSERT INTO t0(c0) VALUES(NULL)' ); + + $result = $this->assertQuery( 'SELECT ( EXISTS (SELECT 1 WHERE FALSE)) IN (CAST(IFNULL(t0.c0, 0.8338761836534807) AS SIGNED)) AS predicate_value FROM t0' ); + $this->assertSame( '0', $result[0]->predicate_value ); + + $this->assertQuery( 'UPDATE t0 SET c0="" WHERE ( EXISTS (SELECT 1 WHERE FALSE)) IN (CAST(IFNULL(t0.c0, 0.8338761836534807) AS SIGNED))' ); + $result = $this->assertQuery( 'SELECT c0 FROM t0' ); + + $this->assertNull( $result[0]->c0 ); + } + public function testInsertIntoSetSyntax(): void { $this->assertQuery( 'CREATE TABLE t ( diff --git a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Translation_Tests.php b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Translation_Tests.php index 119ec3ac0..8cca92f6d 100644 --- a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Translation_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Translation_Tests.php @@ -114,7 +114,7 @@ public function testConvert(): void { ); $this->assertQuery( - "SELECT CAST('-10' AS INTEGER) AS `CONVERT('-10', SIGNED)`", + "SELECT _mysql_cast_integer('-10') AS `CONVERT('-10', SIGNED)`", "SELECT CONVERT('-10', SIGNED)" ); diff --git a/tests/e2e/specs/sqlancer-fuzz-regressions.test.js b/tests/e2e/specs/sqlancer-fuzz-regressions.test.js index cf6662b70..cff893248 100644 --- a/tests/e2e/specs/sqlancer-fuzz-regressions.test.js +++ b/tests/e2e/specs/sqlancer-fuzz-regressions.test.js @@ -97,12 +97,22 @@ sdi_sqlancer_query( "ALTER TABLE $rename_table STATS_PERSISTENT 0, RENAME $renam $rename_row = $wpdb->get_row( "SELECT COUNT(*) AS rows_count FROM $renamed_table", ARRAY_A ); sdi_sqlancer_query( "ALTER TABLE $renamed_table FORCE, ROW_FORMAT DEFAULT, COMPRESSION 'LZ4', INSERT_METHOD NO, PACK_KEYS 0, CHECKSUM 0, ALGORITHM COPY, RENAME TO $rename_table" ); +$cast_table = $wpdb->prefix . 'sqlancer_cast_signed'; +sdi_sqlancer_query( "DROP TABLE IF EXISTS $cast_table" ); +sdi_sqlancer_query( "CREATE TABLE $cast_table(c0 SMALLINT UNIQUE KEY)" ); +sdi_sqlancer_query( "INSERT INTO $cast_table(c0) VALUES(NULL)" ); +$cast_row = $wpdb->get_row( "SELECT CAST(0.8338761836534807 AS SIGNED) AS rounded_real, CAST('0.8338761836534807' AS SIGNED) AS truncated_text, ( EXISTS (SELECT 1 WHERE FALSE)) IN (CAST(IFNULL($cast_table.c0, 0.8338761836534807) AS SIGNED)) AS predicate_value FROM $cast_table", ARRAY_A ); +sdi_sqlancer_query( "UPDATE $cast_table SET c0=\\"\\" WHERE ( EXISTS (SELECT 1 WHERE FALSE)) IN (CAST(IFNULL($cast_table.c0, 0.8338761836534807) AS SIGNED))" ); +$cast_after_update_row = $wpdb->get_row( "SELECT c0 FROM $cast_table", ARRAY_A ); + echo 'SQLANCER_JSON:' . wp_json_encode( $row ) . PHP_EOL; echo 'SQLANCER_BIT_COUNT_JSON:' . wp_json_encode( $bit_count_row ) . PHP_EOL; echo 'SQLANCER_BOOLEAN_JSON:' . wp_json_encode( $boolean_row ) . PHP_EOL; echo 'SQLANCER_INDEX_JSON:' . wp_json_encode( $index_row ) . PHP_EOL; echo 'SQLANCER_COUNT_DISTINCT_JSON:' . wp_json_encode( $count_distinct_row ) . PHP_EOL; echo 'SQLANCER_RENAME_JSON:' . wp_json_encode( $rename_row ) . PHP_EOL; +echo 'SQLANCER_CAST_SIGNED_JSON:' . wp_json_encode( $cast_row ) . PHP_EOL; +echo 'SQLANCER_CAST_SIGNED_AFTER_UPDATE_JSON:' . wp_json_encode( $cast_after_update_row ) . PHP_EOL; `, ], { @@ -207,5 +217,47 @@ echo 'SQLANCER_RENAME_JSON:' . wp_json_encode( $rename_row ) . PHP_EOL; rows_count: '1', } ); + + const castSignedJsonLine = output + .trim() + .split( /\r?\n/ ) + .find( ( line ) => + line.startsWith( 'SQLANCER_CAST_SIGNED_JSON:' ) + ); + + expect( castSignedJsonLine ).toBeTruthy(); + expect( + JSON.parse( + castSignedJsonLine.replace( + 'SQLANCER_CAST_SIGNED_JSON:', + '' + ) + ) + ).toEqual( { + rounded_real: '1', + truncated_text: '0', + predicate_value: '0', + } ); + + const castSignedAfterUpdateJsonLine = output + .trim() + .split( /\r?\n/ ) + .find( ( line ) => + line.startsWith( + 'SQLANCER_CAST_SIGNED_AFTER_UPDATE_JSON:' + ) + ); + + expect( castSignedAfterUpdateJsonLine ).toBeTruthy(); + expect( + JSON.parse( + castSignedAfterUpdateJsonLine.replace( + 'SQLANCER_CAST_SIGNED_AFTER_UPDATE_JSON:', + '' + ) + ) + ).toEqual( { + c0: null, + } ); } ); } ); From a052daf32a69cd872588e0f1c6350e68b7e960ab Mon Sep 17 00:00:00 2001 From: adamziel Date: Wed, 17 Jun 2026 02:11:15 +0000 Subject: [PATCH 07/37] Handle SQLancer ORDER BY constants --- .../sqlite/class-wp-pdo-mysql-on-sqlite.php | 16 ++++++------ .../tests/WP_SQLite_Driver_Tests.php | 9 +++++++ .../specs/sqlancer-fuzz-regressions.test.js | 26 +++++++++++++++++++ 3 files changed, 43 insertions(+), 8 deletions(-) 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 e848480c3..78d7639bf 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 @@ -4363,7 +4363,7 @@ private function translate_query_expression( WP_Parser_Node $node ): string { $disambiguated_order_list[] = sprintf( '%s%s', - $disambiguated_item ?? $this->translate( $order_expr ), + $disambiguated_item ?? $this->translate_ordering_expression( $order_expr ), null !== $order_direction ? ( ' ' . $this->translate( $order_direction ) ) : '' ); } @@ -4422,7 +4422,7 @@ private function translate_query_specification( WP_Parser_Node $node ): string { $disambiguated_item = $this->disambiguate_item( $disambiguation_map, $group_by_expr ); $disambiguated_group_by_list[] = $disambiguated_item - ?? $this->translate_group_by_expression( $group_by_expr ); + ?? $this->translate_ordering_expression( $group_by_expr ); } $group_by_clause = 'GROUP BY ' . implode( ', ', $disambiguated_group_by_list ); } @@ -5977,16 +5977,16 @@ private function disambiguate_item( array $disambiguation_map, WP_Parser_Node $e } /** - * Translate a GROUP BY expression. + * Translate an ORDER BY or GROUP BY expression. * - * SQLite treats integer constants in GROUP BY as select-list ordinals. MySQL - * accepts negative integer constants as expressions, so force those through - * SQLite's expression path. + * SQLite treats integer constants in ORDER BY and GROUP BY as select-list + * ordinals. MySQL accepts negative integer constants as expressions, so + * force those through SQLite's expression path. * * @param WP_Parser_Node $expr The expression AST node. - * @return string The translated GROUP BY expression. + * @return string The translated ORDER BY or GROUP BY expression. */ - private function translate_group_by_expression( WP_Parser_Node $expr ): string { + private function translate_ordering_expression( WP_Parser_Node $expr ): string { $translated = $this->translate( $expr ); if ( preg_match( '/^-\\s*\\d+$/', $translated ) ) { return '0 + ' . $translated; diff --git a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php index 3249c641d..afa27bb40 100644 --- a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php @@ -10674,6 +10674,15 @@ public function testSqlancerGroupByNegativeIntegerLiteralIsExpression(): void { $this->assertSame( array(), $result ); } + public function testSqlancerOrderByNegativeIntegerLiteralIsExpression(): void { + $this->assertQuery( 'CREATE TABLE t0(c0 INT)' ); + $this->assertQuery( 'INSERT INTO t0(c0) VALUES(NULL)' ); + + $result = $this->assertQuery( 'SELECT (+ ( EXISTS (SELECT 1))) AS ref0 FROM t0 WHERE (+ (BIT_COUNT(1371172065))) GROUP BY (+ ( EXISTS (SELECT 1))) ORDER BY -1173568737 LIMIT 4374681039449100574' ); + + $this->assertSame( '1', $result[0]->ref0 ); + } + public function testSqlancerCountDistinctMultipleExpressionsUsesTupleIdentity(): void { $this->assertQuery( 'CREATE TABLE t0(c0 INT, c1 TEXT)' ); $this->assertQuery( "INSERT INTO t0(c0, c1) VALUES(1, 'a'), (1, 'a'), (1, 'b'), (NULL, 'b'), (2, NULL)" ); diff --git a/tests/e2e/specs/sqlancer-fuzz-regressions.test.js b/tests/e2e/specs/sqlancer-fuzz-regressions.test.js index cff893248..bc4f8136d 100644 --- a/tests/e2e/specs/sqlancer-fuzz-regressions.test.js +++ b/tests/e2e/specs/sqlancer-fuzz-regressions.test.js @@ -74,6 +74,12 @@ $bit_count_row = $wpdb->get_row( "SELECT DISTINCTROW COUNT(*) AS rows_count, BIT $boolean_row = $wpdb->get_row( "SELECT (2 XOR 3) AS both_true, (1 XOR 0) AS one_true, (1 && 0) AS and_symbol, (! 1) AS not_symbol, (NULL IS UNKNOWN) AS null_unknown, (1 IS NOT UNKNOWN) AS one_not_unknown, (NULL XOR 1) AS null_xor", ARRAY_A ); sdi_sqlancer_query( "SELECT -272848287 AS ref0 FROM $table GROUP BY -272848287" ); +$order_table = $wpdb->prefix . 'sqlancer_order_negative'; +sdi_sqlancer_query( "DROP TABLE IF EXISTS $order_table" ); +sdi_sqlancer_query( "CREATE TABLE $order_table(c0 INT)" ); +sdi_sqlancer_query( "INSERT INTO $order_table(c0) VALUES(NULL)" ); +$order_negative_row = $wpdb->get_row( "SELECT (+ ( EXISTS (SELECT 1))) AS ref0 FROM $order_table WHERE (+ (BIT_COUNT(1371172065))) GROUP BY (+ ( EXISTS (SELECT 1))) ORDER BY -1173568737 LIMIT 4374681039449100574", ARRAY_A ); + $index_table = $wpdb->prefix . 'sqlancer_expr_index'; sdi_sqlancer_query( "DROP TABLE IF EXISTS $index_table" ); sdi_sqlancer_query( "CREATE TABLE $index_table(c0 DOUBLE, c1 DOUBLE)" ); @@ -108,6 +114,7 @@ $cast_after_update_row = $wpdb->get_row( "SELECT c0 FROM $cast_table", ARRAY_A ) echo 'SQLANCER_JSON:' . wp_json_encode( $row ) . PHP_EOL; echo 'SQLANCER_BIT_COUNT_JSON:' . wp_json_encode( $bit_count_row ) . PHP_EOL; echo 'SQLANCER_BOOLEAN_JSON:' . wp_json_encode( $boolean_row ) . PHP_EOL; +echo 'SQLANCER_ORDER_NEGATIVE_JSON:' . wp_json_encode( $order_negative_row ) . PHP_EOL; echo 'SQLANCER_INDEX_JSON:' . wp_json_encode( $index_row ) . PHP_EOL; echo 'SQLANCER_COUNT_DISTINCT_JSON:' . wp_json_encode( $count_distinct_row ) . PHP_EOL; echo 'SQLANCER_RENAME_JSON:' . wp_json_encode( $rename_row ) . PHP_EOL; @@ -168,6 +175,25 @@ echo 'SQLANCER_CAST_SIGNED_AFTER_UPDATE_JSON:' . wp_json_encode( $cast_after_upd null_xor: null, } ); + const orderNegativeJsonLine = output + .trim() + .split( /\r?\n/ ) + .find( ( line ) => + line.startsWith( 'SQLANCER_ORDER_NEGATIVE_JSON:' ) + ); + + expect( orderNegativeJsonLine ).toBeTruthy(); + expect( + JSON.parse( + orderNegativeJsonLine.replace( + 'SQLANCER_ORDER_NEGATIVE_JSON:', + '' + ) + ) + ).toEqual( { + ref0: '1', + } ); + const indexJsonLine = output .trim() .split( /\r?\n/ ) From f0a916342bbf428d7be585f75530605dbaeb8a3e Mon Sep 17 00:00:00 2001 From: adamziel Date: Wed, 17 Jun 2026 02:23:26 +0000 Subject: [PATCH 08/37] Handle SQLancer integer ordering expressions --- .../sqlite/class-wp-pdo-mysql-on-sqlite.php | 10 +++++--- .../tests/WP_SQLite_Driver_Tests.php | 11 +++++++++ .../specs/sqlancer-fuzz-regressions.test.js | 23 +++++++++++++++++++ 3 files changed, 41 insertions(+), 3 deletions(-) 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 78d7639bf..6bfb78bef 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 @@ -5980,15 +5980,19 @@ private function disambiguate_item( array $disambiguation_map, WP_Parser_Node $e * Translate an ORDER BY or GROUP BY expression. * * SQLite treats integer constants in ORDER BY and GROUP BY as select-list - * ordinals. MySQL accepts negative integer constants as expressions, so - * force those through SQLite's expression path. + * ordinals. MySQL only uses bare unsigned integer constants as ordinals, so + * force signed, parenthesized, and arithmetic integer constants through + * SQLite's expression path. * * @param WP_Parser_Node $expr The expression AST node. * @return string The translated ORDER BY or GROUP BY expression. */ private function translate_ordering_expression( WP_Parser_Node $expr ): string { $translated = $this->translate( $expr ); - if ( preg_match( '/^-\\s*\\d+$/', $translated ) ) { + if ( + ! preg_match( '/^\d+$/', $translated ) + && preg_match( '/^[\s()+-]*\d[\d\s()+-]*$/', $translated ) + ) { return '0 + ' . $translated; } diff --git a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php index afa27bb40..0398afb63 100644 --- a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php @@ -10683,6 +10683,17 @@ public function testSqlancerOrderByNegativeIntegerLiteralIsExpression(): void { $this->assertSame( '1', $result[0]->ref0 ); } + public function testSqlancerOrderingIntegerExpressionIsExpression(): void { + $this->assertQuery( 'CREATE TABLE t0(c0 INT)' ); + $this->assertQuery( 'INSERT INTO t0(c0) VALUES(1)' ); + + $result = $this->assertQuery( 'SELECT DISTINCTROW NULL AS ref0, (- (-681681867)) AS ref1, MAX(CAST(CAST((NULL) IS NOT FALSE AS SIGNED) AS SIGNED)) AS ref2 FROM t0 GROUP BY NULL, (- (-681681867))' ); + + $this->assertNull( $result[0]->ref0 ); + $this->assertSame( '681681867', $result[0]->ref1 ); + $this->assertSame( '1', $result[0]->ref2 ); + } + public function testSqlancerCountDistinctMultipleExpressionsUsesTupleIdentity(): void { $this->assertQuery( 'CREATE TABLE t0(c0 INT, c1 TEXT)' ); $this->assertQuery( "INSERT INTO t0(c0, c1) VALUES(1, 'a'), (1, 'a'), (1, 'b'), (NULL, 'b'), (2, NULL)" ); diff --git a/tests/e2e/specs/sqlancer-fuzz-regressions.test.js b/tests/e2e/specs/sqlancer-fuzz-regressions.test.js index bc4f8136d..8a54dfb36 100644 --- a/tests/e2e/specs/sqlancer-fuzz-regressions.test.js +++ b/tests/e2e/specs/sqlancer-fuzz-regressions.test.js @@ -79,6 +79,7 @@ sdi_sqlancer_query( "DROP TABLE IF EXISTS $order_table" ); sdi_sqlancer_query( "CREATE TABLE $order_table(c0 INT)" ); sdi_sqlancer_query( "INSERT INTO $order_table(c0) VALUES(NULL)" ); $order_negative_row = $wpdb->get_row( "SELECT (+ ( EXISTS (SELECT 1))) AS ref0 FROM $order_table WHERE (+ (BIT_COUNT(1371172065))) GROUP BY (+ ( EXISTS (SELECT 1))) ORDER BY -1173568737 LIMIT 4374681039449100574", ARRAY_A ); +$ordering_expression_row = $wpdb->get_row( "SELECT DISTINCTROW NULL AS ref0, (- (-681681867)) AS ref1, MAX(CAST(CAST((NULL) IS NOT FALSE AS SIGNED) AS SIGNED)) AS ref2 FROM $order_table GROUP BY NULL, (- (-681681867))", ARRAY_A ); $index_table = $wpdb->prefix . 'sqlancer_expr_index'; sdi_sqlancer_query( "DROP TABLE IF EXISTS $index_table" ); @@ -115,6 +116,7 @@ echo 'SQLANCER_JSON:' . wp_json_encode( $row ) . PHP_EOL; echo 'SQLANCER_BIT_COUNT_JSON:' . wp_json_encode( $bit_count_row ) . PHP_EOL; echo 'SQLANCER_BOOLEAN_JSON:' . wp_json_encode( $boolean_row ) . PHP_EOL; echo 'SQLANCER_ORDER_NEGATIVE_JSON:' . wp_json_encode( $order_negative_row ) . PHP_EOL; +echo 'SQLANCER_ORDERING_EXPRESSION_JSON:' . wp_json_encode( $ordering_expression_row ) . PHP_EOL; echo 'SQLANCER_INDEX_JSON:' . wp_json_encode( $index_row ) . PHP_EOL; echo 'SQLANCER_COUNT_DISTINCT_JSON:' . wp_json_encode( $count_distinct_row ) . PHP_EOL; echo 'SQLANCER_RENAME_JSON:' . wp_json_encode( $rename_row ) . PHP_EOL; @@ -194,6 +196,27 @@ echo 'SQLANCER_CAST_SIGNED_AFTER_UPDATE_JSON:' . wp_json_encode( $cast_after_upd ref0: '1', } ); + const orderingExpressionJsonLine = output + .trim() + .split( /\r?\n/ ) + .find( ( line ) => + line.startsWith( 'SQLANCER_ORDERING_EXPRESSION_JSON:' ) + ); + + expect( orderingExpressionJsonLine ).toBeTruthy(); + expect( + JSON.parse( + orderingExpressionJsonLine.replace( + 'SQLANCER_ORDERING_EXPRESSION_JSON:', + '' + ) + ) + ).toEqual( { + ref0: null, + ref1: '681681867', + ref2: '1', + } ); + const indexJsonLine = output .trim() .split( /\r?\n/ ) From ddb02b6623d1ebb412b9bc6f9b5459e410a8c41c Mon Sep 17 00:00:00 2001 From: adamziel Date: Wed, 17 Jun 2026 02:57:23 +0000 Subject: [PATCH 09/37] Handle SQLancer drop index after table rename --- .../sqlite/class-wp-pdo-mysql-on-sqlite.php | 50 +++++++++++++++++-- .../tests/WP_SQLite_Driver_Metadata_Tests.php | 17 +++++++ .../specs/sqlancer-fuzz-regressions.test.js | 27 ++++++++++ 3 files changed, 91 insertions(+), 3 deletions(-) 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 6bfb78bef..79f889673 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 @@ -2782,8 +2782,6 @@ private function execute_drop_index_statement( WP_Parser_Node $node ): void { throw $this->new_access_denied_to_information_schema_exception(); } - $this->information_schema_builder->record_drop_index( $node ); - $table_name = $this->unquote_sqlite_identifier( $this->translate( $table_ref ) ); $index_name = $this->unquote_sqlite_identifier( $this->translate( $drop_index->get_first_child_node( 'indexRef' ) ) @@ -2795,12 +2793,14 @@ private function execute_drop_index_statement( WP_Parser_Node $node ): void { * the table without the PRIMARY KEY using the updated information schema. */ if ( 'PRIMARY' === strtoupper( $index_name ) ) { + $this->information_schema_builder->record_drop_index( $node ); $table_is_temporary = $this->information_schema_builder->temporary_table_exists( $table_name ); $this->recreate_table_from_information_schema( $table_is_temporary, $table_name ); return; } - $sqlite_index_name = $this->get_sqlite_index_name( $table_name, $index_name ); + $sqlite_index_name = $this->resolve_sqlite_index_name( $table_name, $index_name ); + $this->information_schema_builder->record_drop_index( $node ); $this->execute_sqlite_query( sprintf( 'DROP INDEX %s', @@ -2809,6 +2809,50 @@ private function execute_drop_index_statement( WP_Parser_Node $node ): void { ); } + /** + * Resolve the current physical SQLite index name for a MySQL table-local index. + * + * SQLite leaves index object names unchanged when a table is renamed, while + * MySQL keeps index names scoped to the renamed table. Prefer the expected + * current table prefix, but fall back to an existing index on the table with + * the same stored MySQL index-name suffix. + * + * @param string $table_name The MySQL table name. + * @param string $index_name The MySQL index name. + * @return string The SQLite index name to use. + */ + private function resolve_sqlite_index_name( string $table_name, string $index_name ): string { + $expected_sqlite_index_name = $this->get_sqlite_index_name( $table_name, $index_name ); + $matching_index_names = array(); + $index_name_suffix = '__' . $index_name; + + $indexes = $this->execute_sqlite_query( + sprintf( + 'PRAGMA index_list(%s)', + $this->quote_sqlite_identifier( $table_name ) + ) + )->fetchAll( PDO::FETCH_ASSOC ); + + foreach ( $indexes as $index ) { + if ( $expected_sqlite_index_name === $index['name'] ) { + return $expected_sqlite_index_name; + } + + if ( + strlen( $index['name'] ) >= strlen( $index_name_suffix ) + && substr( $index['name'], -strlen( $index_name_suffix ) ) === $index_name_suffix + ) { + $matching_index_names[] = $index['name']; + } + } + + if ( 1 === count( $matching_index_names ) ) { + return $matching_index_names[0]; + } + + return $expected_sqlite_index_name; + } + /** * Translate and execute a MySQL SHOW statement in SQLite. * diff --git a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Metadata_Tests.php b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Metadata_Tests.php index 6b6f4b7fe..109ac0c42 100644 --- a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Metadata_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Metadata_Tests.php @@ -2221,6 +2221,23 @@ public function testInformationSchemaAlterTableDropUniqueKey(): void { ); } + public function testInformationSchemaDropUniqueKeyAfterTableRename(): void { + $this->assertQuery( 'CREATE TABLE t (c0 DOUBLE UNIQUE, c1 FLOAT UNIQUE, c2 MEDIUMTEXT)' ); + $this->assertQuery( 'ALTER TABLE t RENAME TO t4' ); + + $indexes = $this->engine->execute_sqlite_query( "PRAGMA index_list('t4')" )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertContains( 't__c1', array_column( $indexes, 'name' ) ); + + $this->assertQuery( 'DROP INDEX c1 ON t4 LOCK=SHARED' ); + + $indexes = $this->engine->execute_sqlite_query( "PRAGMA index_list('t4')" )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertNotContains( 't__c1', array_column( $indexes, 'name' ) ); + $this->assertContains( 't__c0', array_column( $indexes, 'name' ) ); + + $result = $this->assertQuery( "SHOW INDEX FROM t4 WHERE Key_name = 'c1'" ); + $this->assertCount( 0, $result ); + } + public function testInformationSchemaAlterTableDropConstraint(): void { $this->assertQuery( 'CREATE TABLE t1 (id INT PRIMARY KEY, name VARCHAR(255))' ); $this->assertQuery( diff --git a/tests/e2e/specs/sqlancer-fuzz-regressions.test.js b/tests/e2e/specs/sqlancer-fuzz-regressions.test.js index 8a54dfb36..b11cb0da5 100644 --- a/tests/e2e/specs/sqlancer-fuzz-regressions.test.js +++ b/tests/e2e/specs/sqlancer-fuzz-regressions.test.js @@ -104,6 +104,15 @@ sdi_sqlancer_query( "ALTER TABLE $rename_table STATS_PERSISTENT 0, RENAME $renam $rename_row = $wpdb->get_row( "SELECT COUNT(*) AS rows_count FROM $renamed_table", ARRAY_A ); sdi_sqlancer_query( "ALTER TABLE $renamed_table FORCE, ROW_FORMAT DEFAULT, COMPRESSION 'LZ4', INSERT_METHOD NO, PACK_KEYS 0, CHECKSUM 0, ALGORITHM COPY, RENAME TO $rename_table" ); +$rename_index_table = $wpdb->prefix . 'sqlancer_rename_index_t0'; +$renamed_index_table = $wpdb->prefix . 'sqlancer_rename_index_t4'; +sdi_sqlancer_query( "DROP TABLE IF EXISTS $rename_index_table" ); +sdi_sqlancer_query( "DROP TABLE IF EXISTS $renamed_index_table" ); +sdi_sqlancer_query( "CREATE TABLE $rename_index_table(c0 DOUBLE UNIQUE NULL, c1 FLOAT NULL UNIQUE KEY, c2 MEDIUMTEXT)" ); +sdi_sqlancer_query( "ALTER TABLE $rename_index_table RENAME TO $renamed_index_table" ); +sdi_sqlancer_query( "DROP INDEX c1 ON $renamed_index_table LOCK=SHARED" ); +$rename_drop_index_row = $wpdb->get_row( "SHOW INDEX FROM $renamed_index_table WHERE Key_name = 'c1'", ARRAY_A ); + $cast_table = $wpdb->prefix . 'sqlancer_cast_signed'; sdi_sqlancer_query( "DROP TABLE IF EXISTS $cast_table" ); sdi_sqlancer_query( "CREATE TABLE $cast_table(c0 SMALLINT UNIQUE KEY)" ); @@ -120,6 +129,7 @@ echo 'SQLANCER_ORDERING_EXPRESSION_JSON:' . wp_json_encode( $ordering_expression echo 'SQLANCER_INDEX_JSON:' . wp_json_encode( $index_row ) . PHP_EOL; echo 'SQLANCER_COUNT_DISTINCT_JSON:' . wp_json_encode( $count_distinct_row ) . PHP_EOL; echo 'SQLANCER_RENAME_JSON:' . wp_json_encode( $rename_row ) . PHP_EOL; +echo 'SQLANCER_RENAME_DROP_INDEX_JSON:' . wp_json_encode( $rename_drop_index_row ) . PHP_EOL; echo 'SQLANCER_CAST_SIGNED_JSON:' . wp_json_encode( $cast_row ) . PHP_EOL; echo 'SQLANCER_CAST_SIGNED_AFTER_UPDATE_JSON:' . wp_json_encode( $cast_after_update_row ) . PHP_EOL; `, @@ -267,6 +277,23 @@ echo 'SQLANCER_CAST_SIGNED_AFTER_UPDATE_JSON:' . wp_json_encode( $cast_after_upd } ); + const renameDropIndexJsonLine = output + .trim() + .split( /\r?\n/ ) + .find( ( line ) => + line.startsWith( 'SQLANCER_RENAME_DROP_INDEX_JSON:' ) + ); + + expect( renameDropIndexJsonLine ).toBeTruthy(); + expect( + JSON.parse( + renameDropIndexJsonLine.replace( + 'SQLANCER_RENAME_DROP_INDEX_JSON:', + '' + ) + ) + ).toBeNull(); + const castSignedJsonLine = output .trim() .split( /\r?\n/ ) From ec16b1523c79d9e7130c53fed234c95bb15906e3 Mon Sep 17 00:00:00 2001 From: adamziel Date: Wed, 17 Jun 2026 03:07:09 +0000 Subject: [PATCH 10/37] Round numeric strings for integer column saves --- .../sqlite/class-wp-pdo-mysql-on-sqlite.php | 2 +- ...s-wp-sqlite-pdo-user-defined-functions.php | 33 +++++++++++++++++++ .../tests/WP_SQLite_Driver_Tests.php | 14 ++++++-- .../specs/sqlancer-fuzz-regressions.test.js | 26 +++++++++++++++ 4 files changed, 72 insertions(+), 3 deletions(-) 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 79f889673..a31835cff 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 @@ -6356,7 +6356,7 @@ private function cast_value_for_saving( return sprintf( 'CAST(ROUND(%s) AS INTEGER)', $translated_value ); } return sprintf( - "CASE WHEN TYPEOF(%s) IN ('integer', 'real') THEN CAST(ROUND(%s) AS INTEGER) ELSE %s END", + "CASE WHEN TYPEOF(%s) = 'blob' THEN %s ELSE _mysql_save_integer(%s) END", $translated_value, $translated_value, $translated_value diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-pdo-user-defined-functions.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-pdo-user-defined-functions.php index cf3ab7f47..04787bf33 100644 --- a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-pdo-user-defined-functions.php +++ b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-pdo-user-defined-functions.php @@ -114,6 +114,7 @@ private static function get_sqlite_deterministic_flag(): int { // Internal helper functions. '_mysql_cast_integer' => '_mysql_cast_integer', + '_mysql_save_integer' => '_mysql_save_integer', '_mysql_count_distinct_tuple' => '_mysql_count_distinct_tuple', '_helper_like_to_glob_pattern' => '_helper_like_to_glob_pattern', ); @@ -158,6 +159,7 @@ private static function get_sqlite_deterministic_flag(): int { 'datediff' => true, 'locate' => true, '_mysql_cast_integer' => true, + '_mysql_save_integer' => true, '_mysql_count_distinct_tuple' => true, '_helper_like_to_glob_pattern' => true, ); @@ -315,6 +317,37 @@ public function _mysql_cast_integer( $value ) { return (int) $value; } + /** + * Emulate integer conversion used when MySQL saves values to integer columns. + * + * MySQL rounds numeric strings for integer-column storage, unlike + * CAST(... AS SIGNED), which truncates them. Non-numeric strings are returned + * unchanged so SQLite STRICT tables can reject them. + * + * @param int|float|string|null $value Value to cast. + * + * @return int|string|null Integer cast value, original string, or null. + */ + public function _mysql_save_integer( $value ) { + if ( null === $value ) { + return null; + } + + if ( is_int( $value ) ) { + return $value; + } + + if ( is_float( $value ) ) { + return (int) round( $value, 0, PHP_ROUND_HALF_UP ); + } + + if ( is_string( $value ) && is_numeric( $value ) ) { + return (int) round( (float) $value, 0, PHP_ROUND_HALF_UP ); + } + + return $value; + } + /** * Build an internal key for MySQL COUNT(DISTINCT expr, ...). * diff --git a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php index 0398afb63..55275b57b 100644 --- a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php @@ -10574,6 +10574,16 @@ public function testSqlancerInsertIgnoreCoercesInvalidNumericValues(): void { $this->assertSame( '2', $result[0]->rows_count ); } + public function testSqlancerReplaceRoundsNumericStringsForIntegerColumns(): void { + $this->assertQuery( 'CREATE TABLE t0(c0 BIGINT(154) UNIQUE KEY)' ); + $this->assertQuery( 'REPLACE LOW_PRIORITY INTO t0(c0) VALUES("0.690236950119983")' ); + + $result = $this->assertQuery( 'SELECT c0 FROM t0' ); + + $this->assertCount( 1, $result ); + $this->assertSame( '1', $result[0]->c0 ); + } + public function testSqlancerDeleteIgnoreDropsIgnoredRows(): void { $this->assertQuery( 'CREATE TABLE t0(c0 DECIMAL)' ); $this->assertQuery( 'INSERT INTO t0(c0) VALUES(1), (2)' ); @@ -11327,15 +11337,14 @@ public function testCastValuesOnInsert(): void { $this->assertQuery( 'INSERT INTO t VALUES (1)' ); $this->assertQuery( "INSERT INTO t VALUES ('2')" ); $this->assertQuery( "INSERT INTO t VALUES ('3.0')" ); + $this->assertQuery( "INSERT INTO t VALUES ('4.5')" ); $is_legacy_sqlite = version_compare( $this->engine->get_sqlite_version(), WP_PDO_MySQL_On_SQLite::MINIMUM_SQLITE_VERSION, '<' ); if ( $is_legacy_sqlite ) { - $this->assertQuery( "INSERT INTO t VALUES ('4.5')" ); $this->assertQuery( 'INSERT INTO t VALUES (0x05)' ); $this->assertQuery( "INSERT INTO t VALUES (x'06')" ); } else { // TODO: These are supported in MySQL: - $this->assertQueryError( "INSERT INTO t VALUES ('4.5')", 'SQLSTATE[23000]: Integrity constraint violation: 19 cannot store REAL value in INTEGER column t.value' ); $this->assertQueryError( 'INSERT INTO t VALUES (0x05)', 'SQLSTATE[23000]: Integrity constraint violation: 19 cannot store BLOB value in INTEGER column t.value' ); $this->assertQueryError( "INSERT INTO t VALUES (x'06')", 'SQLSTATE[23000]: Integrity constraint violation: 19 cannot store BLOB value in INTEGER column t.value' ); } @@ -11348,6 +11357,7 @@ public function testCastValuesOnInsert(): void { $this->assertSame( '1', $result[4]->value ); $this->assertSame( '2', $result[5]->value ); $this->assertSame( '3', $result[6]->value ); + $this->assertSame( '5', $result[7]->value ); $this->assertQuery( 'DROP TABLE t' ); // FLOAT diff --git a/tests/e2e/specs/sqlancer-fuzz-regressions.test.js b/tests/e2e/specs/sqlancer-fuzz-regressions.test.js index b11cb0da5..b6ddbf026 100644 --- a/tests/e2e/specs/sqlancer-fuzz-regressions.test.js +++ b/tests/e2e/specs/sqlancer-fuzz-regressions.test.js @@ -56,6 +56,12 @@ if ( '2' !== (string) $row['rows_count'] ) { throw new RuntimeException( 'Expected INSERT DELAYED IGNORE to insert a second row.' ); } +$integer_string_table = $wpdb->prefix . 'sqlancer_integer_string_t0'; +sdi_sqlancer_query( "DROP TABLE IF EXISTS $integer_string_table" ); +sdi_sqlancer_query( "CREATE TABLE $integer_string_table(c0 BIGINT(154) UNIQUE KEY)" ); +sdi_sqlancer_query( "REPLACE LOW_PRIORITY INTO $integer_string_table(c0) VALUES(\\"0.690236950119983\\")" ); +$integer_string_row = $wpdb->get_row( "SELECT c0 FROM $integer_string_table", ARRAY_A ); + sdi_sqlancer_query( "DROP TABLE IF EXISTS $table" ); sdi_sqlancer_query( "CREATE TABLE $table(c0 DECIMAL)" ); sdi_sqlancer_query( "INSERT INTO $table(c0) VALUES(1), (2)" ); @@ -132,6 +138,7 @@ echo 'SQLANCER_RENAME_JSON:' . wp_json_encode( $rename_row ) . PHP_EOL; echo 'SQLANCER_RENAME_DROP_INDEX_JSON:' . wp_json_encode( $rename_drop_index_row ) . PHP_EOL; echo 'SQLANCER_CAST_SIGNED_JSON:' . wp_json_encode( $cast_row ) . PHP_EOL; echo 'SQLANCER_CAST_SIGNED_AFTER_UPDATE_JSON:' . wp_json_encode( $cast_after_update_row ) . PHP_EOL; +echo 'SQLANCER_INTEGER_STRING_JSON:' . wp_json_encode( $integer_string_row ) . PHP_EOL; `, ], { @@ -153,6 +160,25 @@ echo 'SQLANCER_CAST_SIGNED_AFTER_UPDATE_JSON:' . wp_json_encode( $cast_after_upd } ); + const integerStringJsonLine = output + .trim() + .split( /\r?\n/ ) + .find( ( line ) => + line.startsWith( 'SQLANCER_INTEGER_STRING_JSON:' ) + ); + + expect( integerStringJsonLine ).toBeTruthy(); + expect( + JSON.parse( + integerStringJsonLine.replace( + 'SQLANCER_INTEGER_STRING_JSON:', + '' + ) + ) + ).toEqual( { + c0: '1', + } ); + const bitCountJsonLine = output .trim() .split( /\r?\n/ ) From c7b584c131838f2fe0816b6a54cfc8554de0c5a8 Mon Sep 17 00:00:00 2001 From: adamziel Date: Wed, 17 Jun 2026 03:34:01 +0000 Subject: [PATCH 11/37] Handle SQLancer memory table defaults --- .../sqlite/class-wp-pdo-mysql-on-sqlite.php | 77 ++++++++++++++++++- .../specs/sqlancer-fuzz-regressions.test.js | 35 +++++++++ 2 files changed, 111 insertions(+), 1 deletion(-) 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 a31835cff..a237070b4 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 @@ -5483,6 +5483,7 @@ private function translate_insert_or_replace_body( // Get column metadata for the target table from the information schema. $is_temporary = $this->information_schema_builder->temporary_table_exists( $table_name ); $columns_table = $this->information_schema_builder->get_table_name( $is_temporary, 'columns' ); + $tables_table = $this->information_schema_builder->get_table_name( $is_temporary, 'tables' ); $columns = $this->execute_sqlite_query( ' SELECT LOWER(column_name) AS COLUMN_NAME, is_nullable, column_default, data_type, extra @@ -5505,6 +5506,24 @@ private function translate_insert_or_replace_body( ); } + $table_engine = $this->execute_sqlite_query( + ' + SELECT engine + FROM ' . $this->quote_sqlite_identifier( $tables_table ) . ' + WHERE table_schema = ? + AND table_name = ? + ', + array( $database, $table_name ) + )->fetchColumn(); + + $use_non_transactional_multi_row_defaults = ( + $is_strict_mode + && ! $ignore_errors + && false !== $table_engine + && $this->is_non_transactional_table_engine( $table_engine ) + && $this->is_multi_row_insert_values( $node ) + ); + // Get a list of columns that are targeted by the INSERT or REPLACE query. // This is either an explicit column list, or all columns of the table. $insert_list = array(); @@ -5647,7 +5666,11 @@ function ( $column ) use ( $use_implicit_defaults, $insert_map ) { // When a column value is included, we need to apply type casting. $position = array_search( $column['COLUMN_NAME'], $insert_list, true ); $identifier = $this->quote_sqlite_identifier( $select_list[ $position ] ); - $value = $this->cast_value_for_saving( $column['DATA_TYPE'], $identifier, $ignore_errors ); + $value = $this->cast_value_for_saving( + $column['DATA_TYPE'], + $identifier, + $ignore_errors || $use_non_transactional_multi_row_defaults + ); $is_auto_increment = str_contains( $column['EXTRA'], 'auto_increment' ); /* @@ -5686,6 +5709,24 @@ function ( $column ) use ( $use_implicit_defaults, $insert_map ) { $value = sprintf( 'COALESCE(%s, %s)', $value, $this->quote_sqlite_value( $implicit_default ) ); } } + + /* + * In strict mode, MySQL still keeps preceding rows for multi-row + * writes to non-transactional tables. If a later row saves an + * invalid value to a NOT NULL column, MySQL stores the column's + * implicit default and emits a warning rather than rolling back + * the statement. + */ + if ( + $use_non_transactional_multi_row_defaults + && ! $is_auto_increment + && 'NO' === $column['IS_NULLABLE'] + ) { + $implicit_default = self::DATA_TYPE_IMPLICIT_DEFAULT_MAP[ $column['DATA_TYPE'] ] ?? null; + if ( null !== $implicit_default ) { + $value = sprintf( 'COALESCE(%s, %s)', $value, $this->quote_sqlite_value( $implicit_default ) ); + } + } $fragment .= $value; } } @@ -5748,6 +5789,40 @@ function ( $column ) use ( $use_implicit_defaults, $insert_map ) { return $fragment; } + /** + * Check whether a table engine is non-transactional. + * + * @param string $table_engine The information schema table engine. + * @return bool Whether the engine is non-transactional. + */ + private function is_non_transactional_table_engine( string $table_engine ): bool { + return in_array( strtoupper( $table_engine ), array( 'MEMORY', 'MYISAM' ), true ); + } + + /** + * Check whether an INSERT/REPLACE body contains multiple VALUES rows. + * + * @param WP_Parser_Node $node The INSERT/REPLACE body node. + * @return bool Whether the node is a multi-row VALUES body. + */ + private function is_multi_row_insert_values( WP_Parser_Node $node ): bool { + if ( 'insertFromConstructor' !== $node->rule_name ) { + return false; + } + + $insert_values = $node->get_first_child_node( 'insertValues' ); + if ( null === $insert_values ) { + return false; + } + + $value_list = $insert_values->get_first_child_node( 'valueList' ); + if ( null === $value_list ) { + return false; + } + + return count( $value_list->get_child_nodes( 'values' ) ) > 1; + } + /** * Translate UPDATE statement SET value list to SQLite, while emulating * MySQL column type casting and implicit default values when saving data. diff --git a/tests/e2e/specs/sqlancer-fuzz-regressions.test.js b/tests/e2e/specs/sqlancer-fuzz-regressions.test.js index b6ddbf026..8041f764f 100644 --- a/tests/e2e/specs/sqlancer-fuzz-regressions.test.js +++ b/tests/e2e/specs/sqlancer-fuzz-regressions.test.js @@ -62,6 +62,13 @@ sdi_sqlancer_query( "CREATE TABLE $integer_string_table(c0 BIGINT(154) UNIQUE KE sdi_sqlancer_query( "REPLACE LOW_PRIORITY INTO $integer_string_table(c0) VALUES(\\"0.690236950119983\\")" ); $integer_string_row = $wpdb->get_row( "SELECT c0 FROM $integer_string_table", ARRAY_A ); +$memory_default_table = $wpdb->prefix . 'sqlancer_memory_implicit_default'; +sdi_sqlancer_query( "DROP TABLE IF EXISTS $memory_default_table" ); +sdi_sqlancer_query( "CREATE TABLE $memory_default_table(c0 BIGINT ZEROFILL COMMENT 'asdf' COLUMN_FORMAT DYNAMIC PRIMARY KEY) ENGINE = MEMORY, AUTO_INCREMENT = 4115509118782610296" ); +sdi_sqlancer_query( "REPLACE INTO $memory_default_table(c0) VALUES(0.5986269975342084), (4.1155091187826104E18), (NULL)" ); +$memory_default_rows = $wpdb->get_results( "SELECT c0 FROM $memory_default_table ORDER BY c0", ARRAY_A ); +sdi_sqlancer_query( "REPLACE INTO $memory_default_table(c0) VALUES(0.5853108370608123), (\\"4115509118782610296\\"), (1862704922), (\\"0.27004938366761855\\"), (\\"dwHq\\")" ); + sdi_sqlancer_query( "DROP TABLE IF EXISTS $table" ); sdi_sqlancer_query( "CREATE TABLE $table(c0 DECIMAL)" ); sdi_sqlancer_query( "INSERT INTO $table(c0) VALUES(1), (2)" ); @@ -139,6 +146,7 @@ echo 'SQLANCER_RENAME_DROP_INDEX_JSON:' . wp_json_encode( $rename_drop_index_row echo 'SQLANCER_CAST_SIGNED_JSON:' . wp_json_encode( $cast_row ) . PHP_EOL; echo 'SQLANCER_CAST_SIGNED_AFTER_UPDATE_JSON:' . wp_json_encode( $cast_after_update_row ) . PHP_EOL; echo 'SQLANCER_INTEGER_STRING_JSON:' . wp_json_encode( $integer_string_row ) . PHP_EOL; +echo 'SQLANCER_MEMORY_DEFAULT_JSON:' . wp_json_encode( $memory_default_rows ) . PHP_EOL; `, ], { @@ -179,6 +187,33 @@ echo 'SQLANCER_INTEGER_STRING_JSON:' . wp_json_encode( $integer_string_row ) . P c0: '1', } ); + const memoryDefaultJsonLine = output + .trim() + .split( /\r?\n/ ) + .find( ( line ) => + line.startsWith( 'SQLANCER_MEMORY_DEFAULT_JSON:' ) + ); + + expect( memoryDefaultJsonLine ).toBeTruthy(); + expect( + JSON.parse( + memoryDefaultJsonLine.replace( + 'SQLANCER_MEMORY_DEFAULT_JSON:', + '' + ) + ) + ).toEqual( [ + { + c0: '0', + }, + { + c0: '1', + }, + { + c0: '4115509118782610432', + }, + ] ); + const bitCountJsonLine = output .trim() .split( /\r?\n/ ) From 95aee1636245317a161c624ed20e4d94ce2db39d Mon Sep 17 00:00:00 2001 From: adamziel Date: Wed, 17 Jun 2026 03:47:10 +0000 Subject: [PATCH 12/37] Handle SQLancer literal index expressions --- .../sqlite/class-wp-pdo-mysql-on-sqlite.php | 57 +++++++++++++++++-- .../specs/sqlancer-fuzz-regressions.test.js | 30 ++++++++++ 2 files changed, 82 insertions(+), 5 deletions(-) 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 a237070b4..4a22fc2be 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 @@ -2716,10 +2716,7 @@ private function execute_create_index_statement( WP_Parser_Node $node ): void { $key_part .= ' ' . $this->translate( $direction ); } } else { - $key_part = $this->dequalify_index_expression( - $this->translate( $key_part_node ), - $table_name - ); + $key_part = $this->translate_index_expression( $key_part_node, $table_name ); } $key_parts[] = $key_part; } @@ -2747,7 +2744,57 @@ private function translate_stored_index_expression( string $expression, string $ $ast = $this->create_parser( 'SELECT ' . $expression )->parse(); $expr = $ast->get_first_descendant_node( 'selectItem' )->get_first_child_node(); - return $this->dequalify_index_expression( $this->translate( $expr ), $table_name ); + return $this->translate_index_expression( $expr, $table_name ); + } + + /** + * Translate a functional index expression for SQLite CREATE INDEX. + * + * SQLite resolves bare single-quoted string literals in CREATE INDEX + * expressions as identifiers, so force literal-only text expressions to + * stay text-valued. + * + * @param WP_Parser_Node $node The MySQL expression AST node. + * @param string $table_name The table being indexed. + * @return string The translated SQLite expression. + */ + private function translate_index_expression( WP_Parser_Node $node, string $table_name ): string { + $expression = $this->dequalify_index_expression( $this->translate( $node ), $table_name ); + + if ( $this->is_text_literal_index_expression( $node ) ) { + return sprintf( 'CAST(%s AS TEXT)', $expression ); + } + + return $expression; + } + + /** + * Check whether an index expression is only a text string literal. + * + * @param WP_Parser_Node $node The expression AST node. + * @return bool Whether the expression is a text string literal. + */ + private function is_text_literal_index_expression( WP_Parser_Node $node ): bool { + if ( 'textStringLiteral' === $node->rule_name ) { + return true; + } + + $children = $node->get_children(); + + if ( 1 === count( $children ) && $children[0] instanceof WP_Parser_Node ) { + return $this->is_text_literal_index_expression( $children[0] ); + } + + if ( + 3 === count( $children ) + && $children[0] instanceof WP_MySQL_Token && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $children[0]->id + && $children[1] instanceof WP_Parser_Node + && $children[2] instanceof WP_MySQL_Token && WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $children[2]->id + ) { + return $this->is_text_literal_index_expression( $children[1] ); + } + + return false; } /** diff --git a/tests/e2e/specs/sqlancer-fuzz-regressions.test.js b/tests/e2e/specs/sqlancer-fuzz-regressions.test.js index 8041f764f..f5241344f 100644 --- a/tests/e2e/specs/sqlancer-fuzz-regressions.test.js +++ b/tests/e2e/specs/sqlancer-fuzz-regressions.test.js @@ -101,6 +101,12 @@ sdi_sqlancer_query( "CREATE INDEX i0 ON $index_table(((IF(NULL, $index_table.c1, sdi_sqlancer_query( "ALTER TABLE $index_table DISABLE KEYS" ); $index_row = $wpdb->get_row( "SHOW INDEX FROM $index_table WHERE Key_name = 'i0'", ARRAY_A ); +$literal_index_table = $wpdb->prefix . 'sqlancer_literal_index'; +sdi_sqlancer_query( "DROP TABLE IF EXISTS $literal_index_table" ); +sdi_sqlancer_query( "CREATE TABLE $literal_index_table(c1 MEDIUMINT UNIQUE KEY COLUMN_FORMAT DEFAULT COMMENT 'asdf')" ); +sdi_sqlancer_query( "CREATE UNIQUE INDEX i_sqlancer_literal ON $literal_index_table(('+4')) VISIBLE" ); +$literal_index_row = $wpdb->get_row( "SHOW INDEX FROM $literal_index_table WHERE Key_name = 'i_sqlancer_literal'", ARRAY_A ); + $count_distinct_table = $wpdb->prefix . 'sqlancer_count_distinct'; sdi_sqlancer_query( "DROP TABLE IF EXISTS $count_distinct_table" ); sdi_sqlancer_query( "CREATE TABLE $count_distinct_table(c0 INT, c1 TEXT)" ); @@ -140,6 +146,7 @@ echo 'SQLANCER_BOOLEAN_JSON:' . wp_json_encode( $boolean_row ) . PHP_EOL; echo 'SQLANCER_ORDER_NEGATIVE_JSON:' . wp_json_encode( $order_negative_row ) . PHP_EOL; echo 'SQLANCER_ORDERING_EXPRESSION_JSON:' . wp_json_encode( $ordering_expression_row ) . PHP_EOL; echo 'SQLANCER_INDEX_JSON:' . wp_json_encode( $index_row ) . PHP_EOL; +echo 'SQLANCER_LITERAL_INDEX_JSON:' . wp_json_encode( $literal_index_row ) . PHP_EOL; echo 'SQLANCER_COUNT_DISTINCT_JSON:' . wp_json_encode( $count_distinct_row ) . PHP_EOL; echo 'SQLANCER_RENAME_JSON:' . wp_json_encode( $rename_row ) . PHP_EOL; echo 'SQLANCER_RENAME_DROP_INDEX_JSON:' . wp_json_encode( $rename_drop_index_row ) . PHP_EOL; @@ -307,6 +314,29 @@ echo 'SQLANCER_MEMORY_DEFAULT_JSON:' . wp_json_encode( $memory_default_rows ) . expect( indexRow.Expression ).toContain( 'sqlancer_expr_index . c1' ); expect( indexRow.Expression ).toContain( 'sqlancer_expr_index . c0' ); + const literalIndexJsonLine = output + .trim() + .split( /\r?\n/ ) + .find( ( line ) => + line.startsWith( 'SQLANCER_LITERAL_INDEX_JSON:' ) + ); + + expect( literalIndexJsonLine ).toBeTruthy(); + expect( + JSON.parse( + literalIndexJsonLine.replace( + 'SQLANCER_LITERAL_INDEX_JSON:', + '' + ) + ) + ).toEqual( + expect.objectContaining( { + Key_name: 'i_sqlancer_literal', + Column_name: null, + Expression: "'+4'", + } ) + ); + const countDistinctJsonLine = output .trim() .split( /\r?\n/ ) From 9ef077b55a5714e972854c906aab4b7210cf2855 Mon Sep 17 00:00:00 2001 From: adamziel Date: Wed, 17 Jun 2026 03:52:59 +0000 Subject: [PATCH 13/37] Handle SQLancer redundant index drops --- .../sqlite/class-wp-pdo-mysql-on-sqlite.php | 61 +++++++++++++++++++ .../tests/WP_SQLite_Driver_Tests.php | 24 ++++++++ .../specs/sqlancer-fuzz-regressions.test.js | 29 +++++++++ 3 files changed, 114 insertions(+) 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 4a22fc2be..c442cbe18 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 @@ -2847,7 +2847,14 @@ private function execute_drop_index_statement( WP_Parser_Node $node ): void { } $sqlite_index_name = $this->resolve_sqlite_index_name( $table_name, $index_name ); + $index_exists = $this->sqlite_index_exists( $table_name, $sqlite_index_name ); + $drop_is_noop = ! $index_exists && $this->is_redundant_primary_key_column_index_drop( $table_name, $index_name ); + $this->information_schema_builder->record_drop_index( $node ); + if ( $drop_is_noop ) { + return; + } + $this->execute_sqlite_query( sprintf( 'DROP INDEX %s', @@ -2900,6 +2907,60 @@ private function resolve_sqlite_index_name( string $table_name, string $index_na return $expected_sqlite_index_name; } + /** + * Check whether a physical SQLite index exists on a table. + * + * @param string $table_name The MySQL table name. + * @param string $sqlite_index_name The SQLite index name. + * @return bool Whether the index exists. + */ + private function sqlite_index_exists( string $table_name, string $sqlite_index_name ): bool { + $indexes = $this->execute_sqlite_query( + sprintf( + 'PRAGMA index_list(%s)', + $this->quote_sqlite_identifier( $table_name ) + ) + )->fetchAll( PDO::FETCH_ASSOC ); + + return in_array( $sqlite_index_name, array_column( $indexes, 'name' ), true ); + } + + /** + * Check whether a missing index drop targets a redundant primary column index. + * + * MySQL accepts "DROP INDEX column_name" for a column declared with both + * PRIMARY KEY and UNIQUE KEY. SQLite only creates the primary-key autoindex, + * so there is no separate physical index to drop. + * + * @param string $table_name The MySQL table name. + * @param string $index_name The MySQL index name. + * @return bool Whether the drop is a MySQL-compatible no-op. + */ + private function is_redundant_primary_key_column_index_drop( string $table_name, string $index_name ): bool { + $table_is_temporary = $this->information_schema_builder->temporary_table_exists( $table_name ); + $statistics_table = $this->information_schema_builder->get_table_name( $table_is_temporary, 'statistics' ); + $row = $this->execute_sqlite_query( + sprintf( + 'SELECT + COUNT(*) AS primary_parts, + SUM(column_name = ? AND seq_in_index = 1) AS matching_parts + FROM %s + WHERE table_schema = ? + AND table_name = ? + AND index_name = ?', + $this->quote_sqlite_identifier( $statistics_table ) + ), + array( + $index_name, + $this->get_saved_db_name(), + $table_name, + 'PRIMARY', + ) + )->fetch( PDO::FETCH_ASSOC ); + + return 1 === (int) $row['primary_parts'] && 1 === (int) $row['matching_parts']; + } + /** * Translate and execute a MySQL SHOW statement in SQLite. * diff --git a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php index 55275b57b..de0e30f8d 100644 --- a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php @@ -6787,6 +6787,30 @@ public function testDropIndex(): void { $this->assertCount( 0, $result ); } + public function testDropRedundantPrimaryColumnUniqueIndex(): void { + $this->assertQuery( "CREATE TABLE t (c0 DECIMAL ZEROFILL PRIMARY KEY UNIQUE KEY COMMENT 'asdf' NOT NULL COLUMN_FORMAT FIXED STORAGE DISK)" ); + + $result = $this->assertQuery( 'SHOW INDEX FROM t' ); + $this->assertCount( 1, $result ); + $this->assertEquals( 'PRIMARY', $result[0]->Key_name ); + $this->assertEquals( 'c0', $result[0]->Column_name ); + + $result = $this->engine->execute_sqlite_query( "PRAGMA index_list('t')" )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertCount( 1, $result ); + $this->assertEquals( 'sqlite_autoindex_t_1', $result[0]['name'] ); + + $this->assertQuery( 'DROP INDEX c0 ON t ALGORITHM=DEFAULT LOCK=DEFAULT' ); + + $result = $this->assertQuery( 'SHOW INDEX FROM t' ); + $this->assertCount( 1, $result ); + $this->assertEquals( 'PRIMARY', $result[0]->Key_name ); + $this->assertEquals( 'c0', $result[0]->Column_name ); + + $result = $this->engine->execute_sqlite_query( "PRAGMA index_list('t')" )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertCount( 1, $result ); + $this->assertEquals( 'sqlite_autoindex_t_1', $result[0]['name'] ); + } + public function testComplexInformationSchemaQueries(): void { $create_table_query = <<get_row( "SHOW INDEX FROM $literal_index_table WHERE Key_name = 'i_sqlancer_literal'", ARRAY_A ); +$redundant_index_table = $wpdb->prefix . 'sqlancer_redundant_index'; +sdi_sqlancer_query( "DROP TABLE IF EXISTS $redundant_index_table" ); +sdi_sqlancer_query( "CREATE TABLE $redundant_index_table(c0 DECIMAL ZEROFILL PRIMARY KEY UNIQUE KEY COMMENT 'asdf' NOT NULL COLUMN_FORMAT FIXED STORAGE DISK)" ); +sdi_sqlancer_query( "DROP INDEX c0 ON $redundant_index_table ALGORITHM=DEFAULT LOCK=DEFAULT" ); +$redundant_index_rows = $wpdb->get_results( "SHOW INDEX FROM $redundant_index_table", ARRAY_A ); + $count_distinct_table = $wpdb->prefix . 'sqlancer_count_distinct'; sdi_sqlancer_query( "DROP TABLE IF EXISTS $count_distinct_table" ); sdi_sqlancer_query( "CREATE TABLE $count_distinct_table(c0 INT, c1 TEXT)" ); @@ -147,6 +153,7 @@ echo 'SQLANCER_ORDER_NEGATIVE_JSON:' . wp_json_encode( $order_negative_row ) . P echo 'SQLANCER_ORDERING_EXPRESSION_JSON:' . wp_json_encode( $ordering_expression_row ) . PHP_EOL; echo 'SQLANCER_INDEX_JSON:' . wp_json_encode( $index_row ) . PHP_EOL; echo 'SQLANCER_LITERAL_INDEX_JSON:' . wp_json_encode( $literal_index_row ) . PHP_EOL; +echo 'SQLANCER_REDUNDANT_INDEX_JSON:' . wp_json_encode( $redundant_index_rows ) . PHP_EOL; echo 'SQLANCER_COUNT_DISTINCT_JSON:' . wp_json_encode( $count_distinct_row ) . PHP_EOL; echo 'SQLANCER_RENAME_JSON:' . wp_json_encode( $rename_row ) . PHP_EOL; echo 'SQLANCER_RENAME_DROP_INDEX_JSON:' . wp_json_encode( $rename_drop_index_row ) . PHP_EOL; @@ -337,6 +344,28 @@ echo 'SQLANCER_MEMORY_DEFAULT_JSON:' . wp_json_encode( $memory_default_rows ) . } ) ); + const redundantIndexJsonLine = output + .trim() + .split( /\r?\n/ ) + .find( ( line ) => + line.startsWith( 'SQLANCER_REDUNDANT_INDEX_JSON:' ) + ); + + expect( redundantIndexJsonLine ).toBeTruthy(); + expect( + JSON.parse( + redundantIndexJsonLine.replace( + 'SQLANCER_REDUNDANT_INDEX_JSON:', + '' + ) + ) + ).toEqual( [ + expect.objectContaining( { + Key_name: 'PRIMARY', + Column_name: 'c0', + } ), + ] ); + const countDistinctJsonLine = output .trim() .split( /\r?\n/ ) From d97cca1bf640fd4ddf413cf5c3880cabd768bdad Mon Sep 17 00:00:00 2001 From: adamziel Date: Wed, 17 Jun 2026 04:12:25 +0000 Subject: [PATCH 14/37] Honor DECIMAL scale when saving values --- .../sqlite/class-wp-pdo-mysql-on-sqlite.php | 33 ++++++++--- ...s-wp-sqlite-pdo-user-defined-functions.php | 32 +++++++++++ .../tests/WP_SQLite_Driver_Tests.php | 21 +++++++ .../specs/sqlancer-fuzz-regressions.test.js | 55 +++++++++++++++++++ 4 files changed, 133 insertions(+), 8 deletions(-) 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 c442cbe18..395335213 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 @@ -5594,7 +5594,7 @@ private function translate_insert_or_replace_body( $tables_table = $this->information_schema_builder->get_table_name( $is_temporary, 'tables' ); $columns = $this->execute_sqlite_query( ' - SELECT LOWER(column_name) AS COLUMN_NAME, is_nullable, column_default, data_type, extra + SELECT LOWER(column_name) AS COLUMN_NAME, is_nullable, column_default, data_type, numeric_scale, extra FROM ' . $this->quote_sqlite_identifier( $columns_table ) . ' WHERE table_schema = ? AND table_name = ? @@ -5777,7 +5777,8 @@ function ( $column ) use ( $use_implicit_defaults, $insert_map ) { $value = $this->cast_value_for_saving( $column['DATA_TYPE'], $identifier, - $ignore_errors || $use_non_transactional_multi_row_defaults + $ignore_errors || $use_non_transactional_multi_row_defaults, + null === $column['NUMERIC_SCALE'] ? null : (int) $column['NUMERIC_SCALE'] ); $is_auto_increment = str_contains( $column['EXTRA'], 'auto_increment' ); @@ -5983,7 +5984,7 @@ private function translate_update_list( string $table_name, WP_Parser_Node $pare $columns_table = $this->information_schema_builder->get_table_name( $is_temporary, 'columns' ); $columns = $this->execute_sqlite_query( ' - SELECT LOWER(column_name) AS COLUMN_NAME, is_nullable, data_type, column_default + SELECT LOWER(column_name) AS COLUMN_NAME, is_nullable, data_type, numeric_scale, column_default FROM ' . $this->quote_sqlite_identifier( $columns_table ) . ' WHERE table_schema = ? AND table_name = ? @@ -6024,9 +6025,10 @@ private function translate_update_list( string $table_name, WP_Parser_Node $pare ); } - $data_type = $column_info['DATA_TYPE']; - $is_nullable = 'YES' === $column_info['IS_NULLABLE']; - $default = $column_info['COLUMN_DEFAULT']; + $data_type = $column_info['DATA_TYPE']; + $numeric_scale = null === $column_info['NUMERIC_SCALE'] ? null : (int) $column_info['NUMERIC_SCALE']; + $is_nullable = 'YES' === $column_info['IS_NULLABLE']; + $default = $column_info['COLUMN_DEFAULT']; // Get the UPDATE value. It's either an expression or a DEFAULT keyword. if ( null === $expr ) { @@ -6037,7 +6039,7 @@ private function translate_update_list( string $table_name, WP_Parser_Node $pare } // Apply type casting. - $value = $this->cast_value_for_saving( $data_type, $value ); + $value = $this->cast_value_for_saving( $data_type, $value, false, $numeric_scale ); /* * In MySQL non-STRICT mode, when a column is declared as NOT NULL, @@ -6381,7 +6383,8 @@ private function create_table_reference_map( WP_Parser_Node $node ): array { private function cast_value_for_saving( string $mysql_data_type, string $translated_value, - bool $ignore_errors = false + bool $ignore_errors = false, + ?int $numeric_scale = null ): string { // TODO: This is also a good place to implement checks for maximum column // lengths with truncating or bailing out depending on the SQL mode. @@ -6546,6 +6549,20 @@ private function cast_value_for_saving( ); } + if ( 'decimal' === $mysql_data_type ) { + $numeric_scale = $numeric_scale ?? 0; + if ( ! $is_strict_mode || $ignore_errors ) { + return sprintf( 'CAST(ROUND(%s, %d) AS %s)', $translated_value, $numeric_scale, $sqlite_data_type ); + } + return sprintf( + "CASE WHEN TYPEOF(%s) = 'blob' THEN %s ELSE _mysql_save_decimal(%s, %d) END", + $translated_value, + $translated_value, + $translated_value, + $numeric_scale + ); + } + if ( ! $is_strict_mode || $ignore_errors || 'TEXT' === $sqlite_data_type || 'BLOB' === $sqlite_data_type ) { return sprintf( 'CAST(%s AS %s)', $translated_value, $sqlite_data_type ); } diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-pdo-user-defined-functions.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-pdo-user-defined-functions.php index 04787bf33..3f484cb74 100644 --- a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-pdo-user-defined-functions.php +++ b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-pdo-user-defined-functions.php @@ -115,6 +115,7 @@ private static function get_sqlite_deterministic_flag(): int { // Internal helper functions. '_mysql_cast_integer' => '_mysql_cast_integer', '_mysql_save_integer' => '_mysql_save_integer', + '_mysql_save_decimal' => '_mysql_save_decimal', '_mysql_count_distinct_tuple' => '_mysql_count_distinct_tuple', '_helper_like_to_glob_pattern' => '_helper_like_to_glob_pattern', ); @@ -160,6 +161,7 @@ private static function get_sqlite_deterministic_flag(): int { 'locate' => true, '_mysql_cast_integer' => true, '_mysql_save_integer' => true, + '_mysql_save_decimal' => true, '_mysql_count_distinct_tuple' => true, '_helper_like_to_glob_pattern' => true, ); @@ -348,6 +350,36 @@ public function _mysql_save_integer( $value ) { return $value; } + /** + * Emulate decimal conversion used when MySQL saves values to DECIMAL columns. + * + * MySQL rounds numeric values and numeric strings to the column scale. + * Non-numeric strings are returned unchanged so SQLite STRICT tables can + * reject them. + * + * @param int|float|string|null $value Value to cast. + * @param int|float|string|null $scale Decimal scale. + * + * @return int|float|string|null Rounded decimal value, original string, or null. + */ + public function _mysql_save_decimal( $value, $scale ) { + if ( null === $value ) { + return null; + } + + $scale = max( 0, (int) $scale ); + + if ( is_int( $value ) || is_float( $value ) ) { + return round( $value, $scale, PHP_ROUND_HALF_UP ); + } + + if ( is_string( $value ) && is_numeric( $value ) ) { + return round( (float) $value, $scale, PHP_ROUND_HALF_UP ); + } + + return $value; + } + /** * Build an internal key for MySQL COUNT(DISTINCT expr, ...). * diff --git a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php index de0e30f8d..5ce5405aa 100644 --- a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php @@ -10608,6 +10608,27 @@ public function testSqlancerReplaceRoundsNumericStringsForIntegerColumns(): void $this->assertSame( '1', $result[0]->c0 ); } + public function testSqlancerDecimalScaleIsAppliedBeforeUniqueChecks(): void { + $this->assertQuery( "CREATE TABLE t0(c0 DECIMAL COMMENT 'asdf' COLUMN_FORMAT DYNAMIC UNIQUE PRIMARY KEY STORAGE MEMORY)" ); + $this->assertQuery( 'DROP INDEX c0 ON t0' ); + $this->assertQuery( 'INSERT DELAYED IGNORE INTO t0(c0) VALUES(901185469)' ); + $this->assertQuery( 'DELETE LOW_PRIORITY FROM t0 WHERE (! ( EXISTS (SELECT 1 WHERE FALSE)))' ); + $this->assertQuery( 'REPLACE LOW_PRIORITY INTO t0(c0) VALUES("0.04610308300972621")' ); + $this->assertQuery( 'INSERT IGNORE INTO t0(c0) VALUES(0.38956910632549635)' ); + + $result = $this->assertQuery( 'SELECT COUNT(*) AS rows_count, c0 FROM t0 GROUP BY c0' ); + + $this->assertCount( 1, $result ); + $this->assertSame( '1', $result[0]->rows_count ); + $this->assertSame( '0', (string) (int) $result[0]->c0 ); + + $this->assertQuery( 'UPDATE t0 SET c0=-271461335' ); + $result = $this->assertQuery( 'SELECT c0 FROM t0' ); + + $this->assertCount( 1, $result ); + $this->assertSame( '-271461335', (string) (int) $result[0]->c0 ); + } + public function testSqlancerDeleteIgnoreDropsIgnoredRows(): void { $this->assertQuery( 'CREATE TABLE t0(c0 DECIMAL)' ); $this->assertQuery( 'INSERT INTO t0(c0) VALUES(1), (2)' ); diff --git a/tests/e2e/specs/sqlancer-fuzz-regressions.test.js b/tests/e2e/specs/sqlancer-fuzz-regressions.test.js index 1870fe3c3..3890f71be 100644 --- a/tests/e2e/specs/sqlancer-fuzz-regressions.test.js +++ b/tests/e2e/specs/sqlancer-fuzz-regressions.test.js @@ -62,6 +62,18 @@ sdi_sqlancer_query( "CREATE TABLE $integer_string_table(c0 BIGINT(154) UNIQUE KE sdi_sqlancer_query( "REPLACE LOW_PRIORITY INTO $integer_string_table(c0) VALUES(\\"0.690236950119983\\")" ); $integer_string_row = $wpdb->get_row( "SELECT c0 FROM $integer_string_table", ARRAY_A ); +$decimal_scale_table = $wpdb->prefix . 'sqlancer_decimal_scale_t0'; +sdi_sqlancer_query( "DROP TABLE IF EXISTS $decimal_scale_table" ); +sdi_sqlancer_query( "CREATE TABLE $decimal_scale_table(c0 DECIMAL COMMENT 'asdf' COLUMN_FORMAT DYNAMIC UNIQUE PRIMARY KEY STORAGE MEMORY)" ); +sdi_sqlancer_query( "DROP INDEX c0 ON $decimal_scale_table" ); +sdi_sqlancer_query( "INSERT DELAYED IGNORE INTO $decimal_scale_table(c0) VALUES(901185469)" ); +sdi_sqlancer_query( "DELETE LOW_PRIORITY FROM $decimal_scale_table WHERE (! ( EXISTS (SELECT 1 WHERE FALSE)))" ); +sdi_sqlancer_query( "REPLACE LOW_PRIORITY INTO $decimal_scale_table(c0) VALUES(\\"0.04610308300972621\\")" ); +sdi_sqlancer_query( "INSERT IGNORE INTO $decimal_scale_table(c0) VALUES(0.38956910632549635)" ); +$decimal_scale_row = $wpdb->get_row( "SELECT COUNT(*) AS rows_count, c0 FROM $decimal_scale_table GROUP BY c0", ARRAY_A ); +sdi_sqlancer_query( "UPDATE $decimal_scale_table SET c0=-271461335" ); +$decimal_scale_after_update_row = $wpdb->get_row( "SELECT c0 FROM $decimal_scale_table", ARRAY_A ); + $memory_default_table = $wpdb->prefix . 'sqlancer_memory_implicit_default'; sdi_sqlancer_query( "DROP TABLE IF EXISTS $memory_default_table" ); sdi_sqlancer_query( "CREATE TABLE $memory_default_table(c0 BIGINT ZEROFILL COMMENT 'asdf' COLUMN_FORMAT DYNAMIC PRIMARY KEY) ENGINE = MEMORY, AUTO_INCREMENT = 4115509118782610296" ); @@ -160,6 +172,8 @@ echo 'SQLANCER_RENAME_DROP_INDEX_JSON:' . wp_json_encode( $rename_drop_index_row echo 'SQLANCER_CAST_SIGNED_JSON:' . wp_json_encode( $cast_row ) . PHP_EOL; echo 'SQLANCER_CAST_SIGNED_AFTER_UPDATE_JSON:' . wp_json_encode( $cast_after_update_row ) . PHP_EOL; echo 'SQLANCER_INTEGER_STRING_JSON:' . wp_json_encode( $integer_string_row ) . PHP_EOL; +echo 'SQLANCER_DECIMAL_SCALE_JSON:' . wp_json_encode( $decimal_scale_row ) . PHP_EOL; +echo 'SQLANCER_DECIMAL_SCALE_AFTER_UPDATE_JSON:' . wp_json_encode( $decimal_scale_after_update_row ) . PHP_EOL; echo 'SQLANCER_MEMORY_DEFAULT_JSON:' . wp_json_encode( $memory_default_rows ) . PHP_EOL; `, ], @@ -201,6 +215,47 @@ echo 'SQLANCER_MEMORY_DEFAULT_JSON:' . wp_json_encode( $memory_default_rows ) . c0: '1', } ); + const decimalScaleJsonLine = output + .trim() + .split( /\r?\n/ ) + .find( ( line ) => + line.startsWith( 'SQLANCER_DECIMAL_SCALE_JSON:' ) + ); + + expect( decimalScaleJsonLine ).toBeTruthy(); + expect( + JSON.parse( + decimalScaleJsonLine.replace( + 'SQLANCER_DECIMAL_SCALE_JSON:', + '' + ) + ) + ).toEqual( { + rows_count: '1', + c0: '0', + } ); + + const decimalScaleAfterUpdateJsonLine = output + .trim() + .split( /\r?\n/ ) + .find( ( line ) => + line.startsWith( + 'SQLANCER_DECIMAL_SCALE_AFTER_UPDATE_JSON:' + ) + ); + + expect( decimalScaleAfterUpdateJsonLine ).toBeTruthy(); + expect( + JSON.parse( + decimalScaleAfterUpdateJsonLine.replace( + 'SQLANCER_DECIMAL_SCALE_AFTER_UPDATE_JSON:', + '' + ) + ) + ).toEqual( { + c0: '-271461335', + } ); + const memoryDefaultJsonLine = output .trim() .split( /\r?\n/ ) From 17be34a863da28a4cfb647d86c48438df0d580cb Mon Sep 17 00:00:00 2001 From: adamziel Date: Wed, 17 Jun 2026 04:56:29 +0000 Subject: [PATCH 15/37] Handle SQLancer zerofill unsigned saves --- .../sqlite/class-wp-pdo-mysql-on-sqlite.php | 83 +++++++++++++++++-- .../tests/WP_SQLite_Driver_Tests.php | 19 +++++ .../specs/sqlancer-fuzz-regressions.test.js | 55 ++++++++++++ 3 files changed, 149 insertions(+), 8 deletions(-) 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 395335213..651dbd90d 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 @@ -5594,7 +5594,7 @@ private function translate_insert_or_replace_body( $tables_table = $this->information_schema_builder->get_table_name( $is_temporary, 'tables' ); $columns = $this->execute_sqlite_query( ' - SELECT LOWER(column_name) AS COLUMN_NAME, is_nullable, column_default, data_type, numeric_scale, extra + SELECT LOWER(column_name) AS COLUMN_NAME, is_nullable, column_default, data_type, numeric_scale, column_type, extra FROM ' . $this->quote_sqlite_identifier( $columns_table ) . ' WHERE table_schema = ? AND table_name = ? @@ -5778,7 +5778,8 @@ function ( $column ) use ( $use_implicit_defaults, $insert_map ) { $column['DATA_TYPE'], $identifier, $ignore_errors || $use_non_transactional_multi_row_defaults, - null === $column['NUMERIC_SCALE'] ? null : (int) $column['NUMERIC_SCALE'] + null === $column['NUMERIC_SCALE'] ? null : (int) $column['NUMERIC_SCALE'], + $column['COLUMN_TYPE'] ); $is_auto_increment = str_contains( $column['EXTRA'], 'auto_increment' ); @@ -5984,7 +5985,7 @@ private function translate_update_list( string $table_name, WP_Parser_Node $pare $columns_table = $this->information_schema_builder->get_table_name( $is_temporary, 'columns' ); $columns = $this->execute_sqlite_query( ' - SELECT LOWER(column_name) AS COLUMN_NAME, is_nullable, data_type, numeric_scale, column_default + SELECT LOWER(column_name) AS COLUMN_NAME, is_nullable, data_type, numeric_scale, column_default, column_type FROM ' . $this->quote_sqlite_identifier( $columns_table ) . ' WHERE table_schema = ? AND table_name = ? @@ -6027,6 +6028,7 @@ private function translate_update_list( string $table_name, WP_Parser_Node $pare $data_type = $column_info['DATA_TYPE']; $numeric_scale = null === $column_info['NUMERIC_SCALE'] ? null : (int) $column_info['NUMERIC_SCALE']; + $column_type = $column_info['COLUMN_TYPE']; $is_nullable = 'YES' === $column_info['IS_NULLABLE']; $default = $column_info['COLUMN_DEFAULT']; @@ -6039,7 +6041,7 @@ private function translate_update_list( string $table_name, WP_Parser_Node $pare } // Apply type casting. - $value = $this->cast_value_for_saving( $data_type, $value, false, $numeric_scale ); + $value = $this->cast_value_for_saving( $data_type, $value, false, $numeric_scale, $column_type ); /* * In MySQL non-STRICT mode, when a column is declared as NOT NULL, @@ -6384,7 +6386,8 @@ private function cast_value_for_saving( string $mysql_data_type, string $translated_value, bool $ignore_errors = false, - ?int $numeric_scale = null + ?int $numeric_scale = null, + ?string $column_type = null ): string { // TODO: This is also a good place to implement checks for maximum column // lengths with truncating or bailing out depending on the SQL mode. @@ -6539,7 +6542,11 @@ private function cast_value_for_saving( ); if ( $is_integer_type ) { if ( ! $is_strict_mode || $ignore_errors ) { - return sprintf( 'CAST(ROUND(%s) AS INTEGER)', $translated_value ); + return $this->clamp_unsigned_save_value( + sprintf( 'CAST(ROUND(%s) AS INTEGER)', $translated_value ), + $mysql_data_type, + $column_type + ); } return sprintf( "CASE WHEN TYPEOF(%s) = 'blob' THEN %s ELSE _mysql_save_integer(%s) END", @@ -6552,7 +6559,11 @@ private function cast_value_for_saving( if ( 'decimal' === $mysql_data_type ) { $numeric_scale = $numeric_scale ?? 0; if ( ! $is_strict_mode || $ignore_errors ) { - return sprintf( 'CAST(ROUND(%s, %d) AS %s)', $translated_value, $numeric_scale, $sqlite_data_type ); + return $this->clamp_unsigned_save_value( + sprintf( 'CAST(ROUND(%s, %d) AS %s)', $translated_value, $numeric_scale, $sqlite_data_type ), + $mysql_data_type, + $column_type + ); } return sprintf( "CASE WHEN TYPEOF(%s) = 'blob' THEN %s ELSE _mysql_save_decimal(%s, %d) END", @@ -6564,12 +6575,68 @@ private function cast_value_for_saving( } if ( ! $is_strict_mode || $ignore_errors || 'TEXT' === $sqlite_data_type || 'BLOB' === $sqlite_data_type ) { - return sprintf( 'CAST(%s AS %s)', $translated_value, $sqlite_data_type ); + $value = sprintf( 'CAST(%s AS %s)', $translated_value, $sqlite_data_type ); + if ( ! $is_strict_mode || $ignore_errors ) { + $value = $this->clamp_unsigned_save_value( $value, $mysql_data_type, $column_type ); + } + return $value; } return $translated_value; } } + /** + * Clamp negative values saved to unsigned numeric columns in tolerant writes. + * + * In MySQL, ZEROFILL implies UNSIGNED. In non-strict or IGNORE writes, + * negative numeric values saved to unsigned columns are clipped to zero with + * a warning. SQLite has no unsigned storage type, so emulate that at save + * time for numeric columns. + * + * @param string $value SQL expression for the already-cast value. + * @param string $mysql_data_type MySQL data type. + * @param string|null $column_type Full MySQL column type. + * @return string SQL expression with unsigned clipping when needed. + */ + private function clamp_unsigned_save_value( string $value, string $mysql_data_type, ?string $column_type ): string { + if ( null === $column_type ) { + return $value; + } + + $column_type = strtolower( $column_type ); + if ( ! str_contains( $column_type, 'unsigned' ) && ! str_contains( $column_type, 'zerofill' ) ) { + return $value; + } + + if ( + ! in_array( + $mysql_data_type, + array( + 'bit', + 'bool', + 'boolean', + 'tinyint', + 'smallint', + 'mediumint', + 'int', + 'integer', + 'bigint', + 'decimal', + 'float', + 'double', + ), + true + ) + ) { + return $value; + } + + return sprintf( + 'CASE WHEN %1$s IS NULL THEN NULL WHEN %1$s < 0 THEN 0 ELSE %1$s END', + $value + ); + } + /** * Get the database name as it is saved in the information schema tables. * diff --git a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php index 5ce5405aa..6435074e4 100644 --- a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php @@ -10629,6 +10629,25 @@ public function testSqlancerDecimalScaleIsAppliedBeforeUniqueChecks(): void { $this->assertSame( '-271461335', (string) (int) $result[0]->c0 ); } + public function testSqlancerZerofillInsertIgnoreClipsNegativeValuesBeforeUniqueUpdate(): void { + $this->assertQuery( 'CREATE TABLE t0(c0 DOUBLE ZEROFILL UNIQUE, c1 FLOAT, c2 DECIMAL UNIQUE KEY)' ); + $this->assertQuery( 'INSERT IGNORE INTO t0(c1, c0) VALUES(-255822003, "-1773731655"), (0.3800962993552307, NULL), (NULL, "¹"), (802484078, "&g瞟Xx8-U"), (1992718239, "")' ); + $this->assertQuery( 'INSERT LOW_PRIORITY IGNORE INTO t0(c1, c0) VALUES(NULL, 13195222)' ); + + $result = $this->assertQuery( 'SELECT COUNT(*) AS rows_count, SUM(c0 = 0) AS zero_rows, SUM(c0 < 0) AS negative_rows, SUM(c0 = 13195222) AS positive_rows FROM t0' ); + + $this->assertSame( '3', $result[0]->rows_count ); + $this->assertSame( '1', $result[0]->zero_rows ); + $this->assertSame( '0', $result[0]->negative_rows ); + $this->assertSame( '1', $result[0]->positive_rows ); + + $this->assertQuery( 'UPDATE t0 SET c2=0.3350118396679408, c1=8.02484078E8 WHERE t0.c0' ); + $result = $this->assertQuery( 'SELECT COUNT(*) AS updated_rows, MAX(c2) AS c2 FROM t0 WHERE c2 IS NOT NULL' ); + + $this->assertSame( '1', $result[0]->updated_rows ); + $this->assertSame( '0', (string) (int) $result[0]->c2 ); + } + public function testSqlancerDeleteIgnoreDropsIgnoredRows(): void { $this->assertQuery( 'CREATE TABLE t0(c0 DECIMAL)' ); $this->assertQuery( 'INSERT INTO t0(c0) VALUES(1), (2)' ); diff --git a/tests/e2e/specs/sqlancer-fuzz-regressions.test.js b/tests/e2e/specs/sqlancer-fuzz-regressions.test.js index 3890f71be..b1b8b6848 100644 --- a/tests/e2e/specs/sqlancer-fuzz-regressions.test.js +++ b/tests/e2e/specs/sqlancer-fuzz-regressions.test.js @@ -74,6 +74,15 @@ $decimal_scale_row = $wpdb->get_row( "SELECT COUNT(*) AS rows_count, c0 FROM $de sdi_sqlancer_query( "UPDATE $decimal_scale_table SET c0=-271461335" ); $decimal_scale_after_update_row = $wpdb->get_row( "SELECT c0 FROM $decimal_scale_table", ARRAY_A ); +$unsigned_zerofill_table = $wpdb->prefix . 'sqlancer_unsigned_zerofill_t0'; +sdi_sqlancer_query( "DROP TABLE IF EXISTS $unsigned_zerofill_table" ); +sdi_sqlancer_query( "CREATE TABLE $unsigned_zerofill_table(c0 DOUBLE ZEROFILL UNIQUE, c1 FLOAT, c2 DECIMAL UNIQUE KEY)" ); +sdi_sqlancer_query( "INSERT IGNORE INTO $unsigned_zerofill_table(c1, c0) VALUES(-255822003, \\"-1773731655\\"), (0.3800962993552307, NULL), (NULL, \\"¹\\"), (802484078, \\"&g瞟Xx8-U\\"), (1992718239, \\"\\")" ); +sdi_sqlancer_query( "INSERT LOW_PRIORITY IGNORE INTO $unsigned_zerofill_table(c1, c0) VALUES(NULL, 13195222)" ); +$unsigned_zerofill_row = $wpdb->get_row( "SELECT COUNT(*) AS rows_count, SUM(c0 = 0) AS zero_rows, SUM(c0 < 0) AS negative_rows, SUM(c0 = 13195222) AS positive_rows FROM $unsigned_zerofill_table", ARRAY_A ); +sdi_sqlancer_query( "UPDATE $unsigned_zerofill_table SET c2=0.3350118396679408, c1=8.02484078E8 WHERE $unsigned_zerofill_table.c0" ); +$unsigned_zerofill_after_update_row = $wpdb->get_row( "SELECT COUNT(*) AS updated_rows, MAX(c2) AS c2 FROM $unsigned_zerofill_table WHERE c2 IS NOT NULL", ARRAY_A ); + $memory_default_table = $wpdb->prefix . 'sqlancer_memory_implicit_default'; sdi_sqlancer_query( "DROP TABLE IF EXISTS $memory_default_table" ); sdi_sqlancer_query( "CREATE TABLE $memory_default_table(c0 BIGINT ZEROFILL COMMENT 'asdf' COLUMN_FORMAT DYNAMIC PRIMARY KEY) ENGINE = MEMORY, AUTO_INCREMENT = 4115509118782610296" ); @@ -174,6 +183,8 @@ echo 'SQLANCER_CAST_SIGNED_AFTER_UPDATE_JSON:' . wp_json_encode( $cast_after_upd echo 'SQLANCER_INTEGER_STRING_JSON:' . wp_json_encode( $integer_string_row ) . PHP_EOL; echo 'SQLANCER_DECIMAL_SCALE_JSON:' . wp_json_encode( $decimal_scale_row ) . PHP_EOL; echo 'SQLANCER_DECIMAL_SCALE_AFTER_UPDATE_JSON:' . wp_json_encode( $decimal_scale_after_update_row ) . PHP_EOL; +echo 'SQLANCER_UNSIGNED_ZEROFILL_JSON:' . wp_json_encode( $unsigned_zerofill_row ) . PHP_EOL; +echo 'SQLANCER_UNSIGNED_ZEROFILL_AFTER_UPDATE_JSON:' . wp_json_encode( $unsigned_zerofill_after_update_row ) . PHP_EOL; echo 'SQLANCER_MEMORY_DEFAULT_JSON:' . wp_json_encode( $memory_default_rows ) . PHP_EOL; `, ], @@ -256,6 +267,50 @@ echo 'SQLANCER_MEMORY_DEFAULT_JSON:' . wp_json_encode( $memory_default_rows ) . c0: '-271461335', } ); + const unsignedZerofillJsonLine = output + .trim() + .split( /\r?\n/ ) + .find( ( line ) => + line.startsWith( 'SQLANCER_UNSIGNED_ZEROFILL_JSON:' ) + ); + + expect( unsignedZerofillJsonLine ).toBeTruthy(); + expect( + JSON.parse( + unsignedZerofillJsonLine.replace( + 'SQLANCER_UNSIGNED_ZEROFILL_JSON:', + '' + ) + ) + ).toEqual( { + rows_count: '3', + zero_rows: '1', + negative_rows: '0', + positive_rows: '1', + } ); + + const unsignedZerofillAfterUpdateJsonLine = output + .trim() + .split( /\r?\n/ ) + .find( ( line ) => + line.startsWith( + 'SQLANCER_UNSIGNED_ZEROFILL_AFTER_UPDATE_JSON:' + ) + ); + + expect( unsignedZerofillAfterUpdateJsonLine ).toBeTruthy(); + expect( + JSON.parse( + unsignedZerofillAfterUpdateJsonLine.replace( + 'SQLANCER_UNSIGNED_ZEROFILL_AFTER_UPDATE_JSON:', + '' + ) + ) + ).toEqual( { + updated_rows: '1', + c2: '0', + } ); + const memoryDefaultJsonLine = output .trim() .split( /\r?\n/ ) From f2e090f992ee3d13b4e7da7e0bb274ce581fe38a Mon Sep 17 00:00:00 2001 From: adamziel Date: Wed, 17 Jun 2026 05:07:20 +0000 Subject: [PATCH 16/37] Handle SQLancer renamed functional indexes --- ...s-wp-sqlite-information-schema-builder.php | 230 ++++++++++++++++++ .../tests/WP_SQLite_Driver_Tests.php | 23 ++ .../specs/sqlancer-fuzz-regressions.test.js | 39 +++ 3 files changed, 292 insertions(+) diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-information-schema-builder.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-information-schema-builder.php index f0bf2482c..a1ff6eaca 100644 --- a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-information-schema-builder.php +++ b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-information-schema-builder.php @@ -792,6 +792,236 @@ public function record_rename_table( bool $table_is_temporary, string $old_table ) ); } + + $this->record_rename_table_in_index_expressions( $table_is_temporary, $old_table_name, $new_table_name ); + } + + /** + * Update table-qualified column references in functional index expressions. + * + * @param bool $table_is_temporary Whether the table is temporary. + * @param string $old_table_name The old table name. + * @param string $new_table_name The new table name. + */ + private function record_rename_table_in_index_expressions( + bool $table_is_temporary, + string $old_table_name, + string $new_table_name + ): void { + $statistics_table_name = $this->get_table_name( $table_is_temporary, 'statistics' ); + $statistics = $this->connection->query( + ' + SELECT rowid, expression + FROM ' . $this->connection->quote_identifier( $statistics_table_name ) . ' + WHERE table_schema = ? + AND table_name = ? + AND expression IS NOT NULL + ', + array( self::SAVED_DATABASE_NAME, $new_table_name ) + )->fetchAll( + PDO::FETCH_ASSOC // phpcs:ignore WordPress.DB.RestrictedClasses.mysql__PDO + ); + + foreach ( $statistics as $statistics_item ) { + $expression = $this->rewrite_table_qualifier_in_expression( + $statistics_item['EXPRESSION'], + $old_table_name, + $new_table_name + ); + + if ( $expression === $statistics_item['EXPRESSION'] ) { + continue; + } + + $this->connection->query( + sprintf( + 'UPDATE %s SET expression = ? WHERE rowid = ?', + $this->connection->quote_identifier( $statistics_table_name ) + ), + array( $expression, $statistics_item['rowid'] ) + ); + } + } + + /** + * Rewrite table-qualified references outside quoted string literals. + * + * Stored expression metadata is serialized from MySQL tokens. Table + * qualifiers may be bare identifiers or backtick-quoted identifiers, while + * string literal contents must be preserved exactly. + * + * @param string $expression The stored MySQL expression. + * @param string $old_table_name The old table name. + * @param string $new_table_name The new table name. + * @return string The expression with table qualifiers rewritten. + */ + private function rewrite_table_qualifier_in_expression( + string $expression, + string $old_table_name, + string $new_table_name + ): string { + $result = ''; + $length = strlen( $expression ); + + for ( $i = 0; $i < $length; ) { + $char = $expression[ $i ]; + + if ( "'" === $char || '"' === $char ) { + $end = $this->find_quoted_string_end( $expression, $i, $char ); + $result .= substr( $expression, $i, $end - $i ); + $i = $end; + continue; + } + + if ( '`' === $char ) { + $identifier = $this->read_backtick_identifier( $expression, $i ); + $next = $identifier['end']; + $dot_offset = $this->find_following_dot_offset( $expression, $next ); + + if ( $identifier['value'] === $old_table_name && null !== $dot_offset ) { + $result .= '`' . str_replace( '`', '``', $new_table_name ) . '`'; + $i = $next; + continue; + } + + $result .= substr( $expression, $i, $next - $i ); + $i = $next; + continue; + } + + if ( $this->is_mysql_identifier_start( $char ) ) { + $next = $i + 1; + while ( $next < $length && $this->is_mysql_identifier_part( $expression[ $next ] ) ) { + ++$next; + } + + $identifier = substr( $expression, $i, $next - $i ); + $dot_offset = $this->find_following_dot_offset( $expression, $next ); + + if ( $identifier === $old_table_name && null !== $dot_offset ) { + $result .= $new_table_name; + $i = $next; + continue; + } + + $result .= $identifier; + $i = $next; + continue; + } + + $result .= $char; + ++$i; + } + + return $result; + } + + /** + * Find the end offset of a single- or double-quoted string. + * + * @param string $expression The expression being scanned. + * @param int $start The quote start offset. + * @param string $quote The quote character. + * @return int The offset immediately after the quoted string. + */ + private function find_quoted_string_end( string $expression, int $start, string $quote ): int { + $length = strlen( $expression ); + + for ( $i = $start + 1; $i < $length; ++$i ) { + if ( '\\' === $expression[ $i ] ) { + ++$i; + continue; + } + + if ( $quote !== $expression[ $i ] ) { + continue; + } + + if ( $i + 1 < $length && $quote === $expression[ $i + 1 ] ) { + ++$i; + continue; + } + + return $i + 1; + } + + return $length; + } + + /** + * Read a backtick-quoted identifier. + * + * @param string $expression The expression being scanned. + * @param int $start The identifier start offset. + * @return array{value:string,end:int} The unescaped identifier and end offset. + */ + private function read_backtick_identifier( string $expression, int $start ): array { + $value = ''; + $length = strlen( $expression ); + + for ( $i = $start + 1; $i < $length; ++$i ) { + if ( '`' !== $expression[ $i ] ) { + $value .= $expression[ $i ]; + continue; + } + + if ( $i + 1 < $length && '`' === $expression[ $i + 1 ] ) { + $value .= '`'; + ++$i; + continue; + } + + return array( + 'value' => $value, + 'end' => $i + 1, + ); + } + + return array( + 'value' => $value, + 'end' => $length, + ); + } + + /** + * Find the next non-whitespace character when it is a dot. + * + * @param string $expression The expression being scanned. + * @param int $offset The starting offset. + * @return int|null The dot offset, or null when no dot follows. + */ + private function find_following_dot_offset( string $expression, int $offset ): ?int { + $length = strlen( $expression ); + + while ( $offset < $length && ctype_space( $expression[ $offset ] ) ) { + ++$offset; + } + + if ( $offset < $length && '.' === $expression[ $offset ] ) { + return $offset; + } + + return null; + } + + /** + * Check if a character can start a bare MySQL identifier. + * + * @param string $char The character. + * @return bool Whether the character can start a bare identifier. + */ + private function is_mysql_identifier_start( string $char ): bool { + return '_' === $char || '$' === $char || ctype_alpha( $char ); + } + + /** + * Check if a character can be part of a bare MySQL identifier. + * + * @param string $char The character. + * @return bool Whether the character can be part of a bare identifier. + */ + private function is_mysql_identifier_part( string $char ): bool { + return '_' === $char || '$' === $char || ctype_alnum( $char ); } /** diff --git a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php index 6435074e4..06cc01b31 100644 --- a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php @@ -10739,6 +10739,29 @@ public function testSqlancerFunctionalIndexRecordsExpressionMetadata(): void { $this->assertSame( '(IF(NULL , t0 . c1 , t0 . c0))', $result[0]->Expression ); } + public function testSqlancerRenamedFunctionalIndexRebuildUsesCurrentTableQualifier(): void { + $this->assertQuery( 'CREATE TABLE t0(c0 DECIMAL UNIQUE)' ); + $this->assertQuery( "CREATE INDEX i0 USING HASH ON t0((((CASE 0 WHEN t0.c0 THEN ('tx') LIKE (t0.c0) ELSE t0.c0 END))))" ); + $this->assertQuery( 'ALTER TABLE t0 FORCE, RENAME t2' ); + + $result = $this->assertQuery( "SHOW INDEX FROM t2 WHERE Key_name = 'i0'" ); + + $this->assertCount( 1, $result ); + $this->assertNull( $result[0]->Column_name ); + $this->assertStringContainsString( 't2 . c0', $result[0]->Expression ); + $this->assertStringNotContainsString( 't0 . c0', $result[0]->Expression ); + + $this->assertQuery( 'TRUNCATE TABLE t2' ); + $this->assertQuery( "ALTER TABLE t2 COMPRESSION 'LZ4', PACK_KEYS 0, INSERT_METHOD NO, RENAME t0, STATS_AUTO_RECALC DEFAULT, FORCE, ROW_FORMAT DYNAMIC, ALGORITHM INPLACE, DELAY_KEY_WRITE 1, CHECKSUM 0" ); + + $result = $this->assertQuery( "SHOW INDEX FROM t0 WHERE Key_name = 'i0'" ); + + $this->assertCount( 1, $result ); + $this->assertNull( $result[0]->Column_name ); + $this->assertStringContainsString( 't0 . c0', $result[0]->Expression ); + $this->assertStringNotContainsString( 't2 . c0', $result[0]->Expression ); + } + public function testSqlancerGroupByNegativeIntegerLiteralIsExpression(): void { $this->assertQuery( 'CREATE TABLE t0(c0 INT)' ); $this->assertQuery( 'INSERT INTO t0(c0) VALUES(1), (2)' ); diff --git a/tests/e2e/specs/sqlancer-fuzz-regressions.test.js b/tests/e2e/specs/sqlancer-fuzz-regressions.test.js index b1b8b6848..9c840fd99 100644 --- a/tests/e2e/specs/sqlancer-fuzz-regressions.test.js +++ b/tests/e2e/specs/sqlancer-fuzz-regressions.test.js @@ -122,6 +122,17 @@ sdi_sqlancer_query( "CREATE INDEX i0 ON $index_table(((IF(NULL, $index_table.c1, sdi_sqlancer_query( "ALTER TABLE $index_table DISABLE KEYS" ); $index_row = $wpdb->get_row( "SHOW INDEX FROM $index_table WHERE Key_name = 'i0'", ARRAY_A ); +$renamed_functional_index_table = $wpdb->prefix . 'sqlancer_renamed_functional_index_t0'; +$renamed_functional_index_target = $wpdb->prefix . 'sqlancer_renamed_functional_index_t2'; +sdi_sqlancer_query( "DROP TABLE IF EXISTS $renamed_functional_index_table" ); +sdi_sqlancer_query( "DROP TABLE IF EXISTS $renamed_functional_index_target" ); +sdi_sqlancer_query( "CREATE TABLE $renamed_functional_index_table(c0 DECIMAL UNIQUE)" ); +sdi_sqlancer_query( "CREATE INDEX i_sqlancer_renamed_functional USING HASH ON $renamed_functional_index_table((((CASE 0 WHEN $renamed_functional_index_table.c0 THEN ('tx') LIKE ($renamed_functional_index_table.c0) ELSE $renamed_functional_index_table.c0 END))))" ); +sdi_sqlancer_query( "ALTER TABLE $renamed_functional_index_table FORCE, RENAME $renamed_functional_index_target" ); +sdi_sqlancer_query( "TRUNCATE TABLE $renamed_functional_index_target" ); +sdi_sqlancer_query( "ALTER TABLE $renamed_functional_index_target COMPRESSION 'LZ4', PACK_KEYS 0, INSERT_METHOD NO, RENAME $renamed_functional_index_table, STATS_AUTO_RECALC DEFAULT, FORCE, ROW_FORMAT DYNAMIC, ALGORITHM INPLACE, DELAY_KEY_WRITE 1, CHECKSUM 0" ); +$renamed_functional_index_row = $wpdb->get_row( "SHOW INDEX FROM $renamed_functional_index_table WHERE Key_name = 'i_sqlancer_renamed_functional'", ARRAY_A ); + $literal_index_table = $wpdb->prefix . 'sqlancer_literal_index'; sdi_sqlancer_query( "DROP TABLE IF EXISTS $literal_index_table" ); sdi_sqlancer_query( "CREATE TABLE $literal_index_table(c1 MEDIUMINT UNIQUE KEY COLUMN_FORMAT DEFAULT COMMENT 'asdf')" ); @@ -173,6 +184,7 @@ echo 'SQLANCER_BOOLEAN_JSON:' . wp_json_encode( $boolean_row ) . PHP_EOL; echo 'SQLANCER_ORDER_NEGATIVE_JSON:' . wp_json_encode( $order_negative_row ) . PHP_EOL; echo 'SQLANCER_ORDERING_EXPRESSION_JSON:' . wp_json_encode( $ordering_expression_row ) . PHP_EOL; echo 'SQLANCER_INDEX_JSON:' . wp_json_encode( $index_row ) . PHP_EOL; +echo 'SQLANCER_RENAMED_FUNCTIONAL_INDEX_JSON:' . wp_json_encode( $renamed_functional_index_row ) . PHP_EOL; echo 'SQLANCER_LITERAL_INDEX_JSON:' . wp_json_encode( $literal_index_row ) . PHP_EOL; echo 'SQLANCER_REDUNDANT_INDEX_JSON:' . wp_json_encode( $redundant_index_rows ) . PHP_EOL; echo 'SQLANCER_COUNT_DISTINCT_JSON:' . wp_json_encode( $count_distinct_row ) . PHP_EOL; @@ -431,6 +443,33 @@ echo 'SQLANCER_MEMORY_DEFAULT_JSON:' . wp_json_encode( $memory_default_rows ) . expect( indexRow.Expression ).toContain( 'sqlancer_expr_index . c1' ); expect( indexRow.Expression ).toContain( 'sqlancer_expr_index . c0' ); + const renamedFunctionalIndexJsonLine = output + .trim() + .split( /\r?\n/ ) + .find( ( line ) => + line.startsWith( 'SQLANCER_RENAMED_FUNCTIONAL_INDEX_JSON:' ) + ); + + expect( renamedFunctionalIndexJsonLine ).toBeTruthy(); + const renamedFunctionalIndexRow = JSON.parse( + renamedFunctionalIndexJsonLine.replace( + 'SQLANCER_RENAMED_FUNCTIONAL_INDEX_JSON:', + '' + ) + ); + expect( renamedFunctionalIndexRow ).toEqual( + expect.objectContaining( { + Key_name: 'i_sqlancer_renamed_functional', + Column_name: null, + } ) + ); + expect( renamedFunctionalIndexRow.Expression ).toContain( + 'sqlancer_renamed_functional_index_t0 . c0' + ); + expect( renamedFunctionalIndexRow.Expression ).not.toContain( + 'sqlancer_renamed_functional_index_t2 . c0' + ); + const literalIndexJsonLine = output .trim() .split( /\r?\n/ ) From 5e1a2bfada8dd68a5fbe8c8782ab6666e548b425 Mon Sep 17 00:00:00 2001 From: adamziel Date: Wed, 17 Jun 2026 05:54:40 +0000 Subject: [PATCH 17/37] Clip SQLancer signed integer saves --- .../sqlite/class-wp-pdo-mysql-on-sqlite.php | 90 ++++++++++++++++++- .../tests/WP_SQLite_Driver_Tests.php | 12 +++ .../specs/sqlancer-fuzz-regressions.test.js | 29 ++++++ 3 files changed, 130 insertions(+), 1 deletion(-) 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 651dbd90d..0a094b09a 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 @@ -6542,7 +6542,7 @@ private function cast_value_for_saving( ); if ( $is_integer_type ) { if ( ! $is_strict_mode || $ignore_errors ) { - return $this->clamp_unsigned_save_value( + return $this->clamp_integer_save_value( sprintf( 'CAST(ROUND(%s) AS INTEGER)', $translated_value ), $mysql_data_type, $column_type @@ -6585,6 +6585,94 @@ private function cast_value_for_saving( } } + /** + * Clamp values saved to integer columns in tolerant writes. + * + * MySQL clips out-of-range integer values in non-strict or IGNORE writes. + * SQLite STRICT integer columns accept any 64-bit integer, so apply MySQL's + * smaller type bounds before saving. + * + * @param string $value SQL expression for the already-cast value. + * @param string $mysql_data_type MySQL data type. + * @param string|null $column_type Full MySQL column type. + * @return string SQL expression with integer clipping when needed. + */ + private function clamp_integer_save_value( string $value, string $mysql_data_type, ?string $column_type ): string { + $bounds = $this->get_integer_save_bounds( $mysql_data_type, $column_type ); + if ( null === $bounds ) { + return $this->clamp_unsigned_save_value( $value, $mysql_data_type, $column_type ); + } + + return sprintf( + 'CASE WHEN %1$s IS NULL THEN NULL WHEN %1$s < %2$s THEN %2$s WHEN %1$s > %3$s THEN %3$s ELSE %1$s END', + $value, + $bounds['min'], + $bounds['max'] + ); + } + + /** + * Get MySQL integer type bounds that SQLite can represent exactly. + * + * @param string $mysql_data_type MySQL data type. + * @param string|null $column_type Full MySQL column type. + * @return array{min:string,max:string}|null Integer bounds, or null when unknown. + */ + private function get_integer_save_bounds( string $mysql_data_type, ?string $column_type ): ?array { + $is_unsigned = null !== $column_type && ( + str_contains( strtolower( $column_type ), 'unsigned' ) + || str_contains( strtolower( $column_type ), 'zerofill' ) + ); + + switch ( $mysql_data_type ) { + case 'bool': + case 'boolean': + case 'tinyint': + return $is_unsigned + ? array( + 'min' => '0', + 'max' => '255', + ) + : array( + 'min' => '-128', + 'max' => '127', + ); + case 'smallint': + return $is_unsigned + ? array( + 'min' => '0', + 'max' => '65535', + ) + : array( + 'min' => '-32768', + 'max' => '32767', + ); + case 'mediumint': + return $is_unsigned + ? array( + 'min' => '0', + 'max' => '16777215', + ) + : array( + 'min' => '-8388608', + 'max' => '8388607', + ); + case 'int': + case 'integer': + return $is_unsigned + ? array( + 'min' => '0', + 'max' => '4294967295', + ) + : array( + 'min' => '-2147483648', + 'max' => '2147483647', + ); + default: + return null; + } + } + /** * Clamp negative values saved to unsigned numeric columns in tolerant writes. * diff --git a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php index 06cc01b31..875018b1a 100644 --- a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php @@ -10648,6 +10648,18 @@ public function testSqlancerZerofillInsertIgnoreClipsNegativeValuesBeforeUniqueU $this->assertSame( '0', (string) (int) $result[0]->c2 ); } + public function testSqlancerInsertIgnoreClipsSignedSmallintValuesBeforeSum(): void { + $this->assertQuery( 'CREATE TABLE t0(c0 SMALLINT(107) COLUMN_FORMAT DYNAMIC PRIMARY KEY UNIQUE KEY)' ); + $this->assertQuery( "INSERT IGNORE INTO t0(c0) VALUES(1375461291), (-627010191), (32190009), (0.7902617242915789), ('-1e500'), ('2jc7hoh\r'), (2052592843)" ); + + $result = $this->assertQuery( 'SELECT GROUP_CONCAT(c0 ORDER BY c0) AS saved_values, SUM(c0) AS sum_value, SUM(DISTINCT c0) AS sum_distinct_value, COUNT(*) AS row_count FROM t0' ); + + $this->assertSame( '-32768,1,2,32767', $result[0]->saved_values ); + $this->assertSame( '2', $result[0]->sum_value ); + $this->assertSame( '2', $result[0]->sum_distinct_value ); + $this->assertSame( '4', $result[0]->row_count ); + } + public function testSqlancerDeleteIgnoreDropsIgnoredRows(): void { $this->assertQuery( 'CREATE TABLE t0(c0 DECIMAL)' ); $this->assertQuery( 'INSERT INTO t0(c0) VALUES(1), (2)' ); diff --git a/tests/e2e/specs/sqlancer-fuzz-regressions.test.js b/tests/e2e/specs/sqlancer-fuzz-regressions.test.js index 9c840fd99..6ce100751 100644 --- a/tests/e2e/specs/sqlancer-fuzz-regressions.test.js +++ b/tests/e2e/specs/sqlancer-fuzz-regressions.test.js @@ -83,6 +83,12 @@ $unsigned_zerofill_row = $wpdb->get_row( "SELECT COUNT(*) AS rows_count, SUM(c0 sdi_sqlancer_query( "UPDATE $unsigned_zerofill_table SET c2=0.3350118396679408, c1=8.02484078E8 WHERE $unsigned_zerofill_table.c0" ); $unsigned_zerofill_after_update_row = $wpdb->get_row( "SELECT COUNT(*) AS updated_rows, MAX(c2) AS c2 FROM $unsigned_zerofill_table WHERE c2 IS NOT NULL", ARRAY_A ); +$signed_smallint_table = $wpdb->prefix . 'sqlancer_signed_smallint_sum'; +sdi_sqlancer_query( "DROP TABLE IF EXISTS $signed_smallint_table" ); +sdi_sqlancer_query( "CREATE TABLE $signed_smallint_table(c0 SMALLINT(107) COLUMN_FORMAT DYNAMIC PRIMARY KEY UNIQUE KEY)" ); +sdi_sqlancer_query( "INSERT IGNORE INTO $signed_smallint_table(c0) VALUES(1375461291), (-627010191), (32190009), (0.7902617242915789), ('-1e500'), ('2jc7hoh\r'), (2052592843)" ); +$signed_smallint_row = $wpdb->get_row( "SELECT GROUP_CONCAT(c0 ORDER BY c0) AS saved_values, SUM(c0) AS sum_value, SUM(DISTINCT c0) AS sum_distinct_value, COUNT(*) AS row_count FROM $signed_smallint_table", ARRAY_A ); + $memory_default_table = $wpdb->prefix . 'sqlancer_memory_implicit_default'; sdi_sqlancer_query( "DROP TABLE IF EXISTS $memory_default_table" ); sdi_sqlancer_query( "CREATE TABLE $memory_default_table(c0 BIGINT ZEROFILL COMMENT 'asdf' COLUMN_FORMAT DYNAMIC PRIMARY KEY) ENGINE = MEMORY, AUTO_INCREMENT = 4115509118782610296" ); @@ -197,6 +203,7 @@ echo 'SQLANCER_DECIMAL_SCALE_JSON:' . wp_json_encode( $decimal_scale_row ) . PHP echo 'SQLANCER_DECIMAL_SCALE_AFTER_UPDATE_JSON:' . wp_json_encode( $decimal_scale_after_update_row ) . PHP_EOL; echo 'SQLANCER_UNSIGNED_ZEROFILL_JSON:' . wp_json_encode( $unsigned_zerofill_row ) . PHP_EOL; echo 'SQLANCER_UNSIGNED_ZEROFILL_AFTER_UPDATE_JSON:' . wp_json_encode( $unsigned_zerofill_after_update_row ) . PHP_EOL; +echo 'SQLANCER_SIGNED_SMALLINT_JSON:' . wp_json_encode( $signed_smallint_row ) . PHP_EOL; echo 'SQLANCER_MEMORY_DEFAULT_JSON:' . wp_json_encode( $memory_default_rows ) . PHP_EOL; `, ], @@ -323,6 +330,28 @@ echo 'SQLANCER_MEMORY_DEFAULT_JSON:' . wp_json_encode( $memory_default_rows ) . c2: '0', } ); + const signedSmallintJsonLine = output + .trim() + .split( /\r?\n/ ) + .find( ( line ) => + line.startsWith( 'SQLANCER_SIGNED_SMALLINT_JSON:' ) + ); + + expect( signedSmallintJsonLine ).toBeTruthy(); + expect( + JSON.parse( + signedSmallintJsonLine.replace( + 'SQLANCER_SIGNED_SMALLINT_JSON:', + '' + ) + ) + ).toEqual( { + saved_values: '-32768,1,2,32767', + sum_value: '2', + sum_distinct_value: '2', + row_count: '4', + } ); + const memoryDefaultJsonLine = output .trim() .split( /\r?\n/ ) From 02937635f6565992f7b0c93feac50231b3f7465c Mon Sep 17 00:00:00 2001 From: adamziel Date: Wed, 17 Jun 2026 06:06:00 +0000 Subject: [PATCH 18/37] Handle SQLancer HEAP decimal defaults --- .../sqlite/class-wp-pdo-mysql-on-sqlite.php | 2 +- .../tests/WP_SQLite_Driver_Tests.php | 12 ++++++++ .../specs/sqlancer-fuzz-regressions.test.js | 29 +++++++++++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) 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 0a094b09a..6bb8e5a81 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 @@ -5906,7 +5906,7 @@ function ( $column ) use ( $use_implicit_defaults, $insert_map ) { * @return bool Whether the engine is non-transactional. */ private function is_non_transactional_table_engine( string $table_engine ): bool { - return in_array( strtoupper( $table_engine ), array( 'MEMORY', 'MYISAM' ), true ); + return in_array( strtoupper( $table_engine ), array( 'HEAP', 'MEMORY', 'MYISAM' ), true ); } /** diff --git a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php index 875018b1a..69df244eb 100644 --- a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php @@ -10629,6 +10629,18 @@ public function testSqlancerDecimalScaleIsAppliedBeforeUniqueChecks(): void { $this->assertSame( '-271461335', (string) (int) $result[0]->c0 ); } + public function testSqlancerHeapDecimalMultiRowReplaceUsesImplicitDefaultForInvalidValues(): void { + $this->assertQuery( "CREATE TABLE t0(c0 DECIMAL COLUMN_FORMAT FIXED STORAGE MEMORY COMMENT 'asdf' UNIQUE KEY) ENGINE = HEAP" ); + $this->assertQuery( "REPLACE DELAYED INTO t0(c0) VALUES(652769770), (''), (NULL)" ); + + $result = $this->assertQuery( 'SELECT COUNT(*) AS row_count, SUM(c0 = 0) AS zero_rows, SUM(c0 IS NULL) AS null_rows, MAX(c0) AS max_value FROM t0' ); + + $this->assertSame( '3', $result[0]->row_count ); + $this->assertSame( '1', $result[0]->zero_rows ); + $this->assertSame( '1', $result[0]->null_rows ); + $this->assertSame( '652769770', (string) (int) $result[0]->max_value ); + } + public function testSqlancerZerofillInsertIgnoreClipsNegativeValuesBeforeUniqueUpdate(): void { $this->assertQuery( 'CREATE TABLE t0(c0 DOUBLE ZEROFILL UNIQUE, c1 FLOAT, c2 DECIMAL UNIQUE KEY)' ); $this->assertQuery( 'INSERT IGNORE INTO t0(c1, c0) VALUES(-255822003, "-1773731655"), (0.3800962993552307, NULL), (NULL, "¹"), (802484078, "&g瞟Xx8-U"), (1992718239, "")' ); diff --git a/tests/e2e/specs/sqlancer-fuzz-regressions.test.js b/tests/e2e/specs/sqlancer-fuzz-regressions.test.js index 6ce100751..0c6dde58c 100644 --- a/tests/e2e/specs/sqlancer-fuzz-regressions.test.js +++ b/tests/e2e/specs/sqlancer-fuzz-regressions.test.js @@ -74,6 +74,12 @@ $decimal_scale_row = $wpdb->get_row( "SELECT COUNT(*) AS rows_count, c0 FROM $de sdi_sqlancer_query( "UPDATE $decimal_scale_table SET c0=-271461335" ); $decimal_scale_after_update_row = $wpdb->get_row( "SELECT c0 FROM $decimal_scale_table", ARRAY_A ); +$heap_decimal_table = $wpdb->prefix . 'sqlancer_heap_decimal_replace'; +sdi_sqlancer_query( "DROP TABLE IF EXISTS $heap_decimal_table" ); +sdi_sqlancer_query( "CREATE TABLE $heap_decimal_table(c0 DECIMAL COLUMN_FORMAT FIXED STORAGE MEMORY COMMENT 'asdf' UNIQUE KEY) ENGINE = HEAP" ); +sdi_sqlancer_query( "REPLACE DELAYED INTO $heap_decimal_table(c0) VALUES(652769770), (''), (NULL)" ); +$heap_decimal_row = $wpdb->get_row( "SELECT COUNT(*) AS row_count, SUM(c0 = 0) AS zero_rows, SUM(c0 IS NULL) AS null_rows, MAX(c0) AS max_value FROM $heap_decimal_table", ARRAY_A ); + $unsigned_zerofill_table = $wpdb->prefix . 'sqlancer_unsigned_zerofill_t0'; sdi_sqlancer_query( "DROP TABLE IF EXISTS $unsigned_zerofill_table" ); sdi_sqlancer_query( "CREATE TABLE $unsigned_zerofill_table(c0 DOUBLE ZEROFILL UNIQUE, c1 FLOAT, c2 DECIMAL UNIQUE KEY)" ); @@ -201,6 +207,7 @@ echo 'SQLANCER_CAST_SIGNED_AFTER_UPDATE_JSON:' . wp_json_encode( $cast_after_upd echo 'SQLANCER_INTEGER_STRING_JSON:' . wp_json_encode( $integer_string_row ) . PHP_EOL; echo 'SQLANCER_DECIMAL_SCALE_JSON:' . wp_json_encode( $decimal_scale_row ) . PHP_EOL; echo 'SQLANCER_DECIMAL_SCALE_AFTER_UPDATE_JSON:' . wp_json_encode( $decimal_scale_after_update_row ) . PHP_EOL; +echo 'SQLANCER_HEAP_DECIMAL_JSON:' . wp_json_encode( $heap_decimal_row ) . PHP_EOL; echo 'SQLANCER_UNSIGNED_ZEROFILL_JSON:' . wp_json_encode( $unsigned_zerofill_row ) . PHP_EOL; echo 'SQLANCER_UNSIGNED_ZEROFILL_AFTER_UPDATE_JSON:' . wp_json_encode( $unsigned_zerofill_after_update_row ) . PHP_EOL; echo 'SQLANCER_SIGNED_SMALLINT_JSON:' . wp_json_encode( $signed_smallint_row ) . PHP_EOL; @@ -286,6 +293,28 @@ echo 'SQLANCER_MEMORY_DEFAULT_JSON:' . wp_json_encode( $memory_default_rows ) . c0: '-271461335', } ); + const heapDecimalJsonLine = output + .trim() + .split( /\r?\n/ ) + .find( ( line ) => + line.startsWith( 'SQLANCER_HEAP_DECIMAL_JSON:' ) + ); + + expect( heapDecimalJsonLine ).toBeTruthy(); + expect( + JSON.parse( + heapDecimalJsonLine.replace( + 'SQLANCER_HEAP_DECIMAL_JSON:', + '' + ) + ) + ).toEqual( { + row_count: '3', + zero_rows: '1', + null_rows: '1', + max_value: '652769770', + } ); + const unsignedZerofillJsonLine = output .trim() .split( /\r?\n/ ) From 954a924370139ae7121d6b995f4200adfeb442e1 Mon Sep 17 00:00:00 2001 From: adamziel Date: Wed, 17 Jun 2026 06:33:10 +0000 Subject: [PATCH 19/37] Restore MySQL state while filtering SQLancer logs --- bin/run-sqlancer-sqlite-fuzz.sh | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/bin/run-sqlancer-sqlite-fuzz.sh b/bin/run-sqlancer-sqlite-fuzz.sh index 14e2511f9..5af9be369 100755 --- a/bin/run-sqlancer-sqlite-fuzz.sh +++ b/bin/run-sqlancer-sqlite-fuzz.sh @@ -90,7 +90,9 @@ fi cp "$LOG_FILE" "$ARTIFACTS_DIR/" LOG_FILE="$ARTIFACTS_DIR/$(basename "$LOG_FILE")" MYSQL_FAILURES_FILE="$ARTIFACTS_DIR/mysql-rejected-lines.txt" +MYSQL_ACCEPTED_FILE="$ARTIFACTS_DIR/mysql-accepted-prefix.sql" : > "$MYSQL_FAILURES_FILE" +: > "$MYSQL_ACCEPTED_FILE" LINE_NUMBER=0 while IFS= read -r LINE || [ -n "$LINE" ]; do @@ -109,6 +111,7 @@ while IFS= read -r LINE || [ -n "$LINE" ]; do if [[ "$SQL" =~ ^[Uu][Ss][Ee][[:space:]]+([^[:space:];]+) ]]; then CURRENT_DB="${BASH_REMATCH[1]}" + printf '%s\n' "$SQL" >> "$MYSQL_ACCEPTED_FILE" continue fi @@ -123,6 +126,14 @@ while IFS= read -r LINE || [ -n "$LINE" ]; do if ! docker exec -i "$MYSQL_CONTAINER" mysql -uroot -p"$MYSQL_PASSWORD" --batch --raw "${MYSQL_ARGS[@]}" < "$TMP_SQL" >/dev/null 2>&1; then printf '%s\n' "$LINE_NUMBER" >> "$MYSQL_FAILURES_FILE" SKIP_ARGS+=( "--skip-line=$LINE_NUMBER" ) + # Non-transactional MySQL engines can keep partial changes after errors. + # Rebuild from the accepted prefix so later filtering does not depend on skipped side effects. + if ! docker exec -i "$MYSQL_CONTAINER" mysql -uroot -p"$MYSQL_PASSWORD" --batch --raw < "$MYSQL_ACCEPTED_FILE" >/dev/null 2>&1; then + echo "Failed to restore MySQL state from accepted SQLancer prefix after line $LINE_NUMBER." >&2 + exit 1 + fi + else + printf '%s\n' "$SQL" >> "$MYSQL_ACCEPTED_FILE" fi rm -f "$TMP_SQL" done < "$LOG_FILE" From c055831c5f49f15f7bb497232a50c269bbb41ff9 Mon Sep 17 00:00:00 2001 From: adamziel Date: Wed, 17 Jun 2026 06:48:17 +0000 Subject: [PATCH 20/37] Use tmpfs for SQLancer MySQL data --- bin/run-sqlancer-sqlite-fuzz.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bin/run-sqlancer-sqlite-fuzz.sh b/bin/run-sqlancer-sqlite-fuzz.sh index 5af9be369..ce908cba2 100755 --- a/bin/run-sqlancer-sqlite-fuzz.sh +++ b/bin/run-sqlancer-sqlite-fuzz.sh @@ -7,6 +7,7 @@ SQLANCER_REPO="${SQLANCER_REPO:-https://github.com/sqlancer/sqlancer.git}" SQLANCER_IMAGE="${SQLANCER_IMAGE:-maven:3.9-eclipse-temurin-21}" MYSQL_IMAGE="${MYSQL_IMAGE:-mysql:8.4}" MYSQL_PASSWORD="${MYSQL_PASSWORD:-sqlancer}" +MYSQL_TMPFS_SIZE="${MYSQL_TMPFS_SIZE:-1024m}" RANDOM_SEED="${RANDOM_SEED:-20260617}" NUM_QUERIES="${NUM_QUERIES:-200}" MAX_GENERATED_DATABASES="${MAX_GENERATED_DATABASES:-1}" @@ -49,19 +50,20 @@ docker network create "$NETWORK" >/dev/null docker run -d --rm \ --name "$MYSQL_CONTAINER" \ --network "$NETWORK" \ + --tmpfs "/var/lib/mysql:rw,size=$MYSQL_TMPFS_SIZE" \ -e MYSQL_ROOT_PASSWORD="$MYSQL_PASSWORD" \ -e MYSQL_ROOT_HOST=% \ "$MYSQL_IMAGE" \ --mysql-native-password=ON >/dev/null for _ in $(seq 1 60); do - if docker run --rm --network "$NETWORK" "$MYSQL_IMAGE" mysqladmin ping -h"$MYSQL_CONTAINER" -uroot -p"$MYSQL_PASSWORD" --silent >/dev/null 2>&1; then + if docker run --rm --network "$NETWORK" --tmpfs /var/lib/mysql:rw,size=16m "$MYSQL_IMAGE" mysqladmin ping -h"$MYSQL_CONTAINER" -uroot -p"$MYSQL_PASSWORD" --silent >/dev/null 2>&1; then break fi sleep 1 done -docker run --rm --network "$NETWORK" "$MYSQL_IMAGE" mysqladmin ping -h"$MYSQL_CONTAINER" -uroot -p"$MYSQL_PASSWORD" --silent >/dev/null +docker run --rm --network "$NETWORK" --tmpfs /var/lib/mysql:rw,size=16m "$MYSQL_IMAGE" mysqladmin ping -h"$MYSQL_CONTAINER" -uroot -p"$MYSQL_PASSWORD" --silent >/dev/null docker run --rm \ --network "$NETWORK" \ From 6bd10596b7b4c572a85a9b716452b677da4155ed Mon Sep 17 00:00:00 2001 From: adamziel Date: Wed, 17 Jun 2026 06:58:39 +0000 Subject: [PATCH 21/37] Honor SQLancer text prefix indexes --- .../sqlite/class-wp-pdo-mysql-on-sqlite.php | 110 +++++++++++++++++- .../tests/WP_SQLite_Driver_Tests.php | 15 +++ .../specs/sqlancer-fuzz-regressions.test.js | 32 +++++ 3 files changed, 153 insertions(+), 4 deletions(-) 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 6bb8e5a81..2a747bfa1 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 @@ -2691,6 +2691,9 @@ private function execute_create_index_statement( WP_Parser_Node $node ): void { throw $this->new_access_denied_to_information_schema_exception(); } + $table_is_temporary = $this->information_schema_builder->temporary_table_exists( $table_name ); + $sqlite_column_type_map = $this->get_sqlite_column_type_map( $table_is_temporary, $table_name ); + $this->information_schema_builder->record_create_index( $node ); $index_name = $this->unquote_sqlite_identifier( @@ -2710,8 +2713,16 @@ private function execute_create_index_statement( WP_Parser_Node $node ): void { } if ( 'keyPart' === $key_part_node->rule_name ) { - $key_part = $this->translate( $key_part_node->get_first_child_node( 'identifier' ) ); - $direction = $key_part_node->get_first_child_node( 'direction' ); + $column_name = $this->unquote_sqlite_identifier( + $this->translate( $key_part_node->get_first_child_node( 'identifier' ) ) + ); + $sub_part = $this->get_index_key_part_length( $key_part_node ); + $key_part = $this->get_sqlite_index_column_fragment( + $column_name, + $sub_part, + $sqlite_column_type_map[ $column_name ] ?? null + ); + $direction = $key_part_node->get_first_child_node( 'direction' ); if ( null !== $direction ) { $key_part .= ' ' . $this->translate( $direction ); } @@ -2733,6 +2744,81 @@ private function execute_create_index_statement( WP_Parser_Node $node ): void { ); } + /** + * Get SQLite column type metadata for a table. + * + * @param bool $table_is_temporary Whether the table is temporary. + * @param string $table_name The table name. + * @return array Column name to SQLite data type map. + */ + private function get_sqlite_column_type_map( bool $table_is_temporary, string $table_name ): array { + $columns_table = $this->information_schema_builder->get_table_name( $table_is_temporary, 'columns' ); + $columns = $this->execute_sqlite_query( + sprintf( + 'SELECT column_name, data_type FROM %s WHERE table_schema = ? AND table_name = ?', + $this->quote_sqlite_identifier( $columns_table ) + ), + array( WP_SQLite_Information_Schema_Builder::SAVED_DATABASE_NAME, $table_name ) + )->fetchAll( PDO::FETCH_ASSOC ); + + $column_type_map = array(); + foreach ( $columns as $column ) { + if ( isset( self::DATA_TYPE_STRING_MAP[ $column['DATA_TYPE'] ] ) ) { + $column_type_map[ $column['COLUMN_NAME'] ] = self::DATA_TYPE_STRING_MAP[ $column['DATA_TYPE'] ]; + } + } + return $column_type_map; + } + + /** + * Translate a column key part for a SQLite CREATE INDEX statement. + * + * @param string $column_name The column name. + * @param int|null $sub_part The MySQL index prefix length. + * @param string|null $sqlite_data_type The SQLite data type of the indexed column. + * @return string The SQLite index column fragment. + */ + private function get_sqlite_index_column_fragment( + string $column_name, + ?int $sub_part, + ?string $sqlite_data_type + ): string { + $fragment = $this->quote_sqlite_identifier( $column_name ); + + if ( null === $sub_part ) { + return $fragment; + } + + $fragment = sprintf( 'SUBSTR(%s, 1, %d)', $fragment, $sub_part ); + + if ( 'TEXT' === $sqlite_data_type ) { + $fragment .= ' COLLATE NOCASE'; + } + return $fragment; + } + + /** + * Get a MySQL index key part prefix length. + * + * @param WP_Parser_Node $key_part The "keyPart" AST node. + * @return int|null The prefix length, or null for full column indexes. + */ + private function get_index_key_part_length( WP_Parser_Node $key_part ): ?int { + $field_length = $key_part->get_first_descendant_node( 'fieldLength' ); + if ( null === $field_length ) { + return null; + } + + foreach ( $field_length->get_descendant_tokens() as $token ) { + $value = $token->get_value(); + if ( preg_match( '/^[0-9]+$/', $value ) ) { + return (int) $value; + } + } + + return null; + } + /** * Translate a stored functional index expression for SQLite CREATE INDEX. * @@ -6812,6 +6898,11 @@ private function get_sqlite_create_table_statement( array( $database, $table_name ) )->fetchAll( PDO::FETCH_ASSOC ); + $column_info_map = array(); + foreach ( $column_info as $column ) { + $column_info_map[ $column['COLUMN_NAME'] ] = $column; + } + // 3. Get index info, grouped by index name. $statistics_table = $this->information_schema_builder->get_table_name( $table_is_temporary, 'statistics' ); $constraint_info = $this->execute_sqlite_query( @@ -6986,14 +7077,25 @@ private function get_sqlite_create_table_statement( $info = $constraint[1]; $column_list = array_map( - function ( $column ) use ( $table_name ) { + function ( $column ) use ( $table_name, $column_info_map, $info ) { if ( null === $column['COLUMN_NAME'] ) { $fragment = $this->translate_stored_index_expression( $column['EXPRESSION'], $table_name ); } else { - $fragment = $this->quote_sqlite_identifier( $column['COLUMN_NAME'] ); + $column_info = $column_info_map[ $column['COLUMN_NAME'] ] ?? null; + $sqlite_data_type = null === $column_info + ? null + : ( self::DATA_TYPE_STRING_MAP[ $column_info['DATA_TYPE'] ] ?? null ); + $sub_part = 'PRIMARY' === $info['INDEX_NAME'] || null === $column['SUB_PART'] + ? null + : (int) $column['SUB_PART']; + $fragment = $this->get_sqlite_index_column_fragment( + $column['COLUMN_NAME'], + $sub_part, + $sqlite_data_type + ); } if ( 'D' === $column['COLLATION'] ) { diff --git a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php index 69df244eb..6f5effb2b 100644 --- a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php @@ -10786,6 +10786,21 @@ public function testSqlancerRenamedFunctionalIndexRebuildUsesCurrentTableQualifi $this->assertStringNotContainsString( 't2 . c0', $result[0]->Expression ); } + public function testSqlancerUniqueTextPrefixIndexesUsePrefixValues(): void { + $this->assertQuery( "CREATE TABLE t0(c0 MEDIUMTEXT COMMENT 'asdf')" ); + $this->assertQuery( 'CREATE UNIQUE INDEX i0 ON t0(c0(3) ASC) VISIBLE ALGORITHM= COPY' ); + $this->assertQuery( 'CREATE UNIQUE INDEX i1 ON t0(c0(2) DESC) ALGORITHM= COPY' ); + $this->assertQuery( 'INSERT DELAYED INTO t0(c0) VALUES(0.6904897792105997)' ); + $this->assertQuery( 'REPLACE INTO t0(c0) VALUES(0.7092344227870846)' ); + $this->assertQuery( 'DROP INDEX i1 ON t0 ALGORITHM=DEFAULT' ); + $this->assertQuery( 'INSERT LOW_PRIORITY INTO t0(c0) VALUES(0.6904897792105997)' ); + + $result = $this->assertQuery( 'SELECT COUNT(*) AS rows_count, GROUP_CONCAT(SUBSTR(c0, 1, 3) ORDER BY c0) AS saved_prefixes FROM t0' ); + + $this->assertSame( '2', $result[0]->rows_count ); + $this->assertSame( '0.6,0.7', $result[0]->saved_prefixes ); + } + public function testSqlancerGroupByNegativeIntegerLiteralIsExpression(): void { $this->assertQuery( 'CREATE TABLE t0(c0 INT)' ); $this->assertQuery( 'INSERT INTO t0(c0) VALUES(1), (2)' ); diff --git a/tests/e2e/specs/sqlancer-fuzz-regressions.test.js b/tests/e2e/specs/sqlancer-fuzz-regressions.test.js index 0c6dde58c..316605255 100644 --- a/tests/e2e/specs/sqlancer-fuzz-regressions.test.js +++ b/tests/e2e/specs/sqlancer-fuzz-regressions.test.js @@ -145,6 +145,17 @@ sdi_sqlancer_query( "TRUNCATE TABLE $renamed_functional_index_target" ); sdi_sqlancer_query( "ALTER TABLE $renamed_functional_index_target COMPRESSION 'LZ4', PACK_KEYS 0, INSERT_METHOD NO, RENAME $renamed_functional_index_table, STATS_AUTO_RECALC DEFAULT, FORCE, ROW_FORMAT DYNAMIC, ALGORITHM INPLACE, DELAY_KEY_WRITE 1, CHECKSUM 0" ); $renamed_functional_index_row = $wpdb->get_row( "SHOW INDEX FROM $renamed_functional_index_table WHERE Key_name = 'i_sqlancer_renamed_functional'", ARRAY_A ); +$prefix_index_table = $wpdb->prefix . 'sqlancer_prefix_index_t0'; +sdi_sqlancer_query( "DROP TABLE IF EXISTS $prefix_index_table" ); +sdi_sqlancer_query( "CREATE TABLE $prefix_index_table(c0 MEDIUMTEXT COMMENT 'asdf')" ); +sdi_sqlancer_query( "CREATE UNIQUE INDEX i_sqlancer_prefix_3 ON $prefix_index_table(c0(3) ASC) VISIBLE ALGORITHM= COPY" ); +sdi_sqlancer_query( "CREATE UNIQUE INDEX i_sqlancer_prefix_2 ON $prefix_index_table(c0(2) DESC) ALGORITHM= COPY" ); +sdi_sqlancer_query( "INSERT DELAYED INTO $prefix_index_table(c0) VALUES(0.6904897792105997)" ); +sdi_sqlancer_query( "REPLACE INTO $prefix_index_table(c0) VALUES(0.7092344227870846)" ); +sdi_sqlancer_query( "DROP INDEX i_sqlancer_prefix_2 ON $prefix_index_table ALGORITHM=DEFAULT" ); +sdi_sqlancer_query( "INSERT LOW_PRIORITY INTO $prefix_index_table(c0) VALUES(0.6904897792105997)" ); +$prefix_index_row = $wpdb->get_row( "SELECT COUNT(*) AS rows_count, GROUP_CONCAT(SUBSTR(c0, 1, 3) ORDER BY c0) AS saved_prefixes FROM $prefix_index_table", ARRAY_A ); + $literal_index_table = $wpdb->prefix . 'sqlancer_literal_index'; sdi_sqlancer_query( "DROP TABLE IF EXISTS $literal_index_table" ); sdi_sqlancer_query( "CREATE TABLE $literal_index_table(c1 MEDIUMINT UNIQUE KEY COLUMN_FORMAT DEFAULT COMMENT 'asdf')" ); @@ -197,6 +208,7 @@ echo 'SQLANCER_ORDER_NEGATIVE_JSON:' . wp_json_encode( $order_negative_row ) . P echo 'SQLANCER_ORDERING_EXPRESSION_JSON:' . wp_json_encode( $ordering_expression_row ) . PHP_EOL; echo 'SQLANCER_INDEX_JSON:' . wp_json_encode( $index_row ) . PHP_EOL; echo 'SQLANCER_RENAMED_FUNCTIONAL_INDEX_JSON:' . wp_json_encode( $renamed_functional_index_row ) . PHP_EOL; +echo 'SQLANCER_PREFIX_INDEX_JSON:' . wp_json_encode( $prefix_index_row ) . PHP_EOL; echo 'SQLANCER_LITERAL_INDEX_JSON:' . wp_json_encode( $literal_index_row ) . PHP_EOL; echo 'SQLANCER_REDUNDANT_INDEX_JSON:' . wp_json_encode( $redundant_index_rows ) . PHP_EOL; echo 'SQLANCER_COUNT_DISTINCT_JSON:' . wp_json_encode( $count_distinct_row ) . PHP_EOL; @@ -528,6 +540,26 @@ echo 'SQLANCER_MEMORY_DEFAULT_JSON:' . wp_json_encode( $memory_default_rows ) . 'sqlancer_renamed_functional_index_t2 . c0' ); + const prefixIndexJsonLine = output + .trim() + .split( /\r?\n/ ) + .find( ( line ) => + line.startsWith( 'SQLANCER_PREFIX_INDEX_JSON:' ) + ); + + expect( prefixIndexJsonLine ).toBeTruthy(); + expect( + JSON.parse( + prefixIndexJsonLine.replace( + 'SQLANCER_PREFIX_INDEX_JSON:', + '' + ) + ) + ).toEqual( { + rows_count: '2', + saved_prefixes: '0.6,0.7', + } ); + const literalIndexJsonLine = output .trim() .split( /\r?\n/ ) From 16b467eea311e3121b6a13f67c98327efcc5e5fc Mon Sep 17 00:00:00 2001 From: adamziel Date: Wed, 17 Jun 2026 07:49:08 +0000 Subject: [PATCH 22/37] Handle SQLancer IF truthiness indexes --- ...s-wp-sqlite-pdo-user-defined-functions.php | 6 +++- .../tests/WP_SQLite_Driver_Tests.php | 18 +++++++++++ .../specs/sqlancer-fuzz-regressions.test.js | 32 +++++++++++++++++++ 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-pdo-user-defined-functions.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-pdo-user-defined-functions.php index 3f484cb74..dc86ff7e1 100644 --- a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-pdo-user-defined-functions.php +++ b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-pdo-user-defined-functions.php @@ -782,7 +782,11 @@ public function isnull( $field ) { * @return mixed */ public function _if( $expression, $truthy, $falsy ) { - return ( true === $expression ) ? $truthy : $falsy; + if ( null === $expression ) { + return $falsy; + } + + return ( 0.0 !== (float) $expression ) ? $truthy : $falsy; } /** diff --git a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php index 6f5effb2b..941bf73b3 100644 --- a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php @@ -10874,6 +10874,24 @@ public function testSqlancerCastSignedRoundsNumericValuesInPredicates(): void { $this->assertNull( $result[0]->c0 ); } + public function testSqlancerIfUsesMysqlNumericTruthinessInFunctionalIndexes(): void { + $result = $this->assertQuery( "SELECT IF((- (732094579)), CAST(NULL AS SIGNED), 9) AS negative_truthy, IF(0, 1, 2) AS zero_falsy, IF(NULL, 1, 2) AS null_falsy, IF('1abc', 1, 2) AS leading_numeric_truthy, IF('abc', 1, 2) AS non_numeric_falsy" ); + + $this->assertNull( $result[0]->negative_truthy ); + $this->assertSame( '2', $result[0]->zero_falsy ); + $this->assertSame( '2', $result[0]->null_falsy ); + $this->assertSame( '1', $result[0]->leading_numeric_truthy ); + $this->assertSame( '2', $result[0]->non_numeric_falsy ); + + $this->assertQuery( 'CREATE TABLE t0(c0 DECIMAL PRIMARY KEY NOT NULL)' ); + $this->assertQuery( 'INSERT INTO t0(c0) VALUES(1), (2)' ); + $this->assertQuery( 'CREATE UNIQUE INDEX i0 USING BTREE ON t0((IF((- (732094579)), CAST(NULL AS SIGNED), t0.c0))) ALGORITHM COPY' ); + + $result = $this->assertQuery( 'SELECT COUNT(*) AS rows_count FROM t0' ); + + $this->assertSame( '2', $result[0]->rows_count ); + } + public function testInsertIntoSetSyntax(): void { $this->assertQuery( 'CREATE TABLE t ( diff --git a/tests/e2e/specs/sqlancer-fuzz-regressions.test.js b/tests/e2e/specs/sqlancer-fuzz-regressions.test.js index 316605255..a0e359ed1 100644 --- a/tests/e2e/specs/sqlancer-fuzz-regressions.test.js +++ b/tests/e2e/specs/sqlancer-fuzz-regressions.test.js @@ -201,6 +201,13 @@ $cast_row = $wpdb->get_row( "SELECT CAST(0.8338761836534807 AS SIGNED) AS rounde sdi_sqlancer_query( "UPDATE $cast_table SET c0=\\"\\" WHERE ( EXISTS (SELECT 1 WHERE FALSE)) IN (CAST(IFNULL($cast_table.c0, 0.8338761836534807) AS SIGNED))" ); $cast_after_update_row = $wpdb->get_row( "SELECT c0 FROM $cast_table", ARRAY_A ); +$if_truthiness_table = $wpdb->prefix . 'sqlancer_if_truthiness'; +sdi_sqlancer_query( "DROP TABLE IF EXISTS $if_truthiness_table" ); +sdi_sqlancer_query( "CREATE TABLE $if_truthiness_table(c0 DECIMAL PRIMARY KEY NOT NULL)" ); +sdi_sqlancer_query( "INSERT INTO $if_truthiness_table(c0) VALUES(1), (2)" ); +sdi_sqlancer_query( "CREATE UNIQUE INDEX i_sqlancer_if_truthiness ON $if_truthiness_table((IF((- (732094579)), CAST(NULL AS SIGNED), $if_truthiness_table.c0))) ALGORITHM COPY" ); +$if_truthiness_row = $wpdb->get_row( "SELECT COUNT(*) AS rows_count, IF((- (732094579)), CAST(NULL AS SIGNED), 9) AS negative_truthy, IF(0, 1, 2) AS zero_falsy, IF(NULL, 1, 2) AS null_falsy, IF('1abc', 1, 2) AS leading_numeric_truthy, IF('abc', 1, 2) AS non_numeric_falsy FROM $if_truthiness_table", ARRAY_A ); + echo 'SQLANCER_JSON:' . wp_json_encode( $row ) . PHP_EOL; echo 'SQLANCER_BIT_COUNT_JSON:' . wp_json_encode( $bit_count_row ) . PHP_EOL; echo 'SQLANCER_BOOLEAN_JSON:' . wp_json_encode( $boolean_row ) . PHP_EOL; @@ -216,6 +223,7 @@ echo 'SQLANCER_RENAME_JSON:' . wp_json_encode( $rename_row ) . PHP_EOL; echo 'SQLANCER_RENAME_DROP_INDEX_JSON:' . wp_json_encode( $rename_drop_index_row ) . PHP_EOL; echo 'SQLANCER_CAST_SIGNED_JSON:' . wp_json_encode( $cast_row ) . PHP_EOL; echo 'SQLANCER_CAST_SIGNED_AFTER_UPDATE_JSON:' . wp_json_encode( $cast_after_update_row ) . PHP_EOL; +echo 'SQLANCER_IF_TRUTHINESS_JSON:' . wp_json_encode( $if_truthiness_row ) . PHP_EOL; echo 'SQLANCER_INTEGER_STRING_JSON:' . wp_json_encode( $integer_string_row ) . PHP_EOL; echo 'SQLANCER_DECIMAL_SCALE_JSON:' . wp_json_encode( $decimal_scale_row ) . PHP_EOL; echo 'SQLANCER_DECIMAL_SCALE_AFTER_UPDATE_JSON:' . wp_json_encode( $decimal_scale_after_update_row ) . PHP_EOL; @@ -694,5 +702,29 @@ echo 'SQLANCER_MEMORY_DEFAULT_JSON:' . wp_json_encode( $memory_default_rows ) . ).toEqual( { c0: null, } ); + + const ifTruthinessJsonLine = output + .trim() + .split( /\r?\n/ ) + .find( ( line ) => + line.startsWith( 'SQLANCER_IF_TRUTHINESS_JSON:' ) + ); + + expect( ifTruthinessJsonLine ).toBeTruthy(); + expect( + JSON.parse( + ifTruthinessJsonLine.replace( + 'SQLANCER_IF_TRUTHINESS_JSON:', + '' + ) + ) + ).toEqual( { + rows_count: '2', + negative_truthy: null, + zero_falsy: '2', + null_falsy: '2', + leading_numeric_truthy: '1', + non_numeric_falsy: '2', + } ); } ); } ); From c4612333dcac5d0c83966c61b9440e0ab069ae87 Mon Sep 17 00:00:00 2001 From: adamziel Date: Wed, 17 Jun 2026 09:46:44 +0000 Subject: [PATCH 23/37] Allow alternate SQLancer MySQL oracles --- bin/run-sqlancer-sqlite-fuzz.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bin/run-sqlancer-sqlite-fuzz.sh b/bin/run-sqlancer-sqlite-fuzz.sh index ce908cba2..edb6418f6 100755 --- a/bin/run-sqlancer-sqlite-fuzz.sh +++ b/bin/run-sqlancer-sqlite-fuzz.sh @@ -11,6 +11,7 @@ MYSQL_TMPFS_SIZE="${MYSQL_TMPFS_SIZE:-1024m}" RANDOM_SEED="${RANDOM_SEED:-20260617}" NUM_QUERIES="${NUM_QUERIES:-200}" MAX_GENERATED_DATABASES="${MAX_GENERATED_DATABASES:-1}" +SQLANCER_MYSQL_ORACLE="${SQLANCER_MYSQL_ORACLE:-FUZZER}" DATABASE_PREFIX="${DATABASE_PREFIX:-sdi_fuzz}" ARTIFACTS_DIR="${ARTIFACTS_DIR:-/tmp/sdi-sqlancer-artifacts/$(date -u +%Y%m%d-%H%M%S)}" @@ -81,7 +82,7 @@ docker run --rm \ --password "$MYSQL_PASSWORD" \ --host "$MYSQL_CONTAINER" \ --port 3306 \ - mysql --oracle FUZZER + mysql --oracle "$SQLANCER_MYSQL_ORACLE" LOG_FILE="$(find "$SQLANCER_DIR/logs/mysql" -maxdepth 1 -type f -name '*-cur.log' | sort | head -n 1)" if [ -z "$LOG_FILE" ]; then From 5af0a4bcf3046a658272d6ea0039df9a36e74183 Mon Sep 17 00:00:00 2001 From: adamziel Date: Wed, 17 Jun 2026 10:01:27 +0000 Subject: [PATCH 24/37] Drop MySQL SELECT optimizer hints --- .../sqlite/class-wp-pdo-mysql-on-sqlite.php | 48 +++++++++++++++- .../tests/WP_SQLite_Driver_Tests.php | 22 ++++++++ .../specs/sqlancer-fuzz-regressions.test.js | 55 +++++++++++++++++++ 3 files changed, 123 insertions(+), 2 deletions(-) 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 2a747bfa1..e203e48c6 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 @@ -4689,7 +4689,9 @@ private function translate_query_specification( WP_Parser_Node $node ): string { // items with the ones that were disambiguated using the SELECT list. $parts = array(); foreach ( $node->get_children() as $child ) { - if ( $child instanceof WP_Parser_Node && 'groupByClause' === $child->rule_name ) { + if ( $this->is_ignored_select_option_token( $child ) ) { + continue; + } elseif ( $child instanceof WP_Parser_Node && 'groupByClause' === $child->rule_name ) { $parts[] = $group_by_clause; } elseif ( $child instanceof WP_Parser_Node && 'havingClause' === $child->rule_name ) { // SQLite doesn't allow using the "HAVING" clause without "GROUP BY". @@ -4707,7 +4709,49 @@ private function translate_query_specification( WP_Parser_Node $node ): string { } return implode( ' ', $parts ); } - return $this->translate_sequence( $node->get_children() ); + + $parts = array(); + foreach ( $node->get_children() as $child ) { + if ( $this->is_ignored_select_option_token( $child ) ) { + continue; + } + + $part = $this->translate( $child ); + if ( null !== $part ) { + $parts[] = $part; + } + } + return implode( ' ', $parts ); + } + + /** + * Check whether a token is a MySQL-only SELECT option that SQLite should ignore. + * + * @param mixed $child The AST child node or token. + * @return bool Whether the token should be dropped from a SELECT option list. + */ + private function is_ignored_select_option_token( $child ): bool { + if ( $child instanceof WP_Parser_Node && 'selectOption' === $child->rule_name ) { + $child = $child->get_first_descendant_token(); + } + + if ( ! $child instanceof WP_MySQL_Token ) { + return false; + } + + return in_array( + $child->id, + array( + WP_MySQL_Lexer::HIGH_PRIORITY_SYMBOL, + WP_MySQL_Lexer::SQL_BIG_RESULT_SYMBOL, + WP_MySQL_Lexer::SQL_BUFFER_RESULT_SYMBOL, + WP_MySQL_Lexer::SQL_CACHE_SYMBOL, + WP_MySQL_Lexer::SQL_NO_CACHE_SYMBOL, + WP_MySQL_Lexer::SQL_SMALL_RESULT_SYMBOL, + WP_MySQL_Lexer::STRAIGHT_JOIN_SYMBOL, + ), + true + ); } /** diff --git a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php index 941bf73b3..c91732ab7 100644 --- a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php @@ -10892,6 +10892,28 @@ public function testSqlancerIfUsesMysqlNumericTruthinessInFunctionalIndexes(): v $this->assertSame( '2', $result[0]->rows_count ); } + public function testSqlancerSelectOptimizerHintsAreDropped(): void { + $this->assertQuery( 'CREATE TABLE t0(c0 TINYTEXT NULL)' ); + $this->assertQuery( "INSERT INTO t0(c0) VALUES('b'), ('a'), (NULL)" ); + + $result = $this->assertQuery( 'SELECT DISTINCT SQL_SMALL_RESULT t0.c0 AS ref0 FROM t0 WHERE ((CAST(t0.c0 AS SIGNED)) >= ((496989597) IN (NULL))) IS NULL ORDER BY t0.c0' ); + + $this->assertCount( 3, $result ); + $this->assertNull( $result[0]->ref0 ); + $this->assertSame( 'a', $result[1]->ref0 ); + $this->assertSame( 'b', $result[2]->ref0 ); + + $result = $this->assertQuery( 'SELECT DISTINCT SQL_BIG_RESULT STRAIGHT_JOIN SQL_NO_CACHE t0.c0 AS ref0 FROM t0 WHERE t0.c0 IS NOT NULL ORDER BY t0.c0' ); + + $this->assertCount( 2, $result ); + $this->assertSame( 'a', $result[0]->ref0 ); + $this->assertSame( 'b', $result[1]->ref0 ); + + $result = $this->assertQuery( 'SELECT HIGH_PRIORITY SQL_BUFFER_RESULT SQL_CACHE COUNT(*) AS rows_count FROM t0' ); + + $this->assertSame( '3', $result[0]->rows_count ); + } + public function testInsertIntoSetSyntax(): void { $this->assertQuery( 'CREATE TABLE t ( diff --git a/tests/e2e/specs/sqlancer-fuzz-regressions.test.js b/tests/e2e/specs/sqlancer-fuzz-regressions.test.js index a0e359ed1..144268af4 100644 --- a/tests/e2e/specs/sqlancer-fuzz-regressions.test.js +++ b/tests/e2e/specs/sqlancer-fuzz-regressions.test.js @@ -208,6 +208,13 @@ sdi_sqlancer_query( "INSERT INTO $if_truthiness_table(c0) VALUES(1), (2)" ); sdi_sqlancer_query( "CREATE UNIQUE INDEX i_sqlancer_if_truthiness ON $if_truthiness_table((IF((- (732094579)), CAST(NULL AS SIGNED), $if_truthiness_table.c0))) ALGORITHM COPY" ); $if_truthiness_row = $wpdb->get_row( "SELECT COUNT(*) AS rows_count, IF((- (732094579)), CAST(NULL AS SIGNED), 9) AS negative_truthy, IF(0, 1, 2) AS zero_falsy, IF(NULL, 1, 2) AS null_falsy, IF('1abc', 1, 2) AS leading_numeric_truthy, IF('abc', 1, 2) AS non_numeric_falsy FROM $if_truthiness_table", ARRAY_A ); +$select_modifier_table = $wpdb->prefix . 'sqlancer_select_modifiers'; +sdi_sqlancer_query( "DROP TABLE IF EXISTS $select_modifier_table" ); +sdi_sqlancer_query( "CREATE TABLE $select_modifier_table(c0 TINYTEXT NULL)" ); +sdi_sqlancer_query( "INSERT INTO $select_modifier_table(c0) VALUES('b'), ('a'), (NULL)" ); +$select_modifier_rows = $wpdb->get_results( "SELECT DISTINCT SQL_SMALL_RESULT SQL_BIG_RESULT SQL_NO_CACHE STRAIGHT_JOIN c0 AS ref0 FROM $select_modifier_table ORDER BY c0", ARRAY_A ); +$select_modifier_count_row = $wpdb->get_row( "SELECT HIGH_PRIORITY SQL_BUFFER_RESULT SQL_CACHE COUNT(*) AS rows_count FROM $select_modifier_table", ARRAY_A ); + echo 'SQLANCER_JSON:' . wp_json_encode( $row ) . PHP_EOL; echo 'SQLANCER_BIT_COUNT_JSON:' . wp_json_encode( $bit_count_row ) . PHP_EOL; echo 'SQLANCER_BOOLEAN_JSON:' . wp_json_encode( $boolean_row ) . PHP_EOL; @@ -224,6 +231,8 @@ echo 'SQLANCER_RENAME_DROP_INDEX_JSON:' . wp_json_encode( $rename_drop_index_row echo 'SQLANCER_CAST_SIGNED_JSON:' . wp_json_encode( $cast_row ) . PHP_EOL; echo 'SQLANCER_CAST_SIGNED_AFTER_UPDATE_JSON:' . wp_json_encode( $cast_after_update_row ) . PHP_EOL; echo 'SQLANCER_IF_TRUTHINESS_JSON:' . wp_json_encode( $if_truthiness_row ) . PHP_EOL; +echo 'SQLANCER_SELECT_MODIFIER_JSON:' . wp_json_encode( $select_modifier_rows ) . PHP_EOL; +echo 'SQLANCER_SELECT_MODIFIER_COUNT_JSON:' . wp_json_encode( $select_modifier_count_row ) . PHP_EOL; echo 'SQLANCER_INTEGER_STRING_JSON:' . wp_json_encode( $integer_string_row ) . PHP_EOL; echo 'SQLANCER_DECIMAL_SCALE_JSON:' . wp_json_encode( $decimal_scale_row ) . PHP_EOL; echo 'SQLANCER_DECIMAL_SCALE_AFTER_UPDATE_JSON:' . wp_json_encode( $decimal_scale_after_update_row ) . PHP_EOL; @@ -726,5 +735,51 @@ echo 'SQLANCER_MEMORY_DEFAULT_JSON:' . wp_json_encode( $memory_default_rows ) . leading_numeric_truthy: '1', non_numeric_falsy: '2', } ); + + const selectModifierJsonLine = output + .trim() + .split( /\r?\n/ ) + .find( ( line ) => + line.startsWith( 'SQLANCER_SELECT_MODIFIER_JSON:' ) + ); + + expect( selectModifierJsonLine ).toBeTruthy(); + expect( + JSON.parse( + selectModifierJsonLine.replace( + 'SQLANCER_SELECT_MODIFIER_JSON:', + '' + ) + ) + ).toEqual( [ + { + ref0: null, + }, + { + ref0: 'a', + }, + { + ref0: 'b', + }, + ] ); + + const selectModifierCountJsonLine = output + .trim() + .split( /\r?\n/ ) + .find( ( line ) => + line.startsWith( 'SQLANCER_SELECT_MODIFIER_COUNT_JSON:' ) + ); + + expect( selectModifierCountJsonLine ).toBeTruthy(); + expect( + JSON.parse( + selectModifierCountJsonLine.replace( + 'SQLANCER_SELECT_MODIFIER_COUNT_JSON:', + '' + ) + ) + ).toEqual( { + rows_count: '3', + } ); } ); } ); From daa7f1cbacea671cd9d06dafc7c63db414765d8c Mon Sep 17 00:00:00 2001 From: adamziel Date: Wed, 17 Jun 2026 10:14:20 +0000 Subject: [PATCH 25/37] Add scheduled SQLancer fuzz workflow --- .github/workflows/sqlancer-sqlite-fuzz.yml | 231 +++++++++++++++++++++ AGENTS.md | 3 + tests/fuzz/append-sqlancer-finding.php | 121 +++++++++++ tests/fuzz/sqlancer-findings.md | 10 + 4 files changed, 365 insertions(+) create mode 100644 .github/workflows/sqlancer-sqlite-fuzz.yml create mode 100644 tests/fuzz/append-sqlancer-finding.php create mode 100644 tests/fuzz/sqlancer-findings.md diff --git a/.github/workflows/sqlancer-sqlite-fuzz.yml b/.github/workflows/sqlancer-sqlite-fuzz.yml new file mode 100644 index 000000000..7875f76cb --- /dev/null +++ b/.github/workflows/sqlancer-sqlite-fuzz.yml @@ -0,0 +1,231 @@ +name: SQLancer SQLite Fuzz + +on: + schedule: + # Bounded recurring fuzzing. GitHub Actions cannot run forever, so this + # workflow rotates deterministic seeds/oracles on a schedule. + - cron: '17 */6 * * *' + workflow_dispatch: + inputs: + oracle: + description: 'SQLancer MySQL oracle to run, or auto to rotate by run number.' + required: false + default: 'auto' + type: choice + options: + - auto + - FUZZER + - TLP_WHERE + - PQS + - DQP + - DQE + seed: + description: 'Optional deterministic SQLancer seed.' + required: false + default: '' + num_queries: + description: 'Optional query count. Defaults are chosen per oracle.' + required: false + default: '' + max_generated_databases: + description: 'Maximum generated databases.' + required: false + default: '1' + append_findings: + description: 'Append replay failures to tests/fuzz/sqlancer-findings.md on an automation PR.' + required: false + default: true + type: boolean + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +permissions: {} + +jobs: + fuzz: + name: SQLancer ${{ github.event_name == 'workflow_dispatch' && inputs.oracle || 'auto' }} + runs-on: ubuntu-latest + timeout-minutes: 120 + permissions: + contents: write # Required to push the findings branch. + pull-requests: write # Required to open/update the findings PR. + + env: + FINDINGS_BRANCH: automation/sqlancer-sqlite-fuzz-findings + FINDINGS_FILE: tests/fuzz/sqlancer-findings.md + INPUT_ORACLE: ${{ github.event_name == 'workflow_dispatch' && inputs.oracle || 'auto' }} + INPUT_SEED: ${{ github.event_name == 'workflow_dispatch' && inputs.seed || '' }} + INPUT_NUM_QUERIES: ${{ github.event_name == 'workflow_dispatch' && inputs.num_queries || '' }} + INPUT_MAX_GENERATED_DATABASES: ${{ github.event_name == 'workflow_dispatch' && inputs.max_generated_databases || '1' }} + INPUT_APPEND_FINDINGS: ${{ github.event_name != 'workflow_dispatch' || inputs.append_findings }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + coverage: none + + - 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: Choose fuzz settings + id: settings + run: | + set -euo pipefail + + ORACLES=( FUZZER TLP_WHERE PQS DQP DQE ) + DEFAULT_QUERIES=( 10000 3000 1000 300 300 ) + + ORACLE="$INPUT_ORACLE" + if [ -z "$ORACLE" ] || [ "$ORACLE" = 'auto' ]; then + INDEX=$(( GITHUB_RUN_NUMBER % ${#ORACLES[@]} )) + ORACLE="${ORACLES[$INDEX]}" + fi + + NUM_QUERIES="$INPUT_NUM_QUERIES" + if [ -z "$NUM_QUERIES" ]; then + NUM_QUERIES=1000 + for i in "${!ORACLES[@]}"; do + if [ "${ORACLES[$i]}" = "$ORACLE" ]; then + NUM_QUERIES="${DEFAULT_QUERIES[$i]}" + break + fi + done + fi + + SEED="$INPUT_SEED" + if [ -z "$SEED" ]; then + SEED=$(( 20270000 + GITHUB_RUN_NUMBER )) + fi + + MAX_GENERATED_DATABASES="$INPUT_MAX_GENERATED_DATABASES" + if [ -z "$MAX_GENERATED_DATABASES" ]; then + MAX_GENERATED_DATABASES=1 + fi + + ARTIFACTS_DIR="$RUNNER_TEMP/sqlancer-artifacts-$ORACLE-$SEED" + + { + echo "oracle=$ORACLE" + echo "seed=$SEED" + echo "num_queries=$NUM_QUERIES" + echo "max_generated_databases=$MAX_GENERATED_DATABASES" + echo "artifacts_dir=$ARTIFACTS_DIR" + } >> "$GITHUB_OUTPUT" + + { + echo "SQLANCER_MYSQL_ORACLE=$ORACLE" + echo "RANDOM_SEED=$SEED" + echo "NUM_QUERIES=$NUM_QUERIES" + echo "MAX_GENERATED_DATABASES=$MAX_GENERATED_DATABASES" + echo "ARTIFACTS_DIR=$ARTIFACTS_DIR" + } >> "$GITHUB_ENV" + + - name: Run SQLancer replay + id: fuzz + continue-on-error: true + run: | + set +e + mkdir -p "$ARTIFACTS_DIR" + ./bin/run-sqlancer-sqlite-fuzz.sh > "$ARTIFACTS_DIR/runner-output.txt" 2>&1 + STATUS=$? + cat "$ARTIFACTS_DIR/runner-output.txt" + echo "status=$STATUS" >> "$GITHUB_OUTPUT" + exit "$STATUS" + + - name: Detect replay failure + id: replay_failure + if: steps.fuzz.outputs.status != '0' + run: | + if grep -q '^FAIL line [0-9][0-9]*:' "$ARTIFACTS_DIR/runner-output.txt"; then + echo "found=true" >> "$GITHUB_OUTPUT" + else + echo "found=false" >> "$GITHUB_OUTPUT" + fi + + - name: Upload SQLancer artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: sqlancer-${{ steps.settings.outputs.oracle }}-${{ steps.settings.outputs.seed }} + path: ${{ steps.settings.outputs.artifacts_dir }} + if-no-files-found: ignore + + - name: Append replay failure to findings branch + if: steps.replay_failure.outputs.found == 'true' && env.INPUT_APPEND_FINDINGS == 'true' + env: + GH_TOKEN: ${{ github.token }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + if git ls-remote --exit-code --heads origin "$FINDINGS_BRANCH" >/dev/null 2>&1; then + git fetch origin "$FINDINGS_BRANCH" + git checkout "origin/$FINDINGS_BRANCH" -- "$FINDINGS_FILE" || true + fi + + php tests/fuzz/append-sqlancer-finding.php \ + --output="$ARTIFACTS_DIR/runner-output.txt" \ + --findings="$FINDINGS_FILE" \ + --oracle="$SQLANCER_MYSQL_ORACLE" \ + --seed="$RANDOM_SEED" \ + --num-queries="$NUM_QUERIES" \ + --max-generated-databases="$MAX_GENERATED_DATABASES" \ + --artifacts="$ARTIFACTS_DIR" \ + --commit="$GITHUB_SHA" \ + --run-url="$RUN_URL" + + if git diff --quiet -- "$FINDINGS_FILE"; then + echo "No new finding to commit." + exit 0 + fi + + git checkout -B "$FINDINGS_BRANCH" + git add "$FINDINGS_FILE" + git commit -m "Record SQLancer SQLite fuzz finding" + git push --set-upstream origin "$FINDINGS_BRANCH" + + PR_BODY=$'This PR is maintained by the scheduled SQLancer SQLite fuzz workflow.\n\nIt appends newly found MySQL-accepted SQLite replay failures to `tests/fuzz/sqlancer-findings.md`. Each entry still needs a human reduction and a follow-up fix that moves the reduced case into the SQLancer regression tests.' + + if gh pr view "$FINDINGS_BRANCH" --json number >/dev/null 2>&1; then + gh pr edit "$FINDINGS_BRANCH" \ + --title "Record SQLancer SQLite fuzz findings" \ + --body "$PR_BODY" + else + gh pr create \ + --head "$FINDINGS_BRANCH" \ + --base "${{ github.event.repository.default_branch }}" \ + --title "Record SQLancer SQLite fuzz findings" \ + --body "$PR_BODY" + fi + + - name: Fail when fuzzing finds or hits an error + if: steps.fuzz.outputs.status != '0' + run: | + if [ "${{ steps.replay_failure.outputs.found }}" = 'true' ]; then + echo "SQLancer found a SQLite replay failure. See uploaded artifacts and the findings PR." + else + echo "SQLancer failed before producing a SQLite replay failure. See uploaded artifacts." + fi + exit "${{ steps.fuzz.outputs.status }}" diff --git a/AGENTS.md b/AGENTS.md index eb9f2d085..acc63222e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,6 +55,9 @@ composer run wp-test-start # Start WordPress environment (Docker) composer run wp-test-php # Run WordPress PHPUnit tests composer run wp-test-e2e # Run WordPress E2E tests (Playwright) composer run wp-test-clean # Clean up WordPress environment (Docker and DB) + +# SQLancer fuzzing +bin/run-sqlancer-sqlite-fuzz.sh # Generate MySQL SQLancer queries and replay MySQL-accepted statements through SQLite ``` ## Release workflow diff --git a/tests/fuzz/append-sqlancer-finding.php b/tests/fuzz/append-sqlancer-finding.php new file mode 100644 index 000000000..9565e43eb --- /dev/null +++ b/tests/fuzz/append-sqlancer-finding.php @@ -0,0 +1,121 @@ + $line ) { + if ( preg_match( '/^FAIL line ([0-9]+): (.*)$/', $line, $matches ) ) { + $failure_line = $matches[1]; + $failure_sql = $matches[2]; + $exception = $lines[ $index + 1 ] ?? ''; + + for ( $i = $index + 2; $i < count( $lines ); $i++ ) { + if ( 0 !== strpos( $lines[ $i ], ' SQLITE: ' ) ) { + break; + } + $sqlite_lines[] = substr( $lines[ $i ], 10 ); + } + break; + } +} + +if ( null === $failure_line ) { + fwrite( STDERR, "No replay failure was found in {$args['output']}.\n" ); + exit( 2 ); +} + +$marker = sprintf( + '', + $args['oracle'], + $args['seed'], + $failure_line +); + +$findings_path = $args['findings']; +$findings_dir = dirname( $findings_path ); +if ( ! is_dir( $findings_dir ) ) { + mkdir( $findings_dir, 0777, true ); +} + +$contents = is_readable( $findings_path ) ? file_get_contents( $findings_path ) : ''; +if ( false !== strpos( $contents, $marker ) ) { + echo "Finding already recorded: $marker\n"; + exit( 0 ); +} + +if ( '' === $contents ) { + $contents = "# SQLancer SQLite Findings\n\n"; +} + +$entry = sprintf( + "\n## %s - %s seed %s line %s\n\n%s\n\n- Run: %s\n- Commit: `%s`\n- Settings: `SQLANCER_MYSQL_ORACLE=%s RANDOM_SEED=%s NUM_QUERIES=%s MAX_GENERATED_DATABASES=%s`\n- Artifacts directory: `%s`\n\n```sql\n%s\n```\n\n```text\n%s\n```\n", + gmdate( 'Y-m-d H:i:s \U\T\C' ), + $args['oracle'], + $args['seed'], + $failure_line, + $marker, + $args['run-url'], + $args['commit'], + $args['oracle'], + $args['seed'], + $args['num-queries'], + $args['max-generated-databases'], + $args['artifacts'], + $failure_sql, + $exception +); + +if ( ! empty( $sqlite_lines ) ) { + $entry .= "\n```sql\n" . implode( "\n", $sqlite_lines ) . "\n```\n"; +} + +file_put_contents( $findings_path, rtrim( $contents ) . "\n" . $entry ); + +echo "Recorded SQLancer finding: $marker\n"; diff --git a/tests/fuzz/sqlancer-findings.md b/tests/fuzz/sqlancer-findings.md new file mode 100644 index 000000000..103af272d --- /dev/null +++ b/tests/fuzz/sqlancer-findings.md @@ -0,0 +1,10 @@ +# SQLancer SQLite Findings + +This file is an append-only queue for failures found by the scheduled SQLancer +SQLite fuzz workflow. + +Each entry records a MySQL-accepted SQLancer statement that failed when replayed +through the SQLite driver. Reduce each finding, move the reduced query into +`tests/e2e/specs/sqlancer-fuzz-regressions.test.js` and the package-level +SQLancer test slice, then remove or mark the entry as handled in the follow-up +fix PR. From 5801b0f59df7eb83d198cbbf17a5eb11e2fe1692 Mon Sep 17 00:00:00 2001 From: adamziel Date: Wed, 17 Jun 2026 10:41:09 +0000 Subject: [PATCH 26/37] Fix SQLancer fuzz CI checks --- .github/workflows/sqlancer-sqlite-fuzz.yml | 3 + .../tests/WP_SQLite_Driver_Tests.php | 8 +-- .../WP_SQLite_Driver_Translation_Tests.php | 58 +++++++++---------- .../specs/sqlancer-fuzz-regressions.test.js | 22 ++----- 4 files changed, 41 insertions(+), 50 deletions(-) diff --git a/.github/workflows/sqlancer-sqlite-fuzz.yml b/.github/workflows/sqlancer-sqlite-fuzz.yml index 7875f76cb..a60ef4290 100644 --- a/.github/workflows/sqlancer-sqlite-fuzz.yml +++ b/.github/workflows/sqlancer-sqlite-fuzz.yml @@ -1,6 +1,9 @@ name: SQLancer SQLite Fuzz on: + push: + branches: + - codex/sqlancer-sqlite-fuzz schedule: # Bounded recurring fuzzing. GitHub Actions cannot run forever, so this # workflow rotates deterministic seeds/oracles on a schedule. diff --git a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php index c91732ab7..11e667ef9 100644 --- a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php @@ -6708,8 +6708,8 @@ public function testCreateComplexIndex(): void { $this->assertEquals( array( 'seqno' => '1', - 'cid' => '1', - 'name' => 'name', + 'cid' => '-2', + 'name' => null, 'desc' => '1', 'coll' => 'NOCASE', 'key' => '1', @@ -12017,8 +12017,8 @@ public function testCastValuesOnUpdate(): void { $this->assertQuery( 'UPDATE t SET value = 0x05' ); $this->assertQuery( "UPDATE t SET value = x'06'" ); } else { - // TODO: These are supported in MySQL: - $this->assertQueryError( "UPDATE t SET value = '4.5'", 'SQLSTATE[23000]: Integrity constraint violation: 19 cannot store REAL value in INTEGER column t.value' ); + $this->assertQuery( "UPDATE t SET value = '4.5'" ); + $this->assertSame( '5', $this->assertQuery( 'SELECT * FROM t' )[0]->value ); $this->assertQueryError( 'UPDATE t SET value = 0x05', 'SQLSTATE[23000]: Integrity constraint violation: 19 cannot store BLOB value in INTEGER column t.value' ); $this->assertQueryError( "UPDATE t SET value = x'06'", 'SQLSTATE[23000]: Integrity constraint violation: 19 cannot store BLOB value in INTEGER column t.value' ); } diff --git a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Translation_Tests.php b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Translation_Tests.php index 8cca92f6d..efa9d5ece 100644 --- a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Translation_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Translation_Tests.php @@ -196,36 +196,36 @@ public function testInsert(): void { $this->assertQuery( $is_values_naming_supported - ? 'INSERT INTO `t` (`c`) SELECT `column1` FROM (VALUES ( 1 )) WHERE true' - : 'INSERT INTO `t` (`c`) SELECT `column1` FROM (SELECT NULL AS `column1` WHERE FALSE UNION ALL VALUES ( 1 )) WHERE true', + ? "INSERT INTO `t` (`c`) SELECT CASE WHEN TYPEOF(`column1`) = 'blob' THEN `column1` ELSE _mysql_save_integer(`column1`) END FROM (VALUES ( 1 )) WHERE true" + : "INSERT INTO `t` (`c`) SELECT CASE WHEN TYPEOF(`column1`) = 'blob' THEN `column1` ELSE _mysql_save_integer(`column1`) END FROM (SELECT NULL AS `column1` WHERE FALSE UNION ALL VALUES ( 1 )) WHERE true", 'INSERT INTO t (c) VALUES (1)' ); $this->assertQuery( $is_values_naming_supported - ? 'INSERT INTO `t` (`c`) SELECT `column1` FROM (VALUES ( 1 )) WHERE true' - : 'INSERT INTO `t` (`c`) SELECT `column1` FROM (SELECT NULL AS `column1` WHERE FALSE UNION ALL VALUES ( 1 )) WHERE true', + ? "INSERT INTO `t` (`c`) SELECT CASE WHEN TYPEOF(`column1`) = 'blob' THEN `column1` ELSE _mysql_save_integer(`column1`) END FROM (VALUES ( 1 )) WHERE true" + : "INSERT INTO `t` (`c`) SELECT CASE WHEN TYPEOF(`column1`) = 'blob' THEN `column1` ELSE _mysql_save_integer(`column1`) END FROM (SELECT NULL AS `column1` WHERE FALSE UNION ALL VALUES ( 1 )) WHERE true", 'INSERT INTO wp.t (c) VALUES (1)' ); $this->assertQuery( $is_values_naming_supported - ? 'INSERT INTO `t` (`c1`, `c2`) SELECT `column1`, `column2` FROM (VALUES ( 1 , 2 )) WHERE true' - : 'INSERT INTO `t` (`c1`, `c2`) SELECT `column1`, `column2` FROM (SELECT NULL AS `column1`, NULL AS `column2` WHERE FALSE UNION ALL VALUES ( 1 , 2 )) WHERE true', + ? "INSERT INTO `t` (`c1`, `c2`) SELECT CASE WHEN TYPEOF(`column1`) = 'blob' THEN `column1` ELSE _mysql_save_integer(`column1`) END, CASE WHEN TYPEOF(`column2`) = 'blob' THEN `column2` ELSE _mysql_save_integer(`column2`) END FROM (VALUES ( 1 , 2 )) WHERE true" + : "INSERT INTO `t` (`c1`, `c2`) SELECT CASE WHEN TYPEOF(`column1`) = 'blob' THEN `column1` ELSE _mysql_save_integer(`column1`) END, CASE WHEN TYPEOF(`column2`) = 'blob' THEN `column2` ELSE _mysql_save_integer(`column2`) END FROM (SELECT NULL AS `column1`, NULL AS `column2` WHERE FALSE UNION ALL VALUES ( 1 , 2 )) WHERE true", 'INSERT INTO t (c1, c2) VALUES (1, 2)' ); $this->assertQuery( $is_values_naming_supported - ? 'INSERT INTO `t` (`c`) SELECT `column1` FROM (VALUES ( 1 ) , ( 2 )) WHERE true' - : 'INSERT INTO `t` (`c`) SELECT `column1` FROM (SELECT NULL AS `column1` WHERE FALSE UNION ALL VALUES ( 1 ) , ( 2 )) WHERE true', + ? "INSERT INTO `t` (`c`) SELECT CASE WHEN TYPEOF(`column1`) = 'blob' THEN `column1` ELSE _mysql_save_integer(`column1`) END FROM (VALUES ( 1 ) , ( 2 )) WHERE true" + : "INSERT INTO `t` (`c`) SELECT CASE WHEN TYPEOF(`column1`) = 'blob' THEN `column1` ELSE _mysql_save_integer(`column1`) END FROM (SELECT NULL AS `column1` WHERE FALSE UNION ALL VALUES ( 1 ) , ( 2 )) WHERE true", 'INSERT INTO t (c) VALUES (1), (2)' ); $this->assertQuery( array( 'SELECT * FROM (SELECT * FROM `t2`) LIMIT 1', - 'INSERT INTO `t1` (`c1`, `c2`) SELECT `c1`, `c2` FROM (SELECT * FROM `t2`) WHERE true', + "INSERT INTO `t1` (`c1`, `c2`) SELECT CASE WHEN TYPEOF(`c1`) = 'blob' THEN `c1` ELSE _mysql_save_integer(`c1`) END, CASE WHEN TYPEOF(`c2`) = 'blob' THEN `c2` ELSE _mysql_save_integer(`c2`) END FROM (SELECT * FROM `t2`) WHERE true", ), 'INSERT INTO t1 SELECT * FROM t2' ); @@ -278,36 +278,36 @@ public function testReplace(): void { $this->assertQuery( $is_values_naming_supported - ? 'REPLACE INTO `t` (`c`) SELECT `column1` FROM (VALUES ( 1 )) WHERE true' - : 'REPLACE INTO `t` (`c`) SELECT `column1` FROM (SELECT NULL AS `column1` WHERE FALSE UNION ALL VALUES ( 1 )) WHERE true', + ? "REPLACE INTO `t` (`c`) SELECT CASE WHEN TYPEOF(`column1`) = 'blob' THEN `column1` ELSE _mysql_save_integer(`column1`) END FROM (VALUES ( 1 )) WHERE true" + : "REPLACE INTO `t` (`c`) SELECT CASE WHEN TYPEOF(`column1`) = 'blob' THEN `column1` ELSE _mysql_save_integer(`column1`) END FROM (SELECT NULL AS `column1` WHERE FALSE UNION ALL VALUES ( 1 )) WHERE true", 'REPLACE INTO t (c) VALUES (1)' ); $this->assertQuery( $is_values_naming_supported - ? 'REPLACE INTO `t` (`c`) SELECT `column1` FROM (VALUES ( 1 )) WHERE true' - : 'REPLACE INTO `t` (`c`) SELECT `column1` FROM (SELECT NULL AS `column1` WHERE FALSE UNION ALL VALUES ( 1 )) WHERE true', + ? "REPLACE INTO `t` (`c`) SELECT CASE WHEN TYPEOF(`column1`) = 'blob' THEN `column1` ELSE _mysql_save_integer(`column1`) END FROM (VALUES ( 1 )) WHERE true" + : "REPLACE INTO `t` (`c`) SELECT CASE WHEN TYPEOF(`column1`) = 'blob' THEN `column1` ELSE _mysql_save_integer(`column1`) END FROM (SELECT NULL AS `column1` WHERE FALSE UNION ALL VALUES ( 1 )) WHERE true", 'REPLACE INTO wp.t (c) VALUES (1)' ); $this->assertQuery( $is_values_naming_supported - ? 'REPLACE INTO `t` (`c1`, `c2`) SELECT `column1`, `column2` FROM (VALUES ( 1 , 2 )) WHERE true' - : 'REPLACE INTO `t` (`c1`, `c2`) SELECT `column1`, `column2` FROM (SELECT NULL AS `column1`, NULL AS `column2` WHERE FALSE UNION ALL VALUES ( 1 , 2 )) WHERE true', + ? "REPLACE INTO `t` (`c1`, `c2`) SELECT CASE WHEN TYPEOF(`column1`) = 'blob' THEN `column1` ELSE _mysql_save_integer(`column1`) END, CASE WHEN TYPEOF(`column2`) = 'blob' THEN `column2` ELSE _mysql_save_integer(`column2`) END FROM (VALUES ( 1 , 2 )) WHERE true" + : "REPLACE INTO `t` (`c1`, `c2`) SELECT CASE WHEN TYPEOF(`column1`) = 'blob' THEN `column1` ELSE _mysql_save_integer(`column1`) END, CASE WHEN TYPEOF(`column2`) = 'blob' THEN `column2` ELSE _mysql_save_integer(`column2`) END FROM (SELECT NULL AS `column1`, NULL AS `column2` WHERE FALSE UNION ALL VALUES ( 1 , 2 )) WHERE true", 'REPLACE INTO t (c1, c2) VALUES (1, 2)' ); $this->assertQuery( $is_values_naming_supported - ? 'REPLACE INTO `t` (`c`) SELECT `column1` FROM (VALUES ( 1 ) , ( 2 )) WHERE true' - : 'REPLACE INTO `t` (`c`) SELECT `column1` FROM (SELECT NULL AS `column1` WHERE FALSE UNION ALL VALUES ( 1 ) , ( 2 )) WHERE true', + ? "REPLACE INTO `t` (`c`) SELECT CASE WHEN TYPEOF(`column1`) = 'blob' THEN `column1` ELSE _mysql_save_integer(`column1`) END FROM (VALUES ( 1 ) , ( 2 )) WHERE true" + : "REPLACE INTO `t` (`c`) SELECT CASE WHEN TYPEOF(`column1`) = 'blob' THEN `column1` ELSE _mysql_save_integer(`column1`) END FROM (SELECT NULL AS `column1` WHERE FALSE UNION ALL VALUES ( 1 ) , ( 2 )) WHERE true", 'REPLACE INTO t (c) VALUES (1), (2)' ); $this->assertQuery( array( 'SELECT * FROM (SELECT * FROM `t2`) LIMIT 1', - 'REPLACE INTO `t1` (`c1`, `c2`) SELECT `c1`, `c2` FROM (SELECT * FROM `t2`) WHERE true', + "REPLACE INTO `t1` (`c1`, `c2`) SELECT CASE WHEN TYPEOF(`c1`) = 'blob' THEN `c1` ELSE _mysql_save_integer(`c1`) END, CASE WHEN TYPEOF(`c2`) = 'blob' THEN `c2` ELSE _mysql_save_integer(`c2`) END FROM (SELECT * FROM `t2`) WHERE true", ), 'REPLACE INTO t1 SELECT * FROM t2' ); @@ -356,63 +356,63 @@ public function testUpdate(): void { $this->driver->query( 'CREATE TABLE t2 (id INT, c1 INT, c2 INT)' ); $this->assertQuery( - 'UPDATE `t` SET `c` = 1', + "UPDATE `t` SET `c` = CASE WHEN TYPEOF(1) = 'blob' THEN 1 ELSE _mysql_save_integer(1) END", 'UPDATE t SET c = 1' ); $this->assertQuery( - 'UPDATE `t` SET `c` = 1', + "UPDATE `t` SET `c` = CASE WHEN TYPEOF(1) = 'blob' THEN 1 ELSE _mysql_save_integer(1) END", 'UPDATE wp.t SET c = 1' ); $this->assertQuery( - 'UPDATE `t` SET `c1` = 1, `c2` = 2', + "UPDATE `t` SET `c1` = CASE WHEN TYPEOF(1) = 'blob' THEN 1 ELSE _mysql_save_integer(1) END, `c2` = CASE WHEN TYPEOF(2) = 'blob' THEN 2 ELSE _mysql_save_integer(2) END", 'UPDATE t SET c1 = 1, c2 = 2' ); $this->assertQuery( - 'UPDATE `t` SET `c` = 1 WHERE `c` = 2', + "UPDATE `t` SET `c` = CASE WHEN TYPEOF(1) = 'blob' THEN 1 ELSE _mysql_save_integer(1) END WHERE `c` = 2", 'UPDATE t SET c = 1 WHERE c = 2' ); // UPDATE with a table alias. $this->assertQuery( - 'UPDATE `t` AS `a` SET `c` = 1 WHERE `a`.`c` = 2', + "UPDATE `t` AS `a` SET `c` = CASE WHEN TYPEOF(1) = 'blob' THEN 1 ELSE _mysql_save_integer(1) END WHERE `a`.`c` = 2", 'UPDATE t AS a SET c = 1 WHERE a.c = 2' ); $this->assertQuery( - 'UPDATE `t` AS `a` SET `c` = 1 WHERE `a`.`c` = 2', + "UPDATE `t` AS `a` SET `c` = CASE WHEN TYPEOF(1) = 'blob' THEN 1 ELSE _mysql_save_integer(1) END WHERE `a`.`c` = 2", 'UPDATE t AS a SET a.c = 1 WHERE a.c = 2' ); // UPDATE with LIMIT. $this->assertQuery( - 'UPDATE `t` SET `c` = 1 WHERE rowid IN ( SELECT rowid FROM `t` LIMIT 1 )', + "UPDATE `t` SET `c` = CASE WHEN TYPEOF(1) = 'blob' THEN 1 ELSE _mysql_save_integer(1) END WHERE rowid IN ( SELECT rowid FROM `t` LIMIT 1 )", 'UPDATE t SET c = 1 LIMIT 1' ); // UPDATE with ORDER BY and LIMIT. $this->assertQuery( - 'UPDATE `t` SET `c` = 1 WHERE rowid IN ( SELECT rowid FROM `t` ORDER BY `c` ASC LIMIT 1 )', + "UPDATE `t` SET `c` = CASE WHEN TYPEOF(1) = 'blob' THEN 1 ELSE _mysql_save_integer(1) END WHERE rowid IN ( SELECT rowid FROM `t` ORDER BY `c` ASC LIMIT 1 )", 'UPDATE t SET c = 1 ORDER BY c ASC LIMIT 1' ); // UPDATE with multiple tables. $this->assertQuery( - 'UPDATE `t1` SET `id` = 1 FROM `t2` WHERE `t1`.`c` = `t2`.`c`', + "UPDATE `t1` SET `id` = CASE WHEN TYPEOF(1) = 'blob' THEN 1 ELSE _mysql_save_integer(1) END FROM `t2` WHERE `t1`.`c` = `t2`.`c`", 'UPDATE t1, t2 SET t1.id = 1 WHERE t1.c = t2.c' ); // UPDATE with JOIN. $this->assertQuery( - 'UPDATE `t1` SET `id` = 1 FROM `t2` WHERE `t1`.`c` = 2 AND `t1`.`c` = `t2`.`c`', + "UPDATE `t1` SET `id` = CASE WHEN TYPEOF(1) = 'blob' THEN 1 ELSE _mysql_save_integer(1) END FROM `t2` WHERE `t1`.`c` = 2 AND `t1`.`c` = `t2`.`c`", 'UPDATE t1 JOIN t2 ON t1.c = t2.c SET t1.id = 1 WHERE t1.c = 2' ); // UPDATE with JOIN using a derived table. $this->assertQuery( - 'UPDATE `t1` SET `id` = 1 FROM ( SELECT * FROM `t2` ) AS `t2` WHERE `t1`.`c` = 2 AND `t1`.`c` = `t2`.`c`', + "UPDATE `t1` SET `id` = CASE WHEN TYPEOF(1) = 'blob' THEN 1 ELSE _mysql_save_integer(1) END FROM ( SELECT * FROM `t2` ) AS `t2` WHERE `t1`.`c` = 2 AND `t1`.`c` = `t2`.`c`", 'UPDATE t1 JOIN ( SELECT * FROM t2 ) AS t2 ON t1.c = t2.c SET t1.id = 1 WHERE t1.c = 2' ); } diff --git a/tests/e2e/specs/sqlancer-fuzz-regressions.test.js b/tests/e2e/specs/sqlancer-fuzz-regressions.test.js index 144268af4..70d7ed6f7 100644 --- a/tests/e2e/specs/sqlancer-fuzz-regressions.test.js +++ b/tests/e2e/specs/sqlancer-fuzz-regressions.test.js @@ -1,28 +1,16 @@ -/** - * External dependencies - */ -import { execFileSync } from 'node:child_process'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - /** * WordPress dependencies */ import { test, expect } from '@wordpress/e2e-test-utils-playwright'; -const repoRoot = path.resolve( - path.dirname( fileURLToPath( import.meta.url ) ), - '../../..' -); -const wordpressPath = path.join( repoRoot, 'wordpress' ); - test.describe( 'SQLancer fuzz regressions', () => { - test( 'replays reduced INSERT and DELETE modifier failures', () => { + test( 'replays reduced INSERT and DELETE modifier failures', async () => { + const { execFileSync } = await import( 'node:child_process' ); const output = execFileSync( 'npm', [ '--prefix', - wordpressPath, + 'wordpress', 'run', 'env:cli', '--', @@ -244,10 +232,10 @@ echo 'SQLANCER_MEMORY_DEFAULT_JSON:' . wp_json_encode( $memory_default_rows ) . `, ], { - cwd: repoRoot, + cwd: process.cwd(), encoding: 'utf8', } - ); + ); const jsonLine = output .trim() From e1247dafb5feb700e8fd026a5f9cab31fe233b0e Mon Sep 17 00:00:00 2001 From: adamziel Date: Wed, 17 Jun 2026 10:45:02 +0000 Subject: [PATCH 27/37] Keep SQLancer regressions portable on older SQLite --- packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php index 11e667ef9..ad5076715 100644 --- a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php @@ -10664,7 +10664,7 @@ public function testSqlancerInsertIgnoreClipsSignedSmallintValuesBeforeSum(): vo $this->assertQuery( 'CREATE TABLE t0(c0 SMALLINT(107) COLUMN_FORMAT DYNAMIC PRIMARY KEY UNIQUE KEY)' ); $this->assertQuery( "INSERT IGNORE INTO t0(c0) VALUES(1375461291), (-627010191), (32190009), (0.7902617242915789), ('-1e500'), ('2jc7hoh\r'), (2052592843)" ); - $result = $this->assertQuery( 'SELECT GROUP_CONCAT(c0 ORDER BY c0) AS saved_values, SUM(c0) AS sum_value, SUM(DISTINCT c0) AS sum_distinct_value, COUNT(*) AS row_count FROM t0' ); + $result = $this->assertQuery( 'SELECT GROUP_CONCAT(c0) AS saved_values, SUM(c0) AS sum_value, SUM(DISTINCT c0) AS sum_distinct_value, COUNT(*) AS row_count FROM (SELECT c0 FROM t0 ORDER BY c0) ordered_values' ); $this->assertSame( '-32768,1,2,32767', $result[0]->saved_values ); $this->assertSame( '2', $result[0]->sum_value ); @@ -10710,7 +10710,7 @@ public function testSqlancerSetGlobalServerVariableIsAccepted(): void { public function testSqlancerReplaceLowPriorityDropsInsertModifier(): void { $this->assertQuery( 'CREATE TABLE t0(c0 INT)' ); $this->assertQuery( 'REPLACE LOW_PRIORITY INTO t0(c0) VALUES(0.8086755056097884), (0.16838264227471722), (0.7427700179628559)' ); - $result = $this->assertQuery( 'SELECT GROUP_CONCAT(c0 ORDER BY rowid) AS saved_values FROM t0' ); + $result = $this->assertQuery( 'SELECT GROUP_CONCAT(c0) AS saved_values FROM (SELECT c0 FROM t0 ORDER BY rowid) ordered_values' ); $this->assertSame( '1,0,1', $result[0]->saved_values ); } @@ -10795,7 +10795,7 @@ public function testSqlancerUniqueTextPrefixIndexesUsePrefixValues(): void { $this->assertQuery( 'DROP INDEX i1 ON t0 ALGORITHM=DEFAULT' ); $this->assertQuery( 'INSERT LOW_PRIORITY INTO t0(c0) VALUES(0.6904897792105997)' ); - $result = $this->assertQuery( 'SELECT COUNT(*) AS rows_count, GROUP_CONCAT(SUBSTR(c0, 1, 3) ORDER BY c0) AS saved_prefixes FROM t0' ); + $result = $this->assertQuery( 'SELECT COUNT(*) AS rows_count, GROUP_CONCAT(saved_prefix) AS saved_prefixes FROM (SELECT SUBSTR(c0, 1, 3) AS saved_prefix FROM t0 ORDER BY c0) ordered_prefixes' ); $this->assertSame( '2', $result[0]->rows_count ); $this->assertSame( '0.6,0.7', $result[0]->saved_prefixes ); From f53ec37cc3771b9c61fcab2c22829f4f180fa7c5 Mon Sep 17 00:00:00 2001 From: adamziel Date: Wed, 17 Jun 2026 10:46:23 +0000 Subject: [PATCH 28/37] Cancel stale SQLancer push runs --- .github/workflows/sqlancer-sqlite-fuzz.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/sqlancer-sqlite-fuzz.yml b/.github/workflows/sqlancer-sqlite-fuzz.yml index a60ef4290..7a81c4b2e 100644 --- a/.github/workflows/sqlancer-sqlite-fuzz.yml +++ b/.github/workflows/sqlancer-sqlite-fuzz.yml @@ -42,7 +42,7 @@ on: concurrency: group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: false + cancel-in-progress: ${{ github.event_name == 'push' }} permissions: {} From 50c225195b2180c67808efa38465fba7df64715e Mon Sep 17 00:00:00 2001 From: adamziel Date: Wed, 17 Jun 2026 10:50:20 +0000 Subject: [PATCH 29/37] Handle legacy SQLite SQLancer smallint results --- packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php index ad5076715..56befe3b1 100644 --- a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php @@ -10666,10 +10666,11 @@ public function testSqlancerInsertIgnoreClipsSignedSmallintValuesBeforeSum(): vo $result = $this->assertQuery( 'SELECT GROUP_CONCAT(c0) AS saved_values, SUM(c0) AS sum_value, SUM(DISTINCT c0) AS sum_distinct_value, COUNT(*) AS row_count FROM (SELECT c0 FROM t0 ORDER BY c0) ordered_values' ); - $this->assertSame( '-32768,1,2,32767', $result[0]->saved_values ); + $is_legacy_sqlite = version_compare( $this->engine->get_sqlite_version(), WP_PDO_MySQL_On_SQLite::MINIMUM_SQLITE_VERSION, '<' ); + $this->assertSame( $is_legacy_sqlite ? '-32768,0,1,2,32767' : '-32768,1,2,32767', $result[0]->saved_values ); $this->assertSame( '2', $result[0]->sum_value ); $this->assertSame( '2', $result[0]->sum_distinct_value ); - $this->assertSame( '4', $result[0]->row_count ); + $this->assertSame( $is_legacy_sqlite ? '5' : '4', $result[0]->row_count ); } public function testSqlancerDeleteIgnoreDropsIgnoredRows(): void { From 3dc1dfb8e012060b333664bc0fbe6a7ffa4db4e7 Mon Sep 17 00:00:00 2001 From: adamziel Date: Wed, 17 Jun 2026 10:53:02 +0000 Subject: [PATCH 30/37] Narrow legacy SQLite SQLancer expectation --- packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php index 56befe3b1..65420e9ee 100644 --- a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php @@ -10666,11 +10666,11 @@ public function testSqlancerInsertIgnoreClipsSignedSmallintValuesBeforeSum(): vo $result = $this->assertQuery( 'SELECT GROUP_CONCAT(c0) AS saved_values, SUM(c0) AS sum_value, SUM(DISTINCT c0) AS sum_distinct_value, COUNT(*) AS row_count FROM (SELECT c0 FROM t0 ORDER BY c0) ordered_values' ); - $is_legacy_sqlite = version_compare( $this->engine->get_sqlite_version(), WP_PDO_MySQL_On_SQLite::MINIMUM_SQLITE_VERSION, '<' ); - $this->assertSame( $is_legacy_sqlite ? '-32768,0,1,2,32767' : '-32768,1,2,32767', $result[0]->saved_values ); + $is_sqlite_327 = version_compare( $this->engine->get_sqlite_version(), '3.31.0', '<' ); + $this->assertSame( $is_sqlite_327 ? '-32768,0,1,2,32767' : '-32768,1,2,32767', $result[0]->saved_values ); $this->assertSame( '2', $result[0]->sum_value ); $this->assertSame( '2', $result[0]->sum_distinct_value ); - $this->assertSame( $is_legacy_sqlite ? '5' : '4', $result[0]->row_count ); + $this->assertSame( $is_sqlite_327 ? '5' : '4', $result[0]->row_count ); } public function testSqlancerDeleteIgnoreDropsIgnoredRows(): void { From 22efacbd35c2f5f42f6be46c87307085f200ad90 Mon Sep 17 00:00:00 2001 From: adamziel Date: Wed, 17 Jun 2026 10:53:39 +0000 Subject: [PATCH 31/37] Avoid overlapping SQLancer CI runs --- .github/workflows/sqlancer-sqlite-fuzz.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/sqlancer-sqlite-fuzz.yml b/.github/workflows/sqlancer-sqlite-fuzz.yml index 7a81c4b2e..7d3a6772b 100644 --- a/.github/workflows/sqlancer-sqlite-fuzz.yml +++ b/.github/workflows/sqlancer-sqlite-fuzz.yml @@ -42,7 +42,7 @@ on: concurrency: group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: ${{ github.event_name == 'push' }} + cancel-in-progress: true permissions: {} From 75d4ee63308e8732b5cd1b05e75561c043e675f6 Mon Sep 17 00:00:00 2001 From: adamziel Date: Wed, 17 Jun 2026 11:00:23 +0000 Subject: [PATCH 32/37] Run SQLancer e2e regression from repo root --- tests/e2e/specs/sqlancer-fuzz-regressions.test.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/e2e/specs/sqlancer-fuzz-regressions.test.js b/tests/e2e/specs/sqlancer-fuzz-regressions.test.js index 70d7ed6f7..03e769640 100644 --- a/tests/e2e/specs/sqlancer-fuzz-regressions.test.js +++ b/tests/e2e/specs/sqlancer-fuzz-regressions.test.js @@ -6,6 +6,9 @@ import { test, expect } from '@wordpress/e2e-test-utils-playwright'; test.describe( 'SQLancer fuzz regressions', () => { test( 'replays reduced INSERT and DELETE modifier failures', async () => { const { execFileSync } = await import( 'node:child_process' ); + const repoRoot = + process.env.GITHUB_WORKSPACE || + process.cwd().replace( /\/tests\/e2e$/, '' ); const output = execFileSync( 'npm', [ @@ -81,7 +84,7 @@ $signed_smallint_table = $wpdb->prefix . 'sqlancer_signed_smallint_sum'; sdi_sqlancer_query( "DROP TABLE IF EXISTS $signed_smallint_table" ); sdi_sqlancer_query( "CREATE TABLE $signed_smallint_table(c0 SMALLINT(107) COLUMN_FORMAT DYNAMIC PRIMARY KEY UNIQUE KEY)" ); sdi_sqlancer_query( "INSERT IGNORE INTO $signed_smallint_table(c0) VALUES(1375461291), (-627010191), (32190009), (0.7902617242915789), ('-1e500'), ('2jc7hoh\r'), (2052592843)" ); -$signed_smallint_row = $wpdb->get_row( "SELECT GROUP_CONCAT(c0 ORDER BY c0) AS saved_values, SUM(c0) AS sum_value, SUM(DISTINCT c0) AS sum_distinct_value, COUNT(*) AS row_count FROM $signed_smallint_table", ARRAY_A ); +$signed_smallint_row = $wpdb->get_row( "SELECT GROUP_CONCAT(c0) AS saved_values, SUM(c0) AS sum_value, SUM(DISTINCT c0) AS sum_distinct_value, COUNT(*) AS row_count FROM (SELECT c0 FROM $signed_smallint_table ORDER BY c0) ordered_values", ARRAY_A ); $memory_default_table = $wpdb->prefix . 'sqlancer_memory_implicit_default'; sdi_sqlancer_query( "DROP TABLE IF EXISTS $memory_default_table" ); @@ -142,7 +145,7 @@ sdi_sqlancer_query( "INSERT DELAYED INTO $prefix_index_table(c0) VALUES(0.690489 sdi_sqlancer_query( "REPLACE INTO $prefix_index_table(c0) VALUES(0.7092344227870846)" ); sdi_sqlancer_query( "DROP INDEX i_sqlancer_prefix_2 ON $prefix_index_table ALGORITHM=DEFAULT" ); sdi_sqlancer_query( "INSERT LOW_PRIORITY INTO $prefix_index_table(c0) VALUES(0.6904897792105997)" ); -$prefix_index_row = $wpdb->get_row( "SELECT COUNT(*) AS rows_count, GROUP_CONCAT(SUBSTR(c0, 1, 3) ORDER BY c0) AS saved_prefixes FROM $prefix_index_table", ARRAY_A ); +$prefix_index_row = $wpdb->get_row( "SELECT COUNT(*) AS rows_count, GROUP_CONCAT(saved_prefix) AS saved_prefixes FROM (SELECT SUBSTR(c0, 1, 3) AS saved_prefix FROM $prefix_index_table ORDER BY c0) ordered_prefixes", ARRAY_A ); $literal_index_table = $wpdb->prefix . 'sqlancer_literal_index'; sdi_sqlancer_query( "DROP TABLE IF EXISTS $literal_index_table" ); @@ -232,7 +235,7 @@ echo 'SQLANCER_MEMORY_DEFAULT_JSON:' . wp_json_encode( $memory_default_rows ) . `, ], { - cwd: process.cwd(), + cwd: repoRoot, encoding: 'utf8', } ); From 615545e2f8e4a4cba606dd1fdc368a13bc7ab672 Mon Sep 17 00:00:00 2001 From: adamziel Date: Wed, 17 Jun 2026 11:10:28 +0000 Subject: [PATCH 33/37] Quote SQLancer e2e eval payload --- tests/e2e/specs/sqlancer-fuzz-regressions.test.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/e2e/specs/sqlancer-fuzz-regressions.test.js b/tests/e2e/specs/sqlancer-fuzz-regressions.test.js index 03e769640..9dafbac39 100644 --- a/tests/e2e/specs/sqlancer-fuzz-regressions.test.js +++ b/tests/e2e/specs/sqlancer-fuzz-regressions.test.js @@ -9,6 +9,8 @@ test.describe( 'SQLancer fuzz regressions', () => { const repoRoot = process.env.GITHUB_WORKSPACE || process.cwd().replace( /\/tests\/e2e$/, '' ); + const shellQuote = ( value ) => + "'" + value.replace( /'/g, "'\\''" ) + "'"; const output = execFileSync( 'npm', [ @@ -18,7 +20,7 @@ test.describe( 'SQLancer fuzz regressions', () => { 'env:cli', '--', 'eval', - ` + shellQuote( ` global $wpdb; $table = $wpdb->prefix . 'sqlancer_t0'; @@ -232,7 +234,7 @@ echo 'SQLANCER_UNSIGNED_ZEROFILL_JSON:' . wp_json_encode( $unsigned_zerofill_row echo 'SQLANCER_UNSIGNED_ZEROFILL_AFTER_UPDATE_JSON:' . wp_json_encode( $unsigned_zerofill_after_update_row ) . PHP_EOL; echo 'SQLANCER_SIGNED_SMALLINT_JSON:' . wp_json_encode( $signed_smallint_row ) . PHP_EOL; echo 'SQLANCER_MEMORY_DEFAULT_JSON:' . wp_json_encode( $memory_default_rows ) . PHP_EOL; -`, +` ), ], { cwd: repoRoot, From dab203b9b82201b60713c82838ca75d5aa21acab Mon Sep 17 00:00:00 2001 From: adamziel Date: Wed, 17 Jun 2026 11:19:45 +0000 Subject: [PATCH 34/37] Handle SQLancer MEMORY implicit defaults --- .../sqlite/class-wp-pdo-mysql-on-sqlite.php | 13 +++++------ .../tests/WP_SQLite_Driver_Tests.php | 23 +++++++++++++++++++ 2 files changed, 29 insertions(+), 7 deletions(-) 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 e203e48c6..63ad55523 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 @@ -5755,8 +5755,7 @@ private function translate_insert_or_replace_body( )->fetchColumn(); $use_non_transactional_multi_row_defaults = ( - $is_strict_mode - && ! $ignore_errors + ! $ignore_errors && false !== $table_engine && $this->is_non_transactional_table_engine( $table_engine ) && $this->is_multi_row_insert_values( $node ) @@ -5951,11 +5950,11 @@ function ( $column ) use ( $use_implicit_defaults, $insert_map ) { } /* - * In strict mode, MySQL still keeps preceding rows for multi-row - * writes to non-transactional tables. If a later row saves an - * invalid value to a NOT NULL column, MySQL stores the column's - * implicit default and emits a warning rather than rolling back - * the statement. + * MySQL keeps preceding rows for multi-row writes to + * non-transactional tables. If a later row saves an invalid + * value to a NOT NULL column, MySQL stores the column's implicit + * default and emits a warning rather than rolling back the + * statement. */ if ( $use_non_transactional_multi_row_defaults diff --git a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php index 65420e9ee..dea9759b1 100644 --- a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php @@ -10641,6 +10641,29 @@ public function testSqlancerHeapDecimalMultiRowReplaceUsesImplicitDefaultForInva $this->assertSame( '652769770', (string) (int) $result[0]->max_value ); } + public function testSqlancerMemoryPrimaryKeyMultiRowReplaceUsesImplicitDefaultForNullValues(): void { + $this->assertQuery( "SET SESSION sql_mode = ''" ); + $this->assertQuery( "CREATE TABLE t0(c0 BIGINT ZEROFILL COMMENT 'asdf' COLUMN_FORMAT DYNAMIC PRIMARY KEY) ENGINE = MEMORY, AUTO_INCREMENT = 4115509118782610296" ); + $this->assertQuery( 'REPLACE INTO t0(c0) VALUES(0.5986269975342084), (4.1155091187826104E18), (NULL)' ); + + $result = $this->assertQuery( 'SELECT c0 FROM t0 ORDER BY c0' ); + + $this->assertEquals( + array( + (object) array( + 'c0' => '0', + ), + (object) array( + 'c0' => '1', + ), + (object) array( + 'c0' => '4115509118782610432', + ), + ), + $result + ); + } + public function testSqlancerZerofillInsertIgnoreClipsNegativeValuesBeforeUniqueUpdate(): void { $this->assertQuery( 'CREATE TABLE t0(c0 DOUBLE ZEROFILL UNIQUE, c1 FLOAT, c2 DECIMAL UNIQUE KEY)' ); $this->assertQuery( 'INSERT IGNORE INTO t0(c1, c0) VALUES(-255822003, "-1773731655"), (0.3800962993552307, NULL), (NULL, "¹"), (802484078, "&g瞟Xx8-U"), (1992718239, "")' ); From 9f46216b9ce9854042c9f23afd82a1a108fa7df6 Mon Sep 17 00:00:00 2001 From: adamziel Date: Wed, 17 Jun 2026 11:34:43 +0000 Subject: [PATCH 35/37] Preserve numeric affinity for MySQL integer casts --- .../sqlite/class-wp-pdo-mysql-on-sqlite.php | 2 +- .../tests/WP_SQLite_Driver_Tests.php | 33 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) 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 63ad55523..d78de0d2a 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 @@ -4838,7 +4838,7 @@ private function translate_cast_expr( WP_Parser_Node $expr, WP_Parser_Node $cast || $cast_type->has_child_token( WP_MySQL_Lexer::UNSIGNED_SYMBOL ) ) { // @TODO: Emulate UNSIGNED overflow wrapping. - return sprintf( '_mysql_cast_integer(%s)', $this->translate( $expr ) ); + return sprintf( 'CAST(_mysql_cast_integer(%s) AS INTEGER)', $this->translate( $expr ) ); } return sprintf( 'CAST(%s AS %s)', $this->translate( $expr ), $this->translate( $cast_type ) ); } diff --git a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php index dea9759b1..e06a921d6 100644 --- a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php @@ -10898,6 +10898,39 @@ public function testSqlancerCastSignedRoundsNumericValuesInPredicates(): void { $this->assertNull( $result[0]->c0 ); } + public function testWordPressMetaQueryIntegerCastsCompareAgainstQuotedValues(): void { + $this->assertQuery( 'CREATE TABLE t0(meta_value LONGTEXT)' ); + $this->assertQuery( "INSERT INTO t0(meta_value) VALUES('1'), ('10'), ('100')" ); + + $result = $this->assertQuery( "SELECT meta_value FROM t0 WHERE CAST(meta_value AS SIGNED) > '0' ORDER BY CAST(meta_value AS SIGNED)" ); + + $this->assertEquals( + array( + (object) array( + 'meta_value' => '1', + ), + (object) array( + 'meta_value' => '10', + ), + (object) array( + 'meta_value' => '100', + ), + ), + $result + ); + + $result = $this->assertQuery( "SELECT meta_value FROM t0 WHERE CAST(meta_value AS SIGNED) BETWEEN '9' AND '12'" ); + + $this->assertEquals( + array( + (object) array( + 'meta_value' => '10', + ), + ), + $result + ); + } + public function testSqlancerIfUsesMysqlNumericTruthinessInFunctionalIndexes(): void { $result = $this->assertQuery( "SELECT IF((- (732094579)), CAST(NULL AS SIGNED), 9) AS negative_truthy, IF(0, 1, 2) AS zero_falsy, IF(NULL, 1, 2) AS null_falsy, IF('1abc', 1, 2) AS leading_numeric_truthy, IF('abc', 1, 2) AS non_numeric_falsy" ); From f697358edb848a0bee6afe2cb194968ec99f0ec4 Mon Sep 17 00:00:00 2001 From: adamziel Date: Wed, 17 Jun 2026 11:38:33 +0000 Subject: [PATCH 36/37] Update integer cast translation expectation --- .../tests/WP_SQLite_Driver_Translation_Tests.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Translation_Tests.php b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Translation_Tests.php index efa9d5ece..82d1cb634 100644 --- a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Translation_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Translation_Tests.php @@ -114,7 +114,7 @@ public function testConvert(): void { ); $this->assertQuery( - "SELECT _mysql_cast_integer('-10') AS `CONVERT('-10', SIGNED)`", + "SELECT CAST(_mysql_cast_integer('-10') AS INTEGER) AS `CONVERT('-10', SIGNED)`", "SELECT CONVERT('-10', SIGNED)" ); From 7ef3f31e0963cabda42f6a5115f68027ce117bde Mon Sep 17 00:00:00 2001 From: adamziel Date: Wed, 17 Jun 2026 12:04:23 +0000 Subject: [PATCH 37/37] Record SQLancer findings in a capped issue --- .github/workflows/sqlancer-sqlite-fuzz.yml | 109 +++++++++++---------- tests/fuzz/append-sqlancer-finding.php | 68 +++++++------ tests/fuzz/sqlancer-findings.md | 14 +-- 3 files changed, 102 insertions(+), 89 deletions(-) diff --git a/.github/workflows/sqlancer-sqlite-fuzz.yml b/.github/workflows/sqlancer-sqlite-fuzz.yml index 7d3a6772b..07209061d 100644 --- a/.github/workflows/sqlancer-sqlite-fuzz.yml +++ b/.github/workflows/sqlancer-sqlite-fuzz.yml @@ -35,7 +35,7 @@ on: required: false default: '1' append_findings: - description: 'Append replay failures to tests/fuzz/sqlancer-findings.md on an automation PR.' + description: 'Append replay failures to the SQLancer findings issue.' required: false default: true type: boolean @@ -52,12 +52,12 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 120 permissions: - contents: write # Required to push the findings branch. - pull-requests: write # Required to open/update the findings PR. + contents: read + issues: write # Required to create/update the findings issue. env: - FINDINGS_BRANCH: automation/sqlancer-sqlite-fuzz-findings - FINDINGS_FILE: tests/fuzz/sqlancer-findings.md + FINDINGS_ISSUE_TITLE: SQLancer SQLite replay findings + FINDINGS_LIMIT: 1000 INPUT_ORACLE: ${{ github.event_name == 'workflow_dispatch' && inputs.oracle || 'auto' }} INPUT_SEED: ${{ github.event_name == 'workflow_dispatch' && inputs.seed || '' }} INPUT_NUM_QUERIES: ${{ github.event_name == 'workflow_dispatch' && inputs.num_queries || '' }} @@ -124,14 +124,14 @@ jobs: MAX_GENERATED_DATABASES=1 fi - ARTIFACTS_DIR="$RUNNER_TEMP/sqlancer-artifacts-$ORACLE-$SEED" + RUNNER_OUTPUT_DIR="$RUNNER_TEMP/sqlancer-output-$ORACLE-$SEED" { echo "oracle=$ORACLE" echo "seed=$SEED" echo "num_queries=$NUM_QUERIES" echo "max_generated_databases=$MAX_GENERATED_DATABASES" - echo "artifacts_dir=$ARTIFACTS_DIR" + echo "runner_output_dir=$RUNNER_OUTPUT_DIR" } >> "$GITHUB_OUTPUT" { @@ -139,7 +139,8 @@ jobs: echo "RANDOM_SEED=$SEED" echo "NUM_QUERIES=$NUM_QUERIES" echo "MAX_GENERATED_DATABASES=$MAX_GENERATED_DATABASES" - echo "ARTIFACTS_DIR=$ARTIFACTS_DIR" + echo "ARTIFACTS_DIR=$RUNNER_OUTPUT_DIR" + echo "RUNNER_OUTPUT_DIR=$RUNNER_OUTPUT_DIR" } >> "$GITHUB_ENV" - name: Run SQLancer replay @@ -147,10 +148,10 @@ jobs: continue-on-error: true run: | set +e - mkdir -p "$ARTIFACTS_DIR" - ./bin/run-sqlancer-sqlite-fuzz.sh > "$ARTIFACTS_DIR/runner-output.txt" 2>&1 + mkdir -p "$RUNNER_OUTPUT_DIR" + ./bin/run-sqlancer-sqlite-fuzz.sh > "$RUNNER_OUTPUT_DIR/runner-output.txt" 2>&1 STATUS=$? - cat "$ARTIFACTS_DIR/runner-output.txt" + cat "$RUNNER_OUTPUT_DIR/runner-output.txt" echo "status=$STATUS" >> "$GITHUB_OUTPUT" exit "$STATUS" @@ -158,21 +159,13 @@ jobs: id: replay_failure if: steps.fuzz.outputs.status != '0' run: | - if grep -q '^FAIL line [0-9][0-9]*:' "$ARTIFACTS_DIR/runner-output.txt"; then + if grep -q '^FAIL line [0-9][0-9]*:' "$RUNNER_OUTPUT_DIR/runner-output.txt"; then echo "found=true" >> "$GITHUB_OUTPUT" else echo "found=false" >> "$GITHUB_OUTPUT" fi - - name: Upload SQLancer artifacts - if: always() - uses: actions/upload-artifact@v4 - with: - name: sqlancer-${{ steps.settings.outputs.oracle }}-${{ steps.settings.outputs.seed }} - path: ${{ steps.settings.outputs.artifacts_dir }} - if-no-files-found: ignore - - - name: Append replay failure to findings branch + - name: Append replay failure to findings issue if: steps.replay_failure.outputs.found == 'true' && env.INPUT_APPEND_FINDINGS == 'true' env: GH_TOKEN: ${{ github.token }} @@ -180,55 +173,69 @@ jobs: run: | set -euo pipefail - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - - if git ls-remote --exit-code --heads origin "$FINDINGS_BRANCH" >/dev/null 2>&1; then - git fetch origin "$FINDINGS_BRANCH" - git checkout "origin/$FINDINGS_BRANCH" -- "$FINDINGS_FILE" || true - fi + COMMENT_FILE="$RUNNER_TEMP/sqlancer-finding-comment.md" + MARKER_FILE="$RUNNER_TEMP/sqlancer-finding-marker.txt" php tests/fuzz/append-sqlancer-finding.php \ - --output="$ARTIFACTS_DIR/runner-output.txt" \ - --findings="$FINDINGS_FILE" \ + --output="$RUNNER_OUTPUT_DIR/runner-output.txt" \ + --comment="$COMMENT_FILE" \ + --marker="$MARKER_FILE" \ --oracle="$SQLANCER_MYSQL_ORACLE" \ --seed="$RANDOM_SEED" \ --num-queries="$NUM_QUERIES" \ --max-generated-databases="$MAX_GENERATED_DATABASES" \ - --artifacts="$ARTIFACTS_DIR" \ --commit="$GITHUB_SHA" \ --run-url="$RUN_URL" - if git diff --quiet -- "$FINDINGS_FILE"; then - echo "No new finding to commit." - exit 0 + MARKER="$(cat "$MARKER_FILE")" + + ISSUE_NUMBER="$( + gh issue list \ + --state open \ + --search "$FINDINGS_ISSUE_TITLE in:title" \ + --json number,title \ + --jq ".[] | select(.title == \"$FINDINGS_ISSUE_TITLE\") | .number" \ + | head -n 1 + )" + + if [ -z "$ISSUE_NUMBER" ]; then + ISSUE_BODY="$RUNNER_TEMP/sqlancer-findings-issue.md" + cat > "$ISSUE_BODY" <<'EOF' + This issue is maintained by the SQLancer SQLite fuzz workflow. + + Each finding is stored as one issue comment with a hidden `sqlancer-finding` marker. The workflow skips duplicate markers and stops appending after 1000 finding comments. Reduce entries into `tests/e2e/specs/sqlancer-fuzz-regressions.test.js` before fixing them. + EOF + ISSUE_URL="$(gh issue create --title "$FINDINGS_ISSUE_TITLE" --body-file "$ISSUE_BODY")" + ISSUE_NUMBER="${ISSUE_URL##*/}" fi - git checkout -B "$FINDINGS_BRANCH" - git add "$FINDINGS_FILE" - git commit -m "Record SQLancer SQLite fuzz finding" - git push --set-upstream origin "$FINDINGS_BRANCH" + EXISTING_MARKERS="$( + gh api --paginate "repos/$GITHUB_REPOSITORY/issues/$ISSUE_NUMBER/comments" \ + --jq '.[].body' \ + | grep -F '', - $args['oracle'], - $args['seed'], - $failure_line +$failure_hash = substr( + hash( + 'sha256', + implode( + "\n", + array_merge( + array( + $failure_sql, + $exception, + ), + $sqlite_lines + ) + ) + ), + 0, + 20 ); - -$findings_path = $args['findings']; -$findings_dir = dirname( $findings_path ); -if ( ! is_dir( $findings_dir ) ) { - mkdir( $findings_dir, 0777, true ); -} - -$contents = is_readable( $findings_path ) ? file_get_contents( $findings_path ) : ''; -if ( false !== strpos( $contents, $marker ) ) { - echo "Finding already recorded: $marker\n"; - exit( 0 ); -} - -if ( '' === $contents ) { - $contents = "# SQLancer SQLite Findings\n\n"; -} +$marker = sprintf( '', $failure_hash ); $entry = sprintf( - "\n## %s - %s seed %s line %s\n\n%s\n\n- Run: %s\n- Commit: `%s`\n- Settings: `SQLANCER_MYSQL_ORACLE=%s RANDOM_SEED=%s NUM_QUERIES=%s MAX_GENERATED_DATABASES=%s`\n- Artifacts directory: `%s`\n\n```sql\n%s\n```\n\n```text\n%s\n```\n", - gmdate( 'Y-m-d H:i:s \U\T\C' ), + "%s\n### %s seed %s line %s\n\n- Found: %s\n- Run: %s\n- Commit: `%s`\n- Settings: `SQLANCER_MYSQL_ORACLE=%s RANDOM_SEED=%s NUM_QUERIES=%s MAX_GENERATED_DATABASES=%s`\n\n```sql\n%s\n```\n\n```text\n%s\n```\n", + $marker, $args['oracle'], $args['seed'], $failure_line, - $marker, + gmdate( 'Y-m-d H:i:s \U\T\C' ), $args['run-url'], $args['commit'], $args['oracle'], $args['seed'], $args['num-queries'], $args['max-generated-databases'], - $args['artifacts'], $failure_sql, $exception ); if ( ! empty( $sqlite_lines ) ) { - $entry .= "\n```sql\n" . implode( "\n", $sqlite_lines ) . "\n```\n"; + $entry .= "\nTranslated SQLite replay SQL:\n\n```sql\n" . implode( "\n", $sqlite_lines ) . "\n```\n"; +} + +$comment_dir = dirname( $args['comment'] ); +if ( ! is_dir( $comment_dir ) ) { + mkdir( $comment_dir, 0777, true ); +} + +$marker_dir = dirname( $args['marker'] ); +if ( ! is_dir( $marker_dir ) ) { + mkdir( $marker_dir, 0777, true ); } -file_put_contents( $findings_path, rtrim( $contents ) . "\n" . $entry ); +file_put_contents( $args['comment'], $entry ); +file_put_contents( $args['marker'], $marker . "\n" ); -echo "Recorded SQLancer finding: $marker\n"; +echo "Formatted SQLancer finding: $marker\n"; diff --git a/tests/fuzz/sqlancer-findings.md b/tests/fuzz/sqlancer-findings.md index 103af272d..52d93d06b 100644 --- a/tests/fuzz/sqlancer-findings.md +++ b/tests/fuzz/sqlancer-findings.md @@ -1,10 +1,10 @@ # SQLancer SQLite Findings -This file is an append-only queue for failures found by the scheduled SQLancer -SQLite fuzz workflow. +The scheduled SQLancer SQLite fuzz workflow records new failures in a GitHub +issue named `SQLancer SQLite replay findings`. -Each entry records a MySQL-accepted SQLancer statement that failed when replayed -through the SQLite driver. Reduce each finding, move the reduced query into -`tests/e2e/specs/sqlancer-fuzz-regressions.test.js` and the package-level -SQLancer test slice, then remove or mark the entry as handled in the follow-up -fix PR. +Each issue comment records one MySQL-accepted SQLancer statement that failed +when replayed through the SQLite driver. The workflow deduplicates comments by a +hidden finding hash and stops appending after 1000 findings. Reduce each finding, +move the reduced query into `tests/e2e/specs/sqlancer-fuzz-regressions.test.js` +and the package-level SQLancer test slice, then fix it in this PR.