Added SimplePdo class to replace PdoWrapper

simple-pdo
n0nag0n 2 days ago
parent 2ab26aa326
commit ff17dd591d

@ -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)

@ -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`",

@ -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. */

@ -0,0 +1,427 @@
<?php
declare(strict_types=1);
namespace flight\database;
use PDO;
use PDOStatement;
use PDOException;
use flight\util\Collection;
class SimplePdo extends PdoWrapper
{
protected int $maxQueryMetrics = 1000;
/**
* Constructor for the SimplePdo class.
*
* @param string $dsn The Data Source Name (DSN) for the database connection.
* @param string|null $username The username for the database connection.
* @param string|null $password The password for the database connection.
* @param array<int|string, mixed>|null $pdoOptions An array of options for the PDO connection.
* @param array<string, mixed> $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<int|string,mixed> $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<int|string,mixed> $params the params for the sql statement
*
* @return array<string,string|array<int|string,mixed>>
*/
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<int|string,mixed> $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<int|string,mixed> $params - Ex: [ $something ]
*
* @return array<int,Collection|array<string,mixed>>
*/
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<int|string,mixed> $params
* @return array<int,mixed>
*/
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<int|string,mixed> $params
* @return array<string|int,mixed>
*/
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<string,mixed>|array<int,array<string,mixed>> $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<string,mixed> $data
* @param string $where - e.g., "id = ?"
* @param array<int|string,mixed> $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<int|string,mixed> $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();
}
}

@ -0,0 +1,448 @@
<?php
declare(strict_types=1);
namespace tests;
use flight\database\SimplePdo;
use flight\util\Collection;
use PDO;
use PDOException;
use PDOStatement;
use PHPUnit\Framework\TestCase;
class SimplePdoTest extends TestCase
{
private SimplePdo $db;
protected function setUp(): void
{
$this->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);
}
}
Loading…
Cancel
Save