diff --git a/.github/workflows/php-cs-fixer.yml b/.github/workflows/php-cs-fixer.yml new file mode 100644 index 0000000..f77cc76 --- /dev/null +++ b/.github/workflows/php-cs-fixer.yml @@ -0,0 +1,23 @@ +name: Check & fix styling + +on: [push] + +jobs: + php-cs-fixer: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v2 + with: + ref: ${{ github.head_ref }} + + - name: Run PHP CS Fixer + uses: docker://oskarstark/php-cs-fixer-ga + with: + args: --config=.php_cs.dist.php --allow-risky=yes + + - name: Commit changes + uses: stefanzweifel/git-auto-commit-action@v4 + with: + commit_message: 'chore: fix styling' diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..89dfdd1 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,32 @@ +name: Run Tests + +on: ['push', 'pull_request'] + +jobs: + ci: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest] + php: ['7.4', '8.0', '8.1', '8.2'] + fail-fast: true + max-parallel: 1 + + name: PHP ${{ matrix.php }} - ${{ matrix.os }} + + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + tools: composer:v2 + coverage: xdebug + + - name: Install PHP dependencies + run: composer update --no-interaction --no-progress + + - name: All Tests + run: php vendor/bin/alchemy run diff --git a/.gitignore b/.gitignore index eb907e1..648eb0d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,11 @@ # Global .phpunit* +.php-cs-fixer.cache .composer composer.lock package-lock.json vendor/ test/ -tests/ -*.tests.php # OS Generated .DS_Store* diff --git a/.php_cs.dist.php b/.php_cs.dist.php new file mode 100644 index 0000000..7a55888 --- /dev/null +++ b/.php_cs.dist.php @@ -0,0 +1,34 @@ +in([ + __DIR__ . '/src', + __DIR__ . '/tests', + ]) + ->name('*.php') + ->ignoreDotFiles(true) + ->ignoreVCS(true); + +return (new PhpCsFixer\Config()) + ->setRules([ + '@PSR12' => true, + 'array_syntax' => ['syntax' => 'short'], + 'ordered_imports' => ['sort_algorithm' => 'alpha'], + 'no_unused_imports' => true, + 'not_operator_with_successor_space' => false, + 'trailing_comma_in_multiline' => true, + 'phpdoc_scalar' => true, + 'unary_operator_spaces' => true, + 'binary_operator_spaces' => true, + 'blank_line_before_statement' => [ + 'statements' => ['break', 'continue', 'declare', 'return', 'throw', 'try'], + ], + 'phpdoc_single_line_var_spacing' => true, + 'phpdoc_var_without_name' => true, + 'method_argument_space' => [ + 'on_multiline' => 'ensure_fully_multiline', + 'keep_multiple_spaces_after_comma' => true, + ], + 'single_trait_insert_per_statement' => true, + ]) + ->setFinder($finder); diff --git a/alchemy.config.php b/alchemy.config.php new file mode 100644 index 0000000..7224127 --- /dev/null +++ b/alchemy.config.php @@ -0,0 +1,26 @@ + 'pest', + + // php unit options + 'xmlns:xsi' => 'http://www.w3.org/2001/XMLSchema-instance', + 'xsi:noNamespaceSchemaLocation' => './vendor/phpunit/phpunit/phpunit.xsd', + 'bootstrap' => 'vendor/autoload.php', + 'colors' => true, + + // you can have multiple testsuites + 'testsuites' => [ + 'directory' => './tests' + ], + + // coverage options + 'coverage' => [ + 'processUncoveredFiles' => true, + 'include' => [ + './app' => '.php', + './src' => '.php' + ] + ] +]; diff --git a/composer.json b/composer.json index 35982b8..83dd4e7 100644 --- a/composer.json +++ b/composer.json @@ -1,38 +1,46 @@ { - "name": "leafs/db", - "description": "Leaf PHP db module.", - "keywords": [ - "database", - "orm", - "leaf", - "php", - "framework" - ], - "homepage": "https://leafphp.netlify.app/#/", - "type": "library", - "license": "MIT", - "authors": [ - { - "name": "Michael Darko", - "email": "mickdd22@gmail.com", - "homepage": "https://mychi.netlify.app", - "role": "Developer" - } - ], - "autoload": { - "psr-4": { - "Leaf\\": "src" - }, - "files": [ - "src/functions.php" - ] - }, - "minimum-stability": "dev", - "prefer-stable": true, - "require": { - "ext-mysqli": "*" + "name": "leafs/db", + "description": "Leaf PHP db module.", + "keywords": [ + "database", + "orm", + "leaf", + "php", + "framework" + ], + "homepage": "https://leafphp.netlify.app/#/", + "type": "library", + "license": "MIT", + "authors": [ + { + "name": "Michael Darko", + "email": "mickdd22@gmail.com", + "homepage": "https://mychi.netlify.app", + "role": "Developer" + } + ], + "autoload": { + "psr-4": { + "Leaf\\": "src" }, - "require-dev": { - "pestphp/pest": "^1.21" + "files": [ + "src/functions.php" + ] + }, + "minimum-stability": "dev", + "prefer-stable": true, + "require-dev": { + "pestphp/pest": "^1.21", + "leafs/alchemy": "^1.0", + "friendsofphp/php-cs-fixer": "^3.14" + }, + "scripts": { + "format": "vendor/bin/php-cs-fixer fix --config=.php_cs.dist.php --allow-risky=yes", + "test": "vendor/bin/alchemy run" + }, + "config": { + "allow-plugins": { + "pestphp/pest-plugin": true } + } } diff --git a/src/Db.php b/src/Db.php index 0efcaf7..86e5e15 100644 --- a/src/Db.php +++ b/src/Db.php @@ -11,7 +11,7 @@ * Leaf Db * ----- * Simple database interactions - * + * * @version 3.0 * @since v2.1.0 */ @@ -19,34 +19,109 @@ class Db extends Db\Core { /** * Create a database if it doesn't exist - * + * * @param string $db The name of the database to create */ public function create(string $db): self { $this->query("CREATE DATABASE $db"); + return $this; } /** * Drop a database if it exists - * + * * @param string $db The name of the database to drop */ public function drop(string $db): self { $this->query("DROP DATABASE $db"); + + return $this; + } + + /** + * Check if a database table exists + * + * @return bool true if the table exists + */ + public function tableExists(string $table) + { + $schema = $this->select('INFORMATION_SCHEMA.SCHEMATA')->where('SCHEMA_NAME', $table)->all(); + + return count($schema) > 0; + } + + /** + * Create database table + * + * @param string $table The name of the database table to create + * @param array $fields The fields to create + */ + public function createTable(string $table, array $fields = []) + { + $parsed = ''; + + if (count($fields) > 0) { + foreach ($fields as $k => $v) { + $parsed .= "$k $v, "; + } + + $parsed = rtrim($parsed, ', '); + } + + $this->query("CREATE TABLE $table ($parsed);"); + + return $this; + } + + /** + * Create database table + * + * @param string $table The name of the database table to create + * @param array $fields The fields to create + */ + public function createTableIfNotExists(string $table, array $fields = []) + { + $this->createTable($table, $fields); + $this->query(str_replace('CREATE TABLE', 'CREATE TABLE IF NOT EXISTS', $this->query)); + return $this; } + /** + * Drop a database table + * + * @params string $table The name of the table to drop + */ + public function dropTable(string $table) + { + return $this->query("DROP TABLE $table")->execute(); + } + + /** + * Backup a database + * + * @param string $dbName The name of the database to backup + * @param string $destination The path to backup database to + * @param string $withDifferential Whether to use differential backups or not + */ + public function backup(string $dbName, string $destination, bool $withDifferential = false) + { + return $this->query("BACKUP DATABASE $dbName TO DISK = '$destination' " . + $withDifferential ? 'WITH DIFFERENTIAL' : '')->execute(); + } + /** * Add a find by id clause to query - * + * * @param string|int $id The id of the row to find */ public function find($id) { $this->where('id', $id); + return $this->first(); } @@ -56,6 +131,7 @@ public function find($id) public function first() { $this->query .= ' ORDER BY id ASC LIMIT 1'; + return $this->fetchAssoc(); } @@ -65,35 +141,51 @@ public function first() public function last() { $this->query .= ' ORDER BY id DESC LIMIT 1'; + return $this->fetchAssoc(); } /** - * Order query items by a specific - * + * Order query items by a specific + * * @param string $column The column to order results by * @param string $direction The direction to order [DESC, ASC] */ public function orderBy(string $column, string $direction = 'desc') { $this->query = Builder::orderBy($this->query, $column, $direction); + + return $this; + } + + /** + * Group query results by a column + * + * @param string $column The column to group results by + * @author Milos Lukic + */ + public function groupBy($column) + { + $this->query = Builder::groupBy($this->query, $column); + return $this; } /** * Limit query items by a specific number - * + * * @param string|number $limit The number to limit by */ public function limit($limit) { $this->query = Builder::limit($this->query, $limit); + return $this; } /** * Retrieve a row from table - * + * * @param string $table Db Table * @param string $items Specific table columns to fetch */ @@ -101,62 +193,67 @@ public function select(string $table, string $items = "*") { $this->query("SELECT $items FROM $table"); $this->table = $table; + return $this; } /** * Add a new row in a db table - * + * * @param string $table Db Table */ public function insert(string $table): self { $this->query("INSERT INTO $table"); $this->table = $table; + return $this; } /** * Update a row in a db table - * + * * @param string $table Db Table */ public function update(string $table): self { $this->query("UPDATE $table"); $this->table = $table; + return $this; } /** * Delete a table's records - * + * * @param string $table: Db Table */ public function delete(string $table): self { $this->query("DELETE FROM $table"); $this->table = $table; + return $this; } /** - * Pass in parameters into your query - * - * @param array|string $params Key or params to pass into query - * @param string|null $value Value for key - */ - public function params($params): self + * Pass in parameters into your query + * + * @param array|string $params Key or params to pass into query + * @param string|null $value Value for key + */ + public function params($params): self { $this->query = Builder::params($this->query, $params); $this->bind(...(Builder::$bindings)); $this->params = $params; + return $this; } /** * Add a where clause to db query - * + * * @param string|array $condition The condition to evaluate * @param mixed $comparator Condition value or comparator * @param mixed $value The value of condition if comparator is passed @@ -176,7 +273,7 @@ public function where($condition, $comparator = null, $value = null): self /** * Add a where clause with OR comparator to db query - * + * * @param string|array $condition The condition to evaluate * @param mixed $comparator Condition value or comparator * @param mixed $value The value of condition if comparator is passed @@ -197,29 +294,31 @@ public function orWhere($condition, $comparator = null, $value = null): self /** * Hide particular fields from the final value returned - * + * * @param mixed $values The value(s) to hide */ public function hidden(...$values): self { $this->hidden = Utils::flatten($values); + return $this; } /** * Make sure a value doesn't already exist in a table to avoid duplicates. - * + * * @param mixed $uniques Items to check for */ public function unique(...$uniques) { $this->uniques = Utils::flatten($uniques); + return $this; } /** * Add particular fields to the final value returned - * + * * @param string|array $name What to add * @param string $value The value to add */ @@ -236,7 +335,7 @@ public function add($name, $value = null): self /** * Search a db table for a value - * + * * @param string $row The item to search for in table * @param string $value The keyword to search for * @param array|null $hidden The items to hide from returned result diff --git a/src/Db/Builder.php b/src/Db/Builder.php index 441bbb0..1bf07cd 100644 --- a/src/Db/Builder.php +++ b/src/Db/Builder.php @@ -22,7 +22,7 @@ class Builder /** * Order query results by a colum - * + * * @param string $query The query to modify (if any) * @param string $column The column to order results by * @param string $direction The direction to order [DESC, ASC] @@ -45,9 +45,30 @@ public static function orderBy( return $query; } + /** + * Group query results by a column + * + * @param string $query The query to modify (if any) + * @param string $column The column to group results by + * @author Milos Lukic + */ + public static function groupBy( + string $query, + string $column + ): string { + if (strpos($query, 'GROUP BY') === false) { + $query .= " GROUP BY $column"; + } else { + $parts = explode('GROUP BY', $query); + $query = implode("GROUP BY $column", $parts); + } + + return $query; + } + /** * Limit query to specific number of values to return - * + * * @param string $query The query to modify (if any) * @param string|number $number Limit to query */ @@ -68,7 +89,7 @@ public static function limit(string $query, $number): string /** * Controls inner workings of all where blocks - * + * * @param string $query The query to modify * @param string|array $condition The condition to evaluate * @param mixed $value The value if condition is a string @@ -92,7 +113,7 @@ public static function where( } } else { foreach ($condition as $k => $v) { - $query .= "$k$comparator? $operation "; + $query .= "$k$comparator? $operation "; } $values = array_values($condition); @@ -106,7 +127,7 @@ public static function where( /** * Builder for params block - * + * * @param string $query The query to modify * @param array|string $params Key or params to pass into query */ diff --git a/src/Db/Core.php b/src/Db/Core.php index cb91e6f..cc8e5fe 100644 --- a/src/Db/Core.php +++ b/src/Db/Core.php @@ -97,7 +97,9 @@ public function __construct( string $password = '', string $dbtype = 'mysql' ) { - if (class_exists('Leaf\App')) app()->config('db', $this->config); + if (class_exists('Leaf\App')) { + app()->config('db', $this->config); + } if ($host !== '') { $this->connect($host, $dbname, $user, $password, $dbtype); @@ -134,8 +136,6 @@ public function connect( ]); } - // response() - try { $dbtype = $this->config('dbtype'); @@ -191,13 +191,24 @@ protected function dsn(): string if ($dbtype === 'sqlite') { $dsn = "sqlite:$dbname"; + } elseif ($dbtype === 'sqlsrv') { + $dsn = $dbtype . ":Server=" . $this->config('host'); + $dsn .= ";Database=" . $this->config('database'); } else { $dsn = "$dbtype:host=$host"; - if ($dbname !== '') $dsn .= ";dbname=$dbname"; - if ($this->config('port')) $dsn .= ';port=' . $this->config('port'); - if ($this->config('charset')) $dsn .= ';charset=' . $this->config('charset'); - if ($this->config('unixSocket')) $dsn .= ';unix_socket=' . $this->config('unixSocket'); + if ($dbname !== '') { + $dsn .= ";dbname=$dbname"; + } + if ($this->config('port')) { + $dsn .= ';port=' . $this->config('port'); + } + if ($this->config('charset')) { + $dsn .= ';charset=' . $this->config('charset'); + } + if ($this->config('unixSocket')) { + $dsn .= ';unix_socket=' . $this->config('unixSocket'); + } } return $dsn; @@ -210,7 +221,9 @@ protected function dsn(): string */ public function connection(\PDO $connection = null) { - if (!$connection) return $this->connection; + if (!$connection) { + return $this->connection; + } $this->connection = $connection; } @@ -222,6 +235,16 @@ public function close(): void $this->connection = null; } + /** + * Returns the ID of the last inserted row or sequence value + * + * @param string|null $name Name of the sequence object from which the ID should be returned. + */ + public function lastInsertId($name = null) + { + return $this->connection->lastInsertId(); + } + /** * Set the current db table for operations * @@ -230,6 +253,7 @@ public function close(): void public function table(string $table): self { $this->table = $table; + return $this; } @@ -250,7 +274,7 @@ public function config($name, $value = null) $this->config = array_merge($this->config, $name); } else { if (!$value) { - return $this->config[$name]; + return $this->config[$name] ?? null; } else { $this->config[$name] = $value; } @@ -266,6 +290,7 @@ public function config($name, $value = null) public function query(string $sql): self { $this->query = $sql; + return $this; } @@ -274,9 +299,10 @@ public function query(string $sql): self * * @param array|string $data The data to bind to string */ - public function bind($bindings): self + public function bind(...$bindings): self { $this->bindings = $bindings; + return $this; } @@ -309,6 +335,7 @@ public function execute() if (count($this->errors)) { Builder::$bindings = []; + return null; } } @@ -336,6 +363,7 @@ public function execute() public function result() { $this->execute(); + return $this->queryResult; } @@ -345,6 +373,7 @@ public function result() public function column() { $this->execute(); + return $this->queryResult->fetch(\PDO::FETCH_COLUMN); } @@ -354,6 +383,7 @@ public function column() public function count(): int { $this->execute(); + return $this->queryResult->rowCount(); } diff --git a/src/Db/Utils.php b/src/Db/Utils.php index fb00dc9..564486a 100644 --- a/src/Db/Utils.php +++ b/src/Db/Utils.php @@ -15,8 +15,13 @@ class Utils { /** * Flatten multidimensional array into a single array + * + * @param array $array The array to flatten + * @return bool $keys Use array keys or not + * + * @return array */ - public static function flatten(array $array, bool $keys = false): array + public static function flatten(array $array, bool $keys = false) { $parsed = []; @@ -34,33 +39,50 @@ public static function flatten(array $array, bool $keys = false): array } /** - * Construct search that begins with a phrase in db + * Construct search that begins with a phrase in db + * + * @param string $phrase The phrase to check + * + * @return string */ - public static function beginsWith($phrase): string + public static function beginsWith(string $phrase) { return "$phrase%"; } /** - * Construct search that ends with a phrase in db + * Construct search that ends with a phrase in db + * + * @param string $phrase The phrase to check + * + * @return string */ - public static function endsWith($phrase): string + public static function endsWith(string $phrase) { return "%$phrase"; } /** - * Construct search that includes a phrase in db + * Construct search that includes a phrase in db + * + * @param string $phrase The phrase to check + * + * @return string */ - public static function includes($phrase): string + public static function includes(string $phrase) { return "%$phrase%"; } /** - * Construct search that begins and ends with a phrase in db + * Construct search that begins and ends with a phrase in db + * + * @param string $beginsWith The beginning of the phrase to search + * @param string $endsWith The end of the phrase to search + * + * @return string */ - public static function word($beginsWith, $endsWith): string + public static function word(string $beginsWith, string $endsWith): string { return "$beginsWith%$endsWith"; } diff --git a/src/functions.php b/src/functions.php index f887fea..f71c3e1 100644 --- a/src/functions.php +++ b/src/functions.php @@ -3,7 +3,7 @@ if (!function_exists('db') && class_exists('Leaf\App')) { /** * Return the database object - * + * * @return \Leaf\Db */ function db() diff --git a/tests/mysql/connect.test.php b/tests/mysql/connect.test.php new file mode 100644 index 0000000..decd2fc --- /dev/null +++ b/tests/mysql/connect.test.php @@ -0,0 +1,78 @@ +exec($query); + $pdo = null; +}); + +it('connects to database', function () { + $success = false; + + try { + $db = new \Leaf\Db(); + expect($db->connect('sql7.freemysqlhosting.net', 'sql7600346', 'sql7600346', 'l87WSttrMv')) + ->toBeInstanceOf(\PDO::class); + $db->close(); + + $success = true; + } catch (\Throwable $th) { + } + + expect($success)->toBeTrue(); +}); + +it('inserts dummy user into `test` table', function () { + $success = false; + $db = new \Leaf\Db(); + $db->connect('sql7.freemysqlhosting.net', 'sql7600346', 'sql7600346', 'l87WSttrMv'); + + try { + $db->insert('test') + ->params([ + 'name' => 'Name', + 'email' => 'mail@mail.com', + 'password' => 'testing123', + ]) + ->execute(); + + sleep(1); + + $db->insert('test') + ->params([ + 'name' => 'Name2', + 'email' => 'mail2@mail.com', + 'password' => 'testing123', + ]) + ->execute(); + $success = true; + } catch (\Throwable $th) { + } + + expect($success)->toBeTrue(); +}); + +it('selects dummy user from `test` table', function () { + $db = new \Leaf\Db(); + $db->connect('sql7.freemysqlhosting.net', 'sql7600346', 'sql7600346', 'l87WSttrMv'); + + $user = $db->select('test') + ->where('name', 'Name') + ->first(); + + expect($user['name'])->toBe('Name'); + expect($user['email'])->toBe('mail@mail.com'); +}); diff --git a/tests/mysql/leaf-builder.test.php b/tests/mysql/leaf-builder.test.php new file mode 100644 index 0000000..b46056d --- /dev/null +++ b/tests/mysql/leaf-builder.test.php @@ -0,0 +1,30 @@ +connect('sql7.freemysqlhosting.net', 'sql7600346', 'sql7600346', 'l87WSttrMv'); + + $users = $db->select('test')->orderBy("created_at", "asc")->all(); + + expect($users)->toBeArray(); + expect($users[0]['created_at'])->toBeLessThan($users[1]['created_at']); +}); + +it('orders results in descending order', function () { + $db = new \Leaf\Db(); + $db->connect('sql7.freemysqlhosting.net', 'sql7600346', 'sql7600346', 'l87WSttrMv'); + + $users = $db->select('test')->orderBy("created_at", "desc")->all(); + + expect($users)->toBeArray(); + expect($users[1]['created_at'])->toBeLessThan($users[0]['created_at']); +}); + +it('orders by dummy name and count', function () { + $db = new \Leaf\Db(); + $db->connect('sql7.freemysqlhosting.net', 'sql7600346', 'sql7600346', 'l87WSttrMv'); + + $data = $db->select('test', 'name, COUNT(*)')->groupBy("created_at")->all(); + + expect(count($data))->toBe(2); +});