diff --git a/.gemini/GEMINI.md b/.gemini/GEMINI.md new file mode 100644 index 0000000..59a33e4 --- /dev/null +++ b/.gemini/GEMINI.md @@ -0,0 +1,27 @@ +# FlightPHP/Core Project Instructions + +## Overview +This is the main FlightPHP core library for building fast, simple, and extensible PHP web applications. It is dependency-free for core usage and supports PHP 7.4+. + +## Project Guidelines +- PHP 7.4 must be supported. PHP 8 or greater also supported, but avoid PHP 8+ only features. +- Keep the core library dependency-free (no polyfills or interface-only repositories). +- All Flight projects are meant to be kept simple and fast. Performance is a priority. +- Flight is extensible and when implementing new features, consider how they can be added as plugins or extensions rather than bloating the core library. +- Any new features built into the core should be well-documented and tested. +- Any new features should be added with a focus on simplicity and performance, avoiding unnecessary complexity. +- This is not a Laravel, Yii, Code Igniter or Symfony clone. It is a simple, fast, and extensible framework that allows you to build applications quickly without the overhead of large frameworks. + +## Development & Testing +- Run tests: `composer test` (uses phpunit/phpunit and spatie/phpunit-watcher) +- Run test server: `composer test-server` or `composer test-server-v2` +- Lint code: `composer lint` (uses phpstan/phpstan, level 6) +- Beautify code: `composer beautify` (uses squizlabs/php_codesniffer, PSR1) +- Check code style: `composer phpcs` +- Test coverage: `composer test-coverage` + +## Coding Standards +- Follow PSR1 coding standards (enforced by PHPCS) +- Use strict comparisons (`===`, `!==`) +- PHPStan level 6 compliance +- Focus on PHP 7.4 compatibility (avoid PHP 8+ only features) diff --git a/composer.json b/composer.json index 1b2defb..ae0925e 100644 --- a/composer.json +++ b/composer.json @@ -61,16 +61,21 @@ "sort-packages": true }, "scripts": { - "test": "phpunit", - "test-watcher": "phpunit-watcher watch", - "test-ci": "phpunit", - "test-coverage": "rm -f clover.xml && XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-html=coverage --coverage-clover=clover.xml && vendor/bin/coverage-check clover.xml 100", - "test-server": "echo \"Running Test Server\" && php -S localhost:8000 -t tests/server/", - "test-server-v2": "echo \"Running Test Server\" && php -S localhost:8000 -t tests/server-v2/", + "test": "@php vendor/bin/phpunit", + "test-watcher": "@php vendor/bin/phpunit-watcher watch", + "test-ci": "@php vendor/bin/phpunit", + "test-coverage": [ + "rm -f clover.xml", + "@putenv XDEBUG_MODE=coverage", + "@php vendor/bin/phpunit --coverage-html=coverage --coverage-clover=clover.xml", + "@php vendor/bin/coverage-check clover.xml 100" + ], + "test-server": "echo \"Running Test Server\" && @php -S localhost:8000 -t tests/server/", + "test-server-v2": "echo \"Running Test Server\" && @php -S localhost:8000 -t tests/server-v2/", "test-coverage:win": "del clover.xml && phpunit --coverage-html=coverage --coverage-clover=clover.xml && coverage-check clover.xml 100", "test-performance": [ "echo \"Running Performance Tests...\"", - "php -S localhost:8077 -t tests/performance/ > /dev/null 2>&1 & echo $! > server.pid", + "@php -S localhost:8077 -t tests/performance/ > /dev/null 2>&1 & echo $! > server.pid", "sleep 2", "bash tests/performance/performance_tests.sh", "kill `cat server.pid`", diff --git a/flight/database/PdoWrapper.php b/flight/database/PdoWrapper.php index ff13a4c..164946c 100644 --- a/flight/database/PdoWrapper.php +++ b/flight/database/PdoWrapper.php @@ -9,6 +9,9 @@ use flight\util\Collection; use PDO; use PDOStatement; +/** + * @deprecated version 3.18.0 - Use SimplePdo instead + */ class PdoWrapper extends PDO { /** @var bool $trackApmQueries Whether to track application performance metrics (APM) for queries. */ diff --git a/flight/database/SimplePdo.php b/flight/database/SimplePdo.php new file mode 100644 index 0000000..fe1bb0e --- /dev/null +++ b/flight/database/SimplePdo.php @@ -0,0 +1,427 @@ +|null $pdoOptions An array of options for the PDO connection. + * @param array $options An array of options for the SimplePdo class + */ + public function __construct( + ?string $dsn = null, + ?string $username = null, + ?string $password = null, + ?array $pdoOptions = null, + array $options = [] + ) { + // Set default fetch mode if not provided in pdoOptions + if (isset($pdoOptions[PDO::ATTR_DEFAULT_FETCH_MODE]) === false) { + $pdoOptions = $pdoOptions ?? []; + $pdoOptions[PDO::ATTR_DEFAULT_FETCH_MODE] = PDO::FETCH_ASSOC; + } + + // Pass to parent (PdoWrapper) constructor + parent::__construct($dsn, $username, $password, $pdoOptions, false); // APM off by default here + + // Modern defaults – override parent's behavior where needed + $defaults = [ + 'trackApmQueries' => false, // still optional + 'maxQueryMetrics' => 1000, + ]; + + $options = array_merge($defaults, $options); + + $this->trackApmQueries = (bool) $options['trackApmQueries']; + $this->maxQueryMetrics = (int) $options['maxQueryMetrics']; + + // If APM is enabled, pull connection metrics (same as parent) + if ($this->trackApmQueries && $dsn !== null) { + $this->connectionMetrics = $this->pullDataFromDsn($dsn); + } + } + + /** + * Pulls one row from the query + * + * Ex: $row = $db->fetchRow("SELECT * FROM table WHERE something = ?", [ $something ]); + * + * @param string $sql - Ex: "SELECT * FROM table WHERE something = ?" + * @param array $params - Ex: [ $something ] + * + * @return ?Collection + */ + public function fetchRow(string $sql, array $params = []): ?Collection + { + // Smart LIMIT 1 addition (avoid if already present at end or complex query) + if (!preg_match('/\sLIMIT\s+\d+(?:\s+OFFSET\s+\d+)?\s*$/i', trim($sql))) { + $sql .= ' LIMIT 1'; + } + + $results = $this->fetchAll($sql, $params); + + return $results ? $results[0] : null; + } + + /** + * Don't worry about this guy. Converts stuff for IN statements + * + * Ex: $row = $db->fetchAll("SELECT * FROM table WHERE id = ? AND something IN(?), [ $id, [1,2,3] ]); + * Converts this to "SELECT * FROM table WHERE id = ? AND something IN(?,?,?)" + * + * @param string $sql the sql statement + * @param array $params the params for the sql statement + * + * @return array> + */ + protected function processInStatementSql(string $sql, array $params = []): array + { + // First, find all placeholders (?) in the original SQL and their positions + // We need to track which are IN(?) patterns vs regular ? + $originalSql = $sql; + $newParams = []; + $paramIndex = 0; + + // Find all ? positions and whether they're part of IN(?) + $pattern = '/IN\s*\(\s*\?\s*\)/i'; + $inPositions = []; + if (preg_match_all($pattern, $originalSql, $matches, PREG_OFFSET_CAPTURE)) { + foreach ($matches[0] as $match) { + $inPositions[] = $match[1]; + } + } + + // Process from right to left so string positions don't shift + $inPositions = array_reverse($inPositions); + + // First, figure out which param indices correspond to IN(?) patterns + $questionMarkPositions = []; + $pos = 0; + while (($pos = strpos($originalSql, '?', $pos)) !== false) { + $questionMarkPositions[] = $pos; + $pos++; + } + + // Map each ? position to whether it's inside an IN() + $inParamIndices = []; + foreach ($inPositions as $inPos) { + // Find which ? is inside this IN() + foreach ($questionMarkPositions as $idx => $qPos) { + if ($qPos > $inPos && $qPos < $inPos + 20) { // IN(?) is typically under 20 chars + $inParamIndices[$idx] = true; + break; + } + } + } + + // Now build the new SQL and params + $newSql = $originalSql; + $offset = 0; + + // Process each param + for ($i = 0; $i < count($params); $i++) { + if (isset($inParamIndices[$i])) { + $value = $params[$i]; + + // Find the next IN(?) in the remaining SQL + if (preg_match($pattern, $newSql, $match, PREG_OFFSET_CAPTURE, $offset)) { + $matchPos = $match[0][1]; + $matchLen = strlen($match[0][0]); + + if (!is_array($value)) { + // Single value, keep as-is + $newParams[] = $value; + $newSql = substr_replace($newSql, 'IN(?)', $matchPos, $matchLen); + $offset = $matchPos + 5; + } elseif (count($value) === 0) { + // Empty array + $newSql = substr_replace($newSql, 'IN(NULL)', $matchPos, $matchLen); + $offset = $matchPos + 8; + } else { + // Expand array + $placeholders = implode(',', array_fill(0, count($value), '?')); + $replacement = "IN($placeholders)"; + $newSql = substr_replace($newSql, $replacement, $matchPos, $matchLen); + $newParams = array_merge($newParams, $value); + $offset = $matchPos + strlen($replacement); + } + } + } else { + $newParams[] = $params[$i]; + } + } + + return ['sql' => $newSql, 'params' => $newParams]; + } + + /** + * Use this for INSERTS, UPDATES, or if you plan on using a SELECT in a while loop + * + * Ex: $statement = $db->runQuery("SELECT * FROM table WHERE something = ?", [ $something ]); + * while($row = $statement->fetch()) { + * // ... + * } + * + * $db->runQuery("INSERT INTO table (name) VALUES (?)", [ $name ]); + * $db->runQuery("UPDATE table SET name = ? WHERE id = ?", [ $name, $id ]); + * + * @param string $sql - Ex: "SELECT * FROM table WHERE something = ?" + * @param array $params - Ex: [ $something ] + * + * @return PDOStatement + */ + public function runQuery(string $sql, array $params = []): PDOStatement + { + $processed = $this->processInStatementSql($sql, $params); + $sql = $processed['sql']; + $params = $processed['params']; + + $start = $this->trackApmQueries ? microtime(true) : 0; + $memoryStart = $this->trackApmQueries ? memory_get_usage() : 0; + + $stmt = $this->prepare($sql); + if ($stmt === false) { + throw new PDOException( + "Prepare failed: " . ($this->errorInfo()[2] ?? 'Unknown error') + ); + } + + $stmt->execute($params); + + if ($this->trackApmQueries) { + $this->queryMetrics[] = [ + 'sql' => $sql, + 'params' => $params, + 'execution_time' => microtime(true) - $start, + 'row_count' => $stmt->rowCount(), + 'memory_usage' => memory_get_usage() - $memoryStart + ]; + + // Cap to prevent memory leak in long-running processes + if (count($this->queryMetrics) > $this->maxQueryMetrics) { + array_shift($this->queryMetrics); + } + } + + return $stmt; + } + + /** + * Pulls all rows from the query + * + * Ex: $rows = $db->fetchAll("SELECT * FROM table WHERE something = ?", [ $something ]); + * foreach($rows as $row) { + * // ... + * } + * + * @param string $sql - Ex: "SELECT * FROM table WHERE something = ?" + * @param array $params - Ex: [ $something ] + * + * @return array> + */ + public function fetchAll(string $sql, array $params = []): array + { + $stmt = $this->runQuery($sql, $params); // Already processes IN statements and tracks metrics + $results = $stmt->fetchAll(PDO::FETCH_ASSOC); + return array_map(fn($row) => new Collection($row), $results); + } + + /** + * Fetch a single column as an array + * + * Ex: $ids = $db->fetchColumn("SELECT id FROM users WHERE active = ?", [1]); + * + * @param string $sql + * @param array $params + * @return array + */ + public function fetchColumn(string $sql, array $params = []): array + { + $stmt = $this->runQuery($sql, $params); + return $stmt->fetchAll(PDO::FETCH_COLUMN, 0); + } + + /** + * Fetch results as key-value pairs (first column as key, second as value) + * + * Ex: $userNames = $db->fetchPairs("SELECT id, name FROM users"); + * + * @param string $sql + * @param array $params + * @return array + */ + public function fetchPairs(string $sql, array $params = []): array + { + $stmt = $this->runQuery($sql, $params); + return $stmt->fetchAll(PDO::FETCH_KEY_PAIR); + } + + /** + * Execute a callback within a transaction + * + * Ex: $db->transaction(function($db) { + * $db->runQuery("INSERT INTO users (name) VALUES (?)", ['John']); + * $db->runQuery("INSERT INTO logs (action) VALUES (?)", ['user_created']); + * return $db->lastInsertId(); + * }); + * + * @param callable $callback + * @return mixed The return value of the callback + * @throws \Throwable + */ + public function transaction(callable $callback) + { + $this->beginTransaction(); + try { + $result = $callback($this); + $this->commit(); + return $result; + } catch (\Throwable $e) { + $this->rollBack(); + throw $e; + } + } + + /** + * Insert one or more rows and return the last insert ID + * + * Single insert: + * $id = $db->insert('users', ['name' => 'John', 'email' => 'john@example.com']); + * + * Bulk insert: + * $id = $db->insert('users', [ + * ['name' => 'John', 'email' => 'john@example.com'], + * ['name' => 'Jane', 'email' => 'jane@example.com'], + * ]); + * + * @param string $table + * @param array|array> $data Single row or array of rows + * @return string Last insert ID (for single insert or last row of bulk insert) + */ + public function insert(string $table, array $data): string + { + // Detect if this is a bulk insert (array of arrays) + $isBulk = isset($data[0]) && is_array($data[0]); + + if ($isBulk) { + // Bulk insert + if (empty($data[0])) { + throw new PDOException("Cannot perform bulk insert with empty data array"); + } + + // Use first row to determine columns + $firstRow = $data[0]; + $columns = array_keys($firstRow); + $columnCount = count($columns); + + // Validate all rows have same columns + foreach ($data as $index => $row) { + if (count($row) !== $columnCount) { + throw new PDOException( + "Row $index has " . count($row) . " columns, expected $columnCount" + ); + } + } + + // Build placeholders for multiple rows: (?,?), (?,?), (?,?) + $rowPlaceholder = '(' . implode(',', array_fill(0, $columnCount, '?')) . ')'; + $allPlaceholders = implode(', ', array_fill(0, count($data), $rowPlaceholder)); + + $sql = sprintf( + "INSERT INTO %s (%s) VALUES %s", + $table, + implode(', ', $columns), + $allPlaceholders + ); + + // Flatten all row values into a single params array + $params = []; + foreach ($data as $row) { + $params = array_merge($params, array_values($row)); + } + + $this->runQuery($sql, $params); + } else { + // Single insert + $columns = array_keys($data); + $placeholders = array_fill(0, count($data), '?'); + + $sql = sprintf( + "INSERT INTO %s (%s) VALUES (%s)", + $table, + implode(', ', $columns), + implode(', ', $placeholders) + ); + + $this->runQuery($sql, array_values($data)); + } + + return $this->lastInsertId(); + } + + /** + * Update rows and return the number of affected rows + * + * Ex: $affected = $db->update('users', ['name' => 'Jane'], 'id = ?', [1]); + * + * Note: SQLite's rowCount() returns the number of rows where data actually changed. + * If you UPDATE a row with the same values it already has, rowCount() will return 0. + * This differs from MySQL's behavior when using PDO::MYSQL_ATTR_FOUND_ROWS. + * + * @param string $table + * @param array $data + * @param string $where - e.g., "id = ?" + * @param array $whereParams + * @return int Number of affected rows (rows where data actually changed) + */ + public function update(string $table, array $data, string $where, array $whereParams = []): int + { + $sets = []; + foreach (array_keys($data) as $column) { + $sets[] = "$column = ?"; + } + + $sql = sprintf( + "UPDATE %s SET %s WHERE %s", + $table, + implode(', ', $sets), + $where + ); + + $params = array_merge(array_values($data), $whereParams); + $stmt = $this->runQuery($sql, $params); + return $stmt->rowCount(); + } + + /** + * Delete rows and return the number of deleted rows + * + * Ex: $deleted = $db->delete('users', 'id = ?', [1]); + * + * @param string $table + * @param string $where - e.g., "id = ?" + * @param array $whereParams + * @return int Number of deleted rows + */ + public function delete(string $table, string $where, array $whereParams = []): int + { + $sql = "DELETE FROM $table WHERE $where"; + $stmt = $this->runQuery($sql, $whereParams); + return $stmt->rowCount(); + } + +} \ No newline at end of file diff --git a/tests/SimplePdoTest.php b/tests/SimplePdoTest.php new file mode 100644 index 0000000..8d678d7 --- /dev/null +++ b/tests/SimplePdoTest.php @@ -0,0 +1,448 @@ +db = new SimplePdo('sqlite::memory:'); + // Create a test table and insert 3 rows of data + $this->db->exec('CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)'); + $this->db->exec('INSERT INTO users (name, email) VALUES ("John", "john@example.com")'); + $this->db->exec('INSERT INTO users (name, email) VALUES ("Jane", "jane@example.com")'); + $this->db->exec('INSERT INTO users (name, email) VALUES ("Bob", "bob@example.com")'); + } + + protected function tearDown(): void + { + $this->db->exec('DROP TABLE users'); + } + + // ========================================================================= + // Constructor Tests + // ========================================================================= + + public function testDefaultFetchModeIsAssoc(): void + { + $fetchMode = $this->db->getAttribute(PDO::ATTR_DEFAULT_FETCH_MODE); + $this->assertEquals(PDO::FETCH_ASSOC, $fetchMode); + } + + public function testCustomFetchModeCanBeSet(): void + { + $db = new SimplePdo('sqlite::memory:', null, null, [ + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_OBJ + ]); + $fetchMode = $db->getAttribute(PDO::ATTR_DEFAULT_FETCH_MODE); + $this->assertEquals(PDO::FETCH_OBJ, $fetchMode); + } + + public function testApmTrackingOffByDefault(): void + { + // APM is off by default, so logQueries should not trigger events + // We test this indirectly by calling logQueries and ensuring no error + $this->db->logQueries(); + $this->assertTrue(true); // If we get here, no exception was thrown + } + + public function testApmTrackingCanBeEnabled(): void + { + $db = new SimplePdo('sqlite::memory:', null, null, null, [ + 'trackApmQueries' => true + ]); + $db->exec('CREATE TABLE test (id INTEGER PRIMARY KEY)'); + $db->runQuery('SELECT * FROM test'); + $db->logQueries(); // Should work without error + $this->assertTrue(true); + } + + // ========================================================================= + // runQuery Tests + // ========================================================================= + + public function testRunQueryReturnsStatement(): void + { + $stmt = $this->db->runQuery('SELECT * FROM users'); + $this->assertInstanceOf(PDOStatement::class, $stmt); + } + + public function testRunQueryWithParams(): void + { + $stmt = $this->db->runQuery('SELECT * FROM users WHERE name = ?', ['John']); + $rows = $stmt->fetchAll(); + $this->assertCount(1, $rows); + $this->assertEquals('John', $rows[0]['name']); + } + + public function testRunQueryWithoutParamsWithMaxQueryMetrics(): void + { + $db = new class ('sqlite::memory:', null, null, null, ['maxQueryMetrics' => 2, 'trackApmQueries' => true]) extends SimplePdo { + public function getQueryMetrics(): array + { + return $this->queryMetrics; + } + }; + $db->exec('CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)'); + $db->exec('INSERT INTO users (name, email) VALUES ("John", "john@example.com")'); + $db->exec('INSERT INTO users (name, email) VALUES ("Jane", "jane@example.com")'); + $db->exec('INSERT INTO users (name, email) VALUES ("Bob", "bob@example.com")'); + + $db->runQuery('SELECT * FROM users WHERE 1 = 1'); + $db->runQuery('SELECT * FROM users WHERE 1 = 2'); + $this->assertEquals(2, count($db->getQueryMetrics())); + $db->runQuery('SELECT * FROM users WHERE 1 = 3'); + $dbMetrics = $db->getQueryMetrics(); + $this->assertEquals(2, count($dbMetrics)); + $this->assertEquals('SELECT * FROM users WHERE 1 = 2', $dbMetrics[0]['sql']); + $this->assertEquals('SELECT * FROM users WHERE 1 = 3', $dbMetrics[1]['sql']); + } + + public function testRunQueryInsert(): void + { + $stmt = $this->db->runQuery('INSERT INTO users (name, email) VALUES (?, ?)', ['Alice', 'alice@example.com']); + $this->assertEquals(1, $stmt->rowCount()); + } + + public function testRunQueryThrowsOnPrepareFailure(): void + { + $this->expectException(PDOException::class); + $this->db->runQuery('SELECT * FROM nonexistent_table'); + } + + // ========================================================================= + // fetchRow Tests + // ========================================================================= + + public function testFetchRowReturnsCollection(): void + { + $row = $this->db->fetchRow('SELECT * FROM users WHERE id = ?', [1]); + $this->assertInstanceOf(Collection::class, $row); + $this->assertEquals('John', $row['name']); + } + + public function testFetchRowReturnsNullWhenNoResults(): void + { + $row = $this->db->fetchRow('SELECT * FROM users WHERE id = ?', [999]); + $this->assertNull($row); + } + + public function testFetchRowAddsLimitAutomatically(): void + { + // Even though there are 3 rows, fetchRow should only return 1 + $row = $this->db->fetchRow('SELECT * FROM users'); + $this->assertInstanceOf(Collection::class, $row); + $this->assertEquals(1, $row['id']); + } + + public function testFetchRowDoesNotDuplicateLimitClause(): void + { + // Query already has LIMIT - should not add another + $row = $this->db->fetchRow('SELECT * FROM users ORDER BY id DESC LIMIT 1'); + $this->assertInstanceOf(Collection::class, $row); + $this->assertEquals(3, $row['id']); // Should be Bob (id=3) + } + + // ========================================================================= + // fetchAll Tests + // ========================================================================= + + public function testFetchAllReturnsArrayOfCollections(): void + { + $rows = $this->db->fetchAll('SELECT * FROM users'); + $this->assertIsArray($rows); + $this->assertCount(3, $rows); + $this->assertInstanceOf(Collection::class, $rows[0]); + } + + public function testFetchAllReturnsEmptyArrayWhenNoResults(): void + { + $rows = $this->db->fetchAll('SELECT * FROM users WHERE 1 = 0'); + $this->assertIsArray($rows); + $this->assertCount(0, $rows); + } + + public function testFetchAllWithParams(): void + { + $rows = $this->db->fetchAll('SELECT * FROM users WHERE name LIKE ?', ['J%']); + $this->assertCount(2, $rows); // John and Jane + } + + // ========================================================================= + // fetchColumn Tests + // ========================================================================= + + public function testFetchColumnReturnsFlatArray(): void + { + $names = $this->db->fetchColumn('SELECT name FROM users ORDER BY id'); + $this->assertIsArray($names); + $this->assertEquals(['John', 'Jane', 'Bob'], $names); + } + + public function testFetchColumnWithParams(): void + { + $ids = $this->db->fetchColumn('SELECT id FROM users WHERE name LIKE ?', ['J%']); + $this->assertEquals([1, 2], $ids); + } + + public function testFetchColumnReturnsEmptyArrayWhenNoResults(): void + { + $result = $this->db->fetchColumn('SELECT id FROM users WHERE 1 = 0'); + $this->assertEquals([], $result); + } + + // ========================================================================= + // fetchPairs Tests + // ========================================================================= + + public function testFetchPairsReturnsKeyValueArray(): void + { + $pairs = $this->db->fetchPairs('SELECT id, name FROM users ORDER BY id'); + $this->assertEquals([1 => 'John', 2 => 'Jane', 3 => 'Bob'], $pairs); + } + + public function testFetchPairsWithParams(): void + { + $pairs = $this->db->fetchPairs('SELECT id, email FROM users WHERE name = ?', ['John']); + $this->assertEquals([1 => 'john@example.com'], $pairs); + } + + public function testFetchPairsReturnsEmptyArrayWhenNoResults(): void + { + $pairs = $this->db->fetchPairs('SELECT id, name FROM users WHERE 1 = 0'); + $this->assertEquals([], $pairs); + } + + // ========================================================================= + // IN Statement Processing Tests + // ========================================================================= + + public function testInStatementWithArrayOfIntegers(): void + { + $rows = $this->db->fetchAll('SELECT * FROM users WHERE id IN(?)', [[1, 2]]); + $this->assertCount(2, $rows); + } + + public function testInStatementWithArrayOfStrings(): void + { + $rows = $this->db->fetchAll('SELECT * FROM users WHERE name IN(?)', [['John', 'Jane']]); + $this->assertCount(2, $rows); + } + + public function testInStatementWithEmptyArray(): void + { + $rows = $this->db->fetchAll('SELECT * FROM users WHERE id IN(?)', [[]]); + $this->assertCount(0, $rows); // IN(NULL) matches nothing + } + + public function testInStatementWithSingleValue(): void + { + $rows = $this->db->fetchAll('SELECT * FROM users WHERE id IN(?)', [1]); + $this->assertCount(1, $rows); + } + + public function testMultipleInStatements(): void + { + $rows = $this->db->fetchAll( + 'SELECT * FROM users WHERE id IN(?) AND name IN(?)', + [[1, 2, 3], ['John', 'Bob']] + ); + $this->assertCount(2, $rows); // John (id=1) and Bob (id=3) + } + + public function testInStatementWithOtherParams(): void + { + $rows = $this->db->fetchAll( + 'SELECT * FROM users WHERE id > ? AND name IN(?)', + [0, ['John', 'Jane']] + ); + $this->assertCount(2, $rows); + } + + // ========================================================================= + // insert() Tests + // ========================================================================= + + public function testInsertSingleRow(): void + { + $id = $this->db->insert('users', ['name' => 'Alice', 'email' => 'alice@example.com']); + $this->assertEquals('4', $id); + + $row = $this->db->fetchRow('SELECT * FROM users WHERE id = ?', [$id]); + $this->assertEquals('Alice', $row['name']); + $this->assertEquals('alice@example.com', $row['email']); + } + + public function testInsertBulkRows(): void + { + $id = $this->db->insert('users', [ + ['name' => 'Alice', 'email' => 'alice@example.com'], + ['name' => 'Charlie', 'email' => 'charlie@example.com'], + ]); + + // Last insert ID should be 5 (Charlie) + $this->assertEquals('5', $id); + + // Verify both rows were inserted + $rows = $this->db->fetchAll('SELECT * FROM users WHERE id > 3 ORDER BY id'); + $this->assertCount(2, $rows); + $this->assertEquals('Alice', $rows[0]['name']); + $this->assertEquals('Charlie', $rows[1]['name']); + } + + public function testInsertBulkWithEmptyArrayThrows(): void + { + $this->expectException(PDOException::class); + $this->expectExceptionMessage('Cannot perform bulk insert with empty data array'); + + $this->db->insert('users', [[]]); + } + + public function testInsertBulkWithMismatchedColumnCountThrows(): void + { + $this->expectException(PDOException::class); + $this->expectExceptionMessage('columns'); + + $this->db->insert('users', [ + ['name' => 'Alice', 'email' => 'alice@example.com'], + ['name' => 'Charlie'], // Missing email column + ]); + } + + // ========================================================================= + // update() Tests + // ========================================================================= + + public function testUpdateReturnsAffectedRowCount(): void + { + $count = $this->db->update('users', ['name' => 'Updated'], 'name LIKE ?', ['J%']); + $this->assertEquals(2, $count); // John and Jane + + $rows = $this->db->fetchAll('SELECT * FROM users WHERE name = ?', ['Updated']); + $this->assertCount(2, $rows); + } + + public function testUpdateSingleRow(): void + { + $count = $this->db->update('users', ['email' => 'newemail@example.com'], 'id = ?', [1]); + $this->assertEquals(1, $count); + + $row = $this->db->fetchRow('SELECT * FROM users WHERE id = ?', [1]); + $this->assertEquals('newemail@example.com', $row['email']); + } + + public function testUpdateNoMatchingRows(): void + { + $count = $this->db->update('users', ['name' => 'Nobody'], 'id = ?', [999]); + $this->assertEquals(0, $count); + } + + // ========================================================================= + // delete() Tests + // ========================================================================= + + public function testDeleteReturnsDeletedRowCount(): void + { + $count = $this->db->delete('users', 'name LIKE ?', ['J%']); + $this->assertEquals(2, $count); // John and Jane + + $rows = $this->db->fetchAll('SELECT * FROM users'); + $this->assertCount(1, $rows); + $this->assertEquals('Bob', $rows[0]['name']); + } + + public function testDeleteSingleRow(): void + { + $count = $this->db->delete('users', 'id = ?', [1]); + $this->assertEquals(1, $count); + + $rows = $this->db->fetchAll('SELECT * FROM users'); + $this->assertCount(2, $rows); + } + + public function testDeleteNoMatchingRows(): void + { + $count = $this->db->delete('users', 'id = ?', [999]); + $this->assertEquals(0, $count); + } + + // ========================================================================= + // transaction() Tests + // ========================================================================= + + public function testTransactionCommitsOnSuccess(): void + { + $result = $this->db->transaction(function ($db) { + $db->runQuery('INSERT INTO users (name, email) VALUES (?, ?)', ['Alice', 'alice@example.com']); + return $db->lastInsertId(); + }); + + $this->assertEquals('4', $result); + + $row = $this->db->fetchRow('SELECT * FROM users WHERE id = ?', [4]); + $this->assertEquals('Alice', $row['name']); + } + + public function testTransactionRollsBackOnException(): void + { + try { + $this->db->transaction(function ($db) { + $db->runQuery('INSERT INTO users (name, email) VALUES (?, ?)', ['Alice', 'alice@example.com']); + throw new \RuntimeException('Something went wrong'); + }); + } catch (\RuntimeException $e) { + $this->assertEquals('Something went wrong', $e->getMessage()); + } + + // Verify the insert was rolled back + $rows = $this->db->fetchAll('SELECT * FROM users'); + $this->assertCount(3, $rows); // Still only the original 3 rows + } + + public function testTransactionReturnsCallbackValue(): void + { + $result = $this->db->transaction(function () { + return 'hello world'; + }); + + $this->assertEquals('hello world', $result); + } + + public function testTransactionRethrowsException(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Test exception'); + + $this->db->transaction(function () { + throw new \InvalidArgumentException('Test exception'); + }); + } + + // ========================================================================= + // fetchField (inherited from PdoWrapper) Tests + // ========================================================================= + + public function testFetchFieldReturnsValue(): void + { + $name = $this->db->fetchField('SELECT name FROM users WHERE id = ?', [1]); + $this->assertEquals('John', $name); + } + + public function testFetchFieldReturnsFirstColumn(): void + { + $id = $this->db->fetchField('SELECT id, name FROM users WHERE id = ?', [1]); + $this->assertEquals(1, $id); + } + +}