diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cd2e33f --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# Temporary files +.DS_Store +*~ +*.sw[aop] + +# Deployment dependencies +vendor +composer.lock +phpunit.xml + +# IDE project settings +.project +.settings +.idea +*.sublime-workspace +*.sublime-project diff --git a/.phan/config.php b/.phan/config.php new file mode 100644 index 0000000..8893d71 --- /dev/null +++ b/.phan/config.php @@ -0,0 +1,125 @@ + true, + + // Allow null to be cast as any type and for any + // type to be cast to null. + "null_casts_as_any_type" => true, + + // Allow null to be cast as any array-like type + // This is an incremental step in migrating away from null_casts_as_any_type. + // If null_casts_as_any_type is true, this has no effect. + 'null_casts_as_array' => true, + + // Allow any array-like type to be cast to null. + // This is an incremental step in migrating away from null_casts_as_any_type. + // If null_casts_as_any_type is true, this has no effect. + 'array_casts_as_null' => true, + + // If this has entries, scalars (int, float, bool, string, null) + // are allowed to perform the casts listed. + // E.g. ['int' => ['float', 'string'], 'float' => ['int'], 'string' => ['int'], 'null' => ['string']] + // allows casting null to a string, but not vice versa. + // (subset of scalar_implicit_cast) + 'scalar_implicit_partial' => [ + 'int' => ['float', 'string'], + 'float' => ['int'], + 'string' => ['int'], + 'null' => ['string', 'bool'], + 'bool' => ['null'], + ], + + // Backwards Compatibility Checking + 'backward_compatibility_checks' => false, + + // Run a quick version of checks that takes less + // time + "quick_mode" => true, + + // Only emit critical issues + "minimum_severity" => 0, + + // A set of fully qualified class-names for which + // a call to parent::__construct() is required + 'parent_constructor_required' => [ + ], + + // Add any issue types (such as 'PhanUndeclaredMethod') + // here to inhibit them from being reported + 'suppress_issue_types' => [ + // These report false positives in libraries due + // to them not being used by any of the other + // library code. + 'PhanUnreferencedPublicClassConstant', + 'PhanWriteOnlyProtectedProperty', + 'PhanUnreferencedPublicMethod', + 'PhanUnreferencedUseNormal', + 'PhanUnreferencedProtectedMethod', + 'PhanUnreferencedProtectedProperty', + + ], + + // A list of directories that should be parsed for class and + // method information. After excluding the directories + // defined in exclude_analysis_directory_list, the remaining + // files will be statically analyzed for errors. + // + // Thus, both first-party and third-party code being used by + // your application should be included in this list. + 'directory_list' => [ + 'src', + 'vendor', + 'tests', + ], + + // A list of directories holding code that we want + // to parse, but not analyze + "exclude_analysis_directory_list" => [ + "vendor", + ], + + // A file list that defines files that will be excluded + // from parsing and analysis and will not be read at all. + // + // This is useful for excluding hopelessly unanalyzable + // files that can't be removed for whatever reason. + 'exclude_file_list' => [ + ], + + // Set to true in order to attempt to detect dead + // (unreferenced) code. Keep in mind that the + // results will only be a guess given that classes, + // properties, constants and methods can be referenced + // as variables (like `$class->$property` or + // `$class->$method()`) in ways that we're unable + // to make sense of. + 'dead_code_detection' => true, +]; diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..56799b9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2019, DealNews +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..6d0ee6b --- /dev/null +++ b/README.md @@ -0,0 +1,104 @@ +# DealNews Datbase Library + +## Factory + +The factory creates PDO objects using \DealNews\GetConfigto +read database settings and create a PDO database connection. + +### Supported Settings + +| Setting | Description | +|---------|--------------------------------------------------------------------------------------| +| type | This may be one of `pdo`, `mysql`, or `pgsql`. All types return PDO connections. | +| dsn | A valid PDO DSN. See each driver for specifics | +| db | The name of the database. For PDO, this is usually in the DSN. | +| server | One of more comma separated servers names. Not used by the `pdo` type. | +| port | Server port. Not used by the `pdo` type. | +| user | Database user name. Not all PDO drivers require one. | +| pass | Database password. Not all PDO drivers require one. | +| charset | Character set to use for `mysql` connections. The default is `utf8mb4`. | +| options | A JSON encoded array of options to pass to the PDO constructor. These vary by driver | + +### Usage + +Example: + +``` +$mydb = \DealNews\DB\Factory::init("mydb"); +``` + +## CRUD + +The `CRUD` class is a helper that wraps up common PDO logic for CRUD operations. + +### Basic Usage + +``` +$mydb = \DealNews\DB\Factory::init("mydb"); +$crud = new \DealNews\DB\CRUD($mydb); + +// Create +$result = $crud->create( + // table name + "test", + // data to add + [ + "name" => $name, + "description" => $description, + ] +); + +// Read +$rows = $crud->read( + // table name + "test", + // where clause data + ["id" => $id] +); + +// Update +$result = $crud->update( + // table name + "test", + // data to update + ["name" => "Test"], + // where clause data + ["id" => $id] +); + +// Delete +$result = $crud->delete( + // table name + "test", + // where clause data + ["id" => $row["id"]] +); +``` + +### Advanced Usage + +The class also exposes a `run` method which is used internally by the other +methods. Complex queries can be run using this method by providing an SQL +query and a parameter array which will be mapped to the prepared query. It +returns a PDOStatement object. + +``` +// Run a select with no parameters +$stmt = $crud->run("select * from table limit 10"); + +// Run a select query with paramters +$stmt = $crud->run( + "select * from table where foo = :foo" + [ + ":foo" => $foo + ] +); +``` + +## Testing + +By default, only unit tests are run. To run the functional tests the host +machine will need to be a docker host. Also, the pdo_pgsql, pdo_mysql, and +pdo_sqlite extensions must be installed on the host machine. PHPUnit will +start and stop docker containers to test the MySQL and Postgres connections. +Use `--group functional` when running PHPUnit to run these tests. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..3d373ab --- /dev/null +++ b/composer.json @@ -0,0 +1,23 @@ +{ + "name": "dealnews/db", + "type": "library", + "license": "BSD-3-Clause", + "description": "Database Library providing a PDO factory and CRUD operations", + "require-dev": { + "phpunit/phpunit": "^7.5" + }, + "require": { + "php": ">=7.1.0", + "dealnews/get-config": "^1.0" + }, + "autoload": { + "psr-4": { + "DealNews\\DB\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "DealNews\\DB\\Tests\\": "tests/" + } + } +} diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..d8133b0 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,22 @@ + + + + + ./tests + + + + + functional + + + + + ./src + + + + + + \ No newline at end of file diff --git a/src/CRUD.php b/src/CRUD.php new file mode 100644 index 0000000..ee9d056 --- /dev/null +++ b/src/CRUD.php @@ -0,0 +1,288 @@ + + * @copyright 1997-Present DealNews.com, Inc + * @package DB + */ +class CRUD { + + /** + * PDO Object + * @var \PDO + */ + protected $pdo; + + /** + * Creates a new CRUD object + * + * @param \PDO $pdo PDO object + */ + public function __construct(\PDO $pdo) { + $this->pdo = $pdo; + $this->pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); + } + + /** + * Getter for getting the pdo object + * + * @param string $var Property name. Only `pdo` is allowed. + * @return \PDO + */ + public function __get($var) { + if ($var == "pdo") { + return $this->pdo; + } else { + throw new \LogicException("Invalid property $var for ".get_class($this)); + } + } + + /** + * Inserts a new row into a table + * + * @param string $table Table name + * @param array $data Array of fields and their values to insert + * @return bool + * @throws \PDOException + */ + public function create(string $table, array $data) { + + $params = []; + + foreach ($data as $key => $value) { + if (!is_scalar($value) && !is_null($value)) { + throw new \LogicException("Invalid insert value for $key"); + } + $params[":$key"] = $value; + } + + $this->run( + "INSERT INTO ".$table." + (".implode(", ", array_keys($data)).") + VALUES + (:".implode(", :", array_keys($data)).")", + $params + ); + + return true; + } + + /** + * Reads row from a table + * + * @param string $table Table name + * @param array $data Fields and values to use in the where clause + * @param array $fields List of fields to return + * @return array + * @throws \PDOException + */ + public function read(string $table, array $data, int $limit = null, int $start = null, array $fields = ["*"]) { + + $row = []; + + $query = "SELECT ".implode(", ", $fields)." FROM ".$table; + if (!empty($data)) { + $query.= " WHERE ".$this->build_where_clause($data); + } + + if (!empty($limit)) { + if ($this->pdo->getAttribute(\PDO::ATTR_DRIVER_NAME) == "pgsql") { + $query.= " LIMIT ".((int)$limit); + if (!empty($start)) { + $query.= " OFFSET ".((int)$start); + } + } else { + $query.= " LIMIT"; + if (!empty($start)) { + $query.= " ".((int)$start).","; + } + $query.= " ".((int)$limit); + } + } + + $sth = $this->run( + $query, + $this->build_parameters($data) + ); + + if ($sth) { + $row = $sth->fetchAll(\PDO::FETCH_ASSOC); + if (empty($row)) { + $row = []; + } + } + + return $row; + } + + /** + * Updates rows in a table + * + * @param string $table Table name + * @param array $data Fields and values to update + * @param array $where Fields and values to use in the where clause + * @return bool + * @throws \PDOException + */ + public function update(string $table, array $data, array $where) { + $this->run( + "UPDATE ".$table." SET ". + $this->build_update_clause($data)." ". + "WHERE ".$this->build_where_clause($where), + array_merge( + $this->build_parameters($data), + $this->build_parameters($where) + ) + ); + + return true; + } + + /** + * Deletes rows from a table + * @param string $table Table name + * @param array $data Fields and values to use in the where clause + * @return bool + * @throws \PDOException + */ + public function delete(string $table, array $data) { + $this->run( + "DELETE FROM ".$table." WHERE ".$this->build_where_clause($data), + $this->build_parameters($data) + ); + + return true; + } + + /** + * Prepares a query, executes it and returns the \PDOStatement + * @param string $query Query to execute + * @param array $params List of fields and values to bind to the query + * @return \PDOStatement + * @throws \PDOException + */ + public function run(string $query, array $params = []) { + + $sth = $this->pdo->prepare($query); + + $success = $sth->execute($params); + + if (!$success) { + $err = $sth->errorInfo(); + throw new \PDOException($err[2], $err[0]); + } + + return $sth; + } + + /** + * Builds a parameter list + * + * @param array $fields Array of fields and values + * @param int|integer $depth Depth passed along during recursion + * @return array + */ + public function build_parameters(array $fields, int $depth = 0) { + $parameters = []; + foreach ($fields as $field => $value) { + if (!is_numeric($field) && $field != "OR" && $field != "AND") { + if (is_array($value)) { + foreach ($value as $key => $val) { + $parameters[":{$field}{$key}{$depth}"] = $val; + } + } else { + $parameters[":{$field}{$depth}"] = $value; + } + } elseif (is_array($value)) { + if (count($fields) > 1 && ($field === "OR" || $field === "AND")) { + throw new \LogicException("Only one value allowed when AND/OR specified"); + } + $depth++; + $parameters = array_merge( + $parameters, + $this->build_parameters($value, $depth) + ); + } + } + return $parameters; + } + + /** + * Builds a field list for a prepared statement and array of parameters + * to bind to the query. + * + * @param array $fields Array of fields and values + * @param string $conjunction Join string for the field list + * @return array An array containing `fields` and `parameters` + */ + public function build_where_clause(array $fields, int $depth = 0) { + + $conjunction = "AND"; + $clauses = []; + + if (isset($fields["OR"]) || isset($fields["AND"])) { + if (count($fields) > 1) { + throw new \LogicException("Only one value allowed when AND/OR specified"); + } + $conjunction = key($fields); + $fields = current($fields); + $depth++; + } + + foreach ($fields as $field => $value) { + if (!is_numeric($field)) { + if (is_scalar($value)) { + $clauses[] = "$field = :{$field}{$depth}"; + } elseif (is_array($value)) { + $field_clauses = []; + foreach ($value as $key => $val) { + $field_clauses[] = "$field = :{$field}{$key}{$depth}"; + } + if (count($field_clauses) > 1) { + $clauses[] = "(".implode(" OR ", $field_clauses).")"; + } else { + $clauses[] = reset($field_clauses); + } + } else { + throw new \InvalidArgumentException("Invalid field value ".gettype($value), 1); + } + } elseif (is_array($value)) { + $depth++; + $clauses[] = $this->build_where_clause($value, $depth); + } + } + + $where = ""; + if (!empty($clauses)) { + $where = "(".implode(" $conjunction ", $clauses).")"; + } + + return $where; + } + + /** + * Builds and update clause from an array of fields + * + * @param array $fields Array of fields and values + * @return string + */ + public function build_update_clause(array $fields) { + $clauses = []; + foreach ($fields as $field => $value) { + if (is_numeric($field)) { + throw new \LogicException("Invalid field name $field for update clause."); + } + if (!is_scalar($field) && !is_null($field)) { + throw new \LogicException("Invalid value for $field in update."); + } + $clauses[] = "$field = :{$field}0"; + } + return implode(", ", $clauses); + } +} diff --git a/src/Factory.php b/src/Factory.php new file mode 100644 index 0000000..3feb419 --- /dev/null +++ b/src/Factory.php @@ -0,0 +1,208 @@ + + * @copyright 1997-Present DealNews.com, Inc + * @package DB + */ +class Factory { + + /** + * default ini prefix for db config + */ + const CONFIG_PREFIX = ""; + + /** + * Creates a new PDO connection or returns one that already exists + * + * @param string $db Name of the database configuration. This may + * or may not be the same as the database name. + * @param array|null $options Array of additional options to pass to the + * PDO constructor + * @param string|null $type Optional database type that will override the + * type in the configuration file + * + * @return object + * + * @throws \UnexpectedValueException + * @throws \PDOException + * @throws \LogicException + */ + public static function init(string $db, array $options = null, string $type = null) { + + static $objs; + + if (!empty($type)) { + $key = $type.".".$db; + } else { + $key = $db; + } + if (!empty($options)) { + $key .= ".".md5(json_encode($options)); + } + + if (!isset($objs[$key])) { + $objs[$key] = false; + $obj = self::build(self::load_config(self::get_config($db), $options, $type)); + if ($obj !== false) { + $objs[$key] = $obj; + } + } + + return $objs[$key]; + } + + /** + * Creates a new PDO object + * + * @param array $config Configuration array returned by load_config + * + * @return object + * + * @throws \PDOException + */ + public static function build(array $config) { + + $obj = new \PDO( + $config["dsn"], + $config["user"], + $config["pass"], + $config["options"] + ); + + return $obj; + } + + /** + * Loads the db config from the ini file. + * + * @param array $config Configuration array returned by get_config + * @param array|null $options Array of additional options to pass to the + * PDO constructor + * @param string|null $type Optional database type that will override the + * + * @return array + * @throws \LogicException + * @throws \UnexpectedValueException + */ + public static function load_config(array $config, array $options = null, string $type = null) { + + if (empty($config["server"]) && empty($config["dsn"])) { + throw new \LogicException("Either `server` or `dsn` is required", 3); + } elseif (!empty($config["server"])) { + $config["server"] = explode(",", $config["server"]); + } + + // set type to the passed in value, what is in the config, or mysql + $config["type"] = $type ?? $config["type"] ?? "mysql"; + + if (!empty($config["options"])) { + $config["options"] = json_decode($config["options"], true); + $err = json_last_error(); + if ($err !== JSON_ERROR_NONE) { + throw new \UnexpectedValueException("Invalid value for options", 4); + } + } else { + $config["options"] = []; + } + + if (!empty($options)) { + $config["options"] = $config["options"] + $options; + } + + $obj = false; + + + switch ($config["type"]) { + case "mysql": + case "pgsql": + if (empty($config["db"])) { + throw new \UnexpectedValueException("A database name is required for `{$config["type"]}` connections", 5); + } + $servers = $config["server"]; + shuffle($servers); + $server = array_shift($servers); + $config["dsn"] = "{$config["type"]}:host={$server};". + "port={$config["port"]};". + "dbname={$config["db"]}"; + + if (empty($config["charset"]) && $config["type"] == "mysql") { + $config["charset"] = "utf8mb4"; + } + + if (!empty($config["charset"])) { + $config["dsn"].= ";charset{$config["charset"]}"; + } + break; + case "pdo": + if (empty($config["dsn"])) { + throw new \UnexpectedValueException("A DSN is required for PDO connections", 1); + } + break; + default: + throw new \UnexpectedValueException("Invalid database type `{$config["type"]}`", 2); + break; + } + + + return $config; + } + + /** + * Loads the db config from the ini file. + * + * @param string $db Database config name + * @param GetConfig|null $cfg Optional GetConfig object for testing + * + * @return array + * @throws \LogicException + */ + public static function get_config(string $db, GetConfig $cfg = null) { + + if (empty($cfg)) { + $cfg = new GetConfig(); + } + + // Check for an altername environment for this db + $prefix = $cfg->get("db.factory.prefix"); + if (empty($prefix)) { + $prefix = self::CONFIG_PREFIX; + } + + if (!empty($prefix)) { + $prefix .= "."; + } + + $config = array( + "type" => $cfg->get($prefix . "$db.type"), + "db" => $cfg->get($prefix . "$db.db"), + "user" => $cfg->get($prefix . "$db.user"), + "pass" => $cfg->get($prefix . "$db.pass"), + // PDO only + "dsn" => $cfg->get($prefix . "$db.dsn"), + "options" => $cfg->get($prefix . "$db.options"), + // pgsql and mysql only + "server" => $cfg->get($prefix . "$db.server"), + "port" => $cfg->get($prefix . "$db.port"), + // mysql only + "charset" => $cfg->get($prefix . "$db.charset"), + ); + + if (empty($config["db"])) { + $config["db"] = $db; + } + + return $config; + } +} diff --git a/tests/CRUDTest.php b/tests/CRUDTest.php new file mode 100644 index 0000000..84372f9 --- /dev/null +++ b/tests/CRUDTest.php @@ -0,0 +1,330 @@ +crud = new CRUD(\DealNews\DB\Factory::init("testdb")); + } + + public function testBuildParameters() { + + $result = $this->crud->build_parameters( + [ + "foo" => 1, + "bar" => 2 + ] + ); + + $this->assertEquals( + [ + ":foo0" => 1, + ":bar0" => 2 + ], + $result, + "Simple field list" + ); + + $result = $this->crud->build_parameters( + [ + [ + "foo" => 1, + "bar" => 2 + ] + ] + ); + $this->assertEquals( + [ + ":foo1" => 1, + ":bar1" => 2 + ], + $result, + "Single depth level" + ); + + $result = $this->crud->build_parameters( + [ + "OR" => [ + "foo" => 1, + "bar" => 2 + ] + ] + ); + + $this->assertEquals( + [ + ":foo1" => 1, + ":bar1" => 2 + ], + $result, + "Double depth level with OR" + ); + + $result = $this->crud->build_parameters( + [ + [ + "OR" => [ + "foo" => 1, + "bar" => 2 + ] + ], + [ + "OR" => [ + "foo" => 3, + "bar" => 4 + ] + ], + ] + ); + + $this->assertEquals( + [ + ":foo2" => 1, + ":bar2" => 2, + ":foo3" => 3, + ":bar3" => 4 + ], + $result, + "Double depth level with OR" + ); + } + + public function testBuildWhereClause() { + + $result = $this->crud->build_where_clause( + [] + ); + $this->assertEquals( + "", + $result, + "Empty field list" + ); + + $result = $this->crud->build_where_clause( + [ + "foo" => 1, + "bar" => 2 + ] + ); + $this->assertEquals( + "(foo = :foo0 AND bar = :bar0)", + $result, + "Simple field list" + ); + + $result = $this->crud->build_where_clause( + [ + [ + "foo" => 1, + "bar" => 2 + ] + ] + ); + $this->assertEquals( + "((foo = :foo1 AND bar = :bar1))", + $result, + "Single depth level" + ); + + $result = $this->crud->build_where_clause( + [ + "OR" => [ + "foo" => 1, + "bar" => 2 + ] + ] + ); + + $this->assertEquals( + "(foo = :foo1 OR bar = :bar1)", + $result, + "Double depth level with OR" + ); + + $result = $this->crud->build_where_clause( + [ + "OR" => [ + [ + "foo" => 1, + "bar" => 2 + ], + [ + "foo" => 3, + "bar" => 4 + ] + ], + ] + ); + + $this->assertEquals( + "((foo = :foo2 AND bar = :bar2) OR (foo = :foo3 AND bar = :bar3))", + $result, + "Triple depth level with OR" + ); + } + + public function testCreateAndRead() { + $this->createAndRead(); + } + + public function testUpdate() { + + $row = $this->createAndRead(); + + $result = $this->crud->update( + "test", + [ + "name" => $row["name"]." 2" + ], + ["id" => (int)$row["id"]] + ); + + $this->assertNotEmpty( + $result + ); + + $new_rows = $this->crud->read("test", ["id" => (int)$row["id"]]); + + $this->assertNotEmpty( + $new_rows + ); + + $this->assertEquals( + $row["name"]." 2", + $new_rows[0]["name"] + ); + } + + public function testDelete() { + + $row = $this->createAndRead(); + + $result = $this->crud->delete( + "test", + ["id" => $row["id"]] + ); + + $this->assertNotEmpty( + $result + ); + + $new_rows = $this->crud->read("test", ["id" => $row["id"]]); + + $this->assertEmpty( + $new_rows + ); + } + + public function testMultiValueWhere() { + $names = []; + for ($x = 1; $x <= 5; $x++) { + $name = "Multi Test $x ".microtime(true); + $names[] = $name; + $result = $this->crud->create( + "test", + [ + "name" => $name, + "description" => "Description", + ] + ); + $this->assertNotEmpty( + $result + ); + } + + $new_rows = $this->crud->read( + "test", + [ + "name" => $names + ] + ); + + $this->assertEquals( + count($names), + count($new_rows) + ); + + } + + public function testLimit() { + for ($x = 0; $x < 10; $x++) { + $name = "Test $x ".time(); + $description = "Test Description ".time(); + + $result = $this->crud->create( + "test", + [ + "name" => $name, + "description" => $description, + ] + ); + } + + $rows = $this->crud->read("test", [], 5); + + $this->assertEquals( + 5, + count($rows) + ); + + $other_rows = $this->crud->read("test", [], 5, 5); + + $this->assertEquals( + 5, + count($other_rows) + ); + + $this->assertNotEquals( + $rows, + $other_rows + ); + } + + protected function createAndRead() { + + $name = "Test ".time(); + $description = "Test Description ".time(); + + $result = $this->crud->create( + "test", + [ + "name" => $name, + "description" => $description, + ] + ); + + $this->assertEquals( + true, + $result + ); + + $id = $this->crud->pdo->lastInsertId(); + + $this->assertNotEmpty( + $result + ); + + $row = $this->crud->read("test", ["id" => $id]); + + $this->assertNotEmpty( + $row + ); + + $this->assertEquals( + $name, + $row[0]["name"] + ); + + $this->assertEquals( + $description, + $row[0]["description"] + ); + + return $row[0]; + } +} diff --git a/tests/FactoryTest.php b/tests/FactoryTest.php new file mode 100644 index 0000000..c22cd94 --- /dev/null +++ b/tests/FactoryTest.php @@ -0,0 +1,519 @@ + + * @copyright 1997-Present DealNews.com, Inc + * @package DB + * + */ + +namespace DealNews\DB\Tests; + +use \DealNews\DB\Factory; + +class FactoryTest extends \PHPUnit\Framework\TestCase { + + protected static $containers = [ + "mysql" => [ + "name" => "dealnews-db-mysql-test-instance", + "run" => __DIR__."/run_mysql.sh", + "started" => false, + ], + "postgres" => [ + "name" => "dealnews-db-postgres-test-instance", + "run" => __DIR__."/run_pgsql.sh", + "started" => false, + ], + ]; + + public function testGetConfigEmptyDB() { + $gc = $this->getMockBuilder('\DealNews\GetConfig\GetConfig') + ->setMethods(['get']) + ->disableOriginalConstructor() + ->getMock(); + + // Create a map of arguments to return values. + $map = [ + ['db.factory.prefix', null], + ['test.type', 'type'], + ['test.db', null], + ['test.user', 'user'], + ['test.pass', 'pass'], + ['test.dsn', 'dsn'], + ['test.options', 'options'], + ['test.server', 'server'], + ['test.port', 'port'], + ['test.charset', 'charset'], + ]; + + // Configure the stub. + $gc->method('get') + ->will($this->returnValueMap($map)); + + $config = Factory::get_config("test", $gc); + $this->assertEquals( + [ + 'type' => 'type', + 'db' => 'test', + 'user' => 'user', + 'pass' => 'pass', + 'dsn' => 'dsn', + 'options' => 'options', + 'server' => 'server', + 'port' => 'port', + 'charset' => 'charset', + ], + $config + ); + } + + public function testGetConfigDefaultPrefix() { + $gc = $this->getMockBuilder('\DealNews\GetConfig\GetConfig') + ->setMethods(['get']) + ->disableOriginalConstructor() + ->getMock(); + + // Create a map of arguments to return values. + $map = [ + ['db.factory.prefix', null], + ['test.type', 'type'], + ['test.db', 'db'], + ['test.user', 'user'], + ['test.pass', 'pass'], + ['test.dsn', 'dsn'], + ['test.options', 'options'], + ['test.server', 'server'], + ['test.port', 'port'], + ['test.charset', 'charset'], + ]; + + // Configure the stub. + $gc->method('get') + ->will($this->returnValueMap($map)); + + $config = Factory::get_config("test", $gc); + $this->assertEquals( + [ + 'type' => 'type', + 'db' => 'db', + 'user' => 'user', + 'pass' => 'pass', + 'dsn' => 'dsn', + 'options' => 'options', + 'server' => 'server', + 'port' => 'port', + 'charset' => 'charset', + ], + $config + ); + } + + public function testGetConfigCustomPrefix() { + $gc = $this->getMockBuilder('\DealNews\GetConfig\GetConfig') + ->setMethods(['get']) + ->disableOriginalConstructor() + ->getMock(); + + // Create a map of arguments to return values. + $map = [ + ['db.factory.prefix', 'test.prefix'], + ['test.prefix.test.type', 'type'], + ['test.prefix.test.db', 'db'], + ['test.prefix.test.user', 'user'], + ['test.prefix.test.pass', 'pass'], + ['test.prefix.test.dsn', 'dsn'], + ['test.prefix.test.options', 'options'], + ['test.prefix.test.server', 'server'], + ['test.prefix.test.port', 'port'], + ['test.prefix.test.charset', 'charset'], + ]; + + // Configure the stub. + $gc->method('get') + ->will($this->returnValueMap($map)); + + $config = Factory::get_config("test", $gc); + $this->assertEquals( + [ + 'type' => 'type', + 'db' => 'db', + 'user' => 'user', + 'pass' => 'pass', + 'dsn' => 'dsn', + 'options' => 'options', + 'server' => 'server', + 'port' => 'port', + 'charset' => 'charset', + ], + $config + ); + } + + /** + * @dataProvider loadConfigData + */ + public function testLoadConfig($config, $options, $type, $expect) { + $config = Factory::load_config($config, $options, $type); + $this->assertEquals( + $expect, + $config + ); + } + + public function loadConfigData() { + return [ + // Basic PDO config + [ + [ + 'type' => 'pdo', + 'db' => null, + 'user' => 'user', + 'pass' => 'pass', + 'dsn' => 'dsn', + 'options' => null, + 'server' => null, + 'port' => null, + 'charset' => null, + ], + [], + null, + [ + 'type' => 'pdo', + 'db' => null, + 'user' => 'user', + 'pass' => 'pass', + 'dsn' => 'dsn', + 'options' => [], + 'server' => null, + 'port' => null, + 'charset' => null, + ], + ], + // PDO Options + [ + [ + 'type' => 'pdo', + 'db' => null, + 'user' => 'user', + 'pass' => 'pass', + 'dsn' => 'dsn', + 'options' => '{"1":1,"2":2,"3":3}', + 'server' => null, + 'port' => null, + 'charset' => null, + ], + [4=>4,5=>5,6=>6], + null, + [ + 'type' => 'pdo', + 'db' => null, + 'user' => 'user', + 'pass' => 'pass', + 'dsn' => 'dsn', + 'options' => [1=>1,2=>2,3=>3,4=>4,5=>5,6=>6], + 'server' => null, + 'port' => null, + 'charset' => null, + ], + ], + // MySQL Default + [ + [ + 'db' => 'test', + 'user' => 'user', + 'pass' => 'pass', + 'dsn' => null, + 'options' => null, + 'server' => 'test', + 'port' => null, + 'charset' => null, + ], + [], + null, + [ + 'type' => 'mysql', + 'db' => 'test', + 'user' => 'user', + 'pass' => 'pass', + 'dsn' => 'mysql:host=test;port=;dbname=test;charsetutf8mb4', + 'options' => [], + 'server' => ['test'], + 'port' => null, + 'charset' => 'utf8mb4', + ], + ], + // PgSQL + [ + [ + 'db' => 'test', + 'user' => 'user', + 'pass' => 'pass', + 'dsn' => null, + 'options' => null, + 'server' => 'test', + 'port' => null, + 'charset' => null, + ], + [], + "pgsql", + [ + 'type' => 'pgsql', + 'db' => 'test', + 'user' => 'user', + 'pass' => 'pass', + 'dsn' => 'pgsql:host=test;port=;dbname=test', + 'options' => [], + 'server' => ['test'], + 'port' => null, + 'charset' => null, + ], + ] + ]; + } + + /** + * @group integration + */ + public function testInit() { + + $drivers = \PDO::getAvailableDrivers(); + + if (!in_array("sqlite", $drivers)) { + $this->markTestSkipped("PDO SQLite Driver not installed"); + } + + $db1 = Factory::init("chinook"); + $db2 = Factory::init("chinook"); + + $this->assertSame($db1, $db2); + } + + /** + * @group functional + * @dataProvider buildData + */ + public function testBuild($type, $container, $dbname, $fixture, $options = [], $expect = null) { + + $drivers = \PDO::getAvailableDrivers(); + + if (!in_array($type, $drivers)) { + $this->markTestSkipped("PDO Driver `$type` not installed"); + } + + if (!empty($container)) { + if (!$this->startContainer($container)) { + $this->markTestSkipped("docker not available"); + } + } + + $db = Factory::build(Factory::load_config(Factory::get_config($dbname), $options)); + $this->assertTrue( + $db instanceof \PDO, + "Are you running the docker container? See README." + ); + $sth = $db->prepare( + file_get_contents(__DIR__."/fixtures/$fixture") + ); + $success = $sth->execute(); + $err = $sth->errorInfo(); + if (!empty($err[2])) { + $message = $err[2]; + } else { + $message = ""; + } + $this->assertTrue( + $success, + $message + ); + + if (!is_null($expect)) { + $data = $sth->fetchAll(\PDO::FETCH_ASSOC); + $this->assertEquals( + $expect, + $data + ); + } + } + + public function buildData() { + // $type, $container, $dbname, $fixture + return [ + [ + "sqlite", + null, + "chinook", + "sqlite/select.sql", + [], + [ + [ + "count" => 347 + ] + ] + ], + [ + "pgsql", + "postgres", + "pgpdotestdb", + "pgsql/create_table.sql", + ], + [ + "pgsql", + "postgres", + "pgtestdb", + "pgsql/create_table.sql", + ], + [ + "mysql", + "mysql", + "mypdotestdb", + "mysql/create_table.sql", + ], + [ + "mysql", + "mysql", + "mytestdb", + "mysql/create_table.sql", + ], + [ + "mysql", + "mysql", + "mytestdb", + "mysql/show_variables.sql", + [ + // 1002 = \PDO::MYSQL_ATTR_INIT_COMMAND + 1002 => "SET SESSION sql_mode='TRADITIONAL'" + ], + [ + [ + "Variable_name" => "sql_mode", + "Value" => "STRICT_TRANS_TABLES,STRICT_ALL_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,TRADITIONAL,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION" + ] + ] + ], + ]; + } + + + /** + * @expectedException \UnexpectedValueException + * @expectedExceptionCode 1 + */ + public function testNoDSN() { + Factory::load_config( + [ + "type" => "pdo", + "server" => "127.0.0.1", + "user" => "test", + "pass" => "test", + ] + ); + } + + /** + * @expectedException \UnexpectedValueException + * @expectedExceptionCode 2 + */ + public function testBadType() { + Factory::load_config( + [ + "type" => "mssql", + "server" => "127.0.0.1", + "port" => "666", + "db" => "NONONO", + "user" => "test", + "pass" => "test", + ] + ); + } + + /** + * @expectedException \LogicException + * @expectedExceptionCode 3 + */ + public function testNoServer() { + Factory::load_config( + [ + "type" => "mysql", + "port" => "00000", + "db" => "noserver", + "user" => "test", + "pass" => "test", + ] + ); + } + + /** + * @expectedException \UnexpectedValueException + * @expectedExceptionCode 4 + */ + public function testBadOptions() { + Factory::load_config( + [ + "type" => "mysql", + "port" => "00000", + "server" => "noserver", + "db" => "noserver", + "user" => "test", + "pass" => "test", + "options" => "['foo':1,2,3]", + ] + ); + } + + /** + * @expectedException \UnexpectedValueException + * @expectedExceptionCode 5 + */ + public function testNoDB() { + Factory::load_config( + [ + "type" => "mysql", + "port" => "00000", + "server" => "noserver", + "user" => "test", + "pass" => "test", + "options" => null, + ] + ); + } + + protected function startContainer($name) { + + $docker_prog = trim(`which docker`); + if (!empty($docker_prog)) { + if (isset(self::$containers[$name])) { + $container = self::$containers[$name]; + $container_name = "dealnews-db-{$container["name"]}-test-instance"; + $running_container = strlen(trim(`docker ps | fgrep {$container["name"]}`)) > 0; + if (!$running_container) { + $has_container = strlen(trim(`docker ps --all | fgrep {$container["name"]}`)) > 0; + if (!$has_container) { + fwrite(STDERR, "\nRunning $name\n"); + passthru($container["run"]); + } else { + fwrite(STDERR, "\nStarting $name\n"); + passthru("docker start {$container["name"]}"); + } + self::$containers[$name]["started"] = true; + // let the container start up + sleep(10); + register_shutdown_function(["\DealNews\DB\Tests\FactoryTest", "stopContainers"]); + } + return true; + } + } + return false; + } + + public static function stopContainers() { + foreach (self::$containers as $name => $container) { + if (!empty($container["started"])) { + fwrite(STDERR, "Stopping $name\n"); + passthru("docker stop {$container["name"]}"); + self::$containers[$name]["started"] = false; + } + } + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..8782196 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,35 @@ +