From 29483088989267a07b50152ddac2129ae54c1a98 Mon Sep 17 00:00:00 2001 From: Austin Collier Date: Thu, 4 Jan 2024 20:49:56 -0700 Subject: [PATCH 1/3] Documentation and Pdo Wrapper --- README.md | 52 +++++++++++ flight/database/Pdo_Wrapper.php | 148 ++++++++++++++++++++++++++++++++ 2 files changed, 200 insertions(+) create mode 100644 flight/database/Pdo_Wrapper.php diff --git a/README.md b/README.md index a8cef900..f4bf25e9 100644 --- a/README.md +++ b/README.md @@ -378,6 +378,58 @@ $new = Flight::db(false); Keep in mind that mapped methods have precedence over registered classes. If you declare both using the same name, only the mapped method will be invoked. +## PDO Helper Class + +Flight comes with a helper class for PDO. It allows you to easily query your database +with all the prepared/execute/fetchAll() wackiness. It greatly simplifies how you can +query your database. + +```php +// Register the PDO helper class +Flight::register('db', \flight\database\Pdo_Wrapper::class, ['mysql:host=localhost;dbname=cool_db_name', 'user', 'pass', [ + PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES \'utf8mb4\'', + PDO::ATTR_EMULATE_PREPARES => false, + PDO::ATTR_STRINGIFY_FETCHES => false, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC + ] +]); + +Flight::route('/users', function () { + // Get all users + $users = Flight::db()->fetchAll('SELECT * FROM users'); + + // Stream all users + $statement = Flight::db()->runQuery('SELECT * FROM users'); + while ($user = $statement->fetch()) { + echo $user['name']; + } + + // Get a single user + $user = Flight::db()->fetchRow('SELECT * FROM users WHERE id = ?', [123]); + + // Get a single value + $count = Flight::db()->fetchField('SELECT COUNT(*) FROM users'); + + // Special IN() syntax to help out (make sure IN is in caps) + $users = Flight::db()->fetchAll('SELECT * FROM users WHERE id IN (?)', [[1,2,3,4,5]]); + + // Insert a new user + Flight::db()->runQuery("INSERT INTO users (name, email) VALUES (?, ?)", ['Bob', 'bob@example.com']); + $insert_id = $Flight::db()->lastInsertId(); + + // Update a user + Flight::db()->runQuery("UPDATE users SET name = ? WHERE id = ?", ['Bob', 123]); + + // Delete a user + Flight::db()->runQuery("DELETE FROM users WHERE id = ?", [123]); + + // Get the number of affected rows + $statement = Flight::db()->runQuery("UPDATE users SET name = ? WHERE name = ?", ['Bob', 'Sally']); + $affected_rows = $statement->rowCount(); + +}); +``` + # Overriding Flight allows you to override its default functionality to suit your own needs, diff --git a/flight/database/Pdo_Wrapper.php b/flight/database/Pdo_Wrapper.php new file mode 100644 index 00000000..7628d11f --- /dev/null +++ b/flight/database/Pdo_Wrapper.php @@ -0,0 +1,148 @@ +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_sql_data = $this->processInStatementSql($sql, $params); + $sql = $processed_sql_data['sql']; + $params = $processed_sql_data['params']; + $statement = $this->prepare($sql); + $statement->execute($params); + return $statement; + } + + /** + * Pulls one field from the query + * + * Ex: $id = $db->fetchField("SELECT id FROM table WHERE something = ?", [ $something ]); + * + * @param string $sql - Ex: "SELECT id FROM table WHERE something = ?" + * @param array $params - Ex: [ $something ] + * @return mixed + */ + public function fetchField(string $sql, array $params = []) { + $data = $this->fetchRow($sql, $params); + return is_array($data) ? reset($data) : null; + } + + /** + * 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 array + */ + public function fetchRow(string $sql, array $params = []): array { + $sql .= stripos($sql, 'LIMIT') === false ? ' LIMIT 1' : ''; + $result = $this->fetchAll($sql, $params); + return is_array($result) && count($result) ? $result[0] : []; + } + + /** + * 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 { + $processed_sql_data = $this->processInStatementSql($sql, $params); + $sql = $processed_sql_data['sql']; + $params = $processed_sql_data['params']; + $statement = $this->prepare($sql); + $statement->execute($params); + $result = $statement->fetchAll(); + return is_array($result) ? $result : []; + } + + /** + * 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{sql:string,params:array} + */ + protected function processInStatementSql(string $sql, array $params = []): array { + + // Handle "IN(?)". This is to be used with a comma delimited string, but can also be used with an array. + // Remove the spaces in variations of "IN ( ? )" where the space after IN is optional, and any number of spaces before and after the question mark is optional. + // Then loop through each "IN(?)" in the query and replace the single question mark with the correct number of question marks. + $sql = preg_replace('/IN\s*\(\s*\?\s*\)/i', 'IN(?)', $sql); + $current_index = 0; + while(($current_index = strpos($sql, 'IN(?)', $current_index)) !== false) { + $preceeding_count = substr_count($sql, '?', 0, $current_index - 1); + + $param = $params[$preceeding_count]; + $question_marks = '?'; + + // If param is a string, explode it and replace the question mark with the correct number of question marks + if(is_string($param) || is_array($param)) { + + $params_to_use = $param; + if(is_string($param)) { + $params_to_use = explode(',', $param); + } + + foreach($params_to_use as $key => $value) { + if(is_string($value)) { + $params_to_use[$key] = trim($value); + } + } + + // Replace the single question mark with the appropriate number of question marks. + $question_marks = join(',', array_fill(0, count($params_to_use), '?')); + $sql = substr_replace($sql, $question_marks, $current_index + 3, 1); + + // Insert the new params into the params array. + array_splice($params, $preceeding_count, 1, $params_to_use); + } + + // Increment by the length of the question marks and accounting for the length of "IN()" + $current_index += strlen($question_marks) + 4; + } + + return [ 'sql' => $sql, 'params' => $params ]; + } +} \ No newline at end of file From a5f2a5e77197dfa30d779e0d5404eeb7b01b9c3d Mon Sep 17 00:00:00 2001 From: Austin Collier Date: Fri, 5 Jan 2024 16:02:30 -0700 Subject: [PATCH 2/3] Unit tests and such --- README.md | 4 +- flight/core/Loader.php | 2 + .../{Pdo_Wrapper.php => PdoWrapper.php} | 4 +- tests/PdoWrapperTest.php | 111 ++++++++++++++++++ 4 files changed, 118 insertions(+), 3 deletions(-) rename flight/database/{Pdo_Wrapper.php => PdoWrapper.php} (97%) create mode 100644 tests/PdoWrapperTest.php diff --git a/README.md b/README.md index f4bf25e9..fd41cad1 100644 --- a/README.md +++ b/README.md @@ -386,7 +386,7 @@ query your database. ```php // Register the PDO helper class -Flight::register('db', \flight\database\Pdo_Wrapper::class, ['mysql:host=localhost;dbname=cool_db_name', 'user', 'pass', [ +Flight::register('db', \flight\database\PdoWrapper::class, ['mysql:host=localhost;dbname=cool_db_name', 'user', 'pass', [ PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES \'utf8mb4\'', PDO::ATTR_EMULATE_PREPARES => false, PDO::ATTR_STRINGIFY_FETCHES => false, @@ -412,6 +412,8 @@ Flight::route('/users', function () { // Special IN() syntax to help out (make sure IN is in caps) $users = Flight::db()->fetchAll('SELECT * FROM users WHERE id IN (?)', [[1,2,3,4,5]]); + // you could also do this + $users = Flight::db()->fetchAll('SELECT * FROM users WHERE id IN (?)', [ '1,2,3,4,5']); // Insert a new user Flight::db()->runQuery("INSERT INTO users (name, email) VALUES (?, ?)", ['Bob', 'bob@example.com']); diff --git a/flight/core/Loader.php b/flight/core/Loader.php index c4384453..d262e742 100644 --- a/flight/core/Loader.php +++ b/flight/core/Loader.php @@ -204,6 +204,8 @@ public static function autoload(bool $enabled = true, $dirs = []): void /** * Autoloads classes. + * + * Classes are not allowed to have underscores in their names. * * @param string $class Class name */ diff --git a/flight/database/Pdo_Wrapper.php b/flight/database/PdoWrapper.php similarity index 97% rename from flight/database/Pdo_Wrapper.php rename to flight/database/PdoWrapper.php index 7628d11f..ac87aff0 100644 --- a/flight/database/Pdo_Wrapper.php +++ b/flight/database/PdoWrapper.php @@ -5,7 +5,7 @@ use PDO; use PDOStatement; -class Pdo_Wrapper extends PDO { +class PdoWrapper extends PDO { /** * How you create the connection for the database @@ -15,7 +15,7 @@ class Pdo_Wrapper extends PDO { * @param string $password - Ex: 'password' * @param array $options - PDO options you can pass in */ - public function __construct(string $dsn, string $username, string $password, array $options = []) { + public function __construct(string $dsn, ?string $username = null, ?string $password = null, array $options = []) { parent::__construct($dsn, $username, $password, $options); } diff --git a/tests/PdoWrapperTest.php b/tests/PdoWrapperTest.php new file mode 100644 index 00000000..2e047a96 --- /dev/null +++ b/tests/PdoWrapperTest.php @@ -0,0 +1,111 @@ + + * @license MIT, http://flightphp.com/license + */ + + + +class PdoWrapperTest extends PHPUnit\Framework\TestCase +{ + /** + * @var Pdo_Wrapper + */ + private $pdo_wrapper; + + protected function setUp(): void + { + $this->pdo_wrapper = new PdoWrapper('sqlite::memory:'); + // create a test table and insert 3 rows of data + $this->pdo_wrapper->exec('CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)'); + $this->pdo_wrapper->exec('INSERT INTO test (name) VALUES ("one")'); + $this->pdo_wrapper->exec('INSERT INTO test (name) VALUES ("two")'); + $this->pdo_wrapper->exec('INSERT INTO test (name) VALUES ("three")'); + } + + protected function tearDown(): void + { + // delete the test table + $this->pdo_wrapper->exec('DROP TABLE test'); + } + + public function testRunQuerySelectAllStatement() { + $statement = $this->pdo_wrapper->runQuery('SELECT * FROM test'); + $this->assertInstanceOf(PDOStatement::class, $statement); + $this->assertCount(3, $statement->fetchAll()); + } + + public function testRunQuerySelectOneStatement() { + $statement = $this->pdo_wrapper->runQuery('SELECT * FROM test WHERE id = 1'); + $this->assertInstanceOf(PDOStatement::class, $statement); + $this->assertCount(1, $statement->fetchAll()); + } + + public function testRunQueryInsertStatement() { + $statement = $this->pdo_wrapper->runQuery('INSERT INTO test (name) VALUES ("four")'); + $this->assertInstanceOf(PDOStatement::class, $statement); + $this->assertEquals(1, $statement->rowCount()); + } + + public function testRunQueryUpdateStatement() { + $statement = $this->pdo_wrapper->runQuery('UPDATE test SET name = "something" WHERE name LIKE ?', ['%t%']); + $this->assertInstanceOf(PDOStatement::class, $statement); + $this->assertEquals(2, $statement->rowCount()); + } + + public function testRunQueryDeleteStatement() { + $statement = $this->pdo_wrapper->runQuery('DELETE FROM test WHERE name LIKE ?', ['%t%']); + $this->assertInstanceOf(PDOStatement::class, $statement); + $this->assertEquals(2, $statement->rowCount()); + } + + public function testFetchField() { + $id = $this->pdo_wrapper->fetchField('SELECT id FROM test WHERE name = ?', ['two']); + $this->assertEquals(2, $id); + } + + public function testFetchRow() { + $row = $this->pdo_wrapper->fetchRow('SELECT * FROM test WHERE name = ?', ['two']); + $this->assertEquals(2, $row['id']); + $this->assertEquals('two', $row['name']); + } + + public function testFetchAll() { + $rows = $this->pdo_wrapper->fetchAll('SELECT * FROM test'); + $this->assertCount(3, $rows); + $this->assertEquals(1, $rows[0]['id']); + $this->assertEquals('one', $rows[0]['name']); + $this->assertEquals(2, $rows[1]['id']); + $this->assertEquals('two', $rows[1]['name']); + $this->assertEquals(3, $rows[2]['id']); + $this->assertEquals('three', $rows[2]['name']); + } + + public function testFetchAllWithNamedParams() { + $rows = $this->pdo_wrapper->fetchAll('SELECT * FROM test WHERE name = :name', [ 'name' => 'two']); + $this->assertCount(1, $rows); + $this->assertEquals(2, $rows[0]['id']); + $this->assertEquals('two', $rows[0]['name']); + } + + public function testFetchAllWithInInt() { + $rows = $this->pdo_wrapper->fetchAll('SELECT id FROM test WHERE id IN(?)', [ [1,2 ]]); + $this->assertEquals(2, count($rows)); + } + + public function testFetchAllWithInString() { + $rows = $this->pdo_wrapper->fetchAll('SELECT id FROM test WHERE name IN(?)', [ ['one','two' ]]); + $this->assertEquals(2, count($rows)); + } + + public function testFetchAllWithInStringCommas() { + $rows = $this->pdo_wrapper->fetchAll('SELECT id FROM test WHERE id > ? AND name IN(?)', [ 0, 'one,two' ]); + $this->assertEquals(2, count($rows)); + } + +} From 71a3fe3d36909e347ff93436b8843b3105d1631a Mon Sep 17 00:00:00 2001 From: Austin Collier Date: Fri, 5 Jan 2024 16:32:33 -0700 Subject: [PATCH 3/3] added require-dev to composer --- composer.json | 1 + 1 file changed, 1 insertion(+) diff --git a/composer.json b/composer.json index 94c712a4..767db450 100644 --- a/composer.json +++ b/composer.json @@ -33,6 +33,7 @@ ] }, "require-dev": { + "ext-pdo_sqlite": "*", "phpunit/phpunit": "^9.5", "phpstan/phpstan": "^1.10", "phpstan/extension-installer": "^1.3"