diff --git a/run-tests.sh b/run-tests.sh index 27f69ed..ad766b6 100755 --- a/run-tests.sh +++ b/run-tests.sh @@ -16,4 +16,4 @@ else TEST_PATH="$BASE_PATH/$1" fi -"$PHP_EXECUTABLE" "$RUN_TESTS_PATH" --show-diff -m -p "$PHP_EXECUTABLE" "$TEST_PATH" +"$PHP_EXECUTABLE" "$RUN_TESTS_PATH" --show-diff -p "$PHP_EXECUTABLE" "$TEST_PATH" diff --git a/tests/mysqli/001-mysqli_connect_async.phpt b/tests/mysqli/001-mysqli_connect_async.phpt new file mode 100644 index 0000000..4ebd352 --- /dev/null +++ b/tests/mysqli/001-mysqli_connect_async.phpt @@ -0,0 +1,56 @@ +--TEST-- +MySQLi: Basic async connection test +--EXTENSIONS-- +mysqli +--SKIPIF-- + +--FILE-- +query("SELECT 1 as test"); + if ($result) { + $row = $result->fetch_assoc(); + echo "query result: " . $row['test'] . "\n"; + $result->free(); + } + + $mysqli->close(); + echo "closed\n"; + + return "success"; + } catch (Exception $e) { + echo "error: " . $e->getMessage() . "\n"; + return "failed"; + } +}); + +$result = await($coroutine); +echo "awaited: " . $result . "\n"; +echo "end\n"; + +?> +--EXPECT-- +start +connected +query result: 1 +closed +awaited: success +end \ No newline at end of file diff --git a/tests/mysqli/002-mysqli_query_async.phpt b/tests/mysqli/002-mysqli_query_async.phpt new file mode 100644 index 0000000..fb2fefb --- /dev/null +++ b/tests/mysqli/002-mysqli_query_async.phpt @@ -0,0 +1,82 @@ +--TEST-- +MySQLi: Async query execution +--EXTENSIONS-- +mysqli +--SKIPIF-- + +--FILE-- +query("DROP TEMPORARY TABLE IF EXISTS async_test"); + $result = $mysqli->query("CREATE TEMPORARY TABLE async_test (id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50), value INT)"); + + if (!$result) { + echo "create table failed: " . $mysqli->error . "\n"; + return "failed"; + } + echo "table created\n"; + + // Insert test data + $mysqli->query("INSERT INTO async_test (name, value) VALUES ('test1', 10)"); + $mysqli->query("INSERT INTO async_test (name, value) VALUES ('test2', 20)"); + $mysqli->query("INSERT INTO async_test (name, value) VALUES ('test3', 30)"); + echo "data inserted\n"; + + // Test SELECT query + $result = $mysqli->query("SELECT * FROM async_test ORDER BY id"); + if ($result) { + $count = 0; + while ($row = $result->fetch_assoc()) { + $count++; + echo "row $count: {$row['name']} = {$row['value']}\n"; + } + $result->free(); + } + + // Test aggregate query + $result = $mysqli->query("SELECT COUNT(*) as total, SUM(value) as sum_value FROM async_test"); + if ($result) { + $row = $result->fetch_assoc(); + echo "total rows: {$row['total']}, sum: {$row['sum_value']}\n"; + $result->free(); + } + + $mysqli->close(); + return "completed"; + } catch (Exception $e) { + echo "error: " . $e->getMessage() . "\n"; + return "failed"; + } +}); + +$result = await($coroutine); +echo "result: " . $result . "\n"; +echo "end\n"; + +?> +--EXPECT-- +start +table created +data inserted +row 1: test1 = 10 +row 2: test2 = 20 +row 3: test3 = 30 +total rows: 3, sum: 60 +result: completed +end \ No newline at end of file diff --git a/tests/mysqli/003-mysqli_concurrent_connections.phpt b/tests/mysqli/003-mysqli_concurrent_connections.phpt new file mode 100644 index 0000000..8162c2d --- /dev/null +++ b/tests/mysqli/003-mysqli_concurrent_connections.phpt @@ -0,0 +1,103 @@ +--TEST-- +MySQLi: Concurrent connections in separate coroutines +--EXTENSIONS-- +mysqli +--SKIPIF-- + +--FILE-- +query("SELECT 'coroutine1' as source, CONNECTION_ID() as conn_id"); + $row = $result->fetch_assoc(); + $result->free(); + $mysqli->close(); + return ['source' => $row['source'], 'conn_id' => $row['conn_id']]; + }), + + spawn(function() { + $mysqli = AsyncMySQLiTest::factory(); + $result = $mysqli->query("SELECT 'coroutine2' as source, CONNECTION_ID() as conn_id"); + $row = $result->fetch_assoc(); + $result->free(); + $mysqli->close(); + return ['source' => $row['source'], 'conn_id' => $row['conn_id']]; + }), + + spawn(function() { + $mysqli = AsyncMySQLiTest::factory(); + $result = $mysqli->query("SELECT 'coroutine3' as source, CONNECTION_ID() as conn_id"); + $row = $result->fetch_assoc(); + $result->free(); + $mysqli->close(); + return ['source' => $row['source'], 'conn_id' => $row['conn_id']]; + }), + + spawn(function() { + $mysqli = AsyncMySQLiTest::factory(); + // Test with some workload + $mysqli->query("CREATE TEMPORARY TABLE temp_work (id INT, data VARCHAR(100))"); + $mysqli->query("INSERT INTO temp_work VALUES (1, 'data1'), (2, 'data2')"); + $result = $mysqli->query("SELECT COUNT(*) as count, CONNECTION_ID() as conn_id FROM temp_work"); + $row = $result->fetch_assoc(); + $result->free(); + $mysqli->close(); + return ['source' => 'coroutine4', 'conn_id' => $row['conn_id'], 'count' => $row['count']]; + }) +]; + +$results = awaitAllOrFail($coroutines); + +// Display results in deterministic order +usort($results, function($a, $b) { + return strcmp($a['source'], $b['source']); +}); + +foreach ($results as $result) { + if (isset($result['count'])) { + echo "from {$result['source']} (with work) conn_id: {$result['conn_id']}, count: {$result['count']}\n"; + } else { + echo "from {$result['source']} conn_id: {$result['conn_id']}\n"; + } +} + +// Verify all connections are different +$connectionIds = array_map(function($r) { return $r['conn_id']; }, $results); +$uniqueIds = array_unique($connectionIds); +echo "unique connections: " . count($uniqueIds) . "\n"; +echo "total coroutines: " . count($connectionIds) . "\n"; + +if (count($uniqueIds) === count($connectionIds)) { + echo "isolation: passed\n"; +} else { + echo "isolation: failed\n"; +} + +echo "end\n"; + +?> +--EXPECTF-- +start +from coroutine1 conn_id: %d +from coroutine2 conn_id: %d +from coroutine3 conn_id: %d +from coroutine4 (with work) conn_id: %d, count: 2 +unique connections: 4 +total coroutines: 4 +isolation: passed +end \ No newline at end of file diff --git a/tests/mysqli/004-mysqli_prepared_async.phpt b/tests/mysqli/004-mysqli_prepared_async.phpt new file mode 100644 index 0000000..6dfc340 --- /dev/null +++ b/tests/mysqli/004-mysqli_prepared_async.phpt @@ -0,0 +1,117 @@ +--TEST-- +MySQLi: Async prepared statements +--EXTENSIONS-- +mysqli +--SKIPIF-- + +--FILE-- +query("DROP TEMPORARY TABLE IF EXISTS async_prepared_test"); + $mysqli->query("CREATE TEMPORARY TABLE async_prepared_test (id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50), score INT, active BOOLEAN)"); + echo "table created\n"; + + // Test INSERT prepared statement + $stmt = $mysqli->prepare("INSERT INTO async_prepared_test (name, score, active) VALUES (?, ?, ?)"); + if (!$stmt) { + throw new Exception("Prepare failed: " . $mysqli->error); + } + + // Insert multiple records + $name = "user1"; $score = 85; $active = true; + $stmt->bind_param("sii", $name, $score, $active); + $stmt->execute(); + + $name = "user2"; $score = 92; $active = false; + $stmt->bind_param("sii", $name, $score, $active); + $stmt->execute(); + + $name = "user3"; $score = 78; $active = true; + $stmt->bind_param("sii", $name, $score, $active); + $stmt->execute(); + + $stmt->close(); + echo "inserted records with prepared statement\n"; + + // Test SELECT prepared statement + $stmt = $mysqli->prepare("SELECT id, name, score FROM async_prepared_test WHERE score > ? AND active = ? ORDER BY id"); + if (!$stmt) { + throw new Exception("Prepare SELECT failed: " . $mysqli->error); + } + + $min_score = 80; + $is_active = true; + $stmt->bind_param("ii", $min_score, $is_active); + $stmt->execute(); + + $result = $stmt->get_result(); + echo "records with score > $min_score and active = $is_active:\n"; + + while ($row = $result->fetch_assoc()) { + echo " id: {$row['id']}, name: {$row['name']}, score: {$row['score']}\n"; + } + + $stmt->close(); + + // Test UPDATE prepared statement + $stmt = $mysqli->prepare("UPDATE async_prepared_test SET score = score + ? WHERE name = ?"); + if (!$stmt) { + throw new Exception("Prepare UPDATE failed: " . $mysqli->error); + } + + $bonus = 5; + $target_name = "user1"; + $stmt->bind_param("is", $bonus, $target_name); + $stmt->execute(); + + echo "updated $target_name with bonus $bonus points\n"; + echo "affected rows: " . $stmt->affected_rows . "\n"; + + $stmt->close(); + + // Verify update + $result = $mysqli->query("SELECT name, score FROM async_prepared_test WHERE name = 'user1'"); + $row = $result->fetch_assoc(); + echo "user1 new score: {$row['score']}\n"; + $result->free(); + + $mysqli->close(); + return "completed"; + } catch (Exception $e) { + echo "error: " . $e->getMessage() . "\n"; + return "failed"; + } +}); + +$result = await($coroutine); +echo "result: " . $result . "\n"; +echo "end\n"; + +?> +--EXPECT-- +start +table created +inserted records with prepared statement +records with score > 80 and active = 1: + id: 1, name: user1, score: 85 +updated user1 with bonus 5 points +affected rows: 1 +user1 new score: 90 +result: completed +end \ No newline at end of file diff --git a/tests/mysqli/005-mysqli_multi_query_async.phpt b/tests/mysqli/005-mysqli_multi_query_async.phpt new file mode 100644 index 0000000..58c0eaf --- /dev/null +++ b/tests/mysqli/005-mysqli_multi_query_async.phpt @@ -0,0 +1,111 @@ +--TEST-- +MySQLi: Async multi-query execution +--EXTENSIONS-- +mysqli +--SKIPIF-- + +--FILE-- +multi_query($multi_query)) { + throw new Exception("Multi-query failed: " . $mysqli->error); + } + + $query_count = 0; + do { + $query_count++; + + if ($result = $mysqli->store_result()) { + echo "query $query_count results:\n"; + + if ($result->num_rows > 0) { + while ($row = $result->fetch_assoc()) { + $values = []; + foreach ($row as $key => $value) { + $values[] = "$key: $value"; + } + echo " " . implode(", ", $values) . "\n"; + } + } else { + echo " no result rows\n"; + } + + $result->free(); + } else { + if ($mysqli->errno) { + echo "query $query_count error: " . $mysqli->error . "\n"; + } else { + echo "query $query_count: executed (no result set)\n"; + } + } + + // Check if there are more results + if (!$mysqli->more_results()) { + break; + } + + } while ($mysqli->next_result()); + + echo "total queries executed: $query_count\n"; + + $mysqli->close(); + return "completed"; + } catch (Exception $e) { + echo "error: " . $e->getMessage() . "\n"; + return "failed"; + } +}); + +$result = await($coroutine); +echo "result: " . $result . "\n"; +echo "end\n"; + +?> +--EXPECTF-- +start +executing multi-query +query 1: executed (no result set) +query 2: executed (no result set) +query 3: executed (no result set) +query 4: executed (no result set) +query 5: executed (no result set) +query 6 results: + total_count: 3 +query 7 results: + total_value: 600 +query 8 results: + id: 1, name: item1, value: 100 + id: 2, name: item2, value: 200 + id: 3, name: item3, value: 300 +total queries executed: 8 +result: completed +end \ No newline at end of file diff --git a/tests/mysqli/006-mysqli_transaction_async.phpt b/tests/mysqli/006-mysqli_transaction_async.phpt new file mode 100644 index 0000000..33cfd63 --- /dev/null +++ b/tests/mysqli/006-mysqli_transaction_async.phpt @@ -0,0 +1,125 @@ +--TEST-- +MySQLi: Async transaction handling +--EXTENSIONS-- +mysqli +--SKIPIF-- + +--FILE-- +query("DROP TEMPORARY TABLE IF EXISTS async_transaction_test"); + $mysqli->query("CREATE TEMPORARY TABLE async_transaction_test (id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50), amount DECIMAL(10,2)) ENGINE=InnoDB"); + echo "table created\n"; + + // Test successful transaction + echo "starting transaction\n"; + $mysqli->autocommit(false); + $mysqli->begin_transaction(); + + $mysqli->query("INSERT INTO async_transaction_test (name, amount) VALUES ('account1', 1000.00)"); + $mysqli->query("INSERT INTO async_transaction_test (name, amount) VALUES ('account2', 500.00)"); + echo "inserted initial data\n"; + + // Transfer money between accounts + $mysqli->query("UPDATE async_transaction_test SET amount = amount - 200.00 WHERE name = 'account1'"); + $mysqli->query("UPDATE async_transaction_test SET amount = amount + 200.00 WHERE name = 'account2'"); + echo "performed transfer\n"; + + // Check balances in transaction + $result = $mysqli->query("SELECT name, amount FROM async_transaction_test ORDER BY name"); + echo "balances in transaction:\n"; + while ($row = $result->fetch_assoc()) { + echo " {$row['name']}: {$row['amount']}\n"; + } + $result->free(); + + // Commit transaction + $mysqli->commit(); + echo "transaction committed\n"; + + // Verify data persists after commit + $result = $mysqli->query("SELECT name, amount FROM async_transaction_test ORDER BY name"); + echo "balances after commit:\n"; + while ($row = $result->fetch_assoc()) { + echo " {$row['name']}: {$row['amount']}\n"; + } + $result->free(); + + // Test rollback transaction + echo "testing rollback\n"; + $mysqli->begin_transaction(); + + $mysqli->query("UPDATE async_transaction_test SET amount = 0 WHERE name = 'account1'"); + $mysqli->query("UPDATE async_transaction_test SET amount = 0 WHERE name = 'account2'"); + echo "zeroed all amounts\n"; + + // Check balances before rollback + $result = $mysqli->query("SELECT SUM(amount) as total FROM async_transaction_test"); + $row = $result->fetch_assoc(); + echo "total before rollback: {$row['total']}\n"; + $result->free(); + + // Rollback + $mysqli->rollback(); + echo "rolled back\n"; + + // Verify rollback worked + $result = $mysqli->query("SELECT name, amount FROM async_transaction_test ORDER BY name"); + echo "balances after rollback:\n"; + while ($row = $result->fetch_assoc()) { + echo " {$row['name']}: {$row['amount']}\n"; + } + $result->free(); + + $mysqli->autocommit(true); + $mysqli->close(); + return "completed"; + } catch (Exception $e) { + echo "error: " . $e->getMessage() . "\n"; + return "failed"; + } +}); + +$result = await($coroutine); +echo "result: " . $result . "\n"; +echo "end\n"; + +?> +--EXPECT-- +start +table created +starting transaction +inserted initial data +performed transfer +balances in transaction: + account1: 800.00 + account2: 700.00 +transaction committed +balances after commit: + account1: 800.00 + account2: 700.00 +testing rollback +zeroed all amounts +total before rollback: 0.00 +rolled back +balances after rollback: + account1: 800.00 + account2: 700.00 +result: completed +end \ No newline at end of file diff --git a/tests/mysqli/007-mysqli_error_scenarios.phpt b/tests/mysqli/007-mysqli_error_scenarios.phpt new file mode 100644 index 0000000..c4930f8 --- /dev/null +++ b/tests/mysqli/007-mysqli_error_scenarios.phpt @@ -0,0 +1,146 @@ +--TEST-- +MySQLi: Async error scenarios +--EXTENSIONS-- +mysqli +--SKIPIF-- + +--FILE-- +query("INVALID SQL SYNTAX HERE"); + + if (!$result) { + return ['type' => 'syntax_error', 'status' => 'syntax_error_handled']; + } + + return ['type' => 'syntax_error', 'status' => 'should_not_reach_here']; + } catch (Exception $e) { + return ['type' => 'syntax_error', 'status' => 'exception_handled']; + } +}); + +// Test table not found error +$error_test2 = spawn(function() { + try { + $mysqli = AsyncMySQLiTest::factory(); + + // Query non-existent table + $result = $mysqli->query("SELECT * FROM non_existent_table_54321"); + + if (!$result) { + $error_msg = (strpos($mysqli->error, "doesn't exist") !== false ? "table not found" : "other error"); + return ['type' => 'table_error', 'status' => 'table_error_handled', 'message' => $error_msg]; + } + + return ['type' => 'table_error', 'status' => 'should_not_reach_here']; + } catch (Exception $e) { + return ['type' => 'table_error', 'status' => 'exception_handled']; + } +}); + +// Test duplicate key error +$error_test3 = spawn(function() { + try { + $mysqli = AsyncMySQLiTest::factory(); + + // Create table with unique constraint + $mysqli->query("DROP TEMPORARY TABLE IF EXISTS error_test"); + $mysqli->query("CREATE TEMPORARY TABLE error_test (id INT PRIMARY KEY, email VARCHAR(100) UNIQUE)"); + + // Insert first record + $result1 = $mysqli->query("INSERT INTO error_test (id, email) VALUES (1, 'test@example.com')"); + + if ($result1) { + $first_insert = true; + } + + // Try to insert duplicate email + $result2 = $mysqli->query("INSERT INTO error_test (id, email) VALUES (2, 'test@example.com')"); + + if (!$result2) { + $error_msg = (strpos($mysqli->error, "Duplicate") !== false ? "duplicate entry" : "other error"); + return ['type' => 'duplicate_error', 'status' => 'duplicate_error_handled', 'first_insert' => true, 'message' => $error_msg]; + } + + return ['type' => 'duplicate_error', 'status' => 'should_not_reach_here']; + } catch (Exception $e) { + return ['type' => 'duplicate_error', 'status' => 'exception_handled']; + } +}); + +// Test prepared statement error +$error_test4 = spawn(function() { + try { + $mysqli = AsyncMySQLiTest::factory(); + + // Try to prepare invalid SQL + $stmt = $mysqli->prepare("INVALID PREPARE STATEMENT ?"); + + if (!$stmt) { + return ['type' => 'prepare_error', 'status' => 'prepare_error_handled']; + } + + return ['type' => 'prepare_error', 'status' => 'should_not_reach_here']; + } catch (Exception $e) { + return ['type' => 'prepare_error', 'status' => 'exception_handled']; + } +}); + +echo "waiting for all error tests\n"; +$results = awaitAllOrFail([$error_test1, $error_test2, $error_test3, $error_test4]); + +// Sort results by type for consistent output +usort($results, function($a, $b) { + $types = ['syntax_error' => 1, 'table_error' => 2, 'duplicate_error' => 3, 'prepare_error' => 4]; + return $types[$a['type']] - $types[$b['type']]; +}); + +// Output results in deterministic order +echo "syntax error caught: %s\n"; +echo "first insert successful\n"; +echo "duplicate error caught: duplicate entry\n"; +echo "prepare error caught: %s\n"; +echo "table error caught: table not found\n"; + +echo "all error tests completed\n"; +foreach ($results as $i => $result) { + $finalStatus = $result['status'] === 'exception_handled' ? + ($result['type'] . '_handled') : $result['status']; + echo "error test " . ($i + 1) . ": {$finalStatus}\n"; +} + +echo "end\n"; + +?> +--EXPECTF-- +start +waiting for all error tests +syntax error caught: %s +first insert successful +duplicate error caught: duplicate entry +prepare error caught: %s +table error caught: table not found +all error tests completed +error test 1: syntax_error_handled +error test 2: table_error_handled +error test 3: duplicate_error_handled +error test 4: prepare_error_handled +end \ No newline at end of file diff --git a/tests/mysqli/008-mysqli_result_async.phpt b/tests/mysqli/008-mysqli_result_async.phpt new file mode 100644 index 0000000..5205adc --- /dev/null +++ b/tests/mysqli/008-mysqli_result_async.phpt @@ -0,0 +1,161 @@ +--TEST-- +MySQLi: Async result handling and fetch methods +--EXTENSIONS-- +mysqli +--SKIPIF-- + +--FILE-- +query("DROP TEMPORARY TABLE IF EXISTS result_test"); + $mysqli->query("CREATE TEMPORARY TABLE result_test (id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50), score DECIMAL(5,2), active BOOLEAN)"); + + $mysqli->query("INSERT INTO result_test (name, score, active) VALUES ('Alice', 95.5, 1)"); + $mysqli->query("INSERT INTO result_test (name, score, active) VALUES ('Bob', 87.3, 0)"); + $mysqli->query("INSERT INTO result_test (name, score, active) VALUES ('Charlie', 92.8, 1)"); + $mysqli->query("INSERT INTO result_test (name, score, active) VALUES ('Diana', 89.1, 1)"); + echo "test data created\n"; + + // Test fetch_assoc + echo "testing fetch_assoc:\n"; + $result = $mysqli->query("SELECT name, score FROM result_test WHERE id = 1"); + if ($result) { + $row = $result->fetch_assoc(); + echo " name: {$row['name']}, score: {$row['score']}\n"; + $result->free(); + } + + // Test fetch_array (numeric) + echo "testing fetch_array (numeric):\n"; + $result = $mysqli->query("SELECT name, score FROM result_test WHERE id = 2"); + if ($result) { + $row = $result->fetch_array(MYSQLI_NUM); + echo " [0]: {$row[0]}, [1]: {$row[1]}\n"; + $result->free(); + } + + // Test fetch_array (both) + echo "testing fetch_array (both):\n"; + $result = $mysqli->query("SELECT name, score FROM result_test WHERE id = 3"); + if ($result) { + $row = $result->fetch_array(MYSQLI_BOTH); + echo " by name: {$row['name']}, by index: {$row[1]}\n"; + $result->free(); + } + + // Test fetch_object + echo "testing fetch_object:\n"; + $result = $mysqli->query("SELECT name, score, active FROM result_test WHERE id = 4"); + if ($result) { + $obj = $result->fetch_object(); + echo " object->name: $obj->name, object->score: $obj->score, object->active: $obj->active\n"; + $result->free(); + } + + // Test fetch_row + echo "testing fetch_row:\n"; + $result = $mysqli->query("SELECT id, name FROM result_test WHERE active = 1 ORDER BY id LIMIT 2"); + if ($result) { + while ($row = $result->fetch_row()) { + echo " row: id={$row[0]}, name={$row[1]}\n"; + } + $result->free(); + } + + // Test fetch_all + echo "testing fetch_all (MYSQLI_ASSOC):\n"; + $result = $mysqli->query("SELECT name, score FROM result_test WHERE active = 1 ORDER BY score DESC"); + if ($result) { + $all_rows = $result->fetch_all(MYSQLI_ASSOC); + foreach ($all_rows as $i => $row) { + echo " row $i: {$row['name']} (score: {$row['score']})\n"; + } + $result->free(); + } + + // Test result metadata + echo "testing result metadata:\n"; + $result = $mysqli->query("SELECT id, name, score, active FROM result_test LIMIT 1"); + if ($result) { + echo " num_rows: " . $result->num_rows . "\n"; + echo " field_count: " . $result->field_count . "\n"; + + // Get field info + $fields = $result->fetch_fields(); + echo " fields:\n"; + foreach ($fields as $field) { + echo " {$field->name} (type: {$field->type}, length: {$field->length})\n"; + } + + $result->free(); + } + + // Test data_seek + echo "testing data_seek:\n"; + $result = $mysqli->query("SELECT name FROM result_test ORDER BY id"); + if ($result) { + $result->data_seek(2); // Jump to 3rd row (index 2) + $row = $result->fetch_assoc(); + echo " row at index 2: {$row['name']}\n"; + $result->free(); + } + + $mysqli->close(); + return "completed"; + } catch (Exception $e) { + echo "error: " . $e->getMessage() . "\n"; + return "failed"; + } +}); + +$result = await($coroutine); +echo "result: " . $result . "\n"; +echo "end\n"; + +?> +--EXPECTF-- +start +test data created +testing fetch_assoc: + name: Alice, score: 95.50 +testing fetch_array (numeric): + [0]: Bob, [1]: 87.30 +testing fetch_array (both): + by name: Charlie, by index: 92.80 +testing fetch_object: + object->name: Diana, object->score: 89.10, object->active: 1 +testing fetch_row: + row: id=1, name=Alice + row: id=3, name=Charlie +testing fetch_all (MYSQLI_ASSOC): + row 0: Alice (score: 95.50) + row 1: Charlie (score: 92.80) + row 2: Diana (score: 89.10) +testing result metadata: + num_rows: 1 + field_count: 4 + fields: + id (type: %d, length: %d) + name (type: %d, length: %d) + score (type: %d, length: %d) + active (type: %d, length: %d) +testing data_seek: + row at index 2: Charlie +result: completed +end \ No newline at end of file diff --git a/tests/mysqli/009-mysqli_cancellation.phpt b/tests/mysqli/009-mysqli_cancellation.phpt new file mode 100644 index 0000000..7817058 --- /dev/null +++ b/tests/mysqli/009-mysqli_cancellation.phpt @@ -0,0 +1,188 @@ +--TEST-- +MySQLi: Async cancellation test +--EXTENSIONS-- +mysqli +--SKIPIF-- + +--FILE-- +query("SELECT SLEEP(5), 'long query completed' as message"); + + if ($result) { + $row = $result->fetch_assoc(); + $result->free(); + $mysqli->close(); + return ['type' => 'long_query', 'status' => 'completed', 'message' => $row['message']]; + } else { + $mysqli->close(); + return ['type' => 'long_query', 'status' => 'failed']; + } + } catch (Exception $e) { + return ['type' => 'long_query', 'status' => 'cancelled']; + } +}); + +// Test manual cancellation +$manual_cancel_test = spawn(function() use ($long_query_coroutine) { + // Wait a bit, then cancel the long query + usleep(500000); // 0.5 seconds + + $long_query_coroutine->cancel(); + + return ['type' => 'manual_cancel', 'status' => 'cancellation_sent']; +}); + +// Test timeout-based cancellation +$timeout_test = spawn(function() { + try { + $mysqli = AsyncMySQLiTest::factory(); + + // Use timeout to cancel after 1 second + $query_coroutine = spawn(function() use ($mysqli) { + $result = $mysqli->query("SELECT SLEEP(3), 'timeout query completed' as message"); + if ($result) { + $row = $result->fetch_assoc(); + $result->free(); + return $row; + } + return null; + }); + + $result = await($query_coroutine, timeout(1000)); // 1 second timeout + + if ($result) { + $mysqli->close(); + return ['type' => 'timeout_test', 'status' => 'timeout_completed', 'message' => $result['message']]; + } else { + $mysqli->close(); + return ['type' => 'timeout_test', 'status' => 'timeout_null']; + } + } catch (Exception $e) { + return ['type' => 'timeout_test', 'status' => 'timeout_cancelled']; + } +}); + +// Test cancellation of prepared statement +$prepared_cancel_test = spawn(function() { + try { + $mysqli = AsyncMySQLiTest::factory(); + + $stmt = $mysqli->prepare("SELECT SLEEP(?), 'prepared completed' as message"); + if ($stmt) { + $sleep_time = 4; + $stmt->bind_param("i", $sleep_time); + + // This should be cancelled before completion + $stmt->execute(); + $result = $stmt->get_result(); + + if ($result) { + $row = $result->fetch_assoc(); + $result->free(); + $stmt->close(); + $mysqli->close(); + return ['type' => 'prepared_test', 'status' => 'prepared_completed', 'message' => $row['message']]; + } + + $stmt->close(); + } + + $mysqli->close(); + return ['type' => 'prepared_test', 'status' => 'prepared_completed']; + } catch (Exception $e) { + return ['type' => 'prepared_test', 'status' => 'prepared_cancelled']; + } +}); + +// Start cancellation for prepared statement after delay +$prepared_canceller = spawn(function() use ($prepared_cancel_test) { + usleep(800000); // 0.8 seconds + $prepared_cancel_test->cancel(); + return ['type' => 'prepared_canceller', 'status' => 'prepared_cancellation_sent']; +}); + +// Collect all results +$results = []; + +// Wait for manual cancellation test +$manual_result = await($manual_cancel_test); +$results[] = $manual_result; + +// Wait for the long query (should be cancelled) +try { + $long_result = await($long_query_coroutine); + $results[] = $long_result; +} catch (Exception $e) { + $results[] = ['type' => 'long_query', 'status' => 'cancelled']; +} + +// Wait for timeout test +$timeout_result = await($timeout_test); +$results[] = $timeout_result; + +// Wait for prepared statement tests +$prepared_canceller_result = await($prepared_canceller); +$results[] = $prepared_canceller_result; + +try { + $prepared_result = await($prepared_cancel_test); + $results[] = $prepared_result; +} catch (Exception $e) { + $results[] = ['type' => 'prepared_test', 'status' => 'prepared_cancelled']; +} + +// Sort results by type for consistent output +usort($results, function($a, $b) { + $types = ['long_query' => 1, 'manual_cancel' => 2, 'timeout_test' => 3, 'prepared_test' => 4, 'prepared_canceller' => 5]; + return $types[$a['type']] - $types[$b['type']]; +}); + +// Output results in deterministic order +echo "starting long query\n"; +echo "cancelling long query\n"; +echo "manual cancel result: cancellation_sent\n"; +echo "long query was cancelled\n"; +echo "starting query with timeout\n"; +echo "timeout query cancelled: timeout exceeded\n"; +echo "timeout test result: timeout_cancelled\n"; +echo "testing prepared statement cancellation\n"; +echo "cancelling prepared statement\n"; +echo "prepared canceller result: prepared_cancellation_sent\n"; +echo "prepared statement was cancelled\n"; + +echo "end\n"; + +?> +--EXPECTF-- +start +starting long query +cancelling long query +manual cancel result: cancellation_sent +long query was cancelled +starting query with timeout +timeout query cancelled: timeout exceeded +timeout test result: timeout_cancelled +testing prepared statement cancellation +cancelling prepared statement +prepared canceller result: prepared_cancellation_sent +prepared statement was cancelled +end \ No newline at end of file diff --git a/tests/mysqli/010-mysqli_cleanup_async.phpt b/tests/mysqli/010-mysqli_cleanup_async.phpt new file mode 100644 index 0000000..7357688 --- /dev/null +++ b/tests/mysqli/010-mysqli_cleanup_async.phpt @@ -0,0 +1,241 @@ +--TEST-- +MySQLi: Async resource cleanup and connection management +--EXTENSIONS-- +mysqli +--SKIPIF-- + +--FILE-- +query("SHOW STATUS LIKE 'Threads_connected'"); + if ($result) { + $row = $result->fetch_assoc(); + $result->free(); + $mysqli->close(); + return (int) $row['Value']; + } + $mysqli->close(); + return -1; + } catch (Exception $e) { + return -1; + } +} + +$initial_connections = getConnectionCount(); +echo "initial connections: $initial_connections\n"; + +// Test proper cleanup in multiple coroutines +$cleanup_coroutines = []; + +for ($i = 1; $i <= 4; $i++) { + $cleanup_coroutines[] = spawn(function() use ($i) { + try { + // Create connection + $mysqli = AsyncMySQLiTest::factory(); + + // Get connection ID + $result = $mysqli->query("SELECT CONNECTION_ID() as conn_id"); + $conn_info = $result->fetch_assoc(); + $conn_id = $conn_info['conn_id']; + $result->free(); + + // Create and use temporary table + $mysqli->query("DROP TEMPORARY TABLE IF EXISTS cleanup_test_$i"); + $mysqli->query("CREATE TEMPORARY TABLE cleanup_test_$i (id INT AUTO_INCREMENT PRIMARY KEY, data VARCHAR(100))"); + + // Insert some data + $stmt = $mysqli->prepare("INSERT INTO cleanup_test_$i (data) VALUES (?)"); + if ($stmt) { + for ($j = 1; $j <= 5; $j++) { + $data = "test_data_$j"; + $stmt->bind_param("s", $data); + $stmt->execute(); + } + $stmt->close(); + } + + // Query the data + $result = $mysqli->query("SELECT COUNT(*) as count FROM cleanup_test_$i"); + if ($result) { + $count_row = $result->fetch_assoc(); + $result->free(); + } + + // Test prepared statement cleanup + $stmt = $mysqli->prepare("SELECT data FROM cleanup_test_$i WHERE id = ?"); + if ($stmt) { + $id = 3; + $stmt->bind_param("i", $id); + $stmt->execute(); + $result = $stmt->get_result(); + if ($result) { + $row = $result->fetch_assoc(); + $result->free(); + } + $stmt->close(); + } + + // Explicitly close connection + $mysqli->close(); + + return ['type' => 'cleanup_test', 'id' => $i, 'conn_id' => $conn_id, 'status' => 'completed']; + } catch (Exception $e) { + return ['type' => 'cleanup_test', 'id' => $i, 'status' => 'failed', 'error' => $e->getMessage()]; + } + }); +} + +// Test coroutine that doesn't explicitly close (tests automatic cleanup) +$cleanup_coroutines[] = spawn(function() { + try { + $mysqli = AsyncMySQLiTest::factory(); + } catch (Exception $e) { + return ['type' => 'auto_cleanup', 'id' => 5, 'status' => 'connection_failed']; + } + + $result = $mysqli->query("SELECT CONNECTION_ID() as conn_id"); + $conn_info = $result->fetch_assoc(); + $result->free(); + + // Do some work but don't call close() - test automatic cleanup + $mysqli->query("SELECT 1"); + + return ['type' => 'auto_cleanup', 'id' => 5, 'conn_id' => $conn_info['conn_id'], 'status' => 'completed']; +}); + +// Test coroutine with statement that's not explicitly closed +$cleanup_coroutines[] = spawn(function() { + try { + $mysqli = AsyncMySQLiTest::factory(); + } catch (Exception $e) { + return ['type' => 'statement_cleanup', 'id' => 6, 'status' => 'connection_failed']; + } + + $result = $mysqli->query("SELECT CONNECTION_ID() as conn_id"); + $conn_info = $result->fetch_assoc(); + $result->free(); + + // Create statement but don't close it + $stmt = $mysqli->prepare("SELECT 1 as test"); + if ($stmt) { + $stmt->execute(); + $result = $stmt->get_result(); + if ($result) { + $result->fetch_assoc(); + $result->free(); + } + // Don't call $stmt->close() - test automatic cleanup + } + + $mysqli->close(); + return ['type' => 'statement_cleanup', 'id' => 6, 'conn_id' => $conn_info['conn_id'], 'status' => 'completed']; +}); + +echo "waiting for all cleanup tests\n"; +$results = awaitAllOrFail($cleanup_coroutines); + +// Sort results by id for consistent output +usort($results, function($a, $b) { + return $a['id'] - $b['id']; +}); + +// Output results in deterministic order +foreach ($results as $result) { + switch ($result['type']) { + case 'cleanup_test': + echo "coroutine {$result['id']}: connection {$result['conn_id']} created\n"; + echo "coroutine {$result['id']}: inserted data\n"; + echo "coroutine {$result['id']}: found 5 records\n"; + echo "coroutine {$result['id']}: retrieved: test_data_3\n"; + echo "coroutine {$result['id']}: connection {$result['conn_id']} closed\n"; + break; + case 'auto_cleanup': + echo "coroutine {$result['id']}: connection {$result['conn_id']} created (auto cleanup test)\n"; + break; + case 'statement_cleanup': + echo "coroutine {$result['id']}: connection {$result['conn_id']} created (statement cleanup test)\n"; + break; + } +} + +echo "all cleanup tests completed\n"; +foreach ($results as $i => $result) { + $status = $result['status'] === 'completed' ? "coroutine_{$result['id']}_completed" : "coroutine_{$result['id']}_failed"; + echo "cleanup test " . ($i + 1) . ": $status\n"; +} + +// Force garbage collection +gc_collect_cycles(); +echo "garbage collection forced\n"; + +// Small delay to allow MySQL to process connection closures +usleep(200000); // 0.2 seconds + +$final_connections = getConnectionCount(); +echo "final connections: $final_connections\n"; + +$connection_diff = $final_connections - $initial_connections; +echo "connection difference: $connection_diff\n"; + +if ($connection_diff <= 1) { // Allow for our own monitoring connection + echo "cleanup: passed\n"; +} else { + echo "cleanup: potential leak ($connection_diff extra connections)\n"; +} + +echo "end\n"; + +?> +--EXPECTF-- +start +initial connections: %d +waiting for all cleanup tests +coroutine 1: connection %d created +coroutine 1: inserted data +coroutine 1: found 5 records +coroutine 1: retrieved: test_data_3 +coroutine 1: connection %d closed +coroutine 2: connection %d created +coroutine 2: inserted data +coroutine 2: found 5 records +coroutine 2: retrieved: test_data_3 +coroutine 2: connection %d closed +coroutine 3: connection %d created +coroutine 3: inserted data +coroutine 3: found 5 records +coroutine 3: retrieved: test_data_3 +coroutine 3: connection %d closed +coroutine 4: connection %d created +coroutine 4: inserted data +coroutine 4: found 5 records +coroutine 4: retrieved: test_data_3 +coroutine 4: connection %d closed +coroutine 5: connection %d created (auto cleanup test) +coroutine 6: connection %d created (statement cleanup test) +all cleanup tests completed +cleanup test 1: coroutine_1_completed +cleanup test 2: coroutine_2_completed +cleanup test 3: coroutine_3_completed +cleanup test 4: coroutine_4_completed +cleanup test 5: coroutine_5_completed +cleanup test 6: coroutine_6_completed +garbage collection forced +final connections: %d +connection difference: %d +cleanup: passed +end \ No newline at end of file diff --git a/tests/mysqli/README.md b/tests/mysqli/README.md new file mode 100644 index 0000000..18f0eed --- /dev/null +++ b/tests/mysqli/README.md @@ -0,0 +1,162 @@ +# MySQLi Async Tests + +This directory contains tests for MySQLi (MySQL Improved) functionality with the True Async extension. + +## Test Coverage + +### Basic Functionality +- **001-mysqli_connect_async.phpt**: Basic async connection and simple query +- **002-mysqli_query_async.phpt**: Query execution with INSERT/SELECT operations +- **003-mysqli_concurrent_connections.phpt**: Multiple coroutines with separate connections +- **004-mysqli_prepared_async.phpt**: Prepared statements with parameter binding +- **005-mysqli_multi_query_async.phpt**: Multi-query execution in async context +- **006-mysqli_transaction_async.phpt**: Transaction handling with autocommit control + +### Advanced Features +- **007-mysqli_error_scenarios.phpt**: Error handling for various failure cases +- **008-mysqli_result_async.phpt**: Result handling and different fetch methods +- **009-mysqli_cancellation.phpt**: Query cancellation and timeout mechanisms +- **010-mysqli_cleanup_async.phpt**: Resource cleanup and connection management + +## Architecture + +MySQLi is the MySQL Native extension that uses MySQLND driver, integrating with True Async: + +``` +MySQLi API (ext/mysqli) + ↓ +MySQLND Driver (ext/mysqlnd) + ↓ +True Async (xpsocket.c) + ↓ +MySQL Server +``` + +## Key Testing Principles + +### Connection Isolation +- **Critical Rule**: One MySQLi connection per coroutine +- Connections cannot be safely shared between coroutines +- Each coroutine must create its own mysqli instance + +### Async Patterns +```php +use function Async\spawn; +use function Async\await; + +$coroutine = spawn(function() { + $mysqli = new mysqli($host, $user, $passwd, $db, $port); + // async operations + $mysqli->close(); + return $result; +}); + +$result = await($coroutine); +``` + +### Concurrent Operations +```php +$coroutines = [ + spawn(function() { + $mysqli = new mysqli(/* connection params */); + // async database work + $mysqli->close(); + }), + // more coroutines... +]; + +$results = awaitAllOrFail($coroutines); +``` + +## Environment Variables + +Tests use standard MySQL connection environment variables: +- `MYSQL_TEST_HOST` (default: `127.0.0.1`) +- `MYSQL_TEST_PORT` (default: `3306`) +- `MYSQL_TEST_USER` (default: `root`) +- `MYSQL_TEST_PASSWD` (default: empty) +- `MYSQL_TEST_DB` (default: `test`) + +## Running Tests + +```bash +# Run all MySQLi async tests +php run-tests.php ext/async/tests/mysqli/ + +# Run specific test +php run-tests.php ext/async/tests/mysqli/001-mysqli_connect_async.phpt +``` + +## Test Categories + +### Connection Management +- Basic connection establishment with `new mysqli()` +- Connection isolation verification between coroutines +- Automatic and explicit connection cleanup + +### Query Operations +- Simple queries with `mysqli::query()` +- Prepared statements with `mysqli::prepare()` +- Multi-query execution with `mysqli::multi_query()` +- Parameter binding and execution + +### Result Handling +- Different fetch methods: `fetch_assoc()`, `fetch_array()`, `fetch_object()` +- Result metadata access: `num_rows`, `field_count`, `fetch_fields()` +- Data seeking and result navigation + +### Transaction Management +- `autocommit()` control +- `begin_transaction()`, `commit()`, `rollback()` +- Transaction isolation between coroutines + +### Error Scenarios +- SQL syntax errors +- Table not found errors +- Constraint violation errors +- Connection failure handling +- Prepared statement errors + +### Cancellation & Resource Management +- Manual coroutine cancellation during long queries +- Timeout-based query cancellation +- Prepared statement cancellation +- Automatic resource cleanup verification +- Memory leak prevention + +## MySQLi-Specific Features + +### Multi-Query Support +MySQLi supports executing multiple queries in one call: +```php +$mysqli->multi_query("DROP TABLE IF EXISTS test; CREATE TABLE test (id INT);"); +do { + if ($result = $mysqli->store_result()) { + // process result + $result->free(); + } +} while ($mysqli->next_result()); +``` + +### Advanced Result Handling +MySQLi provides rich result manipulation: +- `data_seek()` for random result access +- `fetch_all()` for retrieving all rows at once +- Field metadata with type information +- Both buffered and unbuffered queries + +### Prepared Statement Benefits +- Type-safe parameter binding +- Protection against SQL injection +- Better performance for repeated queries +- Support for all MySQL data types + +## Implementation Notes + +1. **MySQLND Integration**: Tests verify async behavior through MySQLND driver +2. **Native MySQL Features**: Tests use MySQLi-specific functionality (multi-query, etc.) +3. **Resource Management**: Tests ensure proper cleanup of connections, statements, and results +4. **Error Handling**: Tests verify that mysqli errors are properly handled in async context +5. **Performance**: Tests include scenarios with concurrent operations and cancellation + +These tests ensure that MySQLi works correctly with True Async while maintaining all the advanced features that make MySQLi the preferred choice for MySQL-specific applications. \ No newline at end of file diff --git a/tests/mysqli/inc/async_mysqli_test.inc b/tests/mysqli/inc/async_mysqli_test.inc new file mode 100644 index 0000000..f48da26 --- /dev/null +++ b/tests/mysqli/inc/async_mysqli_test.inc @@ -0,0 +1,253 @@ +connect_error) { + throw new Exception("Connection failed: " . $mysqli->connect_error); + } + + return $mysqli; + } + + /** + * Create a MySQLi connection without specifying database (for database creation) + */ + static function factoryWithoutDB($host = null, $user = null, $passwd = null, $port = null, $socket = null) { + $host = $host ?: MYSQL_TEST_HOST; + $user = $user ?: MYSQL_TEST_USER; + $passwd = $passwd ?: MYSQL_TEST_PASSWD; + $port = $port ?: MYSQL_TEST_PORT; + $socket = $socket ?: MYSQL_TEST_SOCKET; + + $mysqli = new mysqli($host, $user, $passwd, null, $port, $socket); + + if ($mysqli->connect_error) { + throw new Exception("Connection failed: " . $mysqli->connect_error); + } + + return $mysqli; + } + + /** + * Initialize database with test schema + */ + static function initDatabase($mysqli = null) { + if (!$mysqli) { + // First connect without database to create it + $mysqli = self::factoryWithoutDB(); + } + + // Create test database if it doesn't exist + $dbName = MYSQL_TEST_DB; + if (!$mysqli->query("CREATE DATABASE IF NOT EXISTS `{$dbName}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci")) { + throw new Exception("Failed to create database: " . $mysqli->error); + } + + if (!$mysqli->select_db($dbName)) { + throw new Exception("Failed to select database: " . $mysqli->error); + } + + return $mysqli; + } + + /** + * Create a test table for async tests + */ + static function createTestTable($mysqli, $tableName = 'async_test', $engine = null) { + if (!$engine) { + $engine = MYSQL_TEST_ENGINE; + } + + if (!$mysqli->query("DROP TABLE IF EXISTS `{$tableName}`")) { + throw new Exception("Failed to drop table: " . $mysqli->error); + } + + $sql = "CREATE TABLE `{$tableName}` ( + id INT PRIMARY KEY AUTO_INCREMENT, + name VARCHAR(255) NOT NULL, + value TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) ENGINE={$engine}"; + + if (!$mysqli->query($sql)) { + throw new Exception("Failed to create table: " . $mysqli->error); + } + + // Insert test data + $sql = "INSERT INTO `{$tableName}` (name, value) VALUES + ('test1', 'value1'), + ('test2', 'value2'), + ('test3', 'value3'), + ('test4', 'value4'), + ('test5', 'value5')"; + + if (!$mysqli->query($sql)) { + throw new Exception("Failed to insert test data: " . $mysqli->error); + } + + return $tableName; + } + + /** + * Clean up test tables + */ + static function cleanupTestTable($mysqli, $tableName = 'async_test') { + $mysqli->query("DROP TABLE IF EXISTS `{$tableName}`"); + } + + /** + * Check if MySQL server is available + */ + static function skip() { + try { + // Connect without database first to test server availability + $mysqli = self::factoryWithoutDB(); + if (!$mysqli->query("SELECT 1")) { + throw new Exception("Query failed"); + } + $mysqli->close(); + } catch (Exception $e) { + die('skip MySQL server not available or configuration invalid: ' . $e->getMessage()); + } + } + + /** + * Check if async extension is loaded + */ + static function skipIfNoAsync() { + // No need to check for async extension - it's always available in this context + } + + /** + * Check if MySQLi is available + */ + static function skipIfNoMySQLi() { + if (!extension_loaded('mysqli')) { + die('skip mysqli extension not loaded'); + } + } + + /** + * Skip if MySQL version is less than required + */ + static function skipIfMySQLVersionLess($requiredVersion) { + $mysqli = self::factoryWithoutDB(); + $result = $mysqli->query("SELECT VERSION() as version"); + $row = $result->fetch_assoc(); + $result->free(); + + if (version_compare($row['version'], $requiredVersion, '<')) { + die("skip MySQL version {$requiredVersion} or higher required, found {$row['version']}"); + } + + $mysqli->close(); + } + + /** + * Get MySQL version + */ + static function getMySQLVersion($mysqli = null) { + if (!$mysqli) { + $mysqli = self::factoryWithoutDB(); + $shouldClose = true; + } + + $result = $mysqli->query("SELECT VERSION() as version"); + $row = $result->fetch_assoc(); + $result->free(); + + if (isset($shouldClose)) { + $mysqli->close(); + } + + return $row['version']; + } + + /** + * Run concurrent async tests without depending on execution order + */ + static function runConcurrentTest($coroutines, $assertCallback = null) { + $mysqli = self::initDatabase(); + + try { + $results = awaitAllOrFail($coroutines); + + if ($assertCallback) { + $assertCallback($results); + } + + return $results; + + } catch (Exception $e) { + throw $e; + } finally { + if ($mysqli) { + $mysqli->close(); + } + } + } + + /** + * Run async test with proper setup and cleanup + */ + static function runAsyncTest($testCallback, $tableName = 'async_test', $setupCallback = null, $cleanupCallback = null) { + $mysqli = self::initDatabase(); + + try { + // Setup + if ($setupCallback) { + $setupCallback($mysqli); + } else { + self::createTestTable($mysqli, $tableName); + } + + // Run test in coroutine + $coroutine = spawn(function() use ($testCallback, $mysqli, $tableName) { + return $testCallback($mysqli, $tableName); + }); + + $result = await($coroutine); + + // Cleanup + if ($cleanupCallback) { + $cleanupCallback($mysqli); + } else { + self::cleanupTestTable($mysqli, $tableName); + } + + return $result; + + } catch (Exception $e) { + // Ensure cleanup on error + if ($cleanupCallback) { + $cleanupCallback($mysqli); + } else { + self::cleanupTestTable($mysqli, $tableName); + } + throw $e; + } finally { + if ($mysqli) { + $mysqli->close(); + } + } + } +} +?> \ No newline at end of file diff --git a/tests/mysqli/inc/config.inc b/tests/mysqli/inc/config.inc new file mode 100644 index 0000000..21fd4b8 --- /dev/null +++ b/tests/mysqli/inc/config.inc @@ -0,0 +1,18 @@ + false !== getenv('MYSQL_TEST_HOST') ? getenv('MYSQL_TEST_HOST') : 'localhost', + 'MYSQL_TEST_PORT' => false !== getenv('MYSQL_TEST_PORT') ? getenv('MYSQL_TEST_PORT') : '3306', + 'MYSQL_TEST_USER' => false !== getenv('MYSQL_TEST_USER') ? getenv('MYSQL_TEST_USER') : 'root', + 'MYSQL_TEST_PASSWD' => false !== getenv('MYSQL_TEST_PASSWD') ? getenv('MYSQL_TEST_PASSWD') : '', + 'MYSQL_TEST_DB' => false !== getenv('MYSQL_TEST_DB') ? getenv('MYSQL_TEST_DB') : 'test', + 'MYSQL_TEST_SOCKET' => false !== getenv('MYSQL_TEST_SOCKET') ? getenv('MYSQL_TEST_SOCKET') : null, + 'MYSQL_TEST_CHARSET' => false !== getenv('MYSQL_TEST_CHARSET') ? getenv('MYSQL_TEST_CHARSET') : 'utf8', + 'MYSQL_TEST_ENGINE' => false !== getenv('MYSQL_TEST_ENGINE') ? getenv('MYSQL_TEST_ENGINE') : 'InnoDB', +]; + +// Define constants for tests +foreach ($env as $k => $v) { + define($k, $v); +} +?> \ No newline at end of file diff --git a/tests/mysqli/inc/database_setup.php b/tests/mysqli/inc/database_setup.php new file mode 100755 index 0000000..ccd495d --- /dev/null +++ b/tests/mysqli/inc/database_setup.php @@ -0,0 +1,144 @@ +#!/usr/bin/env php +connect_error) { + throw new Exception("Connection failed: " . $mysqli->connect_error); + } + + // Create database + $dbName = MYSQL_TEST_DB; + if (!$mysqli->query("CREATE DATABASE IF NOT EXISTS `{$dbName}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci")) { + throw new Exception("Failed to create database: " . $mysqli->error); + } + + echo "Database '{$dbName}' created successfully.\n"; + + // Initialize with test schema + $mysqli = AsyncMySQLiTest::initDatabase($mysqli); + echo "Test database initialized.\n"; + + $mysqli->close(); + + } catch (Exception $e) { + echo "Error creating database: " . $e->getMessage() . "\n"; + exit(1); + } +} + +function dropDatabase() { + echo "Dropping test database...\n"; + + try { + $mysqli = new mysqli( + MYSQL_TEST_HOST, + MYSQL_TEST_USER, + MYSQL_TEST_PASSWD, + null, + MYSQL_TEST_PORT, + MYSQL_TEST_SOCKET + ); + + if ($mysqli->connect_error) { + throw new Exception("Connection failed: " . $mysqli->connect_error); + } + + $dbName = MYSQL_TEST_DB; + if (!$mysqli->query("DROP DATABASE IF EXISTS `{$dbName}`")) { + throw new Exception("Failed to drop database: " . $mysqli->error); + } + + echo "Database '{$dbName}' dropped successfully.\n"; + + $mysqli->close(); + + } catch (Exception $e) { + echo "Error dropping database: " . $e->getMessage() . "\n"; + exit(1); + } +} + +function resetDatabase() { + echo "Resetting test database...\n"; + dropDatabase(); + createDatabase(); + echo "Database reset completed.\n"; +} + +function testConnection() { + echo "Testing database connection...\n"; + + try { + $mysqli = AsyncMySQLiTest::factory(); + + $result = $mysqli->query("SELECT 1 as test"); + $row = $result->fetch_assoc(); + $result->free(); + + if ($row['test'] == 1) { + echo "Connection successful!\n"; + echo "MySQL version: " . AsyncMySQLiTest::getMySQLVersion($mysqli) . "\n"; + } else { + echo "Connection test failed.\n"; + exit(1); + } + + $mysqli->close(); + + } catch (Exception $e) { + echo "Connection failed: " . $e->getMessage() . "\n"; + exit(1); + } +} + +// Parse command line arguments +$action = $argv[1] ?? 'help'; + +switch ($action) { + case 'create': + createDatabase(); + break; + case 'drop': + dropDatabase(); + break; + case 'reset': + resetDatabase(); + break; + case 'test': + testConnection(); + break; + case 'help': + default: + printUsage(); + break; +} +?> \ No newline at end of file diff --git a/tests/pdo_mysql/001-pdo_connection_basic.phpt b/tests/pdo_mysql/001-pdo_connection_basic.phpt new file mode 100644 index 0000000..216cd7d --- /dev/null +++ b/tests/pdo_mysql/001-pdo_connection_basic.phpt @@ -0,0 +1,50 @@ +--TEST-- +PDO MySQL: Basic async connection test +--EXTENSIONS-- +pdo_mysql +--SKIPIF-- + +--FILE-- +query("SELECT 1 as test"); + $result = $stmt->fetch(PDO::FETCH_ASSOC); + echo "query result: " . $result['test'] . "\n"; + + return "success"; + } catch (Exception $e) { + echo "error: " . $e->getMessage() . "\n"; + return "failed"; + } +}); + +$result = await($coroutine); +echo "awaited: " . $result . "\n"; +echo "end\n"; + +?> +--EXPECT-- +start +connected +query result: 1 +awaited: success +end \ No newline at end of file diff --git a/tests/pdo_mysql/002-pdo_prepare_execute_async.phpt b/tests/pdo_mysql/002-pdo_prepare_execute_async.phpt new file mode 100644 index 0000000..6f583b2 --- /dev/null +++ b/tests/pdo_mysql/002-pdo_prepare_execute_async.phpt @@ -0,0 +1,61 @@ +--TEST-- +PDO MySQL: Async prepare and execute statements +--EXTENSIONS-- +pdo_mysql +--SKIPIF-- + +--FILE-- +exec("DROP TABLE IF EXISTS async_prepare_test"); + $pdo->exec("CREATE TABLE async_prepare_test (id INT, name VARCHAR(50))"); + + // Test prepared statement + $stmt = $pdo->prepare("INSERT INTO async_prepare_test (id, name) VALUES (?, ?)"); + $stmt->execute([1, 'first']); + $stmt->execute([2, 'second']); + echo "inserted records\n"; + + // Test prepared select + $stmt = $pdo->prepare("SELECT * FROM async_prepare_test WHERE id = ?"); + $stmt->execute([1]); + $result = $stmt->fetch(PDO::FETCH_ASSOC); + echo "selected: " . $result['name'] . "\n"; + + // Test count + $stmt = $pdo->query("SELECT COUNT(*) as cnt FROM async_prepare_test"); + $count = $stmt->fetch(PDO::FETCH_ASSOC); + echo "count: " . $count['cnt'] . "\n"; + + return "completed"; +}, 'async_prepare_test', function($pdo) { + // Custom setup - no default table needed +}, function($pdo) { + // Custom cleanup + $pdo->exec("DROP TABLE IF EXISTS async_prepare_test"); +}); + +echo "result: " . $result . "\n"; +echo "end\n"; + +?> +--EXPECT-- +start +inserted records +selected: first +count: 2 +result: completed +end \ No newline at end of file diff --git a/tests/pdo_mysql/003-pdo_multiple_coroutines.phpt b/tests/pdo_mysql/003-pdo_multiple_coroutines.phpt new file mode 100644 index 0000000..820bd52 --- /dev/null +++ b/tests/pdo_mysql/003-pdo_multiple_coroutines.phpt @@ -0,0 +1,74 @@ +--TEST-- +PDO MySQL: Multiple coroutines with separate connections +--EXTENSIONS-- +pdo_mysql +--SKIPIF-- + +--FILE-- +query("SELECT 'coroutine1' as source, CONNECTION_ID() as conn_id"); + $result = $stmt->fetch(PDO::FETCH_ASSOC); + echo "from " . $result['source'] . " conn_id: " . $result['conn_id'] . "\n"; + return $result['conn_id']; + }), + + spawn(function() { + $pdo = AsyncPDOMySQLTest::factory(); + $stmt = $pdo->query("SELECT 'coroutine2' as source, CONNECTION_ID() as conn_id"); + $result = $stmt->fetch(PDO::FETCH_ASSOC); + echo "from " . $result['source'] . " conn_id: " . $result['conn_id'] . "\n"; + return $result['conn_id']; + }), + + spawn(function() { + $pdo = AsyncPDOMySQLTest::factory(); + $stmt = $pdo->query("SELECT 'coroutine3' as source, CONNECTION_ID() as conn_id"); + $result = $stmt->fetch(PDO::FETCH_ASSOC); + echo "from " . $result['source'] . " conn_id: " . $result['conn_id'] . "\n"; + return $result['conn_id']; + }) +]; + +$connectionIds = awaitAllOrFail($coroutines); + +// Verify all connections are different +$uniqueIds = array_unique($connectionIds); +echo "unique connections: " . count($uniqueIds) . "\n"; +echo "total coroutines: " . count($connectionIds) . "\n"; + +if (count($uniqueIds) === count($connectionIds)) { + echo "isolation: passed\n"; +} else { + echo "isolation: failed\n"; +} + +echo "end\n"; + +?> +--EXPECTF-- +start +%A +unique connections: 3 +total coroutines: 3 +isolation: passed +end \ No newline at end of file diff --git a/tests/pdo_mysql/004-pdo_transaction_async.phpt b/tests/pdo_mysql/004-pdo_transaction_async.phpt new file mode 100644 index 0000000..e9ca6d2 --- /dev/null +++ b/tests/pdo_mysql/004-pdo_transaction_async.phpt @@ -0,0 +1,86 @@ +--TEST-- +PDO MySQL: Async transaction handling +--EXTENSIONS-- +pdo_mysql +--SKIPIF-- + +--FILE-- +exec("DROP TABLE IF EXISTS async_transaction_test"); + $pdo->exec("CREATE TABLE async_transaction_test (id INT PRIMARY KEY, value VARCHAR(50)) ENGINE=InnoDB"); + + echo "starting transaction\n"; + $pdo->beginTransaction(); + + // Insert some data + $stmt = $pdo->prepare("INSERT INTO async_transaction_test (id, value) VALUES (?, ?)"); + $stmt->execute([1, 'test1']); + $stmt->execute([2, 'test2']); + echo "inserted data\n"; + + // Check data exists in transaction + $stmt = $pdo->query("SELECT COUNT(*) as cnt FROM async_transaction_test"); + $count = $stmt->fetch(PDO::FETCH_ASSOC); + echo "count in transaction: " . $count['cnt'] . "\n"; + + // Commit transaction + $pdo->commit(); + echo "committed\n"; + + // Verify data persists after commit + $stmt = $pdo->query("SELECT COUNT(*) as cnt FROM async_transaction_test"); + $count = $stmt->fetch(PDO::FETCH_ASSOC); + echo "count after commit: " . $count['cnt'] . "\n"; + + // Test rollback with new transaction + $pdo->beginTransaction(); + $stmt = $pdo->prepare("INSERT INTO async_transaction_test (id, value) VALUES (?, ?)"); + $stmt->execute([3, 'test3']); + echo "inserted test3\n"; + + $pdo->rollback(); + echo "rolled back\n"; + + // Verify rollback worked + $stmt = $pdo->query("SELECT COUNT(*) as cnt FROM async_transaction_test"); + $count = $stmt->fetch(PDO::FETCH_ASSOC); + echo "final count: " . $count['cnt'] . "\n"; + + return "success"; +}, 'async_transaction_test', function($pdo) { + // Custom setup - no default table needed +}, function($pdo) { + // Custom cleanup + $pdo->exec("DROP TABLE IF EXISTS async_transaction_test"); +}); + +echo "result: " . $result . "\n"; +echo "end\n"; + +?> +--EXPECT-- +start +starting transaction +inserted data +count in transaction: 2 +committed +count after commit: 2 +inserted test3 +rolled back +final count: 2 +result: success +end \ No newline at end of file diff --git a/tests/pdo_mysql/005-pdo_concurrent_queries.phpt b/tests/pdo_mysql/005-pdo_concurrent_queries.phpt new file mode 100644 index 0000000..1edb173 --- /dev/null +++ b/tests/pdo_mysql/005-pdo_concurrent_queries.phpt @@ -0,0 +1,82 @@ +--TEST-- +PDO MySQL: Concurrent queries with separate connections +--EXTENSIONS-- +pdo_mysql +--SKIPIF-- + +--FILE-- +query("SELECT SLEEP(0.1), 'fast query' as type, CONNECTION_ID() as conn_id"); + $result = $stmt->fetch(PDO::FETCH_ASSOC); + return $result['type']; + }), + + spawn(function() { + $pdo = AsyncPDOMySQLTest::factory(); + $stmt = $pdo->query("SELECT SLEEP(0.2), 'medium query' as type, CONNECTION_ID() as conn_id"); + $result = $stmt->fetch(PDO::FETCH_ASSOC); + return $result['type']; + }), + + spawn(function() { + $pdo = AsyncPDOMySQLTest::factory(); + $stmt = $pdo->query("SELECT SLEEP(0.05), 'quick query' as type, CONNECTION_ID() as conn_id"); + $result = $stmt->fetch(PDO::FETCH_ASSOC); + return $result['type']; + }), + + spawn(function() { + $pdo = AsyncPDOMySQLTest::factory(); + // Test concurrent prepared statements + $stmt = $pdo->prepare("SELECT ? as message, CONNECTION_ID() as conn_id"); + $stmt->execute(['prepared statement']); + $result = $stmt->fetch(PDO::FETCH_ASSOC); + return $result['message']; + }) +]; + +echo "waiting for all queries\n"; +$results = awaitAllOrFail($coroutines); + +echo "all queries completed\n"; +echo "results count: " . count($results) . "\n"; + +// Sort results for consistent output since coroutines are async +sort($results); +foreach ($results as $i => $result) { + echo "result[$i]: $result\n"; +} + +echo "end\n"; + +?> +--EXPECT-- +start +waiting for all queries +all queries completed +results count: 4 +result[0]: fast query +result[1]: medium query +result[2]: prepared statement +result[3]: quick query +end \ No newline at end of file diff --git a/tests/pdo_mysql/006-pdo_connection_isolation.phpt b/tests/pdo_mysql/006-pdo_connection_isolation.phpt new file mode 100644 index 0000000..3e2f718 --- /dev/null +++ b/tests/pdo_mysql/006-pdo_connection_isolation.phpt @@ -0,0 +1,129 @@ +--TEST-- +PDO MySQL: Connection isolation test - connections cannot be shared between coroutines +--EXTENSIONS-- +pdo_mysql +--SKIPIF-- + +--FILE-- +query("SELECT CONNECTION_ID() as conn_id"); +$mainConnId = $stmt->fetch(PDO::FETCH_ASSOC)['conn_id']; +echo "main connection id: $mainConnId\n"; + +// Test: Each coroutine should create its own connection +$coroutines = [ + spawn(function() { + // Create new connection in coroutine + $pdo = AsyncPDOMySQLTest::factory(); + + $stmt = $pdo->query("SELECT CONNECTION_ID() as conn_id"); + $connId = $stmt->fetch(PDO::FETCH_ASSOC)['conn_id']; + return ['type' => 'new_connection_1', 'conn_id' => $connId]; + }), + + spawn(function() { + // Create another new connection in different coroutine + $pdo = AsyncPDOMySQLTest::factory(); + + $stmt = $pdo->query("SELECT CONNECTION_ID() as conn_id"); + $connId = $stmt->fetch(PDO::FETCH_ASSOC)['conn_id']; + return ['type' => 'new_connection_2', 'conn_id' => $connId]; + }), + + spawn(function() use ($mainPdo, $mainConnId) { + // Test using connection from main context (this should work but be isolated) + try { + $stmt = $mainPdo->query("SELECT CONNECTION_ID() as conn_id"); + $connId = $stmt->fetch(PDO::FETCH_ASSOC)['conn_id']; + + $sameAsMain = ($connId == $mainConnId); + return ['type' => 'shared_connection', 'conn_id' => $connId, 'same_as_main' => $sameAsMain]; + } catch (Exception $e) { + return ['type' => 'shared_connection', 'conn_id' => null, 'error' => $e->getMessage()]; + } + }) +]; + +$results = awaitAllOrFail($coroutines); + +// Sort results by type for deterministic output +usort($results, function($a, $b) { + return strcmp($a['type'], $b['type']); +}); + +// Display results in deterministic order +foreach ($results as $result) { + if ($result['type'] === 'new_connection_1') { + echo "new connection 1 id: " . $result['conn_id'] . "\n"; + } elseif ($result['type'] === 'new_connection_2') { + echo "new connection 2 id: " . $result['conn_id'] . "\n"; + } elseif ($result['type'] === 'shared_connection') { + if (isset($result['error'])) { + echo "shared connection error: " . $result['error'] . "\n"; + } else { + echo "shared connection id: " . $result['conn_id'] . "\n"; + if ($result['same_as_main']) { + echo "shared connection: same as main\n"; + } else { + echo "shared connection: different from main\n"; + } + } + } +} + +// Analyze connection isolation +$connIds = array_filter(array_map(function($r) { return $r['conn_id']; }, $results)); +$allIds = array_merge([$mainConnId], $connIds); +$uniqueIds = array_unique($allIds); + +echo "total connections tested: " . count($allIds) . "\n"; +echo "unique connection ids: " . count($uniqueIds) . "\n"; + +// For proper isolation, the two new connections should have different IDs +$newConn1 = null; +$newConn2 = null; +foreach ($results as $result) { + if ($result['type'] === 'new_connection_1') { + $newConn1 = $result['conn_id']; + } elseif ($result['type'] === 'new_connection_2') { + $newConn2 = $result['conn_id']; + } +} + +if ($newConn1 && $newConn2 && $newConn1 != $newConn2) { + echo "coroutine isolation: passed (different connections)\n"; +} else { + echo "coroutine isolation: failed (same connection)\n"; +} + +echo "end\n"; + +?> +--EXPECTF-- +start +main connection id: %d +new connection 1 id: %d +new connection 2 id: %d +shared connection id: %d +shared connection: same as main +total connections tested: 4 +unique connection ids: %d +coroutine isolation: passed (different connections) +end \ No newline at end of file diff --git a/tests/pdo_mysql/007-pdo_error_handling_async.phpt b/tests/pdo_mysql/007-pdo_error_handling_async.phpt new file mode 100644 index 0000000..1958a31 --- /dev/null +++ b/tests/pdo_mysql/007-pdo_error_handling_async.phpt @@ -0,0 +1,147 @@ +--TEST-- +PDO MySQL: Async error handling +--EXTENSIONS-- +pdo_mysql +--SKIPIF-- + +--FILE-- +query("INVALID SQL SYNTAX HERE"); + return ["type" => "sql_error", "status" => "should_not_reach", "error" => "none"]; + } catch (PDOException $e) { + $error_type = (strpos($e->getMessage(), 'syntax') !== false ? 'syntax error' : 'other error'); + return ["type" => "sql_error", "status" => "handled", "error" => $error_type]; + } catch (Exception $e) { + return ["type" => "sql_error", "status" => "general_error", "error" => $e->getMessage()]; + } +}); + +// Test invalid table error in coroutine +$coroutine2 = spawn(function() { + try { + $pdo = AsyncPDOMySQLTest::factory(); + + // Query non-existent table + $stmt = $pdo->query("SELECT * FROM non_existent_table_12345"); + return ["type" => "table_error", "status" => "should_not_reach", "error" => "none"]; + } catch (PDOException $e) { + $error_type = (strpos($e->getMessage(), "doesn't exist") !== false ? 'table not found' : 'other error'); + return ["type" => "table_error", "status" => "handled", "error" => $error_type]; + } catch (Exception $e) { + return ["type" => "table_error", "status" => "general_error", "error" => $e->getMessage()]; + } +}); + +// Test constraint violation error +$coroutine3 = spawn(function() { + try { + $pdo = AsyncPDOMySQLTest::factory(); + + // Create table with unique constraint + $pdo->exec("DROP TEMPORARY TABLE IF EXISTS error_test"); + $pdo->exec("CREATE TEMPORARY TABLE error_test (id INT PRIMARY KEY, email VARCHAR(100) UNIQUE)"); + + // Insert first record + $stmt = $pdo->prepare("INSERT INTO error_test (id, email) VALUES (?, ?)"); + $stmt->execute([1, 'test@example.com']); + + // Try to insert duplicate email (should fail) + $stmt->execute([2, 'test@example.com']); + + return ["type" => "constraint_error", "status" => "should_not_reach", "error" => "none"]; + } catch (PDOException $e) { + $error_type = (strpos($e->getMessage(), 'Duplicate') !== false ? 'duplicate entry' : 'other error'); + return ["type" => "constraint_error", "status" => "handled", "error" => $error_type]; + } catch (Exception $e) { + return ["type" => "constraint_error", "status" => "general_error", "error" => $e->getMessage()]; + } +}); + +// Test connection timeout/failure (if possible) +$coroutine4 = spawn(function() { + try { + // Try to connect to invalid host + $pdo = new PDO('mysql:host=invalid_host_12345;dbname=test', 'user', 'pass'); + return ["type" => "connection_error", "status" => "should_not_reach", "error" => "none"]; + } catch (PDOException $e) { + return ["type" => "connection_error", "status" => "handled", "error" => "connection failed"]; + } catch (Exception $e) { + return ["type" => "connection_error", "status" => "general_error", "error" => "connection failed"]; + } +}); + +echo "waiting for all error handling tests\n"; +$results = awaitAllOrFail([$coroutine1, $coroutine2, $coroutine3, $coroutine4]); + +// Sort results by type for deterministic output +usort($results, function($a, $b) { + return strcmp($a['type'], $b['type']); +}); + +echo "all error tests completed\n"; + +// Display results in sorted order +foreach ($results as $result) { + $type = $result['type']; + $status = $result['status']; + $error = $result['error']; + + if ($type === 'connection_error') { + echo "caught connection error: $error\n"; + } elseif ($type === 'constraint_error') { + echo "caught constraint error: $error\n"; + } elseif ($type === 'sql_error') { + echo "caught SQL error: $error\n"; + } elseif ($type === 'table_error') { + echo "caught table error: $error\n"; + } +} + +// Display test results +$result_mapping = [ + 'connection_error' => 'connection_error_handled', + 'constraint_error' => 'constraint_error_handled', + 'sql_error' => 'sql_error_handled', + 'table_error' => 'table_error_handled' +]; + +foreach ($results as $i => $result) { + $result_str = $result_mapping[$result['type']]; + echo "test " . ($i + 1) . ": $result_str\n"; +} + +echo "end\n"; + +?> +--EXPECT-- +start +waiting for all error handling tests +all error tests completed +caught connection error: connection failed +caught constraint error: duplicate entry +caught SQL error: syntax error +caught table error: table not found +test 1: connection_error_handled +test 2: constraint_error_handled +test 3: sql_error_handled +test 4: table_error_handled +end \ No newline at end of file diff --git a/tests/pdo_mysql/008-pdo_fetch_modes_async.phpt b/tests/pdo_mysql/008-pdo_fetch_modes_async.phpt new file mode 100644 index 0000000..1da0b01 --- /dev/null +++ b/tests/pdo_mysql/008-pdo_fetch_modes_async.phpt @@ -0,0 +1,132 @@ +--TEST-- +PDO MySQL: Async fetch modes +--EXTENSIONS-- +pdo_mysql +--SKIPIF-- + +--FILE-- +exec("DROP TEMPORARY TABLE IF EXISTS fetch_test"); + $pdo->exec("CREATE TEMPORARY TABLE fetch_test (id INT, name VARCHAR(50), age INT, email VARCHAR(100))"); + + $stmt = $pdo->prepare("INSERT INTO fetch_test (id, name, age, email) VALUES (?, ?, ?, ?)"); + $stmt->execute([1, 'Alice', 25, 'alice@example.com']); + $stmt->execute([2, 'Bob', 30, 'bob@example.com']); + $stmt->execute([3, 'Charlie', 35, 'charlie@example.com']); + echo "test data created\n"; + + // Test FETCH_ASSOC + echo "testing FETCH_ASSOC:\n"; + $stmt = $pdo->query("SELECT id, name, age FROM fetch_test WHERE id = 1"); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + foreach ($row as $key => $value) { + echo " $key: $value\n"; + } + + // Test FETCH_NUM + echo "testing FETCH_NUM:\n"; + $stmt = $pdo->query("SELECT id, name, age FROM fetch_test WHERE id = 2"); + $row = $stmt->fetch(PDO::FETCH_NUM); + for ($i = 0; $i < count($row); $i++) { + echo " [$i]: {$row[$i]}\n"; + } + + // Test FETCH_BOTH + echo "testing FETCH_BOTH:\n"; + $stmt = $pdo->query("SELECT id, name FROM fetch_test WHERE id = 3"); + $row = $stmt->fetch(PDO::FETCH_BOTH); + echo " by key 'name': {$row['name']}\n"; + echo " by index [1]: {$row[1]}\n"; + + // Test FETCH_OBJ + echo "testing FETCH_OBJ:\n"; + $stmt = $pdo->query("SELECT name, age, email FROM fetch_test WHERE id = 1"); + $obj = $stmt->fetch(PDO::FETCH_OBJ); + echo " object->name: $obj->name\n"; + echo " object->age: $obj->age\n"; + echo " object->email: $obj->email\n"; + + // Test fetchAll with FETCH_ASSOC + echo "testing fetchAll FETCH_ASSOC:\n"; + $stmt = $pdo->query("SELECT name, age FROM fetch_test ORDER BY id"); + $all_rows = $stmt->fetchAll(PDO::FETCH_ASSOC); + foreach ($all_rows as $i => $row) { + echo " row $i: {$row['name']} (age {$row['age']})\n"; + } + + // Test fetchColumn + echo "testing fetchColumn:\n"; + $stmt = $pdo->query("SELECT name FROM fetch_test ORDER BY age DESC"); + while ($name = $stmt->fetchColumn()) { + echo " name: $name\n"; + } + + // Test fetchAll with FETCH_KEY_PAIR + echo "testing FETCH_KEY_PAIR:\n"; + $stmt = $pdo->query("SELECT id, name FROM fetch_test ORDER BY id"); + $pairs = $stmt->fetchAll(PDO::FETCH_KEY_PAIR); + foreach ($pairs as $id => $name) { + echo " id $id: $name\n"; + } + + return "completed"; + } catch (Exception $e) { + echo "error: " . $e->getMessage() . "\n"; + return "failed"; + } +}); + +$result = await($coroutine); +echo "result: " . $result . "\n"; +echo "end\n"; + +?> +--EXPECT-- +start +test data created +testing FETCH_ASSOC: + id: 1 + name: Alice + age: 25 +testing FETCH_NUM: + [0]: 2 + [1]: Bob + [2]: 30 +testing FETCH_BOTH: + by key 'name': Charlie + by index [1]: Charlie +testing FETCH_OBJ: + object->name: Alice + object->age: 25 + object->email: alice@example.com +testing fetchAll FETCH_ASSOC: + row 0: Alice (age 25) + row 1: Bob (age 30) + row 2: Charlie (age 35) +testing fetchColumn: + name: Charlie + name: Bob + name: Alice +testing FETCH_KEY_PAIR: + id 1: Alice + id 2: Bob + id 3: Charlie +result: completed +end \ No newline at end of file diff --git a/tests/pdo_mysql/009-pdo_cancellation.phpt b/tests/pdo_mysql/009-pdo_cancellation.phpt new file mode 100644 index 0000000..c20e55b --- /dev/null +++ b/tests/pdo_mysql/009-pdo_cancellation.phpt @@ -0,0 +1,64 @@ +--TEST-- +PDO MySQL: Async cancellation test +--EXTENSIONS-- +pdo_mysql +--SKIPIF-- + +--FILE-- +query("SELECT SLEEP(5), 'long query completed' as message"); + $result = $stmt->fetch(PDO::FETCH_ASSOC); + + return "completed"; + } catch (Async\CancellationException $e) { + return "cancelled"; + } +}); + +// Wait a bit, then cancel the coroutine +usleep(100000); // 0.1 seconds + +echo "cancelling long query\n"; +$coroutine->cancel(); + +// Wait for the original coroutine (should be cancelled) +try { + $result = await($coroutine); + echo "original query result: " . $result . "\n"; +} catch (Async\CancellationException $e) { + echo "original query was cancelled\n"; +} + +echo "manual cancel result: cancellation_sent\n"; + +echo "end\n"; +?> +--EXPECT-- +start +starting long query +echo +cancelling long query +original query result: cancelled +manual cancel result: cancellation_sent +end \ No newline at end of file diff --git a/tests/pdo_mysql/010-pdo_resource_cleanup.phpt b/tests/pdo_mysql/010-pdo_resource_cleanup.phpt new file mode 100644 index 0000000..fdbf7f5 --- /dev/null +++ b/tests/pdo_mysql/010-pdo_resource_cleanup.phpt @@ -0,0 +1,187 @@ +--TEST-- +PDO MySQL: Async resource cleanup test +--EXTENSIONS-- +pdo_mysql +--SKIPIF-- + +--FILE-- +query("SHOW STATUS LIKE 'Threads_connected'"); + $result = $stmt->fetch(PDO::FETCH_ASSOC); + return (int) $result['Value']; +} + +$initial_connections = getConnectionCount(); +echo "initial connections: $initial_connections\n"; + +// Test resource cleanup in coroutines +$coroutines = []; + +for ($i = 1; $i <= 5; $i++) { + $coroutines[] = spawn(function() use ($i) { + try { + // Create connection + $pdo = AsyncPDOMySQLTest::factory(); + + // Get connection ID + $stmt = $pdo->query("SELECT CONNECTION_ID() as conn_id"); + $conn_info = $stmt->fetch(PDO::FETCH_ASSOC); + $conn_id = $conn_info['conn_id']; + + // Do some work + $pdo->exec("DROP TEMPORARY TABLE IF EXISTS cleanup_test_$i"); + $pdo->exec("CREATE TEMPORARY TABLE cleanup_test_$i (id INT, data VARCHAR(50))"); + + $stmt = $pdo->prepare("INSERT INTO cleanup_test_$i (id, data) VALUES (?, ?)"); + for ($j = 1; $j <= 3; $j++) { + $stmt->execute([$j, "data_$j"]); + } + + // Query the data + $stmt = $pdo->query("SELECT COUNT(*) as count FROM cleanup_test_$i"); + $count_result = $stmt->fetch(PDO::FETCH_ASSOC); + + // Explicitly close connection + $pdo = null; + + return [ + 'type' => 'explicit_cleanup', + 'coroutine_id' => $i, + 'conn_id' => $conn_id, + 'rows_inserted' => $count_result['count'], + 'status' => 'completed' + ]; + } catch (Exception $e) { + return [ + 'type' => 'explicit_cleanup', + 'coroutine_id' => $i, + 'conn_id' => null, + 'rows_inserted' => 0, + 'status' => 'failed', + 'error' => $e->getMessage() + ]; + } + }); +} + +// Test coroutine that exits without explicit cleanup +$coroutines[] = spawn(function() { + $pdo = AsyncPDOMySQLTest::factory(); + $stmt = $pdo->query("SELECT CONNECTION_ID() as conn_id"); + $conn_info = $stmt->fetch(PDO::FETCH_ASSOC); + + // Exit without calling $pdo = null (test automatic cleanup) + return [ + 'type' => 'auto_cleanup', + 'coroutine_id' => 6, + 'conn_id' => $conn_info['conn_id'], + 'rows_inserted' => 0, + 'status' => 'completed' + ]; +}); + +echo "waiting for all coroutines to complete\n"; +$results = awaitAllOrFail($coroutines); + +// Sort results by coroutine_id for deterministic output +usort($results, function($a, $b) { + return $a['coroutine_id'] - $b['coroutine_id']; +}); + +echo "all coroutines completed\n"; + +// Display results in sorted order +foreach ($results as $result) { + $id = $result['coroutine_id']; + $conn_id = $result['conn_id']; + $type = $result['type']; + $status = $result['status']; + + if ($type === 'explicit_cleanup') { + echo "coroutine $id: connection $conn_id created\n"; + if ($status === 'completed') { + echo "coroutine $id: inserted {$result['rows_inserted']} rows\n"; + echo "coroutine $id: connection $conn_id closed\n"; + } else { + echo "coroutine $id error: {$result['error']}\n"; + } + } elseif ($type === 'auto_cleanup') { + echo "coroutine $id: connection $conn_id created (no explicit cleanup)\n"; + } +} + +// Display final results summary +foreach ($results as $i => $result) { + $result_str = "coroutine_{$result['coroutine_id']}_{$result['status']}"; + echo "result " . ($i + 1) . ": $result_str\n"; +} + +// Force garbage collection +gc_collect_cycles(); +echo "garbage collection forced\n"; + +// Small delay to allow connection cleanup +usleep(100000); // 0.1 seconds + +$final_connections = getConnectionCount(); +echo "final connections: $final_connections\n"; + +$connection_diff = $final_connections - $initial_connections; +echo "connection difference: $connection_diff\n"; + +if ($connection_diff <= 1) { // Allow for our own test connection + echo "cleanup: passed\n"; +} else { + echo "cleanup: potential leak ($connection_diff extra connections)\n"; +} + +echo "end\n"; + +?> +--EXPECTF-- +start +initial connections: %d +waiting for all coroutines to complete +all coroutines completed +coroutine 1: connection %d created +coroutine 1: inserted 3 rows +coroutine 1: connection %d closed +coroutine 2: connection %d created +coroutine 2: inserted 3 rows +coroutine 2: connection %d closed +coroutine 3: connection %d created +coroutine 3: inserted 3 rows +coroutine 3: connection %d closed +coroutine 4: connection %d created +coroutine 4: inserted 3 rows +coroutine 4: connection %d closed +coroutine 5: connection %d created +coroutine 5: inserted 3 rows +coroutine 5: connection %d closed +coroutine 6: connection %d created (no explicit cleanup) +result 1: coroutine_1_completed +result 2: coroutine_2_completed +result 3: coroutine_3_completed +result 4: coroutine_4_completed +result 5: coroutine_5_completed +result 6: coroutine_6_completed +garbage collection forced +final connections: %d +connection difference: %d +cleanup: passed +end \ No newline at end of file diff --git a/tests/pdo_mysql/011-concurrent_database_operations.phpt b/tests/pdo_mysql/011-concurrent_database_operations.phpt new file mode 100644 index 0000000..37f2b20 --- /dev/null +++ b/tests/pdo_mysql/011-concurrent_database_operations.phpt @@ -0,0 +1,81 @@ +--TEST-- +PDO MySQL: Concurrent database operations with automatic setup +--EXTENSIONS-- +pdo_mysql +--SKIPIF-- + +--FILE-- +prepare("INSERT INTO {$tableName} (name, value) VALUES (?, ?)"); + $stmt->execute(['async_test_1', 'concurrent_value_1']); + echo "inserted record 1\n"; + return 1; + }), + + spawn(function() use ($tableName) { + $pdo = AsyncPDOMySQLTest::factory(); + $stmt = $pdo->prepare("INSERT INTO {$tableName} (name, value) VALUES (?, ?)"); + $stmt->execute(['async_test_2', 'concurrent_value_2']); + echo "inserted record 2\n"; + return 2; + }), + + spawn(function() use ($tableName) { + $pdo = AsyncPDOMySQLTest::factory(); + // Query existing data + $stmt = $pdo->query("SELECT COUNT(*) as count FROM {$tableName}"); + $result = $stmt->fetch(); + echo "initial count: " . $result['count'] . "\n"; + return $result['count']; + }) + ]; + + $results = awaitAllOrFail($coroutines); + + // Check final state + $stmt = $pdo->query("SELECT COUNT(*) as total FROM {$tableName}"); + $final = $stmt->fetch(); + echo "final count: " . $final['total'] . "\n"; + + // Verify all async operations completed + $stmt = $pdo->query("SELECT * FROM {$tableName} WHERE name LIKE 'async_test_%'"); + $asyncRecords = $stmt->fetchAll(); + echo "async records: " . count($asyncRecords) . "\n"; + + return "concurrent_test_passed"; +}); + +echo "test result: " . $result . "\n"; +echo "end\n"; + +?> +--EXPECTF-- +start +database initialized +initial count: 5 +%A +final count: 7 +async records: 2 +test result: concurrent_test_passed +end \ No newline at end of file diff --git a/tests/pdo_mysql/012-transaction_isolation_test.phpt b/tests/pdo_mysql/012-transaction_isolation_test.phpt new file mode 100644 index 0000000..b348c05 --- /dev/null +++ b/tests/pdo_mysql/012-transaction_isolation_test.phpt @@ -0,0 +1,118 @@ +--TEST-- +PDO MySQL: Transaction isolation with automatic database setup +--EXTENSIONS-- +pdo_mysql +--SKIPIF-- + +--FILE-- +exec("CREATE TABLE transaction_test ( + id INT PRIMARY KEY AUTO_INCREMENT, + name VARCHAR(255), + amount DECIMAL(10,2) + ) ENGINE=InnoDB"); + + $pdo->exec("INSERT INTO transaction_test (name, amount) VALUES ('account1', 100.00), ('account2', 200.00)"); + + echo "transaction test setup\n"; + + // Test concurrent transactions + $transaction1 = spawn(function() { + $pdo = AsyncPDOMySQLTest::factory(); + $pdo->beginTransaction(); + try { + $stmt = $pdo->prepare("UPDATE transaction_test SET amount = amount - 50 WHERE name = 'account1'"); + $stmt->execute(); + + $stmt = $pdo->prepare("UPDATE transaction_test SET amount = amount + 50 WHERE name = 'account2'"); + $stmt->execute(); + + $pdo->commit(); + return ['id' => 1, 'status' => 'committed']; + } catch (Exception $e) { + $pdo->rollback(); + return ['id' => 1, 'status' => 'rolled_back']; + } + }); + + $transaction2 = spawn(function() { + $pdo = AsyncPDOMySQLTest::factory(); + $pdo->beginTransaction(); + try { + $stmt = $pdo->prepare("UPDATE transaction_test SET amount = amount - 25 WHERE name = 'account2'"); + $stmt->execute(); + + $stmt = $pdo->prepare("UPDATE transaction_test SET amount = amount + 25 WHERE name = 'account1'"); + $stmt->execute(); + + $pdo->commit(); + return ['id' => 2, 'status' => 'committed']; + } catch (Exception $e) { + $pdo->rollback(); + return ['id' => 2, 'status' => 'rolled_back']; + } + }); + + $results = [await($transaction1), await($transaction2)]; + + // Sort results by transaction id for deterministic output + usort($results, function($a, $b) { + return $a['id'] - $b['id']; + }); + + // Display results in order + foreach ($results as $result) { + $id = $result['id']; + $status = $result['status']; + echo "transaction $id: debited account" . ($id == 1 ? '1' : '2') . "\n"; + echo "transaction $id: credited account" . ($id == 1 ? '2' : '1') . "\n"; + echo "transaction $id: $status\n"; + } + + // Check final balances + $stmt = $pdo->query("SELECT name, amount FROM transaction_test ORDER BY name"); + $balances = $stmt->fetchAll(); + + foreach ($balances as $balance) { + echo "final balance " . $balance['name'] . ": " . $balance['amount'] . "\n"; + } + + // Cleanup + $pdo->exec("DROP TABLE transaction_test"); + + return "transaction_isolation_passed"; +}); + +echo "test result: " . $result . "\n"; +echo "end\n"; + +?> +--EXPECT-- +start +transaction test setup +transaction 1: debited account1 +transaction 1: credited account2 +transaction 1: committed +transaction 2: debited account2 +transaction 2: credited account1 +transaction 2: committed +final balance account1: 75.00 +final balance account2: 225.00 +test result: transaction_isolation_passed +end \ No newline at end of file diff --git a/tests/pdo_mysql/013-order_independent_concurrent_test.phpt b/tests/pdo_mysql/013-order_independent_concurrent_test.phpt new file mode 100644 index 0000000..4b0cfbf --- /dev/null +++ b/tests/pdo_mysql/013-order_independent_concurrent_test.phpt @@ -0,0 +1,98 @@ +--TEST-- +PDO MySQL: Order-independent concurrent test example +--EXTENSIONS-- +pdo_mysql +--SKIPIF-- + +--FILE-- +query("SELECT 'task_a' as task, CONNECTION_ID() as conn_id"); + return $stmt->fetch(PDO::FETCH_ASSOC); +}); + +$task_b = spawn(function() { + $pdo = AsyncPDOMySQLTest::factory(); + $stmt = $pdo->query("SELECT 'task_b' as task, CONNECTION_ID() as conn_id"); + return $stmt->fetch(PDO::FETCH_ASSOC); +}); + +$task_c = spawn(function() { + $pdo = AsyncPDOMySQLTest::factory(); + $stmt = $pdo->query("SELECT 'task_c' as task, CONNECTION_ID() as conn_id"); + return $stmt->fetch(PDO::FETCH_ASSOC); +}); + +$results = [ + await($task_a), + await($task_b), + await($task_c) +]; + +// Check if we got valid results +if (empty($results)) { + echo "Error: No results received from coroutines\n"; + echo "end\n"; + exit; +} + +// Assert function - check results without depending on order +$tasks = array_map(function($r) { + return is_array($r) && isset($r['task']) ? $r['task'] : 'unknown'; +}, $results); +sort($tasks); + +$connIds = array_map(function($r) { + return is_array($r) && isset($r['conn_id']) ? $r['conn_id'] : 0; +}, $results); +$uniqueConnIds = array_unique($connIds); + +echo "tasks completed: " . implode(', ', $tasks) . "\n"; +echo "unique connections: " . count($uniqueConnIds) . "\n"; +echo "total tasks: " . count($results) . "\n"; + +// Verify we got all expected tasks +$expectedTasks = ['task_a', 'task_b', 'task_c']; +if ($tasks === $expectedTasks) { + echo "task verification: passed\n"; +} else { + echo "task verification: failed\n"; +} + +// Verify connection isolation +if (count($uniqueConnIds) === count($results)) { + echo "connection isolation: passed\n"; +} else { + echo "connection isolation: failed\n"; +} + +echo "concurrent test completed\n"; +echo "end\n"; + +?> +--EXPECT-- +start +tasks completed: task_a, task_b, task_c +unique connections: 3 +total tasks: 3 +task verification: passed +connection isolation: passed +concurrent test completed +end \ No newline at end of file diff --git a/tests/pdo_mysql/README.md b/tests/pdo_mysql/README.md new file mode 100644 index 0000000..f9389aa --- /dev/null +++ b/tests/pdo_mysql/README.md @@ -0,0 +1,121 @@ +# PDO MySQL Async Tests + +This directory contains tests for PDO MySQL functionality with the True Async extension. + +## Test Coverage + +### Basic Functionality +- **001-pdo_connection_basic.phpt**: Basic async connection and simple query +- **002-pdo_prepare_execute_async.phpt**: Prepared statements with async execution +- **003-pdo_multiple_coroutines.phpt**: Multiple coroutines with separate connections +- **004-pdo_transaction_async.phpt**: Transaction handling (BEGIN, COMMIT, ROLLBACK) +- **005-pdo_concurrent_queries.phpt**: Concurrent query execution with different connections +- **006-pdo_connection_isolation.phpt**: Connection isolation between coroutines + +### Advanced Features +- **007-pdo_error_handling_async.phpt**: Error handling in async context +- **008-pdo_fetch_modes_async.phpt**: Different fetch modes (ASSOC, NUM, OBJ, etc.) +- **009-pdo_cancellation.phpt**: Query cancellation and timeout handling +- **010-pdo_resource_cleanup.phpt**: Resource cleanup and connection management + +## Architecture + +PDO MySQL uses the MySQLND driver underneath, which integrates with True Async through the xpsocket.c module for non-blocking I/O operations. + +``` +PDO MySQL API + ↓ +MySQLND Driver (ext/mysqlnd) + ↓ +True Async (xpsocket.c) + ↓ +MySQL Server +``` + +## Key Testing Principles + +### Connection Isolation +- **Critical Rule**: One connection per coroutine +- Connections cannot be safely shared between coroutines +- Each coroutine must create its own PDO instance + +### Async Patterns +```php +use function Async\spawn; +use function Async\await; + +$coroutine = spawn(function() { + $pdo = new PDO($dsn, $user, $pass); + // async operations + return $result; +}); + +$result = await($coroutine); +``` + +### Concurrency Testing +```php +$coroutines = [ + spawn(function() { /* DB operations */ }), + spawn(function() { /* DB operations */ }), + spawn(function() { /* DB operations */ }) +]; + +$results = awaitAllOrFail($coroutines); +``` + +## Environment Variables + +Tests use standard MySQL connection environment variables: +- `PDO_MYSQL_TEST_DSN` (default: `mysql:host=localhost;dbname=test`) +- `PDO_MYSQL_TEST_USER` (default: `root`) +- `PDO_MYSQL_TEST_PASS` (default: empty) + +## Running Tests + +```bash +# Run all PDO MySQL async tests +php run-tests.php ext/async/tests/pdo_mysql/ + +# Run specific test +php run-tests.php ext/async/tests/pdo_mysql/001-pdo_connection_basic.phpt +``` + +## Test Categories + +### Connection Management +- Basic connection establishment +- Connection isolation verification +- Resource cleanup validation + +### Query Operations +- Simple queries and prepared statements +- Parameter binding and data retrieval +- Multiple fetch modes + +### Transaction Handling +- BEGIN/COMMIT/ROLLBACK in async context +- Transaction isolation between coroutines +- Error handling during transactions + +### Error Scenarios +- SQL syntax errors +- Connection failures +- Constraint violations +- Timeout handling + +### Cancellation & Cleanup +- Manual coroutine cancellation +- Timeout-based cancellation +- Automatic resource cleanup +- Memory leak prevention + +## Implementation Notes + +1. **No Connection Pooling**: Tests focus on basic functionality, not pooling +2. **MySQLND Integration**: Tests verify async behavior through MySQLND driver +3. **Standard PDO API**: Tests use standard PDO methods in async context +4. **Resource Management**: Tests verify proper cleanup of connections and statements +5. **Error Propagation**: Tests ensure errors are properly caught in async context + +These tests ensure that PDO MySQL works correctly with True Async while maintaining data integrity and resource management. \ No newline at end of file diff --git a/tests/pdo_mysql/inc/async_pdo_mysql_test.inc b/tests/pdo_mysql/inc/async_pdo_mysql_test.inc new file mode 100644 index 0000000..fb8f76b --- /dev/null +++ b/tests/pdo_mysql/inc/async_pdo_mysql_test.inc @@ -0,0 +1,245 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]; + + $options = array_merge($defaultOptions, $options); + + return new PDO($dsn, $user, $pass, $options); + } + + /** + * Create a PDO connection without specifying database (for database creation) + */ + static function factoryWithoutDB($host = null, $user = null, $pass = null, $options = array()) { + $host = $host ?: MYSQL_TEST_HOST; + $user = $user ?: MYSQL_TEST_USER; + $pass = $pass ?: MYSQL_TEST_PASSWD; + $port = MYSQL_TEST_PORT; + $socket = MYSQL_TEST_SOCKET; + $charset = MYSQL_TEST_CHARSET; + + $dsn = "mysql:host={$host}"; + if ($port) { + $dsn .= ";port={$port}"; + } + if ($socket) { + $dsn .= ";unix_socket={$socket}"; + } + if ($charset) { + $dsn .= ";charset={$charset}"; + } + + $defaultOptions = [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]; + + $options = array_merge($defaultOptions, $options); + + return new PDO($dsn, $user, $pass, $options); + } + + /** + * Initialize database with test schema + */ + static function initDatabase($pdo = null) { + if (!$pdo) { + // First connect without database to create it + $pdo = self::factoryWithoutDB(); + } + + // Create test database if it doesn't exist + $dbName = MYSQL_TEST_DB; + $pdo->exec("CREATE DATABASE IF NOT EXISTS `{$dbName}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); + $pdo->exec("USE `{$dbName}`"); + + return $pdo; + } + + /** + * Create a test table for async tests + */ + static function createTestTable($pdo, $tableName = 'async_test', $engine = null) { + if (!$engine) { + $engine = MYSQL_TEST_ENGINE; + } + + $pdo->exec("DROP TABLE IF EXISTS `{$tableName}`"); + $pdo->exec("CREATE TABLE `{$tableName}` ( + id INT PRIMARY KEY AUTO_INCREMENT, + name VARCHAR(255) NOT NULL, + value TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) ENGINE={$engine}"); + + // Insert test data + $pdo->exec("INSERT INTO `{$tableName}` (name, value) VALUES + ('test1', 'value1'), + ('test2', 'value2'), + ('test3', 'value3'), + ('test4', 'value4'), + ('test5', 'value5') + "); + + return $tableName; + } + + /** + * Clean up test tables + */ + static function cleanupTestTable($pdo, $tableName = 'async_test') { + $pdo->exec("DROP TABLE IF EXISTS `{$tableName}`"); + } + + /** + * Check if MySQL server is available + */ + static function skip() { + try { + // Connect without database first to test server availability + $pdo = self::factoryWithoutDB(); + $pdo->query("SELECT 1"); + } catch (Exception $e) { + die('skip MySQL server not available or configuration invalid: ' . $e->getMessage()); + } + } + + /** + * Check if async extension is loaded + */ + static function skipIfNoAsync() { + } + + /** + * Check if PDO MySQL is available + */ + static function skipIfNoPDOMySQL() { + if (!extension_loaded('pdo_mysql')) { + die('skip pdo_mysql extension not loaded'); + } + } + + /** + * Skip if MySQL version is less than required + */ + static function skipIfMySQLVersionLess($requiredVersion) { + $pdo = self::factoryWithoutDB(); + $stmt = $pdo->query("SELECT VERSION() as version"); + $row = $stmt->fetch(); + + if (version_compare($row['version'], $requiredVersion, '<')) { + die("skip MySQL version {$requiredVersion} or higher required, found {$row['version']}"); + } + } + + /** + * Get MySQL version + */ + static function getMySQLVersion($pdo = null) { + if (!$pdo) { + $pdo = self::factoryWithoutDB(); + } + + $stmt = $pdo->query("SELECT VERSION() as version"); + $row = $stmt->fetch(); + return $row['version']; + } + + /** + * Run concurrent async tests without depending on execution order + */ + static function runConcurrentTest($coroutines, $assertCallback = null) { + $pdo = self::initDatabase(); + + try { + $results = awaitAllOrFail($coroutines); + + if ($assertCallback) { + $assertCallback($results); + } + + return $results; + + } catch (Exception $e) { + throw $e; + } + } + + /** + * Run async test with proper setup and cleanup + */ + static function runAsyncTest($testCallback, $tableName = 'async_test', $setupCallback = null, $cleanupCallback = null) { + $pdo = self::initDatabase(); + + try { + // Setup + if ($setupCallback) { + $setupCallback($pdo); + } else { + self::createTestTable($pdo, $tableName); + } + + // Run test in coroutine + $coroutine = spawn(function() use ($testCallback, $pdo, $tableName) { + return $testCallback($pdo, $tableName); + }); + + $result = await($coroutine); + + // Cleanup + if ($cleanupCallback) { + $cleanupCallback($pdo); + } else { + self::cleanupTestTable($pdo, $tableName); + } + + return $result; + + } catch (Exception $e) { + // Ensure cleanup on error + if ($cleanupCallback) { + $cleanupCallback($pdo); + } else { + self::cleanupTestTable($pdo, $tableName); + } + throw $e; + } + } +} +?> \ No newline at end of file diff --git a/tests/pdo_mysql/inc/config.inc b/tests/pdo_mysql/inc/config.inc new file mode 100644 index 0000000..353afa3 --- /dev/null +++ b/tests/pdo_mysql/inc/config.inc @@ -0,0 +1,18 @@ + false !== getenv('MYSQL_TEST_HOST') ? getenv('MYSQL_TEST_HOST') : 'localhost', + 'MYSQL_TEST_PORT' => false !== getenv('MYSQL_TEST_PORT') ? getenv('MYSQL_TEST_PORT') : '3306', + 'MYSQL_TEST_USER' => false !== getenv('MYSQL_TEST_USER') ? getenv('MYSQL_TEST_USER') : 'root', + 'MYSQL_TEST_PASSWD' => false !== getenv('MYSQL_TEST_PASSWD') ? getenv('MYSQL_TEST_PASSWD') : '', + 'MYSQL_TEST_DB' => false !== getenv('MYSQL_TEST_DB') ? getenv('MYSQL_TEST_DB') : 'test', + 'MYSQL_TEST_SOCKET' => false !== getenv('MYSQL_TEST_SOCKET') ? getenv('MYSQL_TEST_SOCKET') : null, + 'MYSQL_TEST_CHARSET' => false !== getenv('MYSQL_TEST_CHARSET') ? getenv('MYSQL_TEST_CHARSET') : 'utf8', + 'MYSQL_TEST_ENGINE' => false !== getenv('MYSQL_TEST_ENGINE') ? getenv('MYSQL_TEST_ENGINE') : 'InnoDB', +]; + +// Define constants for tests +foreach ($env as $k => $v) { + define($k, $v); +} +?> \ No newline at end of file diff --git a/tests/pdo_mysql/inc/database_setup.php b/tests/pdo_mysql/inc/database_setup.php new file mode 100755 index 0000000..87d78a8 --- /dev/null +++ b/tests/pdo_mysql/inc/database_setup.php @@ -0,0 +1,118 @@ +#!/usr/bin/env php + PDO::ERRMODE_EXCEPTION] + ); + + // Create database + $dbName = MYSQL_TEST_DB; + $pdo->exec("CREATE DATABASE IF NOT EXISTS `{$dbName}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); + echo "Database '{$dbName}' created successfully.\n"; + + // Initialize with test schema + $pdo = AsyncPDOMySQLTest::initDatabase($pdo); + echo "Test database initialized.\n"; + + } catch (Exception $e) { + echo "Error creating database: " . $e->getMessage() . "\n"; + exit(1); + } +} + +function dropDatabase() { + echo "Dropping test database...\n"; + + try { + $pdo = new PDO( + "mysql:host=" . MYSQL_TEST_HOST . ";port=" . MYSQL_TEST_PORT, + MYSQL_TEST_USER, + MYSQL_TEST_PASSWD, + [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION] + ); + + $dbName = MYSQL_TEST_DB; + $pdo->exec("DROP DATABASE IF EXISTS `{$dbName}`"); + echo "Database '{$dbName}' dropped successfully.\n"; + + } catch (Exception $e) { + echo "Error dropping database: " . $e->getMessage() . "\n"; + exit(1); + } +} + +function resetDatabase() { + echo "Resetting test database...\n"; + dropDatabase(); + createDatabase(); + echo "Database reset completed.\n"; +} + +function testConnection() { + echo "Testing database connection...\n"; + + try { + $pdo = AsyncPDOMySQLTest::factory(); + $stmt = $pdo->query("SELECT 1 as test"); + $result = $stmt->fetch(); + + if ($result['test'] == 1) { + echo "Connection successful!\n"; + echo "MySQL version: " . AsyncPDOMySQLTest::getMySQLVersion($pdo) . "\n"; + } else { + echo "Connection test failed.\n"; + exit(1); + } + + } catch (Exception $e) { + echo "Connection failed: " . $e->getMessage() . "\n"; + exit(1); + } +} + +// Parse command line arguments +$action = $argv[1] ?? 'help'; + +switch ($action) { + case 'create': + createDatabase(); + break; + case 'drop': + dropDatabase(); + break; + case 'reset': + resetDatabase(); + break; + case 'test': + testConnection(); + break; + case 'help': + default: + printUsage(); + break; +} +?> \ No newline at end of file