Page MenuHomeDevCentral

D2498.diff
No OneTemporary

D2498.diff

diff --git a/.arcconfig b/.arcconfig
new file mode 100644
--- /dev/null
+++ b/.arcconfig
@@ -0,0 +1,5 @@
+{
+ "repository.callsign": "KDB",
+ "phabricator.uri": "https://devcentral.nasqueron.org",
+ "unit.engine": "PhpunitTestEngine"
+}
diff --git a/.arclint b/.arclint
new file mode 100644
--- /dev/null
+++ b/.arclint
@@ -0,0 +1,44 @@
+{
+ "exclude": [
+ "(^vendor/)"
+ ],
+ "linters": {
+ "chmod": {
+ "type": "chmod"
+ },
+ "filename": {
+ "type": "filename"
+ },
+ "json": {
+ "type": "json",
+ "include": [
+ "(^\\.arcconfig$)",
+ "(^\\.arclint$)",
+ "(\\.json$)"
+ ]
+ },
+ "merge-conflict": {
+ "type": "merge-conflict"
+ },
+ "php": {
+ "type": "php",
+ "include": "(\\.php$)"
+ },
+ "phpcs": {
+ "type": "phpcs",
+ "bin": "vendor/bin/phpcs",
+ "phpcs.standard": "phpcs.xml",
+ "include": [
+ "(^src/.*\\.php$)",
+ "(^tests/.*\\.php$)"
+ ]
+ },
+ "spelling": {
+ "type": "spelling"
+ },
+ "xml": {
+ "type": "xml",
+ "include": "(\\.xml$)"
+ }
+ }
+}
diff --git a/.gitignore b/.gitignore
new file mode 100644
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+# Composer
+composer.lock
+vendor/
diff --git a/.phan/config.php b/.phan/config.php
new file mode 100644
--- /dev/null
+++ b/.phan/config.php
@@ -0,0 +1,259 @@
+<?php
+
+use Phan\Issue;
+
+return [
+
+ 'target_php_version' => '8.1',
+
+ // If enabled, missing properties will be created when
+ // they are first seen. If false, we'll report an
+ // error message if there is an attempt to write
+ // to a class property that wasn't explicitly
+ // defined.
+ 'allow_missing_properties' => false,
+
+ // If enabled, null can be cast as any type and any
+ // type can be cast to null. Setting this to true
+ // will cut down on false positives.
+ 'null_casts_as_any_type' => false,
+
+ // If enabled, 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,
+
+ // If enabled, 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 enabled, scalars (int, float, bool, string, null)
+ // are treated as if they can cast to each other.
+ // This does not affect checks of array keys. See scalar_array_key_cast.
+ 'scalar_implicit_cast' => false,
+
+ // If enabled, any scalar array keys (int, string)
+ // are treated as if they can cast to each other.
+ // E.g. array<int,stdClass> can cast to array<string,stdClass> and vice versa.
+ // Normally, a scalar type such as int could only cast to/from int and mixed.
+ 'scalar_array_key_cast' => 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' => [],
+
+ // If true, seemingly undeclared variables in the global
+ // scope will be ignored. This is useful for projects
+ // with complicated cross-file globals that you have no
+ // hope of fixing.
+ 'ignore_undeclared_variables_in_global_scope' => true,
+
+ // Set this to false to emit PhanUndeclaredFunction issues for internal functions that Phan has signatures for,
+ // but aren't available in the codebase, or the internal functions used to run phan
+ // (may lead to false positives if an extension isn't loaded)
+ // If this is true(default), then Phan will not warn.
+ 'ignore_undeclared_functions_with_known_signatures' => true,
+
+ // Backwards Compatibility Checking. This is slow
+ // and expensive, but you should consider running
+ // it before upgrading your version of PHP to a
+ // new version that has backward compatibility
+ // breaks.
+ 'backward_compatibility_checks' => false,
+
+ // If true, check to make sure the return type declared
+ // in the doc-block (if any) matches the return type
+ // declared in the method signature.
+ 'check_docblock_signature_return_type_match' => false,
+
+ // (*Requires check_docblock_signature_param_type_match to be true*)
+ // If true, make narrowed types from phpdoc params override
+ // the real types from the signature, when real types exist.
+ // (E.g. allows specifying desired lists of subclasses,
+ // or to indicate a preference for non-nullable types over nullable types)
+ // Affects analysis of the body of the method and the param types passed in by callers.
+ 'prefer_narrowed_phpdoc_param_type' => true,
+
+ // (*Requires check_docblock_signature_return_type_match to be true*)
+ // If true, make narrowed types from phpdoc returns override
+ // the real types from the signature, when real types exist.
+ // (E.g. allows specifying desired lists of subclasses,
+ // or to indicate a preference for non-nullable types over nullable types)
+ // Affects analysis of return statements in the body of the method and the return types passed in by callers.
+ 'prefer_narrowed_phpdoc_return_type' => true,
+
+ // If enabled, check all methods that override a
+ // parent method to make sure its signature is
+ // compatible with the parent's. This check
+ // can add quite a bit of time to the analysis.
+ // This will also check if final methods are overridden, etc.
+ 'analyze_signature_compatibility' => true,
+
+ // This setting maps case insensitive strings to union types.
+ // This is useful if a project uses phpdoc that differs from the phpdoc2 standard.
+ // If the corresponding value is the empty string, Phan will ignore that union type (E.g. can ignore 'the' in `@return the value`)
+ // If the corresponding value is not empty, Phan will act as though it saw the corresponding unionTypes(s) when the keys show up in a UnionType of @param, @return, @var, @property, etc.
+ //
+ // This matches the **entire string**, not parts of the string.
+ // (E.g. `@return the|null` will still look for a class with the name `the`, but `@return the` will be ignored with the below setting)
+ //
+ // (These are not aliases, this setting is ignored outside of doc comments).
+ // (Phan does not check if classes with these names exist)
+ //
+ // Example setting: ['unknown' => '', 'number' => 'int|float', 'char' => 'string', 'long' => 'int', 'the' => '']
+ 'phpdoc_type_mapping' => [],
+
+ // 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' => false,
+
+ // If true, this run a quick version of checks that takes less
+ // time at the cost of not running as thorough
+ // an analysis. You should consider setting this
+ // to true only when you wish you had more **undiagnosed** issues
+ // to fix in your code base.
+ //
+ // In quick-mode the scanner doesn't rescan a function
+ // or a method's code block every time a call is seen.
+ // This means that the problem here won't be detected:
+ //
+ // ```php
+ // <?php
+ // function test($arg):int {
+ // return $arg;
+ // }
+ // test("abc");
+ // ```
+ //
+ // This would normally generate:
+ //
+ // ```sh
+ // test.php:3 TypeError return string but `test()` is declared to return int
+ // ```
+ //
+ // The initial scan of the function's code block has no
+ // type information for `$arg`. It isn't until we see
+ // the call and rescan test()'s code block that we can
+ // detect that it is actually returning the passed in
+ // `string` instead of an `int` as declared.
+ 'quick_mode' => false,
+
+ // If true, then before analysis, try to simplify AST into a form
+ // which improves Phan's type inference in edge cases.
+ //
+ // This may conflict with 'dead_code_detection'.
+ // When this is true, this slows down analysis slightly.
+ //
+ // E.g. rewrites `if ($a = value() && $a > 0) {...}`
+ // into $a = value(); if ($a) { if ($a > 0) {...}}`
+ 'simplify_ast' => true,
+
+ // Enable or disable support for generic templated
+ // class types.
+ 'generic_types_enabled' => true,
+
+ // Override to hardcode existence and types of (non-builtin) globals in the global scope.
+ // Class names should be prefixed with '\\'.
+ // (E.g. ['_FOO' => '\\FooClass', 'page' => '\\PageClass', 'userId' => 'int'])
+ 'globals_type_map' => [],
+
+ // The minimum severity level to report on. This can be
+ // set to Issue::SEVERITY_LOW, Issue::SEVERITY_NORMAL or
+ // Issue::SEVERITY_CRITICAL. Setting it to only
+ // critical issues is a good place to start on a big
+ // sloppy mature code base.
+ 'minimum_severity' => Issue::SEVERITY_LOW,
+
+ // Add any issue types (such as 'PhanUndeclaredMethod')
+ // to this black-list to inhibit them from being reported.
+ 'suppress_issue_types' => [],
+
+ // A regular expression to match files to be excluded
+ // from parsing and analysis and will not be read at all.
+ //
+ // This is useful for excluding groups of test or example
+ // directories/files, unanalyzable files, or files that
+ // can't be removed for whatever reason.
+ // (e.g. '@Test\.php$@', or '@vendor/.*/(tests|Tests)/@')
+ 'exclude_file_regex' => '@^vendor/.*/(tests?|Tests?)/@',
+
+ // 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' => [],
+
+ // A directory list that defines files that will be excluded
+ // from static analysis, but whose class and method
+ // information should be included.
+ //
+ // Generally, you'll want to include the directories for
+ // third-party code (such as "vendor/") in this list.
+ //
+ // n.b.: If you'd like to parse but not analyze 3rd
+ // party code, directories containing that code
+ // should be added to the `directory_list` as
+ // to `exclude_analysis_directory_list`.
+ 'exclude_analysis_directory_list' => [
+ 'vendor/',
+ ],
+
+ // The number of processes to fork off during the analysis
+ // phase.
+ 'processes' => 1,
+
+ // List of case-insensitive file extensions supported by Phan.
+ // (e.g. php, html, htm)
+ 'analyzed_file_extensions' => [
+ 'php',
+ ],
+
+ // You can put paths to stubs of internal extensions in this config option.
+ // If the corresponding extension is **not** loaded, then phan will use the stubs instead.
+ // Phan will continue using its detailed type annotations,
+ // but load the constants, classes, functions, and classes (and their Reflection types)
+ // from these stub files (doubling as valid php files).
+ // Use a different extension from php to avoid accidentally loading these.
+ // The 'tools/make_stubs' script can be used to generate your own stubs (compatible with php 7.0+ right now)
+ 'autoload_internal_extension_signatures' => [],
+
+ // A list of plugin files to execute
+ // Plugins which are bundled with Phan can be added here by providing their name (e.g. 'AlwaysReturnPlugin')
+ // Alternately, you can pass in the full path to a PHP file with the plugin's implementation (e.g. 'vendor/phan/phan/.phan/plugins/AlwaysReturnPlugin.php')
+ 'plugins' => [
+ 'AlwaysReturnPlugin',
+ 'PregRegexCheckerPlugin',
+ 'UnreachableCodePlugin',
+ 'WhitespacePlugin',
+ ],
+
+ // 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',
+ 'tests',
+ 'vendor/phan/phan/src/Phan',
+ 'vendor/phpunit/phpunit/src',
+ ],
+
+ // A list of individual files to include in analysis
+ // with a path relative to the root directory of the
+ // project
+ 'file_list' => [],
+
+];
diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -0,0 +1,36 @@
+# keruald/database
+
+This library offers a simple layer of abstraction for database operations.
+
+## Configuration
+
+To get a database instance, you need to pass configuration as an array.
+The properties and values depend on the engine you want to use.
+
+### MySQLi
+
+| Key | Value | |
+|----------|--------------------------------------|:--------:|
+| engine | MySQLiEngine class reference | |
+| host | The MySQL hostname, e.g. "localhost" | |
+| username | The MySQL user to use for connection | |
+| password | The clear text password to use | |
+| database | The default db to select for queries | optional |
+
+For example:
+
+```php
+[
+ 'engine' => Keruald\Database\Engines\MySQLiEngine::class,
+ 'host' => 'localhost',
+ 'username' => 'app',
+ 'password' => 'someSecret',
+ 'database' => 'app', // optional
+]
+```
+
+## Legacy drivers
+
+The mysql extension has been deprecated in PHP 5.7 and removed in PHP 7.
+As such, this extension isn't supported anymore. You can use straightforwardly
+replace 'MySQL' by 'MySQLi' as engine.
diff --git a/composer.json b/composer.json
new file mode 100644
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,26 @@
+{
+ "name": "keruald/database",
+ "description": "Allow to query a database",
+ "type": "library",
+ "require-dev": {
+ "phan/phan": "^5.3.2",
+ "squizlabs/php_codesniffer": "^3.6.2",
+ "phpunit/phpunit": "^9.5",
+ "nasqueron/codestyle": "^0.0.1"
+ },
+ "scripts": {
+ "lint-src": "find src -type f -name '*.php' | xargs -n1 php -l",
+ "lint-tests": "find tests -type f -name '*.php' | xargs -n1 php -l",
+ "test": "vendor/bin/phpunit"
+ },
+ "license": "BSD-2-Clause",
+ "autoload": {
+ "psr-4": {
+ "Keruald\\Database\\": "src/",
+ "Keruald\\Database\\Tests\\": "tests/"
+ }
+ },
+ "require": {
+ "ext-mysqli": "*"
+ }
+}
diff --git a/phpcs.xml b/phpcs.xml
new file mode 100644
--- /dev/null
+++ b/phpcs.xml
@@ -0,0 +1,7 @@
+<?xml version="1.0"?>
+<ruleset name="Nasqueron">
+ <rule ref="vendor/nasqueron/codestyle/CodeSniffer/ruleset.xml" />
+
+ <file>src</file>
+ <file>tests</file>
+</ruleset>
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.5/phpunit.xsd"
+ bootstrap="vendor/autoload.php"
+ convertErrorsToExceptions="true"
+ convertNoticesToExceptions="true"
+ convertWarningsToExceptions="true"
+ stopOnFailure="false">
+ <php>
+ <ini name="display_errors" value="On" />
+ <ini name="display_startup_errors" value="On" />
+ <ini name="error_reporting" value="On" />
+ </php>
+ <testsuites>
+ <testsuite name="Unit tests">
+ <directory suffix="Test.php">./tests</directory>
+ </testsuite>
+ </testsuites>
+ <coverage processUncoveredFiles="true">
+ <include>
+ <directory suffix=".php">src/</directory>
+ </include>
+ </coverage>
+</phpunit>
diff --git a/src/Database.php b/src/Database.php
new file mode 100644
--- /dev/null
+++ b/src/Database.php
@@ -0,0 +1,100 @@
+<?php
+
+namespace Keruald\Database;
+
+use Keruald\Database\Exceptions\EngineSetupException;
+
+/**
+ * Represents a database.
+ *
+ * The Database class allows to load from a configuration the correct driver.
+ *
+ * It can be instanced in two modes:
+ *
+ * 1) through Database::initialize() if you directly want a database object
+ * or to store in a service container.
+ *
+ * 2) through Database::load() if you want to use a singleton pattern.
+ */
+class Database {
+
+ ///
+ /// Factory pattern
+ ///
+
+ /**
+ * Gets and initializes a database instance
+ *
+ * The correct database instance to initialize will be determined from the
+ * $Config['database']['engine'] preference. Expected value is an instance
+ * of DatabaseEngine.
+ *
+ * Example:
+ * <code>
+ * $Config['database']['engine'] = 'Foo\Quux';
+ * $db = Database::initialize(); // will call Foo\Quux::load();
+ * </code>
+ */
+ static function initialize (array &$config) : DatabaseEngine {
+ $engine_class = self::getEngineClass($config);
+ $instance = call_user_func([$engine_class, 'load'], $config);
+
+ $instance->dontThrowExceptions =
+ $config['dontThrowExceptions'] ?? false;
+
+ unset($config['password']);
+
+ return $instance;
+ }
+
+ private static function getEngineClass (array $config) : string {
+ if (!array_key_exists('engine', $config)) {
+ throw new EngineSetupException(<<<'EOF'
+No database engine is configured. Engine key must be defined.
+EOF
+ );
+ }
+
+ $engine_class = $config['engine'];
+
+ if (!class_exists($engine_class)) {
+ throw new EngineSetupException(
+ "Database engine $engine_class class not found."
+ );
+ }
+
+ return $engine_class;
+ }
+
+ ///
+ /// Singleton pattern
+ ///
+
+ /**
+ * @var DatabaseEngine|null The instance
+ */
+ private static ?DatabaseEngine $instance = null;
+
+ /**
+ * Gets the database instance, initializing it if needed
+ *
+ * Example:
+ * <code>
+ * $Config['database']['engine'] = 'Foo\Quux';
+ * $db = Database::load($Config['database']); // will call Foo\Quux::load()
+ * […]
+ * $db = Database::load($Config['database']); // will return previously
+ * // initialized engine instance
+ * </code>
+ *
+ * @return DatabaseEngine the database instance
+ */
+ public static function load (array &$config) : DatabaseEngine {
+ if (self::$instance === null) {
+ self::$instance = self::initialize($config);
+ }
+
+ return self::$instance;
+ }
+
+}
diff --git a/src/DatabaseEngine.php b/src/DatabaseEngine.php
new file mode 100644
--- /dev/null
+++ b/src/DatabaseEngine.php
@@ -0,0 +1,138 @@
+<?php
+
+namespace Keruald\Database;
+
+use Keruald\Database\Exceptions\SqlException;
+
+use BadMethodCallException;
+use Keruald\Database\Result\DatabaseResult;
+use LogicException;
+
+abstract class DatabaseEngine {
+
+ ///
+ /// Traits
+ ///
+
+ use WithLegacyMethods;
+
+ ///
+ /// Methods the specific engine need to implement to access database
+ ///
+
+ public abstract function escape (string $expression) : string;
+
+ public abstract function query (string $query);
+
+ public abstract function nextId () : int|string;
+
+ public abstract function countAffectedRows () : int;
+
+ protected abstract function getExceptionContext (): array;
+
+ ///
+ /// Engine mechanics
+ ///
+
+ public abstract static function load (array $config): DatabaseEngine;
+
+ public abstract function getUnderlyingDriver () : mixed;
+
+ ///
+ /// Helpers we can use across all engines
+ ///
+
+ /**
+ * Runs a query, then returns the first scalar element of result,
+ * ie the element in the first column of the first row.
+ *
+ * This is intended for queries with only one scalar result like
+ * 'SELECT count(*) FROM …' or 'SELECT value WHERE unique_key = …'.
+ *
+ * @param string $query The query to execute
+ * @return string the scalar result
+ * @throws SqlException
+ */
+ public function queryScalar (string $query = '') : string {
+ if ($query === '') {
+ //No query, no value
+ return '';
+ }
+
+ $result = $this->query($query);
+
+ // If legacy mode is enabled, we have a MySQL error here.
+ if ($result === false) {
+ throw SqlException::fromQuery($query, $this->getExceptionContext());
+ }
+
+ // Ensure the query is SELECT / SHOW / DESCRIBE / EXPLAIN,
+ // so we have a scalar result to actually return.
+ //
+ // That allows to detect bugs where queryScalar() is used
+ // with the intent to fetch metadata information,
+ // e.g. the amount of rows updated or deleted.
+ if (is_bool($result)) {
+ throw new LogicException("The queryScalar method is intended
+ to be used with SELECT queries and assimilated");
+ }
+
+ // Fetches first row of the query, and return the first element
+ // If there isn't any result row, returns an empty string.
+ return $result->fetchRow()[0] ?? "";
+ }
+
+ ///
+ /// Compatibility with legacy code
+ ///
+
+ /**
+ * @var bool Don't throw exceptions if a query doesn't succeed
+ * @deprecated Replace `if (!$result = $db->query(…))` by an error handler
+ */
+ public bool $dontThrowExceptions = false;
+
+ /**
+ * Gets the number of rows affected or returned by a query.
+ *
+ * @return int the number of rows affected (delete/insert/update)
+ * or the number of rows in query result
+ * @deprecated Use $result->numRows or $db->countAffectedRows();
+ */
+ public function numRows (DatabaseResult|bool $result = false) : int {
+ if ($result instanceof DatabaseResult) {
+ return $result->numRows();
+ }
+
+ return $this->countAffectedRows();
+ }
+
+ /**
+ * Fetches a row from the query result.
+ *
+ * @param DatabaseResult $result The query result
+ * @return array|null An associative array with the database result,
+ * or null if no more result is available.
+ */
+ public function fetchRow (DatabaseResult $result) : ?array {
+ return $result->fetchRow();
+ }
+
+ /**
+ * Allows the legacy use of sql_query, sql_fetchrow, sql_escape, etc.
+ *
+ * @throws BadMethodCallException when the method name doesn't exist.
+ * @deprecated
+ */
+ public function __call (string $name, array $arguments) {
+ if (str_starts_with($name, 'sql_')) {
+ return $this->callByLegacyMethodName($name, $arguments);
+ }
+
+ $className = get_class($this);
+ throw new BadMethodCallException(
+ "Method doesn't exist: $className::$name"
+ );
+ }
+
+}
diff --git a/src/Engines/MySQLiEngine.php b/src/Engines/MySQLiEngine.php
new file mode 100644
--- /dev/null
+++ b/src/Engines/MySQLiEngine.php
@@ -0,0 +1,204 @@
+<?php
+
+namespace Keruald\Database\Engines;
+
+use Keruald\Database\DatabaseEngine;
+use Keruald\Database\Exceptions\EngineSetupException;
+use Keruald\Database\Exceptions\SqlException;
+
+use Keruald\Database\Result\MySQLiDatabaseResult;
+use RuntimeException;
+
+use mysqli;
+use mysqli_driver;
+use mysqli_result;
+use mysqli_sql_exception;
+
+class MySQLiEngine extends DatabaseEngine {
+
+ /**
+ * The connection identifier
+ */
+ private mysqli $db;
+
+ /**
+ * The MySQL driver
+ */
+ private mysqli_driver $driver;
+
+ /**
+ * Initializes a new instance of the database abstraction class,
+ * for MySQLi engine.
+ *
+ * @param string $host The host of the MySQL server [optional, default: localhost]
+ * @param string $username The username used to connect [optional, default: root]
+ * @param string $password The password used to connect [optional, default: empty]
+ * @param string $database The database to select [optional]
+ */
+ function __construct(
+ string $host = 'localhost',
+ string $username = 'root',
+ string $password = '',
+ string $database = ''
+ ) {
+ // Checks extension requirement
+ if (!class_exists("mysqli")) {
+ throw new RuntimeException("You've chosen to use a MySQLi database engine, but the MySQLi extension is missing.");
+ }
+
+ // Connects to MySQL server
+ $this->driver = new mysqli_driver();
+ $this->db = new mysqli($host, $username, $password);
+ $this->setCharset('utf8mb4');
+
+ // Selects database
+ if ($database !== '') {
+ $this->db->select_db($database);
+ }
+ }
+
+ /**
+ * Sends a unique query to the database.
+ *
+ * @return mysqli_result|bool For successful SELECT, SHOW, DESCRIBE or
+ * EXPLAIN queries, a <b>mysqli_result</b> object; otherwise, true, or false
+ * on failure in legacy mode.
+ * @throws SqlException if legacy mode is disabled, and the query fails.
+ */
+ function query (string $query) : MySQLiDatabaseResult|bool {
+ // Run query
+ try {
+ $result = $this->db->query($query);
+ } catch (mysqli_sql_exception $ex) {
+ if ($this->dontThrowExceptions) {
+ return false;
+ }
+
+ $this->throwException($ex, $query);
+ }
+
+ if (is_bool($result)) {
+ return $result;
+ }
+
+ return new MySQLiDatabaseResult($result);
+ }
+
+ /**
+ * Gets more information about the last SQL error.
+ *
+ * @return array an array with two keys, code and message, containing error information
+ * @deprecated The PHP drivers and our abstraction now throw exceptions when an error occur.
+ */
+ function error () : array {
+ return [
+ 'code' => $this->db->errno,
+ 'message' => $this->db->error,
+ ];
+ }
+
+ /**
+ * Gets the primary key value of the last query
+ * (works only in INSERT context)
+ *
+ * @return int|string the primary key value
+ */
+ public function nextId () : int|string {
+ return $this->db->insert_id;
+ }
+
+ /**
+ * Escapes a SQL expression.
+ *
+ * @param string $expression The expression to escape
+ * @return string The escaped expression
+ */
+ public function escape (string $expression) : string {
+ return $this->db->real_escape_string($expression);
+ }
+
+ public function countAffectedRows () : int {
+ return $this->db->affected_rows;
+ }
+
+ ///
+ /// MySQL specific
+ ///
+
+ /**
+ * Sets charset
+ */
+ public function setCharset ($encoding) {
+ $this->db->set_charset($encoding);
+ }
+
+ ///
+ /// Engine mechanics methods
+ ///
+
+ private function throwException (mysqli_sql_exception $ex, string $query) {
+ $context = $this->getExceptionContext();
+ throw SqlException::fromException($ex, $query, $context);
+ }
+
+ protected function getExceptionContext () : array {
+ return [
+ 'error' => $this->db->error,
+ 'errno' => $this->db->errno,
+ 'errors' => $this->db->error_list,
+ ];
+ }
+
+ private static function getConfig (array $config) : array {
+ return $config + [
+ 'host' => 'localhost',
+ 'username' => '',
+ 'password' => '',
+ 'database' => '',
+ ];
+ }
+
+ /**
+ * Loads a database instance, connected and ready to process queries.
+ *
+ * @throws EngineSetupException
+ */
+ static function load (array $config): MySQLiEngine {
+ $config = self::getConfig($config);
+
+ // We need to return an exception if it fails.
+ // Switch report mode to the exception throwing one.
+ $driver = new mysqli_driver();
+ $configuredReportMode = $driver->report_mode;
+ $driver->report_mode = MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT;
+
+ try {
+ $instance = new self(
+ $config['host'],
+ $config['username'],
+ $config['password'],
+ $config['database'],
+ );
+ } catch (mysqli_sql_exception $ex) {
+ throw new EngineSetupException(
+ $ex->getMessage(),
+ $ex->getCode(),
+ $ex
+ );
+ }
+
+
+ // Restore report mode as previously configured
+ $driver->report_mode = $configuredReportMode;
+
+ return $instance;
+ }
+
+ /**
+ * @return mysqli Represents a connection between PHP and a MySQL database.
+ */
+ public function getUnderlyingDriver (): mysqli {
+ return $this->db;
+ }
+
+}
diff --git a/src/Exceptions/EngineSetupException.php b/src/Exceptions/EngineSetupException.php
new file mode 100644
--- /dev/null
+++ b/src/Exceptions/EngineSetupException.php
@@ -0,0 +1,9 @@
+<?php
+
+namespace Keruald\Database\Exceptions;
+
+use RuntimeException;
+
+class EngineSetupException extends RuntimeException {
+
+}
diff --git a/src/Exceptions/SqlException.php b/src/Exceptions/SqlException.php
new file mode 100644
--- /dev/null
+++ b/src/Exceptions/SqlException.php
@@ -0,0 +1,88 @@
+<?php
+
+namespace Keruald\Database\Exceptions;
+
+use RuntimeException;
+use Exception;
+
+class SqlException extends RuntimeException {
+
+ ///
+ /// Constants
+ ///
+
+ protected const DEFAULT_MESSAGE =
+ "An exception occurred during a database operation.";
+
+ ///
+ /// Properties
+ ///
+
+ /**
+ * @var string The query run when the error occurred.
+ */
+ public readonly string $query;
+
+ /**
+ * A context representing the state of the database engine and the error.
+ *
+ * @var string[]
+ */
+ public readonly array $state;
+
+ ///
+ /// Constructors
+ ///
+
+ private function __construct (
+ ?Exception $innerException = null,
+ string $query = '',
+ array $state = [],
+ ) {
+ $this->query = $query;
+ $this->state = $state;
+
+ if ($innerException !== null) {
+ // Build from exception
+ parent::__construct(
+ $innerException->getMessage(),
+ $innerException->getCode(),
+ $innerException,
+ );
+ } else {
+ // Build from state
+ parent::__construct(
+ $this->state['error'] ?? self::DEFAULT_MESSAGE,
+ $this->state['errno'] ?? 0,
+ );
+ }
+ }
+
+ /**
+ * Normalize a SQL exception thrown by a PHP database extension.
+ *
+ * @param Exception $innerException
+ * @param string $query
+ * @param array $context
+ * @return static
+ */
+ public static function fromException (
+ Exception $innerException,
+ string $query,
+ array $context
+ ) : self {
+ return new self($innerException, $query, $context);
+ }
+
+ /**
+ * Create a SQL exception from a query and a context.
+ *
+ * @param string $query
+ * @param array $context
+ * @return static
+ */
+ public static function fromQuery (string $query, array $context) : self {
+ return new self(null, $query, $context);
+ }
+
+}
diff --git a/src/Result/DatabaseResult.php b/src/Result/DatabaseResult.php
new file mode 100644
--- /dev/null
+++ b/src/Result/DatabaseResult.php
@@ -0,0 +1,22 @@
+<?php
+
+namespace Keruald\Database\Result;
+
+use IteratorAggregate;
+
+/**
+ * Represents a database result
+ */
+abstract class DatabaseResult implements IteratorAggregate {
+
+ /**
+ * Gets number of rows in result
+ */
+ public abstract function numRows () : int;
+
+ /**
+ * Fetches a row of the result
+ * @return array|null An array if there is still a row to read; null if not.
+ */
+ public abstract function fetchRow () : ?array;
+}
diff --git a/src/Result/EmptyDatabaseResult.php b/src/Result/EmptyDatabaseResult.php
new file mode 100644
--- /dev/null
+++ b/src/Result/EmptyDatabaseResult.php
@@ -0,0 +1,33 @@
+<?php
+
+namespace Keruald\Database\Result;
+
+use EmptyIterator;
+use Traversable;
+
+/**
+ * Represents an empty database result, independent of the used database.
+ */
+class EmptyDatabaseResult extends DatabaseResult {
+
+ ///
+ /// DatabaseResult implementation
+ ///
+
+ public function numRows () : int {
+ return 0;
+ }
+
+ public function fetchRow () : ?array {
+ return null;
+ }
+
+ ///
+ /// IteratorAggregate implementation
+ ///
+
+ public function getIterator () : Traversable {
+ return new EmptyIterator();
+ }
+
+}
diff --git a/src/Result/MySQLiDatabaseResult.php b/src/Result/MySQLiDatabaseResult.php
new file mode 100644
--- /dev/null
+++ b/src/Result/MySQLiDatabaseResult.php
@@ -0,0 +1,41 @@
+<?php
+
+namespace Keruald\Database\Result;
+
+use mysqli_result;
+use Traversable;
+
+class MySQLiDatabaseResult extends DatabaseResult {
+
+ ///
+ /// Constructor
+ ///
+
+ public function __construct (
+ private mysqli_result $result,
+ private int $resultType = MYSQLI_BOTH
+ ) { }
+
+ ///
+ /// DatabaseResult implementation
+ ///
+
+ public function numRows () : int {
+ return $this->result->num_rows;
+ }
+
+ public function fetchRow () : ?array {
+ return $this->result->fetch_array($this->resultType);
+ }
+
+ ///
+ /// IteratorAggregate implementation
+ ///
+
+ public function getIterator () : Traversable {
+ while ($row = $this->fetchRow()) {
+ yield $row;
+ }
+ }
+
+}
diff --git a/src/WithLegacyMethods.php b/src/WithLegacyMethods.php
new file mode 100644
--- /dev/null
+++ b/src/WithLegacyMethods.php
@@ -0,0 +1,39 @@
+<?php
+
+namespace Keruald\Database;
+
+use BadMethodCallException;
+
+trait WithLegacyMethods {
+
+ private static function getNewMethodName (string $legacyName) : string {
+ return match ($legacyName) {
+ 'sql_nextid' => 'nextId',
+ 'sql_query_express' => 'queryScalar',
+ 'sql_fetchrow' => 'fetchRow',
+ 'sql_numrows' => 'numRows',
+ default => substr($legacyName, 4),
+ };
+ }
+
+ protected function callByLegacyMethodName (string $name, array $arguments) {
+ $newMethodName = self::getNewMethodName($name);
+
+ if (!method_exists($this, $newMethodName)) {
+ $className = get_class($this);
+ throw new BadMethodCallException(
+ "Legacy method doesn't exist: $className::$name"
+ );
+ }
+
+ trigger_error(<<<EOF
+\$db->$name calls shall be replaced by \$db->$newMethodName calls.
+EOF
+ , E_USER_DEPRECATED);
+
+ return call_user_func_array(
+ [$this, $newMethodName], $arguments
+ );
+ }
+
+}
diff --git a/tests/Engines/MySQLiEngineTest.php b/tests/Engines/MySQLiEngineTest.php
new file mode 100644
--- /dev/null
+++ b/tests/Engines/MySQLiEngineTest.php
@@ -0,0 +1,178 @@
+<?php
+
+namespace Keruald\Database\Tests\Engines;
+
+use Keruald\Database\Engines\MySQLiEngine;
+use Keruald\Database\Exceptions\EngineSetupException;
+use Keruald\Database\Exceptions\SqlException;
+
+use LogicException;
+use PHPUnit\Framework\TestCase;
+
+class MySQLiEngineTest extends TestCase {
+
+ private MySQLiEngine $db;
+
+ protected function setUp (): void {
+ $this->db = new MySQLiEngine('localhost', '', '', 'test_keruald_db');
+ }
+
+ public function testLoad () {
+ $instance = MySQLiEngine::load([
+ 'host' => 'localhost',
+ 'username' => '',
+ 'password' => '',
+ 'database' => 'test_keruald_db',
+ ]);
+
+ $this->assertInstanceOf("mysqli", $instance->getUnderlyingDriver());
+ }
+
+ public function testLoadWithWrongPassword () {
+ $this->expectException(EngineSetupException::class);
+
+ $instance = MySQLiEngine::load([
+ 'host' => 'localhost',
+ 'username' => 'notexisting',
+ 'password' => 'notexistingeither',
+ 'database' => 'test_keruald_db',
+ ]);
+ }
+
+ public function testQueryScalar () {
+ $sql = "SELECT 1+1";
+ $this->assertEquals(2, $this->db->queryScalar($sql));
+ }
+
+ public function testQueryScalarWithoutQuery () {
+ $this->assertEquals("", $this->db->queryScalar(""));
+ }
+
+ public function testQueryScalarWithWrongQuery () {
+ $this->expectException(SqlException::class);
+
+ $sql = "DELETE FROM nonexisting";
+ $this->db->queryScalar($sql);
+ }
+
+ public function testQueryScalarWithNonSelectQuery () {
+ $this->expectException(LogicException::class);
+
+ $sql = "UPDATE numbers SET number = number * 2";
+ $this->db->queryScalar($sql);
+ }
+
+ public function testSetCharset () {
+ $expected = "binary";
+ $this->db->setCharset($expected);
+
+ $sql = "SELECT @@SESSION.character_set_connection";
+ $actual = $this->db->queryScalar($sql);
+
+ $this->assertEquals($expected, $actual);
+ }
+
+ public function testFetchRow () {
+ $sql = "SELECT 10 UNION SELECT 20 UNION SELECT 30";
+ $result = $this->db->query($sql);
+
+ // First, we get associative arrays like [0 => 10, 10u => 10]
+ // ^ position ^ column name
+ $this->assertEquals(10, $this->db->fetchRow($result)[0]);
+ $this->assertEquals(20, $this->db->fetchRow($result)[0]);
+ $this->assertEquals(30, $this->db->fetchRow($result)[0]);
+
+ // Then, we get a null value
+ $this->assertEquals(null, $this->db->fetchRow($result));
+ }
+
+ public function testArrayShapeForFetchRow () {
+ $sql = "SELECT 10 as score, 50 as `limit`";
+ $result = $this->db->query($sql);
+
+ $expected = [
+ // By position
+ 0 => 10,
+ 1 => 50,
+
+ // By column name
+ "score" => 10,
+ "limit" => 50
+ ];
+
+ $this->assertEquals($expected, $this->db->fetchRow($result));
+ }
+
+ public function testQueryWhenItSucceeds () {
+ $result = $this->db->query("DELETE FROM numbers");
+
+ $this->assertTrue($result);
+ }
+
+ public function testQueryWhenItFailsWithoutException () {
+ mysqli_report(MYSQLI_REPORT_OFF);
+
+ $result = $this->db->query("TRUNCATE not_existing");
+
+ $this->assertFalse($result);
+ }
+
+ public function testQueryWhenItFailsWithException () {
+ mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);
+
+ $this->expectException(SqlException::class);
+ $this->db->query("TRUNCATE not_existing_table");
+ }
+
+ public function testQueryWithWrongQueryInLegacyMode () {
+ mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);
+ $this->db->dontThrowExceptions = true;
+
+ $result = $this->db->query("TRUNCATE not_existing");
+
+ $this->assertFalse($result);
+ }
+
+ public function testNextId () {
+ $this->db->query("TRUNCATE numbers");
+ $this->db->query("INSERT INTO numbers VALUES (1700, 42742)");
+ $this->db->query("INSERT INTO numbers (number) VALUES (666)");
+
+ $this->assertSame(1701, $this->db->nextId());
+ }
+
+ public function testEscape () {
+ $this->assertEquals("foo\')", $this->db->escape("foo')"));
+ }
+
+ public function testGetUnderlyingDriver () {
+ $this->assertInstanceOf("mysqli", $this->db->getUnderlyingDriver());
+ }
+
+ public function testNumRowsForSelect () {
+ $sql = "SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4";
+ $result = $this->db->query($sql);
+
+ $this->assertSame(4, $this->db->numRows($result));
+ }
+
+ public function testNumRowsForInsert () {
+ $sql = "INSERT INTO numbers (number) VALUES (1), (2), (3), (4), (5)";
+ $result = $this->db->query($sql);
+
+ $this->assertSame(5, $this->db->numRows($result));
+ }
+
+ public function testError () {
+ $expected = [
+ "code" => 1146,
+ "message" => "Table 'test_keruald_db.not_existing' doesn't exist",
+ ];
+
+ mysqli_report(MYSQLI_REPORT_OFF);
+ $this->db->query("TRUNCATE not_existing");
+
+ $this->assertEquals($expected, $this->db->error());
+ }
+
+}
diff --git a/tests/Result/EmptyDatabaseResultTest.php b/tests/Result/EmptyDatabaseResultTest.php
new file mode 100644
--- /dev/null
+++ b/tests/Result/EmptyDatabaseResultTest.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace Keruald\Database\Tests\Result;
+
+use Keruald\Database\Result\EmptyDatabaseResult;
+use PHPUnit\Framework\TestCase;
+
+class EmptyDatabaseResultTest extends TestCase {
+
+ private EmptyDatabaseResult $result;
+
+ protected function setUp () : void {
+ $this->result = new EmptyDatabaseResult();
+ }
+
+ public function testNumRows () : void {
+ $this->assertSame(0, $this->result->numRows());
+ }
+
+ public function testFetchRow () : void {
+ $this->assertEmpty($this->result->fetchRow());
+ }
+
+ public function testGetIterator () : void {
+ $actual = iterator_to_array($this->result->getIterator());
+
+ $this->assertSame([], $actual);
+ }
+
+}
diff --git a/tests/Result/MySQLiDatabaseResultTest.php b/tests/Result/MySQLiDatabaseResultTest.php
new file mode 100644
--- /dev/null
+++ b/tests/Result/MySQLiDatabaseResultTest.php
@@ -0,0 +1,54 @@
+<?php
+
+namespace Keruald\Database\Tests\Result;
+
+use Keruald\Database\Engines\MySQLiEngine;
+use Keruald\Database\Result\MySQLiDatabaseResult;
+use PHPUnit\Framework\TestCase;
+
+class MySQLiDatabaseResultTest extends TestCase {
+
+ private MySQLiDatabaseResult $result;
+
+ protected function setUp () : void {
+ $db = new MySQLiEngine('localhost', '', '', 'test_keruald_db');
+
+ $sql = "SELECT id, name, category FROM ships";
+ $this->result = $db->query($sql);
+ }
+
+ public function provideExpectedData () : array {
+ $data = [
+ // MYSQLI_NUM data
+ ["1", "So Much For Subtlety", "GSV"],
+ ["2", "Unfortunate Conflict Of Evidence", "GSV"],
+ ["3", "Just Read The Instructions", "GCU"],
+ ["4", "Just Another Victim Of The Ambient Morality", "GCU"],
+ ];
+
+ return array_map(function ($row) {
+ // MYSQLI_ASSOC data
+ return $row + [
+ "id" => $row[0],
+ "name" => $row[1],
+ "category" => $row[2],
+ ];
+ }, $data);
+ }
+
+ public function testGetIterator () {
+ $actual = iterator_to_array($this->result->getIterator());
+
+ $this->assertEquals($this->provideExpectedData(), $actual);
+ }
+
+ public function testFetchRow () {
+ $expected = $this->provideExpectedData()[0];
+
+ $this->assertEquals($expected, $this->result->fetchRow());
+ }
+
+ public function testNumRows () {
+ $this->assertEquals(4, $this->result->numRows());
+ }
+}

File Metadata

Mime Type
text/plain
Expires
Mon, Apr 21, 11:52 (13 h, 11 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
2589329
Default Alt Text
D2498.diff (43 KB)

Event Timeline